From f585b08b7dbe0b4cd384968f1a241a25b7ba7cbc Mon Sep 17 00:00:00 2001 From: agent-kit-sync Date: Sat, 22 Aug 2026 15:24:09 +0000 Subject: [PATCH] chore: sync private v5.5.0 (8bb0ffd) --- .cursor-plugin/plugin.json | 2 +- .cursor/agent-kit.json | 2 +- .cursor/commands/plan-review-triage.md | 4 +- .cursor/commands/run-plan-all.md | 6 +- .cursor/commands/run-plan.md | 6 +- .../plan-external-review-atomic-wait.test.mjs | 337 +++++++++++++- ...lan-external-review-progress-gate.test.mjs | 247 ++++++++++ .cursor/scripts/plan-external-review.sh | 317 ++++++++++++- .../skills/domain/llm-security-ops/SKILL.md | 85 ++++ .../domain/llm-security-ops/local-lab-ops.md | 47 ++ .../llm-security-ops/mitigation-patterns.md | 37 ++ .../domain/llm-security-ops/owasp-llm-map.md | 29 ++ CHANGELOG.md | 28 ++ README.md | 10 +- dashboard/dashboard-data.mjs | 73 ++- dashboard/dashboard.html | 20 +- docs/capability-inventory.md | 2 +- docs/claude-cli-kit-load.md | 29 +- docs/consumer-configuration.md | 2 +- docs/external-plan-review.md | 29 +- docs/getting-started.md | 110 ++++- install.md | 12 +- package.json | 2 +- packages/cli/README.md | 3 +- packages/cli/package.json | 2 +- packages/cli/src/commands/doctor.ts | 28 +- packages/cli/src/commands/guard.test.ts | 55 +++ packages/cli/src/commands/guard.ts | 3 +- packages/cli/src/commands/hook.test.ts | 52 +++ packages/cli/src/commands/hook.ts | 53 ++- packages/cli/src/commands/init.test.ts | 140 ++++++ packages/cli/src/commands/init.ts | 61 ++- packages/cli/src/commands/install.test.ts | 125 ++++- packages/cli/src/commands/install.ts | 129 ++++- packages/cli/src/commands/readiness.test.ts | 13 +- .../cli/src/commands/setup-global.test.ts | 302 ++++++++++++ packages/cli/src/commands/setup-global.ts | 439 ++++++++++++++++++ packages/cli/src/commands/update.ts | 1 + .../generator/claude-command-adapters.test.ts | 154 ++++++ .../src/generator/claude-command-adapters.ts | 167 +++++++ .../cli/src/generator/claude-kit-load.test.ts | 9 + packages/cli/src/generator/claude-kit-load.ts | 2 +- .../claude-session-start-hook.test.ts | 146 ++++++ .../generator/claude-session-start-hook.ts | 212 +++++++++ .../cli/src/generator/personalization.test.ts | 70 ++- packages/cli/src/generator/personalization.ts | 59 ++- .../src/hooks/format-session-start.test.ts | 50 ++ .../cli/src/hooks/format-session-start.ts | 45 ++ packages/cli/src/hooks/session-start.test.ts | 105 +++++ packages/cli/src/hooks/session-start.ts | 152 +++++- packages/cli/src/index.ts | 2 + .../cli/src/invariants/secrets-scan.test.ts | 69 ++- packages/cli/src/invariants/secrets-scan.ts | 55 ++- .../cli/src/invariants/shell-guard.test.ts | 21 + packages/cli/src/invariants/shell-guard.ts | 9 +- .../cli/src/lifecycle/overlay-known-hashes.ts | 3 + packages/cli/src/lifecycle/overlay.ts | 1 + packages/cli/src/readiness/env-checks.test.ts | 252 ++++++++++ packages/cli/src/readiness/env-checks.ts | 269 +++++++++++ packages/cli/src/utils/terminal.test.ts | 173 +++++++ packages/cli/src/utils/terminal.ts | 126 ++++- 61 files changed, 4873 insertions(+), 120 deletions(-) create mode 100644 .cursor/skills/domain/llm-security-ops/SKILL.md create mode 100644 .cursor/skills/domain/llm-security-ops/local-lab-ops.md create mode 100644 .cursor/skills/domain/llm-security-ops/mitigation-patterns.md create mode 100644 .cursor/skills/domain/llm-security-ops/owasp-llm-map.md create mode 100644 packages/cli/src/commands/guard.test.ts create mode 100644 packages/cli/src/commands/hook.test.ts create mode 100644 packages/cli/src/commands/init.test.ts create mode 100644 packages/cli/src/commands/setup-global.test.ts create mode 100644 packages/cli/src/commands/setup-global.ts create mode 100644 packages/cli/src/generator/claude-command-adapters.test.ts create mode 100644 packages/cli/src/generator/claude-command-adapters.ts create mode 100644 packages/cli/src/generator/claude-session-start-hook.test.ts create mode 100644 packages/cli/src/generator/claude-session-start-hook.ts create mode 100644 packages/cli/src/hooks/format-session-start.test.ts create mode 100644 packages/cli/src/hooks/format-session-start.ts create mode 100644 packages/cli/src/readiness/env-checks.test.ts create mode 100644 packages/cli/src/readiness/env-checks.ts diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index cc02491..4c8fe26 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -15,7 +15,7 @@ "anti-slop" ], "license": "PolyForm-Noncommercial-1.0.0", - "version": "5.4.0", + "version": "5.5.0", "homepage": "https://github.com/agent-kit-startup/agent-kit", "repository": "https://github.com/agent-kit-startup/agent-kit", "logo": "dashboard/logo-marketplace.svg", diff --git a/.cursor/agent-kit.json b/.cursor/agent-kit.json index b848172..f893959 100644 --- a/.cursor/agent-kit.json +++ b/.cursor/agent-kit.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "version": "5.4.0", + "version": "5.5.0", "protected": [ ".cursor/HANDOFF.md", ".cursor/agents/test-suites.md", diff --git a/.cursor/commands/plan-review-triage.md b/.cursor/commands/plan-review-triage.md index 44bebb0..8288049 100644 --- a/.cursor/commands/plan-review-triage.md +++ b/.cursor/commands/plan-review-triage.md @@ -16,6 +16,7 @@ Supports **multi-path walk**: iterate multiple monitors in blocking-first then d - After Claude external plan review completed (monitor file exists under `.cursor/memory/plan-monitor-*.md`) - You want to process findings from the monitor and decide next steps - **Daily path:** `/run-plan` (exhaustion) and `/run-plan-all` (queue-end) continue into this Ask after wait exit 0. This slash stays HITL SoT; operators should not need to type `done` or paste triage solely to resume. ADR `2026-08-14_main-command-dogfood-audit-routing.md`. +- **Owed-row adoption:** an explicit path invocation (`/plan-review-triage `) on a monitor whose slug has a **dead** wait-state (`.cursor/context/audit-wait/.json` `status: "timeout"`/`"soft-fail"`, or `"armed"` with `now >= deadline`) and an **owed** Field Report row for that plan runs this same Step 1-5 walk unchanged, but Step 4's durable heading additionally records the adoption (see Step 4). Gap-aware skip's "no open residuals / clean Outcome" row does **not** apply to an adoption target: a clean adopted monitor still gets the Ask and the durable heading (only "already triaged" or "not a monitor file" may still skip it) — the owed row needs that heading to close. This is the `Adopt existing monitor` HITL label offered by `/run-plan` / `/run-plan-all`'s owed-close path (`.cursor/memory/errors/2026-08-14_audit-owed-ledger-no-close-path.md`); it is never automatic and never rewrites the earlier exit `3`. - **Not for mid-plan reviews** - this command expects `completed` work only ## Usage @@ -150,7 +151,8 @@ Rules: 2. **Ack and stop** must write the heading on the monitor. Updating HANDOFF alone is **not** enough: Field Report uses `isReportTriaged`, which looks for a triage heading (or a follow-up plan reference). Without the heading, the row stays untriaged. 3. Keep HITL: do not invent a choice; do not write the heading before the user picks an option. 4. Prefer appending once near the end of the file; do not delete prior review evidence. -5. **Residuals executors (R15):** when closing Still open items from a residuals plan, **append** a `## Closed by residuals plan` section (ids + evidence). Do **not** rewrite or empty the reviewer's `### Still open` table in place. Prefer the monitor already committed when written so edits have history (ADR `decisions/2026-07-29_plan-monitor-staging-hygiene-r14-r15.md`). +5. **Owed-row adoption:** when this monitor's slug has a dead wait-state and an owed Field Report row (see "Owed-row adoption" above), append the adoption fact to the same heading — for example `- **Closes owed:** yes (adoption; earlier exit 3 for this slug stays 3)`. Never narrate this as "the timed-out audit completed"; it is "an independent monitor exists and the operator adopted it." Choice `Ack and stop` on an owed row records the row as **acked**, still **unreviewed** (do not write `reviewed` for an Ack close). +6. **Residuals executors (R15):** when closing Still open items from a residuals plan, **append** a `## Closed by residuals plan` section (ids + evidence). Do **not** rewrite or empty the reviewer's `### Still open` table in place. Prefer the monitor already committed when written so edits have history (ADR `decisions/2026-07-29_plan-monitor-staging-hygiene-r14-r15.md`). ### Step 5: Execute the choice diff --git a/.cursor/commands/run-plan-all.md b/.cursor/commands/run-plan-all.md index d426021..c40c4d9 100644 --- a/.cursor/commands/run-plan-all.md +++ b/.cursor/commands/run-plan-all.md @@ -101,6 +101,8 @@ This step is delegated to a **Task(explore) subagent** using the reusable worker **Audit session-pile preflight (before the confirm Ask):** count detached workspace-owned `agent-kit-audit-*` sessions via `.cursor/scripts/plan-external-review.sh --reap-audit-sessions --dry-run` (or the arm `--dry-run` line `audit-sessions: N detached owned`). At warn: print the dispose command (or offer `--reap-audit-sessions`) in the Ask/preflight body and continue. At cap: do not arm; surface the cap in the orchestrator Ask/preflight, not only launcher stderr. Attached sessions are operator work and are never counted. This orchestrator check runs once, at queue confirm; per-arm protection is the launcher's own session-cap refusal (exit 4), which the mid-batch and queue-end arms already honour, so no second orchestrator call site is wired. +**Audits unsatisfiable-config preflight (before the confirm Ask):** when `externalPlanReview.preflight` is not `off`, check whether `enabled: true` and `backend` is pinned `"claude"` while this lane's implementer is Claude — that combination can only end owed (`CLAUDE.md` lists Claude external plan-review audits as a non-goal here; implementer≠reviewer same-model skip, ADR `decisions/2026-08-13_audits-atomic-wait-reviewer-fallback.md` point 4). `"auto"`, `"cursor"`, and `"cloud"` stay satisfiable. `midBatchAudits: true` multiplies the cost: every completed plan arms, every arm same-model-skips, every plan in the queue ends owed. `warn`: surface once, naming the combination and the three outs (`backend: "auto"`, `"cursor"`, or `"cloud"`), then continue. `block`: do not start the queue into a config that can only end owed — stop and surface the fix, or continue only on explicit operator deferral; never steal `/git-prod`. This runs before the confirm Ask because that is the one place an operator can still change config cheaply, ahead of a multi-plan run. A growing owed pile is not an acceptable substitute for surfacing this (`.cursor/memory/errors/2026-08-14_audit-owed-ledger-no-close-path.md`). + After synthesis, present the proposal using **Ask questions** tool. Include the ordered list, key overlaps/consolidations, and coherence notes in the question body. Fallback to chat numbered list if the tool is unavailable. > "Plans synthesized. Proposal: [N] plans in order, [M] consolidations, [K] overlaps. Here is the proposed execution queue..." @@ -187,14 +189,14 @@ Read `externalPlanReview` before the queue confirm Ask and at each advance: | Config | Behavior | |--------|----------| -| Audits **pre-flight** (`preflight`: `off` \| `warn` \| `block`) | Before the confirm Ask and before each mid-queue advance: same owed/untriaged check as `/run-plan`. `block` arms or stops; never steals `/git-prod`. | +| Audits **pre-flight** (`preflight`: `off` \| `warn` \| `block`) | Before the confirm Ask and before each mid-queue advance: same owed/untriaged check as `/run-plan`, plus the unsatisfiable-config check (`enabled: true` + pinned `backend: "claude"` in this lane can only end owed; see "Audits unsatisfiable-config preflight" above). `block` arms or stops; never steals `/git-prod`. | | `midBatchAudits: true` and audits enabled | After each plan Task returns `outcome: completed`, the **orchestrator** arms **one** full audit for that plan with `--force --autonomous --wait-monitor` (or one `--batch` + wait_all when batching is intentional) **before** advancing the cursor. No paste Ask between plans. Soft-fail → Field Report owed; still advance. AwaitShell until exit `0|3|4` (chat slice ~90s; remaining budget in `.cursor/context/audit-wait/.json`). **Exit 3 with remaining `waitTimeoutSeconds`:** do not treat as arm-done. Same orchestrator session resumes wait-state polling (re-arm `--wait-monitor` against leftover budget) before advancing the cursor or skipping triage. Exit 3 with zero leftover budget, or exit 4: Field Report owed, then advance. Wait success requires a **fresh** monitor after arm start. Reviewer cascade: `backend: "auto"` uses Claude (Haiku) when usable, else Cursor Agent. Same-model implementer/reviewer is an honest skip. Do **not** fan out N background sessions without wait. Do **not** insert a mid-queue triage Ask (operator non-stop preserved; record ready path for queue-end). Mid-batch stays findings-only: **never** auto-Write residuals or rewrite the Run queue between plans. | | `midBatchAudits` false/missing | **Non-stop** mid-queue: do **not** pause for audit Ask/paste between plans. Mid-queue completed plans stay Field Report **owed** until reviewed. | | Queue exhausted | Final HANDOFF; cadence `batch-complete`; then queue-end audit arm covering remaining owed/unreviewed targets (enabled → `--force --autonomous --wait-monitor` or paste per `mode`; else `offerOnExhausted` Ask). Prefer one launcher `--batch` + wait_all when multiple basenames. After wait exit `0`: run `/plan-review-triage` Ask with an **explicit path list** of fresh monitors (batch uniform Ask when outcomes match; sequential fallback when mixed; durable heading per file). **Batch exhaust without conveyor:** when remaining monitors are process-only / depth-capped, prefer uniform **Ack and stop** or **Fix nits only**; do not spawn unbounded `close-*` backlog from Write residuals (ADR `decisions/2026-08-11_plan-audit-residuals-termination.md`). Then suggest `/git-prod` if staging is ahead of `main` (separate HITL). | Never steal `/git-prod` confirmation. Chat never runs silent headless `--force` / `claude -p` in the agent shell. Spawn-only exit 0 without `--wait-monitor` is **not** review done. Never stop at Final HANDOFF "when monitors exist, run triage" after arming: wait (freshness) then continue (mid-batch waits for file only; queue-end waits then triage Ask with explicit paths). ADR: `2026-07-27_audits-autonomous-plan-review-contract.md` (supersedes queue-end-only); wait freshness: `2026-07-27_audits-wait-freshness-enforce.md`. -**Exit 3 stays timeout-only across the queue.** A mid-queue or queue-end arm that returns `3` reviewed nothing: leave that plan Field Report **owed**, keep its path out of the queue-end triage list, and never narrate it as reviewed. **Same-session resume:** when leftover `waitTimeoutSeconds` remains in `.cursor/context/audit-wait/.json`, the same orchestrator session must keep polling (re-arm `--wait-monitor`) before advancing the cursor or skipping `/plan-review-triage`. A later session may poll leftover budget; that is fallback, not the default while this session is still open. Do not treat a first-slice exit `3` as "arm done, continue the queue." Monitors that show up later, including monitors written by a different arm or a later queue position, do **not** retroactively upgrade an earlier `3`. Exit `4` covers the launcher soft-fails: no usable reviewer (`backend: "auto"` tried Claude then Cursor; pinned `claude` still tips when Claude is missing), same-model refuse, background spawn unavailable, a **silent PTY** early abort (spawn succeeded but produced no scrollback in the grace window), and a **session-cap refusal** (detached `agent-kit-audit-*` pile at the cap, so nothing spawned). Advance the queue on exit 4 or on exit 3 with zero leftover budget, but record the target as owed, never as reviewed. ADR: `2026-07-30_audits-pty-progress-gate-zombie-policy.md`; wait resume: `2026-08-13_audits-atomic-wait-reviewer-fallback.md`. +**Exit 3 stays timeout-only across the queue.** A mid-queue or queue-end arm that returns `3` reviewed nothing: leave that plan Field Report **owed**, keep its path out of the queue-end triage list, and never narrate it as reviewed. **Same-session resume:** when leftover `waitTimeoutSeconds` remains in `.cursor/context/audit-wait/.json`, the same orchestrator session must keep polling (re-arm `--wait-monitor`) before advancing the cursor or skipping `/plan-review-triage`. A later session may poll leftover budget; that is fallback, not the default while this session is still open. Do not treat a first-slice exit `3` as "arm done, continue the queue." Monitors that show up later, including monitors written by a different arm or a later queue position, do **not** retroactively upgrade an earlier `3` — the `3` stays `3` even when a later genuine monitor exists. Exit `4` covers the launcher soft-fails: no usable reviewer (`backend: "auto"` tried Claude then Cursor; pinned `claude` still tips when Claude is missing), same-model refuse, background spawn unavailable, a **silent PTY** early abort (spawn succeeded but produced no scrollback in the grace window), and a **session-cap refusal** (detached `agent-kit-audit-*` pile at the cap, so nothing spawned). Advance the queue on exit 4 or on exit 3 with zero leftover budget, but record the target as owed, never as reviewed. **Leftover budget is `deadline` vs wall clock, never `status: "armed"` alone:** the launcher expires a wait-state file left `armed` past its `deadline` on contact (to `status: "timeout"`, `remainingBudgetSeconds: 0`), so a stale arm from an earlier queue run is never resumed as live budget; sweep all slugs with `.cursor/scripts/plan-external-review.sh --gc-wait-state [--dry-run]`. **Owed close (separate, later event, not an upgrade of the `3`):** once the wait-state for that slug is terminal-and-dead (`status: "timeout"`/`"soft-fail"`, or `"armed"` with `now >= deadline`) and a genuine post-hoc monitor for the slug exists, `/run-plan`'s owed-close HITL applies — `Adopt existing monitor` (into `/plan-review-triage`, closes as reviewed-by-adoption) or `Ack owed without review` (closes as acked/unreviewed); a duplicate re-arm against already-merged work is not the only route. Queue-end triage lists still exclude dead-timeout rows by default; adoption is operator-initiated per row. ADR: `2026-07-30_audits-pty-progress-gate-zombie-policy.md`; wait resume: `2026-08-13_audits-atomic-wait-reviewer-fallback.md`; owed-close: `.cursor/memory/errors/2026-08-14_audit-owed-ledger-no-close-path.md`. ### External plan review (legacy heading) diff --git a/.cursor/commands/run-plan.md b/.cursor/commands/run-plan.md index a172646..82aa60d 100644 --- a/.cursor/commands/run-plan.md +++ b/.cursor/commands/run-plan.md @@ -56,7 +56,7 @@ While `/run-plan` is active, it **overrides** the "1 phase = 1 chat / ask for a - Context Pack `.cursor/context/current/` if it exists - Memory CHECK if the phase touches a known error/decision - **Pre-flight (API-limit stop):** if HANDOFF Gaps / Instruction / stop reason indicates an API/usage limit hard stop from a prior tick, **do not** mark a to-do `in_progress` or dispatch a Task until the operator confirms recovery (named model switch and/or wait for reset). Pre-flight is this HANDOFF check plus operator model choice only; the kit has **no** remaining-quota API. Align with `context-guardian` quota-blocked sessions. -- **Audits pre-flight:** read `externalPlanReview.preflight` (`off` | `warn` | `block`; missing = `off`). When not `off`, check owed / untriaged audits for the active plan slug (Field Report owed, untriaged monitors, cadence WARNING). `warn`: surface once then continue. `block`: arm the launcher (prefer `--autonomous` when `mode: autonomous`, else `--paste-only`) or stop until deferred; never steal `/git-prod`. Stronger than advisory monitor skim (ADR `2026-07-27_audits-autonomous-plan-review-contract.md`). +- **Audits pre-flight:** read `externalPlanReview.preflight` (`off` | `warn` | `block`; missing = `off`). When not `off`, check owed / untriaged audits for the active plan slug (Field Report owed, untriaged monitors, cadence WARNING). `warn`: surface once then continue. `block`: arm the launcher (prefer `--autonomous` when `mode: autonomous`, else `--paste-only`) or stop until deferred; never steal `/git-prod`. Stronger than advisory monitor skim (ADR `2026-07-27_audits-autonomous-plan-review-contract.md`). **Unsatisfiable-config check (same preflight):** `enabled: true` with `backend` pinned `"claude"` while this lane's implementer is Claude can only end owed — `CLAUDE.md` lists Claude external plan-review audits as a non-goal here, and the launcher's implementer≠reviewer split makes a pinned Claude reviewer against a Claude implementer an honest same-model skip (ADR `decisions/2026-08-13_audits-atomic-wait-reviewer-fallback.md` point 4). `"auto"`, `"cursor"`, and `"cloud"` stay satisfiable. `warn`: surface once per run, naming the exact combination and the three outs (`backend: "auto"`, `"cursor"`, or `"cloud"`), then continue. `block`: do not start the run into a config that can only end owed — stop and surface the fix, or continue only on explicit operator deferral; never steal `/git-prod`. `off`: silent, unchanged. A growing owed pile is not an acceptable substitute for surfacing this (`.cursor/memory/errors/2026-08-14_audit-owed-ledger-no-close-path.md`). - **Audit session-pile preflight (before arm):** count detached workspace-owned `agent-kit-audit-*` sessions via `.cursor/scripts/plan-external-review.sh --reap-audit-sessions --dry-run` (or the arm `--dry-run` line `audit-sessions: N detached owned`). At warn: print the dispose command (or offer `--reap-audit-sessions`) and continue. At cap: do not spawn; surface the cap in the orchestrator preflight/Ask, not only launcher stderr. Attached sessions are operator work and are never counted. - **Unprocessed dogfood preflight:** before marking the first to-do `in_progress` (or on resume of a continuous run after HANDOFF reload), skim `##` or `### Unprocessed Files` in factory `dogfood/README.md` or consumer `.cursor/dogfood/README.md`. Empty or missing: silent OK. Non-empty: mention count and top titles once with standard triage labels, then **Ask questions** (one question; chat numbered-list fallback) with labels exactly `Analyze inbox now` / `Enqueue Fix now` / `Not now` (same handlers as `/continue-plan` hard stop 4). Never auto-analyze, never invent Field Reports, never block the tick solely because the inbox is non-empty. When this tick runs inside a `/run-plan-all` per-plan Task and the orchestrator already skimmed Unprocessed at queue-confirm, skip re-reciting **and** skip this Ask (orchestrator owns the skim and the inbox Ask). sessionStart tip remains complementary (ADRs `2026-08-11_dogfood-unprocessed-broad-intake-bucket.md`, `2026-08-14_main-command-dogfood-audit-routing.md`). @@ -115,8 +115,8 @@ After Final HANDOFF when the run stopped because all implementable to-dos are do 6. **Post-arm monitor watch + continue (chat required):** after arming, **do not** stop at Final HANDOFF "when the monitor lands, run `/plan-review-triage`" or wait for the operator to type `done`. Chat autonomous arm **always** includes `--wait-monitor`. In the **same session**: 1. AwaitShell / block on the launcher until exit `0` (fresh monitor ready), `3` (timeout), or `4` (soft-fail while waiting). Chat slice is ~90s (`waitSliceSeconds`); remaining total budget lives in `.cursor/context/audit-wait/.json` (not HANDOFF). Wait success requires a **fresh** monitor after arm start (mtime/arm-epoch or content sentinel); pre-existing files are not ready. Reviewer cascade: `backend: "auto"` uses Claude (Haiku) when usable, else Cursor Agent; pin `claude` or `cursor` to force one backend. Same-family implementer and reviewer is an honest skip (including Auto/Auto). Opus advisor runs only on escalate. Findings-only until `/plan-review-triage`. 2. On **exit 0:** run `/plan-review-triage` Ask for that monitor path (findings-only; no silent-Ack / auto-fix). Apply termination policy (max closeout depth 1; nits/process-only prefer Ack / Fix nits; do not spawn unbounded `close-*` Write residuals) per ADR `decisions/2026-08-11_plan-audit-residuals-termination.md` and `/plan-review-triage` Step 2b. - 3. On **exit 3 with remaining `waitTimeoutSeconds`:** this is a slice timeout, not review done. Exit `3` is **timeout only**. **Same orchestrator session** must resume wait-state polling (re-arm `--wait-monitor` against leftover budget in `.cursor/context/audit-wait/.json`) before treating the audit as finished or skipping triage. Do not narrate exit 3 as reviewed. A later session may also poll leftover budget; that is fallback, not the default while this session is still open. A monitor that appears afterwards (later writer, separate arm, another queue position) does **not** convert it into success. - 4. On **exit 3 with zero remaining budget, or exit 4:** honest tip + Field Report owed; do **not** invent a finished review or run triage as if the monitor is ready. Exit `4` covers a **silent PTY** early abort (spawn succeeded, no scrollback in the grace window) and a **session-cap refusal** (detached `agent-kit-audit-*` pile at the cap, nothing spawned): both mean no audit is running. + 3. On **exit 3 with remaining `waitTimeoutSeconds`:** this is a slice timeout, not review done. Exit `3` is **timeout only**. **Same orchestrator session** must resume wait-state polling (re-arm `--wait-monitor` against leftover budget in `.cursor/context/audit-wait/.json`) before treating the audit as finished or skipping triage. Do not narrate exit 3 as reviewed. A later session may also poll leftover budget; that is fallback, not the default while this session is still open. A monitor that appears afterwards (later writer, separate arm, another queue position) does **not** convert it into success. **Liveness is `deadline` vs wall clock, not `status: "armed"`:** the launcher expires a wait-state file left `armed` past its `deadline` on contact (rewrites it to `status: "timeout"`, `remainingBudgetSeconds: 0`, other fields preserved), so a dead arm is never resumed as live leftover budget. Opt-in sweep across all slugs: `.cursor/scripts/plan-external-review.sh --gc-wait-state [--dry-run]`. + 4. On **exit 3 with zero remaining budget, or exit 4:** honest tip + Field Report owed; do **not** invent a finished review or run triage as if the monitor is ready. Exit `4` covers a **silent PTY** early abort (spawn succeeded, no scrollback in the grace window) and a **session-cap refusal** (detached `agent-kit-audit-*` pile at the cap, nothing spawned): both mean no audit is running. If the wait-state for the slug is later terminal-and-dead (`status: "timeout"`/`"soft-fail"`, or `"armed"` with `now >= deadline` — liveness is `deadline` vs wall clock, not `status: "armed"` alone) **and** a genuine `.cursor/memory/plan-monitor-.md` exists on disk (not a stub, not the pre-arm stale file the freshness gate already rejects; written by a later arm/pass), offer the owed-close HITL: Ask exactly `Adopt existing monitor` / `Ack owed without review` / `Not now`. `Adopt existing monitor` routes into `/plan-review-triage ` for that monitor; a durable triage heading records the close as **reviewed-by-adoption**. `Ack owed without review` closes the row as **acked**, recorded as **unreviewed** (never as reviewed), no product edits, evidence written as an `_index.md` Audits row note (existing mechanism, with the ack reason — no new ledger file format). `Not now` leaves it owed. The earlier exit `3` stays `3` and the wait-state file's `status` is never rewritten to `ready`; this close is a separate, later event, not "the arm succeeded", and a duplicate re-arm against already-merged work is never the only route. See `decisions/2026-08-13_audits-atomic-wait-reviewer-fallback.md` point 5 and `.cursor/memory/errors/2026-08-14_audit-owed-ledger-no-close-path.md`. 5. Never claim the audit finished on spawn-only exit 0 or on a stale pre-arm monitor path. ADR: `decisions/2026-07-27_audits-wait-freshness-enforce.md` (follow-on to `decisions/2026-07-27_audits-post-spawn-monitor-watch-continue.md`); silent PTY and session pile: `decisions/2026-07-30_audits-pty-progress-gate-zombie-policy.md`. 7. **Not a native stop hook:** do **not** register or rely on a Cursor `hooks.json` `stop` follow-up. Exhaustion Ask / arm / watch run only after Final HANDOFF / prod suggestion as a separate gate. diff --git a/.cursor/scripts/plan-external-review-atomic-wait.test.mjs b/.cursor/scripts/plan-external-review-atomic-wait.test.mjs index d6dba9f..1621200 100644 --- a/.cursor/scripts/plan-external-review-atomic-wait.test.mjs +++ b/.cursor/scripts/plan-external-review-atomic-wait.test.mjs @@ -1,6 +1,14 @@ import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { execFileSync, spawnSync } from "node:child_process"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { test } from "node:test"; @@ -24,6 +32,8 @@ function runBash(room, body) { "wait_state_get", "wait_state_write", "wait_state_clear", + "wait_state_expire_if_dead", + "gc_wait_state_sweep", "wait_state_is_resumable", "wait_state_load_or_init", "wait_effective_timeout", @@ -65,6 +75,36 @@ function makeRoom() { return room; } +function writeWaitState(room, slug, state) { + writeFileSync( + join(room, ".cursor/context/audit-wait", `${slug}.json`), + `${JSON.stringify(state, null, 2)}\n`, + ); +} + +function readWaitState(room, slug) { + return JSON.parse(readFileSync(join(room, ".cursor/context/audit-wait", `${slug}.json`), "utf8")); +} + +// Full-binary sandbox for CLI/env-var pure-sweep-mode tests: the launcher resolves ROOT +// from its own path ($(dirname "$0")/../..), so copying it under a temp room's +// .cursor/scripts/ makes ROOT resolve to that room instead of the real repo checkout - +// gc_wait_state_sweep() then only ever touches synthetic files, never the real +// .cursor/context/audit-wait/*.json state. +function makeScriptRoom() { + const room = mkdtempSync(join(tmpdir(), "ak-gc-cli.")); + mkdirSync(join(room, ".cursor/scripts"), { recursive: true }); + mkdirSync(join(room, ".cursor/context/audit-wait"), { recursive: true }); + mkdirSync(join(room, ".cursor/context/templates"), { recursive: true }); + // Required by the pre-pure-mode gates: config_enabled is bypassed with --force, but the + // template-existence check runs unconditionally before either pure-mode block. + writeFileSync(join(room, ".cursor/context/templates/plan-external-review-prompt.md"), "stub\n"); + const script = join(room, ".cursor/scripts/plan-external-review.sh"); + writeFileSync(script, SRC); + chmodSync(script, 0o755); + return { room, script }; +} + test("launcher wait_for_monitors uses persisted state and slice timeout", () => { assert.match(SRC, /wait_state_load_or_init/); assert.match(SRC, /wait_effective_timeout/); @@ -179,3 +219,296 @@ echo "resume=$WAIT_RESUME remaining=$WAIT_REMAINING epoch=$WAIT_ARM_EPOCH effect rmSync(room, { recursive: true, force: true }); } }); + +test("wait_state_expire_if_dead: armed past deadline is expired in place, other fields survive", () => { + const room = makeRoom(); + try { + writeWaitState(room, "a", { + armEpoch: 900, + deadline: 990, + remainingBudgetSeconds: 90, + backend: "cursor", + implementerModel: "auto", + reviewerModel: "sonnet", + status: "armed", + }); + const result = runBash( + room, + ` +wait_state_expire_if_dead "a" +`, + ); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match( + result.stdout, + /audits: wait-state expired a \(armed past deadline; dead arm, not live budget\)/, + ); + const state = readWaitState(room, "a"); + assert.equal(state.status, "timeout"); + assert.equal(state.remainingBudgetSeconds, 0); + assert.equal(state.armEpoch, 900); + assert.equal(state.deadline, 990); + assert.equal(state.backend, "cursor"); + assert.equal(state.implementerModel, "auto"); + assert.equal(state.reviewerModel, "sonnet"); + } finally { + rmSync(room, { recursive: true, force: true }); + } +}); + +test("wait_state_expire_if_dead: live armed file (now < deadline) is untouched and still resumable", () => { + const room = makeRoom(); + try { + writeWaitState(room, "a", { + armEpoch: 900, + deadline: 2000, + remainingBudgetSeconds: 1000, + backend: "claude", + implementerModel: "auto", + reviewerModel: "sonnet", + status: "armed", + }); + const before = readWaitState(room, "a"); + const result = runBash( + room, + ` +wait_state_expire_if_dead "a" +if wait_state_is_resumable "a"; then + echo "resumable=yes" +else + echo "resumable=no" +fi +`, + ); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(result.stdout.trim(), "resumable=yes"); + const after = readWaitState(room, "a"); + assert.deepEqual(after, before); + } finally { + rmSync(room, { recursive: true, force: true }); + } +}); + +test("wait_state_expire_if_dead: already-timeout file is not rewritten (no spurious churn)", () => { + const room = makeRoom(); + try { + writeWaitState(room, "a", { + armEpoch: 100, + deadline: 200, + remainingBudgetSeconds: 0, + backend: "claude", + implementerModel: "auto", + reviewerModel: "sonnet", + status: "timeout", + }); + const before = readWaitState(room, "a"); + const result = runBash( + room, + ` +wait_state_expire_if_dead "a" +echo "done" +`, + ); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(result.stdout.trim(), "done"); + assert.doesNotMatch(result.stdout, /wait-state expired/); + const after = readWaitState(room, "a"); + assert.deepEqual(after, before); + } finally { + rmSync(room, { recursive: true, force: true }); + } +}); + +test("wait_state_expire_if_dead: DRY_RUN=1 previews and does not modify the file on disk", () => { + const room = makeRoom(); + try { + writeWaitState(room, "a", { + armEpoch: 900, + deadline: 990, + remainingBudgetSeconds: 90, + backend: "claude", + implementerModel: "auto", + reviewerModel: "sonnet", + status: "armed", + }); + const before = readWaitState(room, "a"); + const result = runBash( + room, + ` +DRY_RUN=1 +wait_state_expire_if_dead "a" +`, + ); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /audits: wait-state would expire a/); + assert.doesNotMatch(result.stdout, /audits: wait-state expired a /); + const after = readWaitState(room, "a"); + assert.deepEqual(after, before); + } finally { + rmSync(room, { recursive: true, force: true }); + } +}); + +test("gc_wait_state_sweep: expires dead armed, skips live armed, skips already-terminal, one line each", () => { + const room = makeRoom(); + try { + writeWaitState(room, "live", { + armEpoch: 900, + deadline: 2000, + remainingBudgetSeconds: 1000, + backend: "claude", + implementerModel: "auto", + reviewerModel: "sonnet", + status: "armed", + }); + writeWaitState(room, "dead", { + armEpoch: 400, + deadline: 500, + remainingBudgetSeconds: 100, + backend: "cursor", + implementerModel: "auto", + reviewerModel: "sonnet", + status: "armed", + }); + writeWaitState(room, "term", { + armEpoch: 100, + deadline: 200, + remainingBudgetSeconds: 0, + backend: "claude", + implementerModel: "auto", + reviewerModel: "sonnet", + status: "timeout", + }); + const result = runBash(room, "gc_wait_state_sweep"); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match( + result.stdout, + /gc-wait-state skip live \(armed; live budget, not past deadline\)/, + ); + assert.match(result.stdout, /gc-wait-state skip term \(status: timeout; already terminal\)/); + assert.match( + result.stdout, + /wait-state expired dead \(armed past deadline; dead arm, not live budget\)/, + ); + assert.equal(readWaitState(room, "live").status, "armed"); + assert.equal(readWaitState(room, "term").status, "timeout"); + const dead = readWaitState(room, "dead"); + assert.equal(dead.status, "timeout"); + assert.equal(dead.remainingBudgetSeconds, 0); + assert.equal(dead.backend, "cursor"); + } finally { + rmSync(room, { recursive: true, force: true }); + } +}); + +test("gc_wait_state_sweep: empty audit-wait directory reports no files and exits 0", () => { + const room = makeRoom(); + try { + const result = runBash(room, "gc_wait_state_sweep"); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match( + result.stdout, + /gc-wait-state found no \.cursor\/context\/audit-wait\/\*\.json files/, + ); + } finally { + rmSync(room, { recursive: true, force: true }); + } +}); + +test("--gc-wait-state --dry-run (CLI, no plan arg): previews every file, writes nothing, exits 0", () => { + const { room, script } = makeScriptRoom(); + try { + writeWaitState(room, "dead", { + armEpoch: 1, + deadline: 2, + remainingBudgetSeconds: 50, + backend: "claude", + implementerModel: "auto", + reviewerModel: "sonnet", + status: "armed", + }); + const before = readWaitState(room, "dead"); + const out = execFileSync("bash", [script, "--force", "--gc-wait-state", "--dry-run"], { + cwd: room, + encoding: "utf8", + }); + assert.match( + out, + /audits: gc-wait-state dry-run preview \(no plan argument; no audit will start\)/, + ); + assert.match(out, /wait-state would expire dead/); + assert.deepEqual(readWaitState(room, "dead"), before); + } finally { + rmSync(room, { recursive: true, force: true }); + } +}); + +test("--gc-wait-state (CLI, no plan arg, no --dry-run): actually expires the dead file on disk", () => { + const { room, script } = makeScriptRoom(); + try { + writeWaitState(room, "dead", { + armEpoch: 1, + deadline: 2, + remainingBudgetSeconds: 50, + backend: "claude", + implementerModel: "auto", + reviewerModel: "sonnet", + status: "armed", + }); + const out = execFileSync("bash", [script, "--force", "--gc-wait-state"], { + cwd: room, + encoding: "utf8", + }); + assert.match(out, /audits: gc-wait-state mode \(no plan argument; no audit will start\)/); + assert.match(out, /wait-state expired dead/); + const after = readWaitState(room, "dead"); + assert.equal(after.status, "timeout"); + assert.equal(after.remainingBudgetSeconds, 0); + } finally { + rmSync(room, { recursive: true, force: true }); + } +}); + +test("AGENT_KIT_AUDIT_GC_WAIT_STATE=1 (env var, no flag, no plan arg): same pure-sweep exit-0 mode", () => { + const { room, script } = makeScriptRoom(); + try { + writeWaitState(room, "dead", { + armEpoch: 1, + deadline: 2, + remainingBudgetSeconds: 50, + backend: "claude", + implementerModel: "auto", + reviewerModel: "sonnet", + status: "armed", + }); + const out = execFileSync("bash", [script, "--force"], { + cwd: room, + encoding: "utf8", + env: { ...process.env, AGENT_KIT_AUDIT_GC_WAIT_STATE: "1" }, + }); + assert.match(out, /audits: gc-wait-state mode \(no plan argument; no audit will start\)/); + assert.match(out, /wait-state expired dead/); + assert.equal(readWaitState(room, "dead").status, "timeout"); + } finally { + rmSync(room, { recursive: true, force: true }); + } +}); + +test("--gc-wait-state combined with a plan argument is a documented no-op (locks in current behavior)", () => { + // Regression guard for the residual this fixes: the usage synopsis used to promise + // `--gc-wait-state [--dry-run] [plan]`, but GC_WAIT_STATE is only ever consulted in the + // two zero-PLAN_ARGS pure-exit blocks. Combined with a plan argument the flag parses + // without error but never sweeps - this test locks that in so a future change either + // fixes it deliberately (and updates this test) or the header comment stays accurate. + assert.doesNotMatch(SRC, /gc-wait-state \[--dry-run\] \[plan\]/); + const pureModeBlock = SRC.slice( + SRC.indexOf('if [[ "$GC_WAIT_STATE" -eq 1 && "$BATCH" -eq 0'), + SRC.indexOf('if [[ "$GC_WAIT_STATE" -eq 1 && "$BATCH" -eq 0') + 400, + ); + assert.match(pureModeBlock, /\$\{#PLAN_ARGS\[@\]\}"\s*-eq 0/); + assert.equal( + (SRC.match(/gc_wait_state_sweep/g) || []).length > 0, + true, + "gc_wait_state_sweep must still exist and be called somewhere", + ); +}); diff --git a/.cursor/scripts/plan-external-review-progress-gate.test.mjs b/.cursor/scripts/plan-external-review-progress-gate.test.mjs index d44f4e8..a1749a3 100644 --- a/.cursor/scripts/plan-external-review-progress-gate.test.mjs +++ b/.cursor/scripts/plan-external-review-progress-gate.test.mjs @@ -156,6 +156,253 @@ test("heartbeat lines keep status prefixes; suffix is empty under CI", () => { assert.strictEqual(result.stdout, "[]"); }); +function extractNamed(name) { + return spawnSync("sed", ["-n", `/^${name}() {/,/^}$/p`, SCRIPT], { + encoding: "utf8", + }).stdout; +} + +const SESSION_VARS = ` +AUDIT_SESSION_NS_PREFIX="agent-kit-audit-" +AUDIT_WS_TOKEN="aaaaaaaa" +AUDIT_SESSION_OWNED_PREFIX="agent-kit-audit-aaaaaaaa-" +AUDIT_SESSION_WARN=5 +AUDIT_SESSION_CAP=20 +AUDIT_SESSION_HOST_CAP=24 +AUDIT_REAP_MIN_AGE=3600 +REAP_SESSIONS=0 +LAUNCHER_REL=".cursor/scripts/plan-external-review.sh" +`; + +test("host scope counts foreign-token and legacy names; owned scope excludes them", () => { + const fns = [ + "is_owned_audit_session", + "audit_session_matches_scope", + "list_audit_sessions", + "count_audit_sessions", + ] + .map(extractNamed) + .join("\n"); + const bash = ` +${SESSION_VARS} +screen() { + printf 'There are screens on:\\n' + printf '\\t111.agent-kit-audit-aaaaaaaa-111\\t(Detached)\\n' + printf '\\t222.agent-kit-audit-bbbbbbbb-222\\t(Detached)\\n' + printf '\\t333.agent-kit-audit-333\\t(Detached)\\n' + printf '\\t444.agent-kit-audit-aaaaaaaa-444\\t(Attached)\\n' + printf '\\t555.other-session\\t(Detached)\\n' + printf '5 Sockets in /nonexistent-screen-sockdir.\\n' + return 1 +} +tmux() { return 1; } +date() { echo 1000; } +${fns} +printf 'owned=%s host=%s' "$(count_audit_sessions)" "$(count_audit_sessions host)" +`; + const result = spawnSync("bash", ["-c", bash], { encoding: "utf8" }); + assert.strictEqual(result.status ?? 1, 0, result.stderr); + assert.strictEqual(result.stdout, "owned=1 host=3"); +}); + +const MOCK_HOST_PILE = ` +list_audit_sessions() { + printf 'screen\\tagent-kit-audit-aaaaaaaa-1\\tdetached\\t-1\\n' + printf 'screen\\tagent-kit-audit-bbbbbbbb-2\\tdetached\\t-1\\n' + printf 'tmux\\tagent-kit-audit-bbbbbbbb-3\\tdetached\\t-1\\n' + printf 'screen\\tagent-kit-audit-99\\tdetached\\t-1\\n' + printf 'screen\\tagent-kit-audit-aaaaaaaa-4\\tattached\\t-1\\n' +} +`; + +function runPressureGate({ ownedCount, hostCount, hostCap }) { + const fns = ["audit_session_pressure_gate", "print_host_token_breakdown"] + .map(extractNamed) + .join("\n"); + const bash = ` +${SESSION_VARS} +AUDIT_SESSION_HOST_CAP=${hostCap} +${MOCK_HOST_PILE} +count_audit_sessions() { + if [[ "\${1:-owned}" == "host" ]]; then echo ${hostCount}; else echo ${ownedCount}; fi +} +print_dispose_instructions() { echo "DISPOSE_INSTRUCTIONS"; } +emit_paste_only() { echo "PASTE_ONLY:\$1"; } +soft_fail_exit() { echo "SOFT_FAIL"; exit 0; } +${fns} +audit_session_pressure_gate "review" +echo "GATE_PASSED" +`; + const result = spawnSync("bash", ["-c", bash], { encoding: "utf8" }); + return { output: result.stdout, exitCode: result.status ?? 1 }; +} + +test("pressure gate refuses at host cap even when owned count is under the per-token cap", () => { + const { output, exitCode } = runPressureGate({ ownedCount: 3, hostCount: 12, hostCap: 10 }); + assert.strictEqual(exitCode, 0, output); + assert.match(output, /REFUSING to spawn - detached audit sessions are at the HOST cap/); + assert.match(output, /host cap: 10 \(AGENT_KIT_AUDIT_SESSION_HOST_CAP; 0 disables\)/); + assert.match(output, /DISPOSE_INSTRUCTIONS/); + assert.match(output, /PASTE_ONLY:review/); + assert.match(output, /SOFT_FAIL/); + assert.doesNotMatch(output, /GATE_PASSED/); +}); + +test("host cap 0 disables the host refusal", () => { + const { output, exitCode } = runPressureGate({ ownedCount: 3, hostCount: 50, hostCap: 0 }); + assert.strictEqual(exitCode, 0, output); + assert.doesNotMatch(output, /REFUSING/); + assert.doesNotMatch(output, /SOFT_FAIL/); + assert.match(output, /GATE_PASSED/); +}); + +test("host-cap refusal includes the per-token breakdown with dispose-scope honesty", () => { + const { output } = runPressureGate({ ownedCount: 1, hostCount: 4, hostCap: 4 }); + assert.match(output, /Per-token breakdown \(detached, namespace-wide\):/); + assert.match(output, / {2}aaaaaaaa: 1 \(this workspace\)/); + assert.match(output, / {2}bbbbbbbb: 2/); + assert.match(output, / {2}unscoped-legacy: 1/); + assert.match(output, /dispose command below only reaps THIS workspace's share/); + assert.match(output, /never\ndisposes them/); +}); + +test("breakdown skips attached sessions and sorts tokens deterministically", () => { + const fn = extractNamed("print_host_token_breakdown"); + const bash = ` +AUDIT_WS_TOKEN="aaaaaaaa" +${MOCK_HOST_PILE} +${fn} +print_host_token_breakdown +`; + const result = spawnSync("bash", ["-c", bash], { encoding: "utf8" }); + assert.strictEqual(result.status ?? 1, 0, result.stderr); + assert.strictEqual( + result.stdout, + " aaaaaaaa: 1 (this workspace)\n bbbbbbbb: 2\n unscoped-legacy: 1\n", + ); +}); + +test("dry-run keeps the parsed owned line and adds the host line", () => { + const src = spawnSync("cat", [SCRIPT], { encoding: "utf8" }).stdout; + assert.match( + src, + /audit-sessions: \$\{count\} detached owned \(warn: \$\{AUDIT_SESSION_WARN\}, cap: \$\{AUDIT_SESSION_CAP\}\)/, + ); + assert.match( + src, + /audit-sessions-host: \$\{host_count\} detached namespace-wide \(host-cap: \$\{AUDIT_SESSION_HOST_CAP\}\)/, + ); + assert.match(src, /audit-sessions-gate: would refuse to spawn \(host cap reached\)/); +}); + +// --- Bounded session lifetime (AGENT_KIT_AUDIT_SESSION_MAX_AGE) --- + +const LIFETIME_FNS = ["audit_lifetime_timeout_bin", "audit_bounded_session_cmd"] + .map(extractNamed) + .join("\n"); + +function runBoundedCmd({ maxAge, commandMock, cmd = "echo hi" }) { + const bash = ` +AUDIT_SESSION_MAX_AGE=${maxAge} +${LIFETIME_FNS} +${commandMock} +audit_bounded_session_cmd ${JSON.stringify(cmd)} +`; + const result = spawnSync("bash", ["-c", bash], { encoding: "utf8" }); + return { output: result.stdout, exitCode: result.status ?? 1 }; +} + +test("lifetime wrap prefers timeout when available", () => { + const { output, exitCode } = runBoundedCmd({ + maxAge: 3600, + commandMock: `command() { if [[ "\$2" == "timeout" ]]; then return 0; fi; return 1; }`, + }); + assert.strictEqual(exitCode, 0, output); + assert.match(output, /^timeout 3600 bash -lc /); + assert.match(output, /echo\\ hi$/); +}); + +test("lifetime wrap falls back to gtimeout when timeout is absent", () => { + const { output, exitCode } = runBoundedCmd({ + maxAge: 900, + commandMock: `command() { if [[ "\$2" == "gtimeout" ]]; then return 0; fi; return 1; }`, + }); + assert.strictEqual(exitCode, 0, output); + assert.match(output, /^gtimeout 900 bash -lc /); +}); + +test("lifetime wrap falls back to a watchdog subshell scoped to the session's own tree", () => { + const { output, exitCode } = runBoundedCmd({ + maxAge: 120, + commandMock: "command() { return 1; }", + }); + assert.strictEqual(exitCode, 0, output); + // Literal -$$ must reach the session unexpanded: the watchdog signals only the + // spawned session's own process group, never attached or foreign-token sessions. + assert.match(output, /^\( sleep 120; kill -TERM -- -\$\$ /); + assert.match(output, /\) & echo hi$/); + assert.doesNotMatch(output, /list_audit_sessions|agent-kit-audit-|pkill/); +}); + +test("AGENT_KIT_AUDIT_SESSION_MAX_AGE=0 disables the wrap (command byte-identical)", () => { + const { output, exitCode } = runBoundedCmd({ + maxAge: 0, + commandMock: "command() { return 0; }", + cmd: 'echo "h i"', + }); + assert.strictEqual(exitCode, 0, output); + assert.strictEqual(output, 'echo "h i"'); +}); + +test("audit_lifetime_timeout_bin returns 1 when neither timeout nor gtimeout exists", () => { + const fn = extractNamed("audit_lifetime_timeout_bin"); + const bash = ` +command() { return 1; } +${fn} +if audit_lifetime_timeout_bin; then echo FOUND; else echo NONE; fi +`; + const result = spawnSync("bash", ["-c", bash], { encoding: "utf8" }); + assert.strictEqual(result.status ?? 1, 0, result.stderr); + assert.match(result.stdout, /NONE/); + assert.doesNotMatch(result.stdout, /FOUND/); +}); + +test("max-age env is wired through resolve_int_env with default 3600 and tip fallback", () => { + const src = spawnSync("cat", [SCRIPT], { encoding: "utf8" }).stdout; + assert.match(src, /^AUDIT_SESSION_MAX_AGE=3600$/m); + assert.match( + src, + /AUDIT_SESSION_MAX_AGE="\$\(resolve_int_env AGENT_KIT_AUDIT_SESSION_MAX_AGE "\$AUDIT_SESSION_MAX_AGE"\)"/, + ); + const fn = extractNamed("resolve_int_env"); + const bash = ` +export AGENT_KIT_AUDIT_SESSION_MAX_AGE="bogus" +${fn} +resolve_int_env AGENT_KIT_AUDIT_SESSION_MAX_AGE 3600 +echo +export AGENT_KIT_AUDIT_SESSION_MAX_AGE=120 +resolve_int_env AGENT_KIT_AUDIT_SESSION_MAX_AGE 3600 +`; + const result = spawnSync("bash", ["-c", bash], { encoding: "utf8" }); + assert.strictEqual(result.status ?? 1, 0, result.stderr); + assert.strictEqual(result.stdout, "3600\n120"); + assert.match( + result.stderr, + /tip: AGENT_KIT_AUDIT_SESSION_MAX_AGE must be a non-negative integer/, + ); +}); + +test("spawn site wraps only detached multiplexer sessions; emulator channels stay advisory", () => { + const src = spawnSync("cat", [SCRIPT], { encoding: "utf8" }).stdout; + // Self-terminating at spawn: the bounded command is what tmux/screen execute. + assert.match(src, /tmux new-session -d -s "\$session_name" bash -lc "\$bounded_cmd"/); + assert.match(src, /screen -dmS "\$session_name" bash -lc "\$bounded_cmd"/); + // Advisory-only channels keep the unwrapped command (no kill wiring outside the + // launcher-spawned detached session). + assert.match(src, /gnome-terminal -- bash -lc "\$shell_cmd; exec bash"/); + assert.match(src, /bounded_cmd="\$\(audit_bounded_session_cmd "\$shell_cmd"\)"/); +}); + test("audit_kit_suffix is empty under NO_COLOR even without CI", () => { const fn = extractSuffix(); const result = spawnSync( diff --git a/.cursor/scripts/plan-external-review.sh b/.cursor/scripts/plan-external-review.sh index 3572196..1939815 100755 --- a/.cursor/scripts/plan-external-review.sh +++ b/.cursor/scripts/plan-external-review.sh @@ -31,6 +31,7 @@ # .cursor/scripts/plan-external-review.sh --wait-monitor [--wait-timeout SECONDS] [plan] # .cursor/scripts/plan-external-review.sh --focus-terminal ... # rollback: OS window focus # .cursor/scripts/plan-external-review.sh --reap-audit-sessions [--dry-run] [plan] +# .cursor/scripts/plan-external-review.sh --gc-wait-state [--dry-run] # no [plan]: not wired into arming # # Modes: # autonomous (config mode=autonomous, or --autonomous): spawn interactive Claude in an @@ -89,10 +90,34 @@ # the dispose command; at or above the hard cap it refuses to spawn, prints the dispose # instructions plus the paste fallback, and soft-fails without entering the monitor wait # (no audit starts, Field Report stays owed). +# A host-global ceiling additionally counts detached sessions across the WHOLE +# agent-kit-audit- namespace (foreign workspace tokens and legacy unscoped +# agent-kit-audit- names included) and refuses to spawn at or above +# AGENT_KIT_AUDIT_SESSION_HOST_CAP, printing a per-token breakdown. Disposal stays +# owned-only: foreign-token and legacy sessions are never disposed by this process. # Reaping is opt-in (--reap-audit-sessions or AGENT_KIT_AUDIT_REAP=1) and disposes only # detached, workspace-owned sessions whose age is at or above AGENT_KIT_AUDIT_REAP_MIN_AGE. # Attached sessions are never touched, an unknown age counts as too young to reap, and # --dry-run only previews. No pkill, no wildcard kill, nothing outside the owned namespace. +# Separately, each detached tmux/screen session THIS launcher spawns self-terminates +# after AGENT_KIT_AUDIT_SESSION_MAX_AGE seconds (default 3600; 0 disables), enforced at +# spawn inside the session itself, so a session that clears the progress gate cannot +# live forever even when no later launcher run happens. Attached sessions and foreign +# or legacy sessions are never targeted; emulator channels degrade to advisory. +# +# Wait-state hygiene (expire-on-contact, opt-in gc sweep): +# Any slug touched by an arm or poll first expires its own dead file: an +# .cursor/context/audit-wait/.json still status "armed" whose deadline has passed +# is a dead arm, not live budget, and is rewritten in place to status "timeout" with +# remainingBudgetSeconds 0 (armEpoch, deadline, backend, implementerModel, reviewerModel, +# and any cloudAgentId/cloudRunId are preserved). This is automatic and always on; it +# never invents a new status. --gc-wait-state (or AGENT_KIT_AUDIT_GC_WAIT_STATE=1) is an +# opt-in sweep across every .cursor/context/audit-wait/*.json file: expires each dead +# armed file, skips a live armed file (deadline not yet past), and skips a file that is +# already terminal, printing one line per decision. Alone (no plan argument, not --batch) +# it sweeps and exits 0 without starting an audit. --dry-run only previews. Combined with +# a plan argument or --batch it is a no-op (not wired into the arming path) - use the +# standalone form first, then arm separately. # # Environment: # AGENT_KIT_AUDIT_PROGRESS_TIMEOUT progress-gate grace window in seconds (default 60). @@ -106,10 +131,28 @@ # workspace-owned sessions (default 20). 0 disables the # refusal; a non-integer value prints a tip and falls # back to 20. +# AGENT_KIT_AUDIT_SESSION_HOST_CAP refuse to spawn at or above this many detached sessions +# across the whole agent-kit-audit- namespace (any +# workspace token, plus legacy unscoped names). Default +# 24: at or above the per-token cap (20) so it cannot +# shadow it, and below the 26-session pile that collapsed +# a 16 GB host in the 2026-08-14 incident. 0 disables the +# refusal; a non-integer value prints a tip and falls +# back to 24. +# AGENT_KIT_AUDIT_SESSION_MAX_AGE bounded lifetime in seconds for the detached tmux/screen +# session THIS launcher spawns. Self-terminating at spawn +# (timeout/gtimeout wrap, else a watchdog subshell inside +# the session); never depends on a later launcher run. +# Default 3600, well above --wait-timeout 900. 0 disables; +# a non-integer value prints a tip and falls back to 3600. +# Attached and foreign-token sessions are never targeted. # AGENT_KIT_AUDIT_REAP_MIN_AGE age floor in seconds for opt-in reaping (default 3600). # A non-integer value prints a tip and falls back to 3600. # AGENT_KIT_AUDIT_REAP 1/true: same as --reap-audit-sessions (opt-in disposal # of detached workspace-owned sessions past the age floor). +# AGENT_KIT_AUDIT_GC_WAIT_STATE 1/true: same as --gc-wait-state (opt-in sweep that +# expires dead armed .cursor/context/audit-wait/*.json +# files past their deadline to status timeout). # AGENT_KIT_AUDIT_FOCUS_TERMINAL 1/true: rollback to OS Terminal activate / emulator focus. # AGENT_KIT_AUDIT_IMPLEMENTER_MODEL model id that shipped the tick (stamp only; no secrets). # AGENT_KIT_AUDIT_REVIEWER_MODEL override reviewer model id (default sonnet). @@ -236,10 +279,22 @@ AUDIT_SESSION_OWNED_PREFIX="${AUDIT_SESSION_NS_PREFIX}${AUDIT_WS_TOKEN}-" # Detached workspace-owned sessions: warn at or above WARN, refuse to spawn at or above CAP. 0 disables. AUDIT_SESSION_WARN=5 AUDIT_SESSION_CAP=20 +# Host-global ceiling across the whole agent-kit-audit- namespace (any token + legacy +# unscoped names). Default 24: at or above the per-token cap (20) so it cannot shadow it, +# below the 26-session pile of the 2026-08-14 host collapse. 0 disables. +AUDIT_SESSION_HOST_CAP=24 +# Bounded lifetime (seconds) for the detached tmux/screen session THIS launcher spawns. +# Enforced at spawn inside the session itself (timeout/gtimeout wrap, else a watchdog +# subshell), so it never depends on a later launcher run in the same workspace. Default +# is well above WAIT_TIMEOUT 900 so a healthy wait can finish first. 0 disables. +AUDIT_SESSION_MAX_AGE=3600 # Age floor (seconds) for opt-in reaping. Younger sessions are left alone even when reaping is on. AUDIT_REAP_MIN_AGE=3600 # Opt-in destructive disposal (--reap-audit-sessions / AGENT_KIT_AUDIT_REAP). Never the default. REAP_SESSIONS=0 +# Opt-in sweep of dead armed audit-wait/*.json files (--gc-wait-state / +# AGENT_KIT_AUDIT_GC_WAIT_STATE). Never the default; expire-on-contact runs regardless. +GC_WAIT_STATE=0 FOCUS_TERMINAL=0 # Set by launch_background_terminal on success: tmux|screen|macos-terminal|linux-emulator|windows-terminal LAUNCH_CHANNEL="" @@ -260,11 +315,74 @@ is_owned_audit_session() { return 1 } +# 0 when name is in scope: "owned" is the strict workspace-owned match; "host" matches ANY +# session in the agent-kit-audit- namespace (foreign tokens and legacy unscoped names too). +# Host scope is count/attribution only - disposal always stays owned. +audit_session_matches_scope() { + local scope="$1" + local name="$2" + if [[ "$scope" == "host" ]]; then + [[ "$name" == "${AUDIT_SESSION_NS_PREFIX}"* ]] + return $? + fi + is_owned_audit_session "$name" +} + # Build a fresh owned session name for this PID (collision-resistant across workspaces). make_audit_session_name() { printf '%s%s' "$AUDIT_SESSION_OWNED_PREFIX" "$$" } +# Prints the timeout binary to prefer for the bounded session lifetime (timeout, else +# gtimeout for coreutils-on-macOS installs). Returns 1 when neither exists. +audit_lifetime_timeout_bin() { + if command -v timeout >/dev/null 2>&1; then + printf 'timeout' + return 0 + fi + if command -v gtimeout >/dev/null 2>&1; then + printf 'gtimeout' + return 0 + fi + return 1 +} + +# Wrap a session shell command so the detached session THIS launcher spawns +# self-terminates after AUDIT_SESSION_MAX_AGE seconds (bounded lifetime, enforced at +# spawn inside the session; never depends on a later launcher run). Prefers +# timeout/gtimeout; falls back to a watchdog subshell that signals only the session's +# own process group ($$ resolves inside the spawned session, so attached sessions and +# foreign-token sessions are structurally out of reach). 0 disables (command unchanged). +# ADR: decisions/2026-07-30_audits-pty-progress-gate-zombie-policy.md (lifecycle policy). +audit_bounded_session_cmd() { + local cmd="$1" + if [[ "$AUDIT_SESSION_MAX_AGE" -eq 0 ]]; then + printf '%s' "$cmd" + return 0 + fi + local tbin + if tbin="$(audit_lifetime_timeout_bin)"; then + printf '%s %s bash -lc %q' "$tbin" "$AUDIT_SESSION_MAX_AGE" "$cmd" + return 0 + fi + # Watchdog subshell inside the session. Single-quoted so -$$ reaches the session + # literally; kill targets only this session's own process group (fallback: own PID). + printf '( sleep %s; kill -TERM -- -$$ >/dev/null 2>&1 || kill -TERM $$ >/dev/null 2>&1 ) & %s' "$AUDIT_SESSION_MAX_AGE" "$cmd" +} + +# One observability line after a bounded multiplexer spawn. Silent when disabled. +audit_session_lifetime_note() { + if [[ "$AUDIT_SESSION_MAX_AGE" -eq 0 ]]; then + return 0 + fi + local mech="watchdog" + local tbin + if tbin="$(audit_lifetime_timeout_bin)"; then + mech="$tbin" + fi + echo "audits: session lifetime bounded to ${AUDIT_SESSION_MAX_AGE}s via ${mech} (AGENT_KIT_AUDIT_SESSION_MAX_AGE; 0 disables)" +} + if [[ "${AGENT_KIT_AUDIT_FOCUS_TERMINAL:-}" == "1" || "${AGENT_KIT_AUDIT_FOCUS_TERMINAL:-}" == "true" ]]; then FOCUS_TERMINAL=1 fi @@ -297,14 +415,20 @@ resolve_int_env() { AUDIT_SESSION_WARN="$(resolve_int_env AGENT_KIT_AUDIT_SESSION_WARN "$AUDIT_SESSION_WARN")" AUDIT_SESSION_CAP="$(resolve_int_env AGENT_KIT_AUDIT_SESSION_CAP "$AUDIT_SESSION_CAP")" +AUDIT_SESSION_HOST_CAP="$(resolve_int_env AGENT_KIT_AUDIT_SESSION_HOST_CAP "$AUDIT_SESSION_HOST_CAP")" +AUDIT_SESSION_MAX_AGE="$(resolve_int_env AGENT_KIT_AUDIT_SESSION_MAX_AGE "$AUDIT_SESSION_MAX_AGE")" AUDIT_REAP_MIN_AGE="$(resolve_int_env AGENT_KIT_AUDIT_REAP_MIN_AGE "$AUDIT_REAP_MIN_AGE")" if [[ "${AGENT_KIT_AUDIT_REAP:-}" == "1" || "${AGENT_KIT_AUDIT_REAP:-}" == "true" ]]; then REAP_SESSIONS=1 fi +if [[ "${AGENT_KIT_AUDIT_GC_WAIT_STATE:-}" == "1" || "${AGENT_KIT_AUDIT_GC_WAIT_STATE:-}" == "true" ]]; then + GC_WAIT_STATE=1 +fi + usage() { - sed -n '2,128p' "$0" | sed 's/^# \{0,1\}//' + sed -n '2,181p' "$0" | sed 's/^# \{0,1\}//' } while [[ $# -gt 0 ]]; do @@ -357,6 +481,10 @@ while [[ $# -gt 0 ]]; do REAP_SESSIONS=1 shift ;; + --gc-wait-state) + GC_WAIT_STATE=1 + shift + ;; --wait-timeout) if [[ $# -lt 2 || -z "${2:-}" ]]; then echo "error: --wait-timeout requires SECONDS" >&2 @@ -704,27 +832,32 @@ applescript_escape() { # ADR: decisions/2026-07-28_audits-headless-terminal-honesty.md launch_background_terminal() { local shell_cmd="$1" - local uname_s session_name + local uname_s session_name bounded_cmd uname_s="$(uname -s 2>/dev/null || echo unknown)" LAUNCH_CHANNEL="" LAUNCH_ATTACH_HINT="" LAUNCH_SESSION_NAME="" session_name="$(make_audit_session_name)" + # Bounded lifetime applies only to the detached multiplexer session this launcher + # spawns (self-terminating at spawn). Emulator/Terminal channels stay advisory-only. + bounded_cmd="$(audit_bounded_session_cmd "$shell_cmd")" # Prefer detached multiplexers (true headless/inspectable PTY, no OS window focus). if [[ "$FOCUS_TERMINAL" -eq 0 ]] && command -v tmux >/dev/null 2>&1; then - if tmux new-session -d -s "$session_name" bash -lc "$shell_cmd" >/dev/null 2>&1; then + if tmux new-session -d -s "$session_name" bash -lc "$bounded_cmd" >/dev/null 2>&1; then LAUNCH_CHANNEL="tmux" LAUNCH_ATTACH_HINT="tmux attach -t $session_name" LAUNCH_SESSION_NAME="$session_name" + audit_session_lifetime_note return 0 fi fi if [[ "$FOCUS_TERMINAL" -eq 0 ]] && command -v screen >/dev/null 2>&1; then - if screen -dmS "$session_name" bash -lc "$shell_cmd" >/dev/null 2>&1; then + if screen -dmS "$session_name" bash -lc "$bounded_cmd" >/dev/null 2>&1; then LAUNCH_CHANNEL="screen" LAUNCH_ATTACH_HINT="screen -r $session_name" LAUNCH_SESSION_NAME="$session_name" + audit_session_lifetime_note return 0 fi fi @@ -910,11 +1043,15 @@ dispose_launched_session() { return 0 } -# One line per existing workspace-owned session: channelnamestateage_seconds. +# One line per matching session: channelnamestateage_seconds. +# Optional scope arg: "owned" (default) lists only strict workspace-owned sessions; +# "host" lists every session in the agent-kit-audit- namespace (foreign tokens and +# legacy unscoped names included) for host-ceiling counting and attribution only. # state is attached|detached; age_seconds is -1 when it cannot be determined (callers must # treat unknown age as too young to reap). Prints nothing and succeeds when there is none. # ADR: decisions/2026-07-30_audits-pty-progress-gate-zombie-policy.md list_audit_sessions() { + local scope="${1:-owned}" local now now="$(date +%s)" @@ -934,8 +1071,8 @@ list_audit_sessions() { pid="${BASH_REMATCH[1]}" name="${BASH_REMATCH[2]}" marker="${BASH_REMATCH[3]}" - # Workspace ownership + strict pattern (rejects prefix pollution / foreign tokens). - if ! is_owned_audit_session "$name"; then + # Scope filter: owned is the strict workspace match; host is the whole namespace. + if ! audit_session_matches_scope "$scope" "$name"; then continue fi if [[ "$marker" =~ [Aa]ttached ]]; then @@ -961,7 +1098,7 @@ list_audit_sessions() { tmux_out="$(tmux list-sessions -F '#{session_name} #{session_attached} #{session_created}' 2>/dev/null || true)" while read -r t_name t_attached t_created; do [[ -z "$t_name" ]] && continue - if ! is_owned_audit_session "$t_name"; then + if ! audit_session_matches_scope "$scope" "$t_name"; then continue fi if [[ "$t_attached" =~ ^[0-9]+$ && "$t_attached" -gt 0 ]]; then @@ -980,17 +1117,42 @@ list_audit_sessions() { return 0 } -# Detached kit-owned sessions only: attached sessions are operator work in progress, not pile -# pressure, and are never disposed. +# Detached sessions only: attached sessions are operator work in progress, not pile +# pressure, and are never disposed. Optional scope arg mirrors list_audit_sessions +# ("owned" default / "host" for the whole agent-kit-audit- namespace). count_audit_sessions() { + local scope="${1:-owned}" local count - count="$(list_audit_sessions | awk -F'\t' '$3 == "detached"' | wc -l | tr -d '[:space:]' || true)" + count="$(list_audit_sessions "$scope" | awk -F'\t' '$3 == "detached"' | wc -l | tr -d '[:space:]' || true)" if ! [[ "$count" =~ ^[0-9]+$ ]]; then count=0 fi printf '%s' "$count" } +# Per-token breakdown of detached sessions across the whole namespace, one line per token +# (": N", legacy unscoped names grouped as "unscoped-legacy"). Attribution only: +# this invocation never disposes foreign-token or legacy sessions. +print_host_token_breakdown() { + list_audit_sessions host | awk -F'\t' -v own="$AUDIT_WS_TOKEN" ' + $3 == "detached" { + name = $2 + sub(/^agent-kit-audit-/, "", name) + if (name ~ /^[0-9a-f]{8}-[0-9]+$/) { + token = substr(name, 1, 8) + } else { + token = "unscoped-legacy" + } + counts[token]++ + } + END { + for (t in counts) { + suffix = (t == own) ? " (this workspace)" : "" + printf " %s: %d%s\n", t, counts[t], suffix + } + }' | sort +} + # Operator disposal instructions. Namespace-scoped by design: never a bare quit on an # unrelated session, never pkill, never a wildcard kill. print_dispose_instructions() { @@ -1047,6 +1209,47 @@ reap_audit_sessions() { return 0 } +# Opt-in sweep of .cursor/context/audit-wait/*.json (--gc-wait-state / +# AGENT_KIT_AUDIT_GC_WAIT_STATE=1). Every armed file whose deadline has passed is expired +# in place by wait_state_expire_if_dead (status -> "timeout", other fields preserved); a +# live armed file (deadline not yet past) is skipped; a file already in a terminal status +# is skipped without rewrite. One line per decision. --dry-run previews only (delegated to +# wait_state_expire_if_dead) and writes nothing. Never invents a status outside the ADR +# enum (armed | ready | timeout | soft-fail). +gc_wait_state_sweep() { + local dir="$ROOT/$WAIT_STATE_DIR_REL" + local file slug status deadline now seen=0 + if [[ ! -d "$dir" ]]; then + echo "audits: gc-wait-state found no ${WAIT_STATE_DIR_REL} directory" + return 0 + fi + local files=("$dir"/*.json) + if [[ ! -e "${files[0]}" ]]; then + echo "audits: gc-wait-state found no ${WAIT_STATE_DIR_REL}/*.json files" + return 0 + fi + now="$(date +%s)" + for file in "${files[@]}"; do + seen=$((seen + 1)) + slug="$(basename "$file" .json)" + status="$(wait_state_get "$slug" status)" + if [[ "$status" != "armed" ]]; then + echo "audits: gc-wait-state skip $slug (status: ${status:-unknown}; already terminal)" + continue + fi + deadline="$(wait_state_get "$slug" deadline)" + if [[ ! "$deadline" =~ ^[1-9][0-9]*$ ]] || [[ "$now" -lt "$deadline" ]]; then + echo "audits: gc-wait-state skip $slug (armed; live budget, not past deadline)" + continue + fi + wait_state_expire_if_dead "$slug" + done + if [[ "$seen" -eq 0 ]]; then + echo "audits: gc-wait-state found no ${WAIT_STATE_DIR_REL}/*.json files" + fi + return 0 +} + # Pre-spawn pressure gate: reap when opted in, then warn or refuse on detached pile size. # A refusal never spawns and never enters the monitor wait. audit_session_pressure_gate() { @@ -1055,8 +1258,32 @@ audit_session_pressure_gate() { echo "audits: reaping detached ${AUDIT_SESSION_OWNED_PREFIX}* sessions (min-age: ${AUDIT_REAP_MIN_AGE}s)" reap_audit_sessions fi - local count + local count host_count count="$(count_audit_sessions)" + host_count="$(count_audit_sessions host)" + # Host ceiling first: it is the stronger condition and spans every workspace token. + if [[ "$AUDIT_SESSION_HOST_CAP" -ne 0 && "$host_count" -ge "$AUDIT_SESSION_HOST_CAP" ]]; then + cat < -X quit / tmux kill-session -t ); this invocation never +disposes them. +No audit is starting, no monitor will be written by this attempt, and the Field Report +stays owed. Dispose the pile, then re-arm. +EOF + print_dispose_instructions + emit_paste_only "$kind" + soft_fail_exit + fi if [[ "$AUDIT_SESSION_CAP" -ne 0 && "$count" -ge "$AUDIT_SESSION_CAP" ]]; then cat < +# "timeout" and remainingBudgetSeconds -> 0 in place, preserving every other field +# (armEpoch, deadline, backend, implementerModel, reviewerModel, and the optional +# cloudAgentId / cloudRunId). Deliberately does NOT reuse wait_state_write, which rebuilds +# the object from WAIT_* globals and would clobber the stamped backend/model/cloud ids. +# --dry-run (DRY_RUN=1) previews only and writes nothing. No-op when the file is missing, +# not "armed", or its deadline has not passed. +wait_state_expire_if_dead() { + local slug="$1" + local path status deadline now + path="$(wait_state_path "$slug")" + [[ -f "$path" ]] || return 0 + status="$(wait_state_get "$slug" status)" + [[ "$status" == "armed" ]] || return 0 + deadline="$(wait_state_get "$slug" deadline)" + [[ "$deadline" =~ ^[1-9][0-9]*$ ]] || return 0 + now="$(date +%s)" + [[ "$now" -ge "$deadline" ]] || return 0 + if [[ "${DRY_RUN:-0}" -eq 1 ]]; then + echo "audits: wait-state would expire $slug (armed past deadline; dead arm, not live budget; dry-run, not written)" + return 0 + fi + if ! command -v node >/dev/null 2>&1; then + echo "tip: node required to expire audit wait-state at $WAIT_STATE_DIR_REL/${slug}.json" >&2 + return 1 + fi + node -e ' + const fs = require("fs"); + const path = process.argv[1]; + const j = JSON.parse(fs.readFileSync(path, "utf8")); + j.status = "timeout"; + j.remainingBudgetSeconds = 0; + fs.writeFileSync(path, JSON.stringify(j, null, 2) + "\n"); + ' "$path" + echo "audits: wait-state expired $slug (armed past deadline; dead arm, not live budget)" +} + wait_state_is_resumable() { local slug="$1" local status deadline remaining now @@ -1668,6 +1938,7 @@ wait_state_load_or_init() { fi for p in "${paths[@]}"; do slug="$(slug_from_monitor_path "$p")" + wait_state_expire_if_dead "$slug" if ! wait_state_is_resumable "$slug"; then all_resume=0 break @@ -2425,6 +2696,8 @@ fi # Pure disposal mode: --reap-audit-sessions with no plan argument reaps and exits. # This avoids coupling cleanup to a new audit spawn. --dry-run previews only. +# Also runs the --gc-wait-state sweep when both flags are combined, so a combined +# invocation never silently drops one cleanup for the other. if [[ "$REAP_SESSIONS" -eq 1 && "$BATCH" -eq 0 && "${#PLAN_ARGS[@]}" -eq 0 ]]; then if [[ "$DRY_RUN" -eq 1 ]]; then echo "audits: reap-only dry-run preview (no plan argument; no audit will start)" @@ -2432,6 +2705,22 @@ if [[ "$REAP_SESSIONS" -eq 1 && "$BATCH" -eq 0 && "${#PLAN_ARGS[@]}" -eq 0 ]]; t echo "audits: reap-only mode (no plan argument; no audit will start)" fi reap_audit_sessions + if [[ "$GC_WAIT_STATE" -eq 1 ]]; then + gc_wait_state_sweep + fi + exit 0 +fi + +# Pure sweep mode: --gc-wait-state with no plan argument sweeps and exits. Mirrors the +# reap-only block above: cleanup does not couple to a new audit spawn. --dry-run previews +# only (delegated through wait_state_expire_if_dead). +if [[ "$GC_WAIT_STATE" -eq 1 && "$BATCH" -eq 0 && "${#PLAN_ARGS[@]}" -eq 0 ]]; then + if [[ "$DRY_RUN" -eq 1 ]]; then + echo "audits: gc-wait-state dry-run preview (no plan argument; no audit will start)" + else + echo "audits: gc-wait-state mode (no plan argument; no audit will start)" + fi + gc_wait_state_sweep exit 0 fi diff --git a/.cursor/skills/domain/llm-security-ops/SKILL.md b/.cursor/skills/domain/llm-security-ops/SKILL.md new file mode 100644 index 0000000..c48895d --- /dev/null +++ b/.cursor/skills/domain/llm-security-ops/SKILL.md @@ -0,0 +1,85 @@ +--- +name: llm-security-ops +description: "LLM application security review (OWASP Top 10 for LLM Applications 2025) and local LLM lab ops (Docker Compose + Ollama). Project-owned domain skill; educational detect/mitigate/review only. Not contributeable upstream." +version: 0.1.0 +category: domain +--- + +# LLM Security + Ops (domain skill) + +**Project-owned domain skill** (`.cursor/skills/domain/`, one-way: never contributed to the registry; see ADR `2026-08-17_urutau-llm-lab-external-curriculum-domain-skill`). Distilled from the public [Urutau-LLM-Lab](https://github.com/thamaraprata/Urutau-LLM-Lab/) curriculum, which stays **external** — this repository does not vendor, fork, or patch the lab. + +**Voice: detect, mitigate, review.** This skill contains **no** exploit payloads, CTF flags, planted prompts, or attack procedures. It teaches how to recognize and fix LLM-application weaknesses and how to run a local, isolated LLM lab. + +## When to load + +- Reviewing code that sends user input to an LLM (chat endpoints, agents, RAG, tool use) +- Reviewing system prompts, prompt templates, or LLM output handling +- Operating or debugging a local LLM stack (Docker Compose + Ollama, provider-switch setups) +- Security review of anything matching OWASP Top 10 for LLM Applications 2025 themes + +## Binding (do not duplicate classic AppSec) + +- Route review work through the **`security-reviewer` agent** (`.cursor/agents/security-reviewer.md`) and the **`cybersec` pack** discipline (`registry/packs/cybersec/`). This skill adds the LLM-specific layer only. +- Classic checks — hardcoded secrets, auth on endpoints, PII in logs, SQL/XSS injection, CORS, rate limiting — stay owned by the core `security-review` skill (`registry/skills/core/security-review/SKILL.md`) and the L0 `git-secrets-safety` rule. Do not restate them here; run both layers together on LLM-adjacent diffs. + +## Review checklists — ready/wip curriculum items first + +### LLM01: Prompt Injection (curriculum: ready) + +Detect: +- [ ] User input concatenated into the system prompt or template without separation of roles +- [ ] Untrusted content (retrieved docs, web pages, file uploads) fed to the model as if it were instructions +- [ ] No detection of instruction-like input ("ignore previous...", role-play pivots) in high-privilege flows + +Mitigate / review: +- [ ] Clear role separation (system vs user messages); never rebuild the system prompt from user data +- [ ] Treat all model input from external sources as data, not instructions; delimit and label it +- [ ] Least privilege downstream: a jailbroken response must not be able to trigger privileged actions on its own +- [ ] Output-side validation for sensitive flows (see LLM05) instead of trusting prompt-side defenses alone + +### LLM07: System Prompt Leakage (curriculum: ready) + +Detect: +- [ ] Secrets, credentials, internal URLs, or business rules embedded in the system prompt +- [ ] Application behavior that echoes or summarizes its own instructions on request +- [ ] Logs or error paths that include the full prompt payload + +Mitigate / review: +- [ ] Assume the system prompt is ultimately extractable: keep secrets and authorization logic **out** of it (enforce server-side) +- [ ] The system prompt may state policy, but policy enforcement lives in code +- [ ] Log prompt metadata (template id, length), not full prompt bodies + +### LLM06: Excessive Agency (curriculum: wip) + +Detect: +- [ ] LLM-invoked tools/functions with broader permissions than the feature needs +- [ ] Irreversible or high-impact actions (writes, payments, deletes, sends) triggered by model output without human confirmation +- [ ] Agent loops with no step budget, allow-list, or scope boundary + +Mitigate / review: +- [ ] Minimal tool surface: expose only the operations the use case needs, with narrow parameters +- [ ] HITL confirmation on destructive or externally visible actions +- [ ] Per-tool authorization checked server-side per call, not once per session + +## Review checklists — planned curriculum items (short form) + +- **LLM02 Sensitive Information Disclosure:** no PII/secrets in training data, context windows, or transcripts; scrub model I/O logs; per-user data isolation in RAG stores. +- **LLM05 Improper Output Handling:** treat model output as untrusted input — encode before rendering (XSS), parameterize before queries, validate before shell/tool execution. +- **LLM03 Supply Chain:** pin and verify model sources, weights, adapters, and container images; review third-party prompts/plugins like third-party code. +- **LLM09 Misinformation:** ground high-stakes answers (citations/RAG), communicate uncertainty, human review where wrong answers cause harm. +- **LLM10 Unbounded Consumption:** rate limits, max token/step budgets, per-user quotas, cost alarms on LLM endpoints. +- **LLM04 Data & Model Poisoning / LLM08 Vector & Embedding Weaknesses:** no lab challenge yet (curriculum gap); review against the OWASP item text directly — see the map companion. + +## Companions + +- [owasp-llm-map.md](owasp-llm-map.md) — OWASP LLM Top 10 (2025) to curriculum challenge map, with coverage gaps +- [local-lab-ops.md](local-lab-ops.md) — running the local lab: Docker Compose topology, Ollama model management, provider switch, hygiene +- [mitigation-patterns.md](mitigation-patterns.md) — reusable mitigation patterns referenced by the checklists + +## Hard constraints + +- Educational/defensive only: never author exploit payloads, working jailbreak strings, or challenge flags in this repository +- Lab use is **local and isolated**; never point testing at third-party hosted instances +- Lab chat transcripts and lab databases stay out of git +- This skill is not a registry member, not a pack member, and has no L0 command diff --git a/.cursor/skills/domain/llm-security-ops/local-lab-ops.md b/.cursor/skills/domain/llm-security-ops/local-lab-ops.md new file mode 100644 index 0000000..c9030f3 --- /dev/null +++ b/.cursor/skills/domain/llm-security-ops/local-lab-ops.md @@ -0,0 +1,47 @@ +# Local LLM lab ops (Docker Compose + Ollama) + +How to run the external [Urutau-LLM-Lab](https://github.com/thamaraprata/Urutau-LLM-Lab/) locally, and the reusable ops patterns it demonstrates for any Flask/Ollama-style local LLM stack. **Local and isolated only** — never point lab exercises at hosted third-party instances. + +## Topology (docker-compose) + +Two services on one bridge network (`urutau-net`): + +- **app** — Flask app built from the repo `Dockerfile`, port `5000`, bind-mounts `./data` (SQLite) and `./logs`, `restart: unless-stopped` +- **ollama** — `ollama/ollama:latest`, port `11434`, named volume `ollama-data` for model weights, `OLLAMA_KEEP_ALIVE=24h` + +Bring-up: + +```bash +cp .env.example .env +docker compose up -d +docker compose exec ollama ollama pull llama3.1:8b # one-time model pull (~8GB RAM recommended) +# UI: http://localhost:5000 +``` + +## Provider switch (env contract) + +Keys come from `.env.example` only — never commit a populated `.env`: + +| Key | Purpose | +|-----|---------| +| `LLM_PROVIDER` | `ollama` (default, local) or `openai` | +| `OLLAMA_HOST` / `OLLAMA_MODEL` | Ollama endpoint + model tag | +| `OPENAI_API_KEY` / `OPENAI_MODEL` | Alternative hosted provider; client fails fast when the key is missing | +| `FLASK_ENV` / `FLASK_DEBUG` / `SECRET_KEY` / `PORT` | Flask runtime (lab defaults are development-grade) | +| `DATABASE_PATH` / `LOG_LEVEL` | SQLite path, logging | + +Pattern worth reusing: one unified LLM client selects the provider from env at startup and raises immediately on missing credentials, so misconfiguration surfaces at boot rather than mid-request. + +## Service surface (for health checks and review) + +- `GET /health` — status + active provider; used for container checks +- `POST /api/chat` — the LLM-facing endpoint; the one that carries the OWASP LLM attack surface +- `GET /api/challenges`, `/api/challenges//hint`, `/api/challenges//writeup` — curriculum metadata +- Logging pattern worth copying: chat logs record challenge id + message **length**, not message content — transcripts stay out of logs by construction + +## Ops review notes (defensive) + +- Lab defaults are intentionally development-grade: `FLASK_DEBUG=1`, `app.run(host="0.0.0.0")`, fallback `SECRET_KEY` in compose. Fine for an isolated local lab; each one is a finding if seen in a production LLM service. +- Model weights live in the named volume; `docker compose down -v` removes them (re-pull afterwards). +- Keep lab state out of version control: chat transcripts, `data/` SQLite files, `logs/`, and any populated `.env`. +- Ollama exposes port `11434` on localhost in the lab compose; do not forward it beyond the machine. diff --git a/.cursor/skills/domain/llm-security-ops/mitigation-patterns.md b/.cursor/skills/domain/llm-security-ops/mitigation-patterns.md new file mode 100644 index 0000000..fedda65 --- /dev/null +++ b/.cursor/skills/domain/llm-security-ops/mitigation-patterns.md @@ -0,0 +1,37 @@ +# LLM mitigation patterns + +Reusable defensive patterns referenced by the `SKILL.md` checklists. Detect/mitigate/review voice only — no exploit strings, no attack walkthroughs. + +## Prompt hardening (LLM01, LLM07) + +- **Role separation:** system instructions and user input travel in separate message roles; never string-concatenate user data into the system prompt. +- **Data-not-instructions framing:** wrap retrieved/external content in explicit delimiters and label it as untrusted data the model must not follow as instructions. +- **Secret-free prompts:** system prompts state policy but hold no secrets, tokens, internal URLs, or authorization rules — assume eventual extraction; enforce in code. +- **Prompt as config:** version prompt templates in the repo, review them in PRs like code, and log template id/version instead of prompt bodies. + +## Output handling (LLM05, and the backstop for LLM01) + +- **Model output is untrusted input.** Encode for the sink: HTML-encode before rendering, parameterize before SQL, validate/allow-list before shell, tool, or URL use. +- **Schema-validate structured output** (JSON schema / function-call arguments) before acting on it; reject on validation failure rather than repairing silently. +- **Completion checks server-side:** any "success" detection (e.g. pattern matching on responses) is a game mechanic, not a security control; authorization never keys off model output. + +## Bounded agency (LLM06) + +- **Minimal tool surface:** expose the fewest tools with the narrowest parameter types the feature needs. +- **Server-side authorization per tool call**, evaluated per invocation against the acting user, not the model's claim. +- **HITL gates on irreversible actions:** writes, sends, payments, deletes require explicit human confirmation. +- **Budgets:** cap agent steps, tool calls, tokens, and wall time per request. + +## Data protection (LLM02) + +- **Context minimization:** put only the data the current user is authorized to see into the context window; per-tenant isolation in retrieval stores. +- **Transcript hygiene:** scrub or avoid logging model I/O containing PII; lab transcripts and databases stay out of git. + +## Supply chain and consumption (LLM03, LLM10) + +- **Pin and verify:** model tags, weights digests, container images, and third-party prompts/plugins are reviewed and pinned like code dependencies. +- **Meter everything:** rate limits and per-user quotas on LLM endpoints, token ceilings per request, cost alarms — an LLM endpoint without limits is a cost and DoS surface. + +## Grounding (LLM09) + +- **Cite or qualify:** ground high-stakes answers in retrieved sources; surface uncertainty; route irreversible decisions through human review. diff --git a/.cursor/skills/domain/llm-security-ops/owasp-llm-map.md b/.cursor/skills/domain/llm-security-ops/owasp-llm-map.md new file mode 100644 index 0000000..b3ae765 --- /dev/null +++ b/.cursor/skills/domain/llm-security-ops/owasp-llm-map.md @@ -0,0 +1,29 @@ +# OWASP LLM Top 10 (2025) — curriculum map + +Source: read-only inventory of the public [Urutau-LLM-Lab](https://github.com/thamaraprata/Urutau-LLM-Lab/) (2026-08-17, HEAD `a4c6032`). The lab is an external curriculum; challenge internals (planted prompts, flags, hints, write-ups) stay in the lab and are deliberately absent here. + +| OWASP item (2025) | Lab challenge | Status | Difficulty | Review entry point | +|-------------------|---------------|--------|------------|--------------------| +| LLM01: Prompt Injection | 01 — Bypass Básico | ready | ⭐ | SKILL.md LLM01 checklist | +| LLM02: Sensitive Information Disclosure | 04 — Data Exfiltration | planned | ⭐⭐ | SKILL.md short form | +| LLM03: Supply Chain | 06 — Supply Chain Attack | planned | ⭐⭐⭐ | SKILL.md short form | +| LLM04: Data & Model Poisoning | — | **no challenge (gap)** | — | OWASP item text directly | +| LLM05: Improper Output Handling | 05 — Output Injection | planned | ⭐⭐⭐ | SKILL.md short form | +| LLM06: Excessive Agency | 03 — Agente Rebelde | in construction | ⭐⭐ | SKILL.md LLM06 checklist | +| LLM07: System Prompt Leakage | 02 — Vazamento de System Prompt | ready | ⭐ | SKILL.md LLM07 checklist | +| LLM08: Vector & Embedding Weaknesses | — | **no challenge (gap)** | — | OWASP item text directly | +| LLM09: Misinformation | 07 — Misinformation | planned | ⭐⭐ | SKILL.md short form | +| LLM10: Unbounded Consumption | 08 — Unbounded Consumption | planned | ⭐ | SKILL.md short form | + +Challenge 00 ("Chat Livre") is a free-chat baseline with no OWASP mapping. + +## Reading the map + +- **ready** challenges (01, 02) are exercisable today in the local lab; their checklists in `SKILL.md` are full detect/mitigate/review sets. +- **in construction / planned** rows track the upstream roadmap; review guidance does not wait for the lab — the short-form checklists apply now. +- **Gap rows (LLM04, LLM08):** the curriculum does not yet cover data/model poisoning or vector/embedding weaknesses. Do not report "covered by lab" for these; review against the [OWASP Top 10 for LLM Applications 2025](https://genai.owasp.org/llm-top-10/) item text. + +## What we learn vs what stays external + +- **Learned here:** the mapping above, detect/mitigate/review checklists, local-lab ops patterns. +- **Stays in the lab:** flag values and patterns, planted vulnerable system prompts, hints, write-ups, exploit walkthroughs, and any chat transcripts produced while exercising challenges. diff --git a/CHANGELOG.md b/CHANGELOG.md index 735905d..46c0c55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,34 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and ## [Unreleased] +## [5.5.0] - 2026-08-22 + +### Added + +- Kit-repo **domain skill** `.cursor/skills/domain/llm-security-ops/` (SKILL.md + `owasp-llm-map.md`, `local-lab-ops.md`, `mitigation-patterns.md`): OWASP Top 10 for LLM Applications 2025 detect/mitigate/review checklists plus local LLM lab ops (Docker Compose + Ollama), distilled from the external [Urutau-LLM-Lab](https://github.com/thamaraprata/Urutau-LLM-Lab/) curriculum. **Project-owned and one-way**: not a registry member, not a pack member, no L0 command, not consumer-installable — it layers on the `security-reviewer` agent and `cybersec` pack without replacing classic AppSec checklists. Educational/defensive only (no exploit payloads or flags in-tree); the lab itself stays external and unvendored. ADR `2026-08-17_urutau-llm-lab-external-curriculum-domain-skill.md`; first member of the `.cursor/skills/domain/` scaffold (ADR 2026-08-02). +- Consumer install docs name `--force-root` and the start-from-zero path for the first time: `docs/getting-started.md` gains **Project root guard** (which shapes are refused, warn-vs-refuse by interactivity) and **Starting from an empty folder** (`git init` recommended, `--force-root`, or the interactive `Proceed anyway?` yes). `install.md` mirrors both. Installing without Git stays supported - `/agent-kit-onboard` still owns the Git pillar and the CLI never runs `git init` for you. +- Audit wait-state **garbage collection**: liveness of `.cursor/context/audit-wait/.json` is `deadline` vs wall clock, not `status: "armed"`. Any arm or poll expires that slug's own dead file on contact (`status` -> `timeout`, `remainingBudgetSeconds` -> `0`, with `armEpoch`, `deadline`, `backend`, `implementerModel`, `reviewerModel`, and any `cloudAgentId` / `cloudRunId` preserved), so a stale arm is never resumed as live leftover budget. New opt-in sweep `--gc-wait-state` (or `AGENT_KIT_AUDIT_GC_WAIT_STATE=1`) walks every wait-state file, expires each dead armed one, skips live and already-terminal files with one line per decision, and with no plan argument sweeps and exits `0` without starting an audit; `--dry-run` previews. No new status outside the ADR enum (`armed` | `ready` | `timeout` | `soft-fail`). Tests `.cursor/scripts/plan-external-review-atomic-wait.test.mjs`. +- Field Report **owed** can now close honestly in the Claude CLI lane. When a slug's wait-state is terminal-and-dead (`status: "timeout"`/`"soft-fail"`, or `"armed"` past `deadline`) **and** a genuine post-hoc `plan-monitor-.md` exists on disk, `/run-plan` and `/run-plan-all` offer an owed-close HITL: `Adopt existing monitor` (routes into `/plan-review-triage `; the durable triage heading records **reviewed-by-adoption**) or `Ack owed without review` (closes as **acked**, recorded unreviewed). The earlier exit `3` stays `3`, the wait-state `status` is never rewritten to `ready`, and a duplicate re-arm against already-merged work is no longer the only sanctioned route. `Not now` leaves the row owed. +- Audits pre-flight (`externalPlanReview.preflight`) also refuses or warns on **unsatisfiable config**: `enabled: true` with `backend` pinned `"claude"` in the Claude CLI lane can only end owed (audits are a non-goal there, and a pinned Claude reviewer against a Claude implementer is an honest same-model skip), and `midBatchAudits: true` multiplies it across a queue. `warn` surfaces the combination once with the outs (`backend: "auto"`, `"cursor"`, `"cloud"`); `block` does not start the run or the queue. `/run-plan-all` checks before the confirm Ask. A growing owed pile is no longer the only signal. +- Audit sessions gain a **host-global ceiling, a bounded lifetime, and a visibility surface** alongside the per-workspace cap. The 2026-08-14 incident piled 26 detached `agent-kit-audit-*` sessions across four workspace tokens (13/10/2/1) on one 16 GB host while every token stayed under `AGENT_KIT_AUDIT_SESSION_CAP=20` — the cap is per token, the exhausted resource is the host. **Ceiling:** the launcher now also counts detached sessions across the **whole** `agent-kit-audit-` namespace (foreign tokens and legacy unscoped names included) and refuses to spawn at `AGENT_KIT_AUDIT_SESSION_HOST_CAP` (default 24: at or above the per-token cap so it cannot shadow it, below the observed 26-session collapse; `0` disables). The refusal reuses the existing soft-fail shape (no new exit codes) and prints a per-token breakdown stating that the workspace-scoped dispose command only reaps this workspace's share. Attribution is unchanged: reap and dispose stay owned-token-only, and the `--dry-run` `audit-sessions:` owned line is byte-identical, with a new `audit-sessions-host:` line beside it. **Lifetime:** the detached tmux/screen spawn is now self-terminating at spawn — wrapped with `timeout`/`gtimeout` when available, else a watchdog subshell signaling only the session's own process group — after `AGENT_KIT_AUDIT_SESSION_MAX_AGE` seconds (default 3600; `0` disables), so a forgotten session never depends on a later launcher run to die (the opt-in `AGENT_KIT_AUDIT_REAP` sweep stays a separate mechanism). Attached and foreign-token sessions are never killed; emulator/Terminal channels stay advisory; the 60s progress gate remains a launch honesty check, not a lifetime. **Visibility:** the sessionStart hook emits one fail-open section with the host-wide detached count and oldest age (only when count >= 1; silent on zero/missing tool/error, 2s timeouts), and Mission Control exposes `system.detachedAuditSessions` (`{count, oldestAgeSeconds}`|null) with a conditional note in the Processes section. Dogfood `cursor_audit_session_cap_host_scope_2026_08_14.md`; ADR `2026-07-30_audits-pty-progress-gate-zombie-policy.md` amended 2026-08-17. +- `guard prompt` detects **hyphenated vendor `sk-` keys** (`sk-ant-api03-…`, `sk-proj-…`) via a new `sk-hyphenated-vendor` pattern. `openai-sk` is `/\bsk-[A-Za-z0-9]{20,}\b/` and its body class excludes `-`, so those keys returned `hits: []` — indistinguishable from a clean prompt, with the fail-open prompt hook as the only net. `maskSecretExcerpt` learned the hyphen in the same change, so the new detection cannot put raw key material into `SecretHit.excerpt`. Fail-open posture is unchanged. Memory `errors/2026-08-14_guard-secrets-scope-vs-claims.md`. +- **Claude Code consumer adapters**, opt-in via `install --claude` (default install output unchanged without the flag): `.claude/commands/.md` thin pointer adapters for every installed `.cursor/commands/*.md` (no command prose copied, overlay-tracked so hand edits survive a re-run), and a SessionStart hook merged idempotently into `.claude/settings.json` so the same session context Cursor gets from `sessionStart` now auto-loads in Claude Code too. `agent-kit hook session-start` gained `--format claude` (plain stdout, live-docs-verified as the shape Claude Code's SessionStart hooks actually consume — no JSON wrapper, no `node -e` unwrapper needed in consumer settings); `--format cursor` stays the byte-identical default. An existing `.claude/settings.json` that fails to parse is never touched — `install` prints the hook entry to paste in by hand instead. ADR `2026-08-13_claude-cli-kit-load-bootstrap.md` amended 2026-08-21 (reverses its own discarded SessionStart-hook alternative; the mechanism was always sanctioned by `2026-07-29_cli-invariants-thin-hook-adapters.md`, only the Claude Code surface was closed). Dogfood `cursor_claude_code_consumer_adapters_prototype_2026_08_16.md`. +- `agent-kit doctor` reports an environment pillar (bin-on-PATH, npm global prefix writability, Node version, shell profile) via a new `env` field in `--json` output (`env.binOnPath`, `env.npmPrefixWritable` + `env.npmPrefix` detail, `env.nodeVersionOk` + `env.nodeVersion`, `env.shellProfile` + `env.shell`) and a human "environment:" summary section. Read-only diagnostics, no writes. +- `agent-kit setup-global`: self-heals a root-owned npm global prefix (relocates to `~/.npm-global`, fixes `PATH` via the detected shell profile with an idempotent marker comment, reinstalls globally, then verifies), with per-step confirmation, `--dry-run`, and a safe non-interactive fallback (prints manual copy-paste steps, mutates nothing). Never `sudo`/`chown`. Invoke via `npx @dadado/agent-kit-cli setup-global` (works before a global install) or bare `agent-kit setup-global` after one. + +### Changed + +- `install`/`init`'s post-install "what now" line is now environment-aware: `printInstallEpilogue()` prints a 3-option guided block (keep using npx / run `setup-global` / manual steps) when a bare `agent-kit` isn't on `PATH` yet, instead of the old static npx-ephemeral hint. When the bin is already on `PATH`, it prints one short positive line instead. +- `classifyInstallError` (`packages/cli/src/utils/terminal.ts`) distinguishes a root-owned npm global-prefix `EACCES` (new `npm-global-eacces` kind) from the generic `eperm` kind, pointing recovery at `npx @dadado/agent-kit-cli setup-global` plus the manual one-liner instead of the generic npm-cache-ownership hint. + +### Fixed + +- `validateProjectRoot` no longer treats any directory with `.git` as a project root. A root that has `.git` **and** two or more immediate child directories that are themselves repositories is a parent-of-repos: refused in non-interactive mode, warned with a default-no `Proceed anyway?` prompt interactively. Previously `npx @dadado/agent-kit-cli install` one level above the intended project wrote L0 into the wrong grain, silently under `--yes`/CI. The scan is one level deep, skips dot-directories and `node_modules`, stops at the threshold, and is capped at 200 entries. Threshold is 2 because a single nested repo is the normal vendored/submodule shape; a child-directory count alone would false-positive on monorepos. A root that already has `.cursor/agent-kit.json` is never re-flagged. `--force-root` still bypasses, and `update` gets the same behaviour. +- `agent-kit init` runs the project-root guard. It is a compat wrapper over `performInstall`, which writes L0, but it never called `confirmProjectRoot` - so `init` in a blank no-git folder or a parent-of-repos root wrote the tree with no confirmation, bypassing the guard entirely. It now confirms the same way `install` does and accepts `--yes` / `--force-root`. +- Root refusals print what to do instead. Validation returns a `recovery` block next to the short `reason`, and `install` / `update` / `init` print it on a hard refusal: `git init` here (recommended), `--force-root`, or yes at the interactive prompt. The empty-folder refusal used to name only `--force-root`, which no consumer doc mentioned. +- Post-install copy stops implying a bare `agent-kit` is on `PATH`. `npx @dadado/agent-kit-cli install` is ephemeral and never links `bin.agent-kit`, so `docs/getting-started.md` now leads with the two honest invocation forms (`npx @dadado/agent-kit-cli `, or `npm i -g @dadado/agent-kit-cli` then bare `agent-kit`) and its command table lists subcommands rather than a PATH binary; `install.md` and the README Mission Control fences follow. `install` and `init` share one `nextStepAfterInstall` / `ONGOING_CLI_INVOCATION` pair, pinned by tests. `npx` stays ephemeral - only the copy changed. +- Guard and secrets prose no longer advertises coverage the invariants do not cast. `guard shell --help` and the `shell-guard.ts` header call it a **git-workflow / protected-branch** deny-list instead of "the destructive deny-list": `SHELL_DENY_RULES` is five git-scoped rules and `rm -rf /`, `chmod -R 777 /`, `dd …` are allowed by design (ADR `2026-07-29_cli-invariants-thin-hook-adapters.md`) — the claim was renamed, no rule was added and `ALLOW_MAIN_PUSH` is untouched. `SECRET_PATTERNS` states its relation to pre-commit `check-secrets` as **one-way** (the prompt scan is a strict superset; the hook greps the single `json-secret-kv` expression under `*.json|*.js|*.ts|*.env`, so `.md` / `.yaml` / `.sh` / dotfiles are scanned by neither lane) instead of claiming alignment. Each sentence is now pinned by a test against the rule set, the pattern ids, and the hook file, so prose cannot drift. + ## [5.4.0] - 2026-08-15 ### Changed diff --git a/README.md b/README.md index bd0cd85..dde8b27 100644 --- a/README.md +++ b/README.md @@ -81,15 +81,19 @@ Short chooser: [Getting started](docs/getting-started.md#which-command-next). Mission Control is a local panel over Mission Kit runtime state. It binds to loopback by default and serves only its own static files. It is a cockpit for one workspace, not a hosted multi-tenant control plane. ```bash -agent-kit dashboard +npx @dadado/agent-kit-cli dashboard +``` +```bash # Opt-in LAN broadcast (token-gated) -agent-kit dashboard-broadcast +npx @dadado/agent-kit-cli dashboard-broadcast ``` +`npx` is ephemeral: it never leaves an `agent-kit` bin on your `PATH`. Run `npm i -g @dadado/agent-kit-cli` once if you prefer the bare `agent-kit dashboard` form. + Open the **printed** URL if the browser did not open (with `PORT` unset, each workspace gets a stable port in `3333–3588`; do not assume `:3333`). In Cursor chat, `/dashboard` starts the same flow. -**If `agent-kit dashboard` says no `dashboard/start.mjs`:** upgrade or pin `@dadado/agent-kit-cli@4.8.2` or newer, or set `MISSION_CONTROL_KIT_ROOT` / `AGENT_KIT_HOME`. Install does not copy `dashboard/` into your app tree. +**If the `dashboard` subcommand says no `dashboard/start.mjs`:** upgrade or pin `@dadado/agent-kit-cli@4.8.2` or newer, or set `MISSION_CONTROL_KIT_ROOT` / `AGENT_KIT_HOME`. Install does not copy `dashboard/` into your app tree. More: [Getting started - Mission Control](docs/getting-started.md#mission-control-production-ship-constraints) · [consumer configuration](docs/consumer-configuration.md). diff --git a/dashboard/dashboard-data.mjs b/dashboard/dashboard-data.mjs index c60d682..4ef3d8b 100644 --- a/dashboard/dashboard-data.mjs +++ b/dashboard/dashboard-data.mjs @@ -111,7 +111,7 @@ const SNAPSHOT = { dashboardDataVersion: "Semantic version of the data model schema", plans: "Active plans from .cursor/plans/*.plan.md with frontmatter parsing", system: - "System metadata: repoRoot, listen port, handoff state, allowlisted config summary, package info, version, name, contextPacks", + "System metadata: repoRoot, listen port, handoff state, allowlisted config summary, package info, version, name, contextPacks, detachedAuditSessions ({count, oldestAgeSeconds}|null; host-wide detached agent-kit-audit-* PTYs)", agents: "Agent definitions from .cursor/agents/*.md", commands: "Slash commands from .cursor/commands/*.md", memory: @@ -641,6 +641,77 @@ try { SNAPSHOT.processes = []; } +// 13b. Detached audit-session visibility (plan phase3-visibility). +// Mirrors the sessionStart hook semantics (packages/cli/src/hooks/session-start.ts): +// whole agent-kit-audit- namespace (any token, legacy unscoped names included), +// detached sessions only; attached sessions are operator work and never counted. +// Fail-open: null when zero sessions or tmux/screen is missing/errors. +SNAPSHOT.system.detachedAuditSessions = null; +try { + if (withinSnapshotBudget(400)) { + const auditAges = []; + const nowEpoch = Math.floor(Date.now() / 1000); + try { + const tmuxOut = execSync( + "tmux list-sessions -F '#{session_name} #{session_attached} #{session_created}'", + { encoding: "utf-8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] }, + ); + for (const line of tmuxOut.split("\n")) { + const m = line.trim().match(/^(\S+)\s+(\d+)\s+(\d+)$/); + if (!m) continue; + if (!m[1].startsWith("agent-kit-audit-")) continue; + if (Number(m[2]) > 0) continue; + const created = Number(m[3]); + auditAges.push(nowEpoch >= created ? nowEpoch - created : -1); + } + } catch { + // tmux missing or no server: fail-open + } + try { + // `screen -ls` exits 1 while successfully listing, so soften the exit code. + const screenOut = execSync("screen -ls || true", { + encoding: "utf-8", + timeout: 2000, + stdio: ["ignore", "pipe", "ignore"], + }); + // The "N Sockets in ." line trails the session list; socket mtime ~ start time. + let sockdir = null; + for (const line of screenOut.split("\n")) { + const dirMatch = line.match(/^\d+\s+Sockets?\s+in\s+(.+)\.$/); + if (dirMatch) sockdir = dirMatch[1]; + } + for (const line of screenOut.split("\n")) { + const m = line.match(/^\s+(\d+)\.(\S+)\s+\((.*)\)/); + if (!m) continue; + if (!m[2].startsWith("agent-kit-audit-")) continue; + // "Detached" has a single t before "ached", so it never matches [Aa]ttached. + if (/[Aa]ttached/.test(m[3])) continue; + let age = -1; + if (sockdir) { + try { + const mtimeMs = statSync(join(sockdir, `${m[1]}.${m[2]}`)).mtimeMs; + if (Date.now() >= mtimeMs) age = Math.floor((Date.now() - mtimeMs) / 1000); + } catch { + // socket not stat-able: age stays unknown, session still counted + } + } + auditAges.push(age); + } + } catch { + // screen missing: fail-open + } + if (auditAges.length > 0) { + const known = auditAges.filter((a) => a >= 0); + SNAPSHOT.system.detachedAuditSessions = { + count: auditAges.length, + oldestAgeSeconds: known.length ? Math.max(...known) : null, + }; + } + } +} catch { + SNAPSHOT.system.detachedAuditSessions = null; +} + // 10. Health checks (originally) const checks = [ { id: "plans", label: "Plans directory", ok: existsSync(plansDir) && SNAPSHOT.plans.length > 0 }, diff --git a/dashboard/dashboard.html b/dashboard/dashboard.html index 646dd0a..d90949b 100644 --- a/dashboard/dashboard.html +++ b/dashboard/dashboard.html @@ -4270,6 +4270,16 @@ return d.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } +// Humanize a duration in seconds (mirrors formatSessionAge in the sessionStart hook). +function formatDuration(seconds) { + const s = Number(seconds); + if (!Number.isFinite(s) || s < 0) return 'unknown'; + if (s >= 86400) return Math.floor(s / 86400) + 'd'; + if (s >= 3600) return Math.floor(s / 3600) + 'h'; + if (s >= 60) return Math.floor(s / 60) + 'm'; + return Math.floor(s) + 's'; +} + // Presentation contract (Phase 2): progress is never an error signal. Any // in-flight percentage (0-99) renders the neutral accent; 100% renders green // (lifecycle completed with total > 0 implies 100 per the Phase 0 contract). @@ -8289,6 +8299,14 @@ // ===== Processes ===== const processes = d.processes || []; + // Detached audit-session visibility (phase3): host-wide agent-kit-audit-* PTYs. + const auditSessions = d.system?.detachedAuditSessions || null; + const auditSessionsNote = auditSessions && auditSessions.count > 0 + ? ` +
+ ${auditSessions.count} detached audit ${auditSessions.count === 1 ? 'session' : 'sessions'}. Host-wide agent-kit-audit-* plan-review ${auditSessions.count === 1 ? 'PTY is' : 'PTYs are'} still alive (${auditSessions.oldestAgeSeconds != null ? `oldest ~${formatDuration(auditSessions.oldestAgeSeconds)}` : 'oldest age unknown'}). Inspect with tmux attach -t <name> / screen -r <name>, or let the audit launcher's session GC dispose of them on the next spawn. +
` + : ''; parts.push(`
@@ -8297,7 +8315,7 @@
Live ps snapshot. Agent chats spawned inside the IDE do not appear here; the Crew monitor on the Overview tab tracks that activity. -
+
${auditSessionsNote} ${processes.length === 0 ? renderEmptyStateCta({ headline: 'All quiet', diff --git a/docs/capability-inventory.md b/docs/capability-inventory.md index 9988fc0..ab2b4f6 100644 --- a/docs/capability-inventory.md +++ b/docs/capability-inventory.md @@ -343,7 +343,7 @@ Enumerated identity literals and publication routes from `scripts/public-sync.ma | `README.md` | L120 | [Bootstrap](bootstrap.md) — Exactly what lands in your project, and why there's no nested folder | allowlist-synced (`README.md` in `scripts/public-sync.manifest`) | | `README.md` | L121 | [Layers](layers-spec.md) — How the base install, optional packs, and your local files layer together | allowlist-synced (`README.md` in `scripts/public-sync.manifest`) | | `README.md` | L122 | [Domain packs](domain-packs.md) — Optional bundles: clean code, DevOps, testing, and more | allowlist-synced (`README.md` in `scripts/public-sync.manifest`) | -| `README.md` | L123 | [Agent Personas](personas-contract.md) — Mode defaults, `agentPersona` config, hygiene boundary ([create / contribute](creating-personas.md)) | allowlist-synced (`README.md` in `scripts/public-sync.manifest`) | +| `README.md` | L123 | [Agent Personas](personas-contract.md) — Mode defaults, `agentPersona` config, hygiene boundary ([create / contribute](docs/creating-p… | allowlist-synced (`README.md` in `scripts/public-sync.manifest`) | | `README.md` | L125 | [Manifest](agent-kit-manifest.md) — The `.cursor/agent-kit.json` file | allowlist-synced (`README.md` in `scripts/public-sync.manifest`) | | `README.md` | L126 | [Contributing](CONTRIBUTING.md) — Working on the kit itself (includes contributor quickstart) | allowlist-synced (`README.md`) | | `README.md` | L127 | [Development](DEVELOPMENT.md) — Factory topology, local CLI, and maintainer workflows | allowlist-synced (`README.md`) | diff --git a/docs/claude-cli-kit-load.md b/docs/claude-cli-kit-load.md index 4f462ca..a6922c4 100644 --- a/docs/claude-cli-kit-load.md +++ b/docs/claude-cli-kit-load.md @@ -2,7 +2,7 @@ Thin markdown adapters so Claude Code CLI (including a session started in Cursor's terminal) can load Agent Kit / Mission Control context without rediscovering the repository. Cursor agents already receive that context via `sessionStart` and always-apply rules. Claude does not. -**Decision:** ADR `.cursor/memory/decisions/2026-08-13_claude-cli-kit-load-bootstrap.md` (thin auto-load `CLAUDE.md` plus manual `/agent-kit` refresh). +**Decision:** ADR `.cursor/memory/decisions/2026-08-13_claude-cli-kit-load-bootstrap.md` (thin auto-load `CLAUDE.md` plus manual `/agent-kit` refresh), amended 2026-08-21 to sanction two further opt-in surfaces: a CLI-emitted SessionStart context hook and generated `.claude/commands/*.md` pointer adapters (see `claude-code-consumer-adapters.plan.md`; delivery details for those surfaces live outside this always-on kit-load pack). This page is the pack contract. The generator in `packages/cli/src/generator/` must emit the snippets below (skip if the target already exists). Docs here are indicative; delivery truth is generator output plus tests. @@ -49,7 +49,7 @@ These lines are part of the generated `CLAUDE.md` so a Claude session does not i - Not multi-IDE generator parity (Windsurf `.windsurfrules` / VS Code instructions). That is Action A7 in [cursor-native-audit.md](cursor-native-audit.md). - Not opt-in **audits** / external plan review (`docs/external-plan-review.md`, `/plan-external-review`). Session kit-load is not that backend. - Not `agent-kit run-plan --backend claude` tick-runner parity (`packages/cli/src/plan-loop/backends.ts`). -- Not a Claude copy of Cursor hooks (`sessionStart`, `preCompact`, shell/edit/prompt guards). Invariants stay in the CLI; Cursor hooks stay thin adapters. +- Not a Claude copy of Cursor hooks beyond the opt-in SessionStart context adapter (`agent-kit hook session-start --format claude`, CLI-owned, fail-open; sanctioned by the 2026-08-21 amendment to ADR `2026-08-13_claude-cli-kit-load-bootstrap.md`, mechanism per ADR `2026-07-29_cli-invariants-thin-hook-adapters.md`). Other Cursor hook types (`preCompact`, shell/edit/prompt guards) stay Cursor-only; `.claude/rules/` mirrors of `.cursor/rules` and `.claude/agents/` generated from the registry stay closed. Invariants stay in the CLI; hooks and adapters both stay thin. ## Generator wiring @@ -85,7 +85,7 @@ Cursor Ask questions is not available in this CLI. When a command requires a cho - Not Action A7 (Windsurf / VS Code generator parity) - Not Claude external plan-review audits (`/plan-external-review`) - Not `--backend claude` plan-loop ticks -- Not a copy of Cursor `sessionStart` / other IDE hooks +- Not a copy of Cursor hooks beyond the opt-in SessionStart context adapter (`agent-kit hook session-start --format claude`); no `.claude/rules/` mirrors, no `.claude/agents/` generated from the registry ``` ## Canonical `.claude/commands/agent-kit.md` @@ -110,6 +110,29 @@ HITL: numbered-list fallback for Ask questions labels. Never `/git-prod` from th Non-goals: not audits / `/plan-external-review`, not `--backend claude` ticks, not A7, not Cursor hook clones. ``` +## Opt-in surfaces: command adapters and the SessionStart hook + +Everything above is always-on kit-load. Two more surfaces are **opt-in only** (`install --claude`; default install output is unchanged without the flag) — see `claude-code-consumer-adapters.plan.md` and the 2026-08-21 amendment on ADR `2026-08-13_claude-cli-kit-load-bootstrap.md` for the full decision trail. + +### `.claude/commands/.md` pointer adapters + +- Generator: `packages/cli/src/generator/claude-command-adapters.ts` (`generateClaudeCommandAdapters`). +- One adapter per installed `.cursor/commands/*.md` — filename + frontmatter `description:` read from the source, body is a fixed "read the SoT and follow it" pointer plus the same HITL adapter rules as `.claude/commands/agent-kit.md`. No command prose is copied. A command the consumer did not install gets no adapter. +- Reserved: the `agent-kit` name is always skipped — it is the kit-load refresh command above, not a command adapter. +- Overlay, not write-once: `.claude/commands/` is a `CONSUMER_OVERLAY_PREFIXES` entry (`lifecycle/overlay.ts`), sharing the managed-hash ledger with `.cursor/agents|skills|commands`. An adapter that still matches its last-managed body refreshes when the source description changes; a hand-edited adapter is preserved, never clobbered. (Write-once was rejected here: unlike `CLAUDE.md`, each adapter mirrors a live source that can change and the installed command set can grow across updates.) + +### SessionStart hook (`.claude/settings.json`) + +- Generator: `packages/cli/src/generator/claude-session-start-hook.ts` (`writeClaudeSessionStartHook`). +- Idempotent JSON merge into `.claude/settings.json`, touching only `hooks.SessionStart` — every other key and hook type in the file is preserved untouched. A marker substring in the generated command (`hook session-start --format claude`) makes the entry findable for refresh-in-place and prevents duplicates on re-run. +- Command line: `. "${CLAUDE_PROJECT_DIR}/.cursor/hooks/agent/resolve-agent-kit.sh" 2>/dev/null && resolve_agent_kit && exec $AGENT_KIT_RESOLVED hook session-start --format claude; printf '%s' ''` — reuses the existing L0 resolver (env override → PATH → `node_modules/.bin/agent-kit` → factory dist fallback) rather than a new `.claude/hooks/` script. `exec` on success replaces the shell process; the trailing `printf` only runs when resolution or exec itself fails, so the command's own exit status is always 0. +- If an existing `.claude/settings.json` cannot be parsed as JSON, nothing is written — `install` prints the hook entry as copy-paste JSON instead (never guess at repairing a file the kit cannot parse). +- Not `.claude/settings.local.json`: that file is routinely auto-created by Claude Code itself on the first permission approval and is conventionally gitignored, so a skip-if-exists write there would silently no-op for most real users and would not ship as a team default. + +### `agent-kit hook session-start --format claude` + +- `packages/cli/src/commands/hook.ts` / `packages/cli/src/generator/format-session-start.ts`. `--format cursor` (default) is byte-identical to today's `{"additional_context": "..."}` JSON; `--format claude` emits the same context as plain stdout text — Claude Code's SessionStart hooks inject plain stdout directly, no JSON wrapper needed, so the consumer command needs no `node -e` unwrapper. Fail-open: any internal error degrades to a short diagnostic in the requested format instead of throwing; exit is always 0. + ## Related - Operator path: [Getting Started](getting-started.md#claude-code-cli-session-kit-load) diff --git a/docs/consumer-configuration.md b/docs/consumer-configuration.md index 49d0f98..92a8142 100644 --- a/docs/consumer-configuration.md +++ b/docs/consumer-configuration.md @@ -36,7 +36,7 @@ Writable via tab = the Mission Control Config tab (More menu) can save the key t | `externalPlanReview.offerOnExhausted` (boolean) | `config.example.json` | `/run-plan` exhaustion Ask | Yes | Yes (Audits) | `{ "externalPlanReview": { "offerOnExhausted": true } }` | | `externalPlanReview.mode` (`paste` \| `autonomous`) | `config.example.json` | `external-review.ts` | Yes | Yes (Audits) | `{ "externalPlanReview": { "mode": "autonomous" } }` | | `externalPlanReview.midBatchAudits` (boolean) | `config.example.json` | `/run-plan-all` mid-batch policy | Yes | Yes (Audits) | `{ "externalPlanReview": { "midBatchAudits": true } }` | -| `externalPlanReview.preflight` (`off` \| `warn` \| `block`) | `config.example.json` | `/run-plan` audits pre-flight | Yes | Yes (Audits) | `{ "externalPlanReview": { "preflight": "warn" } }` | +| `externalPlanReview.preflight` (`off` \| `warn` \| `block`) | `config.example.json` | `/run-plan` audits pre-flight | Yes | Yes (Audits) | `{ "externalPlanReview": { "preflight": "warn" } }` — also flags the unsatisfiable combo `enabled: true` + pinned `backend: "claude"` in the Claude CLI lane (owed by construction); outs are `backend: "auto"` / `"cursor"` / `"cloud"`. | | `externalPlanReview.cloudAgent` (`repoUrl`, `startingRef`, `model`) | `config.example.json` | `plan-external-review.sh` when `backend: "cloud"` | Yes | No (edit config.json) | `{ "externalPlanReview": { "cloudAgent": { "repoUrl": null, "startingRef": null, "model": "composer-2.5" } } }` — Cloud Agents clone `repoUrl` onto a Cursor VM, so this backend reviews only **pushed** state; the launcher soft-fails with a tip when `HEAD` is not an ancestor of the upstream. For a **working-tree** review use `backend: "cursor"` instead. `repoUrl` defaults to the `origin` remote, `startingRef` to the current branch. `autoCreatePR` and `workOnCurrentBranch` are hard-off and deliberately not configurable (findings-only). ADR `2026-08-14_cursor-cloud-agents-sdk-audits-backend.md`. | | `agentPersona.default` (`autopilot` \| `night-shift` \| `ghost-runner`) | `config.example.json`, `registry/personas/core/index.json` | `packages/cli/src/plan-loop/persona-banners.ts`, SessionStart hook, chat chrome | Yes | Yes (Agent Personas) | `{ "agentPersona": { "default": "night-shift" } }` | | `agentPersona.modes["continue-plan" \| "run-plan" \| "cli-run-plan"]` | `config.example.json` | `persona-banners.ts`, chat chrome | Yes | Yes (Agent Personas) | `{ "agentPersona": { "modes": { "run-plan": "night-shift" } } }` | diff --git a/docs/external-plan-review.md b/docs/external-plan-review.md index e7e1385..5438514 100644 --- a/docs/external-plan-review.md +++ b/docs/external-plan-review.md @@ -140,7 +140,7 @@ Mission Control **Flight Log** shows HANDOFF Gaps (**NOW** + **Earlier** history | `offerOnExhausted` | When `enabled` is false, allow exhaustion Ask until Always or Not now (default: true) | | `mode` | Audits arming path: `"paste"` (legacy clipboard/paste into Cursor Terminal) or `"autonomous"` (background/inspectable PTY auto-launch when enabled). Missing key keeps paste-compatible behavior for existing installs. Greenfield example may show `"autonomous"`. | | `midBatchAudits` | When true, `/run-plan-all` runs full audits mid-queue (after each plan) and at queue end via the launcher `--batch` path. Prefer true under `mode: "autonomous"`. Missing or false preserves queue-end / owed-ledger behavior for paste installs. | -| `preflight` | Audits pre-flight on plan-run commands (`/continue-plan`, `/run-plan`, `/run-plan-all`, `/hotfix`, …): `"off"` (default when missing), `"warn"` (surface once), or `"block"` (arm or stop until owed audits are launched or explicitly deferred). Enforced in L0 (`continue-plan`, `run-plan`, `run-plan-all`, `hotfix`, HITL gate table). | +| `preflight` | Audits pre-flight on plan-run commands (`/continue-plan`, `/run-plan`, `/run-plan-all`, `/hotfix`, …): `"off"` (default when missing), `"warn"` (surface once), or `"block"` (arm or stop until owed audits are launched or explicitly deferred). Also covers the unsatisfiable-config check: `enabled: true` with `backend` pinned `"claude"` in the Claude CLI lane can only end owed (see [Troubleshooting](#troubleshooting)). Enforced in L0 (`continue-plan`, `run-plan`, `run-plan-all`, `hotfix`, HITL gate table). | ### Throughput vs coverage (operator knobs) @@ -169,7 +169,11 @@ The launcher starts Claude with `--permission-mode auto` and `--model` from `rev **Orchestrator pile preflight:** before a queue confirm Ask or an audit arm, `/run-plan` and `/run-plan-all` count detached workspace-owned sessions (launcher `--reap-audit-sessions --dry-run` or arm `--dry-run` line `audit-sessions: N detached owned`). At warn, print the dispose command (or offer reap) in the Ask/preflight body. At cap, do not spawn; surface the cap in the orchestrator Ask/preflight, not only launcher stderr. -**Audit-session cap and dispose policy:** kit-owned audit sessions are named `agent-kit-audit--`, where `` is an 8-hex workspace token derived from the repository root. Cap, warn, count, and opt-in reap only consider sessions owned by **this** workspace (strict pattern `agent-kit-audit-<8hex>-` matching the local token). Legacy unscoped `agent-kit-audit-` names and other workspaces' tokens are never counted or disposed by this process (quit them manually if needed). Before an autonomous spawn the launcher counts existing **detached** workspace-owned sessions; attached sessions are operator work in progress and are never counted as pressure or touched by any flag. At or above the warn threshold (`AGENT_KIT_AUDIT_SESSION_WARN`, default 5, `0` disables) the launcher prints the count plus the dispose command and continues. At or above the hard cap (`AGENT_KIT_AUDIT_SESSION_CAP`, default 20, `0` disables) it **refuses to spawn**: it prints the count, the cap, the dispose instructions, and the paste fallback, then soft-fails (exit `4` with `--wait-monitor`, tip + exit `0` otherwise). A refusal never spawns and never enters the monitor wait, so no audit starts and the Field Report stays owed. Detached is the normal steady state of a healthy autonomous arm, so warn/cap measure concurrency, not staleness; use `--reap-audit-sessions` for opt-in cleanup of sessions past the age floor. Reaping is opt-in via `--reap-audit-sessions` or `AGENT_KIT_AUDIT_REAP=1`: it disposes only detached workspace-owned sessions whose age is at or above `AGENT_KIT_AUDIT_REAP_MIN_AGE` (default 3600 seconds) and prints one line per disposal and per skip (attached, too young, age unknown, not owned). An age that cannot be determined counts as too young, so the safe default is to keep the session. Age comes from the multiplexer (screen socket mtime, tmux `session_created`), so a session with recent multiplexer activity can read younger than its wall-clock start; the bias is always toward keeping sessions alive. `--dry-run` previews the reap without killing anything and adds `audit-sessions: N detached owned (warn: X, cap: Y)`, `audit-workspace-token`, `audit-session-prefix`, `reap: yes|no (min-age: Zs; owned prefix only)`, and the resulting gate verdict to the dry-run report. A non-integer value for any of the three thresholds prints a tip and falls back to the default. The post-spawn progress gate above stays responsible for disposing the single session a run just spawned; this policy covers the pile left by earlier arms. No new exit code, and the CI/headless `--print` path is unaffected. ADR: `.cursor/memory/decisions/2026-07-30_audits-pty-progress-gate-zombie-policy.md`. +**Audit-session cap and dispose policy:** kit-owned audit sessions are named `agent-kit-audit--`, where `` is an 8-hex workspace token derived from the repository root. Cap, warn, count, and opt-in reap only consider sessions owned by **this** workspace (strict pattern `agent-kit-audit-<8hex>-` matching the local token). Legacy unscoped `agent-kit-audit-` names and other workspaces' tokens are never counted toward the per-token cap and never disposed by this process (quit them manually if needed); they do count toward the host-global ceiling below, which is count-only. Before an autonomous spawn the launcher counts existing **detached** workspace-owned sessions; attached sessions are operator work in progress and are never counted as pressure or touched by any flag. At or above the warn threshold (`AGENT_KIT_AUDIT_SESSION_WARN`, default 5, `0` disables) the launcher prints the count plus the dispose command and continues. At or above the hard cap (`AGENT_KIT_AUDIT_SESSION_CAP`, default 20, `0` disables) it **refuses to spawn**: it prints the count, the cap, the dispose instructions, and the paste fallback, then soft-fails (exit `4` with `--wait-monitor`, tip + exit `0` otherwise). A refusal never spawns and never enters the monitor wait, so no audit starts and the Field Report stays owed. Alongside the per-token cap there is a **host-global ceiling**: the launcher also counts detached sessions across the **whole** `agent-kit-audit-` namespace — foreign workspace tokens and legacy unscoped names included — and refuses to spawn at or above `AGENT_KIT_AUDIT_SESSION_HOST_CAP` (default 24, `0` disables). **Host-wide counting vs per-token attribution:** the ceiling *counts* every detached session on the host as pressure (the exhausted resource is the host, not one workspace), but disposal attribution is unchanged — reap and dispose stay owned-token-only, and the host-cap refusal prints a per-token breakdown precisely so the workspace-scoped dispose command is never narrated as full-host cleanup. The host-cap refusal reuses the same soft-fail shape as the per-token cap (exit `4` with `--wait-monitor`, tip + exit `0` otherwise; no new exit codes). Detached is the normal steady state of a healthy autonomous arm, so warn/cap measure concurrency, not staleness; use `--reap-audit-sessions` for opt-in cleanup of sessions past the age floor. Reaping is opt-in via `--reap-audit-sessions` or `AGENT_KIT_AUDIT_REAP=1`: it disposes only detached workspace-owned sessions whose age is at or above `AGENT_KIT_AUDIT_REAP_MIN_AGE` (default 3600 seconds) and prints one line per disposal and per skip (attached, too young, age unknown, not owned). An age that cannot be determined counts as too young, so the safe default is to keep the session. Age comes from the multiplexer (screen socket mtime, tmux `session_created`), so a session with recent multiplexer activity can read younger than its wall-clock start; the bias is always toward keeping sessions alive. `--dry-run` previews the reap without killing anything and adds `audit-sessions: N detached owned (warn: X, cap: Y)` (byte-identical to before the host ceiling), a new `audit-sessions-host: N detached namespace-wide (host-cap: Y)` line beside it, `audit-workspace-token`, `audit-session-prefix`, `reap: yes|no (min-age: Zs; owned prefix only)`, and the resulting gate verdict to the dry-run report. A non-integer value for any of the thresholds prints a tip and falls back to the default. The post-spawn progress gate above stays responsible for disposing the single session a run just spawned; this policy covers the pile left by earlier arms. No new exit code, and the CI/headless `--print` path is unaffected. ADR: `.cursor/memory/decisions/2026-07-30_audits-pty-progress-gate-zombie-policy.md` (amended 2026-08-17: host ceiling, bounded lifetime, visibility). + +**Bounded session lifetime (self-terminating at spawn):** the detached tmux/screen spawn terminates itself after `AGENT_KIT_AUDIT_SESSION_MAX_AGE` seconds (default 3600, `0` disables). The launcher wraps the spawned command with `timeout`/`gtimeout` when available, else a watchdog subshell that signals only the session's own process group — so the lifetime never depends on a later launcher run, and attached sessions and foreign-token sessions are never killed. This is the deliberate distinction from `AGENT_KIT_AUDIT_REAP`: the reap is an opt-in sweep that only helps if a later invocation happens; the lifetime is armed on the session itself at spawn. Channels without a controllable spawn wrapper (macOS Terminal `do script`, Linux/Windows emulators) stay advisory, and the 60s progress gate above remains a launch honesty check, not a lifetime. **Visibility:** the sessionStart hook surfaces one fail-open section with the host-wide detached `agent-kit-audit-*` count and oldest age when the count is >= 1 (silent otherwise), and Mission Control exposes `system.detachedAuditSessions` with a conditional note in the Processes section — informational surfaces only, not controls. + +**Wait-state hygiene (expire-on-contact, `--gc-wait-state`):** liveness of `.cursor/context/audit-wait/.json` is `deadline` vs wall clock, **not** `status: "armed"` — a file left `armed` past its `deadline` is a dead arm, not live leftover budget. Any arm or poll that touches a slug first expires that slug's own dead file in place: `status` becomes `timeout` and `remainingBudgetSeconds` becomes `0`, while `armEpoch`, `deadline`, `backend`, `implementerModel`, `reviewerModel`, and any `cloudAgentId` / `cloudRunId` are preserved. This is automatic, always on, and never invents a status outside the ADR enum (`armed` | `ready` | `timeout` | `soft-fail`). `--gc-wait-state` (or `AGENT_KIT_AUDIT_GC_WAIT_STATE=1`) is the opt-in sweep across every wait-state file: it expires each dead armed file, skips a live armed file, skips an already-terminal file, and prints one line per decision. With no plan argument it sweeps and exits `0` without starting an audit; `--dry-run` previews and writes nothing. Terminal-ready still clears the file (it is not merely expired). Dogfood: `.cursor/memory/errors/2026-08-14_audit-owed-ledger-no-close-path.md`. ADR: `.cursor/memory/decisions/2026-08-13_audits-atomic-wait-reviewer-fallback.md`. **Mid-batch consume:** with `midBatchAudits: true`, arm **one** background `--wait-monitor` per plan (or one `--batch` + wait_all). Do not fan out N sessions without wait. Mid-queue skips triage Ask; queue-end triage uses an explicit path list of fresh monitors. @@ -183,6 +187,7 @@ The launcher starts Claude with `--permission-mode auto` and `--model` from `rev bash -n .cursor/scripts/plan-external-review.sh AGENT_KIT_AUDIT_SESSION_CAP=1 .cursor/scripts/plan-external-review.sh --force --autonomous --wait-monitor --dry-run my-plan.plan.md .cursor/scripts/plan-external-review.sh --reap-audit-sessions --dry-run +.cursor/scripts/plan-external-review.sh --gc-wait-state --dry-run # Stale pre-arm file → exit 3; touch/rewrite after arm or add freshness sentinel → exit 0 ``` @@ -269,6 +274,12 @@ Do not treat Write residuals as the uncaveated happy path when closeout depth is - Fix: chat arm with `--force --autonomous --wait-monitor`, AwaitShell until exit `0|3|4`, then on `0` run `/plan-review-triage` Ask in the same session. Do not invent a finished review on timeout/soft-fail. Do not defer via Final HANDOFF "after monitor lands". - Dogfood: `.cursor/memory/errors/2026-07-27_audits-autonomous-launch-manual-done-continuation.md`. ADR: `decisions/2026-07-27_audits-post-spawn-monitor-watch-continue.md`. +**Owed with a later genuine monitor (no close path):** + +- Symptom: an arm hit `waitSliceSeconds`, exited `3`, reviewed nothing, and the leftover wait budget has since expired (`.cursor/context/audit-wait/.json` is `timeout`/`soft-fail`, or `armed` with `now >= deadline`). A later pass then wrote a genuine `.cursor/memory/plan-monitor-.md` for the same plan. Exit-3 honesty correctly forbids upgrading that `3` to success, so the Field Report stays owed with no legitimate route to reviewed, and re-arming would spawn a duplicate reviewer against already-merged work. +- Fix: `/run-plan` and `/run-plan-all` offer an owed-close HITL once the wait-state is terminal-and-dead and a genuine monitor exists on disk — `Adopt existing monitor` (routes into `/plan-review-triage `; closes as reviewed-by-adoption) or `Ack owed without review` (closes as acked, recorded unreviewed). Never automatic. The earlier exit `3` stays `3`; the close is a separate, later event, never narrated as "the audit that timed out completed." +- Dogfood: `.cursor/memory/errors/2026-08-14_audit-owed-ledger-no-close-path.md`. ADR: `decisions/2026-08-13_audits-atomic-wait-reviewer-fallback.md` point 5. + **Stale pre-arm monitor false-ready:** - Symptom: `--wait-monitor` prints `wait-monitor created` in seconds because `plan-monitor-.md` already existed; orchestrator skips triage Ask or continues as if a new review finished. @@ -284,7 +295,7 @@ Do not treat Write residuals as the uncaveated happy path when closeout depth is **Accumulated detached audit sessions:** - Symptom: repeated autonomous arms leave `agent-kit-audit--` sessions detached (each one a PTY that never produced a monitor), and later arms keep adding to the pile. -- Policy (preventive, not cleanup of a known pile): the launcher counts detached **workspace-owned** sessions before every autonomous spawn, warns at or above `AGENT_KIT_AUDIT_SESSION_WARN` (default 5), and refuses to spawn at or above `AGENT_KIT_AUDIT_SESSION_CAP` (default 20) instead of adding one more. Attached sessions are operator work in progress and are never counted or disposed. Foreign-workspace and legacy unscoped `agent-kit-audit-` names are out of scope for cap/reap. +- Policy (preventive, not cleanup of a known pile): the launcher counts detached **workspace-owned** sessions before every autonomous spawn, warns at or above `AGENT_KIT_AUDIT_SESSION_WARN` (default 5), and refuses to spawn at or above `AGENT_KIT_AUDIT_SESSION_CAP` (default 20) instead of adding one more. It additionally counts the **whole** detached `agent-kit-audit-*` namespace (foreign tokens and legacy unscoped names included) and refuses at or above `AGENT_KIT_AUDIT_SESSION_HOST_CAP` (default 24), printing a per-token breakdown — the workspace-scoped dispose command only reaps this workspace's share. New spawns also self-terminate after `AGENT_KIT_AUDIT_SESSION_MAX_AGE` seconds (default 3600), so a fresh pile ages out on its own. Attached sessions are operator work in progress and are never counted or disposed. Foreign-workspace and legacy unscoped `agent-kit-audit-` names count toward the host ceiling only and are never reaped or disposed by this process. - Backlog: a liveness probe that checks whether each counted detached session is still alive (multiplexer session present and/or scrollback growing) before refusing at the cap would distinguish healthy concurrent arms from a zombie pile. The current count is by session name only, so a host running many genuine concurrent reviews still triggers the cap and must use `--reap-audit-sessions` or raise the cap to continue. - Inspect: `screen -ls` (or `tmux ls`). - Dispose (opt-in, detached kit-owned sessions past the age floor only): @@ -299,6 +310,12 @@ AGENT_KIT_AUDIT_REAP_MIN_AGE=0 .cursor/scripts/plan-external-review.sh --reap-au - Manual, one session at a time: `screen -S -X quit` or `tmux kill-session -t `. Never a wildcard kill or `pkill` on the namespace. - ADR: `.cursor/memory/decisions/2026-07-30_audits-pty-progress-gate-zombie-policy.md`. +**Stale `armed` wait-state read as live budget:** + +- Symptom: `.cursor/context/audit-wait/.json` still says `"status": "armed"` hours after its `deadline`, and a later wait-resume preflight treats it as live leftover budget (or an operator reads the pile as "audits still running"). Nothing else ever wrote the expiry: the ADR clears the file on terminal ready or re-arm only. +- Fix: liveness is `deadline` vs wall clock. Any arm or poll expires the slug's own dead file on contact (`status` -> `timeout`, `remainingBudgetSeconds` -> `0`, all other fields preserved). Sweep every slug with `.cursor/scripts/plan-external-review.sh --gc-wait-state` (`--dry-run` previews). An expired arm is still **owed**, never reviewed - see the owed-close entry above when a genuine later monitor exists. +- Dogfood: `.cursor/memory/errors/2026-08-14_audit-owed-ledger-no-close-path.md`. ADR: `decisions/2026-08-13_audits-atomic-wait-reviewer-fallback.md` (wait-state file, normative). + **Claude CLI not found:** - `backend: "auto"` uses Cursor Agent when Claude is missing or `AGENT_KIT_AUDIT_CLAUDE_QUOTA_EMPTY` is set. That is not the Cursor tick API/usage-limit hard-stop. @@ -306,6 +323,12 @@ AGENT_KIT_AUDIT_REAP_MIN_AGE=0 .cursor/scripts/plan-external-review.sh --reap-au - Same-family implementer and reviewer is an honest skip (including Auto/Auto), not a silent self-review. - Pinned `backend: "cloud"` needs `curl`, `node`, and a non-empty `CURSOR_API_KEY`; without them it tips and skips on the same contract. An SSH-alias `origin` cannot be resolved to a GitHub URL — set `cloudAgent.repoUrl` explicitly rather than letting the launcher guess. +**Unsatisfiable audit config (owed by construction):** + +- Symptom: `externalPlanReview` is `{ "enabled": true, "backend": "claude", "mode": "autonomous", "midBatchAudits": true, "preflight": "warn" }` (or similar) in the Claude CLI lane, where the implementer is also Claude. Every arm hits the same-model honest skip above, so every completed plan ends Field Report owed — silently, with no error, just a growing owed pile. `midBatchAudits: true` multiplies this: every plan in a `/run-plan-all` queue arms, skips, and owes. +- Fix: `preflight: "warn"` or `"block"` catches this before the run starts (`/run-plan` Tick contract step 1, Read state; `/run-plan-all` before the queue confirm Ask and before each mid-queue advance). Re-point `backend` at one of the three satisfiable outs — `"auto"` (cascades past the same-family refusal to Cursor Agent), `"cursor"`, or `"cloud"` — the preflight surfaces the fix, the operator edits `config.json`. This is distinct from "Claude CLI not found" above: that entry covers a pinned `claude` reviewer tipping/skipping per-arm; this entry covers the config itself being unsatisfiable before any arm runs. +- Dogfood: `.cursor/memory/errors/2026-08-14_audit-owed-ledger-no-close-path.md`. ADR: `decisions/2026-08-13_audits-atomic-wait-reviewer-fallback.md` point 4. + ## Cloud Agents backend (`backend: "cloud"`) An opt-in pin that runs the review on **Cursor Cloud Agents** over REST (`https://api.cursor.com`) instead of a local binary. ADR: `.cursor/memory/decisions/2026-08-14_cursor-cloud-agents-sdk-audits-backend.md`. diff --git a/docs/getting-started.md b/docs/getting-started.md index 2c077c0..2c32e1a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -30,11 +30,15 @@ npx -y @dadado/agent-kit-cli install --yes | `EPERM` / `EACCES` on npm cache | User-level cache ownership drift | `npx --cache .npm-cache @dadado/agent-kit-cli install` or `npm cache clean --force` | | Exit 255 (no output) | `npx` prompted for confirmation in a non-TTY environment | Use `npx -y @dadado/agent-kit-cli install` for the `npx` prompt; add `--yes` or set `AGENT_KIT_YES=1` for the CLI root prompt | | `403 Forbidden` from registry | Auth policy or private scope | `npm login`, check `.npmrc`, or use Port B fallback | +| `EACCES` on `npm i -g @dadado/agent-kit-cli` | Root-owned npm global prefix (e.g. `/usr/local/lib/node_modules`) | Check first with `agent-kit doctor --json` (`env.npmPrefixWritable`), then run `npx @dadado/agent-kit-cli setup-global` — it relocates the prefix to `~/.npm-global`, fixes `PATH`, and reinstalls | +| `command not found` after install (or for a bare `agent-kit`) | `npx` is ephemeral; a bare `agent-kit` isn't on `PATH` yet | The install/init epilogue prints this automatically with 3 options; run `npx @dadado/agent-kit-cli setup-global` to fix `PATH`, or keep using `npx @dadado/agent-kit-cli ` | That's the whole install for kit L0. It drops a small set of rules and slash commands into `.cursor/`, a git routine into `autogit/`, and a manifest (`.cursor/agent-kit.json`) that records what was installed so the kit can update itself later without touching your work. Mission Control's `dashboard/` server is **not** copied into your project; the panel runs from the CLI package (4.8.2 onward) or from an agent-kit checkout. See [Mission Control production-ship constraints](#mission-control-production-ship-constraints). **Multi-workspace safety:** the CLI confirms the absolute project root before writing any files (interactive prompt; `--yes` skips the prompt). Each project gets its own `.cursor/` tree and overlay ledger. The shared registry cache (`~/.cache/agent-kit/registry/`) uses a directory lock so parallel installs on the same machine cannot corrupt it. +Before that confirmation, the CLI also runs a **project root guard** - see [Project root guard](#project-root-guard) below. + Want a few extra bundles up front? Add packs (clean code, context tools, and more - see [domain packs](domain-packs.md)): ```bash @@ -58,35 +62,89 @@ npx @dadado/agent-kit-cli doctor --json npx @dadado/agent-kit-cli doctor --fix-safe ``` +`doctor` also reports an environment pillar: `env.binOnPath`, `env.npmPrefixWritable` (with prefix detail), `env.nodeVersionOk`, and `env.shellProfile` in `--json`, plus a human "environment:" summary — read-only diagnostics, no writes. See the [`setup-global`](#how-to-invoke-them) fix for a root-owned npm prefix. + +### Project root guard + +`install` and `update` check that the target directory looks like a project root before writing anything. Three shapes are refused: + +| Refused shape | Why | +|---------------|-----| +| `/` or your home directory | Never a project root | +| Neither `.git` nor `.cursor/agent-kit.json` | Nothing marks it as a project yet | +| Has `.git` **and** two or more immediate child directories that are themselves repositories | Looks like a parent-of-repos folder, so L0 would land one level above the project you meant | + +In an interactive terminal a refusal becomes a warning plus a `Proceed anyway?` prompt that defaults to **no**. In non-interactive mode (`--yes`, `CI=true`, `AGENT_KIT_YES=1`, piped stdin) it is final: exit 1, nothing written. + +`--force-root` is the explicit bypass: + +```bash +npx @dadado/agent-kit-cli install --force-root +``` + +It skips every root check, so confirm the absolute path yourself before using it. `install`, `update`, and `init` all take the same flag. A folder that already has `.cursor/agent-kit.json` is never flagged as a parent-of-repos - you confirmed that root on the first install. + +### Starting from an empty folder + +A brand-new folder has no `.git` and no manifest, so the guard above stops there by design - L0 in the wrong directory is worse than a refusal. The CLI prints the three sanctioned ways forward; pick one: + +| Path | Command | When | +|------|---------|------| +| Initialize Git first (**recommended**) | `git init` then re-run the install | You want readiness pillars and the `/git-staging` → `/git-prod` flow to work. Local-only Git is enough - no remote required | +| Install without Git | `npx @dadado/agent-kit-cli install --force-root` | You are not ready to decide on Git yet | +| Say yes at the prompt | Run the install in an interactive terminal and answer **yes** to `Proceed anyway?` | Same as `--force-root`, just interactive. The prompt defaults to **no** | + +Either of the last two installs L0 into a folder with no Git. That is supported, not a broken state: `/agent-kit-onboard` owns the Git pillar and will offer `Keep repository without Git` / `Initialize local Git` when you run it. The CLI never runs `git init` for you. + +`--yes` / `CI` / a piped stdin removes the interactive option, so automation needs `git init` or `--force-root`. + ### After install checklist (personal path) Keep this path light. No extra runtime packages beyond the CLI (`@clack/prompts`, `citty`, `kolorist` only). TTY chrome (spinners, tips) is ANSI in the CLI package; do not add `ora`, `figlet`, `chalk`, or `ink`. -1. **Node.js 20+** - required for `npx` / `agent-kit` CLI (`engines` in package manifests). +1. **Node.js 20+** - required for the CLI (`engines` in package manifests). `npx @dadado/agent-kit-cli ` needs nothing else installed; `npm i -g @dadado/agent-kit-cli` is what puts a bare `agent-kit` on your `PATH`. 2. **Git** - recommended so readiness pillars and `/git-staging` → `/git-prod` work; local-only Git is valid. 3. **Install** - `npx @dadado/agent-kit-cli install` (or Port B via `install.md`). 4. **Onboard** - `/agent-kit-onboard` until every essential readiness check is ready (non-essentials may defer with a recovery action). 5. **Kit commands** - e.g. `/start-project` in the consumer project. -6. **Mission Control panel (optional)** - `/dashboard`, `npm run dashboard`, or `agent-kit dashboard`. Consumer L0 does not copy `dashboard/` into the project. `agent-kit dashboard` resolves `dashboard/start.mjs` from the installed package (4.8.2 onward); on older pins use a kit checkout or env/sibling discovery. Loopback only (`127.0.0.1`) by default. Opt-in LAN: `agent-kit dashboard-broadcast` / `npm run dashboard:broadcast` (token-gated) binds the same per-workspace port allocation as `/dashboard` (`3333-3588` unless `PORT` is set), so it starts **beside** an already-running Mission Control - this workspace's loopback panel, another workspace, or an unidentified listener is skipped and left running, never killed. It prints a Mission Kit **Share** URL (`https://missionkit.io/mc/open.html#…`, BYO HTTPS via `MISSION_CONTROL_SHARE_BASE`; set `off` for LAN-only). The Share URL embeds the live token (same secret handling); soft TTL is advisory (`MISSION_CONTROL_SHARE_TTL_SEC`, `0` = never). Still requires trusted-LAN reachability; not a WAN relay. The slash `/dashboard-broadcast` ships in the **factory** checkout and CLI docs only (not an L0 consumer artifact); consumers use the CLI/npm entrypoints above. CLI/OS opens use one preferred browser (`missionControl.preferredBrowser`, `MISSION_CONTROL_PREFERRED_BROWSER`, or `--browser`; platform-specific **name**, not a path) or the OS default; slash `/dashboard` opens via IDE browser MCP only (not multi-browser). Posture: [Mission Control production-ship constraints](#mission-control-production-ship-constraints). +6. **Mission Control panel (optional)** - `/dashboard`, `npm run dashboard`, or `npx @dadado/agent-kit-cli dashboard` (bare `agent-kit dashboard` only after a global install). Consumer L0 does not copy `dashboard/` into the project. The `dashboard` subcommand resolves `dashboard/start.mjs` from the installed package (4.8.2 onward); on older pins use a kit checkout or env/sibling discovery. Loopback only (`127.0.0.1`) by default. Opt-in LAN: `npx @dadado/agent-kit-cli dashboard-broadcast` / `npm run dashboard:broadcast` (token-gated) binds the same per-workspace port allocation as `/dashboard` (`3333-3588` unless `PORT` is set), so it starts **beside** an already-running Mission Control - this workspace's loopback panel, another workspace, or an unidentified listener is skipped and left running, never killed. It prints a Mission Kit **Share** URL (`https://missionkit.io/mc/open.html#…`, BYO HTTPS via `MISSION_CONTROL_SHARE_BASE`; set `off` for LAN-only). The Share URL embeds the live token (same secret handling); soft TTL is advisory (`MISSION_CONTROL_SHARE_TTL_SEC`, `0` = never). Still requires trusted-LAN reachability; not a WAN relay. The slash `/dashboard-broadcast` ships in the **factory** checkout and CLI docs only (not an L0 consumer artifact); consumers use the CLI/npm entrypoints above. CLI/OS opens use one preferred browser (`missionControl.preferredBrowser`, `MISSION_CONTROL_PREFERRED_BROWSER`, or `--browser`; platform-specific **name**, not a path) or the OS default; slash `/dashboard` opens via IDE browser MCP only (not multi-browser). Posture: [Mission Control production-ship constraints](#mission-control-production-ship-constraints). ## The commands you get -| Command | What it does | -|---------|-------------| -| `agent-kit install [profile]` | Install the base kit, run readiness, apply safe local fixes | -| `agent-kit init` | Compatibility entry that runs the same install and readiness workflow | -| `agent-kit doctor` | Print or repair repository readiness (`--json`, `--fix-safe`) | -| `agent-kit add ` | Add one skill or pack later | -| `agent-kit status` | Show install state, readiness summary, and profile origin | -| `agent-kit update --check` | Notify-only version compare vs public tags (no L0 writes) | -| `agent-kit cursor-awareness --check` | Opt-in advisory: Cursor changelog vs native-audit inventory (no apply) | -| `agent-kit update` | Explicit apply: pull latest rules/commands; leaves your own files alone | -| `agent-kit diff` | Show what changed between what you have and the latest | -| `agent-kit contribute` | Send an improvement you made locally back upstream | -| `agent-kit handoff` | Save your progress to `.cursor/HANDOFF.md` | -| `agent-kit scan` | Just scan the project, don't install | - -Optional: `agent-kit add mission-kit-comms` drafts recap/release/contributor copy. It does **not** post. Ask before any public network. Guide: [comms.md](comms.md). +### How to invoke them + +`npx @dadado/agent-kit-cli install` is **ephemeral**: `npx` downloads the package, runs it, and leaves nothing on your `PATH`. After it finishes, a bare `agent-kit …` is `command not found` unless you install the package globally. Two honest forms: + +```bash +# Keep using npx (nothing installed globally) +npx @dadado/agent-kit-cli status + +# Or install the bin once, then call it bare +npm i -g @dadado/agent-kit-cli +agent-kit status +``` + +Blocked on the global install by a root-owned npm prefix (`EACCES`)? Run `npx @dadado/agent-kit-cli setup-global` — it self-heals the prefix, fixes `PATH`, and reinstalls; see the troubleshooting table above. `install`/`init` also print this automatically as a 3-option epilogue whenever a bare `agent-kit` isn't on `PATH` yet. + +The table below lists **subcommands**. Prefix each one with `npx @dadado/agent-kit-cli` (or with `agent-kit` after a global install). Slash commands (`/agent-kit-onboard`, `/start-project`, …) are IDE chat commands and need neither. + +| Subcommand | What it does | +|------------|-------------| +| `install [profile]` | Install the base kit, run readiness, apply safe local fixes | +| `init` | Compatibility entry that runs the same install and readiness workflow | +| `doctor` | Print or repair repository readiness (`--json`, `--fix-safe`); `--json` includes an `env` pillar (bin-on-PATH, npm prefix writability, Node version, shell profile) | +| `setup-global` | Self-heal a root-owned npm global prefix: relocate to `~/.npm-global`, fix `PATH`, reinstall globally (`--dry-run`, `--yes`/`-y`) | +| `add ` | Add one skill or pack later | +| `status` | Show install state, readiness summary, and profile origin | +| `update --check` | Notify-only version compare vs public tags (no L0 writes) | +| `cursor-awareness --check` | Opt-in advisory: Cursor changelog vs native-audit inventory (no apply) | +| `update` | Explicit apply: pull latest rules/commands; leaves your own files alone | +| `diff` | Show what changed between what you have and the latest | +| `contribute` | Send an improvement you made locally back upstream | +| `handoff` | Save your progress to `.cursor/HANDOFF.md` | +| `scan` | Just scan the project, don't install | + +Optional: `add mission-kit-comms` drafts recap/release/contributor copy. It does **not** post. Ask before any public network. Guide: [comms.md](comms.md). ## A normal day @@ -147,8 +205,8 @@ Details: [external plan review](external-plan-review.md). Routing ADR: `2026-08- ### Keeping Mission Kit current (consumers) - **Opt-in check:** set `updateCheck.enabled: true` in `.cursor/context/config.json` (Mission Control Config can toggle it). SessionStart may then nudge when a newer public release exists; interval is `updateCheck.intervalDays` (default 7). -- **Manual check:** `agent-kit update --check --json`. -- **Apply:** run `/update` and confirm with Ask questions (or an explicit terminal `agent-kit update`). Never silent L0 overwrite; `updateApply.auto` defaults to `false`. +- **Manual check:** `npx @dadado/agent-kit-cli update --check --json`. +- **Apply:** run `/update` and confirm with Ask questions (or an explicit terminal `npx @dadado/agent-kit-cli update`). Never silent L0 overwrite; `updateApply.auto` defaults to `false`. - Not the same as public sync (factory publish) or remote-cache refresh on resolve. ### Agent Personas @@ -200,6 +258,16 @@ HITL in Claude Code is a numbered-list fallback (Cursor Ask questions is not ava Pack contract: [claude-cli-kit-load.md](claude-cli-kit-load.md). This is **not** audits, **not** `agent-kit run-plan --backend claude`, and **not** Action A7 (Windsurf / VS Code generators). +#### Opt-in: slash commands and auto-loaded session context (`--claude`) + +The session kit-load above is always on. Two more Claude Code surfaces are **opt-in** (default install output is unchanged without the flag): + +``` +npx @dadado/agent-kit-cli install --claude +``` + +This generates `.claude/commands/.md` for every `.cursor/commands/*.md` you actually have installed (thin pointers, not copies — each one just tells Claude Code to read the matching `.cursor/commands/` file), and merges a SessionStart hook into `.claude/settings.json` so the same session context Cursor gets from `sessionStart` loads automatically in Claude Code too. Re-running `install --claude` (fresh or with existing files) is idempotent: unedited generated files refresh, files you hand-edited are preserved, and the hook entry is never duplicated. If an existing `.claude/settings.json` cannot be parsed as JSON, install prints the hook entry for you to paste in by hand instead of guessing at the file. + ### Optional external plan review When `/run-plan` finishes all implementable to-dos, you can get a second-agent check of the shipped work. **Default path when enabled:** `/run-plan` / `/run-plan-all` arm, wait, and continue into `/plan-review-triage`. Artifacts ship with L0; the feature stays opt-in (`enabled: false` by default). Session kit-load (`CLAUDE.md` / `/agent-kit`) is a different surface; see [Claude Code CLI (session kit-load)](#claude-code-cli-session-kit-load). @@ -234,7 +302,7 @@ Mission Control is a **local, single-developer** observability panel. Treat it a Source of truth: `.cursor/memory/decisions/2026-07-27_mission-control-personal-local-only-posture.md` (default product goal), `.cursor/memory/decisions/2026-07-27_mission-control-opt-in-lan-broadcast.md` (opt-in LAN path), `.cursor/memory/decisions/2026-08-11_mission-control-broadcast-url-mask.md` (cosmetic Share URL), plus `.cursor/memory/decisions/2026-07-24_mission-control-local-only-security.md` and `.cursor/memory/decisions/2026-07-26_mission-control-config-write-allowlist.md` (technical guards). -**Where the panel runs:** Consumer `npx` / `install.md` installs kit L0 (including the `/dashboard` command text) but **does not** copy `dashboard/**` into the app tree. Snapshot root is always the operator workspace. The UI host is either (1) a published `@dadado/agent-kit-cli` that ships `dashboard/**` (Path C, 4.8.2 onward), or (2) an agent-kit checkout (`MISSION_CONTROL_KIT_ROOT` / `AGENT_KIT_HOME` / sibling `../agent-kit` / monorepo `dashboard/`). Start with `/dashboard`, `npm run dashboard`, `agent-kit dashboard`, or `node dashboard/start.mjs` (see root README). Several workspaces may run concurrent instances: each gets a stable listen port from its repo root (see printed URL / `system.port`); Mission Control never kills another workspace's listener. +**Where the panel runs:** Consumer `npx` / `install.md` installs kit L0 (including the `/dashboard` command text) but **does not** copy `dashboard/**` into the app tree. Snapshot root is always the operator workspace. The UI host is either (1) a published `@dadado/agent-kit-cli` that ships `dashboard/**` (Path C, 4.8.2 onward), or (2) an agent-kit checkout (`MISSION_CONTROL_KIT_ROOT` / `AGENT_KIT_HOME` / sibling `../agent-kit` / monorepo `dashboard/`). Start with `/dashboard`, `npm run dashboard`, `npx @dadado/agent-kit-cli dashboard`, or `node dashboard/start.mjs` (see root README). Several workspaces may run concurrent instances: each gets a stable listen port from its repo root (see printed URL / `system.port`); Mission Control never kills another workspace's listener. **First failure (no `dashboard/start.mjs`):** the installed CLI is older than 4.8.2 (4.8.0 has no Path C assets; 4.8.1 was never published). Upgrade to 4.8.2+, or point env/sibling at a kit tree. Do not expect Port B alone to place the panel binary in the project. diff --git a/install.md b/install.md index 2e97b2f..4d623c6 100644 --- a/install.md +++ b/install.md @@ -20,13 +20,17 @@ npx @dadado/agent-kit-cli install Unpinned `npx` resolves to the latest publish. Pin a version when you need a reproducible install: `npx @dadado/agent-kit-cli@x.y.z install` (replace `x.y.z` with a version from npm). +**Project-root guard:** before any write, `install`, `update`, and `init` refuse `/` and the home directory, a folder with neither `.git` nor `.cursor/agent-kit.json`, and a folder that has `.git` but also contains two or more child repositories (parent-of-repos). Interactive terminals get a `Proceed anyway?` prompt defaulting to **no**; `--yes` / CI refuses outright with exit 1 and no files written. `--force-root` is the explicit bypass and skips every root check - confirm the absolute path first. Details: [docs/getting-started.md](docs/getting-started.md#project-root-guard). + +**Starting from an empty folder:** an empty directory hits that guard. Three sanctioned paths, all printed by the refusal: `git init` here and re-run (recommended), re-run with `--force-root`, or answer **yes** to the interactive `Proceed anyway?` prompt. Installing without Git is supported - `/agent-kit-onboard` owns the Git pillar afterwards (`Keep repository without Git` / `Initialize local Git`). The CLI never runs `git init` for you. Details: [docs/getting-started.md](docs/getting-started.md#starting-from-an-empty-folder). + Optional L1 packs (separate command): ```bash npx @dadado/agent-kit-cli install --pack clean-code,context-management ``` -After: `agent-kit status` (or `npx @dadado/agent-kit-cli status`). Kit L0 does **not** copy Mission Control's `dashboard/` tree into the project. The panel runs from the CLI package, which ships `dashboard/` from 4.8.2 onward, or from an agent-kit checkout (see [Getting started - Mission Control](docs/getting-started.md#mission-control-production-ship-constraints)). If `/dashboard` or `agent-kit dashboard` reports missing `start.mjs`, the installed CLI predates 4.8.2: upgrade it, or set kit-host env/sibling. +After: `npx @dadado/agent-kit-cli status`. `npx` is ephemeral - it leaves no `agent-kit` on `PATH`, so keep prefixing subcommands with `npx @dadado/agent-kit-cli` (or run `npm i -g @dadado/agent-kit-cli` once if you want the bare `agent-kit` bin). Kit L0 does **not** copy Mission Control's `dashboard/` tree into the project. The panel runs from the CLI package, which ships `dashboard/` from 4.8.2 onward, or from an agent-kit checkout (see [Getting started - Mission Control](docs/getting-started.md#mission-control-production-ship-constraints)). If `/dashboard` or the `dashboard` subcommand reports missing `start.mjs`, the installed CLI predates 4.8.2: upgrade it, or set kit-host env/sibling. Contributors working from a kit monorepo checkout: use the local CLI examples in [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) (Working on the kit). Do not paste monorepo `pnpm --filter` commands into a consumer project. @@ -143,7 +147,7 @@ If the agent has the Agent Kit monorepo open as workspace, use those paths. If o Default public base URL: `https://raw.githubusercontent.com/agent-kit-startup/agent-kit/main/` + each file path. Use **Ask questions** for any registry source confirmation: Options: `Fetch from public registry` / `Use different registry URL` / `Skip registry for now` -When the operator picks **Use different registry URL**, stop and require an explicit trust decision before fetching. Do not silently substitute an untrusted host. Prefer pinning a known public commit SHA (or tag) in the URL path over floating `main` when the operator needs reproducibility. After copy, run `agent-kit doctor` / `agent-kit status` when the CLI is available so the install can be validated. +When the operator picks **Use different registry URL**, stop and require an explicit trust decision before fetching. Do not silently substitute an untrusted host. Prefer pinning a known public commit SHA (or tag) in the URL path over floating `main` when the operator needs reproducibility. After copy, run `npx @dadado/agent-kit-cli doctor` / `npx @dadado/agent-kit-cli status` when the CLI is available so the install can be validated. **Fallback:** if Ask questions tool unavailable, ask the same options in chat as numbered list. @@ -154,7 +158,7 @@ Create if it doesn't exist (adjust `version` / `registry` to current SoT): ```json { "schemaVersion": 1, - "version": "5.4.0", + "version": "5.3.0", "profile": "default", "packs": [], "skills": [], @@ -196,7 +200,7 @@ cp .cursor/hooks/pre-commit/check-secrets.sh .git/hooks/ # only if repo flow re ### 6. Repository readiness onboarding -After L0 files are written, run `agent-kit doctor --json` or `npx @dadado/agent-kit-cli doctor --json` to refresh `.cursor/context/readiness.json`, then run or offer `/agent-kit-onboard`. The command is the sole coordinator for progressive repository preparation. Follow [`.cursor/commands/agent-kit-onboard.md`](.cursor/commands/agent-kit-onboard.md). +After L0 files are written, run `npx @dadado/agent-kit-cli doctor --json` (bare `agent-kit doctor --json` only after a global `npm i -g @dadado/agent-kit-cli`) to refresh `.cursor/context/readiness.json`, then run or offer `/agent-kit-onboard`. The command is the sole coordinator for progressive repository preparation. Follow [`.cursor/commands/agent-kit-onboard.md`](.cursor/commands/agent-kit-onboard.md). The first useful message reports current readiness progress, detected facts, safe fixes, and exactly one next action. It does not ask about skins, external review, or a first deliverable before essential readiness passes. diff --git a/package.json b/package.json index d711563..7bdaf75 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-kit", - "version": "5.4.0", + "version": "5.5.0", "description": "HITL framework for AI-assisted IDEs: plan, handoff, staging-to-prod, memory loop; project-aware setup for Cursor, VS Code, and Windsurf.", "private": true, "license": "PolyForm-Noncommercial-1.0.0", diff --git a/packages/cli/README.md b/packages/cli/README.md index 71caf05..24d3410 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -63,7 +63,8 @@ On an interactive TTY, long-running commands (`init`, `install`, `doctor`, `upda |---------|---------| | `agent-kit install` | Bootstrap L0 (+ optional packs) and write `agent-kit.json` | | `agent-kit status` | Show installed kit version and profile | -| `agent-kit doctor` | Diagnose repository readiness | +| `agent-kit doctor` | Diagnose repository readiness (`--json` includes an `env` pillar: bin-on-PATH, npm prefix writability, Node version, shell profile) | +| `agent-kit setup-global` | Self-heal a root-owned npm global prefix (relocate to `~/.npm-global`, fix `PATH`, reinstall) | | `agent-kit update` | Re-apply L0/packs/skills from the registry | | `agent-kit dashboard` | Start Mission Control for this workspace | | `agent-kit add ` | Install a skill or L1 pack | diff --git a/packages/cli/package.json b/packages/cli/package.json index c5647e6..0ac1af1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@dadado/agent-kit-cli", - "version": "5.4.0", + "version": "5.5.0", "description": "Agent Kit CLI: HITL framework install and tooling for AI-assisted IDEs (rules, skills, plan/handoff, context).", "license": "PolyForm-Noncommercial-1.0.0", "type": "module", diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 02a131d..015447b 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -2,6 +2,7 @@ import path from "node:path"; import { defineCommand } from "citty"; import { type HooksHealthReport, assessHooksHealth } from "../invariants/hooks-health.js"; import { KIT_VERSION } from "../lifecycle/version.js"; +import { type EnvironmentReport, assessEnvironment } from "../readiness/env-checks.js"; import { createReadinessReport } from "../scanner/readiness.js"; import { executeSafeReadinessFixes } from "../scanner/safe-fixes.js"; import { runScanner } from "../scanner/scan.js"; @@ -13,6 +14,7 @@ export interface DoctorResult { report: ReadinessReport; safeChanges: SafeReadinessChange[]; hooks: HooksHealthReport; + env: EnvironmentReport; } export async function runDoctor( @@ -21,13 +23,16 @@ export async function runDoctor( ): Promise { const rootDir = path.resolve(cwd); const hooks = await assessHooksHealth(rootDir); + // Environment pillar is read-only diagnostics; --fix-safe never runs env + // self-heal (that's the future setup-global command), it just reports. + const env = await assessEnvironment(); if (options.fixSafe) { const execution = await executeSafeReadinessFixes(rootDir, { generatorVersion: KIT_VERSION, generatedAt: options.generatedAt, }); await writeReadinessSnapshot(rootDir, execution.after); - return { report: execution.after, safeChanges: execution.changes, hooks }; + return { report: execution.after, safeChanges: execution.changes, hooks, env }; } const scan = await runScanner(rootDir); @@ -36,7 +41,7 @@ export async function runDoctor( generatedAt: options.generatedAt, }); await writeReadinessSnapshot(rootDir, report); - return { report, safeChanges: [], hooks }; + return { report, safeChanges: [], hooks, env }; } function printDoctorSummary(result: DoctorResult): void { @@ -62,6 +67,25 @@ function printDoctorSummary(result: DoctorResult): void { } } + console.log("environment:"); + console.log(` - bin on PATH (agent-kit): ${result.env.binOnPath ? "ok" : "MISSING"}`); + console.log( + ` - npm prefix writable: ${result.env.npmPrefixWritable ? "ok" : "BLOCKED"}${ + result.env.npmPrefix.prefix ? ` (${result.env.npmPrefix.prefix})` : "" + }`, + ); + if (!result.env.npmPrefixWritable && result.env.npmPrefix.reason) { + console.log(` - ${result.env.npmPrefix.reason}`); + } + console.log( + ` - node version >= 20: ${result.env.nodeVersionOk ? "ok" : "TOO OLD"} (${result.env.nodeVersion})`, + ); + console.log( + ` - shell profile: ${result.env.shellProfile ?? "not detected (zsh/bash only)"}${ + result.env.shell ? ` (shell: ${result.env.shell})` : "" + }`, + ); + // Check for ALLOW_MAIN_PUSH environment variable if (process.env.ALLOW_MAIN_PUSH === "1") { console.log("⚠️ WARNING: ALLOW_MAIN_PUSH=1 is set in environment"); diff --git a/packages/cli/src/commands/guard.test.ts b/packages/cli/src/commands/guard.test.ts new file mode 100644 index 0000000..0ebd5d0 --- /dev/null +++ b/packages/cli/src/commands/guard.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { SECRET_PATTERNS } from "../invariants/secrets-scan.js"; +import { SHELL_DENY_RULES } from "../invariants/shell-guard.js"; +import { guardCommand } from "./guard.js"; + +/** + * These tests exist so the advertised sentences stay derived from the invariants + * instead of asserted next to them (`errors/2026-08-14_guard-secrets-scope-vs-claims`). + */ + +type MetaLike = { meta?: { description?: string } }; + +function subDescription(name: string): string { + const subs = guardCommand.subCommands as unknown as Record | undefined; + return subs?.[name]?.meta?.description ?? ""; +} + +describe("guard shell help text matches SHELL_DENY_RULES", () => { + const description = subDescription("shell"); + + it("does not claim a general destructive deny-list", () => { + expect(description).not.toMatch(/destructive deny-list/i); + expect(description).toMatch(/git-workflow/i); + expect(description).toMatch(/protected-branch/i); + }); + + it("only ships git-scoped rules, which is what the sentence promises", () => { + expect(SHELL_DENY_RULES).toHaveLength(5); + for (const rule of SHELL_DENY_RULES) { + expect(rule.id.startsWith("git-")).toBe(true); + } + }); + + it("names every rule family it actually enforces", () => { + // Derived from the rule ids: git-checkout-path, git-restore, git-reset-hard, + // git-clean-fd, git-push-main. A new rule family must reach the help sentence. + const families = SHELL_DENY_RULES.map((rule) => rule.id.split("-")[1]); + for (const family of families) { + expect(description.toLowerCase()).toContain(family); + } + }); +}); + +describe("guard prompt help text matches the scan posture", () => { + const description = subDescription("prompt"); + + it("stays advisory / fail-open in the sentence, as the hook is", () => { + expect(description).toMatch(/advisory/i); + expect(description).toMatch(/fail-open/i); + }); + + it("has a non-empty pattern set behind the sentence", () => { + expect(SECRET_PATTERNS.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/cli/src/commands/guard.ts b/packages/cli/src/commands/guard.ts index 90020ac..8017dcb 100644 --- a/packages/cli/src/commands/guard.ts +++ b/packages/cli/src/commands/guard.ts @@ -27,7 +27,8 @@ export const guardCommand = defineCommand({ shell: defineCommand({ meta: { name: "shell", - description: "Evaluate a shell command against the destructive deny-list", + description: + "Evaluate a shell command against the git-workflow / protected-branch deny-list (git checkout|restore|reset --hard|clean -fd + pushes to main/master/prod). Not a general destructive-command guard: rm -rf, chmod, dd are allowed.", }, args: { json: { diff --git a/packages/cli/src/commands/hook.test.ts b/packages/cli/src/commands/hook.test.ts new file mode 100644 index 0000000..40000e6 --- /dev/null +++ b/packages/cli/src/commands/hook.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { SESSION_START_DEGRADED_MESSAGE } from "../hooks/format-session-start.js"; +import { runSessionStartHook } from "./hook.js"; + +describe("runSessionStartHook (flag matrix: cursor default, claude opt-in, fail-open both)", () => { + const buildContext = async () => ({ additional_context: "## Hi\n\ncontext body" }); + const readStdin = async () => ({}); + + it("default (no --format) matches today's cursor JSON shape byte-for-byte", async () => { + const out = await runSessionStartHook(process.cwd(), undefined, { + readStdin, + buildContext, + }); + expect(out).toBe(JSON.stringify({ additional_context: "## Hi\n\ncontext body" })); + }); + + it("--format cursor is identical to the default", async () => { + const out = await runSessionStartHook(process.cwd(), "cursor", { readStdin, buildContext }); + expect(out).toBe(JSON.stringify({ additional_context: "## Hi\n\ncontext body" })); + }); + + it("--format claude emits plain stdout text, no JSON wrapper, no node -e unwrapper needed", async () => { + const out = await runSessionStartHook(process.cwd(), "claude", { readStdin, buildContext }); + expect(out).toBe("## Hi\n\ncontext body"); + expect(() => JSON.parse(out)).toThrow(); + }); + + it("unknown --format value falls back to cursor default", async () => { + const out = await runSessionStartHook(process.cwd(), "bogus", { readStdin, buildContext }); + expect(out).toBe(JSON.stringify({ additional_context: "## Hi\n\ncontext body" })); + }); + + it("fail-open: stdin read failure degrades to the diagnostic instead of throwing (cursor format)", async () => { + const out = await runSessionStartHook(process.cwd(), "cursor", { + readStdin: async () => { + throw new Error("stdin exploded"); + }, + buildContext, + }); + expect(JSON.parse(out).additional_context).toBe(SESSION_START_DEGRADED_MESSAGE); + }); + + it("fail-open: context builder failure degrades to the diagnostic instead of throwing (claude format)", async () => { + const out = await runSessionStartHook(process.cwd(), "claude", { + readStdin, + buildContext: async () => { + throw new Error("build exploded"); + }, + }); + expect(out).toBe(SESSION_START_DEGRADED_MESSAGE); + }); +}); diff --git a/packages/cli/src/commands/hook.ts b/packages/cli/src/commands/hook.ts index d37eed6..0c09dd2 100644 --- a/packages/cli/src/commands/hook.ts +++ b/packages/cli/src/commands/hook.ts @@ -1,5 +1,10 @@ import path from "node:path"; import { defineCommand } from "citty"; +import { + SESSION_START_DEGRADED_MESSAGE, + formatSessionStartOutput, + resolveSessionStartFormat, +} from "../hooks/format-session-start.js"; import { buildPreCompactUserMessage } from "../hooks/pre-compact.js"; import { readStdinJson } from "../hooks/read-stdin-json.js"; import { @@ -8,29 +13,65 @@ import { resolveSessionRoot, } from "../hooks/session-start.js"; +export interface RunSessionStartHookDeps { + readStdin?: () => Promise; + buildContext?: ( + root: string, + payload: SessionStartPayload, + ) => Promise<{ additional_context: string }>; +} + +/** + * Core session-start hook logic, extracted from the citty `run()` handler so + * it is directly unit-testable (stdin/output are DI'd, not real process + * streams). Fail-open: any error from stdin parsing or context generation is + * swallowed and degrades to `SESSION_START_DEGRADED_MESSAGE` in the + * requested format rather than a thrown error / non-zero exit. + */ +export async function runSessionStartHook( + cwd: string, + formatArg: unknown, + deps: RunSessionStartHookDeps = {}, +): Promise { + const format = resolveSessionStartFormat(formatArg); + try { + const readStdin = deps.readStdin ?? readStdinJson; + const buildContext = deps.buildContext ?? buildSessionStartAdditionalContext; + const payload = await readStdin(); + const root = resolveSessionRoot(payload, path.resolve(cwd)); + const out = await buildContext(root, payload); + return formatSessionStartOutput(out.additional_context, format); + } catch { + return formatSessionStartOutput(SESSION_START_DEGRADED_MESSAGE, format); + } +} + export const hookCommand = defineCommand({ meta: { name: "hook", - description: "Cursor hook adapters (session-start, pre-compact). CLI is SoT.", + description: "Cursor + Claude Code hook adapters (session-start, pre-compact). CLI is SoT.", }, subCommands: { "session-start": defineCommand({ meta: { name: "session-start", - description: "Emit sessionStart additional_context JSON (stdin: Cursor payload)", + description: + "Emit sessionStart context (stdin: host payload). --format cursor (default, JSON additional_context) | claude (plain stdout)", }, args: { cwd: { type: "string", default: process.cwd(), }, + format: { + type: "string", + default: "cursor", + description: "cursor (default) | claude", + }, }, async run({ args }) { - const payload = await readStdinJson(); const cwd = typeof args.cwd === "string" ? args.cwd : process.cwd(); - const root = resolveSessionRoot(payload, path.resolve(cwd)); - const out = await buildSessionStartAdditionalContext(root, payload); - console.log(JSON.stringify(out)); + console.log(await runSessionStartHook(cwd, args.format)); }, }), "pre-compact": defineCommand({ diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts new file mode 100644 index 0000000..339c972 --- /dev/null +++ b/packages/cli/src/commands/init.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { EnvironmentReport } from "../readiness/env-checks.js"; +import { RootRefusedError } from "../utils/terminal.js"; +import { initCommand } from "./init.js"; + +const mockConfirmProjectRoot = vi.hoisted(() => vi.fn()); +const mockPerformInstall = vi.hoisted(() => vi.fn()); +const mockAssessEnvironment = vi.hoisted(() => vi.fn()); + +vi.mock("../utils/terminal.js", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + confirmProjectRoot: (...args: unknown[]) => mockConfirmProjectRoot(...args), + isNonInteractive: () => true, + }; +}); + +vi.mock("../readiness/env-checks.js", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + assessEnvironment: (...args: unknown[]) => mockAssessEnvironment(...args), + }; +}); + +vi.mock("./install.js", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + performInstall: (...args: unknown[]) => mockPerformInstall(...args), + }; +}); + +function makeEnvReport(overrides: Partial = {}): EnvironmentReport { + return { + binOnPath: false, + npmPrefixWritable: true, + npmPrefix: { prefix: "/usr/local", writable: true, source: "heuristic" }, + nodeVersionOk: true, + nodeVersion: "v20.11.0", + shell: "zsh", + shellProfile: "/home/user/.zshrc", + ...overrides, + }; +} + +type RunCtx = { args: Record }; +const runInit = (cwd: string, forceRoot = false) => + (initCommand.run as unknown as (ctx: RunCtx) => Promise)({ + args: { _: [], cwd, yes: true, "force-root": forceRoot }, + }); + +describe("initCommand project-root guard", () => { + beforeEach(() => { + mockAssessEnvironment.mockResolvedValue(makeEnvReport()); + }); + + afterEach(() => { + mockConfirmProjectRoot.mockReset(); + mockPerformInstall.mockReset(); + mockAssessEnvironment.mockReset(); + process.exitCode = undefined; + }); + + it("refuses without installing when the root guard rejects the directory", async () => { + mockConfirmProjectRoot.mockRejectedValue( + new RootRefusedError("/tmp/blank", "Refused /tmp/blank: no .git", "git init"), + ); + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + await runInit("/tmp/blank"); + // The whole point: `init` used to call performInstall unconditionally, + // so a blank no-git folder got L0 written into it with no confirmation. + expect(mockPerformInstall).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(consoleErrorSpy).toHaveBeenCalled(); + } finally { + consoleErrorSpy.mockRestore(); + } + }); + + it("installs into the confirmed root, not the raw cwd argument", async () => { + mockConfirmProjectRoot.mockResolvedValue("/tmp/confirmed-project"); + mockPerformInstall.mockResolvedValue({ + projectRoot: "/tmp/confirmed-project", + manifestPath: "/tmp/confirmed-project/.cursor/agent-kit.json", + stats: {}, + readiness: { pendingActions: [] }, + safeChanges: [], + }); + + await runInit("/tmp/confirmed-project"); + expect(mockPerformInstall).toHaveBeenCalledWith({ cwd: "/tmp/confirmed-project" }); + expect(process.exitCode).toBeUndefined(); + }); + + it("prints the smart epilogue (not a bare `agent-kit` next step) after a successful install", async () => { + mockConfirmProjectRoot.mockResolvedValue("/tmp/confirmed-project"); + mockPerformInstall.mockResolvedValue({ + projectRoot: "/tmp/confirmed-project", + manifestPath: "/tmp/confirmed-project/.cursor/agent-kit.json", + stats: {}, + readiness: { pendingActions: [] }, + safeChanges: [], + }); + mockAssessEnvironment.mockResolvedValue(makeEnvReport({ binOnPath: false })); + const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await runInit("/tmp/confirmed-project"); + expect(mockAssessEnvironment).toHaveBeenCalled(); + const printed = consoleLogSpy.mock.calls.map((call) => String(call[0])).join("\n"); + expect(printed).toContain("npx @dadado/agent-kit-cli setup-global"); + // The "keep using npx" option must recommend npx, never a bare bin. + expect(printed).toContain("1. Keep using npx"); + expect(printed).toContain("npx @dadado/agent-kit-cli "); + } finally { + consoleLogSpy.mockRestore(); + } + }); + + it("passes --force-root through to the guard", async () => { + mockConfirmProjectRoot.mockResolvedValue("/tmp/forced"); + mockPerformInstall.mockResolvedValue({ + projectRoot: "/tmp/forced", + manifestPath: "/tmp/forced/.cursor/agent-kit.json", + stats: {}, + readiness: { pendingActions: [] }, + safeChanges: [], + }); + + await runInit("/tmp/forced", true); + expect(mockConfirmProjectRoot).toHaveBeenCalledWith( + "/tmp/forced", + expect.objectContaining({ forceRoot: true, nonInteractive: true }), + ); + }); +}); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index bfe5db9..90892a1 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -1,10 +1,21 @@ import { intro, outro } from "@clack/prompts"; import { defineCommand } from "citty"; import { KIT_VERSION } from "../lifecycle/version.js"; +import { assessEnvironment } from "../readiness/env-checks.js"; import { logger } from "../utils/logger.js"; -import { classifyInstallError, isNonInteractive } from "../utils/terminal.js"; +import { + RootRefusedError, + classifyInstallError, + confirmProjectRoot, + isNonInteractive, +} from "../utils/terminal.js"; import { withCliProgress } from "../welcome/visual-kit.js"; -import { type InstallResult, performInstall } from "./install.js"; +import { + type InstallResult, + nextStepAfterInstall, + performInstall, + printInstallEpilogue, +} from "./install.js"; type CompatibilityInstaller = (options: { cwd: string }) => Promise; @@ -26,27 +37,61 @@ export const initCommand = defineCommand({ description: "Project root directory", default: process.cwd(), }, + yes: { + type: "boolean", + alias: "y", + description: "Skip interactive prompts; use defaults (IDE-agnostic non-interactive mode)", + default: false, + }, + "force-root": { + type: "boolean", + description: "Bypass the ambiguous-root guard (use with caution)", + default: false, + }, }, async run({ args }) { - const nonInteractive = isNonInteractive(); + const nonInteractive = args.yes || isNonInteractive(); if (!nonInteractive) { intro(`agent-kit v${KIT_VERSION}`); } else { logger.info(`agent-kit v${KIT_VERSION} (non-interactive mode)`); } logger.info("init now uses the canonical install and readiness workflow."); + + // init is a compat wrapper over performInstall, which writes L0. It owes + // the same root confirm as `install`: without it, `init` silently wrote + // into a blank no-git folder or a parent-of-repos root. + let projectRoot: string; + try { + projectRoot = await confirmProjectRoot(args.cwd, { + nonInteractive, + command: "install", + forceRoot: args["force-root"], + }); + } catch (err) { + if (err instanceof RootRefusedError) { + logger.error(err.message); + if (err.recovery) console.error(`\n${err.recovery}\n`); + process.exitCode = 1; + return; + } + throw err; + } + try { - const result = await withCliProgress("init", () => runInitCompatibility(args.cwd)); + const result = await withCliProgress("init", () => runInitCompatibility(projectRoot)); const pending = result.readiness.pendingActions.length; logger.success(`L0 and readiness prepared in ${result.projectRoot}`); - const nextStep = - pending > 0 - ? "Next: run /agent-kit-onboard in Cursor to resolve the first pending action." - : "Next: run /start-project in Cursor when you have a deliverable."; + const nextStep = nextStepAfterInstall(pending); + const env = await assessEnvironment(); + // npx is ephemeral: never point the operator at a bare `agent-kit` here + // unless the environment probe confirms one is already on PATH. if (!nonInteractive) { + printInstallEpilogue(env); outro(nextStep); } else { logger.info(nextStep); + printInstallEpilogue(env); } } catch (err) { const hint = classifyInstallError(err); diff --git a/packages/cli/src/commands/install.test.ts b/packages/cli/src/commands/install.test.ts index 0dee647..6a9c186 100644 --- a/packages/cli/src/commands/install.test.ts +++ b/packages/cli/src/commands/install.test.ts @@ -1,6 +1,20 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import type { EnvironmentReport } from "../readiness/env-checks.js"; import { RootRefusedError } from "../utils/terminal.js"; -import { installCommand } from "./install.js"; +import { installCommand, nextStepAfterInstall, printInstallEpilogue } from "./install.js"; + +function makeEnvReport(overrides: Partial = {}): EnvironmentReport { + return { + binOnPath: false, + npmPrefixWritable: true, + npmPrefix: { prefix: "/usr/local", writable: true, source: "heuristic" }, + nodeVersionOk: true, + nodeVersion: "v20.11.0", + shell: "zsh", + shellProfile: "/home/user/.zshrc", + ...overrides, + }; +} const mockConfirmProjectRoot = vi.hoisted(() => vi.fn()); const mockResolveRegistryFromCli = vi.hoisted(() => vi.fn()); @@ -22,6 +36,79 @@ vi.mock("../lifecycle/resolve-cli.js", async (importOriginal) => { }; }); +describe("post-install next-step copy", () => { + it("points at /agent-kit-onboard when readiness has pending actions", () => { + expect(nextStepAfterInstall(3)).toContain("/agent-kit-onboard"); + }); + + it("points at /start-project when readiness is clear", () => { + expect(nextStepAfterInstall(0)).toContain("/start-project"); + }); + + it("never names a bare agent-kit binary as the next step", () => { + // `npx @dadado/agent-kit-cli install` is ephemeral: it leaves no + // `agent-kit` on PATH, so the success banner must not imply one. + for (const copy of [nextStepAfterInstall(0), nextStepAfterInstall(1)]) { + expect(copy).not.toMatch(/(^|[\s`"'])agent-kit\s/); + } + }); +}); + +describe("printInstallEpilogue", () => { + it("prints a short positive line and skips the numbered options when the bin is already on PATH", () => { + const lines: string[] = []; + printInstallEpilogue(makeEnvReport({ binOnPath: true }), { + color: false, + print: (line) => lines.push(line), + }); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain("agent-kit"); + expect(lines[0]).toContain("PATH"); + expect(lines.join("\n")).not.toContain("setup-global"); + }); + + it("prints all 3 numbered options, mentioning setup-global, when the bin is not on PATH", () => { + const lines: string[] = []; + printInstallEpilogue(makeEnvReport({ binOnPath: false }), { + color: false, + print: (line) => lines.push(line), + }); + const printed = lines.join("\n"); + expect(printed).toContain("1. Keep using npx"); + expect(printed).toContain("npx @dadado/agent-kit-cli "); + expect(printed).toContain("2. Put a bare `agent-kit` on PATH"); + expect(printed).toContain("npx @dadado/agent-kit-cli setup-global"); + expect(printed).toContain("3. Manual steps"); + expect(printed).toContain("docs/getting-started.md"); + // Beginner-first: name the symptom before the choices. + expect(printed.indexOf("won't work yet")).toBeLessThan(printed.indexOf("1. Keep using npx")); + }); + + it("emits no ANSI escape codes when color is disabled (NO_COLOR/CI fallback)", () => { + const lines: string[] = []; + printInstallEpilogue(makeEnvReport({ binOnPath: false }), { + color: false, + print: (line) => lines.push(line), + }); + // biome-ignore lint/suspicious/noControlCharactersInRegex: asserting the absence of ANSI escapes + const ansiEscape = /\[[0-9;]*m/; + for (const line of lines) { + expect(line).not.toMatch(ansiEscape); + } + }); + + it("emits ANSI escape codes when color is enabled", () => { + const lines: string[] = []; + printInstallEpilogue(makeEnvReport({ binOnPath: false }), { + color: true, + print: (line) => lines.push(line), + }); + const printed = lines.join("\n"); + // biome-ignore lint/suspicious/noControlCharactersInRegex: asserting presence of ANSI escapes + expect(printed).toMatch(/\[[0-9;]*m/); + }); +}); + describe("installCommand RootRefusedError", () => { afterEach(() => { mockConfirmProjectRoot.mockReset(); @@ -58,6 +145,42 @@ describe("installCommand RootRefusedError", () => { } }); + it("prints the start-from-zero recovery block on a root refusal", async () => { + mockConfirmProjectRoot.mockRejectedValue( + new RootRefusedError( + "/tmp/blank", + "Refused /tmp/blank: no .git and no .cursor/agent-kit.json.", + " 1. git init\n 2. --force-root", + ), + ); + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + await ( + installCommand.run as unknown as (ctx: { args: Record }) => Promise + )({ + args: { + _: [], + cwd: "/tmp/blank", + yes: true, + "force-root": false, + pack: undefined as unknown as string, + profile: undefined as unknown as string, + registry: undefined as unknown as string, + url: undefined as unknown as string, + ref: undefined as unknown as string, + refresh: false, + }, + }); + expect(process.exitCode).toBe(1); + const printed = consoleErrorSpy.mock.calls.map((call) => String(call[0])).join("\n"); + expect(printed).toContain("git init"); + expect(printed).toContain("--force-root"); + } finally { + consoleErrorSpy.mockRestore(); + } + }); + it("sets exitCode and prints the recovery hint without process.exit on generic install failure", async () => { mockConfirmProjectRoot.mockResolvedValue("/tmp/some-project"); mockResolveRegistryFromCli.mockRejectedValue(new Error("registry resolution exploded")); diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts index d718f3c..2e47a7a 100644 --- a/packages/cli/src/commands/install.ts +++ b/packages/cli/src/commands/install.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { defineCommand } from "citty"; +import { bold, cyan, green, options as koloristOptions } from "kolorist"; import { applyPersonalization, readRepositoryProfile } from "../generator/personalization.js"; import { type ApplyStats, buildManifest, saveManifest } from "../lifecycle/apply.js"; import { resolveProtectedGlobs } from "../lifecycle/protected.js"; @@ -8,6 +9,7 @@ import { REGISTRY_CLI_ARGS, resolveRegistryFromCli } from "../lifecycle/resolve- import { installL0, syncFromManifest } from "../lifecycle/sync.js"; import { KIT_VERSION } from "../lifecycle/version.js"; import { DOMAIN_PACK_IDS, loadAgentKitManifest } from "../manifest/index.js"; +import { type EnvironmentReport, assessEnvironment } from "../readiness/env-checks.js"; import { loadRegistry } from "../registry/client.js"; import { createReadinessReport } from "../scanner/readiness.js"; import { executeSafeReadinessFixes } from "../scanner/safe-fixes.js"; @@ -21,7 +23,7 @@ import { confirmProjectRoot, isNonInteractive, } from "../utils/terminal.js"; -import { withCliProgress } from "../welcome/visual-kit.js"; +import { shouldUseWelcomeColor, withCliProgress } from "../welcome/visual-kit.js"; function parsePackList(raw: string | undefined): string[] { if (!raw?.trim()) return []; @@ -43,6 +45,11 @@ interface InstallOptions { url?: string; ref?: string; refresh?: boolean; + /** + * Opt-in: generate .claude/commands/*.md thin pointer adapters and merge + * a SessionStart context hook into .claude/settings.json (default false). + */ + claudeAdapters?: boolean; } export interface InstallResult { @@ -51,6 +58,14 @@ export interface InstallResult { stats: ApplyStats; readiness: ReadinessReport; safeChanges: SafeReadinessChange[]; + /** Set only when --claude was requested and .claude/settings.json could not be merged (see personalization.ts). */ + claudeSessionStartInstructions?: string; +} + +export function nextStepAfterInstall(pendingActions: number): string { + return pendingActions > 0 + ? "Next: run /agent-kit-onboard in Cursor to resolve the first pending action." + : "Next: run /start-project in Cursor when you have a deliverable."; } function printReadinessNarrative(result: InstallResult): void { @@ -62,11 +77,95 @@ function printReadinessNarrative(result: InstallResult): void { ); console.log(` safe fixes applied: ${fixed}`); console.log(` pending actions: ${pendingActions.length}`); - console.log( - pendingActions.length > 0 - ? "Next: run /agent-kit-onboard in Cursor to resolve the first pending action" - : "Next: run /start-project in Cursor when you have a deliverable", - ); + console.log(nextStepAfterInstall(pendingActions.length)); +} + +export interface InstallEpilogueOptions { + /** Force color on/off; when omitted, derive from shouldUseWelcomeColor(). */ + color?: boolean; + /** Injectable sink for tests; defaults to console.log. */ + print?: (line: string) => void; +} + +/** + * Apply a kolorist paint fn, forcing both `enabled` and `supportLevel` for the + * call. kolorist auto-detects `supportLevel` from stdout/TERM at import time + * and stays at "none" for non-TTY stdout (piped output, most test runners) - + * forcing only `enabled` (as elsewhere) still no-ops there. Once our own + * color gate (`shouldUseWelcomeColor`) has said yes, painting should not be + * silently dropped by a second, independent detector. + */ +function paint(fn: (s: string | number) => string, text: string): string { + const prevEnabled = koloristOptions.enabled; + const prevSupportLevel = koloristOptions.supportLevel; + koloristOptions.enabled = true; + koloristOptions.supportLevel = 3; + try { + return fn(text); + } finally { + koloristOptions.enabled = prevEnabled; + koloristOptions.supportLevel = prevSupportLevel; + } +} + +/** + * The post-install "what now" epilogue for the CLI invocation form. + * + * `npx` is ephemeral: right after `npx @dadado/agent-kit-cli install` a bare + * `agent-kit` is not on PATH, so a beginner who tries one next hits + * "command not found". This names that symptom up front and offers three + * numbered choices (keep using npx / fix PATH via setup-global / manual + * steps) instead of silently repeating the npx form. + * + * When `env.binOnPath` is already true (global install, or a machine where + * the bin already resolves), the choices above are noise — this prints one + * short positive line instead. + */ +export function printInstallEpilogue( + env: EnvironmentReport, + options: InstallEpilogueOptions = {}, +): void { + const print = options.print ?? ((line: string) => console.log(line)); + const color = options.color ?? shouldUseWelcomeColor(); + + if (env.binOnPath) { + const line = "`agent-kit` is on PATH — run it directly, e.g. `agent-kit doctor`."; + print(color ? paint(green, line) : line); + return; + } + + const divider = "─".repeat(60); + const body = [ + "You ran this through npx, so a bare `agent-kit` isn't on PATH yet.", + 'If you try `agent-kit ` next, you will see "command not', + 'found". Pick one:', + "", + " 1. Keep using npx — works right now, no action needed", + " npx @dadado/agent-kit-cli ", + "", + " 2. Put a bare `agent-kit` on PATH", + " npx @dadado/agent-kit-cli setup-global", + " (fixes a root-owned npm prefix if that's the blocker, or just installs)", + "", + " 3. Manual steps", + " See docs/getting-started.md (Troubleshooting npm failures), or:", + " mkdir -p ~/.npm-global", + ' npm config set prefix "~/.npm-global"', + ' export PATH="~/.npm-global/bin:$PATH"', + " npm i -g @dadado/agent-kit-cli", + ]; + + print(color ? paint(cyan, divider) : divider); + const heading = "Heads up: a bare `agent-kit` command won't work yet"; + print(color ? paint(bold, paint(cyan, heading)) : heading); + for (const line of body) print(line); + print(color ? paint(cyan, divider) : divider); +} + +async function printPostInstallSummary(result: InstallResult): Promise { + printReadinessNarrative(result); + const env = await assessEnvironment(); + printInstallEpilogue(env); } export async function performInstall(options: InstallOptions): Promise { @@ -102,6 +201,7 @@ export async function performInstall(options: InstallOptions): Promise { const result = await runDoctor(root, { generatedAt: GENERATED_AT }); const json = JSON.stringify(result); + const parsed = JSON.parse(json); expect(log).not.toHaveBeenCalled(); - expect(JSON.parse(json).report.generatedAt).toBe(GENERATED_AT); + expect(parsed.report.generatedAt).toBe(GENERATED_AT); expect(json).not.toContain(root); + expect(typeof parsed.env.binOnPath).toBe("boolean"); + expect(typeof parsed.env.npmPrefixWritable).toBe("boolean"); + expect(typeof parsed.env.nodeVersionOk).toBe("boolean"); + expect("shellProfile" in parsed.env).toBe(true); log.mockRestore(); }); @@ -58,7 +63,11 @@ describe("readiness commands", () => { expect(result.safeChanges.some((change) => change.status === "applied")).toBe(true); expect(await fileExists(path.join(root, ".gitignore"))).toBe(true); expect(await fileExists(path.join(root, ".git"))).toBe(false); - }); + // --fix-safe never runs env self-heal; it only reports the same + // read-only env pillar as the default doctor path. + expect(typeof result.env.binOnPath).toBe("boolean"); + expect(typeof result.env.nodeVersionOk).toBe("boolean"); + }, 20_000); it("keeps init as a compatibility wrapper over install", async () => { const expected = { projectRoot: "/tmp/example" } as InstallResult; diff --git a/packages/cli/src/commands/setup-global.test.ts b/packages/cli/src/commands/setup-global.test.ts new file mode 100644 index 0000000..884c5db --- /dev/null +++ b/packages/cli/src/commands/setup-global.test.ts @@ -0,0 +1,302 @@ +import { describe, expect, it, vi } from "vitest"; +import type { EnvironmentReport } from "../readiness/env-checks.js"; +import { + type NpmInstallOutcome, + type SetupGlobalFsImpl, + type SetupGlobalRunResult, + planSetupGlobalSteps, + runSetupGlobal, + upsertNpmrcPrefix, +} from "./setup-global.js"; + +function makeEnv(overrides: Partial = {}): EnvironmentReport { + return { + binOnPath: false, + npmPrefixWritable: false, + npmPrefix: { + prefix: "/usr/local", + writable: false, + source: "heuristic", + reason: "root-owned prefix (/usr/local); the classic fresh-install PATH/EACCES blocker", + }, + nodeVersionOk: true, + nodeVersion: "v22.4.0", + shell: "zsh", + shellProfile: "/home/tester/.zshrc", + ...overrides, + }; +} + +/** In-memory fake fs: starts from a fixed set of files, records every mutation call. */ +function makeFakeFs(initial: Record = {}) { + const files = new Map(Object.entries(initial)); + const calls = { mkdir: [] as string[], writeFile: [] as string[], appendFile: [] as string[] }; + const fs: SetupGlobalFsImpl = { + mkdir: async (dir) => { + calls.mkdir.push(dir); + }, + readFile: async (filePath) => { + const content = files.get(filePath); + if (content === undefined) { + const err = new Error(`ENOENT: ${filePath}`) as NodeJS.ErrnoException; + err.code = "ENOENT"; + throw err; + } + return content; + }, + writeFile: async (filePath, content) => { + calls.writeFile.push(filePath); + files.set(filePath, content); + }, + appendFile: async (filePath, content) => { + calls.appendFile.push(filePath); + files.set(filePath, (files.get(filePath) ?? "") + content); + }, + }; + return { fs, files, calls }; +} + +function collectingPrint() { + const lines: string[] = []; + return { print: (line: string) => lines.push(line), lines }; +} + +describe("upsertNpmrcPrefix", () => { + it("appends a prefix line to empty content", () => { + expect(upsertNpmrcPrefix("", "~/.npm-global")).toBe("prefix = ~/.npm-global\n"); + }); + + it("appends a prefix line preserving existing unrelated content", () => { + expect(upsertNpmrcPrefix("registry=https://registry.npmjs.org/\n", "~/.npm-global")).toBe( + "registry=https://registry.npmjs.org/\nprefix = ~/.npm-global\n", + ); + }); + + it("replaces an existing prefix line in place", () => { + const before = "save-exact=true\nprefix = /usr/local\nregistry=https://registry.npmjs.org/\n"; + expect(upsertNpmrcPrefix(before, "~/.npm-global")).toBe( + "save-exact=true\nprefix = ~/.npm-global\nregistry=https://registry.npmjs.org/\n", + ); + }); +}); + +describe("planSetupGlobalSteps", () => { + it("resolves concrete paths for the 4 steps", () => { + const plan = planSetupGlobalSteps(makeEnv(), { homeDir: "/home/tester" }); + expect(plan.npmGlobalDir).toBe("/home/tester/.npm-global"); + expect(plan.npmGlobalBin).toBe("/home/tester/.npm-global/bin"); + expect(plan.npmrcPath).toBe("/home/tester/.npmrc"); + expect(plan.pathExportLine).toBe('export PATH="/home/tester/.npm-global/bin:$PATH"'); + expect(plan.steps.map((s) => s.id)).toEqual([ + "set-prefix", + "append-path", + "npm-install", + "verify", + ]); + expect(plan.shellSupported).toBe(true); + }); + + it("marks the shell unsupported when no zsh/bash profile was detected", () => { + const plan = planSetupGlobalSteps(makeEnv({ shell: "fish", shellProfile: null }), { + homeDir: "/home/tester", + }); + expect(plan.shellSupported).toBe(false); + const appendStep = plan.steps.find((s) => s.id === "append-path"); + expect(appendStep?.detail.join(" ")).toContain("could not be auto-detected"); + expect(appendStep?.detail.join(" ")).toContain(plan.pathExportLine); + }); +}); + +describe("runSetupGlobal: already-writable short-circuit", () => { + it("exits 0 with no prompts and no fs/npm calls when the prefix is already writable", async () => { + const { print, lines } = collectingPrint(); + const { fs, calls } = makeFakeFs(); + const confirmImpl = vi.fn(); + const npmInstallImpl = vi.fn(); + + const result = await runSetupGlobal({ + print, + fsImpl: fs, + confirmImpl, + npmInstallImpl, + assessEnvironmentImpl: async () => makeEnv({ npmPrefixWritable: true }), + }); + + expect(result.outcome).toBe("already-ok"); + expect(result.exitCode).toBe(0); + expect(result.mutated).toBe(false); + expect(confirmImpl).not.toHaveBeenCalled(); + expect(npmInstallImpl).not.toHaveBeenCalled(); + expect(calls.mkdir).toHaveLength(0); + expect(calls.writeFile).toHaveLength(0); + expect(lines.join("\n")).toMatch(/nothing to fix/i); + }); +}); + +describe("runSetupGlobal: --dry-run", () => { + it("prints the resolved plan and mutates nothing", async () => { + const { print, lines } = collectingPrint(); + const { fs, calls } = makeFakeFs(); + const confirmImpl = vi.fn(); + const npmInstallImpl = vi.fn(); + + const result = await runSetupGlobal({ + dryRun: true, + print, + fsImpl: fs, + confirmImpl, + npmInstallImpl, + homeDir: "/home/tester", + assessEnvironmentImpl: async () => makeEnv(), + }); + + expect(result.outcome).toBe("dry-run"); + expect(result.exitCode).toBe(0); + expect(result.mutated).toBe(false); + expect(confirmImpl).not.toHaveBeenCalled(); + expect(npmInstallImpl).not.toHaveBeenCalled(); + expect(calls.mkdir).toHaveLength(0); + expect(calls.writeFile).toHaveLength(0); + expect(calls.appendFile).toHaveLength(0); + const text = lines.join("\n"); + expect(text).toContain("/home/tester/.npm-global"); + expect(text).toContain("npm i -g @dadado/agent-kit-cli"); + }); +}); + +describe("runSetupGlobal: non-interactive", () => { + it("prints manual steps, exits 0, and mutates nothing", async () => { + const { print, lines } = collectingPrint(); + const { fs, calls } = makeFakeFs(); + const confirmImpl = vi.fn(); + const npmInstallImpl = vi.fn(); + + const result = await runSetupGlobal({ + nonInteractive: true, + print, + fsImpl: fs, + confirmImpl, + npmInstallImpl, + homeDir: "/home/tester", + assessEnvironmentImpl: async () => makeEnv(), + }); + + expect(result.outcome).toBe("manual-instructions"); + expect(result.exitCode).toBe(0); + expect(result.mutated).toBe(false); + expect(confirmImpl).not.toHaveBeenCalled(); + expect(npmInstallImpl).not.toHaveBeenCalled(); + expect(calls.mkdir).toHaveLength(0); + expect(calls.writeFile).toHaveLength(0); + const text = lines.join("\n"); + expect(text).toContain("mkdir -p /home/tester/.npm-global"); + expect(text).toContain('npm config set prefix "~/.npm-global"'); + expect(text).toContain("npm i -g @dadado/agent-kit-cli"); + }); +}); + +describe("runSetupGlobal: interactive full run", () => { + it("confirms each step, mutates prefix + profile + installs, and reports success", async () => { + const { print } = collectingPrint(); + const { fs, calls, files } = makeFakeFs(); + const confirmImpl = vi.fn(async () => true); + const npmInstallImpl = vi.fn(async (): Promise => ({ ok: true })); + + const result = await runSetupGlobal({ + nonInteractive: false, + print, + fsImpl: fs, + confirmImpl, + npmInstallImpl, + homeDir: "/home/tester", + assessEnvironmentImpl: async () => makeEnv(), + }); + + expect(result.outcome).toBe("completed"); + expect(result.exitCode).toBe(0); + expect(result.mutated).toBe(true); + expect(confirmImpl.mock.calls.length).toBeGreaterThanOrEqual(3); + expect(calls.mkdir).toContain("/home/tester/.npm-global"); + expect(files.get("/home/tester/.npmrc")).toContain("prefix = ~/.npm-global"); + expect(calls.appendFile).toContain("/home/tester/.zshrc"); + expect(files.get("/home/tester/.zshrc")).toContain("# agent-kit setup-global"); + expect(npmInstallImpl).toHaveBeenCalledWith("@dadado/agent-kit-cli"); + }); + + it("stops without mutating further steps when a confirm is declined", async () => { + const { print } = collectingPrint(); + const { fs, calls } = makeFakeFs(); + const confirmImpl = vi.fn(async () => false); + const npmInstallImpl = vi.fn(); + + const result = await runSetupGlobal({ + nonInteractive: false, + print, + fsImpl: fs, + confirmImpl, + npmInstallImpl, + homeDir: "/home/tester", + assessEnvironmentImpl: async () => makeEnv(), + }); + + expect(result.outcome).toBe("cancelled"); + expect(result.exitCode).toBe(1); + expect(result.mutated).toBe(false); + expect(calls.mkdir).toHaveLength(0); + expect(npmInstallImpl).not.toHaveBeenCalled(); + }); +}); + +describe("runSetupGlobal: idempotent marker", () => { + it("skips the PATH append (no re-confirm, no duplicate) when the marker is already present", async () => { + const { print, lines } = collectingPrint(); + const { fs, calls } = makeFakeFs({ + "/home/tester/.zshrc": + '# agent-kit setup-global\nexport PATH="/home/tester/.npm-global/bin:$PATH"\n', + }); + const confirmImpl = vi.fn(async () => true); + const npmInstallImpl = vi.fn(async (): Promise => ({ ok: true })); + + const result = await runSetupGlobal({ + nonInteractive: false, + print, + fsImpl: fs, + confirmImpl, + npmInstallImpl, + homeDir: "/home/tester", + assessEnvironmentImpl: async () => makeEnv(), + }); + + expect(result.outcome).toBe("completed"); + expect(calls.appendFile).toHaveLength(0); + expect(lines.join("\n")).toMatch(/already present.*skipping/i); + // Only set-prefix, npm-install, and verify prompt when the append step is skipped. + expect(confirmImpl.mock.calls.length).toBe(3); + }); +}); + +describe("runSetupGlobal: unsupported shell", () => { + it("prints manual PATH instructions for that step only, without failing the whole run", async () => { + const { print, lines } = collectingPrint(); + const { fs, calls } = makeFakeFs(); + const confirmImpl = vi.fn(async () => true); + const npmInstallImpl = vi.fn(async (): Promise => ({ ok: true })); + + const result: SetupGlobalRunResult = await runSetupGlobal({ + nonInteractive: false, + print, + fsImpl: fs, + confirmImpl, + npmInstallImpl, + homeDir: "/home/tester", + assessEnvironmentImpl: async () => makeEnv({ shell: "fish", shellProfile: null }), + }); + + expect(result.outcome).toBe("completed"); + expect(calls.appendFile).toHaveLength(0); + expect(lines.join("\n")).toMatch(/shell not auto-detected/i); + expect(npmInstallImpl).toHaveBeenCalled(); + // set-prefix, npm-install, verify — append-path has nothing to confirm. + expect(confirmImpl.mock.calls.length).toBe(3); + }); +}); diff --git a/packages/cli/src/commands/setup-global.ts b/packages/cli/src/commands/setup-global.ts new file mode 100644 index 0000000..58a78f2 --- /dev/null +++ b/packages/cli/src/commands/setup-global.ts @@ -0,0 +1,439 @@ +/** + * `agent-kit setup-global` — self-heal for the classic fresh-install blocker: + * a root-owned npm global prefix (e.g. `/usr/local/lib/node_modules` on + * macOS system Node), which turns `npm i -g @dadado/agent-kit-cli` into an + * EACCES and leaves a beginner at `zsh: command not found` after `npx`. + * + * This mechanizes the 4-step manual fix (see plan evidence): relocate npm's + * global prefix to a user-owned directory (`~/.npm-global`), put its `bin` + * on PATH via the detected shell profile, reinstall globally, then verify. + * + * This command mutates user-level state OUTSIDE the repo (`~/.npmrc`, a + * shell profile) and is HITL-sensitive by design: + * - every step is printed (concrete resolved paths/commands) before it + * runs, and each mutating step has its own confirm gate. + * - non-interactive mode (CI, NO_COLOR-adjacent piped stdin, `--yes`, + * `AGENT_KIT_YES=1` — see `isNonInteractive()`) NEVER mutates anything; + * it prints the equivalent manual shell commands and exits 0. + * - `--dry-run` prints the resolved plan and mutates nothing, regardless + * of interactive/non-interactive. + * - never `sudo`, never `chown` — the fix is relocation, not permission + * repair. + * - the shell-profile append is idempotent via a marker comment; a + * second run (or an already-patched profile) skips that step instead + * of re-confirming/re-appending. + * + * Design choice — npm prefix mutation mechanism: this writes `prefix = ` to + * `~/.npmrc` directly (mirroring `parseNpmrcPrefix`/`detectNpmPrefix` in + * `../readiness/env-checks.js`, which already reads that exact line) rather + * than spawning `npm config set prefix`. That keeps the mutation + * dependency-injectable and unit-testable without a real npm binary, avoids + * npm's multi-second cold-start latency during an interactive confirm flow, + * and round-trips cleanly with the existing reader. `npm i -g` itself still + * has to be spawned — there's no way to replicate a real global install. + */ +import { spawn } from "node:child_process"; +import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; +import { confirm, isCancel } from "@clack/prompts"; +import { defineCommand } from "citty"; +import { cyan, green, yellow } from "kolorist"; +import { + type AssessEnvironmentOptions, + type EnvironmentReport, + assessEnvironment, +} from "../readiness/env-checks.js"; +import { classifyInstallError, isNonInteractive } from "../utils/terminal.js"; + +export const NPM_GLOBAL_DIR_NAME = ".npm-global"; +export const SETUP_GLOBAL_MARKER = "# agent-kit setup-global"; +export const DEFAULT_PACKAGE_SPEC = "@dadado/agent-kit-cli"; + +export type SetupGlobalStepId = "set-prefix" | "append-path" | "npm-install" | "verify"; + +export interface SetupGlobalStep { + id: SetupGlobalStepId; + title: string; + /** Concrete, resolved lines describing exactly what this step will do. */ + detail: string[]; +} + +export interface SetupGlobalPlan { + packageSpec: string; + homeDir: string; + npmGlobalDir: string; + npmGlobalBin: string; + npmrcPath: string; + /** The literal value written to .npmrc's `prefix = ` line (tilde form). */ + npmrcPrefixValue: string; + pathExportLine: string; + markerComment: string; + shellProfile: string | null; + shellSupported: boolean; + alreadyWritable: boolean; + currentPrefix: string | null; + steps: SetupGlobalStep[]; +} + +export interface PlanSetupGlobalOptions { + homeDir?: string; + packageSpec?: string; +} + +/** Pure: build the 4-step self-heal plan from an already-assessed environment. */ +export function planSetupGlobalSteps( + env: EnvironmentReport, + options: PlanSetupGlobalOptions = {}, +): SetupGlobalPlan { + const homeDir = options.homeDir ?? homedir(); + const packageSpec = options.packageSpec ?? DEFAULT_PACKAGE_SPEC; + const npmGlobalDir = path.join(homeDir, NPM_GLOBAL_DIR_NAME); + const npmGlobalBin = path.join(npmGlobalDir, "bin"); + const npmrcPath = path.join(homeDir, ".npmrc"); + const npmrcPrefixValue = `~/${NPM_GLOBAL_DIR_NAME}`; + const pathExportLine = `export PATH="${npmGlobalBin}:$PATH"`; + const shellProfile = env.shellProfile; + const shellSupported = shellProfile != null; + + const steps: SetupGlobalStep[] = [ + { + id: "set-prefix", + title: "Set npm's global install prefix to a folder you own", + detail: [ + `mkdir -p ${npmGlobalDir}`, + `npm config set prefix "${npmrcPrefixValue}" (writes "prefix = ${npmrcPrefixValue}" to ${npmrcPath})`, + ], + }, + { + id: "append-path", + title: shellSupported + ? `Put ${npmGlobalBin} on PATH via ${shellProfile}` + : "Put npm's global bin on PATH (manual — shell not auto-detected)", + detail: shellSupported + ? [`Append to ${shellProfile}:`, ` ${SETUP_GLOBAL_MARKER}`, ` ${pathExportLine}`] + : [ + `Shell could not be auto-detected as zsh or bash (detected: ${env.shell ?? "unknown"}).`, + "You'll need to add this line to your shell's startup file yourself:", + ` ${pathExportLine}`, + ], + }, + { + id: "npm-install", + title: `Reinstall ${packageSpec} globally, now into the new prefix`, + detail: [`npm i -g ${packageSpec}`], + }, + { + id: "verify", + title: "Verify `agent-kit` resolves on PATH", + detail: [ + "Re-check whether a bare `agent-kit` resolves on PATH.", + "Needs a new shell session (or `source` the profile) to take effect — this process's own PATH can't reflect it.", + ], + }, + ]; + + return { + packageSpec, + homeDir, + npmGlobalDir, + npmGlobalBin, + npmrcPath, + npmrcPrefixValue, + pathExportLine, + markerComment: SETUP_GLOBAL_MARKER, + shellProfile, + shellSupported, + alreadyWritable: env.npmPrefixWritable, + currentPrefix: env.npmPrefix.prefix, + steps, + }; +} + +/** Upsert a `prefix = ...` line into .npmrc-style content, preserving everything else. */ +export function upsertNpmrcPrefix(content: string, prefixValue: string): string { + const line = `prefix = ${prefixValue}`; + const prefixLineRe = /^\s*prefix\s*=.*$/m; + if (prefixLineRe.test(content)) { + return content.replace(prefixLineRe, line); + } + const withTrailingNewline = + content.length > 0 && !content.endsWith("\n") ? `${content}\n` : content; + return `${withTrailingNewline}${line}\n`; +} + +export interface SetupGlobalFsImpl { + mkdir: (dir: string) => Promise; + readFile: (filePath: string) => Promise; + writeFile: (filePath: string, content: string) => Promise; + appendFile: (filePath: string, content: string) => Promise; +} + +const defaultFsImpl: SetupGlobalFsImpl = { + mkdir: async (dir) => { + await mkdir(dir, { recursive: true }); + }, + readFile: (filePath) => readFile(filePath, "utf8"), + writeFile: (filePath, content) => writeFile(filePath, content, "utf8"), + appendFile: (filePath, content) => appendFile(filePath, content, "utf8"), +}; + +async function safeReadFile(fs: SetupGlobalFsImpl, filePath: string): Promise { + try { + return await fs.readFile(filePath); + } catch { + return ""; + } +} + +export interface NpmInstallOutcome { + ok: boolean; + error?: unknown; +} + +export type NpmInstallImpl = (packageSpec: string) => Promise; + +const defaultNpmInstallImpl: NpmInstallImpl = (packageSpec) => + new Promise((resolve) => { + const child = spawn("npm", ["i", "-g", packageSpec], { stdio: "inherit" }); + child.on("error", (error) => resolve({ ok: false, error })); + child.on("close", (code) => { + if (code === 0) resolve({ ok: true }); + else resolve({ ok: false, error: new Error(`npm exited with code ${code ?? "unknown"}`) }); + }); + }); + +/** Wraps @clack/prompts confirm(); cancel (Ctrl+C / Esc) reads as "no". */ +export type ConfirmStepImpl = (message: string) => Promise; + +const defaultConfirmImpl: ConfirmStepImpl = async (message) => { + const answer = await confirm({ message, initialValue: true }); + if (isCancel(answer)) return false; + return Boolean(answer); +}; + +export interface RunSetupGlobalOptions extends AssessEnvironmentOptions { + dryRun?: boolean; + /** Pre-computed non-interactive decision (caller folds in --yes / AGENT_KIT_YES / CI / TTY). */ + nonInteractive?: boolean; + packageSpec?: string; + print?: (line: string) => void; + fsImpl?: SetupGlobalFsImpl; + npmInstallImpl?: NpmInstallImpl; + confirmImpl?: ConfirmStepImpl; + /** Injectable for tests; defaults to the real assessEnvironment. */ + assessEnvironmentImpl?: (opts: AssessEnvironmentOptions) => Promise; +} + +export type SetupGlobalOutcome = + | "already-ok" + | "dry-run" + | "manual-instructions" + | "completed" + | "cancelled" + | "error"; + +export interface SetupGlobalRunResult { + exitCode: number; + mutated: boolean; + outcome: SetupGlobalOutcome; + env: EnvironmentReport; + plan: SetupGlobalPlan; +} + +function printHeader(env: EnvironmentReport, print: (line: string) => void): void { + print(cyan("agent-kit setup-global")); + print( + ` npm prefix: ${env.npmPrefix.prefix ?? "unknown"} (${ + env.npmPrefixWritable ? "writable" : "NOT writable" + })`, + ); + if (!env.npmPrefixWritable && env.npmPrefix.reason) { + print(` ${env.npmPrefix.reason}`); + } + print( + ` shell: ${env.shell ?? "unknown"}${ + env.shellProfile + ? ` (profile: ${env.shellProfile})` + : " (profile not auto-detected: zsh/bash only)" + }`, + ); +} + +function printPlanSteps(plan: SetupGlobalPlan, print: (line: string) => void): void { + for (const [index, step] of plan.steps.entries()) { + print(`${index + 1}. ${step.title}`); + for (const line of step.detail) print(` ${line}`); + } +} + +function printManualInstructions(plan: SetupGlobalPlan, print: (line: string) => void): void { + print("No changes made. Same fix, as commands you can run yourself:"); + print(` mkdir -p ${plan.npmGlobalDir}`); + print(` npm config set prefix "${plan.npmrcPrefixValue}"`); + if (plan.shellSupported) { + print(` echo '${plan.markerComment}' >> ${plan.shellProfile}`); + print(` echo '${plan.pathExportLine}' >> ${plan.shellProfile}`); + print(` source ${plan.shellProfile}`); + } else { + print(` # add this line to your shell's startup file:`); + print(` ${plan.pathExportLine}`); + } + print(` npm i -g ${plan.packageSpec}`); + print(" agent-kit --version # verify, in a new shell session"); +} + +/** + * Run (or simulate) the setup-global self-heal. Pure enough for tests: all + * filesystem, npm-install, and prompt effects are dependency-injected, and + * every non-mutating path (already-ok / dry-run / non-interactive) never + * touches `fsImpl` or `npmInstallImpl`. + */ +export async function runSetupGlobal( + options: RunSetupGlobalOptions = {}, +): Promise { + const print = options.print ?? ((line: string) => console.log(line)); + const assessEnvironmentImpl = options.assessEnvironmentImpl ?? assessEnvironment; + const homeDir = options.homeDir ?? homedir(); + + const env = await assessEnvironmentImpl(options); + const plan = planSetupGlobalSteps(env, { homeDir, packageSpec: options.packageSpec }); + + if (plan.alreadyWritable) { + printHeader(env, print); + print(green("npm's global prefix is already writable — nothing to fix.")); + return { exitCode: 0, mutated: false, outcome: "already-ok", env, plan }; + } + + if (options.dryRun) { + printHeader(env, print); + print("Dry run — no changes will be made. Steps that would run:"); + printPlanSteps(plan, print); + return { exitCode: 0, mutated: false, outcome: "dry-run", env, plan }; + } + + const nonInteractive = options.nonInteractive ?? isNonInteractive(); + if (nonInteractive) { + printHeader(env, print); + printManualInstructions(plan, print); + return { exitCode: 0, mutated: false, outcome: "manual-instructions", env, plan }; + } + + printHeader(env, print); + print("You just hit the classic 'command not found' / EACCES fresh-install blocker."); + print("The following steps need your confirmation, one at a time:"); + printPlanSteps(plan, print); + + const fs = options.fsImpl ?? defaultFsImpl; + const confirmStep = options.confirmImpl ?? defaultConfirmImpl; + const npmInstall = options.npmInstallImpl ?? defaultNpmInstallImpl; + let mutated = false; + + // Step 1: set-prefix + const setPrefixStep = plan.steps[0] as SetupGlobalStep; + print(`\n${setPrefixStep.title}`); + for (const line of setPrefixStep.detail) print(` ${line}`); + const proceedPrefix = await confirmStep(`Set npm's global prefix to ${plan.npmGlobalDir}?`); + if (!proceedPrefix) { + print(yellow("Cancelled — no changes made.")); + return { exitCode: 1, mutated, outcome: "cancelled", env, plan }; + } + await fs.mkdir(plan.npmGlobalDir); + const npmrcContent = await safeReadFile(fs, plan.npmrcPath); + await fs.writeFile(plan.npmrcPath, upsertNpmrcPrefix(npmrcContent, plan.npmrcPrefixValue)); + mutated = true; + print(green(` done: prefix set (${plan.npmrcPath}).`)); + + // Step 2: append-path + const appendPathStep = plan.steps[1] as SetupGlobalStep; + print(`\n${appendPathStep.title}`); + for (const line of appendPathStep.detail) print(` ${line}`); + if (!plan.shellSupported) { + print(yellow(" shell not auto-detected as zsh/bash — add the line above yourself.")); + } else { + const profilePath = plan.shellProfile as string; + const profileContent = await safeReadFile(fs, profilePath); + if (profileContent.includes(plan.markerComment)) { + print(` already present in ${profilePath} (marker found) — skipping.`); + } else { + const proceedPath = await confirmStep(`Append the PATH export to ${profilePath}?`); + if (!proceedPath) { + print(yellow("Cancelled — prefix was set, PATH export was not appended.")); + return { exitCode: 1, mutated, outcome: "cancelled", env, plan }; + } + await fs.appendFile(profilePath, `\n${plan.markerComment}\n${plan.pathExportLine}\n`); + mutated = true; + print(green(` done: PATH export appended to ${profilePath}.`)); + } + } + + // Step 3: npm-install + const installStep = plan.steps[2] as SetupGlobalStep; + print(`\n${installStep.title}`); + for (const line of installStep.detail) print(` ${line}`); + const proceedInstall = await confirmStep(`Run: npm i -g ${plan.packageSpec}?`); + if (!proceedInstall) { + print(yellow("Cancelled — prefix/PATH changes above are still in place.")); + return { exitCode: 1, mutated, outcome: "cancelled", env, plan }; + } + const installResult = await npmInstall(plan.packageSpec); + if (!installResult.ok) { + const hint = classifyInstallError(installResult.error); + print(` npm install failed: ${hint.message}`); + print(hint.recovery); + return { exitCode: 1, mutated, outcome: "error", env, plan }; + } + mutated = true; + print(green(` done: ${plan.packageSpec} installed globally.`)); + + // Step 4: verify + const verifyStep = plan.steps[3] as SetupGlobalStep; + print(`\n${verifyStep.title}`); + for (const line of verifyStep.detail) print(` ${line}`); + const proceedVerify = await confirmStep("Verify now (re-check PATH in this process)?"); + if (proceedVerify) { + const verifyEnv = await assessEnvironmentImpl(options); + if (verifyEnv.binOnPath) { + print(green(" verify: `agent-kit` resolves on PATH.")); + } else { + print(" verify: `agent-kit` isn't resolvable in THIS process's PATH yet — that's expected."); + print( + ` Open a new terminal (or run: source ${plan.shellProfile ?? ""}) and re-check with: agent-kit --version`, + ); + } + } else { + print(" skipped verification. Open a new terminal and run: agent-kit --version"); + } + + return { exitCode: 0, mutated, outcome: "completed", env, plan }; +} + +export const setupGlobalCommand = defineCommand({ + meta: { + name: "setup-global", + description: + "Self-heal a root-owned npm prefix: relocate to ~/.npm-global, fix PATH, reinstall globally.", + }, + args: { + "dry-run": { + type: "boolean", + description: "Print the resolved plan; mutate nothing.", + default: false, + }, + yes: { + type: "boolean", + alias: "y", + description: + "Treat as non-interactive: print the manual steps instead of prompting (never mutates).", + default: false, + }, + }, + async run({ args }) { + const nonInteractive = args.yes || isNonInteractive(); + const result = await runSetupGlobal({ + dryRun: Boolean(args["dry-run"]), + nonInteractive, + }); + process.exitCode = result.exitCode; + }, +}); diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index d6c13d6..50c949a 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -100,6 +100,7 @@ export const updateCommand = defineCommand({ } catch (err) { if (err instanceof RootRefusedError) { logger.error(err.message); + if (err.recovery) console.error(`\n${err.recovery}\n`); process.exitCode = 1; return; } diff --git a/packages/cli/src/generator/claude-command-adapters.test.ts b/packages/cli/src/generator/claude-command-adapters.test.ts new file mode 100644 index 0000000..117f61e --- /dev/null +++ b/packages/cli/src/generator/claude-command-adapters.test.ts @@ -0,0 +1,154 @@ +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { loadManagedHashLedger } from "../lifecycle/overlay.js"; +import { + CLAUDE_COMMANDS_DIR_REL, + CURSOR_COMMANDS_DIR_REL, + discoverInstalledCommands, + generateClaudeCommandAdapters, + parseCommandFrontmatter, + renderClaudeCommandAdapter, +} from "./claude-command-adapters.js"; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../.."); +const factoryClaudeCommandsDir = path.join(REPO_ROOT, CLAUDE_COMMANDS_DIR_REL); +/** Factory dogfood adapters are private-only; public-sync does not allowlist them. */ +const factoryAdaptersPresent = existsSync(factoryClaudeCommandsDir); + +async function seedCursorCommand(root: string, name: string, description: string): Promise { + const dir = path.join(root, CURSOR_COMMANDS_DIR_REL); + await mkdir(dir, { recursive: true }); + await writeFile( + path.join(dir, `${name}.md`), + `---\nname: ${name}\ndescription: ${description}\n---\n\n# Command: /${name}\n\nBody.\n`, + "utf8", + ); +} + +describe("parseCommandFrontmatter", () => { + it("extracts the description field", () => { + const raw = "---\nname: foo\ndescription: Does the thing.\n---\n\nBody\n"; + expect(parseCommandFrontmatter("foo", raw)).toEqual({ + name: "foo", + description: "Does the thing.", + }); + }); + + it("returns null when there is no frontmatter fence or no description", () => { + expect(parseCommandFrontmatter("foo", "# no frontmatter\n")).toBeNull(); + expect(parseCommandFrontmatter("foo", "---\nname: foo\n---\nbody\n")).toBeNull(); + }); +}); + +describe("renderClaudeCommandAdapter", () => { + it("is a thin pointer: frontmatter description + read-the-SoT body, no copied content", () => { + const out = renderClaudeCommandAdapter({ name: "foo", description: "Does the thing." }); + expect(out).toContain("description: Does the thing."); + expect(out).toContain("Read `.cursor/commands/foo.md` now and follow that contract exactly"); + expect(out).toContain("this file is only a thin adapter for Claude Code"); + expect(out).not.toContain("Body."); + }); + + it.skipIf(!factoryAdaptersPresent)( + "matches every factory dogfood .claude/commands/*.md byte-for-byte", + async () => { + const commands = await discoverInstalledCommands(REPO_ROOT); + expect(commands.length).toBeGreaterThan(0); + for (const command of commands) { + const actual = await readFile( + path.join(factoryClaudeCommandsDir, `${command.name}.md`), + "utf8", + ); + expect(actual).toBe(renderClaudeCommandAdapter(command)); + } + }, + ); +}); + +describe("discoverInstalledCommands", () => { + it("returns [] when .cursor/commands does not exist (not installed)", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ak-claude-cmds-none-")); + expect(await discoverInstalledCommands(root)).toEqual([]); + }); + + it("excludes the reserved agent-kit name and skips unparseable sources", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ak-claude-cmds-reserved-")); + await seedCursorCommand(root, "foo", "Foo command."); + await seedCursorCommand(root, "agent-kit", "Should never be picked up here."); + const dir = path.join(root, CURSOR_COMMANDS_DIR_REL); + await writeFile(path.join(dir, "broken.md"), "no frontmatter here\n", "utf8"); + + const commands = await discoverInstalledCommands(root); + expect(commands).toEqual([{ name: "foo", description: "Foo command." }]); + }); +}); + +describe("generateClaudeCommandAdapters", () => { + it("writes an adapter for exactly the installed command set (none for uninstalled commands)", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ak-claude-cmds-gen-")); + await seedCursorCommand(root, "foo", "Foo command."); + await seedCursorCommand(root, "bar", "Bar command."); + + const results = await generateClaudeCommandAdapters(root); + expect(results).toEqual( + expect.arrayContaining([ + { relativePath: ".claude/commands/foo.md", status: "applied" }, + { relativePath: ".claude/commands/bar.md", status: "applied" }, + ]), + ); + expect(results).toHaveLength(2); + expect(existsSync(path.join(root, ".claude/commands/foo.md"))).toBe(true); + expect(existsSync(path.join(root, ".claude/commands/baz.md"))).toBe(false); + }); + + it("is idempotent: a second run with no source changes reports unchanged, no rewrite churn", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ak-claude-cmds-idempotent-")); + await seedCursorCommand(root, "foo", "Foo command."); + + await generateClaudeCommandAdapters(root); + const second = await generateClaudeCommandAdapters(root); + expect(second).toEqual([{ relativePath: ".claude/commands/foo.md", status: "unchanged" }]); + }); + + it("refreshes a stale-but-unedited adapter when the source description changes", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ak-claude-cmds-refresh-")); + await seedCursorCommand(root, "foo", "Old description."); + await generateClaudeCommandAdapters(root); + + await seedCursorCommand(root, "foo", "New description."); + const results = await generateClaudeCommandAdapters(root); + expect(results).toEqual([{ relativePath: ".claude/commands/foo.md", status: "refreshed" }]); + const body = await readFile(path.join(root, ".claude/commands/foo.md"), "utf8"); + expect(body).toContain("description: New description."); + }); + + it("preserves a hand-edited adapter instead of clobbering it", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ak-claude-cmds-preserve-")); + await seedCursorCommand(root, "foo", "Old description."); + await generateClaudeCommandAdapters(root); + + const customized = + "---\ndescription: My own words.\n---\n\nCustom body, not the SoT pointer.\n"; + await writeFile(path.join(root, ".claude/commands/foo.md"), customized, "utf8"); + + await seedCursorCommand(root, "foo", "New description."); + const results = await generateClaudeCommandAdapters(root); + expect(results).toEqual([ + { relativePath: ".claude/commands/foo.md", status: "preserved-customized" }, + ]); + expect(await readFile(path.join(root, ".claude/commands/foo.md"), "utf8")).toBe(customized); + }); + + it("records generated adapters in the shared managed-hash ledger (overlay prefix)", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ak-claude-cmds-ledger-")); + await seedCursorCommand(root, "foo", "Foo command."); + await generateClaudeCommandAdapters(root); + + const ledger = await loadManagedHashLedger(root); + expect(ledger.hashes[".claude/commands/foo.md"]).toBeDefined(); + }); +}); diff --git a/packages/cli/src/generator/claude-command-adapters.ts b/packages/cli/src/generator/claude-command-adapters.ts new file mode 100644 index 0000000..2d9618c --- /dev/null +++ b/packages/cli/src/generator/claude-command-adapters.ts @@ -0,0 +1,167 @@ +/** + * Claude Code command adapters: `.claude/commands/.md` thin pointer + * files, one per installed `.cursor/commands/.md`, so Claude Code's + * slash catalog mirrors whatever the consumer actually installed. Opt-in + * (flag or pack) — see `applyPersonalization`'s `claudeAdapters` input + * (wired to `install --claude`); this module has no gate of its own. + * + * Thin adapters, single SoT (ADR 2026-08-13_claude-cli-kit-load-bootstrap.md, + * amended 2026-08-21): each generated file is frontmatter + a one-line + * "read the SoT and follow it" body — no copied command prose. Never + * generated for the reserved `agent-kit` name: that file is the kit-load + * `/agent-kit` refresh command (write-once, `claude-kit-load.ts`), a + * different artifact with no `.cursor/commands/agent-kit.md` counterpart. + * + * Overlay semantics: `.claude/commands/` is a `CONSUMER_OVERLAY_PREFIXES` + * entry (`lifecycle/overlay.ts`). A missing target is written. An existing + * target whose content already matches the freshly rendered body is left + * alone. A target that differs is refreshed when it matches the last managed + * hash (or a known shipped body, ledger-absent) and preserved as customized + * otherwise — the consumer's hand edits are never silently clobbered. + * + * Known limitation (not implemented here, tracked as a follow-up in the ADR + * amendment): a `.cursor/commands/.md` that is later removed does not + * cause this pass to delete the corresponding `.claude/commands/.md`. + */ +import { readFile, readdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { + contentHash, + loadManagedHashLedger, + saveManagedHashLedger, + shouldPreserveCustomizedOverlay, +} from "../lifecycle/overlay.js"; +import { ensureDir, fileExists } from "../utils/fs.js"; + +export const CURSOR_COMMANDS_DIR_REL = ".cursor/commands"; +export const CLAUDE_COMMANDS_DIR_REL = ".claude/commands"; + +/** The kit-load `/agent-kit` refresh command; never fought by generation. */ +export const RESERVED_ADAPTER_NAMES = new Set(["agent-kit"]); + +export interface SourceCommand { + name: string; + description: string; +} + +export type ClaudeCommandAdapterStatus = + | "applied" + | "unchanged" + | "refreshed" + | "preserved-customized"; + +export interface ClaudeCommandAdapterResult { + relativePath: string; + status: ClaudeCommandAdapterStatus; +} + +/** Minimal frontmatter reader for `---\nname: x\ndescription: y\n---` bodies. */ +export function parseCommandFrontmatter(name: string, raw: string): SourceCommand | null { + const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw); + if (!match) return null; + const body = match[1] ?? ""; + const descMatch = /^description:\s*(.+)$/m.exec(body); + if (!descMatch) return null; + const description = (descMatch[1] ?? "").trim(); + if (!description) return null; + return { name, description }; +} + +export function renderClaudeCommandAdapter(command: SourceCommand): string { + return `--- +description: ${command.description} +--- + +Read \`.cursor/commands/${command.name}.md\` now and follow that contract exactly — it is the source of truth for /${command.name}; this file is only a thin adapter for Claude Code. + +Adapter rules (Claude Code CLI): +- Cursor "Ask questions" is unavailable here: use AskUserQuestion when possible, else present the same labels as one numbered list per message and WAIT for the answer. +- Skip or cancel means stop. +- Never \`/git-prod\` without an explicit operator yes. +- Do not clone Cursor hooks or invent behavior beyond the SoT file. +`; +} + +/** + * Discover installed `.cursor/commands/*.md` and their name + description. + * An unreadable directory (not installed) yields an empty list, not a throw. + * Sources missing a parseable `description:` are skipped rather than + * generating a broken adapter. + */ +export async function discoverInstalledCommands(rootDir: string): Promise { + const dir = path.join(rootDir, CURSOR_COMMANDS_DIR_REL); + let entries: string[]; + try { + entries = (await readdir(dir)).filter((f) => f.endsWith(".md")); + } catch { + return []; + } + const commands: SourceCommand[] = []; + for (const file of entries.sort()) { + const name = file.slice(0, -3); + if (RESERVED_ADAPTER_NAMES.has(name)) continue; + try { + const raw = await readFile(path.join(dir, file), "utf8"); + const parsed = parseCommandFrontmatter(name, raw); + if (parsed) commands.push(parsed); + } catch { + // Unreadable source file: skip it, do not fail the whole pass. + } + } + return commands; +} + +/** + * Generate/refresh `.claude/commands/.md` pointer adapters for every + * installed `.cursor/commands/*.md`. No adapter is written for a command the + * consumer did not install. Caller decides whether to invoke this at all + * (opt-in gate lives in `applyPersonalization` / `install`, not here). + */ +export async function generateClaudeCommandAdapters( + rootDir: string, +): Promise { + const commands = await discoverInstalledCommands(rootDir); + if (commands.length === 0) return []; + + const ledger = await loadManagedHashLedger(rootDir); + const results: ClaudeCommandAdapterResult[] = []; + let ledgerDirty = false; + + for (const command of commands) { + const relPath = path.posix.join(CLAUDE_COMMANDS_DIR_REL, `${command.name}.md`); + const rendered = renderClaudeCommandAdapter(command); + const abs = path.join(rootDir, relPath); + + if (!(await fileExists(abs))) { + await ensureDir(path.dirname(abs)); + await writeFile(abs, rendered, "utf8"); + ledger.hashes[relPath] = contentHash(rendered); + ledgerDirty = true; + results.push({ relativePath: relPath, status: "applied" }); + continue; + } + + const localContent = await readFile(abs, "utf8"); + if (localContent === rendered) { + if (ledger.hashes[relPath] !== contentHash(rendered)) { + ledger.hashes[relPath] = contentHash(rendered); + ledgerDirty = true; + } + results.push({ relativePath: relPath, status: "unchanged" }); + continue; + } + + if (shouldPreserveCustomizedOverlay(localContent, ledger.hashes[relPath])) { + results.push({ relativePath: relPath, status: "preserved-customized" }); + continue; + } + + await writeFile(abs, rendered, "utf8"); + ledger.hashes[relPath] = contentHash(rendered); + ledgerDirty = true; + results.push({ relativePath: relPath, status: "refreshed" }); + } + + if (ledgerDirty) await saveManagedHashLedger(rootDir, ledger); + return results; +} diff --git a/packages/cli/src/generator/claude-kit-load.test.ts b/packages/cli/src/generator/claude-kit-load.test.ts index c04757d..311265d 100644 --- a/packages/cli/src/generator/claude-kit-load.test.ts +++ b/packages/cli/src/generator/claude-kit-load.test.ts @@ -21,6 +21,15 @@ const factoryCommandPath = path.join(REPO_ROOT, AGENT_KIT_COMMAND_REL); const factoryKitLoadPresent = existsSync(factoryClaudePath) && existsSync(factoryCommandPath); describe("generateClaudeKitLoadArtifacts", () => { + it("carves out the sanctioned SessionStart adapter without reopening rules/agents mirrors (ADR 2026-08-13, amended 2026-08-21)", () => { + const claudeMd = renderClaudeMd(); + expect(claudeMd).toContain( + "Not a copy of Cursor hooks beyond the opt-in SessionStart context adapter (`agent-kit hook session-start --format claude`)", + ); + expect(claudeMd).toContain("no `.claude/rules/` mirrors"); + expect(claudeMd).toContain("no `.claude/agents/` generated from the registry"); + }); + it("matches the pack-contract canonical fences", async () => { const docs = await readFile(path.join(REPO_ROOT, "docs/claude-cli-kit-load.md"), "utf8"); expect(docs).toContain(renderClaudeMd().trimEnd()); diff --git a/packages/cli/src/generator/claude-kit-load.ts b/packages/cli/src/generator/claude-kit-load.ts index bbcf015..b0384d2 100644 --- a/packages/cli/src/generator/claude-kit-load.ts +++ b/packages/cli/src/generator/claude-kit-load.ts @@ -33,7 +33,7 @@ Cursor Ask questions is not available in this CLI. When a command requires a cho - Not Action A7 (Windsurf / VS Code generator parity) - Not Claude external plan-review audits (\`/plan-external-review\`) - Not \`--backend claude\` plan-loop ticks -- Not a copy of Cursor \`sessionStart\` / other IDE hooks +- Not a copy of Cursor hooks beyond the opt-in SessionStart context adapter (\`agent-kit hook session-start --format claude\`); no \`.claude/rules/\` mirrors, no \`.claude/agents/\` generated from the registry `; } diff --git a/packages/cli/src/generator/claude-session-start-hook.test.ts b/packages/cli/src/generator/claude-session-start-hook.test.ts new file mode 100644 index 0000000..71eb982 --- /dev/null +++ b/packages/cli/src/generator/claude-session-start-hook.test.ts @@ -0,0 +1,146 @@ +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + CLAUDE_SETTINGS_REL, + SESSION_START_HOOK_MARKER, + buildSessionStartHookCommand, + buildSessionStartHookEntry, + mergeSessionStartHookIntoSettings, + writeClaudeSessionStartHook, +} from "./claude-session-start-hook.js"; + +describe("buildSessionStartHookCommand", () => { + it("carries the marker, uses ${CLAUDE_PROJECT_DIR} (not a monorepo-relative path), and has no node -e unwrapper", () => { + const cmd = buildSessionStartHookCommand(); + expect(cmd).toContain(SESSION_START_HOOK_MARKER); + expect(cmd).toContain('"${CLAUDE_PROJECT_DIR}/.cursor/hooks/agent/resolve-agent-kit.sh"'); + expect(cmd).not.toContain("node -e"); + expect(cmd).not.toContain("packages/cli/dist"); + }); + + it("is fail-open: exec on success replaces the process, printf degraded text runs otherwise, exit is always 0", () => { + const cmd = buildSessionStartHookCommand(); + expect(cmd).toContain("&& exec $AGENT_KIT_RESOLVED hook session-start --format claude; printf"); + }); +}); + +describe("mergeSessionStartHookIntoSettings", () => { + it("creates hooks.SessionStart from scratch when the file does not exist", () => { + const result = mergeSessionStartHookIntoSettings(null); + expect(result.status).toBe("applied"); + const parsed = JSON.parse(result.content ?? "{}"); + expect(parsed.hooks.SessionStart).toHaveLength(1); + expect(parsed.hooks.SessionStart[0].hooks[0].command).toContain(SESSION_START_HOOK_MARKER); + }); + + it("preserves unrelated top-level keys and other hook types", () => { + const existing = JSON.stringify({ + env: { FOO: "bar" }, + hooks: { PreCompact: [{ hooks: [{ type: "command", command: "echo hi" }] }] }, + }); + const result = mergeSessionStartHookIntoSettings(existing); + const parsed = JSON.parse(result.content ?? "{}"); + expect(parsed.env).toEqual({ FOO: "bar" }); + expect(parsed.hooks.PreCompact).toEqual([{ hooks: [{ type: "command", command: "echo hi" }] }]); + expect(parsed.hooks.SessionStart).toHaveLength(1); + }); + + it("preserves a user's own pre-existing SessionStart hook alongside the kit one (hooks merge, not shadow)", () => { + const existing = JSON.stringify({ + hooks: { SessionStart: [{ hooks: [{ type: "command", command: "echo user-hook" }] }] }, + }); + const result = mergeSessionStartHookIntoSettings(existing); + const parsed = JSON.parse(result.content ?? "{}"); + expect(parsed.hooks.SessionStart).toHaveLength(2); + expect(parsed.hooks.SessionStart[0].hooks[0].command).toBe("echo user-hook"); + expect(parsed.hooks.SessionStart[1].hooks[0].command).toContain(SESSION_START_HOOK_MARKER); + }); + + it("is idempotent: re-merging an already-kit-owned entry reports unchanged and does not duplicate", () => { + const first = mergeSessionStartHookIntoSettings(null); + const second = mergeSessionStartHookIntoSettings(first.content); + expect(second.status).toBe("unchanged"); + const parsed = JSON.parse(second.content ?? "{}"); + expect(parsed.hooks.SessionStart).toHaveLength(1); + }); + + it("refreshes (in place, no duplicate) when the kit entry's own content has drifted from current", () => { + const stale = JSON.stringify({ + hooks: { + SessionStart: [ + { + hooks: [ + { + type: "command", + command: `old command with a stale flavor of ${SESSION_START_HOOK_MARKER}`, + timeout: 5, + }, + ], + }, + ], + }, + }); + const result = mergeSessionStartHookIntoSettings(stale); + expect(result.status).toBe("refreshed"); + const parsed = JSON.parse(result.content ?? "{}"); + expect(parsed.hooks.SessionStart).toEqual([{ hooks: [buildSessionStartHookEntry()] }]); + }); + + it("degrades to print-instructions on unparseable existing JSON, never guesses/overwrites", () => { + const result = mergeSessionStartHookIntoSettings("{ not valid json"); + expect(result.status).toBe("unavailable"); + expect(result.content).toBeNull(); + expect(result.instructions).toContain(CLAUDE_SETTINGS_REL); + expect(result.instructions).toContain(SESSION_START_HOOK_MARKER); + }); +}); + +describe("writeClaudeSessionStartHook", () => { + it("writes .claude/settings.json when absent", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ak-claude-settings-")); + const result = await writeClaudeSessionStartHook(root); + expect(result.status).toBe("applied"); + const body = await readFile(path.join(root, CLAUDE_SETTINGS_REL), "utf8"); + expect(body).toContain(SESSION_START_HOOK_MARKER); + }); + + it("is idempotent across two full install-style runs (fresh install / re-run)", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ak-claude-settings-idempotent-")); + const first = await writeClaudeSessionStartHook(root); + const second = await writeClaudeSessionStartHook(root); + expect(first.status).toBe("applied"); + expect(second.status).toBe("unchanged"); + const parsed = JSON.parse(await readFile(path.join(root, CLAUDE_SETTINGS_REL), "utf8")); + expect(parsed.hooks.SessionStart).toHaveLength(1); + }); + + it("preserves an existing settings.json's unrelated content (existing settings scenario)", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ak-claude-settings-existing-")); + const dir = path.join(root, ".claude"); + await mkdir(dir, { recursive: true }); + await writeFile( + path.join(dir, "settings.json"), + JSON.stringify({ permissions: { allow: ["Bash(git status:*)"] } }), + "utf8", + ); + const result = await writeClaudeSessionStartHook(root); + expect(result.status).toBe("applied"); + const parsed = JSON.parse(await readFile(path.join(dir, "settings.json"), "utf8")); + expect(parsed.permissions).toEqual({ allow: ["Bash(git status:*)"] }); + expect(parsed.hooks.SessionStart).toHaveLength(1); + }); + + it("never writes and surfaces instructions when existing settings.json is unparseable", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ak-claude-settings-broken-")); + await mkdir(path.join(root, ".claude"), { recursive: true }); + const settingsPath = path.join(root, CLAUDE_SETTINGS_REL); + await writeFile(settingsPath, "{ not valid json at all", "utf8"); + + const result = await writeClaudeSessionStartHook(root); + expect(result.status).toBe("unavailable"); + expect(result.instructions).toBeTruthy(); + expect(await readFile(settingsPath, "utf8")).toBe("{ not valid json at all"); + }); +}); diff --git a/packages/cli/src/generator/claude-session-start-hook.ts b/packages/cli/src/generator/claude-session-start-hook.ts new file mode 100644 index 0000000..00ed89a --- /dev/null +++ b/packages/cli/src/generator/claude-session-start-hook.ts @@ -0,0 +1,212 @@ +/** + * Claude Code SessionStart hook delivery: idempotent JSON merge into + * `.claude/settings.json`. Opt-in (see `install --claude`); this module has + * no gate of its own. + * + * Decision (ADR 2026-08-13_claude-cli-kit-load-bootstrap.md, amended + * 2026-08-21; Phase 3 of claude-code-consumer-adapters.plan.md): merge with + * a marker, not skip-if-exists. Two live-docs facts ruled the other two + * options out: + * + * - `.claude/settings.local.json` is routinely auto-created by Claude Code + * itself the first time a user approves a permission prompt ("Claude + * Code also saves permanent 'don't ask again' permission approvals... + * to this file", code.claude.com/docs/en/settings). A skip-if-exists + * write there would silently no-op for most real users, the same + * footgun ruled out for the shared file by this plan's own constraint. + * It is also conventionally gitignored, so it would not ship as a team + * default the way the rest of consumer L0 does. + * - Claude Code hooks *merge* across settings files rather than shadow + * ("Hook entries merge across settings levels rather than replacing + * each other" / "If you define the same handler in more than one + * settings file, it runs once", same docs page). Writing into the + * shared, version-controlled `.claude/settings.json` cannot silently + * lose a user's other hooks: the merge here touches only + * `hooks.SessionStart`, appends alongside whatever is already there, + * and a marker substring in the generated `command` (not a hash ledger: + * this is one generated JSON object, not markdown a consumer is + * expected to hand-tune) makes re-runs idempotent and the entry + * removable. + * + * The one case merge cannot handle safely — existing `.claude/settings.json` + * that is not valid JSON — degrades to `print-instructions` (`unavailable` + * status): never guess at repairing a file we cannot parse, but never go + * silent either. + * + * Command line: resolution reuses the existing L0-installed + * `.cursor/hooks/agent/resolve-agent-kit.sh` (env override -> PATH -> + * `node_modules/.bin/agent-kit` -> factory `packages/cli/dist` fallback), + * addressed via Claude Code's `${CLAUDE_PROJECT_DIR}` placeholder so the + * command resolves correctly regardless of session cwd or worktree (live + * docs: "${CLAUDE_PROJECT_DIR}: the project root where the session + * started... stays put" even inside a worktree). No new `.claude/hooks/` + * script is generated — the ADR amendment sanctions "a thin shell-out from + * settings.json", not a new script surface, and this reuses a script that + * already ships as part of unconditional L0. + * + * Fail-open: `exec` replaces the shell process on success, so the trailing + * `printf` degraded-mode diagnostic only runs when resolution or exec + * itself fails; either way the command's own exit status is 0. + */ +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { ensureDir } from "../utils/fs.js"; + +export const CLAUDE_SETTINGS_REL = ".claude/settings.json"; +export const RESOLVE_AGENT_KIT_REL = ".cursor/hooks/agent/resolve-agent-kit.sh"; + +/** Present in every kit-generated SessionStart command; identifies the kit-owned entry for merge/idempotency/removal. */ +export const SESSION_START_HOOK_MARKER = "hook session-start --format claude"; + +export const SESSION_START_DEGRADED_TEXT = + "Agent Kit hooks are running in degraded fail-open mode: the agent-kit CLI could not be resolved (checked AGENT_KIT_HOOK_BIN, PATH, node_modules/.bin/agent-kit). Slash command adapters still work; session-context injection is inactive. Fix: install the CLI (npm i -D @dadado/agent-kit-cli) or set AGENT_KIT_HOOK_BIN."; + +export function buildSessionStartHookCommand(): string { + return ( + `. "\${CLAUDE_PROJECT_DIR}/${RESOLVE_AGENT_KIT_REL}" 2>/dev/null && resolve_agent_kit && exec $AGENT_KIT_RESOLVED hook session-start --format claude; ` + + `printf '%s' ${shellSingleQuote(SESSION_START_DEGRADED_TEXT)}` + ); +} + +function shellSingleQuote(text: string): string { + return `'${text.replace(/'/g, "'\\''")}'`; +} + +export interface ClaudeSessionStartHookEntry { + type: "command"; + command: string; + timeout: number; + statusMessage: string; +} + +export function buildSessionStartHookEntry(): ClaudeSessionStartHookEntry { + return { + type: "command", + command: buildSessionStartHookCommand(), + timeout: 15, + statusMessage: "Loading Agent Kit session context", + }; +} + +interface HookGroup { + hooks?: unknown; + [key: string]: unknown; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** True when a hook-group's `hooks[]` contains an entry whose command carries the kit marker. */ +function groupHasMarker(group: unknown): boolean { + if (!isPlainObject(group) || !Array.isArray(group.hooks)) return false; + return group.hooks.some( + (h) => + isPlainObject(h) && + typeof h.command === "string" && + h.command.includes(SESSION_START_HOOK_MARKER), + ); +} + +export type SessionStartHookStatus = "applied" | "unchanged" | "refreshed" | "unavailable"; + +export interface SessionStartHookMergeResult { + content: string | null; + status: SessionStartHookStatus; + /** Only set on "unavailable": what to tell the operator to paste by hand. */ + instructions?: string; +} + +/** + * Pure merge: given the existing `.claude/settings.json` text (or null when + * the file does not exist yet), return the next file content. Never touches + * any key other than `hooks.SessionStart`; never touches other SessionStart + * hook groups the user already has. + */ +export function mergeSessionStartHookIntoSettings( + existingRaw: string | null, +): SessionStartHookMergeResult { + const entry = buildSessionStartHookEntry(); + const newGroup: HookGroup = { hooks: [entry] }; + + let root: Record = {}; + if (existingRaw !== null && existingRaw.trim() !== "") { + try { + const parsed = JSON.parse(existingRaw); + if (!isPlainObject(parsed)) throw new Error("root is not an object"); + root = parsed; + } catch { + return { + content: null, + status: "unavailable", + instructions: instructionsBlock(entry), + }; + } + } + + const hooks = isPlainObject(root.hooks) ? { ...root.hooks } : {}; + const sessionStart = Array.isArray(hooks.SessionStart) ? [...hooks.SessionStart] : []; + + const existingIndex = sessionStart.findIndex((g) => groupHasMarker(g)); + let status: SessionStartHookStatus; + if (existingIndex === -1) { + sessionStart.push(newGroup); + status = "applied"; + } else { + const current = sessionStart[existingIndex]; + if (JSON.stringify(current) === JSON.stringify(newGroup)) { + status = "unchanged"; + } else { + sessionStart[existingIndex] = newGroup; + status = "refreshed"; + } + } + + hooks.SessionStart = sessionStart; + root.hooks = hooks; + + return { content: `${JSON.stringify(root, null, 2)}\n`, status }; +} + +function instructionsBlock(entry: ClaudeSessionStartHookEntry): string { + return [ + `Could not parse the existing ${CLAUDE_SETTINGS_REL} as JSON, so Agent Kit did not touch it.`, + "Add this hook by hand under hooks.SessionStart (create the arrays if they do not exist):", + "", + JSON.stringify(entry, null, 2), + ].join("\n"); +} + +export interface WriteSessionStartHookResult { + relativePath: string; + status: SessionStartHookStatus; + instructions?: string; +} + +export async function writeClaudeSessionStartHook( + rootDir: string, +): Promise { + const abs = path.join(rootDir, CLAUDE_SETTINGS_REL); + let existing: string | null = null; + try { + existing = await readFile(abs, "utf8"); + } catch { + existing = null; + } + + const merged = mergeSessionStartHookIntoSettings(existing); + if (merged.status === "unavailable" || merged.content === null) { + return { + relativePath: CLAUDE_SETTINGS_REL, + status: "unavailable", + instructions: merged.instructions, + }; + } + if (merged.status === "unchanged") { + return { relativePath: CLAUDE_SETTINGS_REL, status: "unchanged" }; + } + + await ensureDir(path.dirname(abs)); + await writeFile(abs, merged.content, "utf8"); + return { relativePath: CLAUDE_SETTINGS_REL, status: merged.status }; +} diff --git a/packages/cli/src/generator/personalization.test.ts b/packages/cli/src/generator/personalization.test.ts index 864c8e0..5d10514 100644 --- a/packages/cli/src/generator/personalization.test.ts +++ b/packages/cli/src/generator/personalization.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -130,6 +130,74 @@ describe("repository personalization", () => { expect(await fileExists(path.join(root, ".cursor/context/personalization.json"))).toBe(true); }); + it("does not generate .claude/commands/*.md adapters by default (opt-in only, byte-identical L0)", async () => { + const { root, profile, report } = await preparedNodeRepository(); + const registry = await loadRegistry(REPOSITORY_ROOT); + await mkdir(path.join(root, ".cursor/commands"), { recursive: true }); + await writeFile( + path.join(root, ".cursor/commands/foo.md"), + "---\nname: foo\ndescription: Foo command.\n---\n\nBody.\n", + "utf8", + ); + + const applied = await applyPersonalization({ + rootDir: root, + registryRoot: REPOSITORY_ROOT, + profile, + report, + registry, + manifest: buildManifest({ version: "4.4.7" }), + generatorVersion: "4.4.7", + }); + + expect(await fileExists(path.join(root, ".claude/commands/foo.md"))).toBe(false); + expect(await fileExists(path.join(root, ".claude/settings.json"))).toBe(false); + expect(applied.result.items.some((item) => item.id === ".claude/commands/foo.md")).toBe(false); + expect(applied.result.items.some((item) => item.id === ".claude/settings.json")).toBe(false); + }); + + it("generates .claude/commands/*.md adapters and merges the SessionStart hook when opted in", async () => { + const { root, profile, report } = await preparedNodeRepository(); + const registry = await loadRegistry(REPOSITORY_ROOT); + await mkdir(path.join(root, ".cursor/commands"), { recursive: true }); + await writeFile( + path.join(root, ".cursor/commands/foo.md"), + "---\nname: foo\ndescription: Foo command.\n---\n\nBody.\n", + "utf8", + ); + + const applied = await applyPersonalization({ + rootDir: root, + registryRoot: REPOSITORY_ROOT, + profile, + report, + registry, + manifest: buildManifest({ version: "4.4.7" }), + generatorVersion: "4.4.7", + claudeAdapters: true, + }); + + const adapterPath = path.join(root, ".claude/commands/foo.md"); + expect(await fileExists(adapterPath)).toBe(true); + expect(await readFile(adapterPath, "utf8")).toContain( + "Read `.cursor/commands/foo.md` now and follow that contract exactly", + ); + expect(applied.result.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: ".claude/commands/foo.md", status: "applied" }), + expect.objectContaining({ id: ".claude/settings.json", status: "applied" }), + ]), + ); + expect(applied.manifest.protected).toEqual( + expect.arrayContaining([".claude/commands/foo.md", ".claude/settings.json"]), + ); + const settings = JSON.parse(await readFile(path.join(root, ".claude/settings.json"), "utf8")); + expect(settings.hooks.SessionStart[0].hooks[0].command).toContain( + "hook session-start --format claude", + ); + expect(applied.result.claudeSessionStartInstructions).toBeUndefined(); + }); + it("omits unverified provider and legacy version claims", async () => { const { root, profile, report } = await preparedNodeRepository(); const registry = await loadRegistry(REPOSITORY_ROOT); diff --git a/packages/cli/src/generator/personalization.ts b/packages/cli/src/generator/personalization.ts index cf6f04a..81163be 100644 --- a/packages/cli/src/generator/personalization.ts +++ b/packages/cli/src/generator/personalization.ts @@ -19,7 +19,9 @@ import type { RepositoryPurpose, } from "../types.js"; import { ensureDir, fileExists, writeJson } from "../utils/fs.js"; +import { generateClaudeCommandAdapters } from "./claude-command-adapters.js"; import { generateClaudeKitLoadArtifacts } from "./claude-kit-load.js"; +import { writeClaudeSessionStartHook } from "./claude-session-start-hook.js"; import { generateVSCodeArtifacts } from "./vscode.js"; export const PERSONALIZATION_CONTRACT_VERSION = 1 as const; @@ -46,6 +48,13 @@ export interface PersonalizationResult { repositoryFingerprint: string; items: PersonalizationItem[]; protectedPaths: string[]; + /** + * Set only when `claudeAdapters` was requested and an existing + * `.claude/settings.json` could not be parsed: never silently skipped, + * the caller (install epilogue) must print this so the operator can add + * the SessionStart hook by hand. + */ + claudeSessionStartInstructions?: string; } const CONTEXT_PATH = ".cursor/project-context.md"; @@ -327,6 +336,16 @@ export async function applyPersonalization(input: { registry: RegistryIndex; manifest: AgentKitManifest; generatorVersion: string; + /** + * Opt-in only (default install behavior unchanged for Cursor-only + * consumers): generates both `.claude/commands/.md` thin pointer + * adapters for every installed `.cursor/commands/*.md`, and a SessionStart + * hook merged into `.claude/settings.json` (ADR + * 2026-08-13_claude-cli-kit-load-bootstrap.md, amended 2026-08-21). One + * flag for the whole Claude Code consumer surface, wired to `install + * --claude`. + */ + claudeAdapters?: boolean; }): Promise<{ result: PersonalizationResult; manifest: AgentKitManifest }> { const planned = buildPersonalizationPlan(input.profile, input.report, input.registry); const componentResults: PersonalizationItem[] = []; @@ -414,6 +433,43 @@ export async function applyPersonalization(input: { }; }); + const claudeCommandItems: PersonalizationItem[] = []; + let claudeSessionStartInstructions: string | undefined; + if (input.claudeAdapters) { + const adapterResults = await generateClaudeCommandAdapters(input.rootDir); + for (const artifact of adapterResults) { + protectedPaths.add(artifact.relativePath); + claudeCommandItems.push({ + kind: "file", + id: artifact.relativePath, + path: artifact.relativePath, + // Overlay statuses (applied/unchanged/refreshed/preserved-customized) + // fold onto the shared PersonalizationStatus union: anything written + // or already current reads as "applied"; a hand-edited adapter left + // alone reads as "skipped-customized" (same meaning as elsewhere in + // this file — never silently clobbered). + status: artifact.status === "preserved-customized" ? "skipped-customized" : "applied", + evidence: profileEvidence, + }); + } + + const hookResult = await writeClaudeSessionStartHook(input.rootDir); + protectedPaths.add(hookResult.relativePath); + claudeCommandItems.push({ + kind: "file", + id: hookResult.relativePath, + path: hookResult.relativePath, + // "unavailable" (existing .claude/settings.json unparseable) is a real + // PersonalizationStatus value already; every other hook status folds + // onto "applied" the same way the command-adapter statuses do above. + status: hookResult.status === "unavailable" ? "unavailable" : "applied", + evidence: profileEvidence, + }); + if (hookResult.status === "unavailable" && hookResult.instructions) { + claudeSessionStartInstructions = hookResult.instructions; + } + } + const ideDetection = await detectIde(input.rootDir); if (ideDetection.ide === "vscode" || ideDetection.ide === "other") { const git: GitDetection = { @@ -462,8 +518,9 @@ export async function applyPersonalization(input: { contractVersion: PERSONALIZATION_CONTRACT_VERSION, generatorVersion: input.generatorVersion, repositoryFingerprint: input.report.repositoryFingerprint, - items: [...fileResults, ...claudeItems, ...componentResults], + items: [...fileResults, ...claudeItems, ...claudeCommandItems, ...componentResults], protectedPaths: [...protectedPaths].sort(), + ...(claudeSessionStartInstructions ? { claudeSessionStartInstructions } : {}), }; await writeJson(path.join(input.rootDir, RESULT_PATH), result); diff --git a/packages/cli/src/hooks/format-session-start.test.ts b/packages/cli/src/hooks/format-session-start.test.ts new file mode 100644 index 0000000..27c8be1 --- /dev/null +++ b/packages/cli/src/hooks/format-session-start.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + SESSION_START_DEGRADED_MESSAGE, + formatSessionStartOutput, + resolveSessionStartFormat, +} from "./format-session-start.js"; + +describe("resolveSessionStartFormat", () => { + it("resolves 'claude' explicitly", () => { + expect(resolveSessionStartFormat("claude")).toBe("claude"); + }); + + it("defaults to 'cursor' for 'cursor', undefined, unknown strings, and non-strings", () => { + expect(resolveSessionStartFormat("cursor")).toBe("cursor"); + expect(resolveSessionStartFormat(undefined)).toBe("cursor"); + expect(resolveSessionStartFormat("bogus")).toBe("cursor"); + expect(resolveSessionStartFormat(42)).toBe("cursor"); + expect(resolveSessionStartFormat(null)).toBe("cursor"); + }); +}); + +describe("formatSessionStartOutput", () => { + it('cursor format wraps in {"additional_context": ...} JSON (Cursor adapter contract unchanged)', () => { + expect(formatSessionStartOutput("hello world", "cursor")).toBe( + JSON.stringify({ additional_context: "hello world" }), + ); + }); + + it("claude format is plain text passthrough, no JSON wrapper", () => { + expect(formatSessionStartOutput("hello world", "claude")).toBe("hello world"); + }); + + it("claude format needs no consumer-side unwrapping: the string is exactly the context", () => { + const context = '## Section\n\nSome *markdown* content with "quotes" and a\nnewline.'; + const out = formatSessionStartOutput(context, "claude"); + expect(out).toBe(context); + expect(() => JSON.parse(out)).toThrow(); + }); + + it("degraded message is non-empty and renders in both formats", () => { + expect(SESSION_START_DEGRADED_MESSAGE.length).toBeGreaterThan(0); + expect(formatSessionStartOutput(SESSION_START_DEGRADED_MESSAGE, "claude")).toBe( + SESSION_START_DEGRADED_MESSAGE, + ); + expect( + JSON.parse(formatSessionStartOutput(SESSION_START_DEGRADED_MESSAGE, "cursor")) + .additional_context, + ).toBe(SESSION_START_DEGRADED_MESSAGE); + }); +}); diff --git a/packages/cli/src/hooks/format-session-start.ts b/packages/cli/src/hooks/format-session-start.ts new file mode 100644 index 0000000..3c14e10 --- /dev/null +++ b/packages/cli/src/hooks/format-session-start.ts @@ -0,0 +1,45 @@ +/** + * sessionStart hook output formatting shared by both consumer hosts. + * + * Cursor's adapter (`.cursor/hooks/agent/session-start.sh`) expects stdout as + * `{"additional_context": "..."}` JSON — that shape is unchanged here. + * + * Claude Code's SessionStart hook accepts plain stdout text directly as + * session context; no JSON wrapper is required (live docs, + * code.claude.com/docs/en/hooks, "SessionStart": "For UserPromptSubmit, + * UserPromptExpansion, and SessionStart... Claude Code adds plain-text stdout + * as context that Claude can see and act on" — the simplest of the two + * documented shapes, the other being `hookSpecificOutput.additionalContext` + * JSON). Emitting plain text CLI-side for `--format claude` means the + * consumer `.claude/settings.json` command needs no `node -e` JSON-unwrapper: + * `agent-kit hook session-start --format claude` is directly consumable. + */ + +export type SessionStartHookFormat = "cursor" | "claude"; + +/** Unknown/omitted values fall back to `cursor` (default unchanged). */ +export function resolveSessionStartFormat(value: unknown): SessionStartHookFormat { + return value === "claude" ? "claude" : "cursor"; +} + +export function formatSessionStartOutput( + additionalContext: string, + format: SessionStartHookFormat, +): string { + if (format === "claude") return additionalContext; + return JSON.stringify({ additional_context: additionalContext }); +} + +/** + * Fail-open diagnostic parity with the Cursor sessionStart adapter + * (docs/marketplace.md, "Hook resolution boundary"): sessionStart is the one + * hook surface that can carry text into the session, so this command must + * never throw a non-zero exit even on an unexpected internal failure. The + * *content* differs from Cursor's CLI-unresolved diagnostic on purpose — that + * failure mode (agent-kit not resolvable at all) is a shell-level concern + * handled by the consumer's own command line, not by this already-running + * process — but the guarantee is identical: fail-open, exit 0, session start + * is never blocked. + */ +export const SESSION_START_DEGRADED_MESSAGE = + "Agent Kit session-start context is unavailable this session (internal error, fail-open mode). Nothing else is affected; retry next session."; diff --git a/packages/cli/src/hooks/session-start.test.ts b/packages/cli/src/hooks/session-start.test.ts index 3c4c1b3..497b7c7 100644 --- a/packages/cli/src/hooks/session-start.test.ts +++ b/packages/cli/src/hooks/session-start.test.ts @@ -6,9 +6,14 @@ import { describe, expect, it, vi } from "vitest"; import { CURSOR_AWARENESS_NUDGE } from "./hard-rules.js"; import { buildPreCompactUserMessage } from "./pre-compact.js"; import { + type AuditSessionCommandRunner, type CursorAwarenessSpawn, buildSessionStartAdditionalContext, cursorAwarenessSection, + detachedAuditSessionsSection, + formatSessionAge, + parseScreenDetachedAuditSessions, + parseTmuxDetachedAuditSessions, parseUnprocessedDogfoodItems, shouldEmitCursorAwarenessNudge, } from "./session-start.js"; @@ -244,6 +249,106 @@ describe("buildSessionStartAdditionalContext", () => { }); }); +describe("detached audit-session visibility (phase3)", () => { + const NOW_EPOCH = 1_760_000_000; + const NOW_MS = NOW_EPOCH * 1000; + + it("tmux parser: detached namespace-wide only (scoped, legacy, foreign tokens)", () => { + const out = [ + // Detached workspace-scoped: counted. + `agent-kit-audit-deadbeef-101 0 ${NOW_EPOCH - 300}`, + // Detached legacy unscoped: counted. + `agent-kit-audit-1234 0 ${NOW_EPOCH - 7200}`, + // Detached foreign token: counted (host-wide namespace). + `agent-kit-audit-cafebabe-77 0 ${NOW_EPOCH - 60}`, + // Attached audit session: operator work in progress, never counted. + `agent-kit-audit-deadbeef-202 1 ${NOW_EPOCH - 9999}`, + // Non-audit session: excluded. + `main 0 ${NOW_EPOCH - 50}`, + "", + ].join("\n"); + const sessions = parseTmuxDetachedAuditSessions(out, NOW_EPOCH); + expect(sessions.map((s) => s.name)).toEqual([ + "agent-kit-audit-deadbeef-101", + "agent-kit-audit-1234", + "agent-kit-audit-cafebabe-77", + ]); + expect(sessions.map((s) => s.ageSeconds)).toEqual([300, 7200, 60]); + }); + + it("screen parser: Detached counted, Attached excluded, socket path resolved", () => { + const listing = [ + "There are screens on:", + "\t4242.agent-kit-audit-deadbeef-99\t(Detached)", + "\t4243.agent-kit-audit-77\t(Attached)", + "\t4244.other-session\t(Detached)", + "3 Sockets in /tmp/screens/S-user.", + "", + ].join("\n"); + const entries = parseScreenDetachedAuditSessions(listing); + // "(Detached)" must not match the [Aa]ttached exclusion (single t before "ached"). + expect(entries).toEqual([ + { + name: "agent-kit-audit-deadbeef-99", + socketPath: "/tmp/screens/S-user/4242.agent-kit-audit-deadbeef-99", + }, + ]); + }); + + it("formatSessionAge humanizes seconds", () => { + expect(formatSessionAge(42)).toBe("42s"); + expect(formatSessionAge(90)).toBe("1m"); + expect(formatSessionAge(7200)).toBe("2h"); + expect(formatSessionAge(180_000)).toBe("2d"); + }); + + it("emits count and oldest age when detached sessions exist", async () => { + const runCommand: AuditSessionCommandRunner = async (cmd) => + cmd === "tmux" + ? [ + `agent-kit-audit-deadbeef-101 0 ${NOW_EPOCH - 300}`, + `agent-kit-audit-1234 0 ${NOW_EPOCH - 7200}`, + ].join("\n") + : null; + const section = await detachedAuditSessionsSection({ runCommand, now: () => NOW_MS }); + expect(section).toContain("## Detached audit sessions (host)"); + expect(section).toContain("2 detached `agent-kit-audit-*` PTY sessions"); + expect(section).toContain("oldest ~2h"); + }); + + it("reports oldest age unknown when no age is determinable", async () => { + const listing = [ + "There is a screen on:", + "\t4242.agent-kit-audit-deadbeef-99\t(Detached)", + "1 Socket in /nonexistent-sockdir-for-test.", + "", + ].join("\n"); + const runCommand: AuditSessionCommandRunner = async (cmd) => + cmd === "screen" ? listing : null; + const section = await detachedAuditSessionsSection({ runCommand, now: () => NOW_MS }); + expect(section).toContain("1 detached `agent-kit-audit-*` PTY session on"); + expect(section).toContain("(oldest age unknown)"); + }); + + it("is silent when no audit sessions exist", async () => { + const runCommand: AuditSessionCommandRunner = async (cmd) => + cmd === "tmux" ? `main 0 ${NOW_EPOCH - 50}\n` : "No Sockets found in /tmp/screens/S-user.\n"; + expect(await detachedAuditSessionsSection({ runCommand, now: () => NOW_MS })).toBeNull(); + }); + + it("is silent when both tools are missing (runner yields null)", async () => { + const runCommand: AuditSessionCommandRunner = async () => null; + expect(await detachedAuditSessionsSection({ runCommand, now: () => NOW_MS })).toBeNull(); + }); + + it("fails open (null) when the runner throws", async () => { + const runCommand: AuditSessionCommandRunner = async () => { + throw new Error("boom"); + }; + expect(await detachedAuditSessionsSection({ runCommand, now: () => NOW_MS })).toBeNull(); + }); +}); + describe("cursor awareness sessionStart gate (T4)", () => { it("shouldEmitCursorAwarenessNudge requires changelog-ahead", () => { expect( diff --git a/packages/cli/src/hooks/session-start.ts b/packages/cli/src/hooks/session-start.ts index 3a69850..85a3ed7 100644 --- a/packages/cli/src/hooks/session-start.ts +++ b/packages/cli/src/hooks/session-start.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import { access, readFile } from "node:fs/promises"; +import { access, readFile, stat } from "node:fs/promises"; import path from "node:path"; import { validateHandoffText } from "../invariants/handoff-schema.js"; import { CHANGELOG_FETCH_TIMEOUT_MS } from "../lifecycle/cursor-update-awareness.js"; @@ -419,6 +419,151 @@ export async function cursorAwarenessSection( return CURSOR_AWARENESS_NUDGE; } +/** + * Detached audit-session visibility (plan phase3-visibility). + * + * Mirrors the launcher's host-scope listing semantics + * (.cursor/scripts/plan-external-review.sh `list_audit_sessions host`): the whole + * `agent-kit-audit-` namespace (any workspace token, legacy unscoped + * `agent-kit-audit-` names included), detached sessions only — attached + * sessions are operator work in progress and are never counted. Implemented + * natively (one `tmux list-sessions` + one `screen -ls`, short timeout) instead of + * spawning the launcher script. Fail-open: silence on zero sessions, missing + * tools, or any error; sessionStart is never broken or blocked by this section. + */ +const AUDIT_SESSION_NS_PREFIX = "agent-kit-audit-"; + +/** Per-command ceiling for the tmux/screen listing shell-outs. */ +export const AUDIT_SESSION_LIST_TIMEOUT_MS = 2_000; + +export interface DetachedAuditSession { + channel: "tmux" | "screen"; + name: string; + /** Seconds since the session started; -1 when it cannot be determined. */ + ageSeconds: number; +} + +export type AuditSessionCommandRunner = (cmd: string, args: string[]) => Promise; + +function runAuditSessionCommand(cmd: string, args: string[]): Promise { + return new Promise((resolve) => { + const child = spawn(cmd, args, { + stdio: ["ignore", "pipe", "ignore"], + timeout: AUDIT_SESSION_LIST_TIMEOUT_MS, + shell: false, + }); + let out = ""; + child.stdout?.on("data", (chunk: Buffer) => { + out += chunk.toString("utf8"); + }); + // Tool missing / spawn failure: fail-open (null). + child.on("error", () => resolve(null)); + // `screen -ls` exits 1 while successfully listing, so never gate on the exit code. + child.on("close", () => resolve(out)); + }); +} + +/** Parse `tmux list-sessions -F '#{session_name} #{session_attached} #{session_created}'`. */ +export function parseTmuxDetachedAuditSessions( + out: string, + nowEpochSeconds: number, +): DetachedAuditSession[] { + const sessions: DetachedAuditSession[] = []; + for (const line of out.split(/\r?\n/)) { + const m = /^(\S+)\s+(\d+)\s+(\d+)$/.exec(line.trim()); + if (!m) continue; + const [, name, attached, created] = m; + if (!name || !name.startsWith(AUDIT_SESSION_NS_PREFIX)) continue; + if (Number(attached) > 0) continue; + const createdEpoch = Number(created); + const ageSeconds = + Number.isFinite(createdEpoch) && nowEpochSeconds >= createdEpoch + ? nowEpochSeconds - createdEpoch + : -1; + sessions.push({ channel: "tmux", name, ageSeconds }); + } + return sessions; +} + +export interface ScreenAuditSessionEntry { + name: string; + /** Socket file whose mtime approximates session start; null when the dir is unknown. */ + socketPath: string | null; +} + +/** Parse `screen -ls` output into detached namespace entries (age comes from socket mtime). */ +export function parseScreenDetachedAuditSessions(listing: string): ScreenAuditSessionEntry[] { + const lines = listing.split(/\r?\n/); + // The "N Sockets in ." line trails the session list, so resolve it first. + let sockdir: string | null = null; + for (const line of lines) { + const dirMatch = /^\d+\s+Sockets?\s+in\s+(.+)\.$/.exec(line); + if (dirMatch?.[1]) sockdir = dirMatch[1]; + } + const entries: ScreenAuditSessionEntry[] = []; + for (const line of lines) { + const m = /^\s+(\d+)\.(\S+)\s+\((.*)\)/.exec(line); + if (!m) continue; + const [, pid, name, marker] = m; + if (!name || !name.startsWith(AUDIT_SESSION_NS_PREFIX)) continue; + // "Detached" has a single t before "ached", so it never matches [Aa]ttached. + if (marker && /[Aa]ttached/.test(marker)) continue; + entries.push({ name, socketPath: sockdir ? path.join(sockdir, `${pid}.${name}`) : null }); + } + return entries; +} + +export function formatSessionAge(seconds: number): string { + if (seconds >= 86_400) return `${Math.floor(seconds / 86_400)}d`; + if (seconds >= 3_600) return `${Math.floor(seconds / 3_600)}h`; + if (seconds >= 60) return `${Math.floor(seconds / 60)}m`; + return `${seconds}s`; +} + +export async function detachedAuditSessionsSection( + deps: { runCommand?: AuditSessionCommandRunner; now?: () => number } = {}, +): Promise { + try { + const run = deps.runCommand ?? runAuditSessionCommand; + const nowMs = (deps.now ?? Date.now)(); + const [tmuxOut, screenOut] = await Promise.all([ + run("tmux", [ + "list-sessions", + "-F", + "#{session_name} #{session_attached} #{session_created}", + ]), + run("screen", ["-ls"]), + ]); + const sessions: DetachedAuditSession[] = tmuxOut + ? parseTmuxDetachedAuditSessions(tmuxOut, Math.floor(nowMs / 1000)) + : []; + if (screenOut) { + for (const entry of parseScreenDetachedAuditSessions(screenOut)) { + let ageSeconds = -1; + if (entry.socketPath) { + try { + const { mtimeMs } = await stat(entry.socketPath); + if (nowMs >= mtimeMs) ageSeconds = Math.floor((nowMs - mtimeMs) / 1000); + } catch { + // Unknown age; the session is still counted. + } + } + sessions.push({ channel: "screen", name: entry.name, ageSeconds }); + } + } + if (sessions.length === 0) return null; + const knownAges = sessions.map((s) => s.ageSeconds).filter((a) => a >= 0); + const oldest = knownAges.length + ? `oldest ~${formatSessionAge(Math.max(...knownAges))}` + : "oldest age unknown"; + const noun = sessions.length === 1 ? "session" : "sessions"; + return `## Detached audit sessions (host)\n\n${sessions.length} detached \`agent-kit-audit-*\` PTY ${noun} on this host (${oldest}). These are external plan-review terminals: inspect with \`tmux attach -t \` / \`screen -r \`, or let the audit launcher's session GC dispose of them on the next spawn.`; + } catch { + // Fail-open: visibility must never break sessionStart. + return null; + } +} + export async function buildSessionStartAdditionalContext( rootDir: string, _payload: SessionStartPayload = {}, @@ -443,6 +588,11 @@ export async function buildSessionStartAdditionalContext( const cursorNudge = await cursorAwarenessSection(root); if (cursorNudge) parts.push(cursorNudge); + // Belt and suspenders on top of the section's own try/catch: this section has no + // config gate, so it runs on every sessionStart and must stay fail-open. + const auditSessions = await detachedAuditSessionsSection().catch(() => null); + if (auditSessions) parts.push(auditSessions); + const formatWarnings = validateHandoffText(handoffFull); if (formatWarnings.length) { const bullet = formatWarnings.map((w) => `- ${w.message}`).join("\n"); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 3db8050..4defe43 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -15,6 +15,7 @@ import { installCommand } from "./commands/install.js"; import { monitorsCommand } from "./commands/monitors.js"; import { runPlanCommand } from "./commands/run-plan.js"; import { scanCommand } from "./commands/scan.js"; +import { setupGlobalCommand } from "./commands/setup-global.js"; import { statusCommand } from "./commands/status.js"; import { updateCommand } from "./commands/update.js"; import { validateCommand } from "./commands/validate.js"; @@ -34,6 +35,7 @@ const main = defineCommand({ scan: scanCommand, add: addCommand, doctor: doctorCommand, + "setup-global": setupGlobalCommand, status: statusCommand, update: updateCommand, "cursor-awareness": cursorAwarenessCommand, diff --git a/packages/cli/src/invariants/secrets-scan.test.ts b/packages/cli/src/invariants/secrets-scan.test.ts index 660a8b6..cdb5b98 100644 --- a/packages/cli/src/invariants/secrets-scan.test.ts +++ b/packages/cli/src/invariants/secrets-scan.test.ts @@ -1,5 +1,10 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { scanTextForSecrets } from "./secrets-scan.js"; +import { SECRET_PATTERNS, scanTextForSecrets } from "./secrets-scan.js"; + +const repoRoot = resolve(fileURLToPath(import.meta.url), "../../../../.."); describe("scanTextForSecrets", () => { it("returns empty for clean prompt", () => { @@ -29,6 +34,37 @@ describe("scanTextForSecrets", () => { expect(hit?.excerpt).toContain("*"); }); + it("detects hyphenated vendor sk- keys that openai-sk cannot cross", () => { + // Split so no contiguous key literal exists in the repo (public-sync content guard). + const anthropic = `paste ${"sk"}-${"ant"}-api03-${"A1b2C3d4E5f6G7h8J9k0L1m2"}`; + const hits = scanTextForSecrets(anthropic); + expect(hits.some((h) => h.patternId === "sk-hyphenated-vendor")).toBe(true); + // The OpenAI-shaped pattern must not claim this shape: its body class excludes `-`. + expect(hits.some((h) => h.patternId === "openai-sk")).toBe(false); + + const project = `paste ${"sk"}-${"proj"}-${"A1b2C3d4E5f6G7h8J9k0L1m2"}`; + expect(scanTextForSecrets(project).some((h) => h.patternId === "sk-hyphenated-vendor")).toBe( + true, + ); + }); + + it("still detects single-segment sk- keys under openai-sk", () => { + const sample = `paste ${"sk"}-${"A1b2C3d4E5f6G7h8J9k0L1m2"}`; + const hits = scanTextForSecrets(sample); + expect(hits.some((h) => h.patternId === "openai-sk")).toBe(true); + expect(hits.some((h) => h.patternId === "sk-hyphenated-vendor")).toBe(false); + }); + + it("masks hyphenated vendor key bodies in excerpts", () => { + const body = "A1b2C3d4E5f6G7h8J9k0L1m2"; + const sample = `paste ${"sk"}-${"ant"}-api03-${body}`; + const hit = scanTextForSecrets(sample).find((h) => h.patternId === "sk-hyphenated-vendor"); + expect(hit).toBeTruthy(); + expect(hit?.excerpt).not.toMatch(new RegExp(body)); + expect(hit?.excerpt).not.toMatch(/api03/); + expect(hit?.excerpt).toContain("*"); + }); + it("masks json-secret-kv values in excerpts", () => { const sample = 'config: {"apiKey": "A1b2C3d4E5f6G7h8J9k0"} end'; const hits = scanTextForSecrets(sample); @@ -39,3 +75,34 @@ describe("scanTextForSecrets", () => { expect(hit?.excerpt).toMatch(/apiKey"\s*:\s*"\*+/); }); }); + +describe("pre-commit check-secrets parity is one-way", () => { + const hook = readFileSync(resolve(repoRoot, ".cursor/hooks/pre-commit/check-secrets.sh"), "utf8"); + + it("pins the hook to the single json-secret-kv expression", () => { + // The comment above SECRET_PATTERNS states the hook greps exactly one expression. + const greps = hook.split("\n").filter((line) => /^\s*if grep -E /.test(line)); + expect(greps).toHaveLength(1); + expect(greps[0]).toContain('"(password|apiKey|api_key|secret|token|auth)"'); + }); + + it("pins the hook to its extension allowlist", () => { + expect(hook).toContain("*.json|*.js|*.ts|*.env)"); + // No second `case` arm: .md / .yaml / .sh / dotfiles are not scanned. + expect(hook.match(/^\s*\*[^)]*\)\s*$/gm) ?? []).toHaveLength(1); + }); + + it("keeps the superset direction true: only json-secret-kv has a hook counterpart", () => { + const withCounterpart = SECRET_PATTERNS.filter(({ id }) => id === "json-secret-kv"); + const withoutCounterpart = SECRET_PATTERNS.filter(({ id }) => id !== "json-secret-kv"); + expect(withCounterpart).toHaveLength(1); + // Named in the comment; if this list changes the comment must change with it. + expect(withoutCounterpart.map((p) => p.id)).toEqual([ + "env-assignment", + "aws-access-key", + "github-pat", + "sk-hyphenated-vendor", + "openai-sk", + ]); + }); +}); diff --git a/packages/cli/src/invariants/secrets-scan.ts b/packages/cli/src/invariants/secrets-scan.ts index ef097c9..b954584 100644 --- a/packages/cli/src/invariants/secrets-scan.ts +++ b/packages/cli/src/invariants/secrets-scan.ts @@ -7,8 +7,21 @@ export interface SecretHit { const CITE = "agent-kit guard prompt (docs/cursor-native-audit.md)"; -/** Patterns aligned with pre-commit check-secrets + common prompt leaks. */ -const SECRET_PATTERNS: Array<{ id: string; re: RegExp }> = [ +/** + * Prompt-leak pattern set. Its relationship to pre-commit `check-secrets` is + * **one-way**: this set is a strict superset of the hook, never a mirror of it. + * + * `.cursor/hooks/pre-commit/check-secrets.sh` greps exactly one expression — the + * `json-secret-kv` equivalent — and only under `case "$f" in *.json|*.js|*.ts|*.env)`. + * So `env-assignment`, `aws-access-key`, `github-pat`, `sk-hyphenated-vendor` and + * `openai-sk` have **no** pre-commit counterpart, and a committed `.md` / `.yaml` / + * `.sh` / dotfile is scanned by neither lane. A clean `guard prompt` result is not + * evidence that a commit would be blocked downstream. + * + * `secrets-scan.test.ts` pins this sentence against the hook file: widening + * `check-secrets.sh` must update the test and this comment in the same change. + */ +export const SECRET_PATTERNS: Array<{ id: string; re: RegExp }> = [ { id: "json-secret-kv", re: /"(password|apiKey|api_key|secret|token|auth)"\s*:\s*"[^"]{12,}"/i, @@ -25,25 +38,39 @@ const SECRET_PATTERNS: Array<{ id: string; re: RegExp }> = [ id: "github-pat", re: /\bghp_[A-Za-z0-9_]{36,}\b/, }, + // Hyphenated vendor keys (`sk-ant-api03-…`, `sk-proj-…`) cannot be matched by + // `openai-sk`: its body class excludes `-`, so it stops at the first separator. + // Listed before `openai-sk`; the two cannot both hit the same span. { + id: "sk-hyphenated-vendor", + re: /\bsk-[A-Za-z0-9]{2,12}-[A-Za-z0-9_-]{16,}\b/, + }, + { + // Single-segment `sk-` bodies only (no `-` in the class) — see `sk-hyphenated-vendor`. id: "openai-sk", re: /\bsk-[A-Za-z0-9]{20,}\b/, }, ]; function maskSecretExcerpt(raw: string): string { - return raw - .replace(/\b(ghp_|sk-|AKIA)([A-Za-z0-9_]{4,})/g, (_m, p1: string, p2: string) => { - return `${p1}${"*".repeat(Math.min(8, p2.length))}`; - }) - .replace( - /(=\s*['"]?)([^\s'"]{4,})/g, - (_m, p1: string, p2: string) => `${p1}${"*".repeat(Math.min(8, p2.length))}`, - ) - .replace( - /("(?:password|apiKey|api_key|secret|token|auth)"\s*:\s*")([^"]{4,})(")/gi, - (_m, p1: string, p2: string, p3: string) => `${p1}${"*".repeat(Math.min(8, p2.length))}${p3}`, - ); + return ( + raw + // Body class keeps `-` so hyphenated vendor keys (`sk-ant-api03-…`) are masked too; + // without it the mask needs 4+ non-hyphen chars after `sk-` and `ant` is 3, so the + // raw key body would survive into `SecretHit.excerpt`. + .replace(/\b(ghp_|sk-|AKIA)([A-Za-z0-9_-]{4,})/g, (_m, p1: string, p2: string) => { + return `${p1}${"*".repeat(Math.min(8, p2.length))}`; + }) + .replace( + /(=\s*['"]?)([^\s'"]{4,})/g, + (_m, p1: string, p2: string) => `${p1}${"*".repeat(Math.min(8, p2.length))}`, + ) + .replace( + /("(?:password|apiKey|api_key|secret|token|auth)"\s*:\s*")([^"]{4,})(")/gi, + (_m, p1: string, p2: string, p3: string) => + `${p1}${"*".repeat(Math.min(8, p2.length))}${p3}`, + ) + ); } function excerptAround(text: string, index: number, len: number): string { diff --git a/packages/cli/src/invariants/shell-guard.test.ts b/packages/cli/src/invariants/shell-guard.test.ts index 759b0d0..0b247fc 100644 --- a/packages/cli/src/invariants/shell-guard.test.ts +++ b/packages/cli/src/invariants/shell-guard.test.ts @@ -201,3 +201,24 @@ describe("evaluateShellCommand", () => { } }); }); + +describe("SHELL_DENY_RULES scope (git-workflow only, deliberately)", () => { + it("is exactly the five git-scoped rules", () => { + expect(SHELL_DENY_RULES.map((r) => r.id)).toEqual([ + "git-checkout-path", + "git-restore", + "git-reset-hard", + "git-clean-fd", + "git-push-main", + ]); + }); + + it("allows non-git destructive commands, as the help sentence now says", () => { + // ADR 2026-07-29_cli-invariants-thin-hook-adapters: this guard protects human + // hunks and protected branches, not the shell's blast radius. If a plan ever + // widens the scope, this test and `guard shell --help` must change together. + for (const cmd of ["rm -rf /", "rm -rf ~", "chmod -R 777 /", "dd if=/dev/zero of=/dev/sda"]) { + expect(evaluateShellCommand(cmd).permission).toBe("allow"); + } + }); +}); diff --git a/packages/cli/src/invariants/shell-guard.ts b/packages/cli/src/invariants/shell-guard.ts index 0406d89..0334d57 100644 --- a/packages/cli/src/invariants/shell-guard.ts +++ b/packages/cli/src/invariants/shell-guard.ts @@ -1,4 +1,11 @@ -/** Destructive shell / push-to-main deny-list (CLI SoT; thin beforeShellExecution adapter). */ +/** + * Git-workflow / protected-branch deny-list (CLI SoT; thin beforeShellExecution adapter). + * + * Scope is deliberately git-only (ADR `2026-07-29_cli-invariants-thin-hook-adapters`): the + * guard protects human working-tree hunks and protected branches. It is NOT a general + * destructive-command guard — `rm -rf /`, `chmod -R 777 /`, `dd …` return `allow`. Do not + * describe it as one; widening the scope is a separate, deliberate plan. + */ export type ShellPermission = "allow" | "deny"; diff --git a/packages/cli/src/lifecycle/overlay-known-hashes.ts b/packages/cli/src/lifecycle/overlay-known-hashes.ts index 0903c6c..216059d 100644 --- a/packages/cli/src/lifecycle/overlay-known-hashes.ts +++ b/packages/cli/src/lifecycle/overlay-known-hashes.ts @@ -152,4 +152,7 @@ export const KNOWN_SHIPPED_OVERLAY_HASHES: ReadonlySet = new Set([ "65cc1c0293b145e48ed73ad0ca9ab33cbba5ed834a5bfb31f195ed30c2f143df", "f04fcfe31354d1b09aeb256a17e4aab91c98ea48a5cff25e4a0281af3cfb289f", "34e559ad9036d93d9cbc96d394bc2bbeb10ce50ead00d58a914158ae19daa76c", + "359a142aeadd769a9b89ac909ae5129940e7e6b0892b70256098c584d97631ce", + "8eab94f7a78149db1bbbc0fd45f21dc2d874209d534bcff39077fd6e1d2c42fb", + "9be406f92f7dca71f3af814b14fb26b011f9f12c37c789f7ddc3d647b1a7ab60", ]); diff --git a/packages/cli/src/lifecycle/overlay.ts b/packages/cli/src/lifecycle/overlay.ts index d04fcc9..1151c9e 100644 --- a/packages/cli/src/lifecycle/overlay.ts +++ b/packages/cli/src/lifecycle/overlay.ts @@ -22,6 +22,7 @@ export const CONSUMER_OVERLAY_PREFIXES = [ ".cursor/agents/", ".cursor/skills/", ".cursor/commands/", + ".claude/commands/", ] as const; export type ManagedHashLedger = { diff --git a/packages/cli/src/readiness/env-checks.test.ts b/packages/cli/src/readiness/env-checks.test.ts new file mode 100644 index 0000000..4a03382 --- /dev/null +++ b/packages/cli/src/readiness/env-checks.test.ts @@ -0,0 +1,252 @@ +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + assessEnvironment, + checkBinOnPath, + checkNpmPrefixWritable, + detectNpmPrefix, + detectShellName, + detectShellProfile, + heuristicPrefixFromExecPath, + isNodeVersionOk, + parseNpmrcPrefix, +} from "./env-checks.js"; + +describe("isNodeVersionOk", () => { + it("accepts versions at or above the minimum major", () => { + expect(isNodeVersionOk("v20.0.0")).toBe(true); + expect(isNodeVersionOk("v20.11.1")).toBe(true); + expect(isNodeVersionOk("v22.4.0")).toBe(true); + expect(isNodeVersionOk("22.4.0")).toBe(true); + }); + + it("rejects versions below the minimum major", () => { + expect(isNodeVersionOk("v19.9.9")).toBe(false); + expect(isNodeVersionOk("v18.20.0")).toBe(false); + }); + + it("respects a custom minimum major", () => { + expect(isNodeVersionOk("v18.20.0", 18)).toBe(true); + expect(isNodeVersionOk("v16.0.0", 18)).toBe(false); + }); + + it("returns false for unparsable version strings", () => { + expect(isNodeVersionOk("not-a-version")).toBe(false); + expect(isNodeVersionOk("")).toBe(false); + }); +}); + +describe("detectShellName", () => { + it("detects zsh and bash from $SHELL", () => { + expect(detectShellName({ SHELL: "/bin/zsh" }, "darwin")).toBe("zsh"); + expect(detectShellName({ SHELL: "/usr/bin/bash" }, "linux")).toBe("bash"); + }); + + it("reports other shells by name instead of null", () => { + expect(detectShellName({ SHELL: "/usr/bin/fish" }, "linux")).toBe("fish"); + }); + + it("returns null when $SHELL is unset", () => { + expect(detectShellName({}, "linux")).toBeNull(); + }); + + it("falls back to powershell/cmd markers on win32 and null otherwise", () => { + expect(detectShellName({ PSModulePath: "C:\\ps" }, "win32")).toBe("powershell"); + expect(detectShellName({ ComSpec: "C:\\cmd.exe" }, "win32")).toBe("cmd"); + expect(detectShellName({}, "win32")).toBeNull(); + }); +}); + +describe("detectShellProfile", () => { + const home = "/home/carlos"; + + it("maps zsh to ~/.zshrc", () => { + expect(detectShellProfile({ SHELL: "/bin/zsh" }, "darwin", home)).toBe( + path.join(home, ".zshrc"), + ); + }); + + it("maps bash to ~/.bashrc", () => { + expect(detectShellProfile({ SHELL: "/bin/bash" }, "linux", home)).toBe( + path.join(home, ".bashrc"), + ); + }); + + it("returns null for other shells", () => { + expect(detectShellProfile({ SHELL: "/usr/bin/fish" }, "linux", home)).toBeNull(); + }); + + it("returns null when $SHELL is unset", () => { + expect(detectShellProfile({}, "linux", home)).toBeNull(); + }); +}); + +describe("checkBinOnPath", () => { + let tmpDir: string; + + afterEach(async () => { + if (tmpDir) await rm(tmpDir, { recursive: true, force: true }); + }); + + it("returns true when the bin exists and is executable on a PATH dir (posix)", async () => { + if (process.platform === "win32") return; // exec bit semantics differ; covered via win32 case below + tmpDir = await mkdtemp(path.join(os.tmpdir(), "agent-kit-envcheck-")); + const binPath = path.join(tmpDir, "agent-kit"); + await import("node:fs/promises").then(({ writeFile, chmod }) => + writeFile(binPath, "#!/bin/sh\necho hi\n").then(() => chmod(binPath, 0o755)), + ); + const found = await checkBinOnPath("agent-kit", { PATH: tmpDir }, "linux"); + expect(found).toBe(true); + }); + + it("returns false when no PATH dir contains the bin", async () => { + tmpDir = await mkdtemp(path.join(os.tmpdir(), "agent-kit-envcheck-")); + await mkdir(path.join(tmpDir, "empty"), { recursive: true }); + const found = await checkBinOnPath("agent-kit", { PATH: path.join(tmpDir, "empty") }, "linux"); + expect(found).toBe(false); + }); + + it("returns false without throwing when PATH is unset", async () => { + await expect(checkBinOnPath("agent-kit", {}, "linux")).resolves.toBe(false); + }); + + it("checks .cmd/.exe suffixes on win32", async () => { + tmpDir = await mkdtemp(path.join(os.tmpdir(), "agent-kit-envcheck-")); + const binPath = path.join(tmpDir, "agent-kit.cmd"); + await import("node:fs/promises").then(({ writeFile }) => writeFile(binPath, "@echo off\n")); + const found = await checkBinOnPath("agent-kit", { PATH: tmpDir }, "win32"); + expect(found).toBe(true); + }); +}); + +describe("parseNpmrcPrefix", () => { + it("extracts a prefix line", () => { + expect(parseNpmrcPrefix("prefix=/home/carlos/.npm-global\n", "/home/carlos")).toBe( + "/home/carlos/.npm-global", + ); + }); + + it("expands a leading ~ against homeDir", () => { + expect(parseNpmrcPrefix("prefix = ~/.npm-global\n", "/home/carlos")).toBe( + path.join("/home/carlos", ".npm-global"), + ); + }); + + it("strips surrounding quotes", () => { + expect(parseNpmrcPrefix('prefix="/opt/npm-global"\n', "/home/carlos")).toBe("/opt/npm-global"); + }); + + it("returns null when there is no prefix line", () => { + expect(parseNpmrcPrefix("registry=https://registry.npmjs.org/\n", "/home/carlos")).toBeNull(); + }); +}); + +describe("heuristicPrefixFromExecPath", () => { + it("strips /bin/node on posix", () => { + expect(heuristicPrefixFromExecPath("/usr/local/bin/node", "linux")).toBe("/usr/local"); + }); + + it("uses the node.exe directory on win32", () => { + expect(heuristicPrefixFromExecPath("C:\\nodejs\\node.exe", "win32")).toBe("C:\\nodejs"); + }); +}); + +describe("detectNpmPrefix", () => { + it("prefers an explicit env override", async () => { + const result = await detectNpmPrefix({ + env: { npm_config_prefix: "/custom/prefix" }, + platform: "linux", + homeDir: "/home/carlos", + execPath: "/usr/local/bin/node", + }); + expect(result).toEqual({ prefix: "/custom/prefix", source: "env" }); + }); + + it("falls back to a .npmrc prefix line via the injected reader", async () => { + const result = await detectNpmPrefix({ + env: {}, + platform: "linux", + homeDir: "/home/carlos", + execPath: "/usr/local/bin/node", + readFileImpl: async () => "prefix=/home/carlos/.npm-global\n", + }); + expect(result).toEqual({ prefix: "/home/carlos/.npm-global", source: "npmrc" }); + }); + + it("falls back to the execPath heuristic when no .npmrc is found", async () => { + const result = await detectNpmPrefix({ + env: {}, + platform: "linux", + homeDir: "/home/carlos", + execPath: "/usr/local/bin/node", + readFileImpl: async () => { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }, + }); + expect(result).toEqual({ prefix: "/usr/local", source: "heuristic" }); + }); +}); + +describe("checkNpmPrefixWritable", () => { + let tmpDir: string; + + afterEach(async () => { + if (tmpDir) await rm(tmpDir, { recursive: true, force: true }); + }); + + it("reports writable:true for a writable directory", async () => { + tmpDir = await mkdtemp(path.join(os.tmpdir(), "agent-kit-envcheck-prefix-")); + const report = await checkNpmPrefixWritable({ + env: { npm_config_prefix: tmpDir }, + platform: "linux", + }); + expect(report).toMatchObject({ prefix: tmpDir, writable: true, source: "env" }); + }); + + it("resolves quickly without spawning any subprocess", async () => { + const started = Date.now(); + await checkNpmPrefixWritable({ + env: { npm_config_prefix: "/nonexistent/prefix" }, + platform: "linux", + }); + expect(Date.now() - started).toBeLessThan(500); + }); +}); + +describe("assessEnvironment", () => { + it("never throws and produces the exact doctor --json shape", async () => { + const report = await assessEnvironment({ + env: { PATH: "", SHELL: "/bin/zsh" }, + platform: "linux", + nodeVersion: "v20.11.0", + homeDir: "/home/carlos", + binName: "agent-kit", + }); + + expect(report).toMatchObject({ + binOnPath: false, + nodeVersionOk: true, + shellProfile: path.join("/home/carlos", ".zshrc"), + }); + expect(typeof report.npmPrefixWritable).toBe("boolean"); + // JSON-serializable: no functions/undefined leaking through. + expect(() => JSON.stringify(report)).not.toThrow(); + const parsed = JSON.parse(JSON.stringify(report)); + expect(parsed).toHaveProperty("binOnPath"); + expect(parsed).toHaveProperty("npmPrefixWritable"); + expect(parsed).toHaveProperty("nodeVersionOk"); + expect(parsed).toHaveProperty("shellProfile"); + }); + + it("flags old node versions as not ok", async () => { + const report = await assessEnvironment({ + env: {}, + platform: "linux", + nodeVersion: "v18.19.0", + homeDir: "/home/carlos", + }); + expect(report.nodeVersionOk).toBe(false); + }); +}); diff --git a/packages/cli/src/readiness/env-checks.ts b/packages/cli/src/readiness/env-checks.ts new file mode 100644 index 0000000..89ebaed --- /dev/null +++ b/packages/cli/src/readiness/env-checks.ts @@ -0,0 +1,269 @@ +/** + * Environment diagnostics for `doctor`'s environment pillar. + * + * Read-only: never writes files, never edits PATH/shell profiles, never + * installs anything, and never throws — every check swallows its own + * failures into a safe default so a broken environment still produces a + * usable report instead of crashing `doctor`. + * + * npm's global prefix is inferred from env/`.npmrc`/the running Node + * binary's location instead of spawning `npm config get prefix` — npm CLI + * startup is slow (multi-second) in some environments (containers, cold + * caches), and this is a diagnostic that must stay fast and dependency-free. + */ + +import { constants, access, readFile, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; + +/** Minimum supported Node.js major version. */ +export const MIN_NODE_MAJOR = 20; + +export interface NpmPrefixReport { + /** The active npm global prefix, or null if it could not be determined. */ + prefix: string | null; + /** Whether the current user can write into the prefix directory. */ + writable: boolean; + /** How `prefix` was determined: explicit env, .npmrc, or a heuristic. */ + source?: "env" | "npmrc" | "heuristic"; + /** Human-readable explanation when `writable` is false or unknown. */ + reason?: string; +} + +export interface EnvironmentReport { + /** True when a bare `agent-kit` resolves on PATH. */ + binOnPath: boolean; + /** True when the active npm global prefix is writable by the current user. */ + npmPrefixWritable: boolean; + /** Detail behind `npmPrefixWritable` (prefix path, reason when not writable). */ + npmPrefix: NpmPrefixReport; + /** True when the running Node.js major version is >= MIN_NODE_MAJOR. */ + nodeVersionOk: boolean; + /** Raw Node.js version string used for the check (e.g. "v20.11.0"). */ + nodeVersion: string; + /** Detected shell binary name (e.g. "zsh", "bash"), or null if unknown. */ + shell: string | null; + /** + * Detected shell profile path candidate for zsh/bash, or null when the + * shell isn't zsh/bash (or couldn't be detected). Diagnostic only — this + * to-do never writes to it. + */ + shellProfile: string | null; +} + +export interface AssessEnvironmentOptions { + /** Defaults to process.env. */ + env?: NodeJS.ProcessEnv; + /** Defaults to process.platform. */ + platform?: NodeJS.Platform; + /** Defaults to process.version. */ + nodeVersion?: string; + /** Defaults to homedir(). */ + homeDir?: string; + /** Defaults to process.execPath. Used for the npm-prefix heuristic fallback. */ + execPath?: string; + /** Defaults to "agent-kit". */ + binName?: string; + /** Injectable for tests; defaults to fs/promises readFile. */ + readFileImpl?: (filePath: string) => Promise; +} + +/** True when a bare `binName` resolves to an executable on PATH. */ +export async function checkBinOnPath( + binName: string, + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform, +): Promise { + const pathVar = env.PATH ?? env.Path ?? ""; + if (!pathVar) return false; + const dirs = pathVar.split(path.delimiter).filter(Boolean); + const candidates = + platform === "win32" + ? [binName, `${binName}.cmd`, `${binName}.exe`, `${binName}.bat`] + : [binName]; + for (const dir of dirs) { + for (const candidate of candidates) { + try { + // Windows access() X_OK is unreliable; existence (F_OK, the default + // mode) is the practical signal there. POSIX checks executability. + await access(path.join(dir, candidate), platform === "win32" ? undefined : constants.X_OK); + return true; + } catch { + // Not found here; keep scanning remaining PATH entries. + } + } + } + return false; +} + +/** True when the Node major version parsed from `nodeVersion` is >= minMajor. */ +export function isNodeVersionOk(nodeVersion: string, minMajor: number = MIN_NODE_MAJOR): boolean { + const match = /^v?(\d+)/.exec(nodeVersion); + if (!match) return false; + const major = Number(match[1]); + return Number.isFinite(major) && major >= minMajor; +} + +/** Detected shell binary name from $SHELL, or null if unset/unrecognized. */ +export function detectShellName(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): string | null { + if (platform === "win32") { + // Best-effort diagnostic only; profile detection below is zsh/bash-only. + if (env.PSModulePath) return "powershell"; + if (env.ComSpec) return "cmd"; + return null; + } + const shellPath = env.SHELL; + if (!shellPath) return null; + const base = path.basename(shellPath).trim(); + return base || null; +} + +/** Shell profile path candidate for zsh/bash; null for any other shell. */ +export function detectShellProfile( + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform, + homeDir: string, +): string | null { + const shellName = detectShellName(env, platform); + if (shellName === "zsh") return path.join(homeDir, ".zshrc"); + if (shellName === "bash") return path.join(homeDir, ".bashrc"); + return null; +} + +/** Extract a `prefix = ...` value from .npmrc-style config text, if present. */ +export function parseNpmrcPrefix(content: string, homeDir: string): string | null { + const match = /^\s*prefix\s*=\s*(.+?)\s*$/m.exec(content); + const captured = match?.[1]; + if (!captured) return null; + let value = captured.trim().replace(/^["']|["']$/g, ""); + if (value.startsWith("~")) { + value = path.join(homeDir, value.slice(1)); + } + return value || null; +} + +/** Heuristic prefix inferred from the running Node binary's location. */ +export function heuristicPrefixFromExecPath(execPath: string, platform: NodeJS.Platform): string { + // Global bins conventionally live under `${prefix}/bin` on POSIX (node + // itself sits at `${prefix}/bin/node`) and directly under `${prefix}` on + // Windows (`${prefix}/node.exe`). Use the explicit win32/posix path + // variants (not the host-dependent default) so this stays correct when + // assessed on one platform for a path shaped like another (as in tests). + const p = platform === "win32" ? path.win32 : path.posix; + return platform === "win32" ? p.dirname(execPath) : p.dirname(p.dirname(execPath)); +} + +export interface DetectedNpmPrefix { + prefix: string; + source: "env" | "npmrc" | "heuristic"; +} + +/** + * Determine the active npm global prefix without spawning npm: explicit env + * override first, then a `prefix` line in the user .npmrc, then a heuristic + * derived from the running Node binary's location. + */ +export async function detectNpmPrefix( + options: AssessEnvironmentOptions = {}, +): Promise { + const env = options.env ?? process.env; + const platform = options.platform ?? process.platform; + const homeDir = options.homeDir ?? homedir(); + const execPath = options.execPath ?? process.execPath; + const readFileImpl = options.readFileImpl ?? ((filePath: string) => readFile(filePath, "utf8")); + + const envPrefix = env.npm_config_prefix ?? env.NPM_CONFIG_PREFIX; + if (envPrefix?.trim()) { + return { prefix: envPrefix.trim(), source: "env" }; + } + + const userconfigPath = env.NPM_CONFIG_USERCONFIG ?? path.join(homeDir, ".npmrc"); + try { + const content = await readFileImpl(userconfigPath); + const npmrcPrefix = parseNpmrcPrefix(content, homeDir); + if (npmrcPrefix) { + return { prefix: npmrcPrefix, source: "npmrc" }; + } + } catch { + // No .npmrc (or unreadable) — fall through to the heuristic. + } + + return { prefix: heuristicPrefixFromExecPath(execPath, platform), source: "heuristic" }; +} + +async function describeUnwritablePrefix(prefix: string): Promise { + try { + const info = await stat(prefix); + const currentUid = typeof process.getuid === "function" ? process.getuid() : undefined; + if ( + process.platform !== "win32" && + currentUid !== undefined && + info.uid === 0 && + currentUid !== 0 + ) { + return `root-owned prefix (${prefix}); the classic fresh-install PATH/EACCES blocker`; + } + } catch { + // Fall through to the generic reason below. + } + return `npm prefix is not writable: ${prefix}`; +} + +/** Resolve the active npm global prefix and whether it's user-writable. */ +export async function checkNpmPrefixWritable( + options: AssessEnvironmentOptions = {}, +): Promise { + let detected: DetectedNpmPrefix; + try { + detected = await detectNpmPrefix(options); + } catch { + return { prefix: null, writable: false, reason: "npm prefix could not be determined" }; + } + + try { + await access(detected.prefix, constants.W_OK); + return { prefix: detected.prefix, writable: true, source: detected.source }; + } catch { + return { + prefix: detected.prefix, + writable: false, + source: detected.source, + reason: await describeUnwritablePrefix(detected.prefix), + }; + } +} + +/** + * Assess the local environment for the `doctor` environment pillar. + * Pure diagnostic: no writes, no prompts, no process.exit, never throws. + */ +export async function assessEnvironment( + options: AssessEnvironmentOptions = {}, +): Promise { + const env = options.env ?? process.env; + const platform = options.platform ?? process.platform; + const nodeVersion = options.nodeVersion ?? process.version; + const homeDir = options.homeDir ?? homedir(); + const binName = options.binName ?? "agent-kit"; + + const [binOnPath, npmPrefix] = await Promise.all([ + checkBinOnPath(binName, env, platform).catch(() => false), + checkNpmPrefixWritable(options).catch( + (): NpmPrefixReport => ({ + prefix: null, + writable: false, + reason: "npm prefix check failed unexpectedly", + }), + ), + ]); + + return { + binOnPath, + npmPrefixWritable: npmPrefix.writable, + npmPrefix, + nodeVersionOk: isNodeVersionOk(nodeVersion), + nodeVersion, + shell: detectShellName(env, platform), + shellProfile: detectShellProfile(env, platform, homeDir), + }; +} diff --git a/packages/cli/src/utils/terminal.test.ts b/packages/cli/src/utils/terminal.test.ts index 8f29bf5..a94533e 100644 --- a/packages/cli/src/utils/terminal.test.ts +++ b/packages/cli/src/utils/terminal.test.ts @@ -3,13 +3,25 @@ import { homedir, tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + NESTED_REPO_AMBIGUITY_THRESHOLD, RootRefusedError, classifyInstallError, confirmProjectRoot, + findNestedRepoChildren, isNonInteractive, validateProjectRoot, } from "./terminal.js"; +/** A directory with its own `.git` plus `count` immediate child repositories. */ +async function makeParentOfRepos(count: number): Promise { + const dir = await mkdtemp(path.join(tmpdir(), "ak-parent-")); + await mkdir(path.join(dir, ".git"), { recursive: true }); + for (let i = 0; i < count; i += 1) { + await mkdir(path.join(dir, `repo-${i}`, ".git"), { recursive: true }); + } + return dir; +} + describe("isNonInteractive", () => { const originalIsTTY = process.stdin.isTTY; @@ -60,6 +72,51 @@ describe("isNonInteractive", () => { }); describe("classifyInstallError", () => { + const npmGlobalEaccesCases: Array<[string, string]> = [ + [ + "macOS mkdir on scoped package dir", + "EACCES: permission denied, mkdir '/usr/local/lib/node_modules/@dadado'", + ], + [ + "macOS access on prefix root", + "EACCES: permission denied, access '/usr/local/lib/node_modules'", + ], + [ + "Linux mkdir under /usr/lib/node_modules", + "EACCES: permission denied, mkdir '/usr/lib/node_modules/@dadado'", + ], + [ + "open on package-lock.json inside global prefix", + "Error: EACCES: permission denied, open '/usr/local/lib/node_modules/.package-lock.json'", + ], + ]; + + it.each(npmGlobalEaccesCases)("classifies npm global EACCES errors: %s", (_label, message) => { + const err = Object.assign(new Error(message), { code: "EACCES" }); + const hint = classifyInstallError(err); + expect(hint.kind).toBe("npm-global-eacces"); + expect(hint.message).toContain("root-owned npm prefix"); + expect(hint.recovery).toContain("setup-global"); + }); + + it("classifies npm global EACCES errors by message alone (no .code set)", () => { + const hint = classifyInstallError( + new Error("EACCES: permission denied, mkdir '/usr/local/lib/node_modules/@dadado'"), + ); + expect(hint.kind).toBe("npm-global-eacces"); + expect(hint.recovery).toContain("setup-global"); + }); + + it("does not misclassify a generic EACCES error unrelated to node_modules", () => { + const err = Object.assign( + new Error("EACCES: permission denied, open '/Users/dev/.config/some-tool/config.json'"), + { code: "EACCES" }, + ); + const hint = classifyInstallError(err); + expect(hint.kind).toBe("eperm"); + expect(hint.recovery).toContain("ownership drift"); + }); + it("classifies EPERM errors", () => { const err = Object.assign(new Error("EPERM: operation not permitted"), { code: "EPERM" }); const hint = classifyInstallError(err); @@ -141,6 +198,19 @@ describe("validateProjectRoot", () => { if (!result.ok) expect(result.reason).toContain("no .git"); }); + it("names all three sanctioned start-from-zero paths on an empty folder", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "ak-root-")); + const result = await validateProjectRoot(dir); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.recovery).toContain("git init"); + expect(result.recovery).toContain("--force-root"); + expect(result.recovery).toContain("Proceed anyway?"); + // The Git pillar itself stays owned by /agent-kit-onboard. + expect(result.recovery).toContain("/agent-kit-onboard"); + } + }); + it("accepts a directory with a .git folder", async () => { const dir = await mkdtemp(path.join(tmpdir(), "ak-root-")); await mkdir(path.join(dir, ".git"), { recursive: true }); @@ -153,6 +223,63 @@ describe("validateProjectRoot", () => { await writeFile(path.join(dir, ".cursor", "agent-kit.json"), "{}", "utf8"); await expect(validateProjectRoot(dir)).resolves.toEqual({ ok: true }); }); + + it("accepts a .git root with a single nested child repo (vendored/submodule shape)", async () => { + const dir = await makeParentOfRepos(1); + await expect(validateProjectRoot(dir)).resolves.toEqual({ ok: true }); + }); + + it("accepts a .git root whose many children are not repositories (monorepo shape)", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "ak-root-")); + await mkdir(path.join(dir, ".git"), { recursive: true }); + for (const name of ["apps", "packages", "docs", "scripts", "tools"]) { + await mkdir(path.join(dir, name), { recursive: true }); + } + await expect(validateProjectRoot(dir)).resolves.toEqual({ ok: true }); + }); + + it("refuses a .git root that is a parent of two or more child repositories", async () => { + const dir = await makeParentOfRepos(2); + const result = await validateProjectRoot(dir); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toContain("parent-of-repos"); + expect(result.reason).toContain("repo-0"); + expect(result.reason).toContain("repo-1"); + expect(result.recovery).toContain("--force-root"); + expect(result.recovery).toContain("cd into the project you meant"); + } + }); + + it("does not re-flag an already-installed root as a parent-of-repos", async () => { + const dir = await makeParentOfRepos(3); + await mkdir(path.join(dir, ".cursor"), { recursive: true }); + await writeFile(path.join(dir, ".cursor", "agent-kit.json"), "{}", "utf8"); + await expect(validateProjectRoot(dir)).resolves.toEqual({ ok: true }); + }); + + it("ignores dot-directories and node_modules when counting nested repos", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "ak-root-")); + await mkdir(path.join(dir, ".git"), { recursive: true }); + for (const name of [".hidden", "node_modules", "vendor"]) { + await mkdir(path.join(dir, name, ".git"), { recursive: true }); + } + await expect(validateProjectRoot(dir)).resolves.toEqual({ ok: true }); + }); +}); + +describe("findNestedRepoChildren", () => { + it("stops counting at the ambiguity threshold", async () => { + const dir = await makeParentOfRepos(5); + const nested = await findNestedRepoChildren(dir); + expect(nested).toHaveLength(NESTED_REPO_AMBIGUITY_THRESHOLD); + }); + + it("returns an empty list for an unreadable path instead of throwing", async () => { + await expect( + findNestedRepoChildren(path.join(tmpdir(), "ak-does-not-exist-", String(Date.now()))), + ).resolves.toEqual([]); + }); }); describe("confirmProjectRoot", () => { @@ -182,6 +309,40 @@ describe("confirmProjectRoot", () => { ).rejects.toThrow(RootRefusedError); }); + it("carries the start-from-zero recovery block on the refusal error", async () => { + const badDir = await mkdtemp(path.join(tmpdir(), "ak-root-")); + const err = await confirmProjectRoot(badDir, { + nonInteractive: true, + command: "install", + }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(RootRefusedError); + expect((err as RootRefusedError).recovery).toContain("git init"); + }); + + it("refuses a parent-of-repos root in non-interactive mode", async () => { + const parent = await makeParentOfRepos(2); + await expect( + confirmProjectRoot(parent, { nonInteractive: true, command: "install" }), + ).rejects.toThrow(RootRefusedError); + }); + + it("still installs into a parent-of-repos root when the user proceeds interactively", async () => { + const { confirm } = await import("@clack/prompts"); + vi.mocked(confirm).mockResolvedValueOnce(true); + const parent = await makeParentOfRepos(2); + await expect( + confirmProjectRoot(parent, { nonInteractive: false, command: "install" }), + ).resolves.toBe(path.resolve(parent)); + expect(confirm).toHaveBeenCalledWith(expect.objectContaining({ initialValue: false })); + }); + + it("bypasses the parent-of-repos guard with forceRoot", async () => { + const parent = await makeParentOfRepos(3); + await expect( + confirmProjectRoot(parent, { nonInteractive: true, command: "install", forceRoot: true }), + ).resolves.toBe(path.resolve(parent)); + }); + it("refuses home directory in non-interactive mode", async () => { await expect( confirmProjectRoot(homedir(), { nonInteractive: true, command: "install" }), @@ -211,6 +372,18 @@ describe("confirmProjectRoot", () => { ); }); + it("accepts an empty no-git folder when the user proceeds interactively", async () => { + const { confirm } = await import("@clack/prompts"); + vi.mocked(confirm).mockResolvedValueOnce(true); + const blank = await mkdtemp(path.join(tmpdir(), "ak-root-")); + await expect( + confirmProjectRoot(blank, { nonInteractive: false, command: "install" }), + ).resolves.toBe(path.resolve(blank)); + // Only the warn prompt runs; the normal "Install Agent Kit in:" confirm + // is not asked a second time. + expect(confirm).toHaveBeenCalledTimes(1); + }); + it("warns and defaults to refusing an ambiguous directory in interactive mode", async () => { const { confirm } = await import("@clack/prompts"); vi.mocked(confirm).mockResolvedValueOnce(false); diff --git a/packages/cli/src/utils/terminal.ts b/packages/cli/src/utils/terminal.ts index af04926..5535d0d 100644 --- a/packages/cli/src/utils/terminal.ts +++ b/packages/cli/src/utils/terminal.ts @@ -4,6 +4,8 @@ * (piped stdin, CI, VS Code output panels without TTY). */ +import type { Dirent } from "node:fs"; +import { readdir } from "node:fs/promises"; import { homedir } from "node:os"; import path from "node:path"; import { confirm, isCancel } from "@clack/prompts"; @@ -23,22 +25,96 @@ export interface ConfirmProjectRootOptions { forceRoot?: boolean; } +/** + * How many immediate child repositories make a `.git` directory look like a + * parent-of-repos instead of a project root. One nested repo is a normal + * vendored/submodule case, so the threshold is two. + */ +export const NESTED_REPO_AMBIGUITY_THRESHOLD = 2; + +/** Upper bound on immediate children inspected, so a huge folder stays cheap. */ +const NESTED_REPO_SCAN_LIMIT = 200; + +/** + * Names of immediate child directories that contain their own `.git`. + * One level deep only, never recursive, and stops as soon as the ambiguity + * threshold is reached. Dot-directories and `node_modules` are skipped. + */ +export async function findNestedRepoChildren(resolved: string): Promise { + let entries: Dirent[]; + try { + entries = await readdir(resolved, { withFileTypes: true }); + } catch { + return []; + } + const nested: string[] = []; + let scanned = 0; + for (const entry of entries) { + if (nested.length >= NESTED_REPO_AMBIGUITY_THRESHOLD) break; + if (scanned >= NESTED_REPO_SCAN_LIMIT) break; + if (!entry.isDirectory()) continue; + if (entry.name.startsWith(".") || entry.name === "node_modules") continue; + scanned += 1; + if (await fileExists(path.join(resolved, entry.name, ".git"))) { + nested.push(entry.name); + } + } + return nested; +} + +export type ProjectRootValidation = + | { ok: true } + /** + * `reason` is short enough to prefix the interactive "Proceed anyway?" + * prompt. `recovery` is the multi-line block printed on a hard refusal, so + * a non-interactive operator is never left with a dead end. + */ + | { ok: false; reason: string; recovery: string }; + /** Validate that a resolved path looks like a project root, not a global directory. */ -export async function validateProjectRoot( - resolved: string, -): Promise<{ ok: true } | { ok: false; reason: string }> { +export async function validateProjectRoot(resolved: string): Promise { const home = path.resolve(homedir()); if (resolved === "/" || resolved === home) { - return { ok: false, reason: `Refused to use ${resolved} as a project root.` }; + return { + ok: false, + reason: `Refused to use ${resolved} as a project root.`, + recovery: "Change into a project directory and re-run. Agent Kit installs per project.", + }; } const hasGit = await fileExists(path.join(resolved, ".git")); const hasManifest = await fileExists(path.join(resolved, ".cursor", "agent-kit.json")); if (!hasGit && !hasManifest) { return { ok: false, - reason: `Refused ${resolved}: no .git and no .cursor/agent-kit.json. Run from a project directory or use --force-root.`, + reason: `Refused ${resolved}: no .git and no .cursor/agent-kit.json.`, + recovery: [ + "Starting from an empty folder? Pick one of these:", + " 1. git init - then re-run. Recommended: readiness and the", + " staging -> prod flow both want Git.", + " 2. --force-root - install without Git. /agent-kit-onboard will", + " still offer to initialize it later.", + " 3. Answer yes to the 'Proceed anyway?' prompt in an interactive terminal.", + "Or re-run from the project directory you actually meant.", + ].join("\n"), }; } + // A manifest means the kit was already installed at this exact root, so the + // operator has confirmed the grain before. Only sniff for a parent-of-repos + // shape when `.git` alone is what let the directory through. + if (hasGit && !hasManifest) { + const nested = await findNestedRepoChildren(resolved); + if (nested.length >= NESTED_REPO_AMBIGUITY_THRESHOLD) { + return { + ok: false, + reason: `Refused ${resolved}: it has .git but also contains child repositories (${nested.join(", ")}). This looks like a parent-of-repos folder, not a project root.`, + recovery: [ + "L0 belongs in one project, not in the folder that holds several.", + " 1. cd into the project you meant, then re-run.", + " 2. --force-root - only if this parent folder really is the project root.", + ].join("\n"), + }; + } + } return { ok: true }; } @@ -60,7 +136,7 @@ export async function confirmProjectRoot( const validation = await validateProjectRoot(resolved); if (!validation.ok) { if (opts.nonInteractive) { - throw new RootRefusedError(resolved, validation.reason); + throw new RootRefusedError(resolved, validation.reason, validation.recovery); } // Interactive mode still confirms, but warns and defaults to refusing. const ok = await confirm({ @@ -68,7 +144,7 @@ export async function confirmProjectRoot( initialValue: false, }); if (isCancel(ok) || !ok) { - throw new RootRefusedError(resolved, validation.reason); + throw new RootRefusedError(resolved, validation.reason, validation.recovery); } return resolved; } @@ -91,6 +167,8 @@ export class RootRefusedError extends Error { constructor( public readonly root: string, reason?: string, + /** Multi-line "what to do instead" block, printed by the callers. */ + public readonly recovery?: string, ) { super(reason ?? `Refused to write into ${root}. Re-run from the correct project directory.`); this.name = "RootRefusedError"; @@ -98,15 +176,47 @@ export class RootRefusedError extends Error { } export interface InstallErrorHint { - kind: "eperm" | "registry-auth" | "network" | "unknown"; + kind: "npm-global-eacces" | "eperm" | "registry-auth" | "network" | "unknown"; message: string; recovery: string; } +/** + * Matches the classic root-owned npm global prefix failure, e.g.: + * EACCES: permission denied, mkdir '/usr/local/lib/node_modules/@dadado' + * EACCES: permission denied, access '/usr/local/lib/node_modules' + * EACCES: permission denied, mkdir '/usr/lib/node_modules/@dadado' (some Linux distros) + * EACCES: permission denied, open '/usr/local/lib/node_modules/.package-lock.json' + * and a best-effort Windows shape (Program Files\nodejs\node_modules). + */ +const NPM_GLOBAL_PREFIX_PATH_RE = /\/lib\/node_modules|Program Files\\nodejs\\node_modules/; +/** Low-false-positive fallback: EACCES/EPERM plus a bare "node_modules" mention. */ +const NPM_GLOBAL_NODE_MODULES_RE = /node_modules/; + +function isNpmGlobalPrefixError(msg: string, code: string | undefined): boolean { + const isPermissionError = code === "EPERM" || code === "EACCES" || /EPERM|EACCES/.test(msg); + if (!isPermissionError) return false; + return NPM_GLOBAL_PREFIX_PATH_RE.test(msg) || NPM_GLOBAL_NODE_MODULES_RE.test(msg); +} + export function classifyInstallError(err: unknown): InstallErrorHint { const msg = err instanceof Error ? err.message : String(err); const code = (err as NodeJS.ErrnoException)?.code; + if (isNpmGlobalPrefixError(msg, code)) { + return { + kind: "npm-global-eacces", + message: `Permission error (root-owned npm prefix): ${msg}`, + recovery: [ + "npm's global install prefix (e.g. /usr/local/lib/node_modules) is owned by root, so global installs fail.", + "Recovery options:", + " 1. Run: npx @dadado/agent-kit-cli setup-global (relocates npm's prefix to a folder you own, fixes PATH, reinstalls)", + ' 2. Manual fix: mkdir -p ~/.npm-global && npm config set prefix "~/.npm-global" && export PATH="~/.npm-global/bin:$PATH" (add to your shell profile) && npm i -g @dadado/agent-kit-cli', + " 3. Use Port B fallback: drag install.md into the Cursor chat", + ].join("\n"), + }; + } + if (code === "EPERM" || code === "EACCES" || /EPERM|EACCES/.test(msg)) { return { kind: "eperm",