From 1cbb1ae4d2780fd2eba63ebb592f07a5c95d5937 Mon Sep 17 00:00:00 2001 From: agent-kit-sync Date: Fri, 14 Aug 2026 12:53:04 +0000 Subject: [PATCH] chore: sync private v5.2.0 (be846d5) --- .cursor-plugin/plugin.json | 2 +- .cursor/agent-kit.json | 2 +- .cursor/agents/mission-kit-comms.md | 35 + .cursor/commands/git-prod.md | 6 +- .cursor/commands/git-staging.md | 2 +- .cursor/commands/handoff.md | 4 +- .cursor/commands/plan-external-review.md | 9 +- .cursor/commands/plan-review-triage.md | 4 +- .cursor/commands/run-plan-all.md | 4 +- .cursor/commands/run-plan-loop.md | 2 +- .cursor/commands/run-plan-orchestrated.md | 2 +- .cursor/commands/run-plan.md | 6 +- .cursor/context/config.example.json | 4 +- .../templates/plan-external-review-prompt.md | 6 +- .cursor/hooks/agent/session-start.sh | 7 +- .cursor/rules/cursor-skills-git-workflow.mdc | 4 +- .cursor/rules/hitl-ask-questions.mdc | 2 +- .cursor/scripts/comms-draft.mjs | 134 +++ .cursor/scripts/comms-draft.test.mjs | 73 ++ .../plan-external-review-atomic-wait.test.mjs | 181 ++++ ...n-external-review-backend-cascade.test.mjs | 26 + ...lan-external-review-model-routing.test.mjs | 110 +++ ...lan-external-review-progress-gate.test.mjs | 34 + .cursor/scripts/plan-external-review.sh | 801 +++++++++++++++++- .../community/mission-kit-comms/SKILL.md | 78 ++ .github/workflows/ci.yml | 3 + .gitignore | 4 + CHANGELOG.md | 70 +- autogit/gitupdate.md | 24 +- dashboard/dashboard.html | 70 +- dashboard/lib/guards.d.mts | 103 +++ dashboard/lib/guards.mjs | 58 +- dashboard/lib/open-browser.mjs | 5 - dashboard/lib/semantic-model.mjs | 8 +- dashboard/start-broadcast.mjs | 7 +- dashboard/start.mjs | 7 +- docs/CONTRIBUTING.md | 15 + docs/DEVELOPMENT.md | 9 +- docs/README.md | 5 +- docs/agentkit-landing.md | 1 + docs/bootstrap.md | 4 +- docs/capability-inventory.md | 12 +- docs/claude-cli-kit-load.md | 120 +++ docs/coherence-inventory.md | 3 +- docs/comms-channel-map.md | 55 ++ docs/comms-content-calendar.md | 38 + docs/comms-publish-pipeline.md | 52 ++ docs/comms-templates/contributor-ask.md | 30 + docs/comms-templates/recap.md | 33 + docs/comms-templates/release.md | 33 + docs/comms.md | 18 + docs/cursor-3-features.md | 14 +- docs/cursor-native-audit.md | 22 +- docs/cursor-update-awareness.md | 5 +- docs/external-plan-review.md | 37 +- docs/five-layer-claim-matrix.md | 1 + docs/getting-started.md | 22 +- docs/marketplace.md | 24 +- docs/npm-publish-checklist.md | 6 +- docs/plugin-smoke-checklist.md | 83 ++ docs/public-launch-announcement.md | 2 +- git-hooks/README.md | 14 +- git-hooks/pre-commit | 14 +- install.md | 2 +- package.json | 7 +- packages/cli/README.md | 2 + packages/cli/package.json | 4 +- .../cli/src/commands/dashboard-broadcast.ts | 12 +- packages/cli/src/commands/dashboard.test.ts | 40 +- packages/cli/src/commands/dashboard.ts | 25 +- packages/cli/src/commands/doctor.ts | 4 +- packages/cli/src/commands/init.ts | 3 +- packages/cli/src/commands/install.test.ts | 46 + packages/cli/src/commands/install.ts | 27 +- packages/cli/src/commands/update.test.ts | 63 ++ packages/cli/src/commands/update.ts | 9 +- .../ci-private-origin-allowlist.test.ts | 17 + .../src/dashboard/guards-dts-parity.test.ts | 51 ++ packages/cli/src/dashboard/guards.test.ts | 26 +- .../cli/src/dashboard/open-browser.test.ts | 43 +- .../dashboard/plugin-ux-validation.test.ts | 62 +- .../cli/src/dashboard/semantic-model.test.ts | 43 + .../cli/src/generator/claude-kit-load.test.ts | 78 ++ packages/cli/src/generator/claude-kit-load.ts | 83 ++ packages/cli/src/generator/index.ts | 2 + .../cli/src/generator/personalization.test.ts | 40 +- packages/cli/src/generator/personalization.ts | 66 +- packages/cli/src/hooks/session-start.test.ts | 28 + packages/cli/src/hooks/session-start.ts | 30 +- .../cli/src/lifecycle/check-updates.test.ts | 57 ++ packages/cli/src/lifecycle/check-updates.ts | 144 +++- .../lifecycle/cursor-update-awareness.test.ts | 110 ++- .../src/lifecycle/cursor-update-awareness.ts | 27 +- .../cli/src/lifecycle/overlay-known-hashes.ts | 15 + .../lifecycle/refresh-known-hashes.test.ts | 125 +++ .../cli/src/lifecycle/refresh-known-hashes.ts | 158 ++++ .../cli/src/plan-loop/persona-banners.test.ts | 42 +- packages/cli/src/plan-loop/run-loop.ts | 17 +- .../src/registry/resolve.lock-enoent.test.ts | 74 ++ packages/cli/src/registry/resolve.test.ts | 80 ++ packages/cli/src/registry/resolve.ts | 83 +- packages/cli/src/welcome/help-groups.ts | 4 + packages/cli/src/welcome/screen.test.ts | 6 + packages/cli/src/welcome/screen.ts | 51 +- packages/cli/src/welcome/visual-kit.test.ts | 214 +++++ packages/cli/src/welcome/visual-kit.ts | 232 +++++ 106 files changed, 4564 insertions(+), 257 deletions(-) create mode 100644 .cursor/agents/mission-kit-comms.md create mode 100644 .cursor/scripts/comms-draft.mjs create mode 100644 .cursor/scripts/comms-draft.test.mjs create mode 100644 .cursor/scripts/plan-external-review-atomic-wait.test.mjs create mode 100644 .cursor/scripts/plan-external-review-backend-cascade.test.mjs create mode 100644 .cursor/scripts/plan-external-review-model-routing.test.mjs create mode 100644 .cursor/skills/community/mission-kit-comms/SKILL.md create mode 100644 docs/claude-cli-kit-load.md create mode 100644 docs/comms-channel-map.md create mode 100644 docs/comms-content-calendar.md create mode 100644 docs/comms-publish-pipeline.md create mode 100644 docs/comms-templates/contributor-ask.md create mode 100644 docs/comms-templates/recap.md create mode 100644 docs/comms-templates/release.md create mode 100644 docs/comms.md create mode 100644 docs/plugin-smoke-checklist.md mode change 100644 => 100755 git-hooks/pre-commit create mode 100644 packages/cli/src/dashboard/guards-dts-parity.test.ts create mode 100644 packages/cli/src/generator/claude-kit-load.test.ts create mode 100644 packages/cli/src/generator/claude-kit-load.ts create mode 100644 packages/cli/src/lifecycle/refresh-known-hashes.test.ts create mode 100644 packages/cli/src/lifecycle/refresh-known-hashes.ts create mode 100644 packages/cli/src/registry/resolve.lock-enoent.test.ts create mode 100644 packages/cli/src/welcome/visual-kit.test.ts create mode 100644 packages/cli/src/welcome/visual-kit.ts diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index ac8988a..83cf686 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.0.0", + "version": "5.2.0", "homepage": "https://github.com/agent-kit-startup/agent-kit", "repository": "https://github.com/agent-kit-startup/agent-kit", "logo": "dashboard/logo.svg", diff --git a/.cursor/agent-kit.json b/.cursor/agent-kit.json index d0f04bc..a76124a 100644 --- a/.cursor/agent-kit.json +++ b/.cursor/agent-kit.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "version": "5.0.0", + "version": "5.2.0", "protected": [ ".cursor/HANDOFF.md", ".cursor/agents/test-suites.md", diff --git a/.cursor/agents/mission-kit-comms.md b/.cursor/agents/mission-kit-comms.md new file mode 100644 index 0000000..564397f --- /dev/null +++ b/.cursor/agents/mission-kit-comms.md @@ -0,0 +1,35 @@ +--- +name: mission-kit-comms +description: Dedicated Mission Kit comms/community agent. Drafts recap, release, contributor-ask, and reply copy under explicit HITL gates. Not an Agent Persona (personas are chat chrome only). +model: claude-sonnet-4 +readonly: false +--- + +# Mission Kit comms agent + +You draft adoption and contributor copy for **Mission Kit**. You do not post. + +Follow skill `registry/skills/community/mission-kit-comms/SKILL.md` (factory overlay: `.cursor/skills/community/mission-kit-comms/SKILL.md`). Tone: short blocks, one ask per message ([ux-message-flows](../skills/community/ux-message-flows/SKILL.md)). Prompt shape: [prompts-markdown](../skills/community/prompts-markdown/SKILL.md). + +## HITL contract + +- Before any **public post or public reply**, Ask questions with `Post this` / `Edit draft` / `Discard` (chat numbered list if the tool is missing). +- Skip or cancel = stop. Never silent cross-network posting. +- Never `/git-prod`. Never buy ads. Never submit Cursor Marketplace (parked plan owns that). +- Never load Agent Personas (`autopilot` / `night-shift` / `ghost-runner`) as the poster. + +## What you may do + +- Draft from [docs/comms-templates/](../../docs/comms-templates/recap.md) and the [calendar](../../docs/comms-content-calendar.md). +- Run `node .cursor/scripts/comms-draft.mjs` for local files under `.cursor/comms-drafts/`. +- Align contributor asks with [CONTRIBUTING](../../docs/CONTRIBUTING.md), [contribute-upstream](../../docs/contribute-upstream.md), CoC, SUPPORT, SECURITY. + +## What you must not do + +- HTTP post, webhook, or `gh` issue comment that is marketing without Ask. +- Put tokens, webhooks, or cookies in files that can be committed. +- Claim product behavior that is not in CHANGELOG 5.0.0 / getting-started / five-layer matrix. + +## Naming + +Mission Kit = product. Agent Kit = CLI/npm/slash/pack. Mission Control = dashboard. diff --git a/.cursor/commands/git-prod.md b/.cursor/commands/git-prod.md index 22e6d30..b597aff 100644 --- a/.cursor/commands/git-prod.md +++ b/.cursor/commands/git-prod.md @@ -19,7 +19,11 @@ Follow the **git prod** routine to promote `origin/staging` to `origin/main` (pr **Advisory (does not replace confirmation):** if Blocking untriaged `.cursor/memory/plan-monitor-*.md` match themes in the staging→main delta, mention them once in the summary. Never steal this Ask; Field Report / `/plan-review-triage` stay attention/HITL SoT. **Fallback:** if Ask questions tool unavailable, ask for explicit confirmation in chat. -5. Run merge to main, push main, create/push annotated vX.Y.Z tag (when absent), confirm production. +5. Run merge to main, then push with the authorized inline form only: + + `ALLOW_MAIN_PUSH=1 git push origin main` + + Bare `git push origin main` stays denied by `agent-kit guard shell` and `git-hooks/pre-push`. Do not export `ALLOW_MAIN_PUSH=1` as a session environment variable. Do not add `--force`, `--no-verify`, or a non-main destination. Then create/push annotated vX.Y.Z tag (when absent) and confirm production. Details: `autogit/gitupdate.md` Prompt git prod step 9. 6. Update `.cursor/HANDOFF.md` ("promoted to production") and memory-loop WRITE if it applies. 7. In this monorepo: annotated tags trigger `publish-npm` + `sync-public` CI; `pnpm git:trigger-public-sync` fallback when needed per `autogit/gitupdate.md`. 8. **Post-prod verification (mandatory in this monorepo):** before ending, check tag CI jobs (`publish-npm`, `sync-public`), `npm view @dadado/agent-kit-cli version`, **public sync PR merged** (not CI-green alone), public `main` sync commit, and public GitHub Release Latest. Report each row. Silent npm success with a stale public Releases badge, or CI-green with an unmerged sync PR, is a kit failure mode; see `autogit/gitupdate.md` step 12.5. diff --git a/.cursor/commands/git-staging.md b/.cursor/commands/git-staging.md index f5b2d94..7963d2a 100644 --- a/.cursor/commands/git-staging.md +++ b/.cursor/commands/git-staging.md @@ -10,7 +10,7 @@ Follow the **git staging** routine to bring local changes to the pre-production **Runs in the main window by default.** Do not dispatch this command to a Task subagent by default; Task isolation is opt-in and used only when the kit repo wants it. 1. **Read** the "Prompt: git staging" section in `autogit/gitupdate.md` (when it exists). -2. **Staging hygiene (monitors):** if `git status` shows untracked or unrelated dirty `.cursor/memory/plan-monitor-*.md`, **warn** before commit. Stage memory/monitor files **add-by-name only**; never broad `git add` of `.cursor/memory/` WIP into a product commit (ADR `decisions/2026-07-27_plan-monitor-consumer-awareness.md`, external-review staging hygiene). +2. **Staging hygiene (monitors):** if `git status` shows untracked or unrelated dirty `.cursor/memory/plan-monitor-*.md`, **warn** before commit. Stage memory/monitor files **add-by-name only**; never broad `git add` of `.cursor/memory/` WIP into a product commit (ADR `decisions/2026-07-27_plan-monitor-consumer-awareness.md`, external-review staging hygiene). Warn/add-by-name forbids sweeps; it does **not** mean leave soft kit artifacts dirty. Inventory remaining safe dirt into a `docs(memory):` / `chore(kit):` bucket and ship it in this run (SoT: `autogit/gitupdate.md` Prompt git staging §1 and §7). Do not stop-and-quiz solely because a path looks out of the current flow. 3. **Lint evidence (required when code/format paths change):** before claiming staging-ready, **run** the repo formatter/linter on touched files and **record the command + result** (pass/fail) in the worker summary or tick notes. Writing `Staging ready: yes` or the contract string alone is **not** evidence. Pure markdown / docs-only with no applicable linter: state `none applicable`. Same gate as `/run-plan` Staging-ready lint gate. **Dashboard CSS/HTML only** (`dashboard/dashboard.html` and similar, outside Biome scope): record `Tests: none applicable (dashboard-CSS); covered by plugin-ux-validation` when the UX suite pins the change (ADR `decisions/2026-07-29_dashboard-css-lint-evidence-convention.md`); do not claim Biome covered the HTML. 4. Run in order: validation (not on `main`), CHANGELOG (`[Unreleased]`), checkout staging, pull, working branch, Conventional Commits, push, MR/PR (**always `--base staging` / target `staging`**), merge, cleanup. 5. **Evidence-checks gate (before merge / Gaps-none):** run `gh pr checks ` and confirm `build` (including the **Evidence checks** step) is green. If Evidence checks fail, regenerate and re-push before merge; do not write HANDOFF `- **Gaps:** none` over red. ADR: `2026-08-01_evidence-checks-merge-gate`. diff --git a/.cursor/commands/handoff.md b/.cursor/commands/handoff.md index d7dbd8d..08b282e 100644 --- a/.cursor/commands/handoff.md +++ b/.cursor/commands/handoff.md @@ -40,9 +40,9 @@ Update the handoff document to preserve current state and allow continuation in If they pick automatic, save `{ "autoHandoff": true }` in `.cursor/context/config.json`. 4. **DevOps spine (suggest, do not run without being asked):** - - If the phase produced commitable code, suggest `/git-staging` to promote to pre-prod. + - If the phase produced commitable changes (including versioned HANDOFF, `plan-monitor-*.md`, or `_index.md` Audits rows), suggest `/git-staging`. That command follows `autogit/gitupdate.md` inventory → theme-bucket → ship. Do not treat HANDOFF/memory as skippable dirt. - If there was an error or a tradeoff decision, suggest a memory-loop WRITE (`.cursor/memory/`). - - Never suggest committing directly to `main`; production only via `/git-prod` after staging. + - Never suggest committing directly to `main`; production only via `/git-prod` after staging. `/git-prod` still requires a clean tree except hard excludes; staging must leave it that way. 5. **Respond to the user:** > "Handoff updated! Continue: `/continue-plan`. With code ready: `/git-staging`. Production: `/git-prod`." diff --git a/.cursor/commands/plan-external-review.md b/.cursor/commands/plan-external-review.md index 5c80d4f..8b8a79b 100644 --- a/.cursor/commands/plan-external-review.md +++ b/.cursor/commands/plan-external-review.md @@ -7,7 +7,7 @@ description: Arm an optional external plan audit after /run-plan exhausts its im ## Goal -Manually arm **optional plan audits** (external plan review via Claude Code) after `/run-plan` has exhausted implementable to-dos. Claude writes an evidence-based monitor under `.cursor/memory/plan-monitor-*.md`. Cursor triage of findings is a **later** step (not this command). +Manually arm **optional plan audits** (external plan review via Claude Code or Cursor Agent) after `/run-plan` has exhausted implementable to-dos. The reviewer writes an evidence-based monitor under `.cursor/memory/plan-monitor-*.md`. Cursor triage of findings is a **later** step (not this command). ## When to Use @@ -34,7 +34,7 @@ If any are missing: stop. Do **not** claim a review ran. Tell the user to run `a 1. Prefight files above exist. 2. `.cursor/context/config.json` has `externalPlanReview.enabled: true` (see `config.example.json`), **or** use `--force` for a one-shot arm without persisting opt-in. Missing file = disabled unless `--force`. 3. Prefer `externalPlanReview.mode: "autonomous"` for background/inspectable auto-launch. Missing `mode` keeps paste-compatible / legacy behavior. -4. Claude Code CLI (`claude`) on PATH for autonomous / interactive / headless launch; if missing, soft tip + exit 0 (Field Report stays owed). `--paste-only` still prints the command without requiring `claude` yet. +4. A usable reviewer: `backend: "auto"` (default for new example/docs) uses Claude when present, else Cursor Agent. Pinned `backend: "claude"` still tips + no-op when Claude is missing. `--paste-only` still prints the command without requiring a binary yet. Same-family implementer and reviewer is an honest skip (including Auto/Auto). Claude review uses `reviewerModel` (default `sonnet` so `--permission-mode auto` can run); `advisorModel` (default `opus`) runs only on escalate. An explicit Haiku pin is valid and cannot run auto. ## Manual arm @@ -51,11 +51,12 @@ If any are missing: stop. Do **not** claim a review ran. Tell the user to run `a ### What "operator-visible" means (smoke notes) -- **Autonomous success:** chat arm **must** use `--force --autonomous --wait-monitor`. The launcher prefers a background/inspectable PTY (no OS Terminal focus by default; `--focus-terminal` / `AGENT_KIT_AUDIT_FOCUS_TERMINAL=1` restores activate), then polls until a **fresh** monitor exists (`mtime >= arm epoch` or the HTML comment sentinel `` written into the monitor). Exit `0` = fresh ready; `3` = timeout; `4` = soft-fail while waiting. Spawn-only exit 0 without wait is **not** review done. **Chat continuation:** AwaitShell until `0|3|4`; on `0` run `/plan-review-triage` Ask in the same session. Do **not** stop at Final HANDOFF "after monitor lands" or require typing `done`. ADR: `decisions/2026-07-27_audits-wait-freshness-enforce.md`. +- **Autonomous success:** chat arm **must** use `--force --autonomous --wait-monitor`. The launcher prefers a background/inspectable PTY (no OS Terminal focus by default; `--focus-terminal` / `AGENT_KIT_AUDIT_FOCUS_TERMINAL=1` restores activate), then polls until a **fresh** monitor exists (`mtime >= arm epoch` or the HTML comment sentinel `` written into the monitor). Chat AwaitShell uses `waitSliceSeconds` (default 90); total budget is `waitTimeoutSeconds` (default 900) persisted in `.cursor/context/audit-wait/.json`. A new session resumes remaining budget and does not restart 900s. Early-ready still exits `0` at first freshness. Exit `0` = fresh ready; `3` = timeout (slice or total; not review done); `4` = soft-fail while waiting. Spawn-only exit 0 without wait is **not** review done. **Chat continuation:** AwaitShell until `0|3|4`; on `0` run `/plan-review-triage` Ask in the same session. Do **not** stop at Final HANDOFF "after monitor lands" or require typing `done`. ADRs: `decisions/2026-07-27_audits-wait-freshness-enforce.md`, `decisions/2026-08-13_audits-atomic-wait-reviewer-fallback.md`. - **Autonomous soft-fail:** missing `claude` → tip + exit `4` when `--wait-monitor` was requested (Field Report owed). Background spawn unavailable → falls back to `--paste-only` UX with an honest "NOT running yet" banner. A **silent PTY** (spawn succeeded, no scrollback within the progress-gate grace window) is reported as a failed launch: the launcher disposes the session it just spawned, prints the paste fallback, and soft-fails instead of burning the wait budget. A **session-cap refusal** (detached `agent-kit-audit-*` sessions at the cap) never spawns at all. Soft-fail does **not** invent a monitor or run triage as if review completed. - **Exit 3 is timeout-only:** it means the freshness gate was not satisfied inside the budget, never that the review finished. A monitor that appears later, including one written by a different or later arm, does **not** convert a `3` into success. Leave the target Field Report **owed** and re-arm. ADR: `decisions/2026-07-30_audits-pty-progress-gate-zombie-policy.md`. - **Paste-only:** clipboard + printed interactive one-liner; review starts only after the operator pastes into their Cursor Terminal. After paste (Claude running), the session still waits for the monitor file then continues into triage Ask when possible. -- **`--dry-run`:** resolves mode/plan and prints `background-cmd` / `paste-cmd` / `focus-terminal` without spawning Claude (useful for smoke). +- **`--dry-run`:** resolves mode/plan and prints `background-cmd` / `paste-cmd` / `focus-terminal` / `reviewer-backend` / `same-model-refuse` without spawning a reviewer (useful for smoke). +- **Reviewer cascade:** `--backend auto|claude|cursor` (or config). Claude spawn passes `--model` from `reviewerModel`. Cursor fallback cannot honor a Claude-family name; Auto/Auto is refused. Findings-only until `/plan-review-triage`. Never `/git-prod`. ### A. Script (preferred) diff --git a/.cursor/commands/plan-review-triage.md b/.cursor/commands/plan-review-triage.md index 3ffdd37..c04e33f 100644 --- a/.cursor/commands/plan-review-triage.md +++ b/.cursor/commands/plan-review-triage.md @@ -115,7 +115,7 @@ Present a concise summary: Before Ask, classify open residuals for **closeout depth** and severity (ADR `decisions/2026-08-11_plan-audit-residuals-termination.md`): -1. **Theme family / closeout_depth:** strip leading `close-`, trailing `-residuals` / `-still-open`, and revision suffixes (`-rN`, `-rN-rM`, `-n2-n3`, `-a-f`, …). Depth includes prior Write-residuals/`close-*` hops for that family; a plan basename that already starts with `close-` is depth ≥ 1. +1. **Theme family / closeout_depth:** strip leading `close-`, trailing `-residuals` / `-still-open`, and revision suffixes (`-rN`, `-rN-rM`, `-n2-n3`, `-a-f`, …). Depth includes prior Write-residuals/`close-*` hops for that family; a plan basename that already starts with `close-` is depth ≥ 1. Count those hops from `.cursor/plans/`, `.cursor/plans/archive/`, and HANDOFF `- **Backlog plans:**` (plan files are gitignored; completed `close-*` plans live in archive). 2. **Preferred class:** - All open items are nits / docs-cite / process hygiene (Gaps voice, R14/R15, ledger, inherited CI dirt) with **no Blocking product defect** → prefer **Ack and stop** or **Fix nits only**. - Monitor is already depth ≥ 1 and no Blocking product finding → prefer **Ack and stop** / **Fix nits only**; do **not** treat Write residuals as the happy path. @@ -239,7 +239,7 @@ When multiple report paths are in scope (explicit args **or** bare-command selec 2. **Never `/git-prod`** from this command - residual fixes go through `/git-staging` only 3. **Never auto-implement** without the triage choice above 4. **Never skip Broad Intake or the backlog write-confirm Ask** on Write residuals plan (write-confirm may collapse for a remaining multi-path set when the operator authorizes it in the same reply as Write residuals). Never park, activate, Gate B, or rewrite Run queue from this path. Clipboard `/start-project` is **not** the happy path (optional operator escape hatch only when they want activate + Gate B). Do not fan out ≥3 plan-author Tasks in one turn (Step 6 pacing). -5. **No broad scope creep** in "Fix nits only" - redirect to Write residuals plan (backlog enqueue) for substantial work +5. **No broad scope creep** in "Fix nits only" - redirect to Write residuals plan (backlog enqueue) for substantial work. At closeout_depth ≥ 1 with no Blocking product finding, Step 5A gate 0 refuses that enqueue; use Ask **Other** / an explicit operator override instead (ADR decision 6) and record it in the triage heading. 6. **Never skip the triage heading** - including Ack and stop 7. **Never unbounded close-* conveyor** - enforce max closeout depth and nits/process-only defaults (Step 2b / Step 5A gate 0; ADR `decisions/2026-08-11_plan-audit-residuals-termination.md`). Depth-capped process-only Still open → Ack or Fix nits, not another `close-*`. diff --git a/.cursor/commands/run-plan-all.md b/.cursor/commands/run-plan-all.md index 34c0dd2..35996fa 100644 --- a/.cursor/commands/run-plan-all.md +++ b/.cursor/commands/run-plan-all.md @@ -184,13 +184,13 @@ 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`. | -| `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`; wait success requires a **fresh** monitor after arm start. 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: 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`). 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. 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: missing `claude`, 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 soft-fail, but record the target as owed, never as reviewed. ADR: `2026-07-30_audits-pty-progress-gate-zombie-policy.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. 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 soft-fail, but record the target as owed, never as reviewed. ADR: `2026-07-30_audits-pty-progress-gate-zombie-policy.md`. ### External plan review (legacy heading) diff --git a/.cursor/commands/run-plan-loop.md b/.cursor/commands/run-plan-loop.md index f9ee78e..1036bc7 100644 --- a/.cursor/commands/run-plan-loop.md +++ b/.cursor/commands/run-plan-loop.md @@ -11,4 +11,4 @@ When invoked, follow [`run-plan.md`](run-plan.md) forcing the **in-session loop* > "Heads up: `/run-plan-loop` is now `/run-plan`. Running with the in-session strategy." -The full tick contract (plan status per tick, HANDOFF, automatic `/git-staging` on diff, **never** `/git-prod`, stop conditions) lives in `run-plan.md`, including the headless runner (`scripts/plan-loop.sh`) section. +The full tick contract (plan status per tick, HANDOFF, automatic `/git-staging` on diff including HANDOFF/memory kit buckets, **never** skip those as trivial, **never** `/git-prod`, stop conditions) lives in `run-plan.md`, including the headless runner (`scripts/plan-loop.sh`) section. diff --git a/.cursor/commands/run-plan-orchestrated.md b/.cursor/commands/run-plan-orchestrated.md index c11420c..82a4cd7 100644 --- a/.cursor/commands/run-plan-orchestrated.md +++ b/.cursor/commands/run-plan-orchestrated.md @@ -11,4 +11,4 @@ When invoked, follow [`run-plan.md`](run-plan.md) forcing the **orchestrated** s > "Heads up: `/run-plan-orchestrated` is now `/run-plan`. Running with the orchestrated strategy." -The full tick contract, worker routing table, and worker prompt template live in `run-plan.md`. Invariants unchanged: automatic `/git-staging` on diff, **never** `/git-prod`. +The full tick contract, worker routing table, and worker prompt template live in `run-plan.md`. Invariants unchanged: automatic `/git-staging` on diff (including HANDOFF/memory kit buckets; never skip as trivial), **never** `/git-prod`. diff --git a/.cursor/commands/run-plan.md b/.cursor/commands/run-plan.md index c3f1dc4..afc9d4d 100644 --- a/.cursor/commands/run-plan.md +++ b/.cursor/commands/run-plan.md @@ -110,7 +110,7 @@ After Final HANDOFF when the run stopped because all implementable to-dos are do - `Always enable automatic`: merge `enabled: true` (and prefer `mode: autonomous` when setting defaults for new opt-in) into `externalPlanReview`, then same background/inspectable arming rules as enabled-true above (not agent-shell `-p`; always include `--wait-monitor` on autonomous arm). - `Not now`: merge `offerOnExhausted: false` (no nag on later exhaustion). Manual `/plan-external-review` still works. 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). Wait success requires a **fresh** monitor after arm start (mtime/arm-epoch or content sentinel); pre-existing files are not ready. + 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). A later session polls remaining budget only. 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 **timeout / soft-fail (3|4):** honest tip + Field Report owed; do **not** invent a finished review or run triage as if the monitor is ready. Exit `3` is **timeout only**: it never means review done, and a monitor that appears afterwards (later writer, separate arm, another queue position) does **not** convert it into success. Exit `4` now also 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. 4. Never claim the audit finished on spawn-only exit 0 or on a stale pre-arm monitor path. @@ -148,8 +148,8 @@ After a findings-contract (or `review-*`) tick returns findings, read `.cursor/c 1. Mark the tick's to-do as `completed` in the plan's frontmatter 2. Update `.cursor/HANDOFF.md` (include `Mode: run-plan ()`) 3. **Cadence ledger:** run `.cursor/scripts/field-report-cadence-bump.sh tick` (increments the gitignored Field Report activity counter; may open a cadence warning when threshold + unreviewed work). Never commit the ledger. ADR: `2026-07-27_field-report-activity-review-cadence.md`. -4. If `git status` has commitable changes **and** it is not just a trivial HANDOFF/memory update: run the `/git-staging` routine **without asking for confirmation** (authorized by this command); 1 MR/PR -> staging branch -> merge. **Monitor hygiene:** if untracked or unrelated dirty `.cursor/memory/plan-monitor-*.md` appear, warn and stage product (and intentional memory) files **add-by-name only**; never broad `git add` of `.cursor/memory/` WIP into a product commit. -5. No commitable diff: just HANDOFF + plan status +4. If `git status` has commitable changes: run the `/git-staging` routine **without asking for confirmation** (authorized by this command), following `autogit/gitupdate.md` inventory → theme-bucket → ship. Do **not** skip because the diff is "just" HANDOFF or memory: versioned HANDOFF, `plan-monitor-*.md`, and `_index.md` Audits rows are a `docs(memory):` / `chore(kit):` bucket (own commit when the product theme differs). **Monitor hygiene:** warn on untracked or unrelated dirty `.cursor/memory/plan-monitor-*.md`; stage **add-by-name only**; never broad `git add` of `.cursor/memory/` WIP into a product commit. Unrelated-plan monitors still get a kit/memory bucket in this staging run; they do not ride inside the product commit. 1 MR/PR -> staging branch -> merge. +5. No commitable diff (working tree clean except gitignored paths and hard excludes): just HANDOFF + plan status ### 6. Reschedule or stop diff --git a/.cursor/context/config.example.json b/.cursor/context/config.example.json index 1da90f6..e0ec634 100644 --- a/.cursor/context/config.example.json +++ b/.cursor/context/config.example.json @@ -9,7 +9,9 @@ }, "externalPlanReview": { "enabled": false, - "backend": "claude", + "backend": "auto", + "reviewerModel": "sonnet", + "advisorModel": "opus", "autoRemediate": false, "offerOnExhausted": true, "mode": "autonomous", diff --git a/.cursor/context/templates/plan-external-review-prompt.md b/.cursor/context/templates/plan-external-review-prompt.md index 323200a..8c2971f 100644 --- a/.cursor/context/templates/plan-external-review-prompt.md +++ b/.cursor/context/templates/plan-external-review-prompt.md @@ -1,6 +1,6 @@ -# External Plan Review Prompt (Claude Code) +# External Plan Review Prompt -Use this prompt when conducting external review of completed Agent Kit plans via Claude Code. +Use this prompt when conducting external review of completed Agent Kit plans via Claude Code or Cursor Agent. ## Role and Contract @@ -12,6 +12,8 @@ You are conducting post-hoc evidence-based monitoring of an Agent Kit plan execu 3. **Evidence gathering** from git history, commits, PRs, and file diffs 4. **Gap detection** between plan requirements and actual shipped deliverables 5. **NO product commits** unless explicitly requested by human after triage +6. **Findings-contract vs git delta:** compare the plan to-dos to `git log` / `git diff` / `git show` vs HEAD. This is not a second implement pass. Do not re-implement features or expand scope. +7. **Advisor escalate:** if any finding is high/critical severity or you are uncertain, add exactly one HTML comment line ``. Do not invoke the advisor yourself. ## Template Path diff --git a/.cursor/hooks/agent/session-start.sh b/.cursor/hooks/agent/session-start.sh index 5463364..5ae9b97 100755 --- a/.cursor/hooks/agent/session-start.sh +++ b/.cursor/hooks/agent/session-start.sh @@ -5,8 +5,11 @@ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd) # shellcheck disable=SC1091 . "$SCRIPT_DIR/resolve-agent-kit.sh" if ! resolve_agent_kit; then - # Fail-open: empty additional context - printf '%s\n' '{}' + # Fail-open, but not silent: sessionStart is the only hook that can surface + # text to the session, so it carries the degraded-mode diagnostic for all + # five adapters (stateless, per-session; see docs/marketplace.md, "Hook + # resolution boundary"). + printf '%s\n' '{"additional_context": "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, packages/cli/dist). Rules/commands/skills still work; hook-provided context, shell guard, schema check, and secrets scan are inactive. Fix: install the CLI (npm i -D @dadado/agent-kit-cli) or set AGENT_KIT_HOOK_BIN."}' exit 0 fi # shellcheck disable=SC2086 diff --git a/.cursor/rules/cursor-skills-git-workflow.mdc b/.cursor/rules/cursor-skills-git-workflow.mdc index 5c37998..e840ca4 100644 --- a/.cursor/rules/cursor-skills-git-workflow.mdc +++ b/.cursor/rules/cursor-skills-git-workflow.mdc @@ -52,11 +52,11 @@ The "never commit to `main`" rule only works if the checkout is not on `main` wh ## Conventional Commits -Use prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`, `test:`, `style:`, `perf:`. +Use prefixes: `feat:`, `fix:`, `docs:`, `docs(memory):`, `refactor:`, `chore:`, `chore(kit):`, `test:`, `style:`, `perf:`. Soft kit paths (plan-monitors, `_index.md` Audits rows, versioned HANDOFF) ship as `docs(memory):` / `chore(kit):` when they are not the product theme. ## When the user says -- **`git staging`** - Full routine in [autogit/gitupdate.md](autogit/gitupdate.md) ("Prompt: git staging" section): validation, CHANGELOG (`[Unreleased]`), checkout staging, pull, working branch, commit, push, MR, merge, cleanup; then HANDOFF. Warn on dirty untracked `plan-monitor-*.md`; stage memory/monitor files add-by-name only (never broad `git add` of `.cursor/memory/` WIP). +- **`git staging`** - Full routine in [autogit/gitupdate.md](autogit/gitupdate.md) ("Prompt: git staging" section): inventory the dirty tree, theme-bucket, CHANGELOG (`[Unreleased]`), checkout staging, pull, working branch, commit, push, MR, merge, cleanup; then HANDOFF. SoT for inventory → bucket → ship. Warn on dirty untracked `plan-monitor-*.md`; stage memory/monitor files add-by-name only (never broad `git add` of `.cursor/memory/` WIP into a product commit). Remaining safe kit dirt ships in a `docs(memory):` / `chore(kit):` bucket in the same run; do not stop-and-quiz because a path looks out of flow. - **`git prod`** - Routine in [autogit/gitupdate.md](autogit/gitupdate.md) ("Prompt: git prod" section): validation, close the release in the CHANGELOG, summary, **explicit confirmation**, merge to main, push; then HANDOFF (+ public sync in this monorepo if applicable). Advisory mention of Blocking untriaged monitors matching the delta is allowed; never steal the confirmation Ask. ## Quick troubleshooting diff --git a/.cursor/rules/hitl-ask-questions.mdc b/.cursor/rules/hitl-ask-questions.mdc index 7d9502e..3b74cec 100644 --- a/.cursor/rules/hitl-ask-questions.mdc +++ b/.cursor/rules/hitl-ask-questions.mdc @@ -34,7 +34,7 @@ Agent Kit uses **Ask questions** tool (`AskQuestion` / ACP `cursor/ask_question` | `/continue-plan` | Confirm next `[to-do-id]`; if multiple plans, pick which to resume | | `/git-prod` | Explicit confirm before merge/push to `main` | | `/hotfix` | Confirm before write+run (`Write mini plan and run` / `Write mini plan only (stop)` / `Modify proposal first` / `Cancel`); lock ambiguous glyphs/tokens before run; risk pause same as `/run-plan` while ticking; never `/git-prod` | -| `/run-plan` | Risk pause (PII, secrets, ambiguous scope); not every tick. Audits pre-flight per `externalPlanReview.preflight`. Plan-exhausted audits arm when enabled (`mode: autonomous` → `--force --autonomous --wait-monitor`; `paste` → paste-only); else `offerOnExhausted` Ask (`Run review now` / `Always enable automatic` / `Not now`); AwaitShell until exit `0|3|4` (fresh monitor; not spawn-only); exit `0` → `/plan-review-triage` Ask (not Final HANDOFF "after monitor lands"); chat never silent headless `--force`; must not steal `/git-prod` HITL (Ask after Final HANDOFF / prod suggestion) | +| `/run-plan` | Risk pause (PII, secrets, ambiguous scope); not every tick. Audits pre-flight per `externalPlanReview.preflight`. Plan-exhausted audits arm when enabled (`mode: autonomous` → `--force --autonomous --wait-monitor`; `paste` → paste-only); else `offerOnExhausted` Ask (`Run review now` / `Always enable automatic` / `Not now`); AwaitShell until exit `0|3|4` (fresh monitor; chat slice `waitSliceSeconds` ~90s; remaining budget in `.cursor/context/audit-wait/`; not spawn-only); `backend: "auto"` uses Claude or Cursor Agent; exit `0` → `/plan-review-triage` Ask (not Final HANDOFF "after monitor lands"); chat never silent headless `--force`; must not steal `/git-prod` HITL (Ask after Final HANDOFF / prod suggestion) | | `/run-plan-all` | Confirm queue Ask (6-way) before execute. Audits pre-flight per config. Mid-queue: when `midBatchAudits` one arm+wait per plan (or one `--batch` + wait_all); no N-Terminal fan-out without wait; no mid-queue triage Ask. Queue-end: audits arm / optional Ask, wait then `/plan-review-triage` Ask with explicit path list, then `/git-prod` suggestion as separate HITL. Malformed Task summary Ask before advancing cursor. Risk gates stay inside each plan Task. | | `/plan-review-triage` | Triage choice (write residuals plan / fix nits only / ack and stop); every outcome persists a durable triage heading on the monitor; reached automatically after wait-monitor exit `0` (freshness) when chat armed the audit. Classify preferred class before Ask (max closeout depth 1; nits/process-only prefer Ack/Fix nits; ADR `2026-08-11_plan-audit-residuals-termination.md`). **Write residuals:** gate 0 refuses another `close-*` when depth-capped/process-only unless operator overrides; Broad Intake (same buckets/labels as `/backlog-add`) → propose from Still open + intake → Ask `Write plan to backlog` / `Modify proposal first` / `Cancel` → plan file + HANDOFF Backlog (no Gate B, no activate, no Run-queue rewrite; `/start-project` optional escape hatch only). **Multi-path:** one Ask when remaining monitors share a uniform outcome class (batch Ack / batch residuals enqueue with one intake + one combined plan); sequential fallback when mixed; never silent-Ack (ADRs `2026-07-27_plan-review-triage-batch-uniform-hitl.md`, `2026-07-28_triage-write-residuals-via-backlog.md`) | | `/handoff` / guardian | Prefer Ask questions when offering auto vs manual handoff | diff --git a/.cursor/scripts/comms-draft.mjs b/.cursor/scripts/comms-draft.mjs new file mode 100644 index 0000000..00d46bb --- /dev/null +++ b/.cursor/scripts/comms-draft.mjs @@ -0,0 +1,134 @@ +#!/usr/bin/env node +/** + * Local Mission Kit comms drafts. Never posts to a network. + * + * node .cursor/scripts/comms-draft.mjs --kind recap --channel x + * node .cursor/scripts/comms-draft.mjs --publish # always exits 2 + */ +import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const KINDS = new Set(["recap", "release", "contributor-ask"]); +const CHANNELS = new Set(["x", "medium", "substack", "hn", "github", "site"]); +const PUBLISH_REFUSE = + "publish refused: comms-draft never posts; operator Ask then human publish"; + +export function utcDay(d = new Date()) { + return d.toISOString().slice(0, 10); +} + +export function draftId(kind, channel, day) { + return `${kind}-${channel}-${day}`; +} + +export function parseArgs(argv) { + const out = { + kind: "recap", + channel: "x", + version: "5.0.0", + publish: false, + forceNew: false, + help: false, + }; + for (let i = 0; i < argv.length; i += 1) { + const a = argv[i]; + if (a === "--help" || a === "-h") out.help = true; + else if (a === "--publish") out.publish = true; + else if (a === "--force-new") out.forceNew = true; + else if (a === "--kind") out.kind = argv[++i] ?? out.kind; + else if (a === "--channel") out.channel = argv[++i] ?? out.channel; + else if (a === "--version") out.version = argv[++i] ?? out.version; + else if (a.startsWith("--kind=")) out.kind = a.slice("--kind=".length); + else if (a.startsWith("--channel=")) out.channel = a.slice("--channel=".length); + else if (a.startsWith("--version=")) out.version = a.slice("--version=".length); + } + return out; +} + +export function validate(opts) { + if (!KINDS.has(opts.kind)) return `unknown --kind ${opts.kind}`; + if (!CHANNELS.has(opts.channel)) return `unknown --channel ${opts.channel}`; + if (opts.publish) return PUBLISH_REFUSE; + return null; +} + +/** Redact any accidental secret-shaped assignment in draft bodies. */ +export function redactSecrets(text) { + return text.replace( + /\b(MISSION_KIT_COMMS_[A-Z0-9_]+|BEARER|TOKEN|WEBHOOK|PASSWORD|SECRET)\s*=\s*\S+/gi, + "$1=", + ); +} + +export function renderDraft(opts, day) { + const id = draftId(opts.kind, opts.channel, day); + const version = opts.version; + const bodies = { + recap: `Mission Kit recap (${day})\n\nShipped this cycle: fill from CHANGELOG [Unreleased] that is already in staging.\n\nInstall: npx @dadado/agent-kit-cli@${version} install\nSite: https://missionkit.io\nHITL: production still needs a human yes.\n`, + release: `Mission Kit ${version} / Agent Kit CLI @${version}\n\nnpx @dadado/agent-kit-cli@${version} install\nhttps://missionkit.io\nPolyForm Noncommercial; commercial: sales@missionkit.io\n`, + "contributor-ask": `Help Mission Kit: skills under registry/skills/community/, or agent-kit contribute from a consumer project.\nIssues: https://github.com/agent-kit-startup/agent-kit\nDo not use public issues for vulnerabilities (SECURITY.md).\nCursor Marketplace submit is not this ask.\n`, + }; + const body = redactSecrets(bodies[opts.kind]); + return { + id, + kind: opts.kind, + channel: opts.channel, + version, + day, + hitl: "pending", + body, + }; +} + +export function draftsDir(repoRoot) { + return join(repoRoot, ".cursor", "comms-drafts"); +} + +export function writeDraft(repoRoot, draft, { forceNew = false } = {}) { + const dir = draftsDir(repoRoot); + mkdirSync(dir, { recursive: true }); + const file = join(dir, `${draft.id}.json`); + if (existsSync(file) && !forceNew) { + return { path: file, reused: true, draft: JSON.parse(readFileSync(file, "utf8")) }; + } + const payload = `${JSON.stringify(draft, null, 2)}\n`; + writeFileSync(file, payload, "utf8"); + return { path: file, reused: false, draft }; +} + +function printHelp() { + process.stdout.write(`comms-draft: local drafts only (never publishes) + +Options: + --kind recap|release|contributor-ask + --channel x|medium|substack|hn|github|site + --version 5.0.0 + --force-new + --publish refused (exit 2) +`); +} + +export function main(argv, repoRoot) { + const opts = parseArgs(argv); + if (opts.help) { + printHelp(); + return 0; + } + const err = validate(opts); + if (err) { + process.stderr.write(`${err}\n`); + return opts.publish ? 2 : 1; + } + const day = utcDay(); + const draft = renderDraft(opts, day); + const result = writeDraft(repoRoot, draft, { forceNew: opts.forceNew }); + process.stdout.write(`${result.reused ? "reused" : "wrote"} ${result.path}\n`); + return 0; +} + +const isDirect = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; +if (isDirect) { + const root = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); + process.exitCode = main(process.argv.slice(2), root); +} diff --git a/.cursor/scripts/comms-draft.test.mjs b/.cursor/scripts/comms-draft.test.mjs new file mode 100644 index 0000000..f1a4ab4 --- /dev/null +++ b/.cursor/scripts/comms-draft.test.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { + draftId, + main, + parseArgs, + redactSecrets, + renderDraft, + validate, + writeDraft, +} from "./comms-draft.mjs"; + +test("publish flag fails closed", () => { + const opts = parseArgs(["--publish", "--kind", "recap"]); + assert.equal(opts.publish, true); + const msg = validate(opts); + assert.match(msg, /never posts/i); +}); + +test("main --publish exits 2 and writes nothing", () => { + const room = mkdtempSync(join(tmpdir(), "ak-comms-")); + try { + const code = main(["--publish"], room); + assert.equal(code, 2); + } finally { + rmSync(room, { recursive: true, force: true }); + } +}); + +test("unknown kind exits 1", () => { + assert.equal(validate(parseArgs(["--kind", "ads"])), "unknown --kind ads"); +}); + +test("renderDraft stays on 5.0.0 and dual-name install", () => { + const d = renderDraft({ kind: "release", channel: "x", version: "5.0.0" }, "2026-08-13"); + assert.equal(d.id, draftId("release", "x", "2026-08-13")); + assert.match(d.body, /@dadado\/agent-kit-cli@5\.0\.0/); + assert.match(d.body, /missionkit\.io/); + assert.equal(d.hitl, "pending"); + assert.doesNotMatch(d.body, /Marketplace listed/i); +}); + +test("redactSecrets strips assignment values", () => { + const out = redactSecrets("MISSION_KIT_COMMS_X_BEARER=live-secret-value TOKEN=abc"); + assert.match(out, //); + assert.doesNotMatch(out, /live-secret-value/); + assert.doesNotMatch(out, /TOKEN=abc/); +}); + +test("writeDraft is idempotent unless force-new", () => { + const room = mkdtempSync(join(tmpdir(), "ak-comms-")); + try { + const draft = renderDraft({ kind: "recap", channel: "x", version: "5.0.0" }, "2026-08-13"); + const a = writeDraft(room, draft); + const b = writeDraft(room, draft); + assert.equal(a.reused, false); + assert.equal(b.reused, true); + const c = writeDraft(room, { ...draft, body: "second" }, { forceNew: true }); + assert.equal(c.reused, false); + const saved = JSON.parse(readFileSync(a.path, "utf8")); + assert.equal(saved.body, "second"); + } finally { + rmSync(room, { recursive: true, force: true }); + } +}); + +test("direct module path is this file's sibling script", () => { + assert.match(fileURLToPath(import.meta.url), /comms-draft\.test\.mjs$/); +}); diff --git a/.cursor/scripts/plan-external-review-atomic-wait.test.mjs b/.cursor/scripts/plan-external-review-atomic-wait.test.mjs new file mode 100644 index 0000000..d6dba9f --- /dev/null +++ b/.cursor/scripts/plan-external-review-atomic-wait.test.mjs @@ -0,0 +1,181 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { 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"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const SCRIPT = join(repoRoot, ".cursor/scripts/plan-external-review.sh"); +const SRC = readFileSync(SCRIPT, "utf8"); + +function extractFn(name) { + const out = spawnSync("sed", ["-n", `/^${name}() {/,/^}$/p`, SCRIPT], { encoding: "utf8" }); + assert.ok(out.stdout.includes(`${name}()`), `failed to extract ${name}`); + return out.stdout; +} + +function runBash(room, body) { + const fns = [ + "is_headless_env", + "slug_from_monitor_path", + "wait_state_path", + "wait_state_get", + "wait_state_write", + "wait_state_clear", + "wait_state_is_resumable", + "wait_state_load_or_init", + "wait_effective_timeout", + "wait_state_finish", + "file_mtime_epoch", + "monitor_is_fresh", + ] + .map(extractFn) + .join("\n"); + const bash = ` +set -euo pipefail +ROOT=${JSON.stringify(room)} +WAIT_TIMEOUT=40 +WAIT_TIMEOUT_EXPLICIT=1 +WAIT_SLICE=2 +WAIT_SLICE_EXPLICIT=1 +WAIT_ARM_EPOCH="" +WAIT_DEADLINE="" +WAIT_REMAINING="" +WAIT_RESUME=0 +WAIT_STATE_DIR_REL=".cursor/context/audit-wait" +WAIT_BACKEND_STAMP="claude" +WAIT_IMPLEMENTER_MODEL="" +WAIT_REVIEWER_MODEL="" +DRY_RUN=0 +unset CI GITHUB_ACTIONS GITLAB_CI AGENT_KIT_HEADLESS +MOCK_TIME=1000 +date() { echo "\$MOCK_TIME"; } +${fns} +${body} +`; + return spawnSync("bash", ["-c", bash], { encoding: "utf8" }); +} + +function makeRoom() { + const room = mkdtempSync(join(tmpdir(), "ak-atomic-wait.")); + mkdirSync(join(room, ".cursor/memory"), { recursive: true }); + mkdirSync(join(room, ".cursor/context/audit-wait"), { recursive: true }); + return room; +} + +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/); + assert.match(SRC, /wait_state_finish ready/); + assert.match(SRC, /wait_state_finish timeout/); + assert.match(SRC, /WAIT_STATE_DIR_REL="\.cursor\/context\/audit-wait"/); + assert.match(SRC, /--wait-slice/); + assert.match(SRC, /waitSliceSeconds/); +}); + +test("wait_effective_timeout uses the chat slice when remaining is larger", () => { + const room = makeRoom(); + try { + const result = runBash( + room, + ` +WAIT_REMAINING=900 +WAIT_SLICE=90 +WAIT_SLICE_EXPLICIT=1 +echo "effective=$(wait_effective_timeout)" +`, + ); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /effective=90/); + } finally { + rmSync(room, { recursive: true, force: true }); + } +}); + +test("early-ready: sentinel monitor is fresh and finish ready clears wait-state", () => { + const room = makeRoom(); + try { + writeFileSync( + join(room, ".cursor/memory/plan-monitor-a.md"), + "\n# ready\n", + ); + const result = runBash( + room, + ` +wait_state_load_or_init ".cursor/memory/plan-monitor-a.md" +if monitor_is_fresh ".cursor/memory/plan-monitor-a.md"; then + echo "fresh=yes" + wait_state_finish ready ".cursor/memory/plan-monitor-a.md" +else + echo "fresh=no" + exit 1 +fi +`, + ); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /fresh=yes/); + assert.equal(existsSync(join(room, ".cursor/context/audit-wait/a.json")), false); + } finally { + rmSync(room, { recursive: true, force: true }); + } +}); + +test("slice timeout persists remaining budget outside HANDOFF", () => { + const room = makeRoom(); + try { + const result = runBash( + room, + ` +wait_state_load_or_init ".cursor/memory/plan-monitor-a.md" +echo "effective=$(wait_effective_timeout) remaining=$WAIT_REMAINING" +wait_state_finish timeout ".cursor/memory/plan-monitor-a.md" +`, + ); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /effective=2/); + assert.match(result.stdout, /wait-slice timeout \(remaining=\d+s/); + const state = JSON.parse(readFileSync(join(room, ".cursor/context/audit-wait/a.json"), "utf8")); + assert.equal(state.status, "armed"); + assert.ok(state.remainingBudgetSeconds >= 30, JSON.stringify(state)); + assert.ok(state.remainingBudgetSeconds <= 40, JSON.stringify(state)); + assert.equal(state.backend, "claude"); + assert.ok(!("token" in state) && !("secret" in state)); + assert.equal(existsSync(join(room, ".cursor/HANDOFF.md")), false); + } finally { + rmSync(room, { recursive: true, force: true }); + } +}); + +test("resume loads stored epoch and remaining instead of restarting 900s", () => { + const room = makeRoom(); + try { + writeFileSync( + join(room, ".cursor/context/audit-wait/a.json"), + JSON.stringify({ + armEpoch: 990, + deadline: 1002, + remainingBudgetSeconds: 2, + backend: "claude", + implementerModel: "", + reviewerModel: "", + status: "armed", + }), + ); + const result = runBash( + room, + ` +WAIT_TIMEOUT=900 +WAIT_SLICE=90 +wait_state_load_or_init ".cursor/memory/plan-monitor-a.md" +echo "resume=$WAIT_RESUME remaining=$WAIT_REMAINING epoch=$WAIT_ARM_EPOCH effective=$(wait_effective_timeout)" +`, + ); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /wait-state resume/); + assert.match(result.stdout, /resume=1 remaining=2 epoch=990 effective=2/); + } finally { + rmSync(room, { recursive: true, force: true }); + } +}); diff --git a/.cursor/scripts/plan-external-review-backend-cascade.test.mjs b/.cursor/scripts/plan-external-review-backend-cascade.test.mjs new file mode 100644 index 0000000..e360f7b --- /dev/null +++ b/.cursor/scripts/plan-external-review-backend-cascade.test.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +const SCRIPT = resolve( + dirname(fileURLToPath(import.meta.url)), + "../..", + ".cursor/scripts/plan-external-review.sh", +); +const SRC = readFileSync(SCRIPT, "utf8"); + +test("launcher cascade: auto prefers Claude, else cursor-agent", () => { + assert.match(SRC, /resolve_reviewer_backend/); + assert.match(SRC, /AGENT_KIT_AUDIT_CLAUDE_QUOTA_EMPTY/); + assert.match(SRC, /cursor-agent -p --force --sandbox disabled/); + assert.match(SRC, /--backend auto\|claude\|cursor/); + assert.match(SRC, /tip_no_reviewer/); + assert.match(SRC, /This is not a Cursor tick API\/usage-limit hard-stop/); +}); + +test("pinned claude does not share the auto fallback path", () => { + assert.match(SRC, /backend=claude is pinned/); + assert.match(SRC, /REVIEWER_BACKEND" == "none"/); +}); diff --git a/.cursor/scripts/plan-external-review-model-routing.test.mjs b/.cursor/scripts/plan-external-review-model-routing.test.mjs new file mode 100644 index 0000000..0613882 --- /dev/null +++ b/.cursor/scripts/plan-external-review-model-routing.test.mjs @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const SCRIPT = join(repoRoot, ".cursor/scripts/plan-external-review.sh"); +const SRC = readFileSync(SCRIPT, "utf8"); + +function extractFn(name) { + const out = spawnSync("sed", ["-n", `/^${name}() {/,/^}$/p`, SCRIPT], { encoding: "utf8" }); + assert.ok(out.stdout.includes(`${name}()`), `failed to extract ${name}`); + return out.stdout; +} + +function runBash(body) { + const fns = ["normalize_model_family", "models_same_family", "effective_reviewer_model"] + .map(extractFn) + .join("\n"); + const bash = ` +set -euo pipefail +REVIEWER_BACKEND="\${REVIEWER_BACKEND:-claude}" +REVIEWER_MODEL="\${REVIEWER_MODEL:-sonnet}" +${fns} +${body} +`; + return spawnSync("bash", ["-c", bash], { encoding: "utf8" }); +} + +// Historical default at f27910a was Haiku; classifier-capable Sonnet is the post-amendment default. +test("launcher model routing: classifier-capable reviewer spawn, escalate sentinel, same-model refuse", () => { + assert.match(SRC, /--reviewer-model/); + assert.match(SRC, /--advisor-model/); + assert.match(SRC, /--implementer-model/); + assert.match(SRC, /AGENT_KIT_AUDIT_IMPLEMENTER_MODEL/); + assert.match(SRC, /--model "\$\{WAIT_REVIEWER_MODEL:-sonnet\}"/); + assert.match(SRC, //); + assert.match(SRC, /maybe_run_advisor/); + assert.match(SRC, /enforce_implementer_reviewer_split/); + assert.match(SRC, /findings-contract against the git delta/); + assert.match(SRC, /This is not a silent self-review/); +}); + +test("normalize_model_family collapses vendor aliases", () => { + const out = runBash(` +echo "$(normalize_model_family "")" +echo "$(normalize_model_family Auto)" +echo "$(normalize_model_family haiku)" +echo "$(normalize_model_family claude-haiku-4-5-20251001)" +echo "$(normalize_model_family claude-3-5-haiku-latest)" +echo "$(normalize_model_family opus)" +echo "$(normalize_model_family claude-opus-4-20250514)" +echo "$(normalize_model_family composer-2.5-fast)" +`); + assert.equal(out.status, 0, out.stderr); + assert.equal( + out.stdout.trim(), + ["auto", "auto", "haiku", "haiku", "haiku", "opus", "opus", "composer"].join("\n"), + ); +}); + +test("models_same_family refuses Auto/Auto and Haiku/Haiku, allows Auto/Haiku", () => { + const out = runBash(` +models_same_family auto Auto && echo auto-auto || echo auto-auto-no +models_same_family haiku claude-haiku-4-5 && echo haiku-haiku || echo haiku-haiku-no +models_same_family auto haiku && echo auto-haiku || echo auto-haiku-no +models_same_family sonnet opus && echo sonnet-opus || echo sonnet-opus-no +`); + assert.equal(out.status, 0, out.stderr); + assert.equal( + out.stdout.trim(), + ["auto-auto", "haiku-haiku", "auto-haiku-no", "sonnet-opus-no"].join("\n"), + ); +}); + +test("effective_reviewer_model: Claude default is sonnet; explicit haiku kept; Cursor Claude-family collapses to auto", () => { + const claudeDefault = runBash(` +REVIEWER_BACKEND=claude +unset REVIEWER_MODEL +echo "$(effective_reviewer_model)" +`); + assert.equal(claudeDefault.status, 0, claudeDefault.stderr); + assert.equal(claudeDefault.stdout.trim(), "sonnet"); + + const claudeHaiku = runBash(` +REVIEWER_BACKEND=claude +REVIEWER_MODEL=haiku +echo "$(effective_reviewer_model)" +`); + assert.equal(claudeHaiku.status, 0, claudeHaiku.stderr); + assert.equal(claudeHaiku.stdout.trim(), "haiku"); + + const cursorHaiku = runBash(` +REVIEWER_BACKEND=cursor +REVIEWER_MODEL=haiku +echo "$(effective_reviewer_model)" +`); + assert.equal(cursorHaiku.status, 0, cursorHaiku.stderr); + assert.equal(cursorHaiku.stdout.trim(), "auto"); + + const cursorNamed = runBash(` +REVIEWER_BACKEND=cursor +REVIEWER_MODEL=composer-2.5-fast +echo "$(effective_reviewer_model)" +`); + assert.equal(cursorNamed.status, 0, cursorNamed.stderr); + assert.equal(cursorNamed.stdout.trim(), "composer-2.5-fast"); +}); diff --git a/.cursor/scripts/plan-external-review-progress-gate.test.mjs b/.cursor/scripts/plan-external-review-progress-gate.test.mjs index c58884a..6939b10 100644 --- a/.cursor/scripts/plan-external-review-progress-gate.test.mjs +++ b/.cursor/scripts/plan-external-review-progress-gate.test.mjs @@ -128,3 +128,37 @@ wait_for_pty_progress "screen" "test-session" /audits: progress gate disabled \(AGENT_KIT_AUDIT_PROGRESS_TIMEOUT=0\)/, ); }); + +function extractSuffix() { + return spawnSync("sed", ["-n", "/^audit_kit_suffix() {/,/^}$/p", SCRIPT], { + encoding: "utf8", + }).stdout; +} + +test("heartbeat lines keep status prefixes; suffix is empty under CI", () => { + const src = spawnSync("cat", [SCRIPT], { encoding: "utf8" }).stdout; + assert.match(src, /audit_kit_suffix\(\)/); + assert.match(src, /audits: progress gate still silent beyond banner/); + assert.match(src, /audits: wait-monitor still waiting/); + assert.match(src, /audits: wait-monitor timeout after \$\{elapsed\}s \(exit 3\)/); + assert.match(src, /audits: wait-monitor soft-fail \(exit 4\)/); + assert.match(src, /Try agent-kit doctor for repository readiness\./); + const fn = extractSuffix(); + assert.match(fn, /audit_kit_suffix/); + const result = spawnSync("bash", ["-c", `${fn}\nexport CI=1\nprintf '[%s]' "$(audit_kit_suffix 20)"`], { + encoding: "utf8", + }); + assert.strictEqual(result.status ?? 1, 0, result.stderr); + assert.strictEqual(result.stdout, "[]"); +}); + +test("audit_kit_suffix is empty under NO_COLOR even without CI", () => { + const fn = extractSuffix(); + const result = spawnSync( + "bash", + ["-c", `${fn}\nunset CI\nexport NO_COLOR=1\nprintf '[%s]' "$(audit_kit_suffix 8)"`], + { encoding: "utf8" }, + ); + assert.strictEqual(result.status ?? 1, 0, result.stderr); + assert.strictEqual(result.stdout, "[]"); +}); diff --git a/.cursor/scripts/plan-external-review.sh b/.cursor/scripts/plan-external-review.sh index d28b062..bcba7b3 100755 --- a/.cursor/scripts/plan-external-review.sh +++ b/.cursor/scripts/plan-external-review.sh @@ -48,13 +48,24 @@ # --focus-terminal / AGENT_KIT_AUDIT_FOCUS_TERMINAL=1: rollback to OS Terminal activate / # emulator focus (legacy foreground window). Default is no-focus background spawn. # --wait-monitor: after autonomous spawn (or standalone), poll until plan-monitor-.md -# is fresh under .cursor/memory/, or until --wait-timeout (default 900s). +# is fresh under .cursor/memory/, or until the effective slice/remaining budget. # Fresh = mtime >= arm epoch (recorded when wait arm starts) OR file contains # the content sentinel line: # Pre-arm / stale files are ignored (do not exit 0 on existence alone). # Combine with --force --autonomous (spawn then wait), or use alone to poll an # already-running review. Does not switch to invisible agent-shell claude -p. -# --wait-timeout SECONDS: poll budget for --wait-monitor (default 900). +# Wait state lives in .cursor/context/audit-wait/.json (not HANDOFF). +# A new session resumes remaining budget; it does not restart 900s from zero. +# --wait-timeout SECONDS: total remaining budget for a new arm (default 900; config +# waitTimeoutSeconds). CI/headless uses this as the invocation cap. +# --wait-slice SECONDS: chat AwaitShell budget per session (default 90; config +# waitSliceSeconds). Early-ready still exits 0 at first freshness. +# --backend auto|claude|cursor: reviewer cascade override. auto uses Claude when +# usable, else cursor-agent. Existing config with missing backend stays claude. +# --reviewer-model NAME: reviewer model id (default sonnet / config reviewerModel). +# --advisor-model NAME: escalate-only advisor (default opus / config advisorModel). +# --implementer-model NAME: stamp the model that shipped the tick (default auto / +# AGENT_KIT_AUDIT_IMPLEMENTER_MODEL). Same-family reviewer is refused. # # Progress gate (post-spawn PTY activity): # A successful spawn is a launch, not a running review. After an autonomous background @@ -98,6 +109,8 @@ # 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_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). # # Exit codes: # 0 ok / fresh monitor ready (with --wait-monitor) / soft-fail tip when NOT waiting @@ -112,6 +125,7 @@ # Freshness ADR: .cursor/memory/decisions/2026-07-27_audits-wait-freshness-enforce.md # Background PTY ADR: .cursor/memory/decisions/2026-07-28_audits-headless-terminal-honesty.md # Progress gate ADR: .cursor/memory/decisions/2026-07-30_audits-pty-progress-gate-zombie-policy.md +# Atomic wait ADR: .cursor/memory/decisions/2026-08-13_audits-atomic-wait-reviewer-fallback.md set -euo pipefail @@ -124,18 +138,66 @@ LAUNCHER_REL=".cursor/scripts/plan-external-review.sh" MODE="" # print | paste-only | interactive | autonomous (resolved after flags + config) MODE_EXPLICIT=0 +# auto requires a classifier-capable reviewer (Sonnet/Opus/Fable). Default reviewer is sonnet. CLAUDE_PERMISSION_MODE="auto" FORCE=0 BATCH=0 DRY_RUN=0 WAIT_MONITOR=0 WAIT_TIMEOUT=900 +WAIT_TIMEOUT_EXPLICIT=0 +WAIT_SLICE=90 +WAIT_SLICE_EXPLICIT=0 WAIT_ARM_EPOCH="" +WAIT_DEADLINE="" +WAIT_REMAINING="" +WAIT_RESUME=0 +WAIT_STATE_DIR_REL=".cursor/context/audit-wait" +WAIT_BACKEND_STAMP="claude" +WAIT_IMPLEMENTER_MODEL="" +WAIT_REVIEWER_MODEL="" +REVIEWER_BACKEND="" +REVIEWER_BACKEND_EXPLICIT=0 +REVIEWER_MODEL="" +REVIEWER_MODEL_EXPLICIT=0 +ADVISOR_MODEL="" +ADVISOR_MODEL_EXPLICIT=0 +IMPLEMENTER_MODEL_EXPLICIT=0 +SAME_MODEL_REFUSE=0 +ADVISOR_ESCALATE_SENTINEL="" # Post-spawn PTY activity gate grace window (seconds). 0 disables. PROGRESS_TIMEOUT=60 # Kit-owned audit session namespace. Cap/reap only touch workspace-owned names (see token). AUDIT_SESSION_NS_PREFIX="agent-kit-audit-" # 8-hex token from ROOT so concurrent workspaces do not share cap/reap scope. + +# Visual-kit heartbeat suffix (frames + tip). Empty under NO_COLOR / CI / non-TTY. +# Does not change wait freshness exits 0|3|4. Same catalog as packages/cli visual-kit.ts. +audit_kit_suffix() { + local elapsed="${1:-0}" + if [[ -n "${NO_COLOR:-}" || -n "${NODE_DISABLE_COLORS:-}" || "${FORCE_COLOR:-}" == "0" ]]; then + return 0 + fi + if [[ -n "${CI:-}" || "${AGENT_KIT_REDUCED_MOTION:-}" == "1" ]]; then + return 0 + fi + if [[ ! -t 1 ]]; then + return 0 + fi + local frames=(⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏) + local tips=( + "Try agent-kit doctor for repository readiness." + "HITL gates stay in Cursor slash commands." + "/run-plan never promotes to production." + "Mission Control is the dashboard, not the CLI name." + "NO_COLOR and CI keep this output static." + "agent-kit --help groups SETUP, MISSION, DASHBOARD, INTEGRITY." + ) + local fi=$((elapsed % ${#frames[@]})) + local ti=$(((elapsed / 4) % ${#tips[@]})) + printf ' %s ✦ %s' "${frames[$fi]}" "${tips[$ti]}" +} + audit_workspace_token() { local hash="" if command -v shasum >/dev/null 2>&1; then @@ -225,7 +287,7 @@ if [[ "${AGENT_KIT_AUDIT_REAP:-}" == "1" || "${AGENT_KIT_AUDIT_REAP:-}" == "true fi usage() { - sed -n '2,111p' "$0" | sed 's/^# \{0,1\}//' + sed -n '2,128p' "$0" | sed 's/^# \{0,1\}//' } while [[ $# -gt 0 ]]; do @@ -288,6 +350,60 @@ while [[ $# -gt 0 ]]; do exit 2 fi WAIT_TIMEOUT="$2" + WAIT_TIMEOUT_EXPLICIT=1 + shift 2 + ;; + --wait-slice) + if [[ $# -lt 2 || -z "${2:-}" ]]; then + echo "error: --wait-slice requires SECONDS" >&2 + exit 2 + fi + if ! [[ "$2" =~ ^[1-9][0-9]*$ ]]; then + echo "error: --wait-slice must be a positive integer (got: $2)" >&2 + exit 2 + fi + WAIT_SLICE="$2" + WAIT_SLICE_EXPLICIT=1 + shift 2 + ;; + --backend) + if [[ $# -lt 2 || -z "${2:-}" ]]; then + echo "error: --backend requires auto|claude|cursor" >&2 + exit 2 + fi + if [[ "$2" != "auto" && "$2" != "claude" && "$2" != "cursor" ]]; then + echo "error: --backend must be auto, claude, or cursor (got: $2)" >&2 + exit 2 + fi + REVIEWER_BACKEND="$2" + REVIEWER_BACKEND_EXPLICIT=1 + shift 2 + ;; + --reviewer-model) + if [[ $# -lt 2 || -z "${2:-}" ]]; then + echo "error: --reviewer-model requires NAME" >&2 + exit 2 + fi + REVIEWER_MODEL="$2" + REVIEWER_MODEL_EXPLICIT=1 + shift 2 + ;; + --advisor-model) + if [[ $# -lt 2 || -z "${2:-}" ]]; then + echo "error: --advisor-model requires NAME" >&2 + exit 2 + fi + ADVISOR_MODEL="$2" + ADVISOR_MODEL_EXPLICIT=1 + shift 2 + ;; + --implementer-model) + if [[ $# -lt 2 || -z "${2:-}" ]]; then + echo "error: --implementer-model requires NAME" >&2 + exit 2 + fi + WAIT_IMPLEMENTER_MODEL="$2" + IMPLEMENTER_MODEL_EXPLICIT=1 shift 2 ;; --) @@ -333,14 +449,25 @@ EOF tip_no_claude() { cat </dev/null 2>&1; then + _ak_suf="$(audit_kit_suffix "$elapsed" || true)" + fi + echo "audits: progress gate still silent beyond banner (${elapsed}s elapsed, baseline=${baseline}, current=${bytes})${_ak_suf}" fi sleep 2 done @@ -1018,6 +1149,547 @@ CI / headless: $LAUNCHER_REL --force --print (claude -p; agent-kit run-plan on EOF } +# Prints a positive integer from externalPlanReview., or $2 when missing/invalid. +config_positive_int() { + local key="$1" + local default="$2" + if [[ ! -f "$CONFIG" ]]; then + echo "$default" + return + fi + if command -v node >/dev/null 2>&1; then + node -e ' + const fs = require("fs"); + const key = process.argv[2]; + const fallback = process.argv[3]; + try { + const j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + const v = j && j.externalPlanReview && j.externalPlanReview[key]; + process.stdout.write(Number.isInteger(v) && v >= 1 ? String(v) : fallback); + } catch { + process.stdout.write(fallback); + } + ' "$CONFIG" "$key" "$default" + return + fi + echo "$default" +} + +apply_wait_budget_defaults() { + if [[ "$WAIT_TIMEOUT_EXPLICIT" -eq 0 ]]; then + WAIT_TIMEOUT="$(config_positive_int waitTimeoutSeconds 900)" + fi + if [[ "$WAIT_SLICE_EXPLICIT" -eq 0 ]]; then + WAIT_SLICE="$(config_positive_int waitSliceSeconds 90)" + fi +} + +# Prints a non-empty string from externalPlanReview., or $2 when missing/invalid. +config_review_string() { + local key="$1" + local default="$2" + if [[ ! -f "$CONFIG" ]]; then + echo "$default" + return + fi + if command -v node >/dev/null 2>&1; then + node -e ' + const fs = require("fs"); + const key = process.argv[2]; + const fallback = process.argv[3]; + try { + const j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + const v = j && j.externalPlanReview && j.externalPlanReview[key]; + process.stdout.write(typeof v === "string" && v.trim() ? v.trim() : fallback); + } catch { + process.stdout.write(fallback); + } + ' "$CONFIG" "$key" "$default" + return + fi + echo "$default" +} + +# Collapse vendor aliases to a family id for implementer≠reviewer compare. +normalize_model_family() { + local raw + raw="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')" + if [[ -z "$raw" ]]; then + printf '%s' "auto" + return + fi + case "$raw" in + auto|cursor-auto|cursorauto|default) + printf '%s' "auto" + ;; + haiku|claude-haiku|claude-3-haiku*|claude-3.5-haiku*|claude-3-5-haiku*|claude-haiku-*|claude-4-haiku*|claude-4.5-haiku*) + printf '%s' "haiku" + ;; + opus|claude-opus|claude-3-opus*|claude-opus-*|claude-4-opus*|claude-4.5-opus*) + printf '%s' "opus" + ;; + sonnet|claude-sonnet|claude-3-sonnet*|claude-3.5-sonnet*|claude-3-5-sonnet*|claude-3-7-sonnet*|claude-sonnet-*|claude-4-sonnet*|claude-4.5-sonnet*) + printf '%s' "sonnet" + ;; + composer|composer-*|cursor-composer*) + printf '%s' "composer" + ;; + grok|grok-*) + printf '%s' "grok" + ;; + *) + printf '%s' "$raw" + ;; + esac +} + +models_same_family() { + [[ "$(normalize_model_family "$1")" == "$(normalize_model_family "$2")" ]] +} + +# Runtime reviewer id. Cursor cannot honor Claude-family names; those collapse to auto. +effective_reviewer_model() { + local named="${REVIEWER_MODEL:-sonnet}" + if [[ "${REVIEWER_BACKEND:-}" == "cursor" ]]; then + local fam + fam="$(normalize_model_family "$named")" + case "$fam" in + haiku|opus|sonnet) + printf '%s' "auto" + return + ;; + esac + fi + printf '%s' "$named" +} + +tip_same_model() { + cat < claude (existing-install pin). +config_review_backend() { + if [[ ! -f "$CONFIG" ]]; then + echo "claude" + return + fi + if command -v node >/dev/null 2>&1; then + node -e ' + const fs = require("fs"); + try { + const j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + const v = j && j.externalPlanReview && j.externalPlanReview.backend; + process.stdout.write(v === "auto" || v === "cursor" || v === "claude" ? v : "claude"); + } catch { + process.stdout.write("claude"); + } + ' "$CONFIG" + return + fi + echo "claude" +} + +claude_usable() { + if [[ "${AGENT_KIT_AUDIT_CLAUDE_QUOTA_EMPTY:-}" == "1" || "${AGENT_KIT_AUDIT_CLAUDE_QUOTA_EMPTY:-}" == "true" ]]; then + return 1 + fi + command -v claude >/dev/null 2>&1 +} + +cursor_agent_usable() { + command -v cursor-agent >/dev/null 2>&1 +} + +# Sets REVIEWER_BACKEND to claude|cursor|none. Distinct from Cursor tick quota hard-stop. +resolve_reviewer_backend() { + local want + if [[ "$REVIEWER_BACKEND_EXPLICIT" -eq 1 && -n "$REVIEWER_BACKEND" ]]; then + want="$REVIEWER_BACKEND" + else + want="$(config_review_backend)" + fi + case "$want" in + cursor) + if cursor_agent_usable; then + REVIEWER_BACKEND="cursor" + else + REVIEWER_BACKEND="none" + fi + ;; + auto) + if claude_usable; then + REVIEWER_BACKEND="claude" + elif cursor_agent_usable; then + REVIEWER_BACKEND="cursor" + else + REVIEWER_BACKEND="none" + fi + ;; + *) + if claude_usable; then + REVIEWER_BACKEND="claude" + else + REVIEWER_BACKEND="none" + fi + ;; + esac + if [[ "$REVIEWER_BACKEND" != "none" ]]; then + WAIT_BACKEND_STAMP="$REVIEWER_BACKEND" + fi +} + +cursor_agent_cmd() { + local prompt="$1" + if [[ -n "${WAIT_REVIEWER_MODEL:-}" && "$(normalize_model_family "$WAIT_REVIEWER_MODEL")" != "auto" ]]; then + printf '%s' "cursor-agent -p --force --sandbox disabled --workspace $(printf '%q' "$ROOT") --model $(printf '%q' "$WAIT_REVIEWER_MODEL") $(printf '%q' "$prompt")" + return + fi + printf '%s' "cursor-agent -p --force --sandbox disabled --workspace $(printf '%q' "$ROOT") $(printf '%q' "$prompt")" +} + +# Interactive (no -p) or print/headless (-p). Never used for chat autonomous spawn. +exec_reviewer() { + local print_flag="${1:-}" + cd "$ROOT" + if [[ "$REVIEWER_BACKEND" == "cursor" ]]; then + if [[ -n "${WAIT_REVIEWER_MODEL:-}" && "$(normalize_model_family "$WAIT_REVIEWER_MODEL")" != "auto" ]]; then + exec cursor-agent -p --force --sandbox disabled --workspace "$ROOT" --model "$WAIT_REVIEWER_MODEL" "$PROMPT" + fi + exec cursor-agent -p --force --sandbox disabled --workspace "$ROOT" "$PROMPT" + fi + if [[ "$print_flag" == "-p" ]]; then + exec claude --permission-mode "$CLAUDE_PERMISSION_MODE" --model "${WAIT_REVIEWER_MODEL:-sonnet}" -p "$PROMPT" + fi + exec claude --permission-mode "$CLAUDE_PERMISSION_MODE" --model "${WAIT_REVIEWER_MODEL:-sonnet}" "$PROMPT" +} + +monitor_wants_advisor() { + local rel="$1" + [[ -f "$ROOT/$rel" ]] && grep -qF "$ADVISOR_ESCALATE_SENTINEL" "$ROOT/$rel" +} + +build_advisor_prompt() { + local paths=("$@") + local list="" + local p + for p in "${paths[@]}"; do + list+="- $p"$'\n' + done + cat < after the Advisor section. +EOF +} + +# After Haiku monitor is fresh: Opus only when the escalate sentinel is present. +# Headless/print may use claude -p (CI path). Chat uses inspectable PTY or a tip; does not re-burn wait. +maybe_run_advisor() { + local paths=("$@") + local want=0 + local p + for p in "${paths[@]}"; do + if monitor_wants_advisor "$p"; then + want=1 + break + fi + done + [[ "$want" -eq 1 ]] || return 0 + if models_same_family "${WAIT_IMPLEMENTER_MODEL:-auto}" "${ADVISOR_MODEL:-opus}"; then + echo "audits: advisor escalate skipped (advisor-model=${ADVISOR_MODEL:-opus} matches implementer; not a silent self-review)" + return 0 + fi + if models_same_family "${WAIT_REVIEWER_MODEL:-sonnet}" "${ADVISOR_MODEL:-opus}"; then + echo "audits: advisor escalate skipped (advisor-model matches reviewer)" + return 0 + fi + echo "audits: advisor escalate (advisor-model=${ADVISOR_MODEL:-opus}; findings-only; not a second implement pass)" + if ! claude_usable; then + echo "tip: advisor requires Claude. Haiku/Cursor monitor is already ready; triage still applies." + return 0 + fi + local advisor_prompt + advisor_prompt="$(build_advisor_prompt "${paths[@]}")" + if is_headless_env || [[ "${MODE:-}" == "print" ]]; then + claude --permission-mode "$CLAUDE_PERMISSION_MODE" --model "${ADVISOR_MODEL:-opus}" -p "$advisor_prompt" || true + return 0 + fi + local advisor_cmd + advisor_cmd="cd $(printf '%q' "$ROOT") && claude --permission-mode $(printf '%q' "$CLAUDE_PERMISSION_MODE") --model $(printf '%q' "${ADVISOR_MODEL:-opus}") $(printf '%q' "$advisor_prompt")" + if launch_background_terminal "$advisor_cmd"; then + echo "audits: advisor PTY launched (channel: ${LAUNCH_CHANNEL:-unknown}). Haiku monitor is already ready for triage." + else + echo "tip: advisor escalate is marked on the monitor. Paste in a terminal (findings-only):" + echo " claude --permission-mode ${CLAUDE_PERMISSION_MODE} --model ${ADVISOR_MODEL:-opus}" + fi +} + +slug_from_monitor_path() { + local rel="$1" + local base + base="$(basename "$rel")" + base="${base#plan-monitor-}" + printf '%s' "${base%.md}" +} + +wait_state_path() { + local slug="$1" + printf '%s' "$ROOT/$WAIT_STATE_DIR_REL/${slug}.json" +} + +wait_state_get() { + local slug="$1" + local field="$2" + local path + path="$(wait_state_path "$slug")" + if [[ ! -f "$path" ]]; then + echo "" + return + fi + if ! command -v node >/dev/null 2>&1; then + echo "" + return + fi + node -e ' + const fs = require("fs"); + try { + const j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + const v = j && j[process.argv[2]]; + process.stdout.write(v == null ? "" : String(v)); + } catch { + process.stdout.write(""); + } + ' "$path" "$field" +} + +wait_state_write() { + local slug="$1" + local arm_epoch="$2" + local deadline="$3" + local remaining="$4" + local status="$5" + local path + path="$(wait_state_path "$slug")" + mkdir -p "$(dirname "$path")" + if ! command -v node >/dev/null 2>&1; then + echo "tip: node required to persist audit wait-state at $WAIT_STATE_DIR_REL" >&2 + return 1 + fi + node -e ' + const fs = require("fs"); + const out = { + armEpoch: Number(process.argv[2]), + deadline: Number(process.argv[3]), + remainingBudgetSeconds: Number(process.argv[4]), + backend: process.argv[5], + implementerModel: process.argv[6], + reviewerModel: process.argv[7], + status: process.argv[8], + }; + fs.writeFileSync(process.argv[1], JSON.stringify(out, null, 2) + "\n"); + ' "$path" "$arm_epoch" "$deadline" "$remaining" "${WAIT_BACKEND_STAMP:-claude}" "${WAIT_IMPLEMENTER_MODEL:-}" "${WAIT_REVIEWER_MODEL:-}" "$status" +} + +wait_state_clear() { + local slug="$1" + rm -f "$(wait_state_path "$slug")" +} + +wait_state_is_resumable() { + local slug="$1" + local status deadline remaining now + status="$(wait_state_get "$slug" status)" + [[ "$status" == "armed" ]] || return 1 + deadline="$(wait_state_get "$slug" deadline)" + remaining="$(wait_state_get "$slug" remainingBudgetSeconds)" + now="$(date +%s)" + [[ "$deadline" =~ ^[1-9][0-9]*$ ]] || return 1 + [[ "$remaining" =~ ^[1-9][0-9]*$ ]] || return 1 + [[ "$now" -lt "$deadline" ]] || return 1 + return 0 +} + +# Sets WAIT_RESUME, WAIT_ARM_EPOCH, WAIT_DEADLINE, WAIT_REMAINING. Writes state unless dry-run. +wait_state_load_or_init() { + local paths=("$@") + local all_resume=1 + local p slug + if [[ ${#paths[@]} -eq 0 ]]; then + WAIT_RESUME=0 + return + fi + for p in "${paths[@]}"; do + slug="$(slug_from_monitor_path "$p")" + if ! wait_state_is_resumable "$slug"; then + all_resume=0 + break + fi + done + local now + now="$(date +%s)" + if [[ "$all_resume" -eq 1 ]]; then + local first_slug + first_slug="$(slug_from_monitor_path "${paths[0]}")" + WAIT_ARM_EPOCH="$(wait_state_get "$first_slug" armEpoch)" + WAIT_DEADLINE="$(wait_state_get "$first_slug" deadline)" + WAIT_REMAINING=$((WAIT_DEADLINE - now)) + if [[ "$WAIT_REMAINING" -ge 1 ]]; then + local min_rem="$WAIT_REMAINING" + for p in "${paths[@]}"; do + slug="$(slug_from_monitor_path "$p")" + local d r + d="$(wait_state_get "$slug" deadline)" + r=$((d - now)) + if [[ "$r" -lt "$min_rem" ]]; then + min_rem="$r" + fi + done + WAIT_REMAINING="$min_rem" + WAIT_RESUME=1 + echo "audits: wait-state resume (arm-epoch=${WAIT_ARM_EPOCH} remaining=${WAIT_REMAINING}s)" + return + fi + fi + WAIT_RESUME=0 + if [[ -z "${WAIT_ARM_EPOCH:-}" ]]; then + WAIT_ARM_EPOCH="$now" + fi + WAIT_DEADLINE=$((WAIT_ARM_EPOCH + WAIT_TIMEOUT)) + WAIT_REMAINING="$WAIT_TIMEOUT" + if [[ "$DRY_RUN" -ne 1 ]]; then + for p in "${paths[@]}"; do + slug="$(slug_from_monitor_path "$p")" + wait_state_write "$slug" "$WAIT_ARM_EPOCH" "$WAIT_DEADLINE" "$WAIT_REMAINING" "armed" + done + fi +} + +wait_effective_timeout() { + local remaining="${WAIT_REMAINING:-$WAIT_TIMEOUT}" + if [[ "$remaining" -lt 1 ]]; then + echo 1 + return + fi + if is_headless_env && [[ "$WAIT_SLICE_EXPLICIT" -eq 0 ]]; then + echo "$remaining" + return + fi + local slice="${WAIT_SLICE:-90}" + if [[ "$slice" -lt "$remaining" ]]; then + echo "$slice" + else + echo "$remaining" + fi +} + +wait_state_finish() { + local outcome="$1" + shift + local paths=("$@") + local now remaining status + now="$(date +%s)" + remaining=$((WAIT_DEADLINE - now)) + if [[ "$remaining" -lt 0 ]]; then + remaining=0 + fi + local p slug + if [[ "$outcome" == "ready" ]]; then + for p in "${paths[@]}"; do + slug="$(slug_from_monitor_path "$p")" + wait_state_clear "$slug" + done + return + fi + status="armed" + if [[ "$remaining" -le 0 ]]; then + status="timeout" + remaining=0 + fi + for p in "${paths[@]}"; do + slug="$(slug_from_monitor_path "$p")" + wait_state_write "$slug" "$WAIT_ARM_EPOCH" "$WAIT_DEADLINE" "$remaining" "$status" + done + if [[ "$status" == "armed" ]]; then + echo "audits: wait-slice timeout (remaining=${remaining}s; resume via --wait-monitor; not review done)" + fi +} + +maybe_resume_from_plan_args() { + [[ "$WAIT_MONITOR" -eq 1 ]] || return 0 + [[ ${#PLAN_ARGS[@]} -gt 0 ]] || return 0 + local paths=() + local arg base + for arg in "${PLAN_ARGS[@]}"; do + base="$(basename "$arg")" + paths+=("$(monitor_path_for_plan_base "$base")") + done + wait_state_load_or_init "${paths[@]}" + if [[ "$WAIT_RESUME" -eq 1 ]]; then + POLL_ONLY=1 + if [[ "$MODE_EXPLICIT" -eq 0 ]]; then + MODE="paste-only" + fi + fi +} + # Prints "true" or "false". Missing config / key / parse failure => false (default). config_auto_remediate() { if [[ ! -f "$CONFIG" ]]; then @@ -1169,6 +1841,12 @@ Also read: $HANDOFF_REL Git HEAD: $HEAD_SHA Repo root: $ROOT autoRemediate (from config): $auto_remediate +Reviewer model: ${WAIT_REVIEWER_MODEL:-sonnet} +Implementer model (stamp): ${WAIT_IMPLEMENTER_MODEL:-auto} +Advisor model (escalate only): ${ADVISOR_MODEL:-opus} + +This is a findings-contract against the git delta plus the plan, not a second implement pass. +Use git log / git diff / git show vs HEAD and the plan to-dos. Do not re-implement features. Contract reminders: - Evidence-based monitor only under .cursor/memory/plan-monitor-.md @@ -1179,6 +1857,9 @@ Contract reminders: - When autoRemediate is false (default): do not apply or suggest starting product edits in this session - Never /git-prod; never broad git add (add-by-name if staging monitor) - Index new monitors in .cursor/memory/_index.md (Audits table) +- If any finding is high/critical severity or you are uncertain, add exactly one HTML comment line: + + Do not invoke the advisor yourself. - Closeout: print a ready-to-paste line with explicit paths for every monitor written this session: /plan-review-triage .cursor/memory/plan-monitor-.md Never recommend bare /plan-review-triage alone (mtime can miss fresh reviews behind bulk-touched older monitors). @@ -1196,6 +1877,12 @@ Also read: $HANDOFF_REL Git HEAD: $HEAD_SHA Repo root: $ROOT autoRemediate (from config): $auto_remediate +Reviewer model: ${WAIT_REVIEWER_MODEL:-sonnet} +Implementer model (stamp): ${WAIT_IMPLEMENTER_MODEL:-auto} +Advisor model (escalate only): ${ADVISOR_MODEL:-opus} + +This is a findings-contract against the git delta plus each plan, not a second implement pass. +Use git log / git diff / git show vs HEAD and the plan to-dos. Do not re-implement features. Review each of these plans in one session (write one plan-monitor-.md per plan): $plan_list @@ -1209,6 +1896,9 @@ Contract reminders: - When autoRemediate is false (default): do not apply or suggest starting product edits in this session - Never /git-prod; never broad git add (add-by-name if staging monitor) - Index new monitors in .cursor/memory/_index.md (Audits table) +- If any finding is high/critical severity or you are uncertain, add exactly one HTML comment line: + + Do not invoke the advisor yourself. - Closeout: print one ready-to-paste line covering every monitor written this session, e.g. /plan-review-triage .cursor/memory/plan-monitor-.md .cursor/memory/plan-monitor-.md Never recommend bare /plan-review-triage alone (mtime can miss fresh reviews behind bulk-touched older monitors). @@ -1225,6 +1915,14 @@ monitor_path_for_plan_base() { # Soft-fail while waiting: exit 4. Soft-fail without wait: tip already printed, exit 0. soft_fail_exit() { if [[ "$WAIT_MONITOR" -eq 1 ]]; then + local arg base slug + for arg in "${PLAN_ARGS[@]+"${PLAN_ARGS[@]}"}"; do + base="$(basename "$arg")" + slug="${base%.plan.md}" + if [[ -f "$(wait_state_path "$slug")" ]]; then + wait_state_write "$slug" "${WAIT_ARM_EPOCH:-$(date +%s)}" "${WAIT_DEADLINE:-0}" 0 "soft-fail" + fi + done echo "audits: wait-monitor soft-fail (exit 4)" exit 4 fi @@ -1266,19 +1964,19 @@ monitor_is_fresh() { # Poll until every relative monitor path is fresh under $ROOT, or timeout. # Prints waiting/created/timeout status lines. Exits 0 (fresh ready) or 3 (timeout). +# Chat uses waitSliceSeconds (default 90); remaining total budget is persisted. wait_for_monitors() { local paths=("$@") if [[ ${#paths[@]} -eq 0 ]]; then echo "error: wait_for_monitors requires at least one monitor path" >&2 exit 2 fi - if [[ -z "${WAIT_ARM_EPOCH:-}" ]]; then - WAIT_ARM_EPOCH="$(date +%s)" - fi - local timeout="$WAIT_TIMEOUT" + wait_state_load_or_init "${paths[@]}" + local timeout + timeout="$(wait_effective_timeout)" local start now elapsed remaining start="$(date +%s)" - echo "audits: wait-monitor waiting (timeout=${timeout}s arm-epoch=${WAIT_ARM_EPOCH})" + echo "audits: wait-monitor waiting (timeout=${timeout}s slice=${WAIT_SLICE}s total=${WAIT_TIMEOUT}s remaining=${WAIT_REMAINING}s arm-epoch=${WAIT_ARM_EPOCH})" local p for p in "${paths[@]}"; do if [[ -f "$ROOT/$p" ]] && ! monitor_is_fresh "$p"; then @@ -1300,6 +1998,8 @@ wait_for_monitors() { for p in "${paths[@]}"; do echo " ready: $p" done + wait_state_finish ready "${paths[@]}" + maybe_run_advisor "${paths[@]}" exit 0 fi now="$(date +%s)" @@ -1315,11 +2015,16 @@ wait_for_monitors() { echo " missing: $p" fi done + wait_state_finish timeout "${paths[@]}" exit 3 fi remaining=$((timeout - elapsed)) if [[ $((elapsed % 30)) -eq 0 ]]; then - echo "audits: wait-monitor still waiting (${elapsed}s elapsed, ${remaining}s left)" + local _ak_suf="" + if declare -F audit_kit_suffix >/dev/null 2>&1; then + _ak_suf="$(audit_kit_suffix "$elapsed" || true)" + fi + echo "audits: wait-monitor still waiting (${elapsed}s elapsed, ${remaining}s left this slice)${_ak_suf}" fi sleep 1 done @@ -1335,16 +2040,23 @@ print_wait_monitor_dry_run() { echo " progress-timeout: ${PROGRESS_TIMEOUT}s" echo " progress-gate-channel: resolved at spawn time (tmux/screen sampled; other channels advisory)" if [[ "$WAIT_MONITOR" -eq 1 ]]; then + wait_state_load_or_init "${paths[@]}" if [[ -z "${WAIT_ARM_EPOCH:-}" ]]; then WAIT_ARM_EPOCH="$(date +%s)" fi echo " wait-monitor: yes" echo " wait-timeout: ${WAIT_TIMEOUT}s" + echo " wait-slice: ${WAIT_SLICE}s" + echo " wait-remaining: ${WAIT_REMAINING:-$WAIT_TIMEOUT}s" + echo " wait-resume: $([[ "$WAIT_RESUME" -eq 1 ]] && echo yes || echo no)" echo " wait-arm-epoch: ${WAIT_ARM_EPOCH}" + echo " wait-state-dir: ${WAIT_STATE_DIR_REL}" echo " wait-freshness: mtime>=arm-epoch or " - local p + local p slug for p in "${paths[@]}"; do + slug="$(slug_from_monitor_path "$p")" echo " wait-path: $p" + echo " wait-state: ${WAIT_STATE_DIR_REL}/${slug}.json" if [[ -f "$ROOT/$p" ]]; then if monitor_is_fresh "$p"; then echo " wait-path-status: fresh (would ready)" @@ -1404,6 +2116,9 @@ if [[ "$REAP_SESSIONS" -eq 1 && "$BATCH" -eq 0 && "${#PLAN_ARGS[@]}" -eq 0 ]]; t exit 0 fi +apply_wait_budget_defaults +resolve_reviewer_backend + # Arm epoch before spawn/poll so pre-existing monitors are not false-ready. if [[ "$WAIT_MONITOR" -eq 1 && -z "${WAIT_ARM_EPOCH:-}" ]]; then WAIT_ARM_EPOCH="$(date +%s)" @@ -1420,14 +2135,23 @@ if [[ "$WAIT_MONITOR" -eq 1 && "$MODE_EXPLICIT" -eq 0 ]]; then POLL_ONLY=1 MODE="paste-only" fi +maybe_resume_from_plan_args +resolve_review_models if [[ "$WAIT_MONITOR" -eq 1 && ( "$MODE" == "interactive" || "$MODE" == "print" ) && "$MODE_EXPLICIT" -eq 1 ]]; then echo "tip: --wait-monitor is ignored with --interactive/--print (process is replaced by claude)" >&2 fi -# paste-only / dry-run / poll-only do not require claude on PATH. +# paste-only / dry-run / poll-only do not require a reviewer binary. +# backend=auto uses Cursor Agent when Claude is missing or quota-empty. +# Cursor tick API/usage-limit hard-stop is a different path (do not reuse here). if [[ "$MODE" != "paste-only" && "$DRY_RUN" -ne 1 && "$POLL_ONLY" -ne 1 ]]; then - if ! command -v claude >/dev/null 2>&1; then - tip_no_claude + if [[ "$REVIEWER_BACKEND" == "none" ]]; then + local_want="$(config_review_backend)" + if [[ "$REVIEWER_BACKEND_EXPLICIT" -eq 0 && "$local_want" == "claude" ]]; then + tip_no_claude + else + tip_no_reviewer + fi soft_fail_exit fi fi @@ -1437,6 +2161,19 @@ if [[ "$FORCE" -eq 1 ]]; then INTERACTIVE_FLAGS+=(--force) fi INTERACTIVE_FLAGS+=(--interactive) +if [[ "$REVIEWER_BACKEND" == "claude" || "$REVIEWER_BACKEND" == "cursor" ]]; then + INTERACTIVE_FLAGS+=(--backend "$REVIEWER_BACKEND") +fi +if [[ -n "${REVIEWER_MODEL:-}" ]]; then + INTERACTIVE_FLAGS+=(--reviewer-model "$REVIEWER_MODEL") +fi +if [[ -n "${WAIT_IMPLEMENTER_MODEL:-}" ]]; then + INTERACTIVE_FLAGS+=(--implementer-model "$WAIT_IMPLEMENTER_MODEL") +fi +if [[ -n "${ADVISOR_MODEL:-}" ]]; then + INTERACTIVE_FLAGS+=(--advisor-model "$ADVISOR_MODEL") +fi +enforce_implementer_reviewer_split if [[ "$BATCH" -eq 1 ]]; then if [[ ${#PLAN_ARGS[@]} -eq 0 ]]; then @@ -1472,6 +2209,11 @@ if [[ "$BATCH" -eq 1 ]]; then echo " plans: ${BATCH_BASENAMES[*]}" echo " head: $HEAD_SHA" echo " mode: $MODE (config mode: ${CONFIG_MODE_RAW:-missing/legacy})" + echo " reviewer-backend: ${REVIEWER_BACKEND:-unresolved}" + echo " reviewer-model: ${WAIT_REVIEWER_MODEL:-unresolved}" + echo " implementer-model: ${WAIT_IMPLEMENTER_MODEL:-auto}" + echo " advisor-model: ${ADVISOR_MODEL:-opus}" + echo " same-model-refuse: $([[ "$SAME_MODEL_REFUSE" -eq 1 ]] && echo yes || echo no)" echo " midBatchAudits: $MID_BATCH_AUDITS" echo " autoRemediate: $AUTO_REMEDIATE" if [[ "$DRY_RUN" -eq 1 ]]; then @@ -1533,12 +2275,10 @@ EOF exit 0 ;; interactive) - cd "$ROOT" - exec claude --permission-mode "$CLAUDE_PERMISSION_MODE" "$PROMPT" + exec_reviewer ;; print) - cd "$ROOT" - exec claude --permission-mode "$CLAUDE_PERMISSION_MODE" -p "$PROMPT" + exec_reviewer -p ;; *) echo "error: internal mode bug: $MODE" >&2 @@ -1565,6 +2305,11 @@ echo "audits / external plan review: prepared" echo " plan: $PLAN_REL" echo " head: $HEAD_SHA" echo " mode: $MODE (config mode: ${CONFIG_MODE_RAW:-missing/legacy})" +echo " reviewer-backend: ${REVIEWER_BACKEND:-unresolved}" +echo " reviewer-model: ${WAIT_REVIEWER_MODEL:-unresolved}" +echo " implementer-model: ${WAIT_IMPLEMENTER_MODEL:-auto}" +echo " advisor-model: ${ADVISOR_MODEL:-opus}" +echo " same-model-refuse: $([[ "$SAME_MODEL_REFUSE" -eq 1 ]] && echo yes || echo no)" echo " midBatchAudits: $MID_BATCH_AUDITS" echo " autoRemediate: $AUTO_REMEDIATE" echo " permission mode: $CLAUDE_PERMISSION_MODE" @@ -1573,6 +2318,11 @@ if command -v claude >/dev/null 2>&1; then else echo " claude: (not on PATH yet)" fi +if command -v cursor-agent >/dev/null 2>&1; then + echo " cursor-agent: $(command -v cursor-agent)" +else + echo " cursor-agent: (not on PATH yet)" +fi SINGLE_MONITOR_PATH="$(monitor_path_for_plan_base "$SINGLE_BASE")" if [[ "$DRY_RUN" -eq 1 ]]; then echo " dry-run: yes (no spawn)" @@ -1636,15 +2386,10 @@ EOF exit 0 ;; interactive) - cd "$ROOT" - # Interactive session in Cursor terminal (user continues the review). - exec claude --permission-mode "$CLAUDE_PERMISSION_MODE" "$PROMPT" + exec_reviewer ;; print) - cd "$ROOT" - # Verified Claude Code flag: -p/--print (non-interactive). Do not invent other flags. - # Headless / CI only. Chat agents must use --autonomous or --paste-only, never silent -p. - exec claude --permission-mode "$CLAUDE_PERMISSION_MODE" -p "$PROMPT" + exec_reviewer -p ;; *) echo "error: internal mode bug: $MODE" >&2 diff --git a/.cursor/skills/community/mission-kit-comms/SKILL.md b/.cursor/skills/community/mission-kit-comms/SKILL.md new file mode 100644 index 0000000..7d796d0 --- /dev/null +++ b/.cursor/skills/community/mission-kit-comms/SKILL.md @@ -0,0 +1,78 @@ +--- +name: mission-kit-comms +description: "Draft Mission Kit adoption posts (recap, release, contributor-ask) with dual-name, claim checks, per-channel checklists, and HITL-before-post. Use when spreading the word, social copy, changelog recaps, or community replies." +version: 0.1.0 +category: ux +--- + +# Mission Kit comms (spread the word) + +Draft public copy for **Mission Kit** adoption. Never publish or reply on a network without an explicit operator Ask (clickable Ask questions, or numbered-list fallback). + +Thin reuse: short blocks and one ask per message from sibling skill `ux-message-flows`; prompt structure from sibling `prompts-markdown`. Do not duplicate those skills. Do not treat Agent Personas as this skill. + +## Dual-name + +| Use | Name | +|-----|------| +| Product / marketing / site | Mission Kit / missionkit.io | +| CLI, npm, slash, pack | Agent Kit / `agent-kit` / `@dadado/agent-kit-cli` | +| Dashboard | Mission Control | + +## Claim sources (indicative docs; verify shipped) + +- `docs/five-layer-claim-matrix.md` +- `docs/getting-started.md` +- `docs/public-launch-announcement.md` +- Channel map: `docs/comms-channel-map.md` +- Calendar: `docs/comms-content-calendar.md` +- Product version floor: **5.0.0**. Do not write 4.x as current. + +Forbidden in drafts: full autonomy without HITL; Cursor Marketplace "listed" unless the parked submit plan has actually listed; npm/CLI renamed to Mission Kit; silent posting. + +## HITL-before-post + +1. Draft only (chat, plan, or `.cursor/comms-drafts/` via `.cursor/scripts/comms-draft.mjs`). +2. Ask: `Post this` / `Edit draft` / `Discard`. +3. Skip or cancel = do not post. +4. Secrets stay out of git. Env names: `docs/comms-publish-pipeline.md`. + +## Per-channel checklist + +Copy the channel row, then tick before Ask. + +### X / Twitter + +- [ ] Two short blocks or fewer; one link (missionkit.io or GitHub) +- [ ] Dual-name if install is mentioned +- [ ] No thread that claims unshipped CHANGELOG items + +### Medium / newsletter / Substack + +- [ ] Recap or release template filled from CHANGELOG / Release +- [ ] HITL positioning sentence present +- [ ] Commercial: PolyForm NC + sales@missionkit.io + +### Hacker News + +- [ ] Tied to a real release or Show HN, not a daily dump +- [ ] Title does not say "autonomous agent that ships to prod alone" + +### GitHub (issues / PR) + +- [ ] Contributor funnel: CONTRIBUTING, contribute-upstream, CoC, SECURITY private path +- [ ] Not a Marketplace submit CTA +- [ ] Promotional maintainer replies still need Ask + +### Slack / Discord + +- [ ] Deferred unless an operator-owned workspace exists +- [ ] Same HITL as other networks + +### GitHub Discussions + +- [ ] Do not post; Discussions is not enabled (`.github/SUPPORT.md`) + +## Output + +Return: channel, draft body, claim-check result, **HITL pending**. Do not call network APIs. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e41187..53ff8b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,6 +87,9 @@ jobs: pnpm evidence:knowledge-classification:check pnpm landing:build pnpm landing:build:check + # Test files for root node --test suites. Scan scripts already run + # above (deny-links) and on publish-npm (verify-cli-dashboard-pack.mjs). + pnpm test:root-node - name: Guard generated CLI dashboard is untracked run: | diff --git a/.gitignore b/.gitignore index a9d930d..4438cc9 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,10 @@ .cursor/context/flight-log.json # Local Field Report activity cadence ledger (tick/batch counters) .cursor/context/field-report-cadence.json +# Local audit wait-state (arm epoch / remaining budget; launcher-owned) +.cursor/context/audit-wait/ +# Local comms drafts (HITL publish; never commit) +.cursor/comms-drafts/ .cursor/agent-kit.config.json # Active Context Packs (are local) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1fa784..1a14a2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# Changelog - Agent Kit +# Changelog - Mission Kit All notable changes to this project are documented in this file. @@ -8,6 +8,74 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and ## [Unreleased] +## [5.2.0] - 2026-08-14 + +### Added + +- Factory CI Evidence checks run the root `node --test` suites (`plan-external-review-progress-gate.test.mjs`, `check-public-deny-links.test.mjs`, `verify-cli-dashboard-pack.test.mjs`, `git-hooks-pre-commit-composed.test.mjs`) via `pnpm test:root-node`. Scan scripts themselves already ran in CI. +- Dashboard `guards.d.mts` now declares every `export function` and `export const` from `dashboard/lib/guards.mjs` (49/49), pinned by `packages/cli/src/dashboard/guards-dts-parity.test.ts`. +- Overlay known-hashes refresh helper: `pnpm overlay:hashes` (root) / `npm run overlay:hashes` (`packages/cli`, `src/lifecycle/refresh-known-hashes.ts`) appends missing consumer-overlay content hashes to `KNOWN_SHIPPED_OVERLAY_HASHES` after L0 command/agent/skill body edits, enumerating the same sources as the overlay coverage tests (L0 overlay artifacts + registry skills core/community). Append-only (existing entries and inline comments are never removed or reordered), idempotent, with `overlay:hashes:check` exiting non-zero listing missing hashes without writing; replaces the manual hand-append step previously documented in `docs/marketplace.md`. +- Knowledge-classification dirty-tree guard: the generator warns on in-scope working-tree drift and refuses regeneration with `--require-clean-tree` (now default in `pnpm evidence:knowledge-classification`) when tracked in-scope paths carry unstaged edits, closing the regen-poison class behind three stale-ledger incidents (ADR `2026-08-13_ledger-regen-clean-tree-guard.md`). + +### Changed + +- `/plan-review-triage` Step 2b names `.cursor/plans/`, `.cursor/plans/archive/`, and HANDOFF Backlog as `closeout_depth` sources; Hard stop 5 points at the depth-capped override (Ask Other) instead of a dead-end Write residuals redirect. +- R15 Closed-by append on `plan-monitor-harden-cursor-awareness-inventory-cwd.md` for R2–R4 (factory composed pre-commit, `inventoryRoot` JSON, stamp at resolved root). +- R15 Closed-by appends on the owning monitors for live Relevant skills rows, root `node --test` CI wiring, landing SoR ADR supersession, and triage-guidelines caveat (`personalization-context-and-ci-root-tests`). +- Landing SoR ADR `2026-08-05_landing-external-design-source-of-record` records a dated supersession: rollback is not a self-contained single-file zip; live procedure is `docs/agentkit-landing.md`. +- `docs/external-plan-review.md` Triage guidelines caveat Write residuals when closeout is depth-capped or Still open is process-only (ADR `2026-08-11_plan-audit-residuals-termination`). +- Audits Claude reviewer default is `sonnet` (classifier-capable) so `--permission-mode auto` can run. `advisorModel` stays `opus` (escalate only). Explicit `reviewerModel: "haiku"` remains valid and cannot run auto (ADR `2026-08-14_audits-haiku-auto-permission-amend.md`). +- Cursor hook resolution boundary decided and surfaced: `.cursor/hooks/agent/session-start.sh` now emits a degraded-mode `additional_context` diagnostic (still exit 0, stateless per session) when `resolve_agent_kit` fails, instead of a silent `{}`; the other four adapters (`guard-shell`, `after-edit-schema`, `secrets-prompt`, `pre-compact`) stay fail-open silent by accepted design. Boundary documented in `docs/marketplace.md` ("Hook resolution boundary"), smoke checklist section 4 aligned, and both branches pinned by `node --test scripts/hook-session-start-diagnostic.test.mjs`. + +### Fixed + +- Generator `renderProjectContext` fills the Relevant skills table from installed skill/component rows (generated empty-state when none) instead of a hardcoded `(none yet)` singleton that ignored live `componentResults`. +- Preferred-browser `openBrowser` no longer short-circuits to detached `spawn` when `spawnFn` is injected without `spawnSyncFn`. `runPreferred` always uses `spawnSync`/`which` (then detach on linux). Hermetic tests inject `spawnSyncFn`. +- `pnpm landing:sync` fails closed while `UPSTREAM-DESIGN-FIX-PROMPT.md` still has unchecked tasks. Apply the prompt in Claude Design first, or waive with `--waive-upstream-prompt` / `LANDING_SYNC_WAIVE_UPSTREAM_PROMPT=1`. +- Dogfood Unprocessed parser skips the markdown table row immediately before a separator (so headers like `Nota` are not items) and ends the section on a Processed heading even without the word Files. +- Dashboard starters `start.mjs` and `start-broadcast.mjs` pass `realpathSync` into `resolveContextConfigPath` (parity with `serve.mjs` and the CLI), so symlink-escape checks are not skipped. +- Cursor-awareness inventory walk-up stops at the nearest `.git` (file or directory) when that directory has no `docs/cursor-native-audit.md`, so a nested consumer checkout does not inherit a parent kit's Open actions. +- Public sync: missing or unreadable `scripts/public-sync.denylist` fails closed (exit 1) instead of loading zero extra patterns. A present comments-only extra-pattern file still dry-runs. +- Public Path C CI: skip factory-only CLI tests when repo-root `CLAUDE.md` / `.claude/commands/agent-kit.md` or `registry/personas/core` are absent (those paths are not in the public-sync allowlist; `registry/**` is public-owned). Recurrence of `errors/2026-07-24_public-sync-ci-test-portability.md`. +- Mission Control Plans tab / Checklist progress fill now matches to-do truth: the numerator counts terminal to-dos (completed + cancelled, mirroring `TERMINAL_TODO_STATUSES`/`todoStats` in `dashboard/lib/semantic-model.mjs`), so the bar reaches 100% whenever the lifecycle pill reads COMPLETED (previously a plan with any cancelled to-do could never fill past its completed-only count). `progressLabel` keeps the honest completed count and appends `· N cancelled` when present; `mergePlansForUi`'s fallback lifecycle mirrors `todoStats.open === 0`; `enrichPlans.progress` exposes `cancelled`/`terminal` counters as the single counter SoT. Vitest pins in `plugin-ux-validation.test.ts` + `semantic-model.test.ts` lock the math, label, fallback, and the completed-pill ⇒ 100% invariant. +- Mission Control progress bars render a readable track and non-error colors: track upgraded from the 4px `var(--border)` hairline (invisible at 0%) to 6px `var(--border-active)` with 3px rounded ends, and `progressColor` returns green at 100% / neutral blue otherwise — low progress no longer renders red like an error state. Shimmer stays keyed to lifecycle `executing` only (never queueRole). +- Cache lock hardening (multi-workspace install isolation residuals A–D): `acquireCacheLock`'s ENOENT retry now backs off with the jittered `LOCK_RETRY_MS` delay, re-creates the vanished parent directory, and reports a distinct timeout cause (was a ~3.4k syscalls/sec hot loop with a misleading "Another install may be stuck"); `releaseCacheLock` claims the owner file atomically (rename + verify + restore-on-mismatch) instead of check-then-`rm -rf`, so a stale-reclaim + successor republish can no longer lose the successor's lock; `writeLockOwner` adds a per-write tmp nonce and refreshes are serialized with an in-flight guard; a corrupt/missing owner file is healed on refresh so the fail-closed release cannot strand the lock until stale reclaim. New regression tests cover the ENOENT compensator, successor restore, and refresh heal/no-touch paths. +- Install command generic failure path (residual E) now sets `process.exitCode = 1` and returns instead of `process.exit(1)`, so the recovery hint cannot be truncated on piped stderr; stale-reclaim mtime semantics documented and pinned by test (residual F: the lock dir mtime tracks the last owner refresh, so reclaim measures liveness, not acquisition age); unused `rmdir` import dropped (residual G). +- HealthCenter fallback token pin hardened (health R1): new positive shape anchor for the `return HEALTH_SEVERITY_CHROME[sev] || { … }` form fails loudly if the fallback is refactored (`??`, extraction), and the negative token pin is whitespace-tolerant (`\s*`) so a line-wrapped return or double space can no longer skip it silently; regex stays bounded to the fallback object. Mutation-verified: H1/H2/H4 fire the pin, H3 fails the anchor. +- Factory tracked `git-hooks/pre-commit` runs the main/master abort first, then `.cursor/hooks/pre-commit/` JSON validation and secrets scan when that directory exists (ADR `2026-08-14_factory-pre-commit-composed-chain.md`). Consumer clones without that directory still get the main-guard. Install/reinstall remains HITL. Pin: `scripts/git-hooks-pre-commit-composed.test.mjs` (in `pnpm test:root-node`). +- `agent-kit cursor-awareness --check --json` includes `inventoryRoot` (absolute resolved inventory directory, or `null` when walk-up fails). Relative `inventoryPath` / `featuresPath` are unchanged. +- Cursor-awareness prefs and `--stamp` read/write `.cursor/context/config.json` under the resolved `inventoryRoot`, not the caller cwd. When walk-up returns null, `--stamp` does not create a `.cursor/` tree at the caller cwd. + +## [5.1.0] - 2026-08-13 + +### Added + +- CLI visual kit: in-process ANSI spinner frames, space marks, and rotating Mission Kit tips on welcome, grouped help, long-running commands, run-plan ticks, and audits wait heartbeats. Motion off under `NO_COLOR`, `CI`, non-TTY, and `AGENT_KIT_REDUCED_MOTION=1`. Runtime deps stay `@clack/prompts`, `citty`, `kolorist`. See `.cursor/memory/decisions/2026-08-13_cli-visual-kit-space-chrome.md`. +- Mission Kit adoption comms loop (community skill `mission-kit-comms`, agent `.cursor/agents/mission-kit-comms.md`, channel map/calendar/templates, fail-closed local draft script). Drafts only; HITL Ask before any public post. Not Core Pack; not auto-posting; Cursor Marketplace submit stays parked. +- Audits atomic wait: launcher persists arm epoch / deadline / remaining budget in `.cursor/context/audit-wait/.json` (not HANDOFF). Chat AwaitShell uses `waitSliceSeconds` (default 90); CI/headless may use the full remaining `waitTimeoutSeconds` (default 900). Early-ready and freshness `0|3|4` stay. See `.cursor/memory/decisions/2026-08-13_audits-atomic-wait-reviewer-fallback.md`. +- Audits reviewer cascade: `backend: "auto"` uses Claude when usable, else Cursor Agent writing the same `plan-monitor-*.md` contract. Pinned `backend: "claude"` keeps tip+no-op when Claude is missing. Claude quota empty (`AGENT_KIT_AUDIT_CLAUDE_QUOTA_EMPTY`) is not the Cursor tick API/usage-limit hard-stop. +- Audits model routing: Claude review spawn uses `reviewerModel` (default `haiku`); `advisorModel` (default `opus`) runs only when the monitor marks ``. Implementer model is stamped at arm (`--implementer-model` / `AGENT_KIT_AUDIT_IMPLEMENTER_MODEL`, default `auto`). Same-family reviewer is refused (including Auto/Auto). The reviewer prompt is a findings-contract against the git delta plus the plan, not a second implement pass. +- ADR: Claude Code dynamic workflows and ultracode stay Claude-native. Agent Kit records a docs-only thin adapter (no ultracode runner, no `--backend claude` ticks, distinct from kit-load and audits). See `.cursor/memory/decisions/2026-08-13_claude-cli-ultracode-orchestration-thin-adapter.md`. +- Claude CLI kit-load: pack contract (`docs/claude-cli-kit-load.md`), generator snippets, factory `CLAUDE.md` + `/agent-kit`, and getting-started / audits / A7 boundary. Install emits root `CLAUDE.md` and `.claude/commands/agent-kit.md` (skip if present). Distinct from audits, `--backend claude` ticks, and Action A7 (ADR `2026-08-13_claude-cli-kit-load-bootstrap.md`). + +### Changed + +- Mission Control Config audits backend is no longer Claude-only: `auto` / `claude` / `cursor`, plus writable `reviewerModel`, `advisorModel`, `waitSliceSeconds`, and `waitTimeoutSeconds`. L0 `/run-plan`, `/run-plan-all`, and `/plan-external-review` document the wait-slice and reviewer cascade. Dogfood `cursor_audit_cursor_auto_fallback_20260813.md` is Processed with a Fix-now pointer to the atomic-wait ADR. +- `/git-prod` documents the authorized post-Ask push form `ALLOW_MAIN_PUSH=1 git push origin main` (inline prefix, not a session export). Bare `git push origin main` stays denied. CLI guard already shipped; this closes the L0 gap for agent-kit-startup/agent-kit#38. +- Factory dogfood bridge for git-staging dirty-tree spine drift (public issue #41). Acceptance is against factory `staging` wording; public tree updates on the next promote. +- `/handoff` and the git-workflow rule now point at the same `/git-staging` inventory → theme-bucket SoT (`autogit/gitupdate.md`). Loop/orchestrated aliases already follow `/run-plan` closeout. Factory has no `/recupera-staging` command file. +- `/run-plan` tick closeout no longer skips "trivial" HANDOFF/memory diffs. Those paths ship as a `docs(memory):` / `chore(kit):` bucket via `/git-staging`; broad `git add` of unrelated monitor WIP into a product commit stays forbidden. +- `/git-staging` inventories the dirty tree and ships every safe theme-bucket (product vs `docs(memory):` / `chore(kit):`) instead of stop-and-quiz when soft kit paths look "out of the current flow." Warn/add-by-name still forbids sweeping monitor WIP into a product commit. Ask HITL stays on `/git-prod`. Public issue #41. +- Naming glossary tighten (ADR `2026-08-06`): Mission Kit / MissionKit = product; Agent Kit = CLI, npm, slash commands, install manifest, and pack only; Mission Control = dashboard shell and tabs. Residual product-voice Agent Kit openers updated (CHANGELOG H1, bootstrap, capability-inventory, cursor-3-features, getting-started, docs index, DEVELOPMENT naming table). npm/CLI/slash/manifest identifiers and marketplace `displayName` unchanged. +- Post-publish D2 dogfood closed: blank-folder `npx @dadado/agent-kit-cli@5.0 install` against npm `latest` 5.0.0 passed five assertions + Path C dashboard HTTP 200; checklist After-publish rows and ship-5.0 npm/npx monitor R15 Closed-by updated (no retag). +- Forward note (do not rewrite `[5.0.0]`): the released evidence-gate "green again" line overstates a staging-red. CI at the predecessor SHA already had Evidence checks success; the red was a local worktree with untracked monitors. +- `docs/evidence/npm-pack-5.0.0-2026-08-11/` is a pre-tag proxy (18 packed files) and is superseded by published npm `5.0.0` (`dist.fileCount` 23, shasum `203db8ec…`). See that directory README. The JSON is left as history, not rewritten. + +### Fixed + +- `agent-kit update --check` honors `--registry` (local checkout): compare against that source's version and L0 drift instead of the public latest tag; JSON `registryUrl` / `registryRef` report the resolved source (agent-kit-startup/agent-kit#37). +- `agent-kit dashboard-broadcast` window and process title used the CLI package folder `cli` when snapshot env was missing. Spawn now sets `MISSION_CONTROL_REPO_ROOT` and titles from the workspace basename. + ## [5.0.0] - 2026-08-12 ### Fixed diff --git a/autogit/gitupdate.md b/autogit/gitupdate.md index 77206d7..37f61c9 100644 --- a/autogit/gitupdate.md +++ b/autogit/gitupdate.md @@ -56,8 +56,10 @@ git staging - `feat:` New feature - `fix:` Bug fix - `docs:` Documentation +- `docs(memory):` Plan-monitors, Audits index rows, memory ADRs - `refactor:` Refactoring - `chore:` Maintenance tasks +- `chore(kit):` Kit-command / HANDOFF-adjacent hygiene (own bucket when the product theme differs) --- @@ -246,7 +248,15 @@ This section contains the detailed prompts that should be followed when commands #### 1. **Security Validation** - Run `git status -sb` to check modified, staged files and current branch. - **BLOCK**: If attempting to commit directly to `main` or `origin/main`, BLOCK and inform: "Direct commits to main are blocked. Use 'git staging' to promote changes via staging." - - If there are uncommitted local changes that don't belong to the current flow, stop and request instructions. + - **Inventory the dirty tree** (do not stop-and-quiz because a path looks "out of the current flow"): + 1. List every uncommitted path. + 2. **Hard exclude** (never stage): secrets, PII, `.env` / keys / credentials, design-source dumps that belong in gitignore. + 3. **Theme-bucket** the rest: + - **Product:** the current feature/fix/docs theme (`feat:`, `fix:`, `refactor:`, `docs:`, `test:`, `perf:`). + - **Kit/memory:** versioned HANDOFF (when tracked), intentional plan files, `plan-monitor-*.md`, `_index.md` Audits rows (`docs(memory):` or `chore(kit):`). + 4. Ship every **safe** bucket add-by-name (product commit first, then kit/memory as its own commit when the theme differs). Same PR is allowed; prefer a separate commit when the product diff is large. + 5. After all safe buckets ship, the tree must be clean except hard excludes. Leftover safe dirt means the inventory is incomplete: bucket and ship, do not abandon. + 6. **Ask HITL stays on `/git-prod`** (and on true risk: secrets, PII, ambiguous product scope). Do **not** invent a theme-Ask stop for routine dirty soft kit paths. #### 2. **Check and Update CHANGELOG.md** - Check if `CHANGELOG.md` exists and read its content. @@ -278,14 +288,20 @@ This section contains the detailed prompts that should be followed when commands - **Lint evidence (staging-ready):** when the diff touches formatted/linted paths (e.g. `*.ts` / `*.tsx` / `*.js` / `*.mjs` under `packages/` or other Biome/ESLint scopes), **run** the focused linter on those files (e.g. `pnpm exec biome check `) **before** commit and **record the exact command + pass/fail output** in the tick / worker summary. Claiming `Staging ready: yes` or pasting the contract phrase without that recorded run is invalid. **`dashboard/dashboard.html` is outside Biome** (`biome check dashboard/dashboard.html` processes nothing): for dashboard CSS/HTML-only diffs, record `Tests: none applicable (dashboard-CSS); covered by plugin-ux-validation` when the UX suite pins the change (ADR `decisions/2026-07-29_dashboard-css-lint-evidence-convention.md`); do not claim Biome covered the HTML. Pure markdown / docs-only with no applicable repo linter: record `Tests: none applicable` (or `Validation: none applicable`). Aligns with `/run-plan` Staging-ready lint gate (background: Biome-red merges fixed only after the fact). #### 7. **Stage and commit with semantic message** - - Add relevant files with `git add` **by name**. If `git status` shows untracked or unrelated dirty `.cursor/memory/plan-monitor-*.md`, **warn** and do **not** broad-`git add` `.cursor/memory/` WIP into a product commit (ADR `decisions/2026-07-27_plan-monitor-consumer-awareness.md`). - - **Monitor closeout (R14):** when a tick intentionally stages a `plan-monitor-*.md` (and/or `_index.md` Audits row), add those paths **by name**. Prefer a separate docs/memory commit when the same PR also has large product diffs. Never sweep unrelated monitor WIP. **An `_index.md` Audits row and its target monitor file must land in the same commit** (no index link without the file). Product commits must not pick up unrelated untracked monitors (dogfood residual R7). ADR: `decisions/2026-07-29_plan-monitor-staging-hygiene-r14-r15.md`. + - Add relevant files with `git add` **by name**. Never broad-`git add` `.cursor/memory/` WIP into a product commit (ADR `decisions/2026-07-27_plan-monitor-consumer-awareness.md`). + - If `git status` shows untracked or unrelated dirty `.cursor/memory/plan-monitor-*.md`, **warn**, then **bucket** (warn is not leave-dirty-forever): + - Closeout evidence for **this** product change: add-by-name into the product commit only when it is that change's monitor (still prefer a separate `docs(memory):` commit when the product diff is large). + - Remaining **safe** monitors, versioned HANDOFF, and kit docs: ship in a `docs(memory):` or `chore(kit):` commit in this `/git-staging` run. Unrelated-plan monitors still get their own kit/memory bucket; they do not ride along inside the product commit. + - Hard excludes stay unstaged. + - **Monitor closeout (R14):** when a tick intentionally stages a `plan-monitor-*.md` (and/or `_index.md` Audits row), add those paths **by name**. Prefer a separate docs/memory commit when the same PR also has large product diffs. Never sweep unrelated monitor WIP into a product commit. **An `_index.md` Audits row and its target monitor file must land in the same commit** (no index link without the file). Product commits must not pick up unrelated untracked monitors (dogfood residual R7). ADR: `decisions/2026-07-29_plan-monitor-staging-hygiene-r14-r15.md`. - Create a commit following [Conventional Commits](https://www.conventionalcommits.org/): - `feat:` for new features - `fix:` for bug fixes - `docs:` for documentation + - `docs(memory):` for plan-monitors, `_index.md` Audits rows, memory ADRs - `refactor:` for refactoring - `chore:` for maintenance tasks + - `chore(kit):` for kit-command / HANDOFF-adjacent hygiene that is not the product theme - Example: `git commit -m "feat: add support for new agent"` or `git commit -m "fix: correct CPF validation"` - If CHANGELOG.md was updated, mention it in the commit: `git commit -m "feat: add new agent\n\nUpdate CHANGELOG.md with new version"` @@ -302,7 +318,7 @@ This section contains the detailed prompts that should be followed when commands - Delete the remote branch on origin with `git push origin --delete update/<...>` or `git push origin --delete feature/<...>`. - Delete the local branch with `git branch -D update/<...>` or `git branch -D feature/<...>` (use `-D` since merge was done remotely and Git might not detect locally). - Update local `staging` with `git pull --ff-only origin staging` to incorporate merged changes. - - Confirm clean state with `git status -sb`. + - Confirm clean state with `git status -sb`. If leftover **safe** dirt remains (HANDOFF, monitors, kit docs), return to §1 inventory and ship the remaining bucket. Hard excludes may remain unstaged. #### 10.5. **Project manager — optional** - **If** the project has PM tool MCP/skill configured: update related task status (e.g., "in staging"). If the user indicated task(s) or there's context in handoff/plan, update them. No tool or no tasks: skip without warning. diff --git a/dashboard/dashboard.html b/dashboard/dashboard.html index a63604d..646dd0a 100644 --- a/dashboard/dashboard.html +++ b/dashboard/dashboard.html @@ -743,16 +743,19 @@ } /* ===== Progress Bar ===== */ +/* Track must stay visibly a track at 0% (contract item 5): --border-active + reads against --bg-card in both skins where --border blended in, and 6px + with rounded ends keeps mid fills from collapsing into a hairline. */ .progress-bar { - height: 4px; - background: var(--border); - border-radius: 2px; + height: 6px; + background: var(--border-active); + border-radius: 3px; overflow: hidden; margin-top: 8px; } .progress-fill { height: 100%; - border-radius: 2px; + border-radius: 3px; transition: width 0.5s ease; } .progress-fill.green { background: var(--green); } @@ -4267,11 +4270,11 @@ return d.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } +// 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). function progressColor(pct) { - if (pct >= 100) return 'green'; - if (pct >= 50) return 'blue'; - if (pct >= 25) return 'yellow'; - return 'red'; + return pct >= 100 ? 'green' : 'blue'; } // Dot semantics: only state signals survive. completed = good, cancelled = @@ -5606,9 +5609,31 @@
- claude is the only backend today. + auto uses Claude when usable, else Cursor Agent. Pin claude or cursor to force one backend. +
+
+ + + Claude default sonnet (auto permission mode). Must differ from the implementer stamp (Auto/Auto refused). +
+
+ + + Opus only when the monitor marks high severity or uncertainty. +
+
+ + + Chat AwaitShell budget per session. Default 90. +
+
+ + + Total remaining budget across slices and CI. Default 900.
@@ -5680,6 +5705,18 @@ offerOnExhausted: !!document.getElementById('config-epr-offer')?.checked, autoRemediate: !!document.getElementById('config-epr-auto')?.checked, backend: document.getElementById('config-epr-backend')?.value || 'claude', + reviewerModel: (document.getElementById('config-epr-reviewerModel')?.value || 'sonnet').trim(), + advisorModel: (document.getElementById('config-epr-advisorModel')?.value || 'opus').trim(), + waitSliceSeconds: (() => { + const raw = document.getElementById('config-epr-waitSlice')?.value; + const n = Number.parseInt(String(raw || '90'), 10); + return Number.isFinite(n) && n >= 1 ? n : 90; + })(), + waitTimeoutSeconds: (() => { + const raw = document.getElementById('config-epr-waitTimeout')?.value; + const n = Number.parseInt(String(raw || '900'), 10); + return Number.isFinite(n) && n >= 1 ? n : 900; + })(), mode: document.getElementById('config-epr-mode')?.value || 'paste', midBatchAudits: !!document.getElementById('config-epr-midBatch')?.checked, preflight: document.getElementById('config-epr-preflight')?.value || 'off', @@ -6300,10 +6337,18 @@ const inProgress = fromItems ? items.filter((t) => t.status === 'in_progress').length : (raw.todos?.inProgress ?? 0); + // Cancelled counts toward the fill numerator (terminal work, mirrors + // TERMINAL_TODO_STATUSES / todoStats in dashboard/lib/semantic-model.mjs) + // so the bar reaches 100% whenever the lifecycle pill says COMPLETED. + const cancelled = fromItems + ? items.filter((t) => t.status === 'cancelled').length + : (raw.todos?.cancelled ?? enriched.progress?.cancelled ?? 0); const nextActionTodo = planNextActionTodo(items); let lifecycle = enriched.lifecycle; if (!lifecycle) { - if (total > 0 && completed >= total && (raw.todos?.inProgress || 0) === 0) { + // Mirrors classifyPlan: terminal (completed + cancelled) exhausting the + // list means todoStats.open === 0 → completed. + if (total > 0 && completed + cancelled >= total && inProgress === 0) { lifecycle = 'completed'; } else { lifecycle = 'incomplete'; @@ -6317,12 +6362,13 @@ modifiedAt: raw.modifiedAt || enriched.modifiedAt || null, progressPct: total > 0 - ? Math.round((completed / total) * 100) + ? Math.round(((completed + cancelled) / total) * 100) : typeof raw.progress === 'number' ? raw.progress : 0, - progressLabel: `${completed} of ${total} complete`, + progressLabel: `${completed} of ${total} complete` + (cancelled > 0 ? ` · ${cancelled} cancelled` : ''), progressCompleted: completed, + progressCancelled: cancelled, progressTotal: total, progressInProgress: inProgress, nextActionTodo, diff --git a/dashboard/lib/guards.d.mts b/dashboard/lib/guards.d.mts index 8c13249..d885f72 100644 --- a/dashboard/lib/guards.d.mts +++ b/dashboard/lib/guards.d.mts @@ -1,5 +1,84 @@ /** Ambient types for dashboard/lib/guards.mjs (consumed by CLI TypeScript). */ +type ProcessEnvLike = NodeJS.ProcessEnv | Record; +type HeaderMap = Record; +type GuardRequest = { headers?: HeaderMap }; +type PortOpts = { base?: number; range?: number }; +type GitStatusFile = { + path: string; + status: string; + staged: boolean; + unstaged: boolean; + untracked: boolean; + oldPath?: string; + renamed?: boolean; +}; + +export const DEFAULT_HOST: "127.0.0.1"; +export const BROADCAST_TOKEN_ENV: "MISSION_CONTROL_TOKEN"; +export const REPO_ROOT_ENV: "MISSION_CONTROL_REPO_ROOT"; +export const KIT_ROOT_ENV_KEYS: readonly ["MISSION_CONTROL_KIT_ROOT", "AGENT_KIT_HOME"]; +export const BROADCAST_TOKEN_MIN_LEN: 16; +export const BROADCAST_TOKEN_COOKIE: "mc_token"; +export const DEFAULT_PORT_BASE: 3333; +export const DEFAULT_PORT_RANGE: 256; +export const MAX_STRING: { + branch: number; + lastCommit: number; + terminalCwd: number; + terminalCommand: number; + processCommand: number; +}; +export const MAX_GIT_FILES: 50; +export const MAX_GIT_PATH: 240; +export const CONTEXT_CONFIG_REL: ".cursor/context/config.json"; +export const CONFIG_PERSONA_IDS: readonly ["autopilot", "night-shift", "ghost-runner"]; +export const CONFIG_PERSONA_MODES: readonly ["continue-plan", "run-plan", "cli-run-plan"]; +export const CONFIG_REVIEW_BACKENDS: readonly ["auto", "claude", "cursor"]; +export const CONFIG_REVIEW_MODES: readonly ["paste", "autonomous"]; +export const CONFIG_REVIEW_PREFLIGHT: readonly ["off", "warn", "block"]; + +export function escapePerlDoubleQuoted(value: string): string; +export function resolveSnapshotRepoRoot(env: ProcessEnvLike | undefined, kitRoot: string): string; +export function normalizeRepoRootKey(repoRoot: string): string; +export function hashRepoRoot(repoRoot: string): number; +export function repoRootLogId(repoRoot: string): string; +export function preferredPortForRepoRoot(repoRoot: string, opts?: PortOpts): number; +export function portCandidatesForRepoRoot(repoRoot: string, opts?: PortOpts): number[]; +export function sameRepoRoot(a: string | null | undefined, b: string | null | undefined): boolean; +export function resolveMissionControlPort(args: { + repoRoot: string; + envPort?: string | number | null; + probe: (port: number) => { listening: boolean; repoRoot: string | null }; + opts?: PortOpts; +}): { port: number; reuse: boolean; explicit: boolean }; +export function isSafeRepoRelativePath(relPath: unknown): boolean; +export function resolveBindHost(envHost?: string | null): string; +export function isLoopbackBindHost(host: string | undefined | null): boolean; +export function normalizeAuthToken(raw: unknown): string; +export function isValidBroadcastToken(token: unknown): boolean; +export function generateBroadcastToken(): string; +export function tokensMatch(a: unknown, b: unknown): boolean; +export function resolveBroadcastAuth( + env?: ProcessEnvLike, +): + | { ok: true; host: string; tokenRequired: boolean; token: string | null; broadcast: boolean } + | { ok: false; error: string }; +export function extractRequestToken(req: GuardRequest, url: URL): string; +export function authorizeMissionControlRequest( + req: GuardRequest, + url: URL, + opts: { tokenRequired: boolean; expectedToken: string | null }, +): { ok: true; viaQuery: boolean } | { ok: false; status: number; error: string }; +export function broadcastAuthCookieHeader(token: string): string; +export function listLanIPv4Addresses(): string[]; +export function truncateStr(value: unknown, maxLen: number): unknown; +export function parseGitStatusShort(output: unknown): { + files: GitStatusFile[]; + total: number; + truncated: boolean; +}; +export function isLoopbackAddress(addr: string | undefined | null): boolean; export function resolveContextConfigPath( repoRoot: string, fsHooks?: { @@ -8,3 +87,27 @@ export function resolveContextConfigPath( mkdirSync?: (path: string, opts?: { recursive?: boolean }) => void; }, ): { ok: true; path: string } | { ok: false; error: string }; +export function validateConfigWriteBody( + body: unknown, +): { ok: true; patch: Record } | { ok: false; error: string }; +export function mergeConfigAllowlist( + existing: Record | object, + patch: Record | object, +): Record; +export function allowlistConfig(raw: unknown): Record; +export function isAllowedOrigin(origin: unknown, port: unknown): boolean; +export function applyCorsHeaders( + req: GuardRequest, + res: { setHeader: (name: string, value: string) => unknown }, + port: unknown, +): boolean; +export function isUnderDashboard(resolvedPath: string, dashboardReal: string): boolean; +export function resolveDashboardStatic( + pathname: string, + hooks: { + dashboardDir: string; + dashboardReal: string; + existsSync: (path: string) => boolean; + realpathSync: (path: string) => string; + }, +): string | null; diff --git a/dashboard/lib/guards.mjs b/dashboard/lib/guards.mjs index ac828d2..a5c6e53 100644 --- a/dashboard/lib/guards.mjs +++ b/dashboard/lib/guards.mjs @@ -445,7 +445,10 @@ export const CONFIG_PERSONA_IDS = Object.freeze(["autopilot", "night-shift", "gh export const CONFIG_PERSONA_MODES = Object.freeze(["continue-plan", "run-plan", "cli-run-plan"]); /** Allowed externalPlanReview.backend values. */ -export const CONFIG_REVIEW_BACKENDS = Object.freeze(["claude"]); +export const CONFIG_REVIEW_BACKENDS = Object.freeze(["auto", "claude", "cursor"]); + +/** Named reviewer / advisor / implementer model id (no secrets). */ +const REVIEW_MODEL_ID = /^[A-Za-z0-9._:+-]{1,64}$/; /** Allowed externalPlanReview.mode values (audits arming path). */ export const CONFIG_REVIEW_MODES = Object.freeze(["paste", "autonomous"]); @@ -591,6 +594,10 @@ export function validateConfigWriteBody(body) { const eprAllowed = new Set([ "enabled", "backend", + "reviewerModel", + "advisorModel", + "waitSliceSeconds", + "waitTimeoutSeconds", "autoRemediate", "offerOnExhausted", "mode", @@ -612,10 +619,45 @@ export function validateConfigWriteBody(body) { } if ("backend" in epr) { if (typeof epr.backend !== "string" || !CONFIG_REVIEW_BACKENDS.includes(epr.backend)) { - return { ok: false, error: "externalPlanReview.backend must be a known backend" }; + return { ok: false, error: "externalPlanReview.backend must be auto, claude, or cursor" }; } eprPatch.backend = epr.backend; } + if ("reviewerModel" in epr) { + if ( + typeof epr.reviewerModel !== "string" || + !REVIEW_MODEL_ID.test(epr.reviewerModel.trim()) + ) { + return { ok: false, error: "externalPlanReview.reviewerModel must be a model id" }; + } + eprPatch.reviewerModel = epr.reviewerModel.trim(); + } + if ("advisorModel" in epr) { + if (typeof epr.advisorModel !== "string" || !REVIEW_MODEL_ID.test(epr.advisorModel.trim())) { + return { ok: false, error: "externalPlanReview.advisorModel must be a model id" }; + } + eprPatch.advisorModel = epr.advisorModel.trim(); + } + if ("waitSliceSeconds" in epr) { + const n = epr.waitSliceSeconds; + if (typeof n !== "number" || !Number.isInteger(n) || n < 1 || n > 3600) { + return { + ok: false, + error: "externalPlanReview.waitSliceSeconds must be an integer 1..3600", + }; + } + eprPatch.waitSliceSeconds = n; + } + if ("waitTimeoutSeconds" in epr) { + const n = epr.waitTimeoutSeconds; + if (typeof n !== "number" || !Number.isInteger(n) || n < 1 || n > 86400) { + return { + ok: false, + error: "externalPlanReview.waitTimeoutSeconds must be an integer 1..86400", + }; + } + eprPatch.waitTimeoutSeconds = n; + } if ("autoRemediate" in epr) { if (typeof epr.autoRemediate !== "boolean") { return { ok: false, error: "externalPlanReview.autoRemediate must be boolean" }; @@ -847,6 +889,18 @@ export function allowlistConfig(raw) { if (typeof raw.externalPlanReview.preflight === "string") { epr.preflight = truncateStr(raw.externalPlanReview.preflight, 16); } + if (typeof raw.externalPlanReview.reviewerModel === "string") { + epr.reviewerModel = truncateStr(raw.externalPlanReview.reviewerModel, 64); + } + if (typeof raw.externalPlanReview.advisorModel === "string") { + epr.advisorModel = truncateStr(raw.externalPlanReview.advisorModel, 64); + } + if (typeof raw.externalPlanReview.waitSliceSeconds === "number") { + epr.waitSliceSeconds = raw.externalPlanReview.waitSliceSeconds; + } + if (typeof raw.externalPlanReview.waitTimeoutSeconds === "number") { + epr.waitTimeoutSeconds = raw.externalPlanReview.waitTimeoutSeconds; + } summary.externalPlanReview = epr; } if (raw.agentPersona && typeof raw.agentPersona === "object") { diff --git a/dashboard/lib/open-browser.mjs b/dashboard/lib/open-browser.mjs index 52c0789..092d5b6 100644 --- a/dashboard/lib/open-browser.mjs +++ b/dashboard/lib/open-browser.mjs @@ -201,16 +201,11 @@ export function openBrowser(url, options = {}) { /** * Preferred open: detect failure before claiming success, then caller may fall back. - * Hermetic tests that only inject spawnFn use the detached path (throw = fail). * * @param {{ command: string, args: string[] }} built * @returns {{ opened: boolean, reason?: string, command: string, args: string[] }} */ function runPreferred(built) { - if (options.spawnFn && !spawnSyncFn) { - return runDetached(built); - } - const sync = spawnSyncFn ?? spawnSync; if (platform !== "darwin" && platform !== "win32") { diff --git a/dashboard/lib/semantic-model.mjs b/dashboard/lib/semantic-model.mjs index 482e80f..3ae577e 100644 --- a/dashboard/lib/semantic-model.mjs +++ b/dashboard/lib/semantic-model.mjs @@ -3064,10 +3064,16 @@ export function enrichPlans(plans, handoff) { path: plan.path, overview: truncateStr(plan.overview || "", MAX_SEMANTIC_LABEL), modifiedAt: plan.modifiedAt || null, + // Counter SoT for plan progress (todoStats / TERMINAL_TODO_STATUSES). + // `terminal` (completed + cancelled) is the fill numerator so the bar can + // reach 100% exactly when classifyPlan says completed (open === 0). + // Label format is mirrored by mergePlansForUi in dashboard/dashboard.html. progress: { completed: stats.completed, + cancelled: stats.cancelled, + terminal: stats.completed + stats.cancelled, total: stats.total, - label: `${stats.completed} of ${stats.total}`, + label: `${stats.completed} of ${stats.total} complete${stats.cancelled > 0 ? ` · ${stats.cancelled} cancelled` : ""}`, }, lifecycle: classifyPlan(plan, handoff), // Preserved when lifecycle is completed so UI/sort can still know provenance. diff --git a/dashboard/start-broadcast.mjs b/dashboard/start-broadcast.mjs index bdceba6..61e4798 100644 --- a/dashboard/start-broadcast.mjs +++ b/dashboard/start-broadcast.mjs @@ -8,8 +8,8 @@ */ import { execFileSync, execSync, spawn } from "node:child_process"; -import { existsSync, openSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { existsSync, openSync, realpathSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { buildBroadcastShareUrl, @@ -35,6 +35,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const KIT_ROOT = join(__dirname, ".."); /** Workspace snapshots / preference config. Defaults to KIT_ROOT. */ const ROOT = resolveSnapshotRepoRoot(process.env, KIT_ROOT); +process.title = `Mission Control · ${basename(ROOT) || "workspace"}`; const SERVE = join(__dirname, "serve.mjs"); const LOG = process.env.MISSION_CONTROL_LOG || "/tmp/mission-control-broadcast.log"; const PORT = Number.parseInt(process.env.PORT || "3333", 10); @@ -240,7 +241,7 @@ async function main() { return; } let configValue = null; - const cfg = resolveContextConfigPath(ROOT, { existsSync }); + const cfg = resolveContextConfigPath(ROOT, { existsSync, realpathSync }); if (cfg.ok) { configValue = readPreferredBrowserFromConfig(cfg.path); } diff --git a/dashboard/start.mjs b/dashboard/start.mjs index 4c1480e..409a6f2 100644 --- a/dashboard/start.mjs +++ b/dashboard/start.mjs @@ -17,8 +17,8 @@ */ import { execFileSync, execSync, spawn } from "node:child_process"; -import { existsSync, openSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; +import { existsSync, openSync, realpathSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { REPO_ROOT_ENV, @@ -34,6 +34,7 @@ import { openBrowser, readPreferredBrowserFromConfig } from "./lib/open-browser. const __dirname = dirname(fileURLToPath(import.meta.url)); const KIT_ROOT = join(__dirname, ".."); const ROOT = resolveSnapshotRepoRoot(process.env, KIT_ROOT); +process.title = `Mission Control · ${basename(ROOT) || "workspace"}`; const SERVE = join(__dirname, "serve.mjs"); const HOST = process.env.HOST || "127.0.0.1"; const DISPLAY_HOST = HOST === "0.0.0.0" ? "127.0.0.1" : HOST; @@ -256,7 +257,7 @@ async function main() { return; } let configValue = null; - const cfg = resolveContextConfigPath(ROOT, { existsSync }); + const cfg = resolveContextConfigPath(ROOT, { existsSync, realpathSync }); if (cfg.ok) { configValue = readPreferredBrowserFromConfig(cfg.path); } diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 9bc9f8d..1c4e119 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -28,6 +28,8 @@ New to the kit? Here's where things land and how to test before your PR: See [getting-started.md](getting-started.md) for the consumer workflow after install. +Optional: `agent-kit add mission-kit-comms` installs the adoption-comms skill (draft recap/release copy with HITL before any public post). Not part of Core Pack. Guide: [comms.md](comms.md). + ## Standards - Conventional Commits @@ -100,6 +102,19 @@ pnpm --filter @dadado/agent-kit-cli start -- contribute \ See [contribute-upstream.md](contribute-upstream.md). Registry contributions now target the **public** repo as Phase B is complete - [topology-private-public.md](topology-private-public.md). +## Contributor funnel (issues to PR to upstream) + +1. **Question or bug:** GitHub issue forms (Discussions is not enabled; see [SUPPORT.md](../.github/SUPPORT.md)). +2. **Code or skill PR:** this guide; public repo targets `main`, factory targets `staging`. +3. **Consumer-project drift:** [`agent-kit contribute`](contribute-upstream.md) (CLI never pushes). +4. **Vulnerability:** [SECURITY.md](../.github/SECURITY.md) private channel only. + +Maintainer replies that are promotional still need HITL (skill `mission-kit-comms`). Cursor Marketplace publisher submit is not this funnel. + +## Telling people about Mission Kit + +Drafts and cadence live in [comms.md](comms.md). The launch paste in [public-launch-announcement.md](public-launch-announcement.md) remains valid seed copy. Nothing in the kit posts to social networks by itself. + ## Contribution license (follow-on) Inbound CLA/DCO terms for a paid-commercial PolyForm NC project are a tracked follow-on (polyform residual E). Until that ships, pull requests are welcome under the repository LICENSE for noncommercial contribution review, and maintainers may request a CLA before merging commercial-impact changes. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index ce0a7d9..2e755a9 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -91,17 +91,18 @@ Published CLI packs `dashboard/**` from 4.8.2 onward; consumers normally run `ag Release and sync ops: [public-launch.md](public-launch.md), [npm-publish-checklist.md](npm-publish-checklist.md). -## Mission Kit vs Agent Kit naming +## Mission Kit / Agent Kit / Mission Control naming -Public marketing uses **Mission Kit** (missionkit.io hero, README first screen, consumer product-family prose). Install and runtime identifiers stay **Agent Kit** / `agent-kit`. Blind find-replace either way breaks install truth or storefront positioning. +Public marketing uses **Mission Kit** / **MissionKit** (missionkit.io hero, README first screen, consumer product-family prose). Install and runtime identifiers stay **Agent Kit** / `agent-kit`. **Mission Control** is the dashboard shell and tabs only. Blind find-replace either way breaks install truth, storefront positioning, or dashboard voice. | Surface | Prefer | Notes | |---------|--------|-------| | missionkit.io hero / SEO | Mission Kit | External design SoT; do not hand-edit `landing-missionkit/remote/` | | Root README storefront | Mission Kit framing | Keep install/CLI names as Agent Kit / `agent-kit` | -| Consumer docs | Mission Kit for the product family; Agent Kit when naming CLI or installed kit | One sentence can introduce both | +| Consumer docs | Mission Kit for the product family; Agent Kit when naming the CLI or pack | One sentence can introduce both | | npm / CLI / npx | `@dadado/agent-kit-cli`, `agent-kit` | Never rename in docs alone | -| Slash commands, `.cursor/agent-kit.json` | Agent Kit identifiers | Literal command and file names | +| Slash commands, `.cursor/agent-kit.json`, agent pack | Agent Kit identifiers | Literal command, file, and pack names | +| Dashboard shell and tabs | Mission Control | Never use Mission Control as the product or CLI name | | Commercial contact | `sales@missionkit.io` | PolyForm Noncommercial path | | Legacy `agent.startupkit.com.br`, `landing-agentkit/` | Historical / rollback-only | Qualify when linked | diff --git a/docs/README.md b/docs/README.md index 6989da0..b7dd2e9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@ - [Contribute upstream](contribute-upstream.md) - `agent-kit contribute` return channel + gate - [Public launch](public-launch.md) - go/no-go + append-only sync - [Public launch announcement](public-launch-announcement.md) - copy-paste launch text (chat / social) +- [Adoption comms](comms.md) - channel map, recap/release cadence, HITL draft pipeline (does not auto-post) - [Mission Kit Landing](agentkit-landing.md) - public marketing page at [missionkit.io](https://missionkit.io) (filename kept for link stability; previous `agent.startupkit.com.br` is deprecated and 301-redirects) - [Topology private × public](topology-private-public.md) - Fase 7 registry-canonical public - [Marketplace catalog](marketplace.md) - versioning, CLI add, Cursor plugin, quality gate @@ -19,9 +20,10 @@ - [Creating Agent Personas](creating-personas.md) - persona pack format, placement, contribute checklist - [Agent Personas contract](personas-contract.md) - persona pack schema, mode defaults, acceptance rules (also summarized in the root [README Features](../README.md#features)) - [External plan review](external-plan-review.md) - opt-in Claude Code monitor after plan exhaustion +- [Claude CLI kit-load](claude-cli-kit-load.md) - thin `CLAUDE.md` plus `/agent-kit` session bootstrap (not audits, not A7) - [Consumer configuration](consumer-configuration.md) - every consumer knob (session config, skin, install choices, CLI flags/env) with copy snippets - Config tab write verification - durable allowlist PATCH matrix for Mission Control Config (private factory evidence under `docs/evidence/`; not public-synced) -- [Cursor 3.0 Features](cursor-3-features.md) - how Agent Kit uses native IDE features +- [Cursor 3.0 Features](cursor-3-features.md) - how Mission Kit uses native IDE features - [Cursor-native audit](cursor-native-audit.md) - hooks.json, plugin, rule modes, VS Code/Windsurf gaps - [Coherence inventory](coherence-inventory.md) - classification of rules, skills, hooks, agents, commands - [Drift inventory](drift-inventory.md) - per-workspace kit copies, L0 candidates, L3 uniques @@ -36,6 +38,7 @@ ## Community +- [Adoption comms](comms.md) - spread-the-word drafts with HITL; contributor funnel stays CONTRIBUTING - [Code of Conduct](../.github/CODE_OF_CONDUCT.md) - Contributor Covenant 2.1 and how to report a concern - [Security policy](../.github/SECURITY.md) - private disclosure channel, supported versions, documented posture (never open a public issue for a vulnerability) - [Support](../.github/SUPPORT.md) - where to ask what, and what makes a question answerable diff --git a/docs/agentkit-landing.md b/docs/agentkit-landing.md index 4f97f08..98bf1b7 100644 --- a/docs/agentkit-landing.md +++ b/docs/agentkit-landing.md @@ -20,6 +20,7 @@ design tool renders. ### Constraints - Do not edit files under `remote/`; `landing:sync` overwrites them wholesale. +- **Upstream Design prompt guard:** `pnpm landing:sync` (and `landing:vendor`, same script) fail closed while `.cursor/context/landing-missionkit/UPSTREAM-DESIGN-FIX-PROMPT.md` exists with any unchecked markdown task (`- [ ]`). Apply that prompt in Claude Design first, export a zip via Download, then sync. The prompt counts as applied when the file is absent or every markdown task in it is checked (`- [x]` / `- [X]`). To sync before that (accepting overwrite of `remote/`), pass `--waive-upstream-prompt` or set `LANDING_SYNC_WAIVE_UPSTREAM_PROMPT=1`. - Do not hand-edit anything in `dist/` after build; `landing:build` derives it (plus the product overlay below). - Do not add design assets by hand; the build derives the design asset list from the canvas. - Visual or copy changes to the marketing canvas go through the design tool, then re-export and re-sync. diff --git a/docs/bootstrap.md b/docs/bootstrap.md index 6f91b3e..eeb0443 100644 --- a/docs/bootstrap.md +++ b/docs/bootstrap.md @@ -1,6 +1,6 @@ -# What installing Agent Kit puts in your project +# What a Mission Kit install puts in your project -When you install Agent Kit, it doesn't copy its own repository into your project. It writes just the files your project needs: a few rules and commands under `.cursor/`, a git routine under `autogit/`, and a small manifest that tracks what was installed. This page shows exactly what lands where and why. +When you install via the Agent Kit CLI (`npx @dadado/agent-kit-cli install`), it does not copy the kit repository into your project. It writes just the files your project needs: a few rules and commands under `.cursor/`, a git routine under `autogit/`, and a small manifest that tracks what was installed. This page shows exactly what lands where and why. ## The layout you get diff --git a/docs/capability-inventory.md b/docs/capability-inventory.md index 6841a8d..d12df45 100644 --- a/docs/capability-inventory.md +++ b/docs/capability-inventory.md @@ -1,10 +1,10 @@ # Capability inventory -Agent Kit capability catalog grouped by surface family. Lists every shipped capability with one line per item. Storefront positioning uses **Mission Kit** on [missionkit.io](https://missionkit.io); technical identifiers remain Agent Kit (naming ADR `2026-08-06_mission-kit-vs-agent-kit-naming`). +Mission Kit capability catalog grouped by surface family. Lists every shipped capability with one line per item. Storefront positioning uses **Mission Kit** on [missionkit.io](https://missionkit.io); CLI, npm, slash commands, and `.cursor/agent-kit.json` remain Agent Kit identifiers (naming ADR `2026-08-06_mission-kit-vs-agent-kit-naming`). **Status (2026-08-06):** Product manifests at `5.0.0`. Capability counts verified against the working tree on private `staging` @ `7fdb03c` (see Real counts). Catalog narrative remains indicative for non-count claims. Evidence lanes: `docs/evidence/artifact-ledger-summary.md`, `docs/evidence/delivery-reconciliation.json` (RC-003/RC-004). Five-layer README positioning claims: `docs/evidence/five-layer-claim-matrix.md` / `docs/five-layer-claim-matrix.md`. -Real counts (verified against private staging @ 7fdb03c): **28** slash commands under `.cursor/commands/` (**27** synced/L0-oriented; **1** factory-only `/public-issue-triage` excluded from public-sync and L0 install), 25 rules, 13 agents, 9 skills, 5 Cursor hooks, 18 CLI commands (plus 5 subsystems), 7 packs, 3 personas, Mission Control dashboard, Git hooks, root scripts, and auxiliary tooling. +Real counts (verified against the working tree on 2026-08-13): **28** slash commands under `.cursor/commands/` (**27** synced/L0-oriented; **1** factory-only `/public-issue-triage` excluded from public-sync and L0 install), 25 rules, 14 agents, 10 skills, 5 Cursor hooks, 18 CLI commands (plus 5 subsystems), 7 packs, 3 personas, Mission Control dashboard, Git hooks, root scripts, and auxiliary tooling. Prior SHA snapshot `7fdb03c` was 13 agents / 9 skills before `mission-kit-comms`. --- @@ -76,7 +76,7 @@ Factory-only counting policy: inventories that describe the **consumer/L0** surf --- -## Named subagents (.cursor/agents/ - 13) +## Named subagents (.cursor/agents/ - 14) - `cleancode-refactor` - Architecture and readability refactoring - `clickup-tasks` - ClickUp task creation and management via MCP @@ -91,19 +91,21 @@ Factory-only counting policy: inventories that describe the **consumer/L0** surf - `sql-schema` - SQL schema creation and modification (demoted to skill-first) - `tech-lead` - Technology decisions, ADRs, architecture tradeoffs - `test-suites` - Test suite maintenance and E2E testing +- `mission-kit-comms` - Adoption/comms drafts with HITL-before-post (not an Agent Persona) --- -## Skills (.cursor/skills/ - 9) +## Skills (.cursor/skills/ - 10) ### Core skills (2) - `clean-code` - AI code slop removal and clean patterns - `docs-repo` - Repository documentation with professional standard -### Community skills (7) +### Community skills (8) - `clickup` - ClickUp task management via MCP - `cursor-skills-node` - Node.js development standards - `json-data-config` - JSON validation, formatting, manipulation +- `mission-kit-comms` - Mission Kit recap/release/contributor drafts; HITL-before-post - `n8n-workflows` - n8n workflow creation and editing - `prompts-markdown` - Agent prompt structure and versioning - `sql-postgres` - PostgreSQL schema and query patterns diff --git a/docs/claude-cli-kit-load.md b/docs/claude-cli-kit-load.md new file mode 100644 index 0000000..4f462ca --- /dev/null +++ b/docs/claude-cli-kit-load.md @@ -0,0 +1,120 @@ +# Claude CLI kit-load pack + +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). + +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. + +## Surfaces + +| File | Role | Load behavior | +|------|------|----------------| +| `CLAUDE.md` (repository root) | Always-on pointers | Claude Code loads this every session. Keep short. Scanner already treats this path as agent guidance. | +| `.claude/commands/agent-kit.md` | Slash `/agent-kit` | Manual refresh. Frontmatter `disable-model-invocation: true` so it does not auto-inject a second copy of the same pack. | + +Do not emit `.claude/CLAUDE.md` as the primary file: the scanner only lists root `CLAUDE.md`. + +## Shared SoT (read these; do not duplicate) + +The pack points at existing files. It does not copy Cursor rules, hooks, or slash commands into a Claude dialect. + +| Pointer | Why | +|---------|-----| +| `AGENTS.md` | Cross-IDE contract | +| `.cursor/project-context.md` | Derived repository facts | +| `.cursor/HANDOFF.md` | Active plan, queue, next to-do (gitignored session state; read when present) | +| `.cursor/commands/` | L0 slash command index (Cursor chat). In Claude Code, treat the filenames as the command catalog and follow the same HITL prose. | +| `.cursor/memory/decisions/` | Accepted tradeoffs | +| `autogit/gitupdate.md` | Staging to production spine | + +Use backticked paths in `CLAUDE.md`. Do **not** use Claude `@path` imports for these files: imports expand at launch and would recreate Cursor always-on bulk. + +## HITL in Claude Code + +Cursor **Ask questions** is an IDE tool. Claude Code in the terminal does not have it. + +When a kit command says to Ask, Claude Code must present the **same option labels** as a numbered list, wait for a reply (number, label, or a typed Other), and treat skip/cancel as stop. CLI wizards (`agent-kit init`) stay on `@clack/prompts`. Never invent a second confirmation dialect. + +`/git-prod` remains explicit operator confirmation. Kit-load must not promote to `main`. + +## Readiness + +`/agent-kit-onboard` (and `agent-kit doctor`) remain the readiness path. Missing a legacy `onboarded` marker must not block unrelated work. Essential unreadiness: surface the first check and offer onboard. Non-essentials are advisory. + +## Non-goals (must appear in the emitted pack) + +These lines are part of the generated `CLAUDE.md` so a Claude session does not invent adjacent scope: + +- 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. + +## Generator wiring + +- Emit on install/personalization **always**, same as `AGENTS.md`, not gated on `detectIde`. Claude CLI inside Cursor is the target. +- Skip each target independently if it already exists (`skipped-customized`). +- Register both paths on `protectedPaths`. +- Call from `applyPersonalization` and from `generateFromProfile` (compat `init` path). +- Do not add `.claude/` to scanner `CONTEXT_PATHS`; root `CLAUDE.md` is enough for honesty. +- Factory dogfood: commit the same two files in this repository so a Claude Code session here loads the pack without running install. + +## Canonical `CLAUDE.md` + +```markdown +# Agent Kit (Claude Code) + +This repository uses Agent Kit / Mission Control. Before rediscovering the tree, read the shared sources of truth (paths below are pointers; do not treat this file as a second rulebook). + +## Read first + +1. `AGENTS.md` - cross-IDE contract +2. `.cursor/project-context.md` - verified repository facts (derived; prefer code, tests, SHAs when docs conflict) +3. `.cursor/HANDOFF.md` - if present: active plan, next to-do, queue fields +4. `.cursor/commands/` - slash catalog (Cursor). Follow the same HITL contracts here. + +Mid-session refresh: `/agent-kit`. + +## HITL + +Cursor Ask questions is not available in this CLI. When a command requires a choice, list the same labels as a numbered list and wait. Skip or cancel means stop. Never `/git-prod` without an explicit operator yes. + +## Non-goals + +- 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 +``` + +## Canonical `.claude/commands/agent-kit.md` + +```markdown +--- +description: Load Agent Kit session context (HANDOFF, project-context, commands). Manual refresh only. +disable-model-invocation: true +--- + +Read these files if they exist, then summarize the active plan, next to-do, and any Gaps. Do not scan the whole repository first. + +1. `AGENTS.md` +2. `.cursor/project-context.md` +3. `.cursor/HANDOFF.md` +4. The plan file named in HANDOFF `- **Plan:**` under `.cursor/plans/` + +If HANDOFF is missing, say so and point at `/agent-kit-onboard` or `/start-project` rather than inventing a plan. + +HITL: numbered-list fallback for Ask questions labels. Never `/git-prod` from this skill. + +Non-goals: not audits / `/plan-external-review`, not `--backend claude` ticks, not A7, not Cursor hook clones. +``` + +## Related + +- Operator path: [Getting Started](getting-started.md#claude-code-cli-session-kit-load) +- ADR `2026-08-13_claude-cli-kit-load-bootstrap.md` +- ADR `2026-07-29_cli-invariants-thin-hook-adapters.md` +- ADR `2026-07-20_optional-claude-code-plan-review.md` +- [External plan review](external-plan-review.md) (audits only) +- [Cursor-native audit](cursor-native-audit.md) Action A7 diff --git a/docs/coherence-inventory.md b/docs/coherence-inventory.md index aad7450..1046ab4 100644 --- a/docs/coherence-inventory.md +++ b/docs/coherence-inventory.md @@ -75,7 +75,7 @@ Workspace layout after dogfood `update`: `.cursor/skills/core|community//SKI --- -## Agents (`.cursor/agents/` - 13) +## Agents (`.cursor/agents/` - 14) Install path = pack/registry member, or dogfood-only / demoted (see [domain-packs.md](domain-packs.md)). Catalog label `core` ≠ L0 install layer. @@ -94,6 +94,7 @@ Install path = pack/registry member, or dogfood-only / demoted (see [domain-pack | `sql-schema.md` | stack | Demoted; prefer `sql-postgres` skill | | `clickup-tasks.md` | stack | L1 `project-management` | | `test-suites.md` | stack | L1 `quality` | +| `mission-kit-comms.md` | stack | Community skill `mission-kit-comms`; not Core Pack; HITL drafts only | --- diff --git a/docs/comms-channel-map.md b/docs/comms-channel-map.md new file mode 100644 index 0000000..7f2d959 --- /dev/null +++ b/docs/comms-channel-map.md @@ -0,0 +1,55 @@ +# Comms channel map and cadence + +Inventory of where Mission Kit may be talked about, with priority, effort, posting policy, and HITL depth. Cadence is inspired by public recap/release cycles (weekly shipped notes, release-tied longer posts). Do not copy another project's brand, assets, or claims. + +Related: [comms hub](comms.md), naming ADR `2026-08-06_mission-kit-vs-agent-kit-naming`, HITL ADR `2026-07-09_framework-hitl-positioning`, claim matrix [five-layer-claim-matrix.md](five-layer-claim-matrix.md). + +## Dual-name (locked) + +| Use | Name | +|-----|------| +| Product / marketing / site | Mission Kit / MissionKit / missionkit.io | +| CLI, npm, slash, manifest, pack | Agent Kit / `agent-kit` / `@dadado/agent-kit-cli` | +| Local dashboard | Mission Control | + +Do not market unchecked full autonomy. Staging may be automatic; production (`/git-prod`) stays a human confirmation. + +## Channel map + +| Channel | Priority | Effort | Disposition | Policy | HITL depth | +|---------|----------|--------|-------------|--------|------------| +| missionkit.io / blog-equivalent | High | Med | **include** | Product voice; dual-name; claims match 5.0.0 shipped docs | Ask before any public publish | +| Copy-paste social (X / Twitter) | High | Low | **include** | Short; no autonomy-first slogans; link site + public GitHub | Ask before post or reply | +| Medium | Med | Med | **include** | Same claims as [public-launch-announcement.md](public-launch-announcement.md) and getting-started | Ask before publish | +| Newsletter / Substack | Med | High | **include** | Digest of recaps and releases only; no unshipped features | Ask before send | +| Hacker News | Med | Low | **include** | Occasional Show HN or comment on a real release; no daily spam | Ask before submit or reply | +| GitHub Issues / PR discussion | High | Low | **include** | Contributor funnel, not ads. Align with CoC and SUPPORT | Ask before maintainer reply that is promotional; routine triage is existing maintainer HITL | +| GitHub Discussions | - | - | **never** (until enabled) | [SUPPORT.md](../.github/SUPPORT.md) states Discussions is not enabled; issues are the support channel | Do not invent a Discussions presence | +| Slack | Low | Med | **defer** | No official Mission Kit Slack is documented in this repo | Do not create or post until an operator-owned workspace exists | +| Discord | Low | Med | **defer** | Same as Slack | Same | +| Cursor Marketplace listing | - | - | **never** (this loop) | Distribution channel owned by parked `submit-cursor-marketplace` | Reference only; no submit from this plan | + +**Never:** paid ads as the first delivery; scraping PII into recaps; silent cross-network posting; renaming npm/CLI/slash to Mission Kit; overloading Agent Personas as the poster. + +## Cadence sketch (Cartesi-style input, not brand copy) + +Bind every cycle to artifacts that already exist: + +| Cycle | Trigger (must be real) | Output | Channels | +|-------|------------------------|--------|----------| +| Recap | Merged CHANGELOG `[Unreleased]` items that have reached `staging`, or a dated recap of what already shipped in 5.0.0 | [recap template](comms-templates/recap.md) | X (short), Medium/newsletter (optional) | +| Release | GitHub Release / closed CHANGELOG version / missionkit.io already showing that version | [release template](comms-templates/release.md) | Site, X, HN (optional), Medium | +| Contributor-ask | Open issues or registry contribution paths that are actually ready | [contributor-ask template](comms-templates/contributor-ask.md) | GitHub, X, launch paste follow-up | + +If CHANGELOG, Releases, and missionkit.io disagree, **stop and fix docs**; do not invent a feature in the recap. + +Living schedule: [comms-content-calendar.md](comms-content-calendar.md). + +## Constraints (comms loop) + +- HITL Ask (or numbered-list fallback) before any public network post or reply. +- Secrets for networks never enter git. Env **names** only: [comms-publish-pipeline.md](comms-publish-pipeline.md). +- Core Pack unchanged. Skill lives under `registry/skills/community/`. +- n8n is not a factory or Core Pack dependency. Use it only if the operator already runs n8n. +- Claims: Mission Kit 5 / Agent Kit install / HITL framework. Do not claim Cursor Marketplace submit or an npm rename. +- Draft script must fail closed: it writes local drafts and refuses publish. diff --git a/docs/comms-content-calendar.md b/docs/comms-content-calendar.md new file mode 100644 index 0000000..92bab5f --- /dev/null +++ b/docs/comms-content-calendar.md @@ -0,0 +1,38 @@ +# Comms content calendar + +Living schedule for Mission Kit recap, release, and contributor-ask drafts. Seed copy: [public-launch-announcement.md](public-launch-announcement.md) (keep that file; evolve here). + +**SoT for "is it shipped?":** `CHANGELOG.md` closed versions, GitHub Releases on the public repo, and live [missionkit.io](https://missionkit.io). Product version for current claims: **5.0.0**. + +## How to add a row + +1. Confirm the item exists in CHANGELOG or a GitHub Release (or is explicitly a contributor process ask, not a product claim). +2. Pick template: recap / release / contributor-ask. +3. Draft with `.cursor/scripts/comms-draft.mjs` (or the `mission-kit-comms` agent). +4. Operator Ask before any public post. Record the date posted only after HITL yes. + +## Calendar + +| When | Kind | Hook (shipped artifact) | Status | Channels (after HITL) | +|------|------|-------------------------|--------|------------------------| +| 2026-08-12 | release | CHANGELOG `[5.0.0]`; npm `@dadado/agent-kit-cli@5.0.0`; missionkit.io Mission Kit 5 | **seed** (launch paste already exists) | site, GitHub, social paste in [public-launch-announcement.md](public-launch-announcement.md) | +| After next closed CHANGELOG version | release | New `## [X.Y.Z]` plus GitHub Release | planned | site, X, optional HN/Medium | +| Weekly while `[Unreleased]` has merged staging work | recap | `[Unreleased]` bullets that are already in `staging` (do not recap unmerged WIP) | planned | X short; optional newsletter | +| Ongoing | contributor-ask | [CONTRIBUTING.md](CONTRIBUTING.md), [contribute-upstream.md](contribute-upstream.md), community skills under `registry/skills/community/` | planned | GitHub issues, social only after Ask | + +Do not schedule Cursor Marketplace submit here. That work is parked. + +## Templates + +- [Recap](comms-templates/recap.md) +- [Release](comms-templates/release.md) +- [Contributor ask](comms-templates/contributor-ask.md) + +## Claim check (every draft) + +- [ ] Mission Kit = product; Agent Kit = CLI/npm/slash/pack; Mission Control = dashboard +- [ ] Version and features match 5.0.0 shipped docs (getting-started, README, five-layer matrix) +- [ ] HITL / staging→prod confirmation is stated; no "posts itself" or "full autonomy" +- [ ] PolyForm Noncommercial + `sales@missionkit.io` for commercial use +- [ ] Public GitHub: `https://github.com/agent-kit-startup/agent-kit` +- [ ] No Marketplace listing claimed as done diff --git a/docs/comms-publish-pipeline.md b/docs/comms-publish-pipeline.md new file mode 100644 index 0000000..06ff302 --- /dev/null +++ b/docs/comms-publish-pipeline.md @@ -0,0 +1,52 @@ +# Comms draft → HITL → publish pipeline + +Kit-native first: community skill `mission-kit-comms`, agent `.cursor/agents/mission-kit-comms.md`, Ask questions (numbered-list fallback), optional local draft script. **n8n is not wired** and is not a Core Pack or factory dependency. + +Nothing in this pipeline performs an HTTP post to X, Discord, Slack, Medium, Substack, or HN. + +## Flow + +```text +CHANGELOG / Release / calendar row + → draft (agent or comms-draft.mjs) + → operator Ask: Post / Edit / Discard + → only the human publishes on the network +``` + +Fail closed: if Ask is skipped, do not publish. The script's `--publish` flag exits non-zero on purpose. + +## Draft script + +```bash +node .cursor/scripts/comms-draft.mjs --kind recap --channel x +node .cursor/scripts/comms-draft.mjs --kind release --channel medium --version 5.0.0 +node .cursor/scripts/comms-draft.mjs --kind contributor-ask --channel github +``` + +Writes under `.cursor/comms-drafts/` (gitignored). Idempotent: same kind+channel+UTC day reuses the file unless `--force-new`. `--publish` always refuses. + +Tests: `node --test .cursor/scripts/comms-draft.test.mjs`. + +## Env contract (names only) + +Do **not** create a committed `.env` file (`.env` and `.env.*` are gitignored). Document names here. Live values stay in the operator's local `.env` (untracked). + +| Name | Used for | Required to draft? | Required to post? | +|------|----------|--------------------|-------------------| +| `MISSION_KIT_COMMS_X_BEARER` | X API (operator tools only) | no | yes, if the human uses an API client | +| `MISSION_KIT_COMMS_DISCORD_WEBHOOK` | Discord webhook | no | yes, if used | +| `MISSION_KIT_COMMS_SLACK_WEBHOOK` | Slack webhook | no | yes, if used | +| `MISSION_KIT_COMMS_MEDIUM_TOKEN` | Medium | no | yes, if used | +| `MISSION_KIT_COMMS_SUBSTACK_SESSION` | Substack | no | yes, if used | + +The draft script must not print env values. Webhooks that post without a recorded HITL decision are forbidden. + +## Rate limits and idempotency + +- One public post per calendar row per channel unless the operator explicitly asks to retry. +- Draft ids: `{kind}-{channel}-{YYYY-MM-DD}`. +- Recaps must not duplicate the previous day's file without `--force-new`. + +## GitHub Action + +No workflow in this repo posts comms. A `workflow_dispatch` that published would still need a human click; even that is deferred so secrets never sit on CI for social posts. diff --git a/docs/comms-templates/contributor-ask.md b/docs/comms-templates/contributor-ask.md new file mode 100644 index 0000000..821d7dd --- /dev/null +++ b/docs/comms-templates/contributor-ask.md @@ -0,0 +1,30 @@ +# Template: contributor-ask + +Point people at existing contribution paths. Do not invent a Marketplace submit call-to-action. + +## English + +```text +Want to help Mission Kit? + +- Skills: registry/skills/community//SKILL.md (see docs/CONTRIBUTING.md) +- From a consumer project: agent-kit contribute (docs/contribute-upstream.md) +- Bugs and questions: GitHub issues (Discussions is not enabled) +- Code of Conduct: .github/CODE_OF_CONDUCT.md +- Security: private channel in .github/SECURITY.md (never a public issue) + +Public repo: https://github.com/agent-kit-startup/agent-kit +Install: npx @dadado/agent-kit-cli@5.0.0 install +``` + +## Maintainer reply draft (HITL before posting) + +Keep under three short blocks (ux-message-flows). One ask per message. Confirm before linking commercial sales. + +```text +Thanks for the interest. + +Best next step: {{issue form / CONTRIBUTING section / agent-kit contribute}}. + +If this is a vulnerability, please use the private channel in SECURITY.md instead of this thread. +``` diff --git a/docs/comms-templates/recap.md b/docs/comms-templates/recap.md new file mode 100644 index 0000000..ccad5bd --- /dev/null +++ b/docs/comms-templates/recap.md @@ -0,0 +1,33 @@ +# Template: recap + +Short cycle tied to **already merged** CHANGELOG `[Unreleased]` or a dated slice of shipped 5.0.0 work. Not a preview of unmerged branches. + +Fill `{{placeholders}}`. Run claim check in [comms-content-calendar.md](../comms-content-calendar.md) before Ask. + +## English (docs / Medium / newsletter) + +```text +Mission Kit recap ({{date}}) + +Shipped in the Agent Kit repo / CLI this cycle: +{{bullet list from CHANGELOG, no unshipped items}} + +Install stays Agent Kit: npx @dadado/agent-kit-cli@5.0.0 install +Site: https://missionkit.io +Contribute: issues and PRs on https://github.com/agent-kit-startup/agent-kit + +HITL framework: plans and staging can move; production still needs a human yes. +``` + +## Short (X) + +```text +Mission Kit recap: {{one shipped fact}}. + +npx @dadado/agent-kit-cli@5.0.0 install +https://missionkit.io +``` + +## Portuguese (optional; launch voice) + +Reuse tone from [public-launch-announcement.md](../public-launch-announcement.md). Keep dual-name (Mission Kit product / Agent Kit install) and do not add features absent from CHANGELOG. diff --git a/docs/comms-templates/release.md b/docs/comms-templates/release.md new file mode 100644 index 0000000..5c03dd0 --- /dev/null +++ b/docs/comms-templates/release.md @@ -0,0 +1,33 @@ +# Template: release + +Use only when a CHANGELOG version is **closed**, a GitHub Release exists (or is the same tag), and missionkit.io claims match. Current floor: **5.0.0**. + +## English + +```text +Mission Kit {{version}} is out. + +What it is: HITL development operations in Cursor and VS Code (plan, handoff, staging→prod with confirmation). Dashboard: Mission Control. + +Install (Agent Kit CLI): +npx @dadado/agent-kit-cli@{{version}} install + +Site: https://missionkit.io +Notes: {{link to CHANGELOG section or GitHub Release}} +Source-available: PolyForm Noncommercial; commercial: sales@missionkit.io +``` + +## Short (X) + +```text +Mission Kit {{version}} / Agent Kit CLI @{{version}} + +npx @dadado/agent-kit-cli@{{version}} install +https://missionkit.io +``` + +## Hacker News (optional, after Ask) + +Title: `Show HN: Mission Kit {{version}} – HITL plan/handoff/staging→prod for AI IDEs` + +Body: dual-name one-liner, install command, what is **not** claimed (no full autonomy, Marketplace listing not this release unless the parked submit plan has actually listed it). diff --git a/docs/comms.md b/docs/comms.md new file mode 100644 index 0000000..ab4726b --- /dev/null +++ b/docs/comms.md @@ -0,0 +1,18 @@ +# Mission Kit adoption comms + +Repeatable **draft then HITL approve then publish** loop for Mission Kit adoption and contributor asks. Nothing in this tree posts to a public network by itself. + +**Product version in copy:** 5.0.0 (npm `@dadado/agent-kit-cli`, site [missionkit.io](https://missionkit.io)). Docs are indicative; claims must match shipped surfaces. + +| Piece | Path | +|-------|------| +| Channel map, cadence, constraints | [comms-channel-map.md](comms-channel-map.md) | +| Living calendar | [comms-content-calendar.md](comms-content-calendar.md) | +| Templates (recap, release, contributor-ask) | [comms-templates/](comms-templates/recap.md) | +| Draft pipeline and env names | [comms-publish-pipeline.md](comms-publish-pipeline.md) | +| Contributor funnel | [CONTRIBUTING.md](CONTRIBUTING.md), [contribute-upstream.md](contribute-upstream.md) | +| Community skill | `registry/skills/community/mission-kit-comms/SKILL.md` (`agent-kit add mission-kit-comms`) | +| Dedicated agent | `.cursor/agents/mission-kit-comms.md` (not an Agent Persona) | +| One-shot launch paste | [public-launch-announcement.md](public-launch-announcement.md) | + +Agent Personas (`autopilot`, `night-shift`, `ghost-runner`) stay chat chrome. They are not posters. Cursor Marketplace publisher submit is owned by a parked plan; do not treat listing as shipped from this loop. diff --git a/docs/cursor-3-features.md b/docs/cursor-3-features.md index 88704ec..275b4c4 100644 --- a/docs/cursor-3-features.md +++ b/docs/cursor-3-features.md @@ -1,14 +1,14 @@ -# Cursor 3.0 Features - How Agent Kit Uses Them +# Cursor 3.0 Features - How Mission Kit Uses Them -Agent Kit helps develop without losing context and uses native Cursor features as **complement**, not replacement. +Mission Kit helps develop without losing context and uses native Cursor features as **complement**, not replacement. Install and CLI identifiers stay Agent Kit (`agent-kit`, `@dadado/agent-kit-cli`). -## Agent Kit Position +## Mission Kit position -File-based handoff (`.cursor/HANDOFF.md`) is the **source of truth** for continuity. No IDE guarantees infinite memory; context limits are real, and new sessions start from zero. That's why Agent Kit maintains state on disk: structured plans, progress, and suggested routines. +File-based handoff (`.cursor/HANDOFF.md`) is the **source of truth** for continuity. No IDE guarantees infinite memory; context limits are real, and new sessions start from zero. That's why Mission Kit maintains state on disk: structured plans, progress, and suggested routines. ## Native features and how to use them -| Feature | What it does | How Agent Kit uses it | +| Feature | What it does | How Mission Kit uses it | |---------|-----------|----------------------| | `/resume` | Resume previous conversation | Complement to HANDOFF - good for quick reference, doesn't replace file state | | Summaries | Automatic summary of long sessions | Useful for revisiting decisions; handoff remains mandatory | @@ -16,11 +16,11 @@ File-based handoff (`.cursor/HANDOFF.md`) is the **source of truth** for continu | Agents Window | Multiple agents in parallel | Each agent reads HANDOFF before acting; shared state is in file | | `/worktree` | Isolated git worktree | For risky changes without dirtying the working tree | | `/best-of-n` | Compare approaches side by side | For architecture decisions | -| Plans | Native Cursor plans | Agent Kit generates plans with todos in frontmatter; HANDOFF references active plan | +| Plans | Native Cursor plans | Mission Kit generates plans with todos in frontmatter; HANDOFF references active plan | ## MCP, hooks and SDK -**Agent gateways parallel to Cursor are not part of the Agent Kit stack** - we don't document or version config for that. +**Agent gateways parallel to Cursor are not part of the Mission Kit stack** - we don't document or version config for that. For extensions and automation, use what Cursor itself offers: diff --git a/docs/cursor-native-audit.md b/docs/cursor-native-audit.md index b3d064d..3e513af 100644 --- a/docs/cursor-native-audit.md +++ b/docs/cursor-native-audit.md @@ -9,12 +9,12 @@ Audit of Cursor-specific artifacts in the Agent Kit repository: what exists, wha | Area | Status | Notes | |------|--------|-------| | `.cursor/rules/` | Present (25 files) | 10 core rules `alwaysApply: true`; stack rules use globs | -| `.cursor/skills/` | Present (9 skills) | Install output from registry (`core/` 2 + `community/` 7) | -| `.cursor/agents/` | Present (13 agents) | Mix of core and stack subagents; EN pack ids | +| `.cursor/skills/` | Present (10 skills) | Install output from registry (`core/` 2 + `community/` 8) | +| `.cursor/agents/` | Present (14 agents) | Mix of core and stack subagents; EN pack ids | | `.cursor/commands/` | Present (27 commands) | DevOps spine + handoff + orchestration + backlog/dashboard | | `.cursor/hooks/` (shell) | Present | Git pre-commit + edit validators; not wired to Cursor agent events | | `.cursor/hooks.json` | **Present (L0)** | 5 events (`sessionStart`, `preCompact`, `beforeShellExecution`, `afterFileEdit`, `beforeSubmitPrompt`); no `stop` hook | -| `.cursor-plugin/plugin.json` | Present | Marketplace-ready at **5.0.0**, aligned with product; declares explicit component paths | +| `.cursor-plugin/plugin.json` | Present | Marketplace-ready at **5.2.0**, aligned with product; declares explicit component paths | | `git-hooks/prepare-commit-msg` | Present | Strips Cursor co-author trailer | | `AGENTS.md` (dogfood) | **Present** | Root cross-IDE contract; points at `.cursor/project-context.md` | | `mcp.json` | Absent | No project-level MCP config in core | @@ -28,7 +28,7 @@ Audit of Cursor-specific artifacts in the Agent Kit repository: what exists, wha | Field | Value | |-------|-------| | name | `agent-kit` | -| version | `5.0.0` (pinned to `KIT_VERSION` by `packages/cli/src/lifecycle/l0.test.ts`) | +| version | `5.2.0` (pinned to `KIT_VERSION` by `packages/cli/src/lifecycle/l0.test.ts`) | | description | HITL framework for AI-assisted IDEs (plan, handoff, staging→prod, memory loop, anti-slop) | | components | `rules`, `skills`, `agents`, `commands`, `hooks` declared explicitly | @@ -39,7 +39,7 @@ Audit of Cursor-specific artifacts in the Agent Kit repository: what exists, wha - `skills` points at `.cursor/skills/core`, not `.cursor/skills`: discovery matches direct children holding a `SKILL.md`, and `core/`/`community/` are one level too shallow. This also keeps stack skills on `agent-kit add`. - CLI `init` does **not** write `.cursor-plugin/plugin.json` into target projects; no generator under `packages/cli/src/generator/` references it. (Corrected 2026-08-05; the earlier claim was stale.) -**Gap:** Plugin packaging and Marketplace submission flow are not documented end-to-end in `docs/getting-started.md`. See [marketplace.md](marketplace.md) and Phase B registry cutover plan. Live submission stays publisher HITL and is gated on the public mirror carrying the 5.0.0 manifest. +**Gap:** Plugin packaging and Marketplace submission flow are not documented end-to-end in `docs/getting-started.md`. See [marketplace.md](marketplace.md) and Phase B registry cutover plan. Live submission stays publisher HITL and is gated on the public mirror carrying the 5.2.0 manifest. --- @@ -140,7 +140,7 @@ Separate from `.cursor/hooks/`; optional hygiene for teams that reject bot co-au ## Agents and commands (Cursor-native) -### Agents (`.cursor/agents/` - 13) +### Agents (`.cursor/agents/` - 14) Cursor subagent definitions. Consumed via Task tool / Agents Window. Full classification in [coherence-inventory.md](coherence-inventory.md). @@ -216,12 +216,12 @@ Commands are **Cursor-only**. VS Code/Windsurf have no equivalent slash-command The Agent Kit repo uses the full Cursor workspace and **does**: 1. Version `.cursor/hooks.json` (`sessionStart` / `preCompact`) +2. Include root `AGENTS.md` (cross-IDE contract) Still open dogfood gaps: -2. ~~Include root `AGENTS.md` (cross-IDE contract)~~ **Done** (root `AGENTS.md` present) -3. Auto-install git hooks on clone (documented manual copy) -4. Ship `mcp.json` for optional MCP servers +1. Auto-install git hooks on clone (documented manual copy) +2. Ship `mcp.json` for optional MCP servers Acceptable for private SoT until Phase B registry cutover defines minimum dogfood before public sync. @@ -234,10 +234,10 @@ Acceptable for private SoT until Phase B registry cutover defines minimum dogfoo | A1 | ✅ Done | Fix `ux-tone.mdc` frontmatter | | A2 | ✅ Done | Refresh [coherence-inventory.md](coherence-inventory.md) + [drift-inventory.md](drift-inventory.md) (2026-07-19) | | A3 | ✅ Done | `.cursor/hooks.json` + agent scripts (`sessionStart` / `preCompact`) | -| A4 | Partial | Dual hook lanes remain: `git-hooks/` (main/push guards + co-author strip) vs `.cursor/hooks/pre-commit/` (secrets + JSON). Next step: document install matrix and decide whether CLI `git-hooks` generator should mirror the Cursor pre-commit chain (no silent merge). | +| A4 | ✅ Done | Factory tracked `git-hooks/pre-commit` chains main-guard first, then `.cursor/hooks/pre-commit/` JSON + secrets (`validate-all-json.sh` then `check-secrets.sh`) via `git rev-parse --show-toplevel`. Consumer clones without that directory still get the main-guard. Alternate Cursor-native copy lacks the main-guard. CLI generator still skips if a hook exists. Install/reinstall remains HITL. | | A5 | ✅ Done | Root `AGENTS.md` present (dogfood cross-IDE contract) | | A6 | Partial | Marketplace plugin path documented ([marketplace.md](marketplace.md) packaging contract, 2026-08-05); VS Code/Windsurf install still open | -| A7 | Open | Expand generators or template files for multi-IDE parity (scoped: Windsurf `.windsurfrules` handoff/git bullets + VS Code instruction parity with [cursor-3-features.md](cursor-3-features.md); not Marketplace submit) | +| A7 | Open | Expand generators or template files for multi-IDE parity (scoped: Windsurf `.windsurfrules` handoff/git bullets + VS Code instruction parity with [cursor-3-features.md](cursor-3-features.md); not Marketplace submit). Claude CLI session kit-load (`CLAUDE.md` / `/agent-kit`) is a separate surface: [claude-cli-kit-load.md](claude-cli-kit-load.md). | --- diff --git a/docs/cursor-update-awareness.md b/docs/cursor-update-awareness.md index e38191e..9763a26 100644 --- a/docs/cursor-update-awareness.md +++ b/docs/cursor-update-awareness.md @@ -30,11 +30,12 @@ agent-kit cursor-awareness --check [--cwd ] [--json] [--respect-prefs] [-- ### Inventory path / cwd -The check resolves `docs/cursor-native-audit.md` by walking up from `--cwd` (default: `process.cwd()`) until the file exists. Nested monorepo directories (for example `packages/cli`) therefore reuse the kit-root inventory. Prefs and `--stamp` still read/write `.cursor/context/config.json` under the caller `--cwd`. +The check resolves `docs/cursor-native-audit.md` by walking up from `--cwd` (default: `process.cwd()`) until the file exists. Nested directories in the same git repository (for example `packages/cli`) therefore reuse the kit-root inventory. Walk-up stops at the first ancestor that contains `.git` (file or directory) when that directory has no inventory, so a nested checkout does not inherit a parent kit inventory. `--json` includes `inventoryRoot`: the absolute resolved directory that contains `docs/`, or `null` when walk-up fails (missing inventory, error, or nested `.git` boundary). Relative `inventoryPath` and `featuresPath` stay `docs/cursor-native-audit.md` and `docs/cursor-3-features.md`. Prefs and `--stamp` follow `inventoryRoot`: they read and write `.cursor/context/config.json` under that resolved directory, not the caller `--cwd`. When `inventoryRoot` is `null`, `--stamp` does not write and does not create a `.cursor/` tree under the caller cwd. -If no inventory is found in any ancestor (typical consumer checkout without the factory docs tree), the result is `status: error` with an actionable hint to pass `--cwd` at the kit/repo root that contains the inventory. There is no silent success without an inventory file. +If no inventory is found before that git boundary (typical consumer checkout without the factory docs tree), the result is `status: error` with an actionable hint to pass `--cwd` at the kit/repo root that contains the inventory. There is no silent success without an inventory file. Secondary map `docs/cursor-3-features.md` is read from the same resolved inventory root. + ## Slash command `/cursor-update-awareness` runs the check, summarizes gaps, then Ask-routes to `/backlog-add` or `/dogfood`. diff --git a/docs/external-plan-review.md b/docs/external-plan-review.md index 43b6289..5324c8d 100644 --- a/docs/external-plan-review.md +++ b/docs/external-plan-review.md @@ -2,7 +2,9 @@ Optional post-completion **audits** of shipped work against the original plan using Claude Code CLI. When a plan finishes all implementable to-dos, get evidence-based gap detection from a second agent without interfering with the original execution flow. Config object key remains `externalPlanReview` for compatibility; L0 and docs prefer the product name **audits**. -L0 ships the commands, templates, launcher, and `config.example.json` with the base install. The feature stays **opt-in** (`enabled: false` by default). Claude Code is never required for install or CI; a missing `claude` binary or missing prompt template yields a tip and exit 0. +This page is **not** session kit-load. Loading Agent Kit context at the start of a Claude Code session (`CLAUDE.md`, `/agent-kit`) is documented in [claude-cli-kit-load.md](claude-cli-kit-load.md). + +L0 ships the commands, templates, launcher, and `config.example.json` with the base install. The feature stays **opt-in** (`enabled: false` by default). Claude Code is never required for install or CI. `backend: "auto"` uses Cursor Agent when Claude is missing or quota-empty. A pinned `backend: "claude"` with no usable Claude still yields a tip and exit 0 (or exit 4 with `--wait-monitor`). A missing prompt template yields a tip and exit 0. **Install note:** session L3 protection covers `config.json`, `current/**`, and `backups/**` only. If an older manifest still lists `.cursor/context/**`, `agent-kit update` expands that glob so templates can install. If the prompt file is missing after a fresh offer, run `agent-kit update --refresh` and re-arm. @@ -14,7 +16,9 @@ L0 ships the commands, templates, launcher, and `config.example.json` with the b { "externalPlanReview": { "enabled": true, - "backend": "claude", + "backend": "auto", + "reviewerModel": "sonnet", + "advisorModel": "opus", "autoRemediate": false, "offerOnExhausted": true, "mode": "autonomous", @@ -111,7 +115,9 @@ Mission Control **Flight Log** shows HANDOFF Gaps (**NOW** + **Earlier** history { "externalPlanReview": { "enabled": false, - "backend": "claude", + "backend": "auto", + "reviewerModel": "sonnet", + "advisorModel": "opus", "autoRemediate": false, "offerOnExhausted": true, "mode": "paste", @@ -124,7 +130,9 @@ Mission Control **Flight Log** shows HANDOFF Gaps (**NOW** + **Earlier** history | Field | Meaning | |-------|---------| | `enabled` | Auto-arm on plan exhaustion (default: false) | -| `backend` | External agent type (default: `"claude"`) | +| `backend` | Reviewer: `"auto"` (Claude if usable, else Cursor Agent), `"claude"`, or `"cursor"`. Missing key keeps `"claude"` for existing installs. Claude quota empty (`AGENT_KIT_AUDIT_CLAUDE_QUOTA_EMPTY=1`) is not the Cursor tick API/usage-limit hard-stop. | +| `reviewerModel` | Named reviewer (default `sonnet`). Claude spawn passes `--model`. Auto permission mode requires a classifier-capable model (Sonnet/Opus/Fable); Haiku is a valid explicit pin and cannot run auto. Cursor fallback cannot honor a Claude-family name; runtime is Auto unless a named Cursor model is set. | +| `advisorModel` | Escalate-only advisor (default `opus`). Runs only when the reviewer monitor includes `` (high severity or uncertainty). Findings-only; not a second implement pass. | | `autoRemediate` | When `false` (default): review workers and external Claude stay findings-only; `/run-plan` must not auto-fix product code after findings (fix-agent Task for small nits, or residuals backlog plan for large). When `true`: review workers remain findings-only; orchestrator may dispatch a fix agent for small nits without an extra Ask. External-monitor path still requires `/plan-review-triage` before product edits. | | `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"`. | @@ -148,11 +156,11 @@ Do **not** flip `autoRemediate` to `true` as a shortcut for fewer backlog plans; Canonical launcher: `.cursor/scripts/plan-external-review.sh` (wrapper: `scripts/plan-external-review.sh`). -The launcher starts Claude with `--permission-mode auto` in interactive and headless modes. This removes the need to toggle auto mode manually while retaining Claude's permission policy; it does not use `bypassPermissions`. +The launcher starts Claude with `--permission-mode auto` and `--model` from `reviewerModel` (default `sonnet`) in interactive and headless modes. Auto requires a classifier-capable reviewer; the default is Sonnet so that mode can run (ADR `2026-08-14_audits-haiku-auto-permission-amend.md`). This does not use `bypassPermissions`. Override with `--reviewer-model`, `--advisor-model`, and `--implementer-model` (or `AGENT_KIT_AUDIT_IMPLEMENTER_MODEL`). Same-family implementer and reviewer is an honest skip (exit 4 with `--wait-monitor`), including Auto/Auto. **Background/inspectable auto-launch (`mode: "autonomous"` or `--autonomous`):** prefers tmux/screen detached PTY, then macOS Terminal.app `do script` **without** `activate`, then Linux/Windows emulators. Soft-falls back to `--paste-only` when spawn is unavailable. Soft-fails with tip + exit 0 when `claude` is missing (Field Report owed). Never runs silent `claude -p` in a chat agent shell. Rollback to OS window focus: `--focus-terminal` or `AGENT_KIT_AUDIT_FOCUS_TERMINAL=1`. ADR: `.cursor/memory/decisions/2026-07-28_audits-headless-terminal-honesty.md`. -**Post-spawn monitor watch (`--wait-monitor`):** chat autonomous arms **must** pass `--force --autonomous --wait-monitor`. The launcher records an arm epoch, then polls until `.cursor/memory/plan-monitor-.md` is **fresh** (`mtime >= arm epoch`, or a content sentinel line `` / `updated`), or until `--wait-timeout` (default 900s). Pre-arm files are ignored (existence alone is not ready). Exit codes: `0` fresh ready (soft-fail tip + exit 0 only when wait is off), `3` timeout, `4` soft-fail while waiting. Dry-run prints wait path, timeout, arm-epoch, and stale/missing status. Spawn-only exit 0 without wait is not review done. Exit `3` is **timeout only**: it never means review done, and a monitor written afterwards by a later or separate arm does not convert it into success (leave the target Field Report owed and re-arm). ADRs: `.cursor/memory/decisions/2026-07-27_audits-wait-freshness-enforce.md`, `.cursor/memory/decisions/2026-07-27_audits-post-spawn-monitor-watch-continue.md`. +**Post-spawn monitor watch (`--wait-monitor`):** chat autonomous arms **must** pass `--force --autonomous --wait-monitor`. The launcher records an arm epoch and persists wait state in `.cursor/context/audit-wait/.json` (gitignored; not HANDOFF). It then polls until `.cursor/memory/plan-monitor-.md` is **fresh** (`mtime >= arm epoch`, or a content sentinel line `` / `updated`). Chat AwaitShell uses `waitSliceSeconds` / `--wait-slice` (default 90s). Total budget across slices is `waitTimeoutSeconds` / `--wait-timeout` (default 900s). CI/headless may use the full remaining budget in one invocation. A new session that finds an armed wait-state file either exits `0` immediately (monitor already fresh vs stored epoch) or polls **remaining** budget only; it does not restart 900s from zero. Early-ready is unchanged: first freshness exits `0`. Pre-arm files are ignored (existence alone is not ready). Exit codes: `0` fresh ready (soft-fail tip + exit 0 only when wait is off), `3` timeout (slice or total; not review done), `4` soft-fail while waiting. Dry-run prints wait path, slice, timeout, remaining, resume, arm-epoch, wait-state path, and stale/missing status. Spawn-only exit 0 without wait is not review done. Exit `3` is **timeout only**: it never means review done, and a monitor written afterwards by a later or separate arm does not convert it into success (leave the target Field Report owed and re-arm). ADRs: `.cursor/memory/decisions/2026-07-27_audits-wait-freshness-enforce.md`, `.cursor/memory/decisions/2026-07-27_audits-post-spawn-monitor-watch-continue.md`, `.cursor/memory/decisions/2026-08-13_audits-atomic-wait-reviewer-fallback.md`. **Post-spawn progress gate:** a successful spawn is a launch, not a running review. After an autonomous background spawn on a channel that exposes scrollback (tmux `capture-pane`, screen `hardcopy`), the launcher waits briefly for its own pre-exec banner to land, measures that banner as a baseline, then polls every 2 seconds for scrollback growth *beyond* the banner before entering the monitor wait. Default grace window is 60s, overridable with `AGENT_KIT_AUDIT_PROGRESS_TIMEOUT` (`0` disables the gate; a non-integer value prints a tip and falls back to 60). Channels without a scrollback API (Terminal.app, Linux/Windows emulators) degrade to advisory (`progress gate skipped`) and proceed to the normal wait; the gate never aborts a channel it cannot sample. A silent PTY (no growth beyond the banner, or a session that vanished before producing output) is reported as a failed launch: the launcher disposes only the session it just spawned, prints the paste fallback, and soft-fails (exit `4` with `--wait-monitor`, tip + exit `0` otherwise) instead of burning the remaining `--wait-timeout`. No new exit code. Dry-run prints `progress-gate`, `progress-timeout`, and a note that the channel is resolved at spawn time. ADR: `.cursor/memory/decisions/2026-07-30_audits-pty-progress-gate-zombie-policy.md`. @@ -233,6 +241,8 @@ AGENT_KIT_AUDIT_SESSION_CAP=1 .cursor/scripts/plan-external-review.sh --force -- - Architecture improvements - Multi-file refactoring needs +Do not treat Write residuals as the uncaveated happy path when closeout depth is already capped (`close-*`) or Still open is nits / process-only. Prefer **Ack and stop** or **Fix nits only** in those cases (ADR `decisions/2026-08-11_plan-audit-residuals-termination.md`). Blocking product work may still use Write residuals, or an explicit operator override. + **Choose "Ack and stop" for:** - Known limitations (documented decisions) @@ -286,9 +296,16 @@ AGENT_KIT_AUDIT_REAP_MIN_AGE=0 .cursor/scripts/plan-external-review.sh --reap-au **Claude CLI not found:** -- External review skips with a tip message and exit 0 (exit 4 when `--wait-monitor` was requested) -- Plan execution continues normally -- Manual review remains available when Claude is installed later +- `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. +- Pinned `backend: "claude"` still tips and skips (exit 0, or exit 4 with `--wait-monitor`). Field Report stays owed. +- Same-family implementer and reviewer is an honest skip (including Auto/Auto), not a silent self-review. + +## Security + +- **Findings-only:** reviewers write `plan-monitor-*.md` and do not auto-fix product source. `autoRemediate` stays false unless the operator opts in; `/plan-review-triage` remains the HITL gate. Never silent-Ack. +- **Secrets:** review prompts and `.cursor/context/audit-wait/.json` hold model ids and timestamps only. No tokens, `.env`, or credentials. +- **Integrity:** freshness stays `mtime >=` arm epoch or the wait-fresh sentinel. Exit `0` ready / `3` timeout / `4` soft-fail. `/git-prod` is never armed from this path. +- **Split-brain:** implementer ≠ reviewer is a hard refuse. Cursor fallback cannot honor a Claude-family `reviewerModel`; runtime is Auto unless a named Cursor model is set. **Review disabled:** @@ -305,7 +322,7 @@ AGENT_KIT_AUDIT_REAP_MIN_AGE=0 .cursor/scripts/plan-external-review.sh --reap-au ## Implementation notes - **Not a native Cursor hook:** Avoids interfering with HITL confirmation prompts -- **Evidence-based:** Claude reviews actual git history and deliverables +- **Evidence-based:** the reviewer (Claude Haiku or Cursor Agent) checks git history and deliverables against the plan. Tests: `.cursor/scripts/plan-external-review-atomic-wait.test.mjs` (early-ready, slice honesty), `plan-external-review-backend-cascade.test.mjs` (Claude-missing cascade), `plan-external-review-model-routing.test.mjs` (same-model refuse). - **Findings-only review workers:** Claude and Cursor review ticks write monitors/findings; they do not auto-fix product code. `/run-plan` enforces `externalPlanReview.autoRemediate` (default false) as the remediation gate (fix agent vs residuals plan). The launcher injects the current `autoRemediate` value into the Claude prompt. - **Staging-first:** All fixes follow standard `/git-staging` → `/git-prod` flow - **Opt-in default:** Requires explicit configuration (or Always / onboard Enable) to auto-arm diff --git a/docs/five-layer-claim-matrix.md b/docs/five-layer-claim-matrix.md index 171b069..575222e 100644 --- a/docs/five-layer-claim-matrix.md +++ b/docs/five-layer-claim-matrix.md @@ -33,6 +33,7 @@ Classification: **shipped core** (L0), **optional pack** (L1/L2), **planned**, * 3. Hosted multi-tenant control plane or cloud HANDOFF sync. 4. Guaranteed production readiness without lane-qualified release evidence. 5. Silent auto-remediation of product code from external review when `autoRemediate` is false (default). +6. Silent cross-network social posting (adoption comms draft with HITL only; community skill `mission-kit-comms`). ## Lane note diff --git a/docs/getting-started.md b/docs/getting-started.md index 80e085d..c8b6e8d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -14,7 +14,7 @@ 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). -**IDE-agnostic:** works in Cursor, VS Code, and any terminal with Node.js. For non-interactive terminals (CI, piped stdin, VS Code output panels without TTY), suppress both `npx`'s own confirmation and the CLI's root prompt: +**IDE-agnostic:** works in Cursor, VS Code, and any terminal with Node.js. Claude Code CLI session kit-load (`CLAUDE.md` / `/agent-kit`) is documented under [Claude Code CLI (session kit-load)](#claude-code-cli-session-kit-load). For non-interactive terminals (CI, piped stdin, VS Code output panels without TTY), suppress both `npx`'s own confirmation and the CLI's root prompt: ```bash npx -y @dadado/agent-kit-cli install --yes @@ -60,7 +60,7 @@ npx @dadado/agent-kit-cli doctor --fix-safe ### After install checklist (personal path) -Keep this path light. No extra runtime packages beyond the CLI (`@clack/prompts`, `citty`, `kolorist` only). +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). 2. **Git** - recommended so readiness pillars and `/git-staging` → `/git-prod` work; local-only Git is valid. @@ -86,6 +86,8 @@ Keep this path light. No extra runtime packages beyond the CLI (`@clack/prompts` | `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). + ## A normal day The idea is simple: work against a plan, save your place before a conversation gets too big, and (in **manual** mode) keep **one phase per chat**. Continuous and queue modes are opt-in denser paths under [Less babysitting](#less-babysitting). Modes overview: [plan-routine §5](../autogit/plan-routine.md#5-two-execution-modes). @@ -118,7 +120,7 @@ Operator sequence when you drive each unit (command SoT: [`.cursor/commands/cont Do not re-author Gate A/B or continuous tick contracts here; link L0 commands when you need the full contract. -### Keeping Agent Kit current (consumers) +### 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`. @@ -162,9 +164,21 @@ For the full list of consumer-configurable knobs (session config, dashboard skin **Mission Control shortcut:** the Checklist **Run all** header button copies `/run-plan-all` into chat input (copy-only; it does not write HANDOFF or start the queue). While a queue is live, Checklist cards sort by role-priority (executing, then next up, then queued / completed), not by Run queue index. Plan card Actions also offer **Run (manual)** (`/continue-plan`) and **Run (auto)** (`/run-plan`) as copy-only pastes. +### Claude Code CLI (session kit-load) + +Install writes a thin root `CLAUDE.md` and `.claude/commands/agent-kit.md` so Claude Code (including a session started in the Cursor terminal) can load Agent Kit context without rediscovering the repository. Cursor agents already get that context from `sessionStart` and always-apply rules. + +1. Start Claude Code in the project root. +2. `CLAUDE.md` loads automatically. Mid-session refresh: type `/agent-kit`. +3. Claude should read `.cursor/HANDOFF.md`, `.cursor/project-context.md`, and `.cursor/commands/` instead of a full archaeology pass. + +HITL in Claude Code is a numbered-list fallback (Cursor Ask questions is not available). Never `/git-prod` from kit-load. + +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). + ### Optional external plan review -When `/run-plan` finishes all implementable to-dos, you can get a second-agent check of the shipped work. Artifacts ship with L0; the feature stays opt-in (`enabled: false` by default). +When `/run-plan` finishes all implementable to-dos, you can get a second-agent check of the shipped work. 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). 1. **Enable it:** set `"externalPlanReview": { "enabled": true, "offerOnExhausted": true }` in `.cursor/context/config.json` (see `config.example.json`), or accept the exhaustion Ask when a single `/run-plan` finishes (or once when a `/run-plan-all` queue exhausts) 2. **Chat path:** when enabled or you pick `Run review now`, the agent prepares a paste command (`--paste-only` / `--force --paste-only`); you run it in **your** Cursor Terminal (not a silent agent-shell `claude -p`) diff --git a/docs/marketplace.md b/docs/marketplace.md index 47b7a91..0ac1240 100644 --- a/docs/marketplace.md +++ b/docs/marketplace.md @@ -68,19 +68,37 @@ The plugin root is the **parent of `.cursor-plugin/`** - the repo root. Cursor's |--------------|-------|-----| | `rules` | `.cursor/rules` | 25 `.mdc`; 10 `alwaysApply: true` structural, the rest glob-gated per stack | | `skills` | `.cursor/skills/core` | **Core only.** Discovery matches direct children holding a `SKILL.md`, so `.cursor/skills` (whose children are `core/` and `community/`) would find nothing. Pointing at `core/` also encodes the thesis: stack skills stay on `agent-kit add` | -| `agents` | `.cursor/agents` | 13 subagent definitions | +| `agents` | `.cursor/agents` | 14 subagent definitions | | `commands` | `.cursor/commands` | 27 slash commands | -| `hooks` | `.cursor/hooks.json` | Thin adapters; every one is fail-open, so an unresolved path degrades quietly | +| `hooks` | `.cursor/hooks.json` | Thin adapters; every one is fail-open, and `sessionStart` surfaces the degraded-mode diagnostic (see "Hook resolution boundary" below) | | `logo` | `dashboard/logo.svg` | Must sit on a path inside `scripts/public-sync.manifest`. `assets/**` is **not** synced and would be dropped from the public mirror without failing anything | +#### Hook resolution boundary + +All five hook adapters (`sessionStart`, `preCompact`, `beforeShellExecution`, `afterFileEdit`, `beforeSubmitPrompt`) resolve the CLI via `.cursor/hooks/agent/resolve-agent-kit.sh` (order: `AGENT_KIT_HOOK_BIN` → `PATH` → `/node_modules/.bin/agent-kit` → `node /packages/cli/dist/index.js`) and are **fail-open by design**: they must never block shell, edit, prompt, or compact flows, so an unresolved CLI makes them exit 0 with no effect. That silent degradation is the **accepted boundary** for the four non-sessionStart hooks — `guard-shell`, `after-edit-schema`, `secrets-prompt`, and `pre-compact` emit nothing when resolution fails. + +`sessionStart` is the one hook that can surface text into the session, so it carries the diagnostic for all five: when resolution fails, `.cursor/hooks/agent/session-start.sh` emits (exit 0) an `additional_context` JSON payload stating that hooks are in degraded fail-open mode, which surfaces are inactive (hook-provided context, shell guard, schema check, secrets scan), and the fix (`npm i -D @dadado/agent-kit-cli` or set `AGENT_KIT_HOOK_BIN`). Emission is stateless per session — no marker files. + +**Verify:** `node --test scripts/hook-session-start-diagnostic.test.mjs` exercises both branches (resolvable → valid JSON from the real CLI; unresolvable → exit 0 + degraded-mode diagnostic). Manually: run `sh .cursor/hooks/agent/session-start.sh --help` (or `pnpm dlx`). -- [ ] Blank-folder dogfood: `npx @dadado/agent-kit-cli@5.0 install` (or `@5.0.0`) plus five assertions (`--version`, manifest pin, `hooks.json`, dashboard invoke, no nested `agent-kit/` folder). Owned after publish; blocked while `latest` is 4.8.9. +- [x] Blank-folder dogfood: `npx @dadado/agent-kit-cli@5.0 install` (or `@5.0.0`) plus five assertions (`--version`, manifest pin, `hooks.json`, dashboard invoke, no nested `agent-kit/` folder). Verified 2026-08-12 against npm `latest` 5.0.0 (see monitor D2 Closed-by). - [ ] Public GitHub Release Latest and public storefront label resolve to 5.0 (after tag + sync-public), not pre-tag mechanism-only checks. -- [ ] Scoped Path C smoke (blank folder): install the published package under `node_modules/@dadado/agent-kit-cli` and confirm `agent-kit dashboard` returns HTTP 200 on loopback (required after Path C / detach-start changes; also a `/git-prod` §12.5 row). +- [x] Scoped Path C smoke (blank folder): `npx @dadado/agent-kit-cli@5.0.0 dashboard --no-open` served from `_npx/.../@dadado/agent-kit-cli/dashboard/` with HTTP 200 on loopback (2026-08-12 D2 dogfood). - [ ] GitHub Actions `publish-npm` job for the tag shows publish success (not skip), when using CI. ## Cross-lens go/no-go (before tagging) diff --git a/docs/plugin-smoke-checklist.md b/docs/plugin-smoke-checklist.md new file mode 100644 index 0000000..87e3902 --- /dev/null +++ b/docs/plugin-smoke-checklist.md @@ -0,0 +1,83 @@ +# Cursor plugin smoke checklist + +Rerunnable smoke procedure for the Agent Kit Cursor plugin (`.cursor-plugin/plugin.json`). Run it (a) before asking the operator to promote/submit, and (b) again after public promotion against the public ref ("Post-promote re-verification" below). Companion to the packaging contract in [marketplace.md](marketplace.md). + +**Record evidence:** for each section, capture the command outputs (or an IDE screenshot for observational steps) in the PR description or the plan's tick notes. A checklist run without captured output is not a smoke record. + +## 1. Manifest discovery + +From the repo root (the plugin root is the parent of `.cursor-plugin/`): + +```bash +# Manifest parses and declares explicit component paths +node -e " +const m = require('./.cursor-plugin/plugin.json'); +const fs = require('fs'); +const missing = ['rules','skills','agents','commands','hooks','logo'] + .filter((k) => !m[k] || !fs.existsSync(m[k])); +if (typeof m.author !== 'object' || !m.author.name) missing.push('author-object'); +if (missing.length) { console.error('FAIL', missing); process.exit(1); } +console.log('ok: manifest keys resolve', m.version); +" +``` + +- [ ] Every declared path exists; `author` is an object; version printed matches the release being smoked. +- [ ] Version alignment: `node -e "console.log(require('./.cursor-plugin/plugin.json').version === require('./packages/cli/package.json').version ? 'ok: versions aligned' : 'FAIL: version drift')"`. + +## 2. Component inventories + +Counts are expected to drift across releases — the check is **non-empty + shape**, with exact counts recorded as evidence: + +```bash +ls .cursor/rules/*.mdc | wc -l # rules (.mdc with frontmatter) +ls .cursor/commands/*.md | wc -l # commands (frontmatter: name + description) +ls .cursor/agents/*.md | wc -l # agents +ls .cursor/skills/core/*/SKILL.md | wc -l # core skills (direct children with SKILL.md) +``` + +- [ ] All four inventories are non-empty; record the counts. +- [ ] Skill discovery precondition: every direct child of `.cursor/skills/core/` holds a `SKILL.md` (the manifest points at `core/`, not `skills/`, because discovery matches direct children only). +- [ ] Command frontmatter: spot-check 2-3 files under `.cursor/commands/` for `name` (kebab-case, matches filename slug) + `description`. +- [ ] Overlay hash coverage is current: `pnpm overlay:hashes:check` (append-only helper; exit 0 = every shipped overlay body is a known hash). + +## 3. Local install + reload (IDE, observational) + +1. Install the plugin from the local checkout (Cursor plugin dev flow), or point a scratch workspace at the repo. +2. Reload Cursor. + +- [ ] Rules: structural `alwaysApply` rules are listed in the workspace rules UI. +- [ ] Commands: a known slash command (e.g. `/run-plan`) autocompletes and opens its body. +- [ ] Skills: `clean-code` and `docs-repo` appear as available skills. +- [ ] Agents: subagent definitions are selectable. +- [ ] No error toasts / log entries from plugin load (empty-plugin symptom = manifest paths not declared). + +## 4. Hooks + +All five hooks (`sessionStart`, `preCompact`, `beforeShellExecution`, `afterFileEdit`, `beforeSubmitPrompt`) are thin fail-open adapters resolved via `.cursor/hooks/agent/resolve-agent-kit.sh`. + +```bash +# Hook scripts exist and are executable +for h in session-start pre-compact guard-shell after-edit-schema secrets-prompt; do + test -x ".cursor/hooks/agent/$h.sh" && echo "ok: $h" || echo "FAIL: $h" +done +# First-run resolution diagnostic (see marketplace.md, hook resolution boundary) +bash .cursor/hooks/agent/session-start.sh prod flow). Git doesn't version `. | Hook | What it does | |------|--------------| -| `pre-commit` | Aborts direct commit to `main`/`master`. Work goes to working branch or `staging`. | +| `pre-commit` | Aborts direct commit to `main`/`master` first. Then, if `.cursor/hooks/pre-commit/` exists, runs `validate-all-json.sh` then `check-secrets.sh`. Work goes to a working branch or `staging`. | | `pre-push` | Aborts direct push to `main`/`master` on any remote, unless `ALLOW_MAIN_PUSH=1` (used by `/git-prod`). Also aborts force-update or delete of `refs/tags/v*` unless `ALLOW_TAG_FORCE=1`. | | `prepare-commit-msg` | Removes the `Co-authored-by: Cursor` trailer from commit message. | +## Install matrix + +Factory source of truth is this folder (`git-hooks/`). Updating a tracked hook does not change a live `.git/hooks/` copy until reinstall. Reinstall is operator HITL (do not `cp`, `chmod` `.git/hooks/*`, or set `core.hooksPath` from an agent session without that confirm). + +| Lane | Source | main/master abort | secrets + JSON | +|------|--------|-------------------|----------------| +| Factory SoT | `git-hooks/pre-commit` via the copy loop or `core.hooksPath git-hooks` | Yes (first) | Yes, when `.cursor/hooks/pre-commit/` exists; skip (exit 0 after the guard) when it does not | +| Alternate | `.cursor/hooks/pre-commit/pre-commit` copied to `.git/hooks/pre-commit` | No | Yes (`validate-all-json.sh` then `check-secrets.sh`). Resolves repo root via `dirname $0/../..`, which breaks under `core.hooksPath git-hooks` | +| CLI generator | `packages/cli/src/generator/git-hooks.ts` | No | Skip if `.git/hooks/pre-commit` already exists; otherwise writes a simpler `rg` scan, not this chain | + +Repo root for the factory hook is `git rev-parse --show-toplevel`, so both install methods above resolve `.cursor/hooks/pre-commit/` helpers. + ## Install ```sh diff --git a/git-hooks/pre-commit b/git-hooks/pre-commit old mode 100644 new mode 100755 index 82f08dd..910e343 --- a/git-hooks/pre-commit +++ b/git-hooks/pre-commit @@ -1,7 +1,10 @@ #!/bin/sh # Block direct commits on the production branch (main/master). # Work happens on a working branch or on staging; main only receives merges via `git prod`. -# Install: cp git-hooks/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit +# After the main-guard: if `.cursor/hooks/pre-commit/` exists, run JSON then secrets +# (same order as `.cursor/hooks/pre-commit/pre-commit`). Missing that directory is skip, not fail. +# Repo root: `git rev-parse --show-toplevel` (works for `.git/hooks` copy and `core.hooksPath git-hooks`). +# Install (operator HITL): cp git-hooks/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) case "$branch" in main|master) @@ -12,4 +15,13 @@ case "$branch" in exit 1 ;; esac + +REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || REPO_ROOT= +PRE_COMMIT_DIR="$REPO_ROOT/.cursor/hooks/pre-commit" + +if [ -n "$REPO_ROOT" ] && [ -d "$PRE_COMMIT_DIR" ]; then + "$PRE_COMMIT_DIR/validate-all-json.sh" || exit 1 + "$PRE_COMMIT_DIR/check-secrets.sh" || exit 1 +fi + exit 0 diff --git a/install.md b/install.md index cc7ad66..c037f1e 100644 --- a/install.md +++ b/install.md @@ -154,7 +154,7 @@ Create if it doesn't exist (adjust `version` / `registry` to current SoT): ```json { "schemaVersion": 1, - "version": "5.0.0", + "version": "5.2.0", "profile": "default", "packs": [], "skills": [], diff --git a/package.json b/package.json index 27b1b30..0695689 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-kit", - "version": "5.0.0", + "version": "5.2.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", @@ -22,14 +22,17 @@ "evidence:artifact-ledger:check": "node scripts/generate-artifact-ledger.mjs --check && node --test scripts/generate-artifact-ledger.test.mjs", "evidence:authority-graph": "node scripts/generate-authority-graph.mjs", "evidence:authority-graph:check": "node scripts/generate-authority-graph.mjs --check && node --test scripts/generate-authority-graph.test.mjs", - "evidence:knowledge-classification": "node scripts/generate-knowledge-classification.mjs --handoff-fixture scripts/fixtures/handoff-knowledge-test.md", + "evidence:knowledge-classification": "node scripts/generate-knowledge-classification.mjs --require-clean-tree --handoff-fixture scripts/fixtures/handoff-knowledge-test.md", "evidence:knowledge-classification:check": "node scripts/generate-knowledge-classification.mjs --check --handoff-fixture scripts/fixtures/handoff-knowledge-test.md && node --test scripts/generate-knowledge-classification.test.mjs", "evidence:codebase-findings": "node scripts/generate-codebase-findings.mjs --write", "evidence:codebase-findings:check": "node --test scripts/generate-codebase-findings.test.mjs scripts/evidence-check-scripts-non-mutating.test.mjs", "evidence:risk-hotspots": "node scripts/score-codebase-risk-surface.mjs --write", "evidence:risk-hotspots:check": "node --test scripts/score-codebase-risk-surface.test.mjs", "check:public-deny-links": "node scripts/check-public-deny-links.mjs", + "overlay:hashes": "pnpm --dir packages/cli run overlay:hashes", + "overlay:hashes:check": "pnpm --dir packages/cli run overlay:hashes:check", "check:public-deny-links:test": "node --test scripts/check-public-deny-links.test.mjs", + "test:root-node": "node --test .cursor/scripts/plan-external-review-progress-gate.test.mjs scripts/check-public-deny-links.test.mjs scripts/verify-cli-dashboard-pack.test.mjs scripts/git-hooks-pre-commit-composed.test.mjs", "landing:sync": "node scripts/sync-landing.mjs", "landing:vendor": "node scripts/sync-landing.mjs", "landing:build": "node scripts/build-landing.mjs", diff --git a/packages/cli/README.md b/packages/cli/README.md index 6b49ff9..71caf05 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -55,6 +55,8 @@ NO_COLOR=1 agent-kit Subcommands and `agent-kit --version` are unchanged. Chat-only HITL flows (`/start-project`, `/git-staging`, `/git-prod`, `/run-plan-all`, backlog CRUD) are not CLI commands. +On an interactive TTY, long-running commands (`init`, `install`, `doctor`, `update`, `run-plan` ticks) show an in-process ANSI spinner plus a rotating Mission Kit tip. Set `AGENT_KIT_REDUCED_MOTION=1` for static text on a capable TTY. Runtime dependencies stay `@clack/prompts`, `citty`, and `kolorist` (no `ora` / `figlet` / `chalk` / `ink`). Window titles for `agent-kit dashboard` and `agent-kit dashboard-broadcast` use the workspace basename, not the CLI package folder. + ## Common commands | Command | Purpose | diff --git a/packages/cli/package.json b/packages/cli/package.json index 1f3b6b6..e217ec6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@dadado/agent-kit-cli", - "version": "5.0.0", + "version": "5.2.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", @@ -22,6 +22,8 @@ "dev": "tsx src/index.ts", "start": "tsx src/index.ts", "lint": "biome check src", + "overlay:hashes": "tsx src/lifecycle/refresh-known-hashes.ts", + "overlay:hashes:check": "tsx src/lifecycle/refresh-known-hashes.ts --check", "test": "vitest run", "typecheck": "tsc --noEmit" }, diff --git a/packages/cli/src/commands/dashboard-broadcast.ts b/packages/cli/src/commands/dashboard-broadcast.ts index 1412c43..3c97094 100644 --- a/packages/cli/src/commands/dashboard-broadcast.ts +++ b/packages/cli/src/commands/dashboard-broadcast.ts @@ -5,8 +5,9 @@ import { defineCommand } from "citty"; import { logger } from "../utils/logger.js"; import { type FindDashboardOptions, - applyDashboardOpenEnv, bundledDashboardCandidates, + dashboardProcessTitle, + dashboardSpawnEnv, findDashboardStart, resolveDashboardSnapshotRoot, } from "./dashboard.js"; @@ -124,10 +125,11 @@ export const dashboardBroadcastCommand = defineCommand({ } const snapshotRoot = resolveDashboardSnapshotRoot(args.cwd); - const env = applyDashboardOpenEnv( - { ...process.env }, - { noOpen: Boolean(args["no-open"]), browser: args.browser, cwd: snapshotRoot }, - ); + const env = dashboardSpawnEnv({ ...process.env }, snapshotRoot, { + noOpen: Boolean(args["no-open"]), + browser: args.browser, + }); + process.title = dashboardProcessTitle(snapshotRoot); const code = await runStartScript(startPath, env); if (code !== 0) process.exitCode = code; diff --git a/packages/cli/src/commands/dashboard.test.ts b/packages/cli/src/commands/dashboard.test.ts index 63c5beb..29eb5e0 100644 --- a/packages/cli/src/commands/dashboard.test.ts +++ b/packages/cli/src/commands/dashboard.test.ts @@ -1,12 +1,14 @@ import { execFileSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { pathToFileURL } from "node:url"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; import { findDashboardBroadcastStart } from "../commands/dashboard-broadcast.js"; import { bundledDashboardCandidates, + dashboardProcessTitle, + dashboardSpawnEnv, findDashboardStart, resolveDashboardSnapshotRoot, } from "../commands/dashboard.js"; @@ -138,3 +140,35 @@ describe("findDashboardBroadcastStart", () => { await expect(findDashboardBroadcastStart(consumer, {}, { moduleUrl })).resolves.toBeNull(); }); }); + +describe("dashboardSpawnEnv / dashboardProcessTitle", () => { + it("sets MISSION_CONTROL_REPO_ROOT to the resolved snapshot root", () => { + const root = join(tmpdir(), "ak-workspace"); + const env = dashboardSpawnEnv({ FOO: "1" }, root, {}); + expect(env.MISSION_CONTROL_REPO_ROOT).toBe(resolve(root)); + expect(env.FOO).toBe("1"); + }); + + it("uses workspace basename only (no home path, not the CLI package folder)", () => { + expect(dashboardProcessTitle("/Users/example/Git/agent-kit")).toBe( + "Mission Control · agent-kit", + ); + expect(dashboardProcessTitle("/Users/example/Git/agent-kit")).not.toContain("/Users/"); + expect(dashboardProcessTitle("/tmp/work/my-app")).toBe("Mission Control · my-app"); + }); +}); + +describe("starter resolveContextConfigPath call sites", () => { + const kitRoot = resolve(fileURLToPath(import.meta.url), "../../../../.."); + + it("pass realpathSync with existsSync (parity with serve.mjs / dashboard.ts)", () => { + for (const rel of ["dashboard/start.mjs", "dashboard/start-broadcast.mjs"]) { + const src = readFileSync(join(kitRoot, rel), "utf8"); + expect(src).toMatch(/from "node:fs"/); + expect(src).toMatch(/\brealpathSync\b/); + expect(src).toMatch( + /resolveContextConfigPath\(\s*ROOT,\s*\{\s*existsSync,\s*realpathSync\s*\}\s*\)/, + ); + } + }); +}); diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 2eba958..c2f68a1 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -53,6 +53,22 @@ export function applyDashboardOpenEnv( return next; } +/** Snapshot env + open flags for Mission Control starters. */ +export function dashboardSpawnEnv( + env: NodeJS.ProcessEnv, + snapshotRoot: string, + opts: { noOpen?: boolean; browser?: string }, +): NodeJS.ProcessEnv { + const root = path.resolve(snapshotRoot); + return applyDashboardOpenEnv({ ...env, MISSION_CONTROL_REPO_ROOT: root }, { ...opts, cwd: root }); +} + +/** Terminal / tab identity: workspace basename only (no absolute home paths). */ +export function dashboardProcessTitle(snapshotRoot: string): string { + const base = path.basename(path.resolve(snapshotRoot)) || "workspace"; + return `Mission Control · ${base}`; +} + /** Candidates for dashboard assets shipped beside the CLI package (Path C). */ export function bundledDashboardCandidates( filename: "start.mjs" | "start-broadcast.mjs", @@ -222,10 +238,11 @@ export const dashboardCommand = defineCommand({ return; } - const env = applyDashboardOpenEnv( - { ...process.env, MISSION_CONTROL_REPO_ROOT: snapshotRoot }, - { noOpen: Boolean(args["no-open"]), browser: args.browser, cwd: snapshotRoot }, - ); + const env = dashboardSpawnEnv({ ...process.env }, snapshotRoot, { + noOpen: Boolean(args["no-open"]), + browser: args.browser, + }); + process.title = dashboardProcessTitle(snapshotRoot); const code = await runStartScript(startPath, env); if (code !== 0) process.exitCode = code; diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 7667f32..02a131d 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -7,6 +7,7 @@ import { executeSafeReadinessFixes } from "../scanner/safe-fixes.js"; import { runScanner } from "../scanner/scan.js"; import { writeReadinessSnapshot } from "../scanner/snapshot.js"; import type { ReadinessReport, SafeReadinessChange } from "../types.js"; +import { withCliProgress } from "../welcome/visual-kit.js"; export interface DoctorResult { report: ReadinessReport; @@ -97,7 +98,8 @@ export const doctorCommand = defineCommand({ }, }, async run({ args }) { - const result = await runDoctor(args.cwd, { fixSafe: args["fix-safe"] }); + const run = () => runDoctor(args.cwd, { fixSafe: args["fix-safe"] }); + const result = args.json ? await run() : await withCliProgress("doctor", run); if (args.json) { console.log(JSON.stringify(result, null, 2)); return; diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 98b7c77..bfe5db9 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -3,6 +3,7 @@ import { defineCommand } from "citty"; import { KIT_VERSION } from "../lifecycle/version.js"; import { logger } from "../utils/logger.js"; import { classifyInstallError, isNonInteractive } from "../utils/terminal.js"; +import { withCliProgress } from "../welcome/visual-kit.js"; import { type InstallResult, performInstall } from "./install.js"; type CompatibilityInstaller = (options: { cwd: string }) => Promise; @@ -35,7 +36,7 @@ export const initCommand = defineCommand({ } logger.info("init now uses the canonical install and readiness workflow."); try { - const result = await runInitCompatibility(args.cwd); + const result = await withCliProgress("init", () => runInitCompatibility(args.cwd)); const pending = result.readiness.pendingActions.length; logger.success(`L0 and readiness prepared in ${result.projectRoot}`); const nextStep = diff --git a/packages/cli/src/commands/install.test.ts b/packages/cli/src/commands/install.test.ts index a72e4f1..0dee647 100644 --- a/packages/cli/src/commands/install.test.ts +++ b/packages/cli/src/commands/install.test.ts @@ -3,6 +3,7 @@ import { RootRefusedError } from "../utils/terminal.js"; import { installCommand } from "./install.js"; const mockConfirmProjectRoot = vi.hoisted(() => vi.fn()); +const mockResolveRegistryFromCli = vi.hoisted(() => vi.fn()); vi.mock("../utils/terminal.js", async (importOriginal) => { const mod = await importOriginal(); @@ -13,6 +14,14 @@ vi.mock("../utils/terminal.js", async (importOriginal) => { }; }); +vi.mock("../lifecycle/resolve-cli.js", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + resolveRegistryFromCli: (...args: unknown[]) => mockResolveRegistryFromCli(...args), + }; +}); + describe("installCommand RootRefusedError", () => { afterEach(() => { mockConfirmProjectRoot.mockReset(); @@ -48,4 +57,41 @@ describe("installCommand RootRefusedError", () => { exitSpy.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")); + const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => { + throw new Error("process.exit must not be called on the generic failure path"); + }) as never); + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + await ( + installCommand.run as unknown as (ctx: { args: Record }) => Promise + )({ + args: { + _: [], + cwd: "/tmp/some-project", + 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); + expect(exitSpy).not.toHaveBeenCalled(); + // Recovery hint reaches stderr before the command returns (no truncation + // risk from an immediate exit on piped stderr). + expect(consoleErrorSpy).toHaveBeenCalled(); + } finally { + exitSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + mockResolveRegistryFromCli.mockReset(); + } + }); }); diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts index 9169f79..d718f3c 100644 --- a/packages/cli/src/commands/install.ts +++ b/packages/cli/src/commands/install.ts @@ -21,6 +21,7 @@ import { confirmProjectRoot, isNonInteractive, } from "../utils/terminal.js"; +import { withCliProgress } from "../welcome/visual-kit.js"; function parsePackList(raw: string | undefined): string[] { if (!raw?.trim()) return []; @@ -196,15 +197,17 @@ export const installCommand = defineCommand({ } try { - const result = await performInstall({ - cwd: projectRoot, - profile: args.profile as string | undefined, - pack: args.pack, - registry: args.registry, - url: args.url, - ref: args.ref, - refresh: args.refresh, - }); + const result = await withCliProgress("install", () => + performInstall({ + cwd: projectRoot, + profile: args.profile as string | undefined, + pack: args.pack, + registry: args.registry, + url: args.url, + ref: args.ref, + refresh: args.refresh, + }), + ); logApplyStats(result.stats); logger.success(`Manifest written: ${result.manifestPath}`); logger.success("Readiness snapshot written: .cursor/context/readiness.json"); @@ -213,7 +216,11 @@ export const installCommand = defineCommand({ const hint = classifyInstallError(err); logger.error(hint.message); console.error(`\n${hint.recovery}\n`); - process.exit(1); + // exitCode + return (not process.exit): an immediate exit can truncate + // the recovery hint when stderr is a pipe (CI/log capture), and it + // matches the refusal path above and update.ts. + process.exitCode = 1; + return; } }, }); diff --git a/packages/cli/src/commands/update.test.ts b/packages/cli/src/commands/update.test.ts index 7bb59ef..b0c960a 100644 --- a/packages/cli/src/commands/update.test.ts +++ b/packages/cli/src/commands/update.test.ts @@ -86,4 +86,67 @@ describe("updateCommand", () => { // original installedAt value, not a fresh timestamp. expect(saved.installedAt).toBe("2026-07-30T00:00:00.000Z"); }, 15_000); + + it("passes --registry into update --check", async () => { + const consumer = await mkdtemp(path.join(tmpdir(), "ak-update-check-c-")); + const kit = await mkdtemp(path.join(tmpdir(), "ak-update-check-k-")); + await mkdir(path.join(consumer, ".cursor"), { recursive: true }); + await writeFile( + path.join(consumer, ".cursor", "agent-kit.json"), + JSON.stringify({ + schemaVersion: 1, + version: "5.0.0", + packs: [], + skills: [], + protected: [], + registry: { url: "https://github.com/agent-kit-startup/agent-kit", ref: "main" }, + }), + "utf8", + ); + await mkdir(path.join(kit, "registry"), { recursive: true }); + await writeFile(path.join(kit, "registry", "registry.json"), "{}\n", "utf8"); + await mkdir(path.join(kit, "packages", "cli"), { recursive: true }); + await writeFile( + path.join(kit, "packages", "cli", "package.json"), + JSON.stringify({ version: "5.1.0" }), + "utf8", + ); + + const logs: string[] = []; + const spy = vi.spyOn(console, "log").mockImplementation((line: unknown) => { + logs.push(String(line)); + }); + try { + await ( + updateCommand.run as unknown as (ctx: { args: Record }) => Promise + )({ + args: { + _: [], + cwd: consumer, + check: true, + json: true, + "respect-prefs": false, + stamp: false, + "seed-overlay": false, + registry: kit, + url: undefined as unknown as string, + ref: undefined as unknown as string, + refresh: false, + }, + }); + } finally { + spy.mockRestore(); + } + + const payload = JSON.parse(logs.join("\n")) as { + status: string; + registryUrl: string; + registryRef: string; + message: string; + }; + expect(payload.status).toBe("update-available"); + expect(payload.registryUrl).toBe(path.resolve(kit)); + expect(payload.registryRef).toBe("local"); + expect(payload.message).not.toMatch(/public/i); + }); }); diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index 4d113b5..d6c13d6 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -9,6 +9,7 @@ import { KIT_VERSION } from "../lifecycle/version.js"; import { loadAgentKitManifest } from "../manifest/index.js"; import { logger } from "../utils/logger.js"; import { RootRefusedError, confirmProjectRoot, isNonInteractive } from "../utils/terminal.js"; +import { withCliProgress } from "../welcome/visual-kit.js"; export const updateCommand = defineCommand({ meta: { @@ -24,7 +25,7 @@ export const updateCommand = defineCommand({ check: { type: "boolean", description: - "Check-only: compare installed version to latest public tag; never apply L0 writes", + "Check-only: compare installed version to the resolved registry (public tags, or --registry local checkout); never apply L0 writes", default: false, }, json: { @@ -68,6 +69,8 @@ export const updateCommand = defineCommand({ respectPrefs: Boolean(args["respect-prefs"]), stamp: Boolean(args.stamp), publicRegistryUrl: typeof args.url === "string" && args.url ? args.url : undefined, + registryPath: + typeof args.registry === "string" && args.registry ? args.registry : undefined, }); if (args.json) { @@ -140,7 +143,9 @@ export const updateCommand = defineCommand({ await seedManagedHashLedger(projectRoot); logger.info("Seeded managed-hash ledger from current local overlay files."); } - const stats = await syncFromManifest(registry.root, projectRoot, next); + const stats = await withCliProgress("update", () => + syncFromManifest(registry.root, projectRoot, next), + ); await saveManifest(projectRoot, next); logApplyStats(stats); logger.success("Update complete (L3 protected paths left untouched)."); diff --git a/packages/cli/src/dashboard/ci-private-origin-allowlist.test.ts b/packages/cli/src/dashboard/ci-private-origin-allowlist.test.ts index 6e2d357..c4b98ec 100644 --- a/packages/cli/src/dashboard/ci-private-origin-allowlist.test.ts +++ b/packages/cli/src/dashboard/ci-private-origin-allowlist.test.ts @@ -54,4 +54,21 @@ describe("ci.yml private-origin allowlist pin", () => { expect(body).not.toMatch(denylistRe); }, ); + + it.skipIf(!ciPresent)("runs the three root node --test suites from Evidence checks", () => { + const evidenceIdx = body.indexOf("- name: Evidence checks"); + const guardIdx = body.indexOf("- name: Guard generated CLI dashboard is untracked"); + expect(evidenceIdx).toBeGreaterThan(-1); + expect(guardIdx).toBeGreaterThan(evidenceIdx); + const evidenceBlock = body.slice(evidenceIdx, guardIdx); + expect(evidenceBlock).toContain("pnpm test:root-node"); + + const pkg = JSON.parse(readFileSync(resolve(repoRoot, "package.json"), "utf8")) as { + scripts?: Record; + }; + const rootNode = pkg.scripts?.["test:root-node"] ?? ""; + expect(rootNode).toContain("plan-external-review-progress-gate.test.mjs"); + expect(rootNode).toContain("check-public-deny-links.test.mjs"); + expect(rootNode).toContain("verify-cli-dashboard-pack.test.mjs"); + }); }); diff --git a/packages/cli/src/dashboard/guards-dts-parity.test.ts b/packages/cli/src/dashboard/guards-dts-parity.test.ts new file mode 100644 index 0000000..33f05d8 --- /dev/null +++ b/packages/cli/src/dashboard/guards-dts-parity.test.ts @@ -0,0 +1,51 @@ +import { readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const repoRoot = resolve(fileURLToPath(import.meta.url), "../../../../.."); +const mjsPath = join(repoRoot, "dashboard/lib/guards.mjs"); +const dtsPath = join(repoRoot, "dashboard/lib/guards.d.mts"); + +function exportNames(source: string, kind: "function" | "const"): string[] { + const re = + kind === "function" + ? /^export function ([A-Za-z_$][\w$]*)/gm + : /^export const ([A-Za-z_$][\w$]*)/gm; + const names: string[] = []; + for (const match of source.matchAll(re)) { + const name = match[1]; + if (name) names.push(name); + } + return names; +} + +function allExportNames(source: string): string[] { + return [...new Set([...exportNames(source, "function"), ...exportNames(source, "const")])]; +} + +describe("guards.d.mts export parity", () => { + const mjs = readFileSync(mjsPath, "utf8"); + const dts = readFileSync(dtsPath, "utf8"); + const mjsFunctions = exportNames(mjs, "function"); + const mjsConsts = exportNames(mjs, "const"); + const dtsExports = allExportNames(dts); + const mjsExports = allExportNames(mjs); + const dtsSet = new Set(dtsExports); + const mjsSet = new Set(mjsExports); + + it("declares every export function from guards.mjs", () => { + const missing = mjsFunctions.filter((name) => !dtsSet.has(name)); + expect(missing, `functions missing from guards.d.mts: ${missing.join(", ")}`).toEqual([]); + }); + + it("declares every export const from guards.mjs", () => { + const missing = mjsConsts.filter((name) => !dtsSet.has(name)); + expect(missing, `consts missing from guards.d.mts: ${missing.join(", ")}`).toEqual([]); + }); + + it("does not declare exports absent from guards.mjs", () => { + const extra = dtsExports.filter((name) => !mjsSet.has(name)); + expect(extra, `guards.d.mts names missing from guards.mjs: ${extra.join(", ")}`).toEqual([]); + }); +}); diff --git a/packages/cli/src/dashboard/guards.test.ts b/packages/cli/src/dashboard/guards.test.ts index dc812b9..0b0cb84 100644 --- a/packages/cli/src/dashboard/guards.test.ts +++ b/packages/cli/src/dashboard/guards.test.ts @@ -56,6 +56,10 @@ describe("allowlistConfig", () => { offerOnExhausted: false, autoRemediate: false, backend: "claude", + reviewerModel: "haiku", + advisorModel: "opus", + waitSliceSeconds: 90, + waitTimeoutSeconds: 900, mode: "autonomous", midBatchAudits: true, preflight: "warn", @@ -82,6 +86,10 @@ describe("allowlistConfig", () => { mode: "autonomous", midBatchAudits: true, preflight: "warn", + reviewerModel: "haiku", + advisorModel: "opus", + waitSliceSeconds: 90, + waitTimeoutSeconds: 900, }, agentPersona: { default: "night-shift", @@ -123,7 +131,11 @@ describe("config write allowlist", () => { updateCheck: { enabled: true, intervalDays: 7 }, externalPlanReview: { enabled: true, - backend: "claude", + backend: "auto", + reviewerModel: "haiku", + advisorModel: "opus", + waitSliceSeconds: 90, + waitTimeoutSeconds: 900, autoRemediate: false, offerOnExhausted: true, mode: "autonomous", @@ -178,6 +190,18 @@ describe("config write allowlist", () => { expect(validateConfigWriteBody({ externalPlanReview: { unknownAuditsKey: true } }).ok).toBe( false, ); + expect(validateConfigWriteBody({ externalPlanReview: { backend: "auto" } }).ok).toBe(true); + expect(validateConfigWriteBody({ externalPlanReview: { backend: "cursor" } }).ok).toBe(true); + expect(validateConfigWriteBody({ externalPlanReview: { backend: "windsurf" } }).ok).toBe(false); + expect( + validateConfigWriteBody({ + externalPlanReview: { reviewerModel: "haiku", waitSliceSeconds: 90 }, + }).ok, + ).toBe(true); + expect(validateConfigWriteBody({ externalPlanReview: { waitSliceSeconds: 0 } }).ok).toBe(false); + expect(validateConfigWriteBody({ externalPlanReview: { reviewerModel: "bad name" } }).ok).toBe( + false, + ); }); it("merges without wiping onboarding nests", () => { diff --git a/packages/cli/src/dashboard/open-browser.test.ts b/packages/cli/src/dashboard/open-browser.test.ts index c01da59..70028a5 100644 --- a/packages/cli/src/dashboard/open-browser.test.ts +++ b/packages/cli/src/dashboard/open-browser.test.ts @@ -169,27 +169,34 @@ describe("buildOpenBrowserCommand", () => { }); describe("openBrowser spawn", () => { - it("spawns exactly one process with resolved args", () => { + it("detaches exactly once after linux which-probe succeeds", () => { const child = { unref: vi.fn(), on: vi.fn() }; const spawnFn = vi.fn(() => child); + const spawnSyncFn = vi.fn(() => ({ status: 0, error: null })); const result = openBrowser("http://127.0.0.1:3511/", { env: {}, - preferred: "Google Chrome", - platform: "darwin", + preferred: "firefox", + platform: "linux", spawnFn, + spawnSyncFn, }); expect(result.opened).toBe(true); - expect(spawnFn).toHaveBeenCalledTimes(1); - expect(spawnFn).toHaveBeenCalledWith( - "open", - ["-a", "Google Chrome", "http://127.0.0.1:3511/"], - { detached: true, stdio: "ignore" }, + expect(spawnSyncFn).toHaveBeenCalledTimes(1); + expect(spawnSyncFn).toHaveBeenCalledWith( + "which", + ["firefox"], + expect.objectContaining({ encoding: "utf8" }), ); + expect(spawnFn).toHaveBeenCalledTimes(1); + expect(spawnFn).toHaveBeenCalledWith("firefox", ["http://127.0.0.1:3511/"], { + detached: true, + stdio: "ignore", + }); expect(child.unref).toHaveBeenCalled(); expect(child.on).toHaveBeenCalledWith("error", expect.any(Function)); }); - it("falls back to OS default when preferred spawn throws", () => { + it("falls back to OS default when preferred detach throws after which succeeds", () => { const child = { unref: vi.fn(), on: vi.fn() }; const spawnFn = vi .fn() @@ -197,14 +204,28 @@ describe("openBrowser spawn", () => { throw Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }); }) .mockImplementationOnce(() => child); + const spawnSyncFn = vi.fn(() => ({ status: 0, error: null })); const result = openBrowser("http://127.0.0.1:3511/", { env: {}, preferred: "NoSuchBrowser", platform: "linux", spawnFn, + spawnSyncFn, }); expect(result.opened).toBe(true); expect(result.reason).toBe("preferred-fallback"); + expect(spawnSyncFn).toHaveBeenNthCalledWith( + 1, + "which", + ["NoSuchBrowser"], + expect.objectContaining({ encoding: "utf8" }), + ); + expect(spawnSyncFn).toHaveBeenNthCalledWith( + 2, + "which", + ["xdg-open"], + expect.objectContaining({ encoding: "utf8" }), + ); expect(spawnFn).toHaveBeenCalledTimes(2); expect(spawnFn.mock.calls[1]?.[0]).toBe("xdg-open"); }); @@ -213,14 +234,18 @@ describe("openBrowser spawn", () => { const spawnFn = vi.fn(() => { throw new Error("spawn failed"); }); + const spawnSyncFn = vi.fn(() => ({ status: 0, error: null })); const result = openBrowser("http://127.0.0.1:3511/", { env: {}, preferred: "bad-bin", platform: "linux", spawnFn, + spawnSyncFn, }); expect(result.opened).toBe(false); expect(result.reason).toBe("spawn-failed"); + expect(spawnSyncFn).toHaveBeenCalled(); + expect(spawnFn).toHaveBeenCalled(); }); it("returns invalid-url for empty url", () => { diff --git a/packages/cli/src/dashboard/plugin-ux-validation.test.ts b/packages/cli/src/dashboard/plugin-ux-validation.test.ts index 31bea1f..e019f54 100644 --- a/packages/cli/src/dashboard/plugin-ux-validation.test.ts +++ b/packages/cli/src/dashboard/plugin-ux-validation.test.ts @@ -1291,6 +1291,28 @@ describe("plugin-ux-validation: narrow shell + a11y chrome", () => { expect(plansRenderer).not.toMatch( /class="plan-accordion-panel"[\s\S]*?class="progress-bar plan-progress-bar"/, ); + // Shimmer keys on lifecycle only — queueRole never attaches is-executing + // (queue-mode COMPLETED + EXECUTING dual pills must not shimmer a done bar). + expect(dashboardHtml).not.toMatch(/queueRole[^\n]*is-executing/); + }); + + it("renders a readable 6px progress track with non-error fill colors", () => { + // Track must stay visibly a track at 0% (Phase 0 contract item 5): + // 6px var(--border-active) with rounded ends, not the old 4px + // var(--border) hairline that vanished against the card background. + const track = dashboardHtml.match(/\.progress-bar\s*\{([^}]+)\}/)?.[1]; + expect(track).toBeTruthy(); + expect(track).toContain("height: 6px"); + expect(track).toContain("background: var(--border-active)"); + expect(track).toContain("border-radius: 3px"); + expect(track).not.toContain("height: 4px"); + // Progress is never an error signal: green only at 100%, neutral blue + // otherwise — no red/yellow low-progress branches. + const colorFn = dashboardHtml.match(/function progressColor\(pct\)\s*\{([\s\S]*?)\n\}/)?.[1]; + expect(colorFn).toBeTruthy(); + expect(colorFn).toContain("return pct >= 100 ? 'green' : 'blue';"); + expect(colorFn).not.toContain("red"); + expect(colorFn).not.toContain("yellow"); }); it("renders current-mission status badges as solid fills with no outline", () => { @@ -2764,8 +2786,13 @@ describe("plugin-ux-validation: hardening regressions", () => { expect(staticFn).not.toContain("/api/config"); // Clipboard failure toast truncates long snippets instead of dumping the full body. expect(dashboardHtml).toContain("const preview = raw.length > 120"); - // Dead-control hints: backend is claude-only; updateApply.auto never writable. - expect(dashboardHtml).toContain("claude is the only backend today."); + // Audits backend cascade is writable (auto / claude / cursor); updateApply.auto never writable. + expect(dashboardHtml).toContain("auto uses Claude when usable, else Cursor Agent."); + expect(dashboardHtml).not.toContain("claude is the only backend today."); + expect(dashboardHtml).toContain('option value="auto"'); + expect(dashboardHtml).toContain('option value="cursor"'); + expect(dashboardHtml).toContain('id="config-epr-reviewerModel"'); + expect(dashboardHtml).toContain('id="config-epr-waitSlice"'); expect(dashboardHtml).toContain("updateApply.auto is never writable here."); }); @@ -3164,6 +3191,27 @@ describe("plans tab v2: actionable rows, live status bar, next action", () => { expect(renderer).toContain("Next: ${escapeHtml(p.nextActionTodo.id)}"); }); + it("fills progress from terminal to-dos and mirrors todoStats in the fallback lifecycle", () => { + // Phase 0 contract (align-with-todoStats): the fill numerator counts + // terminal to-dos (completed + cancelled, the TERMINAL_TODO_STATUSES set) + // so the bar reaches 100% whenever the lifecycle pill says COMPLETED. + expect(dashboardHtml).toContain("items.filter((t) => t.status === 'cancelled').length"); + expect(dashboardHtml).toContain("Math.round(((completed + cancelled) / total) * 100)"); + expect(dashboardHtml).not.toContain("Math.round((completed / total) * 100)"); + // The label keeps the honest completed-only count and surfaces cancelled + // explicitly — only when cancelled > 0. + expect(dashboardHtml).toContain( + "progressLabel: `${completed} of ${total} complete` + (cancelled > 0 ? ` · ${cancelled} cancelled` : '')", + ); + expect(dashboardHtml).toContain("progressCancelled: cancelled,"); + // Fallback lifecycle (no missionControl enrichment) mirrors + // todoStats.open === 0: terminal work exhausting the list is completed. + expect(dashboardHtml).toContain( + "if (total > 0 && completed + cancelled >= total && inProgress === 0) {", + ); + expect(dashboardHtml).not.toContain("if (total > 0 && completed >= total && inProgress === 0)"); + }); + it("computes the next actionable to-do (in_progress first, else first pending)", () => { expect(dashboardHtml).toContain("function planNextActionTodo(items)"); expect(dashboardHtml).toContain("items.find((t) => t.status === 'in_progress')"); @@ -3724,9 +3772,15 @@ describe("plugin-ux-validation: Healthcenter (More → Health)", () => { expect(dashboardHtml).not.toMatch( /HEALTH_SEVERITY_CHROME\s*=\s*\{(?:(?!\};)[\s\S])*\btoken\s*:(?:(?!\};)[\s\S])*\};/, ); - // Fallback || { … }: reject token within that object only (not unbounded to EOF). + // Positive shape anchor for the fallback return: if the `|| { … }` form is + // refactored away (e.g. `??`, extracted default), this fails loudly instead + // of letting the negative pin below go silently vacuous (health R1). + expect(dashboardHtml).toMatch(/return HEALTH_SEVERITY_CHROME\[sev\]\s*\|\|\s*\{/); + // Fallback || { … }: reject token within that object only (not unbounded to + // EOF). Whitespace-tolerant (\s*) so a line-wrapped return or double space + // cannot silently skip the pin (mutations H2/H4). expect(dashboardHtml).not.toMatch( - /return HEALTH_SEVERITY_CHROME\[sev\] \|\| \{(?:(?!\})[\s\S])*\btoken\s*:/, + /return HEALTH_SEVERITY_CHROME\[sev\]\s*\|\|\s*\{(?:(?!\})[\s\S])*\btoken\s*:/, ); // Presence dot, card dot, and severity label all read the same mapping. expect(dashboardHtml).toContain( diff --git a/packages/cli/src/dashboard/semantic-model.test.ts b/packages/cli/src/dashboard/semantic-model.test.ts index 74ecce5..fb63174 100644 --- a/packages/cli/src/dashboard/semantic-model.test.ts +++ b/packages/cli/src/dashboard/semantic-model.test.ts @@ -546,6 +546,49 @@ describe("run-plan-all queue view (semantic layer)", () => { true, ); }); + + it("enrichPlans progress exposes terminal counters (cancelled counts toward the fill)", () => { + const handoff = { plan: "other.plan.md", mode: "idle" }; + const enrich = (statuses: string[]) => + enrichPlans([queuePlan("p.plan.md", statuses)], handoff)[0]; + + // No cancelled work: counters unchanged, label has no cancelled suffix. + const mid = enrich(["completed", "completed", "in_progress", "pending", "pending"]); + expect(mid.progress).toMatchObject({ completed: 2, cancelled: 0, terminal: 2, total: 5 }); + expect(mid.progress.label).toBe("2 of 5 complete"); + + // Cancelled is terminal: it joins the numerator and is surfaced in the label, + // so terminal === total exactly when classifyPlan reports completed. + const cancelheavy = enrich(["completed", "completed", "completed", "cancelled", "cancelled"]); + expect(cancelheavy.progress).toMatchObject({ + completed: 3, + cancelled: 2, + terminal: 5, + total: 5, + }); + expect(cancelheavy.progress.label).toBe("3 of 5 complete · 2 cancelled"); + expect(cancelheavy.lifecycle).toBe("completed"); + + // All-cancelled worst case: 100% terminal fill, honest label, completed pill. + const allcancel = enrich(["cancelled", "cancelled"]); + expect(allcancel.progress).toMatchObject({ completed: 0, cancelled: 2, terminal: 2, total: 2 }); + expect(allcancel.progress.label).toBe("0 of 2 complete · 2 cancelled"); + expect(allcancel.lifecycle).toBe("completed"); + + // Invariant (Phase 0 contract item 5): lifecycle completed with total > 0 + // always has terminal === total, so the UI's terminal-inclusive pct math + // (mergePlansForUi) renders exactly 100% under the COMPLETED pill. + const done = enrich(["completed", "completed", "completed"]); + for (const plan of [done, cancelheavy, allcancel]) { + expect(plan.lifecycle).toBe("completed"); + expect(plan.progress.total).toBeGreaterThan(0); + expect(plan.progress.terminal).toBe(plan.progress.total); + expect(Math.round((plan.progress.terminal / plan.progress.total) * 100)).toBe(100); + } + // And never prematurely: open work keeps terminal below total. + expect(mid.lifecycle).not.toBe("completed"); + expect(mid.progress.terminal).toBeLessThan(mid.progress.total); + }); }); /** Parked list entry that still has open todos (decision note stays). */ diff --git a/packages/cli/src/generator/claude-kit-load.test.ts b/packages/cli/src/generator/claude-kit-load.test.ts new file mode 100644 index 0000000..c04757d --- /dev/null +++ b/packages/cli/src/generator/claude-kit-load.test.ts @@ -0,0 +1,78 @@ +import { existsSync } from "node:fs"; +import { 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 { detectContext } from "../scanner/detect-repository.js"; +import { fileExists } from "../utils/fs.js"; +import { + AGENT_KIT_COMMAND_REL, + CLAUDE_MD_REL, + generateClaudeKitLoadArtifacts, + renderAgentKitCommand, + renderClaudeMd, +} from "./claude-kit-load.js"; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../.."); +const factoryClaudePath = path.join(REPO_ROOT, CLAUDE_MD_REL); +const factoryCommandPath = path.join(REPO_ROOT, AGENT_KIT_COMMAND_REL); +/** Factory dogfood files are private-only; public-sync does not allowlist them. */ +const factoryKitLoadPresent = existsSync(factoryClaudePath) && existsSync(factoryCommandPath); + +describe("generateClaudeKitLoadArtifacts", () => { + 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()); + expect(docs).toContain(renderAgentKitCommand().trimEnd()); + }); + + it.skipIf(!factoryKitLoadPresent)( + "keeps factory dogfood files identical to generator snippets", + async () => { + expect(await readFile(factoryClaudePath, "utf8")).toBe(renderClaudeMd()); + expect(await readFile(factoryCommandPath, "utf8")).toBe(renderAgentKitCommand()); + }, + ); + + it("writes CLAUDE.md and /agent-kit on first run", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ak-claude-load-")); + const results = await generateClaudeKitLoadArtifacts(root); + + expect(results).toEqual([ + { relativePath: CLAUDE_MD_REL, status: "applied" }, + { relativePath: AGENT_KIT_COMMAND_REL, status: "applied" }, + ]); + + expect(await readFile(path.join(root, CLAUDE_MD_REL), "utf8")).toBe(renderClaudeMd()); + expect(await readFile(path.join(root, AGENT_KIT_COMMAND_REL), "utf8")).toBe( + renderAgentKitCommand(), + ); + + const context = await detectContext(root); + expect(context.hasAgentGuidance).toBe(true); + expect(context.sources).toEqual( + expect.arrayContaining([{ source: "file", value: "CLAUDE.md:agent guidance" }]), + ); + }); + + it("skips existing consumer files without overwriting them", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ak-claude-load-skip-")); + await writeFile(path.join(root, CLAUDE_MD_REL), "# Existing Claude notes\n", "utf8"); + + const results = await generateClaudeKitLoadArtifacts(root); + + expect(results).toContainEqual({ + relativePath: CLAUDE_MD_REL, + status: "skipped-customized", + }); + expect(results).toContainEqual({ + relativePath: AGENT_KIT_COMMAND_REL, + status: "applied", + }); + expect(await readFile(path.join(root, CLAUDE_MD_REL), "utf8")).toBe( + "# Existing Claude notes\n", + ); + expect(await fileExists(path.join(root, AGENT_KIT_COMMAND_REL))).toBe(true); + }); +}); diff --git a/packages/cli/src/generator/claude-kit-load.ts b/packages/cli/src/generator/claude-kit-load.ts new file mode 100644 index 0000000..bbcf015 --- /dev/null +++ b/packages/cli/src/generator/claude-kit-load.ts @@ -0,0 +1,83 @@ +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import { ensureDir, fileExists } from "../utils/fs.js"; + +export const CLAUDE_MD_REL = "CLAUDE.md"; +export const AGENT_KIT_COMMAND_REL = ".claude/commands/agent-kit.md"; + +export interface ClaudeKitLoadArtifactResult { + relativePath: string; + status: "applied" | "skipped-customized"; +} + +export function renderClaudeMd(): string { + return `# Agent Kit (Claude Code) + +This repository uses Agent Kit / Mission Control. Before rediscovering the tree, read the shared sources of truth (paths below are pointers; do not treat this file as a second rulebook). + +## Read first + +1. \`AGENTS.md\` - cross-IDE contract +2. \`.cursor/project-context.md\` - verified repository facts (derived; prefer code, tests, SHAs when docs conflict) +3. \`.cursor/HANDOFF.md\` - if present: active plan, next to-do, queue fields +4. \`.cursor/commands/\` - slash catalog (Cursor). Follow the same HITL contracts here. + +Mid-session refresh: \`/agent-kit\`. + +## HITL + +Cursor Ask questions is not available in this CLI. When a command requires a choice, list the same labels as a numbered list and wait. Skip or cancel means stop. Never \`/git-prod\` without an explicit operator yes. + +## Non-goals + +- 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 +`; +} + +export function renderAgentKitCommand(): string { + return `--- +description: Load Agent Kit session context (HANDOFF, project-context, commands). Manual refresh only. +disable-model-invocation: true +--- + +Read these files if they exist, then summarize the active plan, next to-do, and any Gaps. Do not scan the whole repository first. + +1. \`AGENTS.md\` +2. \`.cursor/project-context.md\` +3. \`.cursor/HANDOFF.md\` +4. The plan file named in HANDOFF \`- **Plan:**\` under \`.cursor/plans/\` + +If HANDOFF is missing, say so and point at \`/agent-kit-onboard\` or \`/start-project\` rather than inventing a plan. + +HITL: numbered-list fallback for Ask questions labels. Never \`/git-prod\` from this skill. + +Non-goals: not audits / \`/plan-external-review\`, not \`--backend claude\` ticks, not A7, not Cursor hook clones. +`; +} + +async function writeUnlessExists( + rootDir: string, + relativePath: string, + content: string, +): Promise { + const target = path.join(rootDir, relativePath); + if (await fileExists(target)) { + return { relativePath, status: "skipped-customized" }; + } + await ensureDir(path.dirname(target)); + await writeFile(target, content, "utf8"); + return { relativePath, status: "applied" }; +} + +/** Always emit (not detectIde). Skip each path independently when the consumer already has a file. */ +export async function generateClaudeKitLoadArtifacts( + rootDir: string, +): Promise { + return Promise.all([ + writeUnlessExists(rootDir, CLAUDE_MD_REL, renderClaudeMd()), + writeUnlessExists(rootDir, AGENT_KIT_COMMAND_REL, renderAgentKitCommand()), + ]); +} diff --git a/packages/cli/src/generator/index.ts b/packages/cli/src/generator/index.ts index 8d5c3a9..f1e4c1b 100644 --- a/packages/cli/src/generator/index.ts +++ b/packages/cli/src/generator/index.ts @@ -1,5 +1,6 @@ import type { ProjectProfile } from "../types.js"; import { generateAgentsMd } from "./agents-md.js"; +import { generateClaudeKitLoadArtifacts } from "./claude-kit-load.js"; import { generateGitHooks } from "./git-hooks.js"; export { @@ -17,5 +18,6 @@ export type { /** Compatibility path for legacy callers. Repository readiness uses applyPersonalization. */ export async function generateFromProfile(profile: ProjectProfile): Promise { await generateAgentsMd(profile); + await generateClaudeKitLoadArtifacts(profile.rootDir); if (profile.installHooks) await generateGitHooks(profile); } diff --git a/packages/cli/src/generator/personalization.test.ts b/packages/cli/src/generator/personalization.test.ts index 8664f37..864c8e0 100644 --- a/packages/cli/src/generator/personalization.test.ts +++ b/packages/cli/src/generator/personalization.test.ts @@ -12,6 +12,7 @@ import { applyPersonalization, buildPersonalizationPlan, readRepositoryProfile, + renderProjectContext, } from "./personalization.js"; const REPOSITORY_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../.."); @@ -112,7 +113,12 @@ describe("repository personalization", () => { ]), ); expect(applied.manifest.protected).toEqual( - expect.arrayContaining(["AGENTS.md", ".cursor/project-context.md"]), + expect.arrayContaining([ + "AGENTS.md", + ".cursor/project-context.md", + "CLAUDE.md", + ".claude/commands/agent-kit.md", + ]), ); expect(applied.manifest.personalization).toEqual( expect.objectContaining({ @@ -160,4 +166,36 @@ describe("repository personalization", () => { expect(command).toContain("confirm-provider"); expect(command).toContain("warnings only"); }); + + it("fills Relevant skills from installed components instead of a hardcoded empty row", async () => { + const { root, profile, report } = await preparedNodeRepository(); + const registry = await loadRegistry(REPOSITORY_ROOT); + + await applyPersonalization({ + rootDir: root, + registryRoot: REPOSITORY_ROOT, + profile, + report, + registry, + manifest: buildManifest({ version: "4.4.7" }), + generatorVersion: "4.4.7", + }); + const context = await readFile(path.join(root, ".cursor/project-context.md"), "utf8"); + + expect(context).toContain("cursor-skills-node"); + expect(context).not.toContain( + "Add rows when `/agent-kit-onboard` scaffolds domain skills or personalization installs packs", + ); + expect(context).not.toMatch(/\|\s*\(none yet\)\s*\|/); + }); + + it("emits a generated empty Relevant skills row when no skills are installed", async () => { + const { profile } = await preparedNodeRepository(); + const context = renderProjectContext(profile, []); + + expect(context).toContain("| (none yet) | No installed or project-owned skills detected | — |"); + expect(context).not.toContain( + "Add rows when `/agent-kit-onboard` scaffolds domain skills or personalization installs packs", + ); + }); }); diff --git a/packages/cli/src/generator/personalization.ts b/packages/cli/src/generator/personalization.ts index 9c9ee6f..cf6f04a 100644 --- a/packages/cli/src/generator/personalization.ts +++ b/packages/cli/src/generator/personalization.ts @@ -19,6 +19,7 @@ import type { RepositoryPurpose, } from "../types.js"; import { ensureDir, fileExists, writeJson } from "../utils/fs.js"; +import { generateClaudeKitLoadArtifacts } from "./claude-kit-load.js"; import { generateVSCodeArtifacts } from "./vscode.js"; export const PERSONALIZATION_CONTRACT_VERSION = 1 as const; @@ -197,7 +198,52 @@ export function buildPersonalizationPlan( .sort((left, right) => `${left.kind}:${left.id}`.localeCompare(`${right.kind}:${right.id}`)); } -function renderProjectContext(profile: RepositoryProfile): string { +const INSTALLED_SKILL_STATUSES = new Set(["applied", "skipped-customized"]); + +export function installedSkillItems( + items: PersonalizationItem[], + installedIds: Iterable = [], +): PersonalizationItem[] { + const rows: PersonalizationItem[] = []; + const seen = new Set(); + for (const item of items) { + if (item.kind !== "skill" || !INSTALLED_SKILL_STATUSES.has(item.status)) continue; + if (seen.has(item.id)) continue; + seen.add(item.id); + rows.push(item); + } + for (const id of installedIds) { + if (seen.has(id)) continue; + seen.add(id); + rows.push({ + kind: "skill", + id, + status: "applied", + evidence: [{ source: "configuration", value: `.cursor/agent-kit.json skills[]:${id}` }], + }); + } + return rows.sort((left, right) => left.id.localeCompare(right.id)); +} + +function relevantSkillTableRows(skills: PersonalizationItem[]): string[] { + if (skills.length === 0) { + return ["| (none yet) | No installed or project-owned skills detected | — |"]; + } + return skills.map((skill) => { + const label = skill.path ?? skill.id; + const role = + skill.status === "skipped-customized" + ? "Already present (customized)" + : "Installed by personalization"; + const evidence = skill.path ?? skill.evidence[0]?.value ?? "personalization"; + return `| ${label} | ${role} | ${evidence} |`; + }); +} + +export function renderProjectContext( + profile: RepositoryProfile, + skillItems: PersonalizationItem[] = [], +): string { const sections: string[] = ["# Project Context", "", "Verified repository facts:"]; const purpose = purposeEvidence(profile); if (purpose.length > 0 && profile.purpose.value !== "unknown") { @@ -224,7 +270,7 @@ function renderProjectContext(profile: RepositoryProfile): string { "", "| Skill / path | Role | Evidence |", "|--------------|------|----------|", - "| (none yet) | Add rows when `/agent-kit-onboard` scaffolds domain skills or personalization installs packs | — |", + ...relevantSkillTableRows(installedSkillItems(skillItems)), ); if (profile.context.sources.length > 0) { sections.push("", "## Sources", ...profile.context.sources.map((item) => `- ${item.value}`)); @@ -343,7 +389,7 @@ export async function applyPersonalization(input: { createOwnedFile( input.rootDir, CONTEXT_PATH, - renderProjectContext(input.profile), + renderProjectContext(input.profile, installedSkillItems(componentResults, skills)), profileEvidence, ), createOwnedFile( @@ -356,6 +402,18 @@ export async function applyPersonalization(input: { protectedPaths.add(CONTEXT_PATH); protectedPaths.add(AGENTS_PATH); + const claudeResults = await generateClaudeKitLoadArtifacts(input.rootDir); + const claudeItems = claudeResults.map((artifact) => { + protectedPaths.add(artifact.relativePath); + return { + kind: "file" as const, + id: artifact.relativePath, + path: artifact.relativePath, + status: artifact.status, + evidence: profileEvidence, + }; + }); + const ideDetection = await detectIde(input.rootDir); if (ideDetection.ide === "vscode" || ideDetection.ide === "other") { const git: GitDetection = { @@ -404,7 +462,7 @@ export async function applyPersonalization(input: { contractVersion: PERSONALIZATION_CONTRACT_VERSION, generatorVersion: input.generatorVersion, repositoryFingerprint: input.report.repositoryFingerprint, - items: [...fileResults, ...componentResults], + items: [...fileResults, ...claudeItems, ...componentResults], protectedPaths: [...protectedPaths].sort(), }; await writeJson(path.join(input.rootDir, RESULT_PATH), result); diff --git a/packages/cli/src/hooks/session-start.test.ts b/packages/cli/src/hooks/session-start.test.ts index 8fc5275..3c4c1b3 100644 --- a/packages/cli/src/hooks/session-start.test.ts +++ b/packages/cli/src/hooks/session-start.test.ts @@ -61,6 +61,34 @@ describe("parseUnprocessedDogfoodItems", () => { "### Unprocessed Files\n1. `first.md`\n2) `second.md`\n\n### Processed Files\n3. `done.md`\n"; expect(parseUnprocessedDogfoodItems(text)).toEqual(["`first.md`", "`second.md`"]); }); + + it("skips the table header row before a separator (not a word allowlist)", () => { + const text = [ + "## Unprocessed Files", + "", + "| Nota | Status |", + "| --- | --- |", + "| `issue-37.md` | pending |", + "", + "## Processed Files", + "| `done.md` | done |", + "", + ].join("\n"); + expect(parseUnprocessedDogfoodItems(text)).toEqual(["`issue-37.md`"]); + }); + + it("stops Unprocessed at ### Processed without the word Files (no leak)", () => { + const text = [ + "## Unprocessed Files", + "- `a.md`", + "", + "### Processed", + "- `done.md`", + "| `later.md` | done |", + "", + ].join("\n"); + expect(parseUnprocessedDogfoodItems(text)).toEqual(["`a.md`"]); + }); }); describe("buildPreCompactUserMessage", () => { diff --git a/packages/cli/src/hooks/session-start.ts b/packages/cli/src/hooks/session-start.ts index 0045f72..3a69850 100644 --- a/packages/cli/src/hooks/session-start.ts +++ b/packages/cli/src/hooks/session-start.ts @@ -45,12 +45,25 @@ async function fileExists(p: string): Promise { } } +function isMarkdownTableSeparator(line: string): boolean { + const stripped = line.trim(); + if (!stripped.startsWith("|")) return false; + const parts = stripped + .replace(/^\|/, "") + .replace(/\|$/, "") + .split("|") + .map((cell) => cell.trim()); + return parts.length > 0 && parts.every((cell) => /^:?-+:?$/.test(cell)); +} + /** Match factory (`###`) and consumer (`##`) Unprocessed headings. */ export function parseUnprocessedDogfoodItems(readmeText: string): string[] { const items: string[] = []; let inSection = false; let sectionLevel = 0; - for (const line of readmeText.split(/\r?\n/)) { + const lines = readmeText.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const line = lines[i] ?? ""; const unprocessedMatch = /^(#{2,3})\s+Unprocessed Files\b/.exec(line); if (unprocessedMatch) { const hashes = unprocessedMatch[1]; @@ -62,12 +75,16 @@ export function parseUnprocessedDogfoodItems(readmeText: string): string[] { if (!inSection) continue; const headingMatch = /^(#{1,6})\s+/.exec(line); if (headingMatch) { - // Any Processed Files heading ends the section (mixed H2/H3 must not leak). - if (/\bProcessed Files\b/.test(line)) break; + // Any Processed heading ends the section (Files optional; mixed H2/H3 must not leak). + if (/^#{1,6}\s+Processed(?:\s+Files)?\b/.test(line)) break; const hashes = headingMatch[1]; if (hashes && hashes.length <= sectionLevel) break; continue; } + // Header is the table row immediately before a separator, not a word allowlist. + if (line.trim().startsWith("|") && isMarkdownTableSeparator(lines[i + 1] ?? "")) { + continue; + } const body = extractUnprocessedDogfoodLine(line); if (!body) continue; items.push(body); @@ -88,17 +105,14 @@ function extractUnprocessedDogfoodLine(line: string): string | null { if (numbered?.[2]) { raw = numbered[2].trim(); } else if (stripped.startsWith("|")) { + if (isMarkdownTableSeparator(stripped)) return null; const parts = stripped .replace(/^\|/, "") .replace(/\|$/, "") .split("|") .map((cell) => cell.trim()); if (parts.length === 0) return null; - if (parts.every((cell) => /^:?-+:?$/.test(cell))) return null; - const first = parts[0] ?? ""; - const headerish = first.toLowerCase().replace(/[*_`]/g, "").trim(); - if (/^(note|file|entrada|title|name|item|path)$/.test(headerish)) return null; - raw = first.trim(); + raw = (parts[0] ?? "").trim(); } } diff --git a/packages/cli/src/lifecycle/check-updates.test.ts b/packages/cli/src/lifecycle/check-updates.test.ts index 9cdc775..4549df5 100644 --- a/packages/cli/src/lifecycle/check-updates.test.ts +++ b/packages/cli/src/lifecycle/check-updates.test.ts @@ -199,4 +199,61 @@ describe("checkForUpdates", () => { const result = await checkForUpdates(cwd); expect(result.status).toBe("skipped-no-manifest"); }); + + it("compares --registry local checkout instead of public tags", async () => { + const cwd = await mkdtemp(path.join(tmpdir(), "ak-check-consumer-")); + const kit = await mkdtemp(path.join(tmpdir(), "ak-check-kit-")); + temps.push(cwd, kit); + await writeManifest(cwd, "5.0.0", { + url: "https://github.com/agent-kit-startup/agent-kit", + ref: "main", + }); + await mkdir(path.join(kit, "registry"), { recursive: true }); + await writeFile(path.join(kit, "registry", "registry.json"), "{}\n", "utf8"); + await mkdir(path.join(kit, "packages", "cli"), { recursive: true }); + await writeFile( + path.join(kit, "packages", "cli", "package.json"), + JSON.stringify({ version: "5.1.0" }), + "utf8", + ); + + const result = await checkForUpdates(cwd, { registryPath: kit }); + expect(result.status).toBe("update-available"); + expect(result.installedVersion).toBe("5.0.0"); + expect(result.latestVersion).toBe("5.1.0"); + expect(result.registryUrl).toBe(path.resolve(kit)); + expect(result.registryRef).toBe("local"); + expect(result.message).not.toMatch(/public/i); + expect(result.applyRecommended).toBe(false); + }); + + it("does not report public up-to-date when local --registry L0 drifted", async () => { + const cwd = await mkdtemp(path.join(tmpdir(), "ak-check-drift-c-")); + const kit = await mkdtemp(path.join(tmpdir(), "ak-check-drift-k-")); + temps.push(cwd, kit); + await writeManifest(cwd, "5.0.0", { + url: "https://github.com/agent-kit-startup/agent-kit", + ref: "main", + }); + await mkdir(path.join(kit, "registry"), { recursive: true }); + await writeFile(path.join(kit, "registry", "registry.json"), "{}\n", "utf8"); + await mkdir(path.join(kit, "packages", "cli"), { recursive: true }); + await writeFile( + path.join(kit, "packages", "cli", "package.json"), + JSON.stringify({ version: "5.0.0" }), + "utf8", + ); + const rel = path.join(".cursor", "rules", "cursor-plan-handoff.mdc"); + await mkdir(path.join(kit, ".cursor", "rules"), { recursive: true }); + await mkdir(path.join(cwd, ".cursor", "rules"), { recursive: true }); + await writeFile(path.join(kit, rel), "# kit newer\n", "utf8"); + await writeFile(path.join(cwd, rel), "# consumer older\n", "utf8"); + + const result = await checkForUpdates(cwd, { registryPath: kit }); + expect(result.status).toBe("update-available"); + expect(result.registryUrl).toBe(path.resolve(kit)); + expect(result.registryRef).toBe("local"); + expect(result.message).not.toMatch(/public/i); + expect(result.message).toMatch(/drift/i); + }); }); diff --git a/packages/cli/src/lifecycle/check-updates.ts b/packages/cli/src/lifecycle/check-updates.ts index 3067b04..c70017a 100644 --- a/packages/cli/src/lifecycle/check-updates.ts +++ b/packages/cli/src/lifecycle/check-updates.ts @@ -2,8 +2,14 @@ import { execFile } from "node:child_process"; import path from "node:path"; import { promisify } from "node:util"; import { loadAgentKitManifest } from "../manifest/index.js"; -import { DEFAULT_REGISTRY_URL, assertSafeRegistrySource } from "../registry/resolve.js"; +import type { AgentKitManifest } from "../manifest/types.js"; +import { + DEFAULT_REGISTRY_URL, + assertSafeRegistrySource, + resolveRegistryRoot, +} from "../registry/resolve.js"; import { readJson, writeJson } from "../utils/fs.js"; +import { diffAgainstRegistry, summarizeDiff } from "./diff.js"; const execFileAsync = promisify(execFile); @@ -194,14 +200,142 @@ export interface CheckForUpdatesOptions { stamp?: boolean; /** Override public registry URL for tag lookup (HTTPS only). */ publicRegistryUrl?: string; + /** Local kit checkout (`--registry`). Compared instead of public tags. */ + registryPath?: string; /** Injected latest version (tests). */ latestVersion?: string | null; /** Injected now (tests). */ now?: Date; } +async function readLocalKitVersion(registryRoot: string): Promise { + for (const rel of ["packages/cli/package.json", "package.json"] as const) { + const data = await readJson<{ version?: unknown }>(path.join(registryRoot, rel)); + if (data && typeof data.version === "string" && data.version.length > 0) { + return data.version; + } + } + return null; +} + +async function checkAgainstLocalRegistry( + cwd: string, + manifest: AgentKitManifest, + options: CheckForUpdatesOptions, +): Promise { + const registryPath = options.registryPath; + if (!registryPath) { + throw new Error("checkAgainstLocalRegistry requires registryPath"); + } + + let resolved: Awaited>; + try { + resolved = await resolveRegistryRoot({ cwd, registryPath }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { + status: "error", + installedVersion: manifest.version, + latestVersion: null, + registryUrl: path.resolve(registryPath), + registryRef: "local", + applyRecommended: false, + message: `Failed to resolve --registry: ${msg}`, + }; + } + + const registryUrl = resolved.root; + const registryRef = "local"; + + try { + let latest: string | null; + if (options.latestVersion !== undefined) { + latest = options.latestVersion; + } else { + const raw = await readLocalKitVersion(resolved.root); + latest = raw ? normalizeSemver(raw) : null; + } + + const summary = summarizeDiff(await diffAgainstRegistry(resolved.root, cwd, manifest)); + const hasFileDrift = summary.drift > 0 || summary["missing-local"] > 0; + + const installed = normalizeSemver(manifest.version); + if (!installed) { + return { + status: "error", + installedVersion: manifest.version, + latestVersion: latest, + registryUrl, + registryRef, + applyRecommended: false, + message: `Installed version "${manifest.version}" is not valid semver.`, + }; + } + + if (!latest) { + return { + status: "error", + installedVersion: installed, + latestVersion: null, + registryUrl, + registryRef, + applyRecommended: false, + message: + "No semver found in the local --registry checkout (packages/cli/package.json or package.json).", + }; + } + + const cmp = compareSemver(installed, latest); + if (hasFileDrift) { + return { + status: "update-available", + installedVersion: installed, + latestVersion: latest, + registryUrl, + registryRef, + applyRecommended: false, + message: `Local registry has drift vs this install (drift=${summary.drift}, missing-local=${summary["missing-local"]}; source v${latest}). Run /update --registry (Ask confirm) to apply; never silent.`, + }; + } + if (cmp === 0) { + return { + status: "up-to-date", + installedVersion: installed, + latestVersion: latest, + registryUrl, + registryRef, + applyRecommended: false, + message: `Installed v${installed} matches local registry v${latest}.`, + }; + } + if (cmp < 0) { + return { + status: "update-available", + installedVersion: installed, + latestVersion: latest, + registryUrl, + registryRef, + applyRecommended: false, + message: `Update available from local registry: v${installed} → v${latest}. Run /update --registry (Ask confirm) to apply; never silent.`, + }; + } + return { + status: "ahead-of-public", + installedVersion: installed, + latestVersion: latest, + registryUrl, + registryRef, + applyRecommended: false, + message: `Installed v${installed} is ahead of local registry v${latest}.`, + }; + } finally { + await resolved.unlock?.(); + } +} + /** - * Check-only: compare installed manifest version to latest public tag. + * Check-only: compare installed manifest version to latest public tag, + * or to a local `--registry` checkout when `registryPath` is set. * Never writes L0 / packs / skills. applyRecommended is always false. */ export async function checkForUpdates( @@ -221,7 +355,11 @@ export async function checkForUpdates( }; } - const registryUrl = manifest.registry?.url ?? null; + if (options.registryPath) { + return checkAgainstLocalRegistry(cwd, manifest, options); + } + + const registryUrl = options.publicRegistryUrl ?? manifest.registry?.url ?? null; const registryRef = manifest.registry?.ref ?? null; if (isFactoryOrDevRegistry(registryUrl, registryRef)) { diff --git a/packages/cli/src/lifecycle/cursor-update-awareness.test.ts b/packages/cli/src/lifecycle/cursor-update-awareness.test.ts index cd4ea5f..5a3143e 100644 --- a/packages/cli/src/lifecycle/cursor-update-awareness.test.ts +++ b/packages/cli/src/lifecycle/cursor-update-awareness.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -183,6 +183,27 @@ describe("checkCursorUpdateAwareness", () => { writeInventory(cwd, "last refreshed **2026-07-19**\n"); const result = await checkCursorUpdateAwareness(cwd, { respectPrefs: true, offline: true }); expect(result.status).toBe("skipped-disabled"); + expect(result.inventoryRoot).toBe(path.resolve(cwd)); + }); + + it("skips when respectPrefs and within interval", async () => { + const cwd = mkdtempSync(path.join(tmpdir(), "cursor-awareness-")); + writeInventory(cwd, "last refreshed **2026-07-19**\n"); + mkdirSync(path.join(cwd, ".cursor", "context"), { recursive: true }); + writeFileSync( + path.join(cwd, ".cursor", "context", "config.json"), + JSON.stringify({ + cursorUpdateCheck: { + enabled: true, + intervalDays: 7, + lastCheckedAt: new Date().toISOString(), + }, + }), + "utf8", + ); + const result = await checkCursorUpdateAwareness(cwd, { respectPrefs: true, offline: true }); + expect(result.status).toBe("skipped-interval"); + expect(result.inventoryRoot).toBe(path.resolve(cwd)); }); it("walks up from nested cwd to find inventory", async () => { @@ -191,6 +212,7 @@ describe("checkCursorUpdateAwareness", () => { root, "Living audit; last refreshed **2026-07-19**.\n\n| ID | Status | Action |\n|----|--------|--------|\n| A1 | ✅ Done | Fix |\n", ); + mkdirSync(path.join(root, ".git"), { recursive: true }); const nested = path.join(root, "packages", "cli"); mkdirSync(nested, { recursive: true }); @@ -198,6 +220,66 @@ describe("checkCursorUpdateAwareness", () => { const result = await checkCursorUpdateAwareness(nested, { offline: true }); expect(result.status).toBe("current"); expect(result.message).not.toMatch(/Missing inventory/); + expect(result.inventoryRoot).toBe(path.resolve(root)); + expect(result.inventoryRoot).not.toBe(nested); + expect(result.inventoryPath).toBe(path.join("docs", "cursor-native-audit.md")); + expect(result.featuresPath).toBe(path.join("docs", "cursor-3-features.md")); + }); + + it("stamps prefs at resolved inventory root, not nested cwd", async () => { + const root = mkdtempSync(path.join(tmpdir(), "cursor-awareness-stamp-root-")); + writeInventory( + root, + "Living audit; last refreshed **2026-07-19**.\n\n| ID | Status | Action |\n|----|--------|--------|\n| A1 | ✅ Done | Fix |\n", + ); + mkdirSync(path.join(root, ".git"), { recursive: true }); + mkdirSync(path.join(root, ".cursor", "context"), { recursive: true }); + writeFileSync( + path.join(root, ".cursor", "context", "config.json"), + JSON.stringify({ + cursorUpdateCheck: { + enabled: true, + lastSeenCursorVersion: "3.0", + }, + }), + "utf8", + ); + const nested = path.join(root, "packages", "cli"); + mkdirSync(nested, { recursive: true }); + + const result = await checkCursorUpdateAwareness(nested, { + stamp: true, + changelogBody: "3.6 May 29 · Changelog Cursor 3.6", + }); + expect(result.inventoryRoot).toBe(path.resolve(root)); + expect(result.gaps.some((g) => g.id === "changelog-ahead")).toBe(true); + + const cfg = JSON.parse( + readFileSync(path.join(root, ".cursor", "context", "config.json"), "utf8"), + ) as { cursorUpdateCheck: { lastSeenCursorVersion: string | null } }; + expect(cfg.cursorUpdateCheck.lastSeenCursorVersion).toBe("3.6"); + expect(existsSync(path.join(nested, ".cursor"))).toBe(false); + }); + + it("does not inherit a parent kit inventory across a nested .git boundary", async () => { + const kitroot = mkdtempSync(path.join(tmpdir(), "cursor-awareness-nested-kit-")); + writeInventory( + kitroot, + "Living audit; last refreshed **2026-07-19**.\n\n| ID | Status | Action |\n|----|--------|--------|\n| Z9 | Open | Foreign kit action that the consumer does not own |\n", + ); + mkdirSync(path.join(kitroot, ".git"), { recursive: true }); + const nestedCwd = path.join(kitroot, "consumer-app", "src"); + mkdirSync(nestedCwd, { recursive: true }); + mkdirSync(path.join(kitroot, "consumer-app", ".git"), { recursive: true }); + + expect(await resolveInventoryRoot(nestedCwd)).toBeNull(); + const result = await checkCursorUpdateAwareness(nestedCwd, { offline: true }); + expect(result.status).toBe("error"); + expect(result.inventoryRoot).toBeNull(); + expect(result.openActionIds).toEqual([]); + expect(result.gaps.some((g) => g.id === "open-action-Z9")).toBe(false); + expect(result.message).toMatch(/Missing inventory/); + expect(result.message).toMatch(/--cwd/); }); it("errors with --cwd hint when no inventory in ancestors", async () => { @@ -205,7 +287,33 @@ describe("checkCursorUpdateAwareness", () => { expect(await resolveInventoryRoot(cwd)).toBeNull(); const result = await checkCursorUpdateAwareness(cwd, { offline: true }); expect(result.status).toBe("error"); + expect(result.inventoryRoot).toBeNull(); expect(result.message).toMatch(/Missing inventory/); expect(result.message).toMatch(/--cwd/); }); + + it("does not stamp under caller cwd when inventory root is missing", async () => { + const cwd = mkdtempSync(path.join(tmpdir(), "cursor-awareness-nostamp-")); + const result = await checkCursorUpdateAwareness(cwd, { stamp: true, offline: true }); + expect(result.status).toBe("error"); + expect(result.inventoryRoot).toBeNull(); + expect(existsSync(path.join(cwd, ".cursor", "context", "config.json"))).toBe(false); + }); + + it("does not stamp under caller cwd across a nested .git boundary", async () => { + const kitroot = mkdtempSync(path.join(tmpdir(), "cursor-awareness-nostamp-git-")); + writeInventory( + kitroot, + "Living audit; last refreshed **2026-07-19**.\n\n| ID | Status | Action |\n|----|--------|--------|\n| Z9 | Open | Foreign kit action that the consumer does not own |\n", + ); + mkdirSync(path.join(kitroot, ".git"), { recursive: true }); + const nestedCwd = path.join(kitroot, "consumer-app", "src"); + mkdirSync(nestedCwd, { recursive: true }); + mkdirSync(path.join(kitroot, "consumer-app", ".git"), { recursive: true }); + + const result = await checkCursorUpdateAwareness(nestedCwd, { stamp: true, offline: true }); + expect(result.status).toBe("error"); + expect(result.inventoryRoot).toBeNull(); + expect(existsSync(path.join(nestedCwd, ".cursor", "context", "config.json"))).toBe(false); + }); }); diff --git a/packages/cli/src/lifecycle/cursor-update-awareness.ts b/packages/cli/src/lifecycle/cursor-update-awareness.ts index 2a5c064..812be6e 100644 --- a/packages/cli/src/lifecycle/cursor-update-awareness.ts +++ b/packages/cli/src/lifecycle/cursor-update-awareness.ts @@ -1,6 +1,6 @@ import { access, readFile } from "node:fs/promises"; import path from "node:path"; -import { readJson, writeJson } from "../utils/fs.js"; +import { fileExists, readJson, writeJson } from "../utils/fs.js"; /** Official Cursor product changelog (detection source SoT). */ export const DEFAULT_CURSOR_CHANGELOG_URL = "https://cursor.com/changelog"; @@ -19,7 +19,9 @@ const FEATURES_REL = path.join("docs", "cursor-3-features.md"); /** * Walk up from cwd until docs/cursor-native-audit.md exists. - * Returns the directory that contains docs/, or null when none is found. + * Stops at the first directory that contains `.git` (file or directory) if + * that directory has no inventory, so a nested checkout does not inherit a + * parent kit inventory. Returns the directory that contains docs/, or null. */ export async function resolveInventoryRoot(cwd: string): Promise { let dir = path.resolve(cwd); @@ -28,8 +30,9 @@ export async function resolveInventoryRoot(cwd: string): Promise await access(path.join(dir, INVENTORY_REL)); return dir; } catch { - // keep walking + // keep walking unless this directory is a git root } + if (await fileExists(path.join(dir, ".git"))) break; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -74,6 +77,8 @@ export interface CursorAwarenessResult { applyRecommended: false; /** Always false: never auto Field Reports. */ fieldReportRecommended: false; + /** Absolute directory that contains docs/, or null when walk-up fails. */ + inventoryRoot: string | null; inventoryPath: string; featuresPath: string; changelogUrl: string | null; @@ -325,13 +330,17 @@ export async function checkCursorUpdateAwareness( cwd: string, options: CursorAwarenessOptions = {}, ): Promise { - const prefs = readCursorUpdateCheckPrefs(await loadContextConfig(cwd)); + const inventoryRoot = await resolveInventoryRoot(cwd); + const prefs = readCursorUpdateCheckPrefs( + inventoryRoot ? await loadContextConfig(inventoryRoot) : null, + ); const changelogUrl = options.changelogUrl ?? prefs.changelogUrl; if (options.respectPrefs) { if (!prefs.enabled) { return baseResult({ status: "skipped-disabled", + inventoryRoot, inventoryPath: INVENTORY_REL, featuresPath: FEATURES_REL, changelogUrl, @@ -347,6 +356,7 @@ export async function checkCursorUpdateAwareness( if (!intervalElapsed(prefs.lastCheckedAt, prefs.intervalDays)) { return baseResult({ status: "skipped-interval", + inventoryRoot, inventoryPath: INVENTORY_REL, featuresPath: FEATURES_REL, changelogUrl, @@ -360,10 +370,10 @@ export async function checkCursorUpdateAwareness( } } - const inventoryRoot = await resolveInventoryRoot(cwd); if (!inventoryRoot) { return baseResult({ status: "error", + inventoryRoot: null, inventoryPath: INVENTORY_REL, featuresPath: FEATURES_REL, changelogUrl, @@ -385,6 +395,7 @@ export async function checkCursorUpdateAwareness( } catch { return baseResult({ status: "error", + inventoryRoot, inventoryPath: INVENTORY_REL, featuresPath: FEATURES_REL, changelogUrl, @@ -479,6 +490,7 @@ export async function checkCursorUpdateAwareness( const msg = err instanceof Error ? err.message : String(err); return baseResult({ status: "error", + inventoryRoot, inventoryPath: INVENTORY_REL, featuresPath: FEATURES_REL, changelogUrl, @@ -492,8 +504,8 @@ export async function checkCursorUpdateAwareness( } } - if (options.stamp) { - await stampCursorUpdateCheck(cwd, { + if (options.stamp && inventoryRoot) { + await stampCursorUpdateCheck(inventoryRoot, { lastSeenCursorVersion: latestCursorVersion ?? prefs.lastSeenCursorVersion, }); } @@ -506,6 +518,7 @@ export async function checkCursorUpdateAwareness( return baseResult({ status, + inventoryRoot, inventoryPath: INVENTORY_REL, featuresPath: FEATURES_REL, changelogUrl: options.offline ? null : changelogUrl, diff --git a/packages/cli/src/lifecycle/overlay-known-hashes.ts b/packages/cli/src/lifecycle/overlay-known-hashes.ts index 1ad9cbe..d47b187 100644 --- a/packages/cli/src/lifecycle/overlay-known-hashes.ts +++ b/packages/cli/src/lifecycle/overlay-known-hashes.ts @@ -9,8 +9,11 @@ export const KNOWN_SHIPPED_OVERLAY_HASHES: ReadonlySet = new Set([ "004d98f557942677264d053dcd8335f2bbcf19c5591123ec16a7a2697cb88932", "05ee6ecde4215e33ace0ee152ffbf2cd885d2bd4b032900f0f0bb001d350ef0f", "082bdbc584be3f3d8aa6e9d7b9f4e076450f3ff48f9147a1deb557e31475036b", + "09850afc201eccb3e700cb934400d49c9cbe7510d4a546ee84ee519771a8200e", "0b5fe4729c3c68654434837630d775b847c082418f25b0b2c581700fe84b797a", "1106b279558b6a20f87951102f172a680976f3e6530855d5dad083e1f89b9ba3", + "12bed56b332406477cf546d9e8360d3cab129bd82a638bea356726d00a6847a3", + "13f6a0711a2fcadcc7f1b751772448517ce4871cdef6a0269fc26dad51ebe26d", "15566765ec95dad3bc3160f20ac910e01a13471df536e6d990f4b263700784a9", "1681e6cf7b80a67cd999b94b8b6e16eb8caed0e11b32905848ba733da956e902", "17818e47e06349ee35c1d8e1cc16e8bcbbbcc89c664014e9096e1f08c8567807", @@ -20,6 +23,7 @@ export const KNOWN_SHIPPED_OVERLAY_HASHES: ReadonlySet = new Set([ "1c5c1af87951367ad04bb6fa5b1d6ebaaefbc0ee95989b624cabf406a1869907", "1ded5c7b7eefc355fc1d17feb65ec472e35ed96076e47ce95f7bff5c1b84746c", "1e58e0d4f2e8459b95a40d1b1e72c5c85a87230cbde945840a84073fe3a20422", + "2280e3ca96129ab2de8a85a301df7c2b15ffaa6204b734ed3b1f303e0055c41d", "285668d08f1b15d96286d27e29cc4a67e126e097fb8a940bdde33ccf25bd2bcd", "2acdb148b07f3a4872b896da419af796fd3fefaace7ac79cdee0aab447004a83", "2cd7fc018a384b0188737259ecf593ce25b4b75cc38b3e410063ac91745894b9", @@ -32,6 +36,7 @@ export const KNOWN_SHIPPED_OVERLAY_HASHES: ReadonlySet = new Set([ "46a1dd4a07a2190ab9fb09abc05a3962277209f8e0c28033bb41450301474c86", "48a0d55e21132a305946b195db7f6b75da78d44a1d32166fb0c69bef4a5ffbe2", "4b62a46c74e5fa6a80283f21e8e6711958fbcd577051dc73ff4050643a031475", + "4ba6e319ac79455a7d2713674f64fabbc9cd401b5d26b1c0edbd19c27a52297e", "4bf23acbc9bd4e0f8468e61f4970e182600b72d9025d29e81ddbd83cb7b1710a", "4c5167d0ebde685f266dce32c516a7c43876e0520647b93f07f2e2147e6a7b2f", "4dc4efcd1b43c643d3889fb349df8d4964d0867c3a67eb67e2ec168fbb70fc75", @@ -54,8 +59,10 @@ export const KNOWN_SHIPPED_OVERLAY_HASHES: ReadonlySet = new Set([ "6b66ed7747ea2d19cb457f5af656e3c07f9f563f7f32b3951fa4ae5f27f0ac46", "6dede0588274f5b44d833bcc696ec4776eb978ed24a20efdcc9c38caf3df1576", "71991a10972b8b956cae93c88517e31741c95e351814082df65e44f813590210", + "75ae3018a70ed838428c0e63e8b6901ac05bf1f1cf1845faa6f73c3940d22e4a", "75e591bfde61ca1ca3ca1303cc9bc487197af2f713c68f346a46e94ca9432fd5", "76ae85bbc336c6f435d9653e8f254ae49774b4ebaecf7335697115933eb8797c", + "76c4361e70a73276771e841e2c939253b4124d690a0fd25aff2a3ac636d8f4b0", // mission-kit-comms/SKILL.md "79878f0f6f59d1a0b65d94011c0cafe7b6f8e03e89f568aa638b8ba7a2c34076", "8108b202c829627c3c2df33575016ebe942df42acf4f1cc393900be3b82b402c", "819b7bd3ba9e8bf12dcd4f81a76c98e48261b34c397f339b36d36eb364d9c3f4", @@ -64,6 +71,7 @@ export const KNOWN_SHIPPED_OVERLAY_HASHES: ReadonlySet = new Set([ "8a11c80b0fbf6c4aabf60eec05b8cca7dccf5bca472787a8afa3028fd8fbedfa", "8b25659727063013e8bf6d7925f815b2630c2919b21e6fdac354362bf122bfab", "8c7edbacc74b1431fbecb04c9857c0e7a9cb1578fc6ca6367be888dbcec3c98f", + "8d8d60e929ebf717eed3c1a29ee0de7c71ae917d125eb1b220a77220597281ed", "8ffc181bb152c68a3353309bfdc87c42bdb925b98675cdba959c0cdb90b2adea", "91ce0c933e3c8ee81927b8224f944ec5ce5445098a2ad154773092e3541dbf69", "9309af07a0a99a56143386b3ddd2a35fae29378fba3f6b4c9297a5d1c02a9724", @@ -75,6 +83,7 @@ export const KNOWN_SHIPPED_OVERLAY_HASHES: ReadonlySet = new Set([ "9ccf52fe53c6fa4d731650ab4928ce50e201fa34f3e174fee70e3d29867fef2a", "9d905aca0123f3018c81c579217104f1749d8da802f3837b2407fbad8f2d803f", "9e324c52c1a9d1ceee738bccf47da92910aca9f7a5a21cc41738d9573c414830", + "9eb457654167df562481637b25ecd91f0ff3c9282ba79409089d644d3b09b926", "a013a6a476160183d6457ff6c1c51ec228bdca359b11eb15cd9b7fc177a705db", "a08d04f8efa121ad498f96873778c22630c9172c07c216839e348cd0bb7c2292", "a7cd7058bcb6fbd962c37317fa16893bc86a270d0164af5a0e6f3f026aa408ad", @@ -83,6 +92,8 @@ export const KNOWN_SHIPPED_OVERLAY_HASHES: ReadonlySet = new Set([ "b5c7a10df90d209be07159b7ff9bfa24e869538a0b6026bb5ce79e1cdd5b2528", "bd9fd1efacbd132c5dafc12cd0cf2b651a2b1fab7b2d7a9ba4a3e44128097f48", "be964ff33a40280e13ce6071fb3e30ce87c374527efbb74c25c9d4534084a634", + "c245035b539e41e822fab8c74fcde3b18c45f4b9f26f1c8583fc9e7254249698", + "c794accc6668d74a739c982c1b63985a8be4be08afe3766214ab1e08d25ea95c", "c96d522be440fbb638dbe953cebf1bb31185c431ac2e14446369cfb7d155a951", "cf2bd11891b934484171fa705f470de284e3874402e1e487750735dbc7692857", "d0e3bf82efbe32c09761689802ffea7838046dbdd4803b88ead5ec3dfc7efb7e", @@ -112,9 +123,13 @@ export const KNOWN_SHIPPED_OVERLAY_HASHES: ReadonlySet = new Set([ "f061432a2606049717c0cbf098ef07b1da4df2b136c4cb9cdaa7ea44c876bcd6", "f061d36a1180293519fbb560bbf659c621bd90885e995fca6eaf2eda9e418a50", "f28731a82f4b394a5765fdefe2d1b667810831f6dcea400ca62bc66e1d65371c", + "f43a5484e39aca4e6af86e7f8d2941773cdc1c960838ad88ae3540c49a1097df", "f46c108457e4fe12953dca75b409e06fca9cb264baffabe0c0d04c89af471e56", "f6bc17efa746680a44bbc49be67ac3f924d9c306c6d937f9959c57a1d3c5b9af", "f981764422d468567b5aff31148dc659aeee22cb13dc0ecd873d737deaf07372", "fa306a0cdb0f40c817e32164cd03b946564a02b4b7f7c3f4f0b9513096584d28", "fa5cf460eb314437081f7cea30dc8041c0bd6fc3f560a74bc2d7be1bf07384b0", + "fc39ec6d8a22498697f968ffc0fe5f717bed97afd68f922c76a80ed1f10d6579", + "a8756742197c3a2e1a6d64f4bde2a88db48abb66a0f449d625cd2e1c7b8d0cb4", + "6a5f8795a4a26b419f167e249576b798781c95308dc1ea50958351de713231d4", ]); diff --git a/packages/cli/src/lifecycle/refresh-known-hashes.test.ts b/packages/cli/src/lifecycle/refresh-known-hashes.test.ts new file mode 100644 index 0000000..d37e19f --- /dev/null +++ b/packages/cli/src/lifecycle/refresh-known-hashes.test.ts @@ -0,0 +1,125 @@ +import { 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 { KNOWN_SHIPPED_OVERLAY_HASHES } from "./overlay-known-hashes.js"; +import { contentHash } from "./overlay.js"; +import { + appendHashes, + collectExpectedHashes, + parseKnownHashes, + refreshKnownHashes, +} from "./refresh-known-hashes.js"; + +const kitRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../.."); + +const hashA = contentHash("body a\n"); +const hashB = contentHash("body b\n"); +const hashOutOfOrder = contentHash("out of order body\n"); +const hashNew = contentHash("brand new body\n"); + +const FIXTURE = `/** + * Fixture mirror of overlay-known-hashes.ts. + */ +export const KNOWN_SHIPPED_OVERLAY_HASHES: ReadonlySet = new Set([ + "${hashA}", + "${hashOutOfOrder}", // intentionally out-of-order entry with inline comment + "${hashB}", +]); +`; + +async function makeFixture(): Promise { + const dir = await mkdtemp(path.join(tmpdir(), "agent-kit-known-hashes-")); + const file = path.join(dir, "overlay-known-hashes.ts"); + await writeFile(file, FIXTURE, "utf8"); + return file; +} + +describe("refresh-known-hashes helper", () => { + it("appendHashes is purely additive: existing entries, order, and comments survive", () => { + const next = appendHashes(FIXTURE, [hashNew]); + // Everything before the closing `]);` is byte-identical. + const marker = FIXTURE.lastIndexOf("]);"); + expect(next.startsWith(FIXTURE.slice(0, marker))).toBe(true); + expect(next.endsWith(FIXTURE.slice(marker))).toBe(true); + // Inline comment on the out-of-order entry is intact. + expect(next).toContain("// intentionally out-of-order entry with inline comment"); + // Original relative order preserved (out-of-order entry stays where it was). + expect(next.indexOf(hashA)).toBeLessThan(next.indexOf(hashOutOfOrder)); + expect(next.indexOf(hashOutOfOrder)).toBeLessThan(next.indexOf(hashB)); + // New hash lands after all existing entries. + expect(next.indexOf(hashNew)).toBeGreaterThan(next.indexOf(hashB)); + expect(parseKnownHashes(next)).toEqual(new Set([hashA, hashOutOfOrder, hashB, hashNew])); + }); + + it("refresh appends a missing body hash and keeps every existing entry", async () => { + const file = await makeFixture(); + const expected = [ + { source: ".cursor/commands/a.md", hash: hashA }, + { source: ".cursor/commands/new.md", hash: hashNew }, + ]; + const result = await refreshKnownHashes({ kitRoot, knownHashesPath: file, expected }); + expect(result.wrote).toBe(true); + expect(result.missing).toEqual([{ source: ".cursor/commands/new.md", hash: hashNew }]); + const written = await readFile(file, "utf8"); + expect(parseKnownHashes(written)).toEqual(new Set([hashA, hashOutOfOrder, hashB, hashNew])); + // Append-only: the original body up to the closing marker is untouched. + expect(written.startsWith(FIXTURE.slice(0, FIXTURE.lastIndexOf("]);")))).toBe(true); + }); + + it("deduplicates identical bodies: one hash appended for two sources", async () => { + const file = await makeFixture(); + const expected = [ + { source: ".cursor/commands/x.md", hash: hashNew }, + { source: ".cursor/agents/y.md", hash: hashNew }, + ]; + const result = await refreshKnownHashes({ kitRoot, knownHashesPath: file, expected }); + expect(result.wrote).toBe(true); + expect(result.missing).toHaveLength(2); + const written = await readFile(file, "utf8"); + const occurrences = written.split(hashNew).length - 1; + expect(occurrences).toBe(1); + }); + + it("check mode reports missing hashes without writing", async () => { + const file = await makeFixture(); + const expected = [{ source: ".cursor/commands/new.md", hash: hashNew }]; + const result = await refreshKnownHashes({ + kitRoot, + knownHashesPath: file, + expected, + check: true, + }); + expect(result.wrote).toBe(false); + expect(result.missing).toEqual(expected); + expect(await readFile(file, "utf8")).toBe(FIXTURE); + }); + + it("is idempotent: second run is a no-op", async () => { + const file = await makeFixture(); + const expected = [{ source: ".cursor/commands/new.md", hash: hashNew }]; + const first = await refreshKnownHashes({ kitRoot, knownHashesPath: file, expected }); + expect(first.wrote).toBe(true); + const afterFirst = await readFile(file, "utf8"); + const second = await refreshKnownHashes({ kitRoot, knownHashesPath: file, expected }); + expect(second.wrote).toBe(false); + expect(second.missing).toEqual([]); + expect(await readFile(file, "utf8")).toBe(afterFirst); + }); + + it("refuses to edit a file without the closing `]);` marker", () => { + expect(() => appendHashes("export const X = 1;\n", [hashNew])).toThrow(/refusing to edit/); + }); + + it("enumerates the same sources as the coverage tests and matches the shipped set", async () => { + const expected = await collectExpectedHashes(kitRoot); + expect(expected.length).toBeGreaterThan(0); + // L0 overlay artifacts and registry skills are both represented. + expect(expected.some((e) => e.source.startsWith(".cursor/commands/"))).toBe(true); + expect(expected.some((e) => e.source.endsWith("/SKILL.md"))).toBe(true); + for (const e of expected) { + expect(KNOWN_SHIPPED_OVERLAY_HASHES.has(e.hash), `missing ${e.source}`).toBe(true); + } + }); +}); diff --git a/packages/cli/src/lifecycle/refresh-known-hashes.ts b/packages/cli/src/lifecycle/refresh-known-hashes.ts new file mode 100644 index 0000000..6aeb6de --- /dev/null +++ b/packages/cli/src/lifecycle/refresh-known-hashes.ts @@ -0,0 +1,158 @@ +/** + * Append-only refresher for `KNOWN_SHIPPED_OVERLAY_HASHES` + * (`overlay-known-hashes.ts`). + * + * Enumerates exactly the same sources as the coverage tests in + * `overlay.test.ts` ("KNOWN_SHIPPED_OVERLAY_HASHES coverage"): + * 1. L0 artifacts whose target is a consumer overlay path + * (`.cursor/agents|skills|commands`), body read from the kit root. + * 2. Every `registry/registry.json` skill (core + community) `/SKILL.md`. + * + * Missing hashes are APPENDED before the closing `]);` — existing entries are + * never removed or reordered, and inline comments are preserved. Idempotent: + * a second run is a no-op. + * + * Usage (from packages/cli): + * npm run overlay:hashes # append missing hashes + * npm run overlay:hashes:check # list missing hashes, exit 1, no writes + * Or from the repo root: `pnpm overlay:hashes` / `pnpm overlay:hashes:check`. + */ +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { L0_ARTIFACTS } from "./l0.js"; +import { contentHash, isConsumerOverlayPath } from "./overlay.js"; + +/** Kit-root-relative path of the file this helper maintains. */ +export const KNOWN_HASHES_REL = "packages/cli/src/lifecycle/overlay-known-hashes.ts"; + +export interface ExpectedHash { + /** Kit-root-relative source path the hash was computed from. */ + source: string; + /** sha256 hex of the utf8 body (contentHash). */ + hash: string; +} + +/** + * Enumerate every body the coverage tests check and compute its contentHash. + * Mirrors overlay.test.ts "KNOWN_SHIPPED_OVERLAY_HASHES coverage" exactly. + */ +export async function collectExpectedHashes(kitRoot: string): Promise { + const out: ExpectedHash[] = []; + for (const artifact of L0_ARTIFACTS.filter((a) => isConsumerOverlayPath(a.target))) { + const body = await readFile(path.join(kitRoot, artifact.source), "utf8"); + out.push({ source: artifact.source, hash: contentHash(body) }); + } + const reg = JSON.parse(await readFile(path.join(kitRoot, "registry/registry.json"), "utf8")) as { + skills?: { + core?: Array<{ path: string }>; + community?: Array<{ path: string }>; + }; + }; + for (const skill of [...(reg.skills?.core || []), ...(reg.skills?.community || [])]) { + const source = `${skill.path}/SKILL.md`; + const body = await readFile(path.join(kitRoot, source), "utf8"); + out.push({ source, hash: contentHash(body) }); + } + return out; +} + +const HASH_ENTRY_RE = /"([0-9a-f]{64})"/g; + +/** Extract the set of 64-hex hashes currently listed in the file body. */ +export function parseKnownHashes(fileContent: string): Set { + const found = new Set(); + for (const match of fileContent.matchAll(HASH_ENTRY_RE)) { + const hash = match[1]; + if (hash) { + found.add(hash); + } + } + return found; +} + +/** + * Return the file content with `hashes` appended (sorted among themselves) + * immediately before the closing `]);`. Purely additive by construction: + * everything before and after the insertion point is byte-identical. + */ +export function appendHashes(fileContent: string, hashes: readonly string[]): string { + const marker = fileContent.lastIndexOf("]);"); + if (marker === -1) { + throw new Error(`closing \`]);\` not found in ${KNOWN_HASHES_REL}; refusing to edit`); + } + const lines = [...hashes] + .sort() + .map((h) => ` "${h}",\n`) + .join(""); + return fileContent.slice(0, marker) + lines + fileContent.slice(marker); +} + +export interface RefreshResult { + /** Expected entries whose hash was not in the file (empty = up to date). */ + missing: ExpectedHash[]; + /** True when the file was rewritten (never in check mode). */ + wrote: boolean; +} + +export async function refreshKnownHashes(opts: { + kitRoot: string; + /** Override target file (tests); defaults to KNOWN_HASHES_REL under kitRoot. */ + knownHashesPath?: string; + /** No-write mode: report missing hashes only. */ + check?: boolean; + /** Override enumeration (tests); defaults to collectExpectedHashes(kitRoot). */ + expected?: ExpectedHash[]; +}): Promise { + const filePath = opts.knownHashesPath ?? path.join(opts.kitRoot, KNOWN_HASHES_REL); + const expected = opts.expected ?? (await collectExpectedHashes(opts.kitRoot)); + const original = await readFile(filePath, "utf8"); + const known = parseKnownHashes(original); + const missing = expected.filter((e) => !known.has(e.hash)); + if (missing.length === 0 || opts.check) { + return { missing, wrote: false }; + } + const uniqueNewHashes = [...new Set(missing.map((m) => m.hash))]; + const next = appendHashes(original, uniqueNewHashes); + // Append-only invariant: every previously known hash must survive. + const nextKnown = parseKnownHashes(next); + for (const h of known) { + if (!nextKnown.has(h)) { + throw new Error(`append-only invariant violated: hash ${h} would be lost; aborting`); + } + } + await writeFile(filePath, next, "utf8"); + return { missing, wrote: true }; +} + +async function main(): Promise { + const check = process.argv.includes("--check"); + const kitRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../.."); + const result = await refreshKnownHashes({ kitRoot, check }); + if (result.missing.length === 0) { + console.log("overlay known-hashes: up to date (no missing hashes)"); + return; + } + for (const m of result.missing) { + console.log(`missing ${m.hash} ${m.source}`); + } + if (check) { + console.error( + `overlay known-hashes: ${result.missing.length} missing hash(es); run \`npm run overlay:hashes\` (packages/cli) or \`pnpm overlay:hashes\` (root) to append.`, + ); + process.exitCode = 1; + return; + } + console.log( + `overlay known-hashes: appended ${new Set(result.missing.map((m) => m.hash)).size} hash(es) to ${KNOWN_HASHES_REL}`, + ); +} + +const invokedDirectly = + process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href; +if (invokedDirectly) { + main().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + }); +} diff --git a/packages/cli/src/plan-loop/persona-banners.test.ts b/packages/cli/src/plan-loop/persona-banners.test.ts index 236f1ef..a829dfd 100644 --- a/packages/cli/src/plan-loop/persona-banners.test.ts +++ b/packages/cli/src/plan-loop/persona-banners.test.ts @@ -1,6 +1,8 @@ -import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, readdir, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_CLI_PERSONA_ID, @@ -10,6 +12,23 @@ import { resolveCliPersonaId, } from "./persona-banners.js"; +function findShippedPersonaRepoRoot(): string | null { + let dir = path.dirname(fileURLToPath(import.meta.url)); + for (;;) { + if ( + existsSync(path.join(dir, "registry", "personas", "core", "ghost-runner", "persona.json")) + ) { + return dir; + } + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +/** Core persona packs live under `registry/**`, which public-sync excludes (public-owned SoT). */ +const shippedPersonaRepoRoot = findShippedPersonaRepoRoot(); + async function writePersona( root: string, id: string, @@ -121,4 +140,25 @@ describe("persona-banners", () => { expect(createPersonaBannerPrinter({ id: "x" })).toBeNull(); expect(createPersonaBannerPrinter(null)).toBeNull(); }); + + it.skipIf(!shippedPersonaRepoRoot)( + "keeps shipped core cliBanners prefixes at or under 40 characters", + async () => { + const coreDir = path.join(shippedPersonaRepoRoot ?? "", "registry", "personas", "core"); + const ids = await readdir(coreDir); + expect(ids.length).toBeGreaterThan(0); + let packs = 0; + for (const id of ids) { + const personaPath = path.join(coreDir, id, "persona.json"); + if (!existsSync(personaPath)) continue; + packs += 1; + const raw = await readFile(personaPath, "utf8"); + const pack = JSON.parse(raw) as { cliBanners?: Record }; + for (const [key, value] of Object.entries(pack.cliBanners ?? {})) { + expect(value.length, `${id} ${key}`).toBeLessThanOrEqual(40); + } + } + expect(packs).toBeGreaterThan(0); + }, + ); }); diff --git a/packages/cli/src/plan-loop/run-loop.ts b/packages/cli/src/plan-loop/run-loop.ts index e0062e7..27e3986 100644 --- a/packages/cli/src/plan-loop/run-loop.ts +++ b/packages/cli/src/plan-loop/run-loop.ts @@ -2,6 +2,7 @@ import { mkdir, readFile, rm, unlink } from "node:fs/promises"; import path from "node:path"; import { fileExists } from "../utils/fs.js"; import { logger } from "../utils/logger.js"; +import { withCliProgress } from "../welcome/visual-kit.js"; import type { AgentBackend } from "./backends.js"; import { armExternalPlanReview, @@ -126,12 +127,14 @@ export async function runPlanLoop(opts: RunPlanLoopOptions): Promise { let agentExit = 0; try { - const result = await opts.backend.run({ - workspace: opts.root, - prompt: TICK_PROMPT, - model: opts.model, - logPath, - }); + const result = await withCliProgress(`tick ${tick}`, () => + opts.backend.run({ + workspace: opts.root, + prompt: TICK_PROMPT, + model: opts.model, + logPath, + }), + ); agentExit = result.exitCode; } catch (err) { logger.error(String(err)); @@ -197,7 +200,7 @@ export async function runPlanLoop(opts: RunPlanLoopOptions): Promise { if (opts.sleepSeconds > 0) { if (banners) banners.sleep(opts.sleepSeconds); - await sleep(opts.sleepSeconds * 1000); + await withCliProgress(`sleep ${opts.sleepSeconds}s`, () => sleep(opts.sleepSeconds * 1000)); } } diff --git a/packages/cli/src/registry/resolve.lock-enoent.test.ts b/packages/cli/src/registry/resolve.lock-enoent.test.ts new file mode 100644 index 0000000..a625503 --- /dev/null +++ b/packages/cli/src/registry/resolve.lock-enoent.test.ts @@ -0,0 +1,74 @@ +import { mkdtemp, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { acquireCacheLock } from "./resolve.js"; + +// Separate file: the node:fs/promises mock below is module-wide and must not +// leak into the ordinary resolve tests. +const mkdirControl = vi.hoisted(() => ({ + failFor: "", + failuresLeft: 0, + calls: [] as Array<{ target: string; recursive: boolean }>, +})); + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + mkdir: async (target: string, options?: { recursive?: boolean }) => { + mkdirControl.calls.push({ target, recursive: options?.recursive === true }); + if (target === mkdirControl.failFor && mkdirControl.failuresLeft > 0) { + mkdirControl.failuresLeft -= 1; + const err = new Error( + `ENOENT: no such file or directory, mkdir '${target}'`, + ) as NodeJS.ErrnoException; + err.code = "ENOENT"; + throw err; + } + return actual.mkdir(target, options); + }, + }; +}); + +describe("acquireCacheLock ENOENT compensator", () => { + it("backs off, re-creates the parent, and still acquires after transient ENOENT", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "ak-lock-enoent-")); + const target = path.join(dir, "cache-target"); + const lockDir = `${target}.lock`; + + mkdirControl.failFor = lockDir; + mkdirControl.failuresLeft = 2; + mkdirControl.calls = []; + + const started = Date.now(); + const unlock = await acquireCacheLock(target); + const elapsed = Date.now() - started; + + // Both injected ENOENTs were consumed and acquisition still succeeded. + expect(mkdirControl.failuresLeft).toBe(0); + const lockExists = await stat(lockDir) + .then(() => true) + .catch(() => false); + expect(lockExists).toBe(true); + + // No hot loop: each ENOENT retry waits the jittered LOCK_RETRY_MS (200ms) backoff. + expect(elapsed).toBeGreaterThanOrEqual(380); + + // Parent self-heal: initial recursive mkdir plus one per ENOENT compensation. + const parentHeals = mkdirControl.calls.filter( + (call) => call.target === path.dirname(lockDir) && call.recursive, + ); + expect(parentHeals.length).toBeGreaterThanOrEqual(3); + + // Bounded attempts: 2 failures + 1 success on the lock dir, not a spin. + const lockAttempts = mkdirControl.calls.filter((call) => call.target === lockDir); + expect(lockAttempts.length).toBe(3); + + await unlock(); + const released = await stat(lockDir) + .then(() => true) + .catch(() => false); + expect(released).toBe(false); + }); +}); diff --git a/packages/cli/src/registry/resolve.test.ts b/packages/cli/src/registry/resolve.test.ts index a7decd3..fb21e48 100644 --- a/packages/cli/src/registry/resolve.test.ts +++ b/packages/cli/src/registry/resolve.test.ts @@ -7,6 +7,7 @@ import { DEFAULT_REGISTRY_URL, acquireCacheLock, assertSafeRegistrySource, + refreshLockOwner, releaseCacheLock, resolveRegistryRoot, } from "./resolve.js"; @@ -349,4 +350,83 @@ describe("acquireCacheLock", () => { .catch(() => false); expect(gone).toBe(false); }); + + it("releaseCacheLock restores a successor's owner file instead of deleting the lock", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "ak-lock-")); + const lockDir = path.join(dir, "cache-target.lock"); + await mkdir(lockDir, { recursive: true }); + // Stale reclaim + successor republish landed between the prior holder's + // ownership read and its removal step: the release must claim-and-verify, + // then hand the owner file back untouched. + await writeFile( + path.join(lockDir, "owner.json"), + JSON.stringify({ pid: process.pid, uuid: "successor-uuid", updatedAt: Date.now() }), + "utf8", + ); + await releaseCacheLock(lockDir, "prior-holder-uuid"); + const restored = JSON.parse(await readFile(path.join(lockDir, "owner.json"), "utf8")) as { + uuid: string; + }; + expect(restored.uuid).toBe("successor-uuid"); + await rm(lockDir, { recursive: true, force: true }); + }); + + it("refreshLockOwner heals a corrupt owner file so the holder can still release", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "ak-lock-")); + const lockDir = path.join(dir, "cache-target.lock"); + await mkdir(lockDir, { recursive: true }); + // Corrupt owner.json + fail-closed release used to strand the lock until + // stale reclaim (up to LOCK_STALE_MS); the refresh heal closes that. + await writeFile(path.join(lockDir, "owner.json"), "{corrupt", "utf8"); + await refreshLockOwner(lockDir, "holder-uuid"); + const healed = JSON.parse(await readFile(path.join(lockDir, "owner.json"), "utf8")) as { + uuid: string; + }; + expect(healed.uuid).toBe("holder-uuid"); + await releaseCacheLock(lockDir, "holder-uuid"); + const gone = await stat(lockDir) + .then(() => true) + .catch(() => false); + expect(gone).toBe(false); + }); + + it("owner refresh advances the lock dir mtime (stale reclaim measures liveness, not age)", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "ak-lock-")); + const lockDir = path.join(dir, "cache-target.lock"); + await mkdir(lockDir, { recursive: true }); + await writeFile( + path.join(lockDir, "owner.json"), + JSON.stringify({ pid: process.pid, uuid: "holder-uuid", updatedAt: Date.now() }), + "utf8", + ); + // Age the dir as if the holder acquired long ago. + const past = new Date(Date.now() - 10 * 60_000); + await utimes(lockDir, past, past); + const before = (await stat(lockDir)).mtimeMs; + await refreshLockOwner(lockDir, "holder-uuid"); + const after = (await stat(lockDir)).mtimeMs; + // tmp + rename inside the dir bumps its mtime: the LOCK_STALE_MS gate in + // tryReclaimStaleLock counts from the last heartbeat, not acquisition. + expect(after).toBeGreaterThan(before); + await releaseCacheLock(lockDir, "holder-uuid"); + }); + + it("refreshLockOwner never overwrites another holder's owner file", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "ak-lock-")); + const lockDir = path.join(dir, "cache-target.lock"); + await mkdir(lockDir, { recursive: true }); + await writeFile( + path.join(lockDir, "owner.json"), + JSON.stringify({ pid: process.pid, uuid: "other-holder", updatedAt: 1 }), + "utf8", + ); + await refreshLockOwner(lockDir, "mine"); + const untouched = JSON.parse(await readFile(path.join(lockDir, "owner.json"), "utf8")) as { + uuid: string; + updatedAt: number; + }; + expect(untouched.uuid).toBe("other-holder"); + expect(untouched.updatedAt).toBe(1); + await rm(lockDir, { recursive: true, force: true }); + }); }); diff --git a/packages/cli/src/registry/resolve.ts b/packages/cli/src/registry/resolve.ts index 8e2708b..299ce3d 100644 --- a/packages/cli/src/registry/resolve.ts +++ b/packages/cli/src/registry/resolve.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; -import { mkdir, readFile, rename, rm, rmdir, stat, writeFile } from "node:fs/promises"; +import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import path from "node:path"; import { promisify } from "node:util"; @@ -45,23 +45,51 @@ async function readLockOwner(lockDir: string): Promise { async function writeLockOwner(lockDir: string, owner: LockOwner): Promise { // Atomic publish: never leave an observable lock dir without a complete owner file. + // Per-write nonce: the uuid alone is constant for the holder's lifetime, so an + // overlapping refresh write to the same tmp path could interleave and publish + // corrupt bytes. Stray tmp files are swept by the recursive rm on release. const finalPath = lockOwnerPath(lockDir); - const tmpPath = path.join(lockDir, `owner.${owner.uuid}.tmp`); + const tmpPath = path.join(lockDir, `owner.${owner.uuid}.${randomUUID()}.tmp`); await writeFile(tmpPath, JSON.stringify(owner), "utf8"); await rename(tmpPath, finalPath); } async function refreshLockOwner(lockDir: string, uuid: string): Promise { const owner = await readLockOwner(lockDir); - if (!owner || owner.uuid !== uuid) return; - await writeLockOwner(lockDir, { ...owner, updatedAt: Date.now() }); + // Another holder's lock: never touch it. + if (owner && owner.uuid !== uuid) return; + // owner === null while we hold the lock means our owner.json is missing or + // corrupt. Without healing, the fail-closed release could never remove the + // lock and peers would stall until stale reclaim (up to LOCK_STALE_MS). + await writeLockOwner(lockDir, { pid: process.pid, uuid, updatedAt: Date.now() }); } async function releaseCacheLock(lockDir: string, uuid: string): Promise { - const owner = await readLockOwner(lockDir); - // Fail closed: null/unreadable/mismatched owner is not ours (avoids deleting a - // successor's lock during the mkdir → owner publish window). - if (!owner || owner.uuid !== uuid) { + const claimPath = path.join(lockDir, `releasing.${uuid}`); + try { + // Atomically claim the owner file (rename) instead of check-then-`rm -rf`: + // a bare read → rm window let a stale-reclaim + successor republish land in + // between, deleting the successor's fresh lock. The rename also bumps the + // lock dir mtime, which keeps tryReclaimStaleLock away for LOCK_STALE_MS + // while we verify and remove. + await rename(lockOwnerPath(lockDir), claimPath); + } catch { + // No owner file (or dir gone): fail closed, nothing of ours to release. + return; + } + let claimed: LockOwner | null = null; + try { + const raw = await readFile(claimPath, "utf8"); + const parsed = JSON.parse(raw) as LockOwner; + if (typeof parsed.pid === "number" && typeof parsed.uuid === "string") { + claimed = parsed; + } + } catch {} + if (!claimed || claimed.uuid !== uuid) { + // We grabbed a successor's (or corrupt) owner file — put it back and abort. + try { + await rename(claimPath, lockOwnerPath(lockDir)); + } catch {} return; } try { @@ -69,6 +97,15 @@ async function releaseCacheLock(lockDir: string, uuid: string): Promise { } catch {} } +/** + * Stale-reclaim mtime semantics (intentional, pinned by test): every owner + * refresh publishes via tmp + rename inside the lock dir, which bumps the lock + * dir's mtime. The `mtimeMs > LOCK_STALE_MS` gate below therefore measures + * time since the last heartbeat (liveness), not time since acquisition — a + * dead owner becomes reclaimable LOCK_STALE_MS after its last refresh, while a + * long-running live install stays protected by refreshes (and by the + * isProcessAlive + ownerFresh short-circuit). + */ async function tryReclaimStaleLock(lockDir: string): Promise { let owner: LockOwner | null; let lockStat: Awaited>; @@ -105,12 +142,22 @@ async function acquireCacheLock(cacheDir: string): Promise<() => Promise> await mkdir(path.dirname(lockDir), { recursive: true }); + let lastErrorCode: string | undefined; while (Date.now() < deadline) { try { await mkdir(lockDir, { recursive: false }); await writeLockOwner(lockDir, owner); + // Serialize refreshes: a slow write outliving the interval must not + // overlap the next one (paired with the per-write tmp nonce). + let refreshing = false; const refreshInterval = setInterval(() => { - refreshLockOwner(lockDir, uuid).catch(() => {}); + if (refreshing) return; + refreshing = true; + refreshLockOwner(lockDir, uuid) + .catch(() => {}) + .finally(() => { + refreshing = false; + }); }, LOCK_REFRESH_MS); return async () => { clearInterval(refreshInterval); @@ -118,6 +165,7 @@ async function acquireCacheLock(cacheDir: string): Promise<() => Promise> }; } catch (err) { const code = (err as NodeJS.ErrnoException).code; + lastErrorCode = code; if (code === "EEXIST") { if (await tryReclaimStaleLock(lockDir)) { continue; @@ -125,17 +173,28 @@ async function acquireCacheLock(cacheDir: string): Promise<() => Promise> await new Promise((r) => setTimeout(r, LOCK_RETRY_MS + Math.random() * 100)); continue; } - // Lock dir vanished between mkdir and owner publish (peer fail-closed race); retry. + // Lock dir (or its parent) vanished between mkdir and owner publish — + // peer fail-closed race or concurrent cache clear. Self-heal the parent + // and back off like EEXIST: a persistent ENOENT must not hot-loop + // (~3.4k syscalls/sec measured without the delay). if (code === "ENOENT") { + await mkdir(path.dirname(lockDir), { recursive: true }); + await new Promise((r) => setTimeout(r, LOCK_RETRY_MS + Math.random() * 100)); continue; } throw err; } } - throw new Error(`Timed out waiting for cache lock on ${cacheDir}. Another install may be stuck.`); + throw new Error( + lastErrorCode === "ENOENT" + ? `Timed out acquiring cache lock on ${cacheDir}: the lock parent directory kept vanishing (concurrent cache clear?).` + : `Timed out waiting for cache lock on ${cacheDir}. Another install may be stuck.`, + ); } -export { acquireCacheLock, releaseCacheLock }; +// refreshLockOwner is exported for tests only (like releaseCacheLock); none of +// these are re-exported from a package entry point. +export { acquireCacheLock, refreshLockOwner, releaseCacheLock }; export const DEFAULT_REGISTRY_URL = "https://github.com/agent-kit-startup/agent-kit"; export const DEFAULT_REGISTRY_REF = "main"; diff --git a/packages/cli/src/welcome/help-groups.ts b/packages/cli/src/welcome/help-groups.ts index 71f9700..b4776de 100644 --- a/packages/cli/src/welcome/help-groups.ts +++ b/packages/cli/src/welcome/help-groups.ts @@ -6,6 +6,7 @@ import type { ArgsDef, CommandDef } from "citty"; import { bold, gray, underline } from "kolorist"; import { KIT_VERSION } from "../lifecycle/version.js"; import { shouldUseWelcomeColor } from "./screen.js"; +import { SPACE_MARKS, shouldUseVisualMotion, tipAt } from "./visual-kit.js"; export type HelpGroupId = "setup" | "mission" | "dashboard" | "integrity" | "other"; @@ -103,11 +104,14 @@ export async function renderGroupedRootHelp( lines.push(""); } + const tipMark = shouldUseVisualMotion() ? SPACE_MARKS.star : SPACE_MARKS.tick; + const tip = tipAt([...version].reduce((n, c) => n + c.charCodeAt(0), 0)); lines.push( g(`Use \`${name} --help\` for more information about a command.`), g( "Chat-only HITL (start-project, backlog, git-staging/prod, run-plan-all) is not a CLI surface.", ), + g(`${tipMark} ${tip}`), "", ); return lines.join("\n"); diff --git a/packages/cli/src/welcome/screen.test.ts b/packages/cli/src/welcome/screen.test.ts index b766a6c..079dc0d 100644 --- a/packages/cli/src/welcome/screen.test.ts +++ b/packages/cli/src/welcome/screen.test.ts @@ -79,6 +79,9 @@ describe("renderWelcomeScreen", () => { expect(out).toContain("agent-kit dashboard"); expect(out).toContain("HITL"); expect(out.includes("\u001b")).toBe(false); + expect(out).toMatch( + /Try agent-kit doctor|HITL gates stay|never promotes|Mission Control is the dashboard|NO_COLOR and CI|groups SETUP/, + ); }); it("includes helmet ASCII frame lines", () => { @@ -128,6 +131,9 @@ describe("renderGroupedRootHelp", () => { expect(text).toContain("INTEGRITY"); expect(text).toContain("init"); expect(text).toContain("Chat-only HITL"); + expect(text).toMatch( + /Try agent-kit doctor|HITL gates stay|never promotes|Mission Control is the dashboard|NO_COLOR and CI|groups SETUP/, + ); for (const g of CLI_HELP_GROUPS) { expect(g.commands.length).toBeGreaterThan(0); } diff --git a/packages/cli/src/welcome/screen.ts b/packages/cli/src/welcome/screen.ts index d2b1ed9..bb2571d 100644 --- a/packages/cli/src/welcome/screen.ts +++ b/packages/cli/src/welcome/screen.ts @@ -13,15 +13,26 @@ import { white, } from "kolorist"; import { KIT_VERSION } from "../lifecycle/version.js"; +import { + HELMET_ACCENT, + HELMET_FILL, + HELMET_OUTLINE, + LABEL_MUTED, + SPACE_MARKS, + type WelcomeRenderOptions, + shouldUseVisualMotion, + shouldUseWelcomeColor, + tipAt, +} from "./visual-kit.js"; -/** Helmet outline / primary text — MC `--text-primary` / landing logo stroke. */ -export const HELMET_OUTLINE = "#e2e8f0"; -/** Deep brand blue — landing logo gradient mid. */ -export const HELMET_FILL = "#0C8DEB"; -/** Aqua accent — landing logo gradient late stops. */ -export const HELMET_ACCENT = "#00D0E7"; -/** Muted labels — MC `--text-secondary`. */ -export const LABEL_MUTED = "#8899aa"; +export { + HELMET_ACCENT, + HELMET_FILL, + HELMET_OUTLINE, + LABEL_MUTED, + shouldUseWelcomeColor, + type WelcomeRenderOptions, +}; /** kolorist SupportLevel.TrueColor — needed so trueColor() emits when TTY probes say none (CI). */ const KOLORIST_TRUECOLOR = 3; @@ -66,30 +77,11 @@ function withKoloristColor(fn: () => T): T { } } -export interface WelcomeRenderOptions { - version?: string; - /** Force color on/off; when omitted, derive from env + TTY. */ - color?: boolean; - stdoutIsTTY?: boolean; -} - /** True when argv names a citty subcommand (non-flag token), so skip root welcome. */ export function hasCliSubcommand(rawArgs: string[] | undefined): boolean { return Boolean(rawArgs?.some((arg) => !arg.startsWith("-"))); } -/** Whether welcome / root help should emit ANSI (respects NO_COLOR / CI / non-TTY). */ -export function shouldUseWelcomeColor(opts: WelcomeRenderOptions = {}): boolean { - if (opts.color === false) return false; - if (process.env.NO_COLOR) return false; - if (process.env.NODE_DISABLE_COLORS) return false; - if (process.env.FORCE_COLOR === "0") return false; - if (process.env.CI != null && process.env.CI !== "") return false; - if (opts.color === true) return true; - const tty = opts.stdoutIsTTY ?? Boolean(process.stdout.isTTY); - return tty; -} - export function renderHelmetAscii(color: boolean): string { if (!color) return HELMET_ASCII.join("\n"); return withKoloristColor(() => @@ -136,6 +128,11 @@ export function renderWelcomeScreen(opts: WelcomeRenderOptions = {}): string { muted( "Chat HITL (start-project, git-staging/prod, run-plan-all) stays in Cursor slash commands.", ), + muted( + `${shouldUseVisualMotion(opts) ? SPACE_MARKS.star : SPACE_MARKS.tick} ${tipAt( + [...version].reduce((n, c) => n + c.charCodeAt(0), 0), + )}`, + ), ]; return `${lines.join("\n")}\n`; } diff --git a/packages/cli/src/welcome/visual-kit.test.ts b/packages/cli/src/welcome/visual-kit.test.ts new file mode 100644 index 0000000..7a8d9c2 --- /dev/null +++ b/packages/cli/src/welcome/visual-kit.test.ts @@ -0,0 +1,214 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + HELMET_ACCENT, + HELMET_FILL, + HELMET_OUTLINE, + KIT_TIPS, + SPACE_MARKS, + SPINNER_FRAMES, + TtySpinner, + renderProgressLine, + shouldUseVisualMotion, + shouldUseWelcomeColor, + spinnerFrame, + tipAt, + withCliProgress, + wrapNarrow, +} from "./visual-kit.js"; + +function clearEnv(key: string): void { + Reflect.deleteProperty(process.env, key); +} + +function setEnv(key: string, value: string | undefined): void { + if (value === undefined) clearEnv(key); + else process.env[key] = value; +} + +describe("visual-kit color and motion gates", () => { + const prev = { + NO_COLOR: process.env.NO_COLOR, + CI: process.env.CI, + FORCE_COLOR: process.env.FORCE_COLOR, + NODE_DISABLE_COLORS: process.env.NODE_DISABLE_COLORS, + AGENT_KIT_REDUCED_MOTION: process.env.AGENT_KIT_REDUCED_MOTION, + }; + + afterEach(() => { + for (const [k, v] of Object.entries(prev)) { + setEnv(k, v); + } + }); + + it("disables color and motion when NO_COLOR is set", () => { + setEnv("NO_COLOR", "1"); + clearEnv("CI"); + clearEnv("FORCE_COLOR"); + clearEnv("AGENT_KIT_REDUCED_MOTION"); + expect(shouldUseWelcomeColor({ stdoutIsTTY: true, color: undefined })).toBe(false); + expect(shouldUseVisualMotion({ stdoutIsTTY: true })).toBe(false); + }); + + it("disables color in CI even on TTY", () => { + clearEnv("NO_COLOR"); + setEnv("CI", "true"); + expect(shouldUseWelcomeColor({ stdoutIsTTY: true })).toBe(false); + expect(shouldUseVisualMotion({ stdoutIsTTY: true })).toBe(false); + }); + + it("disables color when stdout is not a TTY", () => { + clearEnv("NO_COLOR"); + clearEnv("CI"); + clearEnv("FORCE_COLOR"); + expect(shouldUseWelcomeColor({ stdoutIsTTY: false })).toBe(false); + expect(shouldUseVisualMotion({ stdoutIsTTY: false })).toBe(false); + }); + + it("disables motion when AGENT_KIT_REDUCED_MOTION=1 on a color TTY", () => { + clearEnv("NO_COLOR"); + clearEnv("CI"); + clearEnv("FORCE_COLOR"); + clearEnv("NODE_DISABLE_COLORS"); + setEnv("AGENT_KIT_REDUCED_MOTION", "1"); + expect(shouldUseWelcomeColor({ stdoutIsTTY: true })).toBe(true); + expect(shouldUseVisualMotion({ stdoutIsTTY: true })).toBe(false); + }); +}); + +describe("visual-kit catalog", () => { + it("reuses HELMET_* space tokens", () => { + expect(HELMET_OUTLINE).toBe("#e2e8f0"); + expect(HELMET_FILL).toBe("#0C8DEB"); + expect(HELMET_ACCENT).toBe("#00D0E7"); + }); + + it("exposes spinner frames and small marks without a full helmet reprint", () => { + expect(SPINNER_FRAMES.length).toBeGreaterThanOrEqual(4); + expect(SPACE_MARKS.star).toBe("✦"); + expect(SPACE_MARKS.visor).toBe("( )"); + expect(Object.values(SPACE_MARKS).join("\n")).not.toContain(".-'"); + }); + + it("rotates tips without home paths or token-like secrets", () => { + expect(KIT_TIPS.length).toBeGreaterThanOrEqual(4); + const blob = KIT_TIPS.join("\n"); + expect(blob).not.toMatch(/\/Users\/|\/home\/|API_KEY|SECRET=|TOKEN=/); + expect(tipAt(0)).toBe(KIT_TIPS[0]); + expect(tipAt(KIT_TIPS.length)).toBe(KIT_TIPS[0]); + expect(spinnerFrame(0)).toBe(SPINNER_FRAMES[0]); + expect(spinnerFrame(SPINNER_FRAMES.length)).toBe(SPINNER_FRAMES[0]); + }); +}); + +describe("wrapNarrow", () => { + it("wraps to the column budget", () => { + const out = wrapNarrow("HITL gates stay in Cursor slash commands.", 12); + const lines = out.split("\n"); + expect(lines.length).toBeGreaterThan(1); + expect(lines.every((line) => line.length <= 12)).toBe(true); + }); + + it("floors width at 8 and splits overlong tokens", () => { + const out = wrapNarrow("abcdefghijk", 4); + expect(out.split("\n").every((line) => line.length <= 8)).toBe(true); + expect(out).toContain("abcdefgh"); + }); +}); + +describe("renderProgressLine", () => { + it("uses a static mark when motion is off", () => { + const line = renderProgressLine({ + frameIndex: 3, + tipIndex: 0, + columns: 80, + motion: false, + color: false, + }); + expect(line.startsWith(SPACE_MARKS.tick)).toBe(true); + expect(line).toContain(KIT_TIPS[0]); + expect(line.includes("\u001b")).toBe(false); + }); + + it("uses a spinner frame when motion is on", () => { + const line = renderProgressLine({ + frameIndex: 1, + tipIndex: 1, + columns: 120, + motion: true, + color: false, + }); + expect(line.startsWith(SPINNER_FRAMES[1])).toBe(true); + expect(line).toContain(KIT_TIPS[1]); + }); +}); + +describe("TtySpinner", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("writes one static line when motion is off", () => { + const chunks: string[] = []; + const spinner = new TtySpinner({ + write: (c) => { + chunks.push(c); + }, + columns: () => 80, + motion: false, + color: false, + }); + spinner.start("install"); + spinner.stop(); + expect(chunks.join("")).toContain("install"); + expect(chunks.join("")).toContain("\n"); + expect(chunks.join("")).not.toContain("\r"); + }); + + it("advances frames on the interval when motion is on", () => { + vi.useFakeTimers(); + const chunks: string[] = []; + const spinner = new TtySpinner({ + write: (c) => { + chunks.push(c); + }, + columns: () => 80, + motion: true, + color: false, + intervalMs: 80, + now: () => Date.now(), + }); + spinner.start("sync"); + vi.advanceTimersByTime(160); + spinner.stop("done"); + const out = chunks.join(""); + expect(out).toContain("sync"); + expect(out).toContain("\r"); + expect(out).toContain("done"); + }); +}); + +describe("withCliProgress", () => { + it("does not start a spinner when motion is off", async () => { + const start = vi.spyOn(TtySpinner.prototype, "start"); + await expect(withCliProgress("install", async () => "ok", { motion: false })).resolves.toBe( + "ok", + ); + expect(start).not.toHaveBeenCalled(); + start.mockRestore(); + }); + + it("stops the spinner when fn throws", async () => { + const stop = vi.spyOn(TtySpinner.prototype, "stop"); + await expect( + withCliProgress( + "install", + async () => { + throw new Error("boom"); + }, + { motion: true, color: false, write: () => undefined }, + ), + ).rejects.toThrow("boom"); + expect(stop).toHaveBeenCalled(); + stop.mockRestore(); + }); +}); diff --git a/packages/cli/src/welcome/visual-kit.ts b/packages/cli/src/welcome/visual-kit.ts new file mode 100644 index 0000000..1de139f --- /dev/null +++ b/packages/cli/src/welcome/visual-kit.ts @@ -0,0 +1,232 @@ +/** + * Shared CLI visual-kit primitives (frames, marks, tips, motion gate). + * Welcome helmet ASCII stays in screen.ts; this module does not replace it. + */ + +import { cyan, gray, options as koloristOptions } from "kolorist"; + +/** Helmet outline / primary text: MC `--text-primary` / landing logo stroke. */ +export const HELMET_OUTLINE = "#e2e8f0"; +/** Deep brand blue: landing logo gradient mid. */ +export const HELMET_FILL = "#0C8DEB"; +/** Aqua accent: landing logo gradient late stops. */ +export const HELMET_ACCENT = "#00D0E7"; +/** Muted labels: MC `--text-secondary`. */ +export const LABEL_MUTED = "#8899aa"; + +/** Braille spinner frames (no ora). */ +export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const; + +/** Small space marks. Full helmet art remains `HELMET_ASCII` in screen.ts. */ +export const SPACE_MARKS = { + star: "✦", + tick: "·", + diamond: "✧", + visor: "( )", +} as const; + +/** Rotating Mission Kit tips. No secrets, tokens, or absolute home paths. */ +export const KIT_TIPS = [ + "Try agent-kit doctor for repository readiness.", + "HITL gates stay in Cursor slash commands.", + "/run-plan never promotes to production.", + "Mission Control is the dashboard, not the CLI name.", + "NO_COLOR and CI keep this output static.", + "agent-kit --help groups SETUP, MISSION, DASHBOARD, INTEGRITY.", +] as const; + +export interface WelcomeRenderOptions { + version?: string; + /** Force color on/off; when omitted, derive from env + TTY. */ + color?: boolean; + stdoutIsTTY?: boolean; +} + +/** Whether welcome / root help should emit ANSI (respects NO_COLOR / CI / non-TTY). */ +export function shouldUseWelcomeColor(opts: WelcomeRenderOptions = {}): boolean { + if (opts.color === false) return false; + if (process.env.NO_COLOR) return false; + if (process.env.NODE_DISABLE_COLORS) return false; + if (process.env.FORCE_COLOR === "0") return false; + if (process.env.CI != null && process.env.CI !== "") return false; + if (opts.color === true) return true; + const tty = opts.stdoutIsTTY ?? Boolean(process.stdout.isTTY); + return tty; +} + +/** Frames and spinners: color gate plus optional reduced-motion. */ +export function shouldUseVisualMotion(opts: WelcomeRenderOptions = {}): boolean { + if (process.env.AGENT_KIT_REDUCED_MOTION === "1") return false; + return shouldUseWelcomeColor(opts); +} + +export function spinnerFrame(index: number): string { + const n = SPINNER_FRAMES.length; + return SPINNER_FRAMES[((index % n) + n) % n] ?? SPINNER_FRAMES[0]; +} + +export function tipAt(index: number): string { + const n = KIT_TIPS.length; + return KIT_TIPS[((index % n) + n) % n] ?? KIT_TIPS[0]; +} + +/** Word-wrap to a column budget. Width below 8 collapses to 8. */ +export function wrapNarrow(text: string, columns: number): string { + const width = Math.max(8, Number.isFinite(columns) ? Math.floor(columns) : 80); + const words = text.trim().split(/\s+/).filter(Boolean); + if (words.length === 0) return ""; + const lines: string[] = []; + let current = ""; + for (const word of words) { + if (word.length > width) { + if (current) { + lines.push(current); + current = ""; + } + for (let i = 0; i < word.length; i += width) { + const chunk = word.slice(i, i + width); + if (i + width >= word.length) current = chunk; + else lines.push(chunk); + } + continue; + } + const next = current ? `${current} ${word}` : word; + if (next.length > width) { + lines.push(current); + current = word; + } else { + current = next; + } + } + if (current) lines.push(current); + return lines.join("\n"); +} + +export interface ProgressLineOptions { + frameIndex: number; + tipIndex: number; + columns?: number; + label?: string; + motion?: boolean; + color?: boolean; +} + +/** One progress row: spinner or static mark, optional label, rotating tip. */ +export function renderProgressLine(opts: ProgressLineOptions): string { + const motion = opts.motion ?? shouldUseVisualMotion(); + const color = opts.color ?? shouldUseWelcomeColor(); + const mark = motion ? spinnerFrame(opts.frameIndex) : SPACE_MARKS.tick; + const tip = tipAt(opts.tipIndex); + const label = opts.label?.trim() ?? ""; + const raw = label ? `${mark} ${label} ${SPACE_MARKS.star} ${tip}` : `${mark} ${tip}`; + const cols = opts.columns ?? process.stdout.columns ?? 80; + const wrapped = wrapNarrow(raw, cols); + if (!color) return wrapped; + const prevEnabled = koloristOptions.enabled; + koloristOptions.enabled = true; + try { + return wrapped + .split("\n") + .map((line, i) => (i === 0 ? cyan(line) : gray(line))) + .join("\n"); + } finally { + koloristOptions.enabled = prevEnabled; + } +} + +export interface TtySpinnerOptions { + write?: (chunk: string) => void; + columns?: () => number; + motion?: boolean; + color?: boolean; + intervalMs?: number; + now?: () => number; +} + +/** + * Lightweight TTY spinner. No-op interval when motion is off (one static line). + * Callers must `stop()` to clear the interval. + */ +export class TtySpinner { + private timer: ReturnType | null = null; + private frame = 0; + private startedAt = 0; + private label = ""; + private readonly write: (chunk: string) => void; + private readonly columns: () => number; + private readonly motion: boolean; + private readonly color: boolean; + private readonly intervalMs: number; + private readonly now: () => number; + + constructor(opts: TtySpinnerOptions = {}) { + this.write = opts.write ?? ((chunk) => process.stdout.write(chunk)); + this.columns = opts.columns ?? (() => process.stdout.columns ?? 80); + this.motion = opts.motion ?? shouldUseVisualMotion(); + this.color = opts.color ?? shouldUseWelcomeColor(); + this.intervalMs = opts.intervalMs ?? 80; + this.now = opts.now ?? (() => Date.now()); + } + + start(label = ""): void { + this.stop(); + this.label = label; + this.frame = 0; + this.startedAt = this.now(); + this.paint(false); + if (!this.motion) return; + this.timer = setInterval(() => { + this.frame += 1; + this.paint(true); + }, this.intervalMs); + this.timer.unref?.(); + } + + stop(finalLine?: string): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + if (this.motion) this.write("\r\u001b[K"); + if (finalLine) this.write(`${finalLine}\n`); + } + + private paint(overwrite: boolean): void { + const elapsed = this.now() - this.startedAt; + const tipIndex = Math.floor(elapsed / 4000); + const line = + renderProgressLine({ + frameIndex: this.frame, + tipIndex, + columns: this.columns(), + label: this.label, + motion: this.motion, + color: this.color, + }).split("\n")[0] ?? ""; + if (overwrite && this.motion) this.write(`\r\u001b[K${line}`); + else if (this.motion) this.write(line); + else this.write(`${line}\n`); + } +} + +/** Run `fn` with a TTY spinner; always stops, including on throw. No-op when motion is off. */ +export async function withCliProgress( + label: string, + fn: () => Promise, + opts?: TtySpinnerOptions, +): Promise { + const motion = opts?.motion ?? shouldUseVisualMotion(); + if (!motion) { + return fn(); + } + const spinner = new TtySpinner({ ...opts, motion: true }); + spinner.start(label); + try { + const result = await fn(); + spinner.stop(); + return result; + } catch (err) { + spinner.stop(); + throw err; + } +}