diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 8edeeee..076963b 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.2.1", + "version": "5.3.0", "homepage": "https://github.com/agent-kit-startup/agent-kit", "repository": "https://github.com/agent-kit-startup/agent-kit", "logo": "dashboard/logo-marketplace.svg", diff --git a/.cursor/agent-kit.json b/.cursor/agent-kit.json index d9af3cf..bd3e4b4 100644 --- a/.cursor/agent-kit.json +++ b/.cursor/agent-kit.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "version": "5.2.1", + "version": "5.3.0", "protected": [ ".cursor/HANDOFF.md", ".cursor/agents/test-suites.md", diff --git a/.cursor/commands/backlog-add.md b/.cursor/commands/backlog-add.md index 2a449ac..08c56d8 100644 --- a/.cursor/commands/backlog-add.md +++ b/.cursor/commands/backlog-add.md @@ -36,6 +36,8 @@ Example: `/backlog-add Polish Mission Control empty-state icons` Same planning blockers as `/start-project`: unresolved essential readiness → point to `/agent-kit-onboard`. Non-essential pending is advisory only. +**Never-Ask:** `confirm-provider` / `collaboration.provider` must not become an Ask. One-line advisory or silence, then continue Broad Intake → propose → write Ask. Do not halt enqueue. Distinct from an essential-readiness hard stop (that still points to `/agent-kit-onboard`). The Broad Intake worker inherits this: do not return a readiness-gate Ask for that check. + ## What to Do ### 1. Broad Intake Review (required before plan proposal) @@ -52,7 +54,7 @@ Before enqueueing a new plan, **scan** these sources (read/skim; do not deep-div | **Archived context** | Prior packs for same theme | `.cursor/context/archive/**` (if present; glob by topic) | | **Decisions** | ADRs that constrain the goal | `.cursor/memory/decisions/`, `_index.md` Decisions table | | **Memory** | Errors, audits, consolidations, review logs, plan-monitors, findings audits | `.cursor/memory/errors/`, `.cursor/memory/plan-monitor-*.md`, theme-matched `plan-review-*.md`, `_index.md` (Audits + Decisions) | -| **Unprocessed dogfood** | Factory/consumer inbox notes awaiting triage (not sessionStart-only) | `dogfood/README.md` or `.cursor/dogfood/README.md` under `##` or `### Unprocessed Files`; skim titles/summaries only. Missing/empty inbox → no findings. Labels: ignore (owned by open plan), error/include (kit gap), note (inbox evidence only). Never auto-analyze or memory WRITE (ADR `decisions/2026-08-11_dogfood-unprocessed-broad-intake-bucket.md`) | +| **Unprocessed dogfood** | Factory/consumer inbox notes awaiting triage (not sessionStart-only) | `dogfood/README.md` or `.cursor/dogfood/README.md` under `##` or `### Unprocessed Files`; skim titles/summaries only. Missing/empty inbox → no findings. Labels: ignore (owned by open plan), error/include (kit gap), note (inbox evidence only). Never auto-analyze or memory WRITE (ADRs `decisions/2026-08-11_dogfood-unprocessed-broad-intake-bucket.md`, `decisions/2026-08-14_main-command-dogfood-audit-routing.md`) | | **Local docs** | SoT / inventories / getting-started that the goal touches | `docs/**`, especially files named in the payload or related SoT | | **Working tree** | Uncommitted local work that would collide | `git status`, `git diff` (staged + unstaged); do not commit | | **Recent commits** | What already shipped for this theme | `git log` (short, recent), related PR titles if available | @@ -83,6 +85,13 @@ The actual scanning and triage is delegated to a **Task(explore) subagent** usin **Fallback:** If Task dispatch is unavailable, run the Broad Intake Review inline (same as pre-delegation behavior). +**Unprocessed inbox Ask (when non-empty):** after the skim, if Unprocessed has rows, **Ask questions** (one question; chat numbered-list fallback) with labels exactly: +- `Analyze inbox now` — start the ingest ritual (analyze → memory WRITE → triage). Notes become plans/memory after HITL, never `plan-monitor-*.md`. Then continue this enqueue. +- `Enqueue Fix now` — fold include/error Unprocessed rows into this proposal (already in Broad Intake). Do not start a nested `/backlog-add`. +- `Not now` — continue the write Ask. Treat remaining inbox rows as `note` only. + +Never auto-analyze. Empty or missing inbox: skip this Ask. + ### 2. Vague goal If the goal is missing or vague, use **Ask questions** (chat numbered-list fallback): diff --git a/.cursor/commands/continue-plan.md b/.cursor/commands/continue-plan.md index c1496b8..9221c7d 100644 --- a/.cursor/commands/continue-plan.md +++ b/.cursor/commands/continue-plan.md @@ -19,7 +19,11 @@ Resume a plan from the last handoff. Confirm the next unit, then execute **only 1. **Read `.cursor/HANDOFF.md` first.** No handoff → say so and suggest `/start-project`. Do not invent progress. 2. **Pre-flight (API/usage limit):** if `- **Mode:**`, `- **Gaps:**`, or `- **Instruction for the next agent:**` still records an API/usage limit hard stop from a prior tick, **do not** mark a to-do `in_progress`, dispatch a Task, or edit product files until the operator confirms recovery (switch from Auto to a named model such as Claude Opus, Sonnet 4.6, or Composer 2.5 Fast; and/or wait for quota reset). Surface recovery via **Ask questions** when available. **Fallback after Auto→Grok:** Grok 4.5 and Auto often lack Ask questions (see `.cursor/memory/decisions/2026-07-20_ask-questions-model-availability.md`); use the numbered-list chat fallback (reply by number or label; typed answer = "Other"). Pre-flight is HANDOFF check plus operator model choice only; the kit has no remaining-quota API. Align with `context-guardian` Quota-blocked sessions and `/run-plan` Read-state pre-flight. 3. **Audits pre-flight** (config `externalPlanReview.preflight`: `off` | `warn` | `block`; missing = `off`): before the next-unit confirmation Ask, check owed / untriaged audits for the chosen plan slug (Field Report owed, untriaged `plan-monitor-*.md`, open cadence WARNING). `off`: skip. `warn`: surface once, then continue. `block`: arm via `.cursor/scripts/plan-external-review.sh` (prefer `--autonomous` when `mode: autonomous`, else `--paste-only`) or stop until the operator defers; never steal `/git-prod`. Distinct from the advisory pre-unit monitor skim below (ADR `2026-07-27_audits-autonomous-plan-review-contract.md`). -4. **Unprocessed dogfood preflight (advisory):** before the next-unit confirmation Ask, skim `##` or `### Unprocessed Files` in factory `dogfood/README.md` or consumer `.cursor/dogfood/README.md`. Non-empty: mention count and top titles once with standard triage labels (`ignore` / `error` / `include` / `note`). Empty or missing inbox: silent OK. Do **not** force analysis, memory WRITE, or Field Reports; do **not** block resume solely because the inbox is non-empty. Runs regardless of `externalPlanReview.preflight`. sessionStart inbox tip stays complementary (ADR `2026-08-11_dogfood-unprocessed-broad-intake-bucket.md`). +4. **Unprocessed dogfood preflight:** before the next-unit confirmation Ask, skim `##` or `### Unprocessed Files` in factory `dogfood/README.md` or consumer `.cursor/dogfood/README.md`. Empty or missing inbox: silent OK. Non-empty: mention count and top titles once with standard triage labels (`ignore` / `error` / `include` / `note`), then **Ask questions** (one question; chat numbered-list fallback) with labels exactly: + - `Analyze inbox now` — start the ingest ritual (analyze → memory WRITE → triage). Notes become plans/memory after HITL, never `plan-monitor-*.md`. `/dogfood` stays file-only. + - `Enqueue Fix now` — route include/error notes through the `/backlog-add` contract (Broad Intake + write-confirm; no Gate B, no activate). + - `Not now` — continue to the next-unit Ask. Do not start analysis. + Never auto-analyze, never invent Field Reports, never block resume solely because the inbox is non-empty. Runs regardless of `externalPlanReview.preflight`. sessionStart inbox tip stays complementary (ADRs `2026-08-11_dogfood-unprocessed-broad-intake-bucket.md`, `2026-08-14_main-command-dogfood-audit-routing.md`). 5. **Apply Agent Persona chrome.** Read `.cursor/context/config.json` for `agentPersona.modes.continue-plan` (fallback to legacy `workspaceSkin`, then "autopilot"). Use the corresponding persona's `chatHints` from `registry/personas/core/` for tone and confirmations. 6. **Confirm the next unit with Ask questions** before editing. Use concrete option labels (see What to Do step 5). Do not accept a typed "yes" as the gate. 7. **One unit per chat** (phase or one heavy to-do) unless the user explicitly ran `/run-plan`. diff --git a/.cursor/commands/dashboard-broadcast.md b/.cursor/commands/dashboard-broadcast.md index e653ccd..d3ddcb7 100644 --- a/.cursor/commands/dashboard-broadcast.md +++ b/.cursor/commands/dashboard-broadcast.md @@ -37,7 +37,9 @@ node dashboard/start-broadcast.mjs export MC_PORT=$(node -e 'import("./dashboard/lib/guards.mjs").then(m => console.log(m.preferredPortForRepoRoot(process.cwd()))).catch(e => { console.error(e.message); process.exit(1) })') ``` - If you set an explicit `PORT`, use that value instead of the derivation. + If you set an explicit `PORT`, use that value instead of the derivation. `MC_PORT` is the *preferred* + port: when it is already held the starter walks to the next per-workspace candidate, so always prefer + the `Bind:` line it prints over the derivation. 1. **Prefer the starter** (handles token generation, `HOST=0.0.0.0`, detach, LAN URL print): @@ -91,13 +93,19 @@ node dashboard/start-broadcast.mjs ```bash curl -sf "http://127.0.0.1:$MC_PORT/dashboard-data.json?token=$MISSION_CONTROL_TOKEN" | node -e 'let d="";process.stdin.on("data",c=>d+=c);process.stdin.on("end",()=>console.log(JSON.parse(d).system?.repoRoot))' ``` -- If a loopback `/dashboard` already holds the port, stop it before broadcast. +- **You do not have to stop anything to broadcast.** When the preferred port is held by this workspace's + loopback `/dashboard`, by another workspace, or by a token-gated instance the starter cannot identify, + it walks to the next per-workspace candidate and prints what it skipped ("left running"). Concurrent + Mission Control instances are supported; nothing is killed on your behalf. +- To **reuse** an existing broadcast for this workspace instead of starting another, export the same + `MISSION_CONTROL_TOKEN` it was started with. A freshly generated token cannot match a running instance, + so the starter treats it as someone else's and starts beside it. - OS firewall may block inbound LAN TCP; allow the chosen port for your local network profile if needed. ## Notes -- Port: `PORT` env overrides; default is the per-workspace hash allocation (range `3333-3588`). Derive it with the snippet in step 0 or read the printed `system.port`. -- Log default: `/tmp/mission-control-broadcast.log` +- Port: `PORT` env overrides; default is the per-workspace hash allocation (range `3333-3588`), walking to the next candidate when one is held. Derive the preferred port with the snippet in step 0, or read the printed `Bind:` line / `system.port`. Explicit `PORT` refuses instead of walking, so a pinned port never silently moves. +- Log default: `/tmp/mission-control-broadcast-.log` (per workspace; `MISSION_CONTROL_LOG` overrides) - Loopback UX remains `/dashboard` / `npm run dashboard` / `agent-kit dashboard` - Detach lessons match `/dashboard` (error `2026-07-25_dashboard-server-reaped-agent-shell`) @@ -106,6 +114,8 @@ node dashboard/start-broadcast.mjs | Symptom | Cause | Fix | |---|---|---| | Serve exits: non-loopback requires token | `HOST` set without `MISSION_CONTROL_TOKEN` | Use `dashboard:broadcast` or set a ≥16 char token | -| Port busy / token rejected | Existing instance on the allocated port | Kill LISTEN pid for **this** workspace only (verify `repoRoot`); retry broadcast | +| Port busy / token rejected | Existing instance on the allocated port | Nothing to do: the starter skips it and binds the next per-workspace candidate. Want that exact port? Kill the LISTEN pid **only** after verifying `repoRoot` is yours | +| Explicit `PORT` refused | You pinned a `PORT` that another instance holds | Unset `PORT` (auto-pick a free per-workspace port), or free that port yourself if it is this workspace's | +| A second broadcast appears each run | `MISSION_CONTROL_TOKEN` is regenerated per run, so the running one cannot be identified | Export a stable `MISSION_CONTROL_TOKEN` to reuse the existing broadcast | | Phone cannot connect | Firewall or wrong IP | Confirm printed LAN IPv4; allow inbound TCP | | Config save 403 from phone | Expected | Config writes are loopback-only | diff --git a/.cursor/commands/dogfood.md b/.cursor/commands/dogfood.md index b203a70..9c4b7ca 100644 --- a/.cursor/commands/dogfood.md +++ b/.cursor/commands/dogfood.md @@ -132,13 +132,14 @@ If the operator chooses `Keep local only`, stop. The local file is the record. ### Step 6: Respond -> Dogfood filed: `dogfood/cursor__.md` (factory) or `.cursor/dogfood/cursor__.md` (consumer). Next: analyze → memory WRITE → triage, or ask for a public issue if the pattern is upstream-relevant. +> Dogfood filed: `dogfood/cursor__.md` (factory) or `.cursor/dogfood/cursor__.md` (consumer). This command stays file-only. Analysis is offered from `/continue-plan`, `/run-plan`, `/run-plan-all`, or `/backlog-add` preflight Ask (`Analyze inbox now` / `Enqueue Fix now` / `Not now`), not from `/dogfood`. Notes become plans/memory after HITL, never `plan-monitor-*.md`. ## Related - `dogfood/README.md` — factory inbox and ingest ritual - `.cursor/memory/decisions/2026-07-31_dogfood-factory-consumer-lanes.md` — lane decision - `.cursor/memory/decisions/2026-07-31_dogfood-ingest-contract.md` — ingest contract +- `.cursor/memory/decisions/2026-08-14_main-command-dogfood-audit-routing.md` — main-command daily path; this slash stays file-only - `.cursor/memory/decisions/2026-07-17_session-origin-not-product-usecase.md` — hygiene - Cursor product-update gaps may route here via `/cursor-update-awareness` (Ask → `/dogfood`) - Incoming **public** issue triage (factory-only) is `/public-issue-triage`, not this command diff --git a/.cursor/commands/plan-external-review.md b/.cursor/commands/plan-external-review.md index aa5a580..632bf9b 100644 --- a/.cursor/commands/plan-external-review.md +++ b/.cursor/commands/plan-external-review.md @@ -15,7 +15,7 @@ Manually arm **optional plan audits** (external plan review via Claude Code or C - You want a second-agent check of shipped work vs the plan (gaps, residuals) - Auto-arm from the exhausted path was skipped (opt-in off, no `claude` on PATH, soft-fail tip) or you prefer a manual re-run -**Wired path:** when `/run-plan` (orchestrated / in-session) or headless `agent-kit run-plan` stops on plan exhausted, the kit arms or suggests `.cursor/scripts/plan-external-review.sh` (see `/run-plan` "Optional external plan review"). Use this command when you need to re-arm manually. Still not a Cursor `hooks.json` `stop` entry. +**Daily path:** `/run-plan` and `/run-plan-all` invoke this contract when audits are enabled (arm + `--wait-monitor`). Use this slash for paste, manual re-arm, or when auto-arm was skipped. Still not a Cursor `hooks.json` `stop` entry. ADR `2026-08-14_main-command-dogfood-audit-routing.md`. Do **not** use this mid-plan for in-flight to-dos; the monitor method only verdicts `completed` work. @@ -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. 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. +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. Pinned `backend: "cloud"` (Cursor Cloud Agents over REST) needs `curl`, `node`, and `CURSOR_API_KEY`, and reviews the **pushed** branch — it soft-fails rather than auditing unpushed state, and is never reached by `"auto"`. `--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 @@ -56,7 +56,7 @@ If any are missing: stop. Do **not** claim a review ran. Tell the user to run `a - **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` / `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`. +- **Reviewer cascade:** `--backend auto|claude|cursor|cloud` (or config). `cloud` is an opt-in pin (Cursor Cloud Agents over REST) that `auto` never reaches. 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 c5d2812..44bebb0 100644 --- a/.cursor/commands/plan-review-triage.md +++ b/.cursor/commands/plan-review-triage.md @@ -15,7 +15,7 @@ Supports **multi-path walk**: iterate multiple monitors in blocking-first then d - After Claude external plan review completed (monitor file exists under `.cursor/memory/plan-monitor-*.md`) - You want to process findings from the monitor and decide next steps -- **Automatic after chat arm:** `/run-plan` / `/plan-external-review` (and `/run-plan-all` queue-end) wait for the monitor then continue into this command's Ask; operators should not need to type `done` or paste triage solely to resume +- **Daily path:** `/run-plan` (exhaustion) and `/run-plan-all` (queue-end) continue into this Ask after wait exit 0. This slash stays HITL SoT; operators should not need to type `done` or paste triage solely to resume. ADR `2026-08-14_main-command-dogfood-audit-routing.md`. - **Not for mid-plan reviews** - this command expects `completed` work only ## Usage diff --git a/.cursor/commands/run-plan-all.md b/.cursor/commands/run-plan-all.md index 35996fa..d426021 100644 --- a/.cursor/commands/run-plan-all.md +++ b/.cursor/commands/run-plan-all.md @@ -97,7 +97,9 @@ This step is delegated to a **Task(explore) subagent** using the reusable worker ## Confirm Queue (Ask questions) -**Unprocessed dogfood preflight (advisory):** before the confirm Ask, skim `##` or `### Unprocessed Files` in factory `dogfood/README.md` or consumer `.cursor/dogfood/README.md`. Non-empty: mention count and top titles once in the Ask body (standard triage labels). Empty or missing: silent OK. Never auto-analyze, never invent Field Reports, never refuse the queue solely because the inbox is non-empty. Orchestrator owns this skim for the batch: per-plan `/run-plan` workers must not re-recite the same inbox. sessionStart tip remains complementary (ADR `2026-08-11_dogfood-unprocessed-broad-intake-bucket.md`). +**Unprocessed dogfood preflight:** before the confirm Ask, skim `##` or `### Unprocessed Files` in factory `dogfood/README.md` or consumer `.cursor/dogfood/README.md`. Empty or missing: silent OK. Non-empty: mention count and top titles once with standard triage labels, then **Ask questions** (one question, before the 6-way confirm; chat numbered-list fallback) with labels exactly `Analyze inbox now` / `Enqueue Fix now` / `Not now` (same handlers as `/continue-plan` hard stop 4). Never auto-analyze, never invent Field Reports, never refuse the queue solely because the inbox is non-empty. After `Not now` or after ingest/enqueue, continue to the 6-way confirm. Orchestrator owns this skim and Ask for the batch: per-plan `/run-plan` workers must not re-recite the same inbox and must not Ask again. sessionStart tip remains complementary (ADRs `2026-08-11_dogfood-unprocessed-broad-intake-bucket.md`, `2026-08-14_main-command-dogfood-audit-routing.md`). + +**Audit session-pile preflight (before the confirm Ask):** count detached workspace-owned `agent-kit-audit-*` sessions via `.cursor/scripts/plan-external-review.sh --reap-audit-sessions --dry-run` (or the arm `--dry-run` line `audit-sessions: N detached owned`). At warn: print the dispose command (or offer `--reap-audit-sessions`) in the Ask/preflight body and continue. At cap: do not arm; surface the cap in the orchestrator Ask/preflight, not only launcher stderr. Attached sessions are operator work and are never counted. This orchestrator check runs once, at queue confirm; per-arm protection is the launcher's own session-cap refusal (exit 4), which the mid-batch and queue-end arms already honour, so no second orchestrator call site is wired. After synthesis, present the proposal using **Ask questions** tool. Include the ordered list, key overlaps/consolidations, and coherence notes in the question body. Fallback to chat numbered list if the tool is unavailable. @@ -158,7 +160,7 @@ The orchestrator **must not** implement to-dos, edit product code, run tests, wr ### Per-plan flow -1. **Activate the plan** — update `.cursor/HANDOFF.md`: `Mode: run-plan-all`, active plan basename, full `Run queue`, `Queue cursor`, `Queue status: running`. +1. **Activate the plan** — update `.cursor/HANDOFF.md`: `Mode: run-plan-all`, active plan basename, full `Run queue`, `Queue cursor`, `Queue status: running`. HITL fields that assert a human decision (Queue status stopped, Parked, approved, deferred, confirmed) must record Ask id, operator reply, or `agent-inferred`. Do not write Parked / Queue status stopped as operator action unless an Ask id and operator reply exist. 2. **Dispatch Task** — launch one Task subagent with a self-contained prompt (template below) that includes: - Absolute path to the plan file under `.cursor/plans/` - HANDOFF snapshot (active plan, cursor, outcomes so far, queue order) @@ -179,18 +181,20 @@ Subagent ownership (inside the Task): mark to-dos `in_progress` → implement ### Audits (mid-batch + queue end) +**Default path when audits are enabled:** this command arms, waits, rearms leftover wait budget on exit 3, and continues into `/plan-review-triage` at queue end (explicit path list). Operators stay on `/run-plan-all`; specialist `/plan-external-review` and `/plan-review-triage` stay SoT and are invoked from this path. Do not reimplement exit-3 resume (`dogfood-ingest-fix-now`) or a Cloud Agents reviewer (`cursor-cloud-agents-sdk`). ADR `2026-08-14_main-command-dogfood-audit-routing.md`. + 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` (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: true` and audits enabled | After each plan Task returns `outcome: completed`, the **orchestrator** arms **one** full audit for that plan with `--force --autonomous --wait-monitor` (or one `--batch` + wait_all when batching is intentional) **before** advancing the cursor. No paste Ask between plans. Soft-fail → Field Report owed; still advance. AwaitShell until exit `0|3|4` (chat slice ~90s; remaining budget in `.cursor/context/audit-wait/.json`). **Exit 3 with remaining `waitTimeoutSeconds`:** do not treat as arm-done. Same orchestrator session resumes wait-state polling (re-arm `--wait-monitor` against leftover budget) before advancing the cursor or skipping triage. Exit 3 with zero leftover budget, or exit 4: Field Report owed, then advance. Wait success requires a **fresh** monitor after arm start. Reviewer cascade: `backend: "auto"` uses Claude (Haiku) when usable, else Cursor Agent. Same-model implementer/reviewer is an honest skip. Do **not** fan out N background sessions without wait. Do **not** insert a mid-queue triage Ask (operator non-stop preserved; record ready path for queue-end). Mid-batch stays findings-only: **never** auto-Write residuals or rewrite the Run queue between plans. | | `midBatchAudits` false/missing | **Non-stop** mid-queue: do **not** pause for audit Ask/paste between plans. Mid-queue completed plans stay Field Report **owed** until reviewed. | | Queue exhausted | Final HANDOFF; cadence `batch-complete`; then queue-end audit arm covering remaining owed/unreviewed targets (enabled → `--force --autonomous --wait-monitor` or paste per `mode`; else `offerOnExhausted` Ask). Prefer one launcher `--batch` + wait_all when multiple basenames. After wait exit `0`: run `/plan-review-triage` Ask with an **explicit path list** of fresh monitors (batch uniform Ask when outcomes match; sequential fallback when mixed; durable heading per file). **Batch exhaust without conveyor:** when remaining monitors are process-only / depth-capped, prefer uniform **Ack and stop** or **Fix nits only**; do not spawn unbounded `close-*` backlog from Write residuals (ADR `decisions/2026-08-11_plan-audit-residuals-termination.md`). Then suggest `/git-prod` if staging is ahead of `main` (separate HITL). | Never steal `/git-prod` confirmation. Chat never runs silent headless `--force` / `claude -p` in the agent shell. Spawn-only exit 0 without `--wait-monitor` is **not** review done. Never stop at Final HANDOFF "when monitors exist, run triage" after arming: wait (freshness) then continue (mid-batch waits for file only; queue-end waits then triage Ask with explicit paths). ADR: `2026-07-27_audits-autonomous-plan-review-contract.md` (supersedes queue-end-only); wait freshness: `2026-07-27_audits-wait-freshness-enforce.md`. -**Exit 3 stays timeout-only across the queue.** A mid-queue or queue-end arm that returns `3` reviewed nothing: leave that plan Field Report **owed**, keep its path out of the queue-end triage list, and never narrate it as reviewed. 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`. +**Exit 3 stays timeout-only across the queue.** A mid-queue or queue-end arm that returns `3` reviewed nothing: leave that plan Field Report **owed**, keep its path out of the queue-end triage list, and never narrate it as reviewed. **Same-session resume:** when leftover `waitTimeoutSeconds` remains in `.cursor/context/audit-wait/.json`, the same orchestrator session must keep polling (re-arm `--wait-monitor`) before advancing the cursor or skipping `/plan-review-triage`. A later session may poll leftover budget; that is fallback, not the default while this session is still open. Do not treat a first-slice exit `3` as "arm done, continue the queue." Monitors that show up later, including monitors written by a different arm or a later queue position, do **not** retroactively upgrade an earlier `3`. Exit `4` covers the launcher soft-fails: no usable reviewer (`backend: "auto"` tried Claude then Cursor; pinned `claude` still tips when Claude is missing), same-model refuse, background spawn unavailable, a **silent PTY** early abort (spawn succeeded but produced no scrollback in the grace window), and a **session-cap refusal** (detached `agent-kit-audit-*` pile at the cap, so nothing spawned). Advance the queue on exit 4 or on exit 3 with zero leftover budget, but record the target as owed, never as reviewed. ADR: `2026-07-30_audits-pty-progress-gate-zombie-policy.md`; wait resume: `2026-08-13_audits-atomic-wait-reviewer-fallback.md`. ### External plan review (legacy heading) @@ -220,6 +224,8 @@ Rules: - Unprocessed dogfood already skimmed by the orchestrator at queue-confirm — do not re-recite. - When this plan is exhausted (`outcome: completed`), **skip** chat exhaustion Ask/paste in the worker (parent orchestrator owns mid-batch + queue-end audits). Return the structured summary and stop. - Never `/git-prod`. Never ask the user for `/continue-plan` as the default path. +- A refused command (permission classifier, never /git-prod class) is terminal in this worker: do not retry it. +- Do not fabricate HITL: do not write Parked / Queue status stopped as operator action unless an Ask id and operator reply exist. - Do not start the next queued plan; this Task owns only this plan. - Prefer orchestrated /run-plan strategy inside this Task when Task nesting is available; otherwise in-session loop for this plan only. - Update plan frontmatter todo statuses and HANDOFF for this plan as you go. @@ -271,6 +277,8 @@ Queue outcomes: plan-a.plan.md: completed (to-dos: id-1, id-2, id-3) ``` +HITL claims on these fields (especially `Queue status: stopped` or a Parked row as operator action) must record Ask id, operator reply, or `agent-inferred`. An inferred stop must not look identical to an operator stop. + The approved queue order is persisted so a resume in a fresh chat does not re-synthesize. See the ADR at `.cursor/memory/decisions/2026-07-26_run-plan-all-queue-contract.md`. **Gaps voice:** keep `- **Gaps:**` short and operator-facing (exact `none` when only mid-batch / cadence / monitor plumbing changed; never `none. Residuals…` as an OK debit). Do not dump queue-outcome tables, mid-batch monitor paths, or `/git-prod` boilerplate into Gaps. Full say/avoid pattern: handoff template + ADR `2026-07-27_mc-flight-log-panel.md`. diff --git a/.cursor/commands/run-plan.md b/.cursor/commands/run-plan.md index afc9d4d..a172646 100644 --- a/.cursor/commands/run-plan.md +++ b/.cursor/commands/run-plan.md @@ -57,7 +57,8 @@ While `/run-plan` is active, it **overrides** the "1 phase = 1 chat / ask for a - Memory CHECK if the phase touches a known error/decision - **Pre-flight (API-limit stop):** if HANDOFF Gaps / Instruction / stop reason indicates an API/usage limit hard stop from a prior tick, **do not** mark a to-do `in_progress` or dispatch a Task until the operator confirms recovery (named model switch and/or wait for reset). Pre-flight is this HANDOFF check plus operator model choice only; the kit has **no** remaining-quota API. Align with `context-guardian` quota-blocked sessions. - **Audits pre-flight:** read `externalPlanReview.preflight` (`off` | `warn` | `block`; missing = `off`). When not `off`, check owed / untriaged audits for the active plan slug (Field Report owed, untriaged monitors, cadence WARNING). `warn`: surface once then continue. `block`: arm the launcher (prefer `--autonomous` when `mode: autonomous`, else `--paste-only`) or stop until deferred; never steal `/git-prod`. Stronger than advisory monitor skim (ADR `2026-07-27_audits-autonomous-plan-review-contract.md`). -- **Unprocessed dogfood preflight (advisory):** before marking the first to-do `in_progress` (or on resume of a continuous run after HANDOFF reload), skim `##` or `### Unprocessed Files` in factory `dogfood/README.md` or consumer `.cursor/dogfood/README.md`. Non-empty: mention count and top titles once with standard triage labels. Empty or missing: silent OK. Never auto-analyze, never invent Field Reports, never block the tick solely because the inbox is non-empty. When this tick runs inside a `/run-plan-all` per-plan Task and the orchestrator already skimmed Unprocessed at queue-confirm, skip re-reciting (orchestrator owns the skim). sessionStart tip remains complementary (ADR `2026-08-11_dogfood-unprocessed-broad-intake-bucket.md`). +- **Audit session-pile preflight (before arm):** count detached workspace-owned `agent-kit-audit-*` sessions via `.cursor/scripts/plan-external-review.sh --reap-audit-sessions --dry-run` (or the arm `--dry-run` line `audit-sessions: N detached owned`). At warn: print the dispose command (or offer `--reap-audit-sessions`) and continue. At cap: do not spawn; surface the cap in the orchestrator preflight/Ask, not only launcher stderr. Attached sessions are operator work and are never counted. +- **Unprocessed dogfood preflight:** before marking the first to-do `in_progress` (or on resume of a continuous run after HANDOFF reload), skim `##` or `### Unprocessed Files` in factory `dogfood/README.md` or consumer `.cursor/dogfood/README.md`. Empty or missing: silent OK. Non-empty: mention count and top titles once with standard triage labels, then **Ask questions** (one question; chat numbered-list fallback) with labels exactly `Analyze inbox now` / `Enqueue Fix now` / `Not now` (same handlers as `/continue-plan` hard stop 4). Never auto-analyze, never invent Field Reports, never block the tick solely because the inbox is non-empty. When this tick runs inside a `/run-plan-all` per-plan Task and the orchestrator already skimmed Unprocessed at queue-confirm, skip re-reciting **and** skip this Ask (orchestrator owns the skim and the inbox Ask). sessionStart tip remains complementary (ADRs `2026-08-11_dogfood-unprocessed-broad-intake-bucket.md`, `2026-08-14_main-command-dogfood-audit-routing.md`). ### 2. Choose the next to-do @@ -78,6 +79,8 @@ Optional per-to-do budget fields (`read_scope`, `worker_contract`, `max_ticks`, #### Optional external plan review (plan exhausted only) +**Default path when audits are enabled:** this command arms, waits (`--wait-monitor`), rearms leftover wait budget on exit 3, and continues into `/plan-review-triage` on exit 0. Operators stay here; they do not need to memorize `/plan-external-review` or `/plan-review-triage` for a normal day. Those specialist slashes stay SoT and are invoked from this path (manual/paste re-arm; HITL triage). Exit-3 same-session resume is already shipped (`dogfood-ingest-fix-now`); do not reimplement it here. Cloud Agents / `@cursor/sdk` reviewer is out of scope (`cursor-cloud-agents-sdk`). ADR `2026-08-14_main-command-dogfood-audit-routing.md`. + After Final HANDOFF when the run stopped because all implementable to-dos are done (`Mode: STOPPED` / plan exhausted, any strategy), and **after** suggesting `/git-prod` if staging is ahead of `main` (that suggestion is a **separate** HITL gate; never steal `/git-prod` confirmation): **Consumer advisory (before arm/Ask):** briefly consult related `.cursor/memory/plan-monitor-*.md` / owed Field Report state for the exhausted plan slug. Do **not** weaken audits pre-flight, mid-batch policy, or CI headless behavior (ADRs `decisions/2026-07-27_plan-monitor-consumer-awareness.md`, `decisions/2026-07-27_audits-autonomous-plan-review-contract.md`). @@ -110,10 +113,11 @@ 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). 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`. + 1. AwaitShell / block on the launcher until exit `0` (fresh monitor ready), `3` (timeout), or `4` (soft-fail while waiting). Chat slice is ~90s (`waitSliceSeconds`); remaining total budget lives in `.cursor/context/audit-wait/.json` (not HANDOFF). Wait success requires a **fresh** monitor after arm start (mtime/arm-epoch or content sentinel); pre-existing files are not ready. Reviewer cascade: `backend: "auto"` uses Claude (Haiku) when usable, else Cursor Agent; pin `claude` or `cursor` to force one backend. Same-family implementer and reviewer is an honest skip (including Auto/Auto). Opus advisor runs only on escalate. Findings-only until `/plan-review-triage`. 2. On **exit 0:** run `/plan-review-triage` Ask for that monitor path (findings-only; no silent-Ack / auto-fix). Apply termination policy (max closeout depth 1; nits/process-only prefer Ack / Fix nits; do not spawn unbounded `close-*` Write residuals) per ADR `decisions/2026-08-11_plan-audit-residuals-termination.md` and `/plan-review-triage` Step 2b. - 3. On **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. + 3. On **exit 3 with remaining `waitTimeoutSeconds`:** this is a slice timeout, not review done. Exit `3` is **timeout only**. **Same orchestrator session** must resume wait-state polling (re-arm `--wait-monitor` against leftover budget in `.cursor/context/audit-wait/.json`) before treating the audit as finished or skipping triage. Do not narrate exit 3 as reviewed. A later session may also poll leftover budget; that is fallback, not the default while this session is still open. A monitor that appears afterwards (later writer, separate arm, another queue position) does **not** convert it into success. + 4. On **exit 3 with zero remaining budget, or exit 4:** honest tip + Field Report owed; do **not** invent a finished review or run triage as if the monitor is ready. Exit `4` covers a **silent PTY** early abort (spawn succeeded, no scrollback in the grace window) and a **session-cap refusal** (detached `agent-kit-audit-*` pile at the cap, nothing spawned): both mean no audit is running. + 5. Never claim the audit finished on spawn-only exit 0 or on a stale pre-arm monitor path. ADR: `decisions/2026-07-27_audits-wait-freshness-enforce.md` (follow-on to `decisions/2026-07-27_audits-post-spawn-monitor-watch-continue.md`); silent PTY and session pile: `decisions/2026-07-30_audits-pty-progress-gate-zombie-policy.md`. 7. **Not a native stop hook:** do **not** register or rely on a Cursor `hooks.json` `stop` follow-up. Exhaustion Ask / arm / watch run only after Final HANDOFF / prod suggestion as a separate gate. 8. **Still never `/git-prod`** from this path. Suggesting prod when staging is ahead of `main` stays a human next step (separate from monitor watch and triage Ask). @@ -146,7 +150,7 @@ After a findings-contract (or `review-*`) tick returns findings, read `.cursor/c ### 5. Close the tick: status + staging 1. Mark the tick's to-do as `completed` in the plan's frontmatter -2. Update `.cursor/HANDOFF.md` (include `Mode: run-plan ()`) +2. Update `.cursor/HANDOFF.md` (include `Mode: run-plan ()`). HITL claims (parked, approved, deferred, confirmed, stopped-by-operator) must record Ask id, operator reply, or `agent-inferred`. 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: 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 @@ -302,7 +306,9 @@ Rules: - Hygiene: technical commits/docs; no transient content - Do not expect a broad `git add` of `.cursor/memory/plan-monitor-*.md` WIP into a product commit (orchestrator stages monitors add-by-name only when intentional) - Never /git-prod +- A refused command (permission classifier, never /git-prod class) is terminal. Do not retry the same command. - Do not ask the user for /continue-plan +- When writing HANDOFF HITL claims (parked, approved, deferred, confirmed, stopped-by-operator), record Ask id, operator reply, or agent-inferred. Do not fabricate operator action. - Before Staging ready: yes: run repository-appropriate formatter/linter on touched files (e.g. biome/eslint/prettier for code; markdownlint or docs tests if the repo has them). Do not require a global lint for pure markdown when no applicable linter exists; state none applicable in Tests/Validation. - Summary MUST include Tests: or Validation: with commands and results (pass/fail). Staging ready: yes without that evidence is invalid when you changed formatted/linted files. diff --git a/.cursor/commands/start-project.md b/.cursor/commands/start-project.md index a067fb3..181ed42 100644 --- a/.cursor/commands/start-project.md +++ b/.cursor/commands/start-project.md @@ -34,7 +34,7 @@ Before intake, read `.cursor/agent-kit.config.json` and `.cursor/context/readine **Planning blockers** (stop and point to `/agent-kit-onboard`; do not resolve setup here): - Derive unresolved essentials from `pillars[].checks[]` where `essential: true` and `status` is not `ready`. Preserve report order. -- Do **not** treat `pendingActions` as an essential-only queue. Non-essential pending items (for example `collaboration.provider` / action `confirm-provider`) are **warnings only**: mention once, continue Broad Intake, and do not halt planning. +- Do **not** treat `pendingActions` as an essential-only queue. Non-essential pending items (for example `collaboration.provider` / action `confirm-provider`) are **warnings only**: one-line mention or silence, continue Broad Intake, and do not halt planning. Do **not** Ask on `confirm-provider` / `collaboration.provider`. - Missing readiness or config files, unsupported snapshot schema, or a fingerprint mismatch that invalidates the snapshot: point to `/agent-kit-onboard`. Do not reconstruct onboarding inside `/start-project`. **When essentials are ready** (non-essential pending allowed): diff --git a/.cursor/context/config.example.json b/.cursor/context/config.example.json index e0ec634..58425d0 100644 --- a/.cursor/context/config.example.json +++ b/.cursor/context/config.example.json @@ -16,7 +16,12 @@ "offerOnExhausted": true, "mode": "autonomous", "midBatchAudits": true, - "preflight": "warn" + "preflight": "warn", + "cloudAgent": { + "repoUrl": null, + "startingRef": null, + "model": "composer-2.5" + } }, "autoHandoff": false, "interTickCooldownMs": 0, diff --git a/.cursor/context/templates/command-worker-prompt.md b/.cursor/context/templates/command-worker-prompt.md index 235fae2..68a6f86 100644 --- a/.cursor/context/templates/command-worker-prompt.md +++ b/.cursor/context/templates/command-worker-prompt.md @@ -30,6 +30,7 @@ You are an Agent Kit worker. Execute ONLY the task below and stop. - Hygiene: technical commits/docs; no transient content (no meta-language, no agent gossip) - If you touch `.cursor/memory/plan-monitor-*.md`, never expect a broad `git add` of `.cursor/memory/` into a product commit; the orchestrator stages monitors **add-by-name only** when intentional - Never `/git-prod` +- A refused command (permission classifier, never `/git-prod` class) is terminal. Do not retry the same command. - Do not ask the user for `/continue-plan` - Before "Staging ready: yes": run repository-appropriate formatter/linter on touched files (e.g. biome/eslint/prettier for code; markdownlint or docs tests if the repo has them). Pure markdown/docs with no applicable linter: state none applicable in Tests/Validation. - Summary MUST include `Tests:` or `Validation:` with commands and results (pass/fail). `Staging ready: yes` without that evidence is invalid when you changed formatted/linted files. diff --git a/.cursor/context/templates/handoff.md b/.cursor/context/templates/handoff.md index 3168845..66224f1 100644 --- a/.cursor/context/templates/handoff.md +++ b/.cursor/context/templates/handoff.md @@ -4,6 +4,22 @@ Machine fields below must stay as `- **Field:**` bullets (Mission Control parses **Mid-batch monitor pointers are not durable here.** `.cursor/HANDOFF.md` is gitignored session state. Pointers to sibling monitors must live in the tracked `.cursor/memory/_index.md` Audits row for the watched monitor (R14-paired in the same commit). Cite that row in the plan or monitor notes; do not rely on a HANDOFF line as delivery evidence. +### HITL provenance (required) + +Any field that asserts a human decision (parked, approved, deferred, confirmed, stopped-by-operator, `Queue status: stopped` as an operator action) must record what produced it in the same bullet or the next line: + +- Ask id (example: `queue-confirm`) +- Operator reply text (example: `Run as proposed`) +- `agent-inferred` when no human said it + +An inferred park or stop must not look identical to an operator park. Do not write Parked / Queue status stopped as operator action unless an Ask id and operator reply exist. + +Examples: + +- `- **Parked plans:** \`foo.plan.md\` (agent-inferred; no Ask)` +- `- **Queue status:** stopped (Ask \`queue-confirm\` = Cancel)` +- `- **Queue status:** running (Ask \`queue-confirm\` = Run as proposed)` + - **Plan:** `file.plan.md` - **Last updated:** [YYYY-MM-DD HH:MM] - **Mode:** [manual | run-plan (orchestrated) | run-plan (in-session loop) | run-plan-all] diff --git a/.cursor/context/templates/plan.md b/.cursor/context/templates/plan.md index 0417447..bc92ac1 100644 --- a/.cursor/context/templates/plan.md +++ b/.cursor/context/templates/plan.md @@ -21,7 +21,7 @@ isProject: true # [Plan name] -**Goal:** … +**Goal:** … (what and why first; how belongs in Phases / Constraints) ## Phases diff --git a/.cursor/rules/context-guardian.mdc b/.cursor/rules/context-guardian.mdc index a0bdf39..10433a6 100644 --- a/.cursor/rules/context-guardian.mdc +++ b/.cursor/rules/context-guardian.mdc @@ -72,7 +72,7 @@ If detection fails, just tell the user the command without copying it. - Do not block unrelated work because the legacy `onboarded` marker is missing. - Derive blockers from `pillars[].checks[]` with `essential: true` and `status` not `ready`. Do not treat `pendingActions` as essential-only. - Essential unresolved: surface the first one and offer `/agent-kit-onboard`. `/start-project` must stop until essentials are ready. -- Non-essential unresolved (for example provider confirm / local-only collaboration): advisory only; do not halt `/start-project` or active plan work. +- Non-essential unresolved (for example `confirm-provider` / `collaboration.provider` / local-only collaboration): advisory only; do not halt `/start-project`, `/backlog-add`, or active plan work. `/backlog-add` must not Ask on that check (one-line advisory or silence, then Broad Intake → write Ask). - Preserve an active plan and HANDOFF as the current work. Readiness guidance must not restart or replace them. - `/start-project` remains responsible for deliverable planning after essential readiness passes. diff --git a/.cursor/rules/hitl-ask-questions.mdc b/.cursor/rules/hitl-ask-questions.mdc index 3b74cec..15c30fd 100644 --- a/.cursor/rules/hitl-ask-questions.mdc +++ b/.cursor/rules/hitl-ask-questions.mdc @@ -27,15 +27,15 @@ Agent Kit uses **Ask questions** tool (`AskQuestion` / ACP `cursor/ask_question` | `/agent-kit-onboard` | One unresolved readiness decision at a time; confirm Git, branch, remote, hooks, CI, deploy, protection, and external mutations before acting; optional domain-skills scaffold gate after essentials (`Scaffold domain skills` / `Defer (record reason)` / `Skip`) before finish-setup CTA | | `context-guardian` readiness | Surface the first pending readiness check without blocking active plan/HANDOFF work; auto vs manual handoff preference (first time) | | `/start-project` | Single composite Gate A question: disposition + write merged into one pick (with active plan: backlog+write / park+write / modify / cancel; without active plan: write / write+backlog / modify / cancel); Gate B (start first unit / run-plan / edit / Add to backlog / Stop here); vague goal clarify; fallback rule: one numbered list per message | -| `/backlog-add` | Vague-goal clarify; write confirm (`Write plan to backlog` / `Modify proposal first` / `Cancel`); never park/activate/Gate B | +| `/backlog-add` | Vague-goal clarify; write confirm (`Write plan to backlog` / `Modify proposal first` / `Cancel`); when Unprocessed is non-empty, inbox Ask (`Analyze inbox now` / `Enqueue Fix now` / `Not now`) before write; never park/activate/Gate B; never Ask `confirm-provider` / `collaboration.provider` (one-line advisory or silence) | | `/backlog-edit` | Confirm before plan-file mutate (`Edit [plan-file]` / `Cancel`) | | `/backlog-delete` | Confirm before Backlog remove + archive move (`Delete [plan-file] from backlog` / `Cancel`) | | `/backlog-cancel` | Confirm soft cancel (`Cancel [plan-file] on backlog` / `Keep on backlog`) | -| `/continue-plan` | Confirm next `[to-do-id]`; if multiple plans, pick which to resume | +| `/continue-plan` | Confirm next `[to-do-id]`; if multiple plans, pick which to resume; when Unprocessed is non-empty, inbox Ask (`Analyze inbox now` / `Enqueue Fix now` / `Not now`) before the next-unit Ask | | `/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; 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. | +| `/run-plan` | Risk pause (PII, secrets, ambiguous scope); not every tick. When Unprocessed is non-empty (and this is not a `/run-plan-all` per-plan Task), inbox Ask (`Analyze inbox now` / `Enqueue Fix now` / `Not now`) before the first to-do. 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` | When Unprocessed is non-empty, inbox Ask (`Analyze inbox now` / `Enqueue Fix now` / `Not now`) before the 6-way confirm. 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 | | Other commands / docs | Any "stop and ask" / "Want to… (yes/no)" prose in L0 + getting-started / bootstrap | diff --git a/.cursor/scripts/plan-external-review-cloud-backend.test.mjs b/.cursor/scripts/plan-external-review-cloud-backend.test.mjs new file mode 100644 index 0000000..ed9bfff --- /dev/null +++ b/.cursor/scripts/plan-external-review-cloud-backend.test.mjs @@ -0,0 +1,139 @@ +// backend=cloud (Cursor Cloud Agents) contract asserts against the launcher source. +// Source-string asserts only: these tests never call the Cursor REST API and never read +// CURSOR_API_KEY. Spending an operator's key from a test suite is not acceptable. +// ADR: .cursor/memory/decisions/2026-08-14_cursor-cloud-agents-sdk-audits-backend.md +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const SCRIPT = resolve(ROOT, ".cursor/scripts/plan-external-review.sh"); +const SRC = readFileSync(SCRIPT, "utf8"); + +test("cloud is an accepted --backend value in both usage messages", () => { + assert.match(SRC, /--backend requires auto\|claude\|cursor\|cloud/); + assert.match(SRC, /--backend must be auto, claude, cursor, or cloud/); + assert.match(SRC, /"\$2" != "cloud"/); +}); + +test("config allow-list accepts cloud; unknown values still fall back to claude", () => { + assert.match(SRC, /const ok = \["auto", "cursor", "claude", "cloud"\];/); + assert.match(SRC, /ok\.includes\(v\) \? v : "claude"/); +}); + +test("cloud is a pin only: resolve has an explicit case and auto never cascades into it", () => { + assert.match(SRC, /cloud\)\n\s+if cloud_usable; then\n\s+REVIEWER_BACKEND="cloud"/); + // The auto branch must still be Claude -> cursor-agent -> none, with no cloud step. + const autoBranch = SRC.slice( + SRC.indexOf(" auto)"), + SRC.indexOf(" *)\n if claude_usable"), + ); + assert.ok(autoBranch.length > 0, "auto branch not found"); + assert.doesNotMatch(autoBranch, /cloud/); +}); + +test("cloud_usable requires curl, node, and a non-empty CURSOR_API_KEY", () => { + assert.match(SRC, /cloud_usable\(\) \{/); + assert.match(SRC, /command -v curl >\/dev\/null 2>&1 \|\| return 1/); + assert.match(SRC, /\[\[ -n "\$\{CURSOR_API_KEY:-\}" \]\]/); +}); + +test("the API key never reaches argv: curl reads its auth header from stdin config", () => { + assert.match(SRC, /-K -\)/); + assert.match(SRC, /printf 'header = "Authorization: Bearer %s"\\n' "\$\{CURSOR_API_KEY:-\}"/); + // -u KEY: would expose the key in `ps` output. + assert.doesNotMatch(SRC, /curl[^\n]*-u "\$\{?CURSOR_API_KEY/); + // No secret may be echoed or interpolated into a printed/pasteable command. + assert.doesNotMatch(SRC, /VISIBLE_CMD[^\n]*CURSOR_API_KEY/); + assert.doesNotMatch(SRC, /echo[^\n]*\$CURSOR_API_KEY/); +}); + +test("findings-only: the create body hard-codes autoCreatePR and workOnCurrentBranch false", () => { + assert.match(SRC, /autoCreatePR: false,/); + assert.match(SRC, /workOnCurrentBranch: false,/); + // Neither may be sourced from config: they are not operator-tunable. + assert.doesNotMatch(SRC, /config_cloud_string autoCreatePR/); + assert.doesNotMatch(SRC, /config_cloud_string workOnCurrentBranch/); +}); + +test("pushed-state preflight guards against auditing stale state, without fetching", () => { + assert.match(SRC, /cloud_pushed_state_ok\(\) \{/); + assert.match(SRC, /merge-base --is-ancestor HEAD "\$upstream"/); + assert.doesNotMatch(SRC, /git -C "\$ROOT" fetch/); + assert.match( + SRC, + /if \[\[ "\$REVIEWER_BACKEND" == "cloud" \]\] && ! cloud_pushed_state_ok; then\n\s+soft_fail_exit/, + ); +}); + +test("wait-state carries the cloud handles for an exit-3 resume, and no key", () => { + assert.match(SRC, /out\.cloudAgentId = process\.argv\[9\]/); + assert.match(SRC, /out\.cloudRunId = process\.argv\[10\]/); + assert.match(SRC, /WAIT_CLOUD_AGENT_ID="\$agent_id"/); + // The persisted wait-state holds ids, timestamps, and model names only. + const writer = SRC.slice( + SRC.indexOf("wait_state_write() {"), + SRC.indexOf("wait_state_clear() {"), + ); + assert.doesNotMatch(writer, /CURSOR_API_KEY|apiKey|token|secret/i); +}); + +test("run status maps to the existing exit contract and never fabricates a monitor", () => { + assert.match(SRC, /FINISHED\)\n\s+break/); + assert.match(SRC, /ERROR\|CANCELLED\|EXPIRED\)/); + assert.match(SRC, /RAN AND FAILED/); + assert.match(SRC, /NEVER STARTED/); + assert.match(SRC, /refusing to write an empty monitor/); + assert.match(SRC, /exit 3/); + assert.match(SRC, /audits-wait-fresh: created/); +}); + +test("cloud skips the PTY machinery and refuses --batch honestly", () => { + const branch = SRC.slice( + SRC.indexOf('if [[ "$REVIEWER_BACKEND" == "cloud" && "$MODE" != "paste-only"'), + ); + assert.match(branch, /cloud_review_run "\$SINGLE_MONITOR_PATH"/); + assert.match(SRC, /backend=cloud does not support --batch yet/); + // The cloud runner itself must not reach for tmux/screen concepts. + const runner = SRC.slice( + SRC.indexOf("cloud_review_run() {"), + SRC.indexOf("build_batch_prompt() {"), + ); + assert.doesNotMatch( + runner, + /launch_background_terminal|wait_for_pty_progress|audit_session_pressure_gate/, + ); +}); + +test("cloud output contract tells the reviewer not to branch, commit, push, or open a PR", () => { + assert.match(SRC, /build_cloud_prompt\(\) \{/); + assert.match(SRC, /Do NOT create a branch, commit, push, or open a pull request/); + assert.match(SRC, /Your FINAL message must be the complete monitor markdown/); +}); + +// Plan files are gitignored session state, so a clean CI checkout has none. Skip the +// end-to-end dry-run there rather than asserting against a file that cannot exist. +const PLAN_REL = ".cursor/plans/cursor-cloud-agents-sdk.plan.md"; +test( + "dry-run previews the cloud path without spending the key or the API", + { skip: !existsSync(resolve(ROOT, PLAN_REL)) && "plan file absent (gitignored session state)" }, + () => { + const out = execFileSync( + "bash", + [SCRIPT, "--force", "--backend", "cloud", "--dry-run", "cursor-cloud-agents-sdk.plan.md"], + { + cwd: ROOT, + encoding: "utf8", + env: { ...process.env, CURSOR_API_KEY: "placeholder-not-a-real-key" }, + }, + ); + assert.match(out, /reviewer-backend: cloud/); + assert.match(out, /cloud-api-base: https:\/\/api\.cursor\.com/); + assert.match(out, /cloud-write-switches: autoCreatePR=false workOnCurrentBranch=false/); + assert.match(out, /cloud-pushed-state:/); + assert.doesNotMatch(out, /placeholder-not-a-real-key/); + }, +); diff --git a/.cursor/scripts/plan-external-review.sh b/.cursor/scripts/plan-external-review.sh index bcba7b3..3572196 100755 --- a/.cursor/scripts/plan-external-review.sh +++ b/.cursor/scripts/plan-external-review.sh @@ -60,8 +60,10 @@ # 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 +# --backend auto|claude|cursor|cloud: reviewer cascade override. auto uses Claude when # usable, else cursor-agent. Existing config with missing backend stays claude. +# cloud is an opt-in pin only (Cursor Cloud Agents over REST); it is never in the +# auto cascade. cursor reviews the working tree; cloud reviews the PUSHED branch. # --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 / @@ -111,6 +113,11 @@ # 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). +# CURSOR_API_KEY SECRET. Cursor user/service-account key, required by +# backend=cloud only. Read from the environment; never +# echoed, logged, put on a command line, or written to +# wait-state. curl reads it from a stdin config (-K -). +# AGENT_KIT_CURSOR_API_BASE Cloud Agents REST base (default https://api.cursor.com). # # Exit codes: # 0 ok / fresh monitor ready (with --wait-monitor) / soft-fail tip when NOT waiting @@ -119,13 +126,16 @@ # 3 --wait-monitor timeout (no fresh monitor within budget) # 4 soft-fail while --wait-monitor was requested (e.g. missing claude on autonomous # arm, background spawn fell back to paste-only without a waitable arm, the -# post-spawn progress gate aborted early on a silent PTY, or the audit-session cap -# refused the spawn). Without --wait-monitor the same soft-fails stay tip + exit 0. +# post-spawn progress gate aborted early on a silent PTY, the audit-session cap +# refused the spawn, or on backend=cloud: no CURSOR_API_KEY, unpushed HEAD, a +# create call that never started, or a run that reached ERROR/CANCELLED/EXPIRED). +# Without --wait-monitor the same soft-fails stay tip + exit 0. # # 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 +# Cloud Agents ADR: .cursor/memory/decisions/2026-08-14_cursor-cloud-agents-sdk-audits-backend.md set -euo pipefail @@ -158,6 +168,13 @@ WAIT_IMPLEMENTER_MODEL="" WAIT_REVIEWER_MODEL="" REVIEWER_BACKEND="" REVIEWER_BACKEND_EXPLICIT=0 +REVIEWER_BACKEND_WANT="" +# Cloud Agents (backend=cloud). Ids only; the API key is never stored in any of these. +CLOUD_API_BASE="${AGENT_KIT_CURSOR_API_BASE:-https://api.cursor.com}" +CLOUD_MODEL_DEFAULT="composer-2.5" +WAIT_CLOUD_AGENT_ID="" +WAIT_CLOUD_RUN_ID="" +CLOUD_TMP_FILES=() REVIEWER_MODEL="" REVIEWER_MODEL_EXPLICIT=0 ADVISOR_MODEL="" @@ -368,11 +385,11 @@ while [[ $# -gt 0 ]]; do ;; --backend) if [[ $# -lt 2 || -z "${2:-}" ]]; then - echo "error: --backend requires auto|claude|cursor" >&2 + echo "error: --backend requires auto|claude|cursor|cloud" >&2 exit 2 fi - if [[ "$2" != "auto" && "$2" != "claude" && "$2" != "cursor" ]]; then - echo "error: --backend must be auto, claude, or cursor (got: $2)" >&2 + if [[ "$2" != "auto" && "$2" != "claude" && "$2" != "cursor" && "$2" != "cloud" ]]; then + echo "error: --backend must be auto, claude, cursor, or cloud (got: $2)" >&2 exit 2 fi REVIEWER_BACKEND="$2" @@ -468,6 +485,46 @@ tip: no reviewer backend is usable (claude missing/quota-empty and cursor-agent EOF } +tip_no_cloud() { + cat </dev/null || true)" + if [[ -z "$upstream" ]]; then + cat </dev/null; then + local ahead + ahead="$(git -C "$ROOT" rev-list --count "$upstream..HEAD" 2>/dev/null || echo '?')" + cat < claude (existing-install pin). +# Prints auto|claude|cursor|cloud. Missing key / file => claude (existing-install pin). config_review_backend() { if [[ ! -f "$CONFIG" ]]; then echo "claude" @@ -1327,7 +1394,8 @@ config_review_backend() { 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"); + const ok = ["auto", "cursor", "claude", "cloud"]; + process.stdout.write(ok.includes(v) ? v : "claude"); } catch { process.stdout.write("claude"); } @@ -1348,7 +1416,16 @@ 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. +# backend=cloud needs curl, node (JSON), and a non-empty CURSOR_API_KEY in the environment. +# The key is only tested for emptiness here; it is never printed, logged, or exported onward. +cloud_usable() { + command -v curl >/dev/null 2>&1 || return 1 + command -v node >/dev/null 2>&1 || return 1 + [[ -n "${CURSOR_API_KEY:-}" ]] +} + +# Sets REVIEWER_BACKEND to claude|cursor|cloud|none. Distinct from Cursor tick quota hard-stop. +# cloud is a pin only: auto never cascades into it (ADR 2026-08-14 cursor-cloud-agents-sdk). resolve_reviewer_backend() { local want if [[ "$REVIEWER_BACKEND_EXPLICIT" -eq 1 && -n "$REVIEWER_BACKEND" ]]; then @@ -1356,6 +1433,8 @@ resolve_reviewer_backend() { else want="$(config_review_backend)" fi + # Requested value survives the resolve so tips can name the pin that failed. + REVIEWER_BACKEND_WANT="$want" case "$want" in cursor) if cursor_agent_usable; then @@ -1364,6 +1443,13 @@ resolve_reviewer_backend() { REVIEWER_BACKEND="none" fi ;; + cloud) + if cloud_usable; then + REVIEWER_BACKEND="cloud" + else + REVIEWER_BACKEND="none" + fi + ;; auto) if claude_usable; then REVIEWER_BACKEND="claude" @@ -1544,8 +1630,12 @@ wait_state_write() { reviewerModel: process.argv[7], status: process.argv[8], }; + // Cloud Agents handles so an exit-3 resume re-polls the same run instead of + // creating a second agent. Ids and timestamps only: never the API key. + if (process.argv[9]) out.cloudAgentId = process.argv[9]; + if (process.argv[10]) out.cloudRunId = process.argv[10]; 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" + ' "$path" "$arm_epoch" "$deadline" "$remaining" "${WAIT_BACKEND_STAMP:-claude}" "${WAIT_IMPLEMENTER_MODEL:-}" "${WAIT_REVIEWER_MODEL:-}" "$status" "${WAIT_CLOUD_AGENT_ID:-}" "${WAIT_CLOUD_RUN_ID:-}" } wait_state_clear() { @@ -1866,6 +1956,235 @@ Contract reminders: EOF } +# backend=cloud output contract. The reviewer runs on a Cursor VM against a clone, so it +# cannot write into this working tree; the launcher materializes the monitor locally from +# the terminal run result. That keeps freshness polling and findings-only intact. +build_cloud_prompt() { + local base="$1" + cat </dev/null 2>&1; then + printf '%s' "$default" + return + fi + 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 c = j && j.externalPlanReview && j.externalPlanReview.cloudAgent; + const v = c && c[key]; + process.stdout.write(typeof v === "string" && v.trim() ? v.trim() : fallback); + } catch { + process.stdout.write(fallback); + } + ' "$CONFIG" "$key" "$default" +} + +# Normalize the origin remote to the https GitHub form Cloud Agents expect. An explicit +# cloudAgent.repoUrl always wins. SSH host aliases (git@my-alias:owner/repo.git, common with +# per-repo deploy keys) cannot be resolved to a real host here, so this prints nothing and +# the caller soft-fails asking for an explicit repoUrl rather than guessing wrong. +cloud_repo_url() { + local configured raw host_path host path + configured="$(config_cloud_string repoUrl "")" + if [[ -n "$configured" ]]; then + printf '%s' "$configured" + return + fi + raw="$(git -C "$ROOT" remote get-url origin 2>/dev/null || true)" + [[ -n "$raw" ]] || return 0 + case "$raw" in + git@*:*) + host_path="${raw#git@}" + host="${host_path%%:*}" + path="${host_path#*:}" + raw="https://${host}/${path}" + ;; + ssh://git@*) + raw="https://${raw#ssh://git@}" + ;; + esac + raw="${raw%.git}" + if [[ "$raw" != https://github.com/*/* ]]; then + return 0 + fi + printf '%s' "$raw" +} + +# One authenticated REST call. The key is passed through curl's stdin config (-K -), never +# on the command line, so it cannot appear in `ps`, in a printed command, or in a log. +# Body (never secret) goes through a file. Prints the HTTP status; body lands in $2. +cloud_api() { + local method="$1" out_file="$2" url="$3" body_file="${4:-}" + local args=(-sS --max-time 120 -o "$out_file" -w '%{http_code}' -X "$method" -K -) + if [[ -n "$body_file" ]]; then + args+=(-H "Content-Type: application/json" --data-binary "@$body_file") + fi + printf 'header = "Authorization: Bearer %s"\n' "${CURSOR_API_KEY:-}" \ + | curl "${args[@]}" "$url" 2>/dev/null || true +} + +cloud_json_get() { + node -e ' + const fs = require("fs"); + try { + const j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + const v = process.argv[2].split(".").reduce((a, k) => (a == null ? a : a[k]), j); + process.stdout.write(v == null ? "" : String(v)); + } catch { + process.stdout.write(""); + } + ' "$1" "$2" +} + +# Create the Cloud Agent, poll its run, and materialize the monitor locally on FINISHED. +# Exit contract matches the rest of the launcher: 0 fresh-ready, 3 timeout, 4 soft-fail +# while waiting (0 with a tip when --wait-monitor is off). Never fabricates a monitor. +cloud_review_run() { + local monitor_rel="$1" + local slug body_file resp_file code agent_id run_id status model repo_url starting_ref + slug="$(slug_from_monitor_path "$monitor_rel")" + model="$(config_cloud_string model "$CLOUD_MODEL_DEFAULT")" + repo_url="$(cloud_repo_url)" + starting_ref="$(config_cloud_string startingRef "$(git -C "$ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo main)")" + + if [[ -z "$repo_url" ]]; then + cat <&2 +tip: backend=cloud could not derive a GitHub repo URL from the origin remote + ($(git -C "$ROOT" remote get-url origin 2>/dev/null || echo 'no origin')). + SSH host aliases cannot be resolved to a real host, so nothing is guessed. + Set externalPlanReview.cloudAgent.repoUrl to the https://github.com// form + and re-arm. Audit skipped; Field Report stays owed. +EOF + soft_fail_exit + fi + + body_file="$(mktemp -t agent-kit-cloud-body)" + resp_file="$(mktemp -t agent-kit-cloud-resp)" + CLOUD_TMP_FILES=("$body_file" "$resp_file") + # Most paths out of this function are `exit`, so cleanup hangs off EXIT, not RETURN. + # shellcheck disable=SC2064 + trap 'rm -f "${CLOUD_TMP_FILES[@]}"' EXIT + + # autoCreatePR and workOnCurrentBranch are hard-false: a reviewer must never touch the + # branch or open a PR (findings-only; ADR 2026-08-14_cursor-cloud-agents-sdk-audits-backend). + CLOUD_PROMPT="$(build_cloud_prompt "$PROMPT")" node -e ' + const fs = require("fs"); + fs.writeFileSync(process.argv[1], JSON.stringify({ + prompt: { text: process.env.CLOUD_PROMPT }, + model: { id: process.argv[2] }, + name: ("audit " + process.argv[3]).slice(0, 100), + repos: [{ url: process.argv[4], startingRef: process.argv[5] }], + autoCreatePR: false, + workOnCurrentBranch: false, + })); + ' "$body_file" "$model" "$slug" "$repo_url" "$starting_ref" + + echo "audits: creating Cursor Cloud Agent (findings-only, autoCreatePR=false)" + echo " api: $CLOUD_API_BASE/v1/agents" + echo " repo: $repo_url @ $starting_ref" + echo " model: $model" + code="$(cloud_api POST "$resp_file" "$CLOUD_API_BASE/v1/agents" "$body_file")" + agent_id="$(cloud_json_get "$resp_file" agent.id)" + run_id="$(cloud_json_get "$resp_file" run.id)" + if [[ "$code" != "200" && "$code" != "201" ]] || [[ -z "$agent_id" || -z "$run_id" ]]; then + cat <&2 +tip: backend=cloud could not start the review (HTTP ${code:-none}). The review NEVER STARTED, + so no monitor exists and the Field Report stays owed. Check CURSOR_API_KEY validity and + that the Cursor GitHub App can read $repo_url. This is not a Cursor tick usage-limit hard-stop. +EOF + soft_fail_exit + fi + WAIT_CLOUD_AGENT_ID="$agent_id" + WAIT_CLOUD_RUN_ID="$run_id" + echo "audits: cloud agent $agent_id run $run_id (inspect: https://cursor.com/agents)" + if [[ "$WAIT_MONITOR" -eq 1 ]]; then + wait_state_write "$slug" "${WAIT_ARM_EPOCH:-$(date +%s)}" "${WAIT_DEADLINE:-0}" "${WAIT_REMAINING:-0}" "armed" || true + fi + + if [[ "$WAIT_MONITOR" -ne 1 ]]; then + cat <&2 +tip: the cloud review RAN AND FAILED (status $status). No monitor is written and none is + fabricated. Field Report stays owed. Inspect the run at https://cursor.com/agents and re-arm. +EOF + soft_fail_exit + ;; + esac + sleep 10 + done + + local result + result="$(cloud_json_get "$resp_file" result)" + if [[ -z "$result" ]]; then + echo "tip: cloud run FINISHED with an empty result; refusing to write an empty monitor. Field Report stays owed." >&2 + soft_fail_exit + fi + mkdir -p "$(dirname "$ROOT/$monitor_rel")" + { + echo "" + printf '%s\n' "$result" + } >"$ROOT/$monitor_rel" + wait_state_clear "$slug" + cat <&2 +tip: backend=cloud does not support --batch yet (one cloud agent per plan is not wired). + Arm each plan separately, or use backend "claude" / "cursor" for the batch. + Audit skipped; Field Reports stay owed. +EOF + soft_fail_exit + fi + echo case "$MODE" in @@ -2330,6 +2669,20 @@ if [[ "$DRY_RUN" -eq 1 ]]; then echo " paste-cmd: $PASTE_CMD" echo " focus-terminal: $FOCUS_TERMINAL" echo " triage: $TRIAGE_PASTE" + if [[ "$REVIEWER_BACKEND" == "cloud" || "$REVIEWER_BACKEND_WANT" == "cloud" ]]; then + echo " cloud-api-base: $CLOUD_API_BASE (key from CURSOR_API_KEY env; never printed)" + echo " cloud-repo: $(cloud_repo_url || true) @ $(config_cloud_string startingRef "$(git -C "$ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo main)")" + if [[ -z "$(cloud_repo_url || true)" ]]; then + echo " cloud-repo-note: unresolved (SSH alias or non-GitHub origin) - set cloudAgent.repoUrl" + fi + echo " cloud-model: $(config_cloud_string model "$CLOUD_MODEL_DEFAULT")" + echo " cloud-write-switches: autoCreatePR=false workOnCurrentBranch=false (findings-only, not configurable)" + if cloud_pushed_state_ok >/dev/null 2>&1; then + echo " cloud-pushed-state: ok (HEAD is an ancestor of upstream)" + else + echo " cloud-pushed-state: STALE - would soft-fail (HEAD not pushed; cloud clones the remote)" + fi + fi print_session_pressure_dry_run print_wait_monitor_dry_run "$SINGLE_MONITOR_PATH" exit 0 @@ -2343,6 +2696,13 @@ fi echo +# backend=cloud has no PTY: create -> poll -> the launcher writes the monitor from the +# terminal run result. It deliberately skips launch_background_terminal, the progress +# gate, and the detached-session pressure gate, which are tmux/screen concepts. +if [[ "$REVIEWER_BACKEND" == "cloud" && "$MODE" != "paste-only" && "$POLL_ONLY" -ne 1 ]]; then + cloud_review_run "$SINGLE_MONITOR_PATH" +fi + case "$MODE" in paste-only) emit_paste_only "review" diff --git a/CHANGELOG.md b/CHANGELOG.md index 71997c3..bd2995f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,26 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and ## [Unreleased] +## [5.3.0] - 2026-08-15 + +### Added + +- Optional **Cursor Cloud Agents audits backend**: `externalPlanReview.backend: "cloud"` runs the post-hoc plan review on Cursor Cloud Agents over REST (`https://api.cursor.com`) and writes the same `plan-monitor-*.md` findings contract. It is a **pin only** — `"auto"` still cascades Claude → Cursor Agent CLI and never reaches it. Because Cloud Agents clone the repo, it reviews the **pushed** branch and a preflight soft-fails on unpushed `HEAD` instead of auditing state the reviewer cannot see (`backend: "cursor"` remains the working-tree reviewer). The launcher writes the monitor from the terminal run result, so freshness, exits `0|3|4`, `autoRemediate: false`, implementer≠reviewer, and `/plan-review-triage` are unchanged; `autoCreatePR` and `workOnCurrentBranch` are hard-off and not configurable. `ERROR` / `CANCELLED` / `EXPIRED` / empty results write no monitor and fabricate nothing; agent and run ids persist in wait-state for exit-3 resume. `--batch` is refused rather than partially covered. New config: `externalPlanReview.cloudAgent` (`repoUrl`, `startingRef`, `model`). Requires `curl`, `node`, and `CURSOR_API_KEY` — no new runtime dependency, and `@cursor/sdk` is deliberately **not** vendored (it needs Node 22.13+ against the kit's `engines >=20`). ADR `2026-08-14_cursor-cloud-agents-sdk-audits-backend.md`; tests `.cursor/scripts/plan-external-review-cloud-backend.test.mjs`. +- Spec Kit SDD research: Accepted ADR maps GitHub Spec Kit commands onto existing Agent Kit surfaces (adapt vocabulary, ignore runtime / CLI / `.specify/`). Getting-started pointer and plan-template Goal what/why cue. ADR `2026-08-14_spec-kit-sdd-adapt-ignore.md`. +- **Multi-instance Mission Control broadcast**: `agent-kit dashboard-broadcast` / `npm run dashboard:broadcast` binds the same per-workspace port allocation as `/dashboard` (hash of the snapshot root in `3333-3588`) instead of a hardcoded `3333`, so a broadcast starts **beside** an already-running instance. New `classifyBroadcastListener` / `resolveBroadcastPort` / `describeBroadcastListener` in `dashboard/lib/guards.mjs` walk the candidate ports and reuse **only** this workspace's own broadcast — reuse requires LAN reachability, not just a matching `system.repoRoot`, because a loopback panel answers `?token=` with 200 without any token. Everything else (our loopback panel, another workspace, a token-gated instance this token cannot identify, an unknown process) is skipped and left running. Export a stable `MISSION_CONTROL_TOKEN` to reuse an existing broadcast instead of starting another. The broadcast log is now per workspace (`/tmp/mission-control-broadcast-.log`). + +### Fixed + +- HANDOFF HITL claims (parked, approved, deferred, confirmed, stopped-by-operator) must record Ask id, operator reply, or `agent-inferred`. A refused command is terminal in the worker contract (same class as never `/git-prod`). Queue and audit start preflight the detached `agent-kit-audit-*` session pile (warn or offer reap; cap surfaces in the orchestrator Ask, not only launcher stderr). +- `/backlog-add` (and the reused Broad Intake worker) never Asks on non-essential `confirm-provider` / `collaboration.provider`. One-line advisory or silence, then Broad Intake → write Ask. +- Audit wait-slice exit 3 with leftover `waitTimeoutSeconds` resumes in the same `/run-plan` / `/run-plan-all` session (re-arm `--wait-monitor`) before advancing the queue or skipping triage. Exit 3 is still timeout-only and is never narrated as reviewed. +- Broadcast preflight no longer answers a busy port with `kill "$(lsof -nP -iTCP:3333 -sTCP:LISTEN -t)"`. It names who holds each port it walked past (this workspace's loopback panel, another workspace by root, a token-gated instance, an unidentified process), leaves them running, and offers a `kill` line **only** for a listener proven to be this workspace's own. Explicit `PORT` still refuses rather than silently moving. The starter also stamps `PORT` and `MISSION_CONTROL_REPO_ROOT` onto the detached `serve.mjs` env on both spawn paths, so the server binds the allocated port instead of falling back to `3333`. The token gate for non-loopback bind is unchanged. + +### Changed + +- Operator submitted the Core Pack plugin to Cursor Marketplace (pending review). +- Daily dogfood and audit path stays on `/run-plan`, `/run-plan-all`, `/continue-plan`, and `/backlog-add`. When Unprocessed is non-empty, those commands may Ask `Analyze inbox now` / `Enqueue Fix now` / `Not now` (never auto-analyze). `/dogfood` stays file-only. Notes become plans/memory after HITL, never `plan-monitor-*.md`. When audits are enabled, arm/wait/rearm/triage continuation is the `/run-plan` / `/run-plan-all` default; `/plan-external-review` and `/plan-review-triage` stay specialist SoT. ADR `2026-08-14_main-command-dogfood-audit-routing.md`. + ## [5.2.1] - 2026-08-14 ### Changed diff --git a/dashboard/lib/guards.d.mts b/dashboard/lib/guards.d.mts index d885f72..4f9e119 100644 --- a/dashboard/lib/guards.d.mts +++ b/dashboard/lib/guards.d.mts @@ -52,6 +52,39 @@ export function resolveMissionControlPort(args: { probe: (port: number) => { listening: boolean; repoRoot: string | null }; opts?: PortOpts; }): { port: number; reuse: boolean; explicit: boolean }; +type BroadcastProbe = { + listening: boolean; + repoRoot?: string | null; + acceptsToken?: boolean; + tokenGated?: boolean; + lanReachable?: boolean | null; +}; +type BroadcastListenerKind = + | "free" + | "self-broadcast" + | "self-other-mode" + | "foreign" + | "token-gated" + | "unknown"; +export function classifyBroadcastListener( + info: BroadcastProbe, + repoRoot: string, +): BroadcastListenerKind; +export function describeBroadcastListener( + kind: string, + info?: { port?: number | null; repoRoot?: string | null }, +): string; +export function resolveBroadcastPort(args: { + repoRoot: string; + envPort?: string | number | null; + probe: (port: number) => BroadcastProbe; + opts?: PortOpts; +}): { + port: number; + reuse: boolean; + explicit: boolean; + skipped: Array<{ port: number; kind: string; repoRoot: string | null }>; +}; export function isSafeRepoRelativePath(relPath: unknown): boolean; export function resolveBindHost(envHost?: string | null): string; export function isLoopbackBindHost(host: string | undefined | null): boolean; diff --git a/dashboard/lib/guards.mjs b/dashboard/lib/guards.mjs index a5c6e53..6a11253 100644 --- a/dashboard/lib/guards.mjs +++ b/dashboard/lib/guards.mjs @@ -194,6 +194,134 @@ export function resolveMissionControlPort({ repoRoot, envPort, probe, opts = {} ); } +/** + * Classify what holds a candidate Mission Control port, from the point of view + * of a **broadcast** (LAN) start. + * + * Broadcast needs a non-loopback, token-gated listener of its own. A loopback + * `/dashboard` for this same workspace answers `?token=…` with 200 (the query is + * ignored when no token is required), so "same repoRoot + accepts our token" is + * not sufficient: reuse also requires the listener to answer on a LAN address. + * `lanReachable` may be `null`/omitted when the host has no LAN IPv4 to probe — + * then a root + token match is accepted. + * + * `token-gated` means: something Mission-Control-shaped is listening but refuses + * our token, so its owner cannot be confirmed. Like `foreign` and `unknown` it is + * never killed and never reused. + * + * @param {{ listening: boolean, repoRoot?: string|null, acceptsToken?: boolean, tokenGated?: boolean, lanReachable?: boolean|null }} info + * @param {string} repoRoot + * @returns {"free"|"self-broadcast"|"self-other-mode"|"foreign"|"token-gated"|"unknown"} + */ +export function classifyBroadcastListener(info, repoRoot) { + if (!info || info.listening !== true) return "free"; + const mine = sameRepoRoot(info.repoRoot, repoRoot); + if (mine && info.acceptsToken === true) { + return info.lanReachable === false ? "self-other-mode" : "self-broadcast"; + } + if (mine) return "self-other-mode"; + if (info.repoRoot != null) return "foreign"; + return info.tokenGated === true ? "token-gated" : "unknown"; +} + +/** Operator-facing wording for each {@link classifyBroadcastListener} kind. */ +const BROADCAST_LISTENER_LABELS = { + free: "free", + "self-broadcast": "this workspace's broadcast", + "self-other-mode": + "this workspace, but not a broadcast listener (loopback /dashboard, or another token)", + foreign: "another workspace", + "token-gated": "a token-gated Mission Control whose owner this token cannot confirm", + unknown: "an unidentified process", + exhausted: "no free port in this workspace's range", +}; + +/** + * One honest line about who holds a port. Used by the broadcast preflight so the + * operator is told *which* instance is in the way instead of a blind kill recipe. + * + * @param {string} kind + * @param {{ port?: number | null, repoRoot?: string | null }} [info] + * @returns {string} + */ +export function describeBroadcastListener(kind, info = {}) { + const label = BROADCAST_LISTENER_LABELS[kind] || BROADCAST_LISTENER_LABELS.unknown; + const owner = + kind === "foreign" && info.repoRoot ? `another workspace (${info.repoRoot})` : label; + return info.port == null ? owner : `${info.port}: ${owner}`; +} + +/** + * Pick a listen port for a broadcast (LAN) Mission Control instance. + * + * Same per-workspace candidate walk as {@link resolveMissionControlPort}, with + * broadcast-aware reuse: only this workspace's own broadcast listener is reused. + * Anything else on a candidate port (our loopback panel, another workspace, an + * unidentified process) is **skipped, never killed** — that is what allows more + * than one Mission Control instance to run at once. + * + * Explicit `PORT` still refuses rather than walking, so an operator who pinned a + * port is told the truth instead of silently landing somewhere else. + * + * @param {object} args + * @param {string} args.repoRoot + * @param {string | number | undefined | null} [args.envPort] - raw `PORT` env + * @param {(port: number) => { listening: boolean, repoRoot?: string|null, acceptsToken?: boolean, tokenGated?: boolean, lanReachable?: boolean|null }} args.probe + * @param {{ base?: number, range?: number }} [args.opts] + * @returns {{ port: number, reuse: boolean, explicit: boolean, skipped: Array<{ port: number, kind: string, repoRoot: string | null }> }} + */ +export function resolveBroadcastPort({ repoRoot, envPort, probe, opts = {} }) { + const root = resolve(String(repoRoot || "").trim() || "."); + const raw = + envPort != null && String(envPort).trim() !== "" + ? Number.parseInt(String(envPort), 10) + : Number.NaN; + /** @type {Array<{ port: number, kind: string, repoRoot: string | null }>} */ + const skipped = []; + + if (Number.isFinite(raw) && raw > 0) { + const info = probe(raw) || { listening: false }; + const kind = classifyBroadcastListener(info, root); + if (kind === "free") return { port: raw, reuse: false, explicit: true, skipped }; + if (kind === "self-broadcast") return { port: raw, reuse: true, explicit: true, skipped }; + const owner = info.repoRoot ? String(info.repoRoot) : null; + skipped.push({ port: raw, kind, repoRoot: owner }); + const detail = + kind === "self-other-mode" + ? "it is this workspace's Mission Control but not a broadcast listener (loopback /dashboard, or a different MISSION_CONTROL_TOKEN)" + : kind === "foreign" + ? `it is Mission Control for ${owner}, and broadcast never touches another workspace` + : kind === "token-gated" + ? "a token-gated Mission Control is there whose owner this token cannot confirm, so it is left alone" + : "an unidentified process is there, so it is left alone"; + const err = new Error( + `PORT ${raw} is not available for broadcast: ${detail}. Unset PORT to let broadcast pick a free per-workspace port instead.`, + ); + Object.assign(err, { + broadcast: { port: raw, kind, repoRoot: owner, explicit: true, skipped }, + }); + throw err; + } + + for (const port of portCandidatesForRepoRoot(root, opts)) { + const info = probe(port) || { listening: false }; + const kind = classifyBroadcastListener(info, root); + if (kind === "free") return { port, reuse: false, explicit: false, skipped }; + if (kind === "self-broadcast") return { port, reuse: true, explicit: false, skipped }; + skipped.push({ port, kind, repoRoot: info.repoRoot ? String(info.repoRoot) : null }); + } + + const base = Number.isFinite(opts.base) ? opts.base : DEFAULT_PORT_BASE; + const range = Number.isFinite(opts.range) && opts.range > 0 ? opts.range : DEFAULT_PORT_RANGE; + const err = new Error( + `No free Mission Control broadcast port in ${base}-${base + range - 1} for ${root}. Stop one of your own unused instances, or set PORT to a free port.`, + ); + Object.assign(err, { + broadcast: { port: null, kind: "exhausted", repoRoot: null, explicit: false, skipped }, + }); + throw err; +} + export const MAX_STRING = { branch: 64, lastCommit: 120, diff --git a/dashboard/start-broadcast.mjs b/dashboard/start-broadcast.mjs index 61e4798..1570925 100644 --- a/dashboard/start-broadcast.mjs +++ b/dashboard/start-broadcast.mjs @@ -5,11 +5,17 @@ * Opt-in LAN bind: HOST=0.0.0.0 (or explicit non-loopback), requires * MISSION_CONTROL_TOKEN (generated when unset), detach-starts serve.mjs, * prints LAN URL(s) with token. Does not weaken loopback `/dashboard`. + * + * Multi-instance: the listen port is the same per-workspace allocation the + * loopback starter uses (hash of the snapshot root in the 3333-3588 range unless + * PORT is set). Candidate ports held by this workspace's loopback panel, another + * workspace, or an unidentified process are skipped - never killed - so a + * broadcast can come up beside an already-running Mission Control. */ import { execFileSync, execSync, spawn } from "node:child_process"; import { existsSync, openSync, realpathSync } from "node:fs"; -import { basename, dirname, join } from "node:path"; +import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { buildBroadcastShareUrl, @@ -19,13 +25,17 @@ import { } from "./lib/broadcast-share.mjs"; import { BROADCAST_TOKEN_ENV, + REPO_ROOT_ENV, + describeBroadcastListener, escapePerlDoubleQuoted, generateBroadcastToken, isLoopbackBindHost, isValidBroadcastToken, listLanIPv4Addresses, normalizeAuthToken, + repoRootLogId, resolveBindHost, + resolveBroadcastPort, resolveContextConfigPath, resolveSnapshotRepoRoot, } from "./lib/guards.mjs"; @@ -37,11 +47,14 @@ 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 LOG = process.env.MISSION_CONTROL_LOG || "/tmp/mission-control-broadcast.log"; -const PORT = Number.parseInt(process.env.PORT || "3333", 10); +const LOG = + process.env.MISSION_CONTROL_LOG || `/tmp/mission-control-broadcast-${repoRootLogId(ROOT)}.log`; const READY_TIMEOUT_MS = 20_000; const READY_POLL_MS = 250; +/** Allocated listen port for this run (see resolveBroadcastPort in main). */ +let PORT = 0; + function resolveBroadcastEnv() { const env = { ...process.env }; let host = resolveBindHost(env.HOST); @@ -58,30 +71,84 @@ function resolveBroadcastEnv() { return { env, host, token }; } -function urlsForProbe(token) { +function urlsForProbe(token, port = PORT) { const q = `?token=${encodeURIComponent(token)}`; - const urls = [`http://127.0.0.1:${PORT}/${q}`]; + const urls = [`http://127.0.0.1:${port}/${q}`]; for (const ip of listLanIPv4Addresses()) { - urls.push(`http://${ip}:${PORT}/${q}`); + urls.push(`http://${ip}:${port}/${q}`); } return urls; } +/** HTTP status code as a string; "000" when the connection failed. */ +function httpStatus(url) { + try { + return execFileSync( + "curl", + ["-s", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "3", url], + { encoding: "utf8", timeout: 5000 }, + ).trim(); + } catch { + return "000"; + } +} + function probeHttp(url) { + return httpStatus(url) === "200"; +} + +/** Snapshot root reported by a listener that accepts our token, else null. */ +function snapshotRootAt(port, token) { + const url = `http://127.0.0.1:${port}/dashboard-data.json?token=${encodeURIComponent(token)}`; try { - const code = execFileSync("curl", ["-sf", "-o", "/dev/null", "-w", "%{http_code}", url], { + const raw = execFileSync("curl", ["-sf", url], { encoding: "utf8", - timeout: 3000, - }).trim(); - return code === "200"; + timeout: 8000, + maxBuffer: 10 * 1024 * 1024, + }); + const root = JSON.parse(raw)?.system?.repoRoot; + return typeof root === "string" && root.trim() ? resolve(root.trim()) : null; } catch { - return false; + return null; } } -function listeningPids() { +/** + * Probe one candidate port for `resolveBroadcastPort`. + * + * `lanReachable` is what separates our own broadcast (reusable) from our own + * loopback panel: a loopback listener needs no token, so it answers `?token=…` + * with 200 on 127.0.0.1 while staying unreachable on every LAN address. + */ +function probeBroadcastPort(port, token) { + const q = `?token=${encodeURIComponent(token)}`; + const loopbackStatus = httpStatus(`http://127.0.0.1:${port}/${q}`); + const listening = loopbackStatus !== "000" || listeningPids(port).length > 0; + if (!listening) { + return { listening: false, repoRoot: null, acceptsToken: false, tokenGated: false }; + } + const acceptsToken = loopbackStatus === "200"; + if (!acceptsToken) { + return { + listening: true, + repoRoot: null, + acceptsToken: false, + tokenGated: loopbackStatus === "401" || loopbackStatus === "403", + }; + } + const ips = listLanIPv4Addresses(); + return { + listening: true, + repoRoot: snapshotRootAt(port, token), + acceptsToken: true, + tokenGated: false, + lanReachable: ips.length > 0 ? ips.some((ip) => probeHttp(`http://${ip}:${port}/${q}`)) : null, + }; +} + +function listeningPids(port = PORT) { try { - const out = execFileSync("lsof", ["-nP", `-iTCP:${PORT}`, "-sTCP:LISTEN", "-t"], { + const out = execFileSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], { encoding: "utf8", timeout: 3000, }).trim(); @@ -124,6 +191,7 @@ function detachStart(env) { const hostEsc = escapePerlDoubleQuoted(String(env.HOST)); const tokenEsc = escapePerlDoubleQuoted(String(env[BROADCAST_TOKEN_ENV])); const portEsc = escapePerlDoubleQuoted(String(PORT)); + const snapEsc = escapePerlDoubleQuoted(ROOT); const perl = [ "use POSIX qw(setsid);", "exit if fork;", @@ -136,6 +204,7 @@ function detachStart(env) { `$ENV{HOST}="${hostEsc}";`, `$ENV{${BROADCAST_TOKEN_ENV}}="${tokenEsc}";`, `$ENV{PORT}="${portEsc}";`, + `$ENV{${REPO_ROOT_ENV}}="${snapEsc}";`, `exec("node","${serveEsc}");`, ].join(" "); @@ -159,6 +228,68 @@ async function waitReady(urls) { return null; } +/** + * Preflight: name every candidate port we walked past. Nothing here is killed — + * a busy port means another Mission Control (ours or someone else's) keeps + * running and broadcast lands on the next free per-workspace port. + */ +function reportSkipped(skipped) { + if (!skipped || skipped.length === 0) return; + console.log("Ports already held (left running):"); + for (const entry of skipped) { + console.log(` ${describeBroadcastListener(entry.kind, entry)}`); + } + if (skipped.some((entry) => entry.kind === "token-gated")) { + console.log( + ` Export ${BROADCAST_TOKEN_ENV} with an existing broadcast's token to reuse it instead of starting another.`, + ); + } +} + +/** Recovery text for a port we may not take. Never a blind kill of a foreign listener. */ +function recoveryLines(kind, port) { + switch (kind) { + case "self-other-mode": + return [ + ` That listener is this workspace (${ROOT}). Stop it yourself if you want this exact port:`, + ` kill "$(lsof -nP -iTCP:${port} -sTCP:LISTEN -t)"`, + " Or retry with PORT unset: broadcast will take a free per-workspace port and leave it running.", + ]; + case "foreign": + return [ + " Leave that workspace's Mission Control running. Retry with PORT unset to take a free per-workspace port.", + ]; + case "token-gated": + return [ + ` Export ${BROADCAST_TOKEN_ENV} with that instance's token to reuse it, or retry with PORT unset.`, + ]; + case "exhausted": + return [ + " Every candidate port in this workspace's range is held by an instance that is not ours to stop.", + " Stop one of your own Mission Control instances, or set PORT to a port you know is free.", + ]; + default: + return [ + " Leave that process alone. Retry with PORT unset to take a free per-workspace port.", + ]; + } +} + +function reportRefusal(err) { + console.error(err instanceof Error ? err.message : String(err)); + const info = err?.broadcast; + if (!info) return; + if (info.kind === "exhausted" && info.skipped?.length) { + console.error("Ports checked (all left running):"); + for (const entry of info.skipped.slice(0, 8)) { + console.error(` ${describeBroadcastListener(entry.kind, entry)}`); + } + } + for (const line of recoveryLines(info.kind, info.port)) { + console.error(line); + } +} + async function main() { const { env, host, token } = resolveBroadcastEnv(); if (isLoopbackBindHost(host)) { @@ -168,6 +299,26 @@ async function main() { process.exit(1); } + let allocation; + try { + allocation = resolveBroadcastPort({ + repoRoot: ROOT, + envPort: process.env.PORT, + probe: (port) => probeBroadcastPort(port, token), + }); + } catch (err) { + reportRefusal(err); + process.exit(1); + } + + reportSkipped(allocation.skipped); + + PORT = allocation.port; + // Pin the allocation for this process and the detached server. + process.env.PORT = String(PORT); + env.PORT = String(PORT); + env[REPO_ROOT_ENV] = ROOT; + const urls = urlsForProbe(token); const primaryLan = listLanIPv4Addresses()[0]; const displayUrl = @@ -175,15 +326,9 @@ async function main() { ? `http://${primaryLan}:${PORT}/?token=${encodeURIComponent(token)}` : urls[0]; - const already = listeningPids().length > 0 && urls.some((u) => probeHttp(u)); - if (!already) { - if (listeningPids().length > 0) { - console.error( - `Port ${PORT} is listening but did not accept the broadcast token. Stop the existing Mission Control instance (loopback /dashboard) first, then retry.`, - ); - console.error(` kill "$(lsof -nP -iTCP:${PORT} -sTCP:LISTEN -t)"`); - process.exit(1); - } + if (allocation.reuse) { + console.log(`Mission Control broadcast already listening on port ${PORT} for ${ROOT}`); + } else { console.log(`Starting Mission Control broadcast on ${host}:${PORT}…`); detachStart(env); const ready = await waitReady(urls); @@ -192,8 +337,6 @@ async function main() { console.error(`Check the log: ${LOG}`); process.exit(1); } - } else { - console.log(`Mission Control broadcast already listening on port ${PORT}`); } const shareBase = resolveShareBase(process.env); @@ -233,7 +376,10 @@ async function main() { " Share is a cosmetic Mission Kit (or BYO) link; phone must still reach this LAN.", ); } - console.log(" Config writes stay loopback-only. Stop: kill the LISTEN pid on this port."); + console.log(` Root: ${ROOT}`); + console.log( + " Config writes stay loopback-only. Stop this workspace only: kill the LISTEN pid on this port.", + ); console.log(" Firewall: allow inbound TCP on this port for your LAN profile if needed."); console.log(""); diff --git a/docs/consumer-configuration.md b/docs/consumer-configuration.md index 90e4575..49d0f98 100644 --- a/docs/consumer-configuration.md +++ b/docs/consumer-configuration.md @@ -31,12 +31,13 @@ Writable via tab = the Mission Control Config tab (More menu) can save the key t | `cursorUpdateCheck.changelogUrl` (HTTPS) | `config.example.json` | `cursor-update-awareness.ts` | Yes | No | `{ "cursorUpdateCheck": { "changelogUrl": "https://cursor.com/changelog" } }` | | `updateApply.auto` (boolean) | `config.example.json` | `check-updates.ts` | Yes (CLI/`/update` only) | **Never** (ADR: apply stays `/update` Ask or explicit CLI) | `{ "updateApply": { "auto": false } }` — keep `false` unless you accept silent L0 overwrite risk; edit by hand only | | `externalPlanReview.enabled` (boolean) | `config.example.json` | `packages/cli/src/plan-loop/external-review.ts`, `/run-plan` | Yes | Yes (Audits) | `{ "externalPlanReview": { "enabled": true } }` | -| `externalPlanReview.backend` (`claude`) | `config.example.json` | `external-review.ts` | Yes | Yes (Audits; single option) | `{ "externalPlanReview": { "backend": "claude" } }` | +| `externalPlanReview.backend` (`auto` \| `claude` \| `cursor` \| `cloud`) | `config.example.json` | `.cursor/scripts/plan-external-review.sh` (`config_review_backend`) | Yes | Yes (Audits) | `{ "externalPlanReview": { "backend": "auto" } }` — `auto` = Claude when usable, else Cursor Agent CLI; `cursor` = the `cursor-agent` binary; `cloud` = Cursor Cloud Agents over REST (opt-in pin only, never in the `auto` cascade). An unrecognised value falls back to `claude`. Distinct from `agent-kit run-plan --backend`. | | `externalPlanReview.autoRemediate` (boolean) | `config.example.json` | `/run-plan` remediation gate | Yes | Yes (Audits) | `{ "externalPlanReview": { "autoRemediate": false } }` | | `externalPlanReview.offerOnExhausted` (boolean) | `config.example.json` | `/run-plan` exhaustion Ask | Yes | Yes (Audits) | `{ "externalPlanReview": { "offerOnExhausted": true } }` | | `externalPlanReview.mode` (`paste` \| `autonomous`) | `config.example.json` | `external-review.ts` | Yes | Yes (Audits) | `{ "externalPlanReview": { "mode": "autonomous" } }` | | `externalPlanReview.midBatchAudits` (boolean) | `config.example.json` | `/run-plan-all` mid-batch policy | Yes | Yes (Audits) | `{ "externalPlanReview": { "midBatchAudits": true } }` | | `externalPlanReview.preflight` (`off` \| `warn` \| `block`) | `config.example.json` | `/run-plan` audits pre-flight | Yes | Yes (Audits) | `{ "externalPlanReview": { "preflight": "warn" } }` | +| `externalPlanReview.cloudAgent` (`repoUrl`, `startingRef`, `model`) | `config.example.json` | `plan-external-review.sh` when `backend: "cloud"` | Yes | No (edit config.json) | `{ "externalPlanReview": { "cloudAgent": { "repoUrl": null, "startingRef": null, "model": "composer-2.5" } } }` — Cloud Agents clone `repoUrl` onto a Cursor VM, so this backend reviews only **pushed** state; the launcher soft-fails with a tip when `HEAD` is not an ancestor of the upstream. For a **working-tree** review use `backend: "cursor"` instead. `repoUrl` defaults to the `origin` remote, `startingRef` to the current branch. `autoCreatePR` and `workOnCurrentBranch` are hard-off and deliberately not configurable (findings-only). ADR `2026-08-14_cursor-cloud-agents-sdk-audits-backend.md`. | | `agentPersona.default` (`autopilot` \| `night-shift` \| `ghost-runner`) | `config.example.json`, `registry/personas/core/index.json` | `packages/cli/src/plan-loop/persona-banners.ts`, SessionStart hook, chat chrome | Yes | Yes (Agent Personas) | `{ "agentPersona": { "default": "night-shift" } }` | | `agentPersona.modes["continue-plan" \| "run-plan" \| "cli-run-plan"]` | `config.example.json` | `persona-banners.ts`, chat chrome | Yes | Yes (Agent Personas) | `{ "agentPersona": { "modes": { "run-plan": "night-shift" } } }` | | `workspaceSkin` (legacy) | superseded by `agentPersona` | display migration only (`persona-banners.ts`, `allowlistConfig`) | Legacy (migrated) | No (migrated on read) | prefer `agentPersona` | @@ -82,6 +83,7 @@ Invocation-time inputs; not persisted configuration. The table lists consumer-fa | `agent-kit run-plan` | `--max-ticks N`, `--model M`, `--sleep S`, `--backend cursor-agent\|claude`, `--dry-run` | `--backend claude` is **reserved, not implemented** (`packages/cli/src/plan-loop/backends.ts` throws); use `cursor-agent`. Distinct from `externalPlanReview.backend: claude`, which is the working Audits launcher backend. | | Mission Control server | `PORT` (default 3333), `HOST`, `MISSION_CONTROL_REPO_ROOT`, `MISSION_CONTROL_KIT_ROOT` / `AGENT_KIT_HOME` (Path C kit-root fallback), `MISSION_CONTROL_TOKEN` (LAN broadcast), `MISSION_CONTROL_SHARE_BASE` / `MISSION_CONTROL_SHARE_TTL_SEC` / `MISSION_CONTROL_SHARE_SHOW_LAN` (cosmetic Share URL mask), `MISSION_CONTROL_NO_OPEN`, `MISSION_CONTROL_PREFERRED_BROWSER`, `agent-kit dashboard --browser`, `agent-kit dashboard-broadcast --browser`, `AGENT_KIT_DASHBOARD_DATA_TIMEOUT_MS`, `AGENT_KIT_DASHBOARD_DATA_BUDGET_MS` | Kit-root fallback is documented in README Path C and `/dashboard`. Preferred browser: one OS open (app/binary) or OS default; never multi-open. ADR `2026-08-11_mission-control-preferred-browser.md`. Share mask: ADR `2026-08-11_mission-control-broadcast-url-mask.md`. | | Registry / hooks | `AGENT_KIT_REGISTRY`, `ALLOW_MAIN_PUSH` | `ALLOW_MAIN_PUSH=1` disables main-push protection for the session (doctor warns). | +| Audits, `backend: "cloud"` | `CURSOR_API_KEY` | **Secret.** Cursor user or service-account key for Cursor Cloud Agents (`@cursor/sdk` uses the same variable). Supply it from the environment or a gitignored `.env`; `.env.example` ships an empty placeholder only. Never commit, echo, log, or persist it — it must not appear in `.cursor/context/audit-wait/*.json`, monitors, CHANGELOG, dogfood, or HANDOFF. Prefer a service-account key for shared use; Team Admin keys are not supported. ADR `2026-08-14_cursor-cloud-agents-sdk-audits-backend.md`. | ## How the Config tab saves diff --git a/docs/cursor-3-features.md b/docs/cursor-3-features.md index 275b4c4..ae37efa 100644 --- a/docs/cursor-3-features.md +++ b/docs/cursor-3-features.md @@ -26,7 +26,10 @@ For extensions and automation, use what Cursor itself offers: - **MCP** - servers supported by Cursor (ex.: official integrations or documented in ecosystem; in IDE, prefer what comes enabled or project `mcp.json` for stable tools). - **Hooks** - agent events in workspace (see `create-hook` skill in your Cursor installation, if applicable). -- **`@cursor/sdk`** - agents and flows outside IDE when it makes sense (see SDK documentation). +- **Cursor Cloud Agents / `@cursor/sdk`** - agents and flows outside the IDE. The kit uses this in exactly **one** place: the opt-in `externalPlanReview.backend: "cloud"` audits reviewer, driven over the Cloud Agents REST API (`https://api.cursor.com`) so the kit keeps zero runtime dependencies. See [`external-plan-review.md`](external-plan-review.md#cloud-agents-backend-backend-cloud) and ADR `2026-08-14_cursor-cloud-agents-sdk-audits-backend.md`. + - The SDK itself (`@cursor/sdk`, Node 22.13+; `cursor-sdk` for Python) is **not** a kit dependency. Scripting it directly outside the kit is supported and documented, not a gap: `Agent.prompt` (one-shot), `Agent.create` + `agent.send` (durable/stream), `Agent.resume` (re-attach by `bc-` id). Always set `local` or `cloud` explicitly. + - `CURSOR_API_KEY` is a secret: environment or gitignored `.env` only, empty placeholder in `.env.example`, never committed or logged. + - Kit-load (`CLAUDE.md` / `/agent-kit`), audits, and `agent-kit run-plan --backend` stay three separate surfaces. Cloud Agents are not a plan-loop tick backend. This maintains a single source of truth for agent tools and avoids duplicating session control outside the native model. diff --git a/docs/external-plan-review.md b/docs/external-plan-review.md index 5324c8d..e7e1385 100644 --- a/docs/external-plan-review.md +++ b/docs/external-plan-review.md @@ -2,9 +2,11 @@ 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**. +**Daily path:** when audits are enabled, `/run-plan` and `/run-plan-all` arm, wait, rearm leftover wait budget on exit 3, and continue into `/plan-review-triage`. Specialist slashes `/plan-external-review` (paste / manual re-arm) and `/plan-review-triage` (HITL) stay the contracts and are invoked from those mains. ADR `2026-08-14_main-command-dogfood-audit-routing.md`. + 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. +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. A fourth value, `backend: "cloud"` (Cursor Cloud Agents over REST), is an **opt-in pin that `"auto"` never reaches** and reviews the pushed branch — see [Cloud Agents backend](#cloud-agents-backend-backend-cloud). **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. @@ -130,7 +132,8 @@ Mission Control **Flight Log** shows HANDOFF Gaps (**NOW** + **Earlier** history | Field | Meaning | |-------|---------| | `enabled` | Auto-arm on plan exhaustion (default: false) | -| `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. | +| `backend` | Reviewer: `"auto"` (Claude if usable, else Cursor Agent), `"claude"`, `"cursor"`, or `"cloud"`. Missing/unrecognised 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. `"cloud"` is an **opt-in pin only** and is never reached by `"auto"` — see [Cloud Agents backend](#cloud-agents-backend-backend-cloud). | +| `cloudAgent` | `backend: "cloud"` settings: `repoUrl` (defaults to the `origin` remote when it resolves to `https://github.com//`), `startingRef` (defaults to the current branch), `model` (default `composer-2.5`). `autoCreatePR` and `workOnCurrentBranch` are hard-off and not configurable. | | `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. | @@ -160,10 +163,12 @@ The launcher starts Claude with `--permission-mode auto` and `--model` from `rev **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 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 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. **Same-session resume on exit 3:** when leftover `waitTimeoutSeconds` remains, the same `/run-plan` or `/run-plan-all` orchestrator session must re-arm `--wait-monitor` (poll remaining budget) before treating the audit as done, advancing the queue, or skipping `/plan-review-triage`. A later session may also poll leftover budget; that is fallback, not the default while this session is still open. Do not narrate exit 3 as reviewed. 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`. +**Orchestrator pile preflight:** before a queue confirm Ask or an audit arm, `/run-plan` and `/run-plan-all` count detached workspace-owned sessions (launcher `--reap-audit-sessions --dry-run` or arm `--dry-run` line `audit-sessions: N detached owned`). At warn, print the dispose command (or offer reap) in the Ask/preflight body. At cap, do not spawn; surface the cap in the orchestrator Ask/preflight, not only launcher stderr. + **Audit-session cap and dispose policy:** kit-owned audit sessions are named `agent-kit-audit--`, where `` is an 8-hex workspace token derived from the repository root. Cap, warn, count, and opt-in reap only consider sessions owned by **this** workspace (strict pattern `agent-kit-audit-<8hex>-` matching the local token). Legacy unscoped `agent-kit-audit-` names and other workspaces' tokens are never counted or disposed by this process (quit them manually if needed). Before an autonomous spawn the launcher counts existing **detached** workspace-owned sessions; attached sessions are operator work in progress and are never counted as pressure or touched by any flag. At or above the warn threshold (`AGENT_KIT_AUDIT_SESSION_WARN`, default 5, `0` disables) the launcher prints the count plus the dispose command and continues. At or above the hard cap (`AGENT_KIT_AUDIT_SESSION_CAP`, default 20, `0` disables) it **refuses to spawn**: it prints the count, the cap, the dispose instructions, and the paste fallback, then soft-fails (exit `4` with `--wait-monitor`, tip + exit `0` otherwise). A refusal never spawns and never enters the monitor wait, so no audit starts and the Field Report stays owed. Detached is the normal steady state of a healthy autonomous arm, so warn/cap measure concurrency, not staleness; use `--reap-audit-sessions` for opt-in cleanup of sessions past the age floor. Reaping is opt-in via `--reap-audit-sessions` or `AGENT_KIT_AUDIT_REAP=1`: it disposes only detached workspace-owned sessions whose age is at or above `AGENT_KIT_AUDIT_REAP_MIN_AGE` (default 3600 seconds) and prints one line per disposal and per skip (attached, too young, age unknown, not owned). An age that cannot be determined counts as too young, so the safe default is to keep the session. Age comes from the multiplexer (screen socket mtime, tmux `session_created`), so a session with recent multiplexer activity can read younger than its wall-clock start; the bias is always toward keeping sessions alive. `--dry-run` previews the reap without killing anything and adds `audit-sessions: N detached owned (warn: X, cap: Y)`, `audit-workspace-token`, `audit-session-prefix`, `reap: yes|no (min-age: Zs; owned prefix only)`, and the resulting gate verdict to the dry-run report. A non-integer value for any of the three thresholds prints a tip and falls back to the default. The post-spawn progress gate above stays responsible for disposing the single session a run just spawned; this policy covers the pile left by earlier arms. No new exit code, and the CI/headless `--print` path is unaffected. ADR: `.cursor/memory/decisions/2026-07-30_audits-pty-progress-gate-zombie-policy.md`. **Mid-batch consume:** with `midBatchAudits: true`, arm **one** background `--wait-monitor` per plan (or one `--batch` + wait_all). Do not fan out N sessions without wait. Mid-queue skips triage Ask; queue-end triage uses an explicit path list of fresh monitors. @@ -299,9 +304,25 @@ AGENT_KIT_AUDIT_REAP_MIN_AGE=0 .cursor/scripts/plan-external-review.sh --reap-au - `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. +- Pinned `backend: "cloud"` needs `curl`, `node`, and a non-empty `CURSOR_API_KEY`; without them it tips and skips on the same contract. An SSH-alias `origin` cannot be resolved to a GitHub URL — set `cloudAgent.repoUrl` explicitly rather than letting the launcher guess. + +## Cloud Agents backend (`backend: "cloud"`) + +An opt-in pin that runs the review on **Cursor Cloud Agents** over REST (`https://api.cursor.com`) instead of a local binary. ADR: `.cursor/memory/decisions/2026-08-14_cursor-cloud-agents-sdk-audits-backend.md`. + +- **Pin only.** `"auto"` never cascades into `cloud`; it stays Claude → Cursor Agent CLI → none. Widening `auto` would be an explicit ADR amendment. +- **Reviews the pushed branch, not the working tree.** Cloud Agents clone `repoUrl` onto a Cursor VM. Before spending any API credit the launcher runs a pushed-state preflight (`git merge-base --is-ancestor HEAD @{upstream}`, reading local remote-tracking state — **no fetch**) and soft-fails with a tip when HEAD is ahead of upstream, rather than auditing state the reviewer cannot see. For a **working-tree** review use `backend: "cursor"`. +- **The launcher writes the monitor.** The cloud agent cannot touch your working tree; its final message *is* the monitor markdown, and the launcher writes it to `.cursor/memory/plan-monitor-.md` with the `` sentinel. Freshness, the exit contract, `autoRemediate: false`, implementer≠reviewer, and `/plan-review-triage` are unchanged. +- **Findings-only is enforced in the request.** `autoCreatePR: false` and `workOnCurrentBranch: false` are hard-coded, and the prompt forbids branching, committing, pushing, or opening a PR. +- **Exit mapping.** `FINISHED` with a non-empty result → monitor written, exit 0. Slice/total budget exhausted while `CREATING`/`RUNNING` → exit 3 (**timeout, not review done**); the agent and run ids are persisted in `.cursor/context/audit-wait/.json` so the same session re-polls that run instead of creating a second agent. A create call that never started (bad key, repo not visible to the Cursor GitHub App) → soft-fail tip, exit 0 (or 4 while waiting). A run that reached `ERROR` / `CANCELLED` / `EXPIRED`, or `FINISHED` with an empty result → **no monitor is written and none is fabricated**; the Field Report stays owed. +- **Not wired:** `--batch` (one cloud agent per plan is not implemented — the launcher refuses instead of covering only the first plan), SSE streaming, artifacts, and `mode: "plan"`. +- **Reviewer model** is the cloud model id (`cloudAgent.model`, default `composer-2.5`); a Claude-family `reviewerModel` cannot be honored on a Cursor VM, exactly as on the `cursor` backend. + +Tests: `.cursor/scripts/plan-external-review-cloud-backend.test.mjs` (12 source-contract asserts plus a dry-run; it never calls the API and never reads a real key). ## Security +- **`CURSOR_API_KEY` (backend `cloud` only):** supply it from the environment or a gitignored `.env`; `.env.example` ships an empty placeholder. The launcher passes it to `curl` through a **stdin config** (`-K -`), never as `-u KEY:` or any other argument, so it cannot appear in `ps`, in the printed `background-cmd` / `paste-cmd`, or in a log. It is never written to wait-state, monitors, or HANDOFF. Prefer a Cursor service-account key for shared use. - **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. diff --git a/docs/getting-started.md b/docs/getting-started.md index c8b6e8d..2c077c0 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -67,7 +67,7 @@ Keep this path light. No extra runtime packages beyond the CLI (`@clack/prompts` 3. **Install** - `npx @dadado/agent-kit-cli install` (or Port B via `install.md`). 4. **Onboard** - `/agent-kit-onboard` until every essential readiness check is ready (non-essentials may defer with a recovery action). 5. **Kit commands** - e.g. `/start-project` in the consumer project. -6. **Mission Control panel (optional)** - `/dashboard`, `npm run dashboard`, or `agent-kit dashboard`. Consumer L0 does not copy `dashboard/` into the project. `agent-kit dashboard` resolves `dashboard/start.mjs` from the installed package (4.8.2 onward); on older pins use a kit checkout or env/sibling discovery. Loopback only (`127.0.0.1`) by default. Opt-in LAN: `agent-kit dashboard-broadcast` / `npm run dashboard:broadcast` (token-gated) prints a Mission Kit **Share** URL (`https://missionkit.io/mc/open.html#…`, BYO HTTPS via `MISSION_CONTROL_SHARE_BASE`; set `off` for LAN-only). The Share URL embeds the live token (same secret handling); soft TTL is advisory (`MISSION_CONTROL_SHARE_TTL_SEC`, `0` = never). Still requires trusted-LAN reachability; not a WAN relay. The slash `/dashboard-broadcast` ships in the **factory** checkout and CLI docs only (not an L0 consumer artifact); consumers use the CLI/npm entrypoints above. CLI/OS opens use one preferred browser (`missionControl.preferredBrowser`, `MISSION_CONTROL_PREFERRED_BROWSER`, or `--browser`; platform-specific **name**, not a path) or the OS default; slash `/dashboard` opens via IDE browser MCP only (not multi-browser). Posture: [Mission Control production-ship constraints](#mission-control-production-ship-constraints). +6. **Mission Control panel (optional)** - `/dashboard`, `npm run dashboard`, or `agent-kit dashboard`. Consumer L0 does not copy `dashboard/` into the project. `agent-kit dashboard` resolves `dashboard/start.mjs` from the installed package (4.8.2 onward); on older pins use a kit checkout or env/sibling discovery. Loopback only (`127.0.0.1`) by default. Opt-in LAN: `agent-kit dashboard-broadcast` / `npm run dashboard:broadcast` (token-gated) binds the same per-workspace port allocation as `/dashboard` (`3333-3588` unless `PORT` is set), so it starts **beside** an already-running Mission Control - this workspace's loopback panel, another workspace, or an unidentified listener is skipped and left running, never killed. It prints a Mission Kit **Share** URL (`https://missionkit.io/mc/open.html#…`, BYO HTTPS via `MISSION_CONTROL_SHARE_BASE`; set `off` for LAN-only). The Share URL embeds the live token (same secret handling); soft TTL is advisory (`MISSION_CONTROL_SHARE_TTL_SEC`, `0` = never). Still requires trusted-LAN reachability; not a WAN relay. The slash `/dashboard-broadcast` ships in the **factory** checkout and CLI docs only (not an L0 consumer artifact); consumers use the CLI/npm entrypoints above. CLI/OS opens use one preferred browser (`missionControl.preferredBrowser`, `MISSION_CONTROL_PREFERRED_BROWSER`, or `--browser`; platform-specific **name**, not a path) or the OS default; slash `/dashboard` opens via IDE browser MCP only (not multi-browser). Posture: [Mission Control production-ship constraints](#mission-control-production-ship-constraints). ## The commands you get @@ -99,7 +99,7 @@ Operator sequence when you drive each unit (command SoT: [`.cursor/commands/cont 0. **`/agent-kit-onboard`** *(first time / incomplete readiness)* - Repository preparation only: show detected facts, safe fixes, and one pending decision at a time via **Ask questions**. Completes when essential readiness checks pass (or allowed non-essential items are deferred with a recovery action). Skins and external review stay optional after essentials. Path after CLI install: open the folder in Cursor → `/agent-kit-onboard` → `/start-project`. Contract: [repository-readiness-onboarding.md](repository-readiness-onboarding.md). 1. **`/start-project`** - After the repository is prepared, Broad Intake Review (buckets listed in the command, including **Unprocessed dogfood** from `dogfood/README.md` or `.cursor/dogfood/README.md` under `##` or `### Unprocessed Files`) and two gates using **Ask questions**: (A) the agent proposes and writes a plan with checkable to-dos (no coding yet); (B) only after you confirm, it runs the **first** unit. Broad Intake's Memory bucket consults `.cursor/memory/plan-monitor-*.md` and theme-matched `plan-review-*` audits (duplicates / open residuals / outdated reviews) without changing Field Report detection. Unprocessed dogfood uses the same triage labels and never auto-analyzes (ADR `2026-08-11_dogfood-unprocessed-broad-intake-bucket.md`). Uses clickable options with chat fallback when tool unavailable. Goal text in the same message is not execute permission. 1b. **Backlog without activation** - `/backlog-add` runs the same Broad Intake (including plan-monitor consult and Unprocessed dogfood), writes a plan, and appends it under HANDOFF Backlog plans (no Gate B, never parks or activates the new plan). Manage rows with `/backlog-edit`, `/backlog-delete` (move to `.cursor/plans/archive/`), and `/backlog-cancel` (soft-cancel open to-dos, keep the file). All mutates use **Ask questions** (chat numbered-list fallback). Distinct from `/archive-plan` (parked list only). Routine backlog CRUD does not create Field Report cards. -2. **Confirm the unit, then work one phase.** On `/continue-plan` (and after Gate B on a new plan), the agent uses **Ask questions** before editing (`Start [to-do-id]` / `Edit plan first` / `Switch to different plan` / `Stop here`). Before that Ask it also runs advisory Unprocessed dogfood preflight (mention non-empty inbox; never block solely for inbox). After you pick Start, it implements **only** that phase (or one heavy to-do), checks it off, updates `.cursor/HANDOFF.md`, and **stops**. Context Guardian plus **native Cursor hooks** (`sessionStart` / `preCompact`) enforce that boundary; multi-phase in one window needs `/run-plan` or `/run-plan-all` below. `/run-plan` and `/run-plan-all` apply the same advisory dogfood preflight on first tick / queue confirm. +2. **Confirm the unit, then work one phase.** On `/continue-plan` (and after Gate B on a new plan), the agent uses **Ask questions** before editing (`Start [to-do-id]` / `Edit plan first` / `Switch to different plan` / `Stop here`). Before that Ask, if Unprocessed dogfood is non-empty, it may Ask `Analyze inbox now` / `Enqueue Fix now` / `Not now` (never auto-analyze; never block solely for inbox). After you pick Start, it implements **only** that phase (or one heavy to-do), checks it off, updates `.cursor/HANDOFF.md`, and **stops**. Context Guardian plus **native Cursor hooks** (`sessionStart` / `preCompact`) enforce that boundary; multi-phase in one window needs `/run-plan` or `/run-plan-all` below. `/run-plan` and `/run-plan-all` apply the same inbox Ask on first tick / queue confirm (per-plan workers skip a second Ask). 3. **Suggest staging when there is a diff.** After the unit, the agent should suggest `/git-staging` (you run it when ready). Do not expect automatic promote to production; `/git-prod` is always a separate HITL step. 4. **Handoff if the window fills.** `/handoff` (or guardian auto-handoff when configured) writes where things stand before context is lost. 5. **New chat → paste `/continue-plan`.** Open a **fresh** conversation for the next phase. The agent reads HANDOFF, confirms the next unit again, and continues without you re-explaining the project. Chat tone follows the Autopilot persona by default (see [personas contract](personas-contract.md)). Mission Control Checklist Actions can copy `/continue-plan `; Current mission shows operator-friendly Mode labels (auto mode / run all / human-in-the-loop; HANDOFF keeps raw Mode tokens) and offers copy-only `/git-staging` (and mode-aware `/continue-plan` when Mode is manual). @@ -120,6 +120,30 @@ 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. +### Coming from Spec Kit (SDD) + +GitHub Spec Kit slash commands map onto existing Agent Kit surfaces. The kit does not install the Spec Kit CLI or a `.specify/` tree. Durable ranks: ADR `2026-08-14_spec-kit-sdd-adapt-ignore.md`. + +| Spec Kit | Agent Kit | +|----------|-----------| +| constitution | `.cursor/rules/`, `AGENTS.md` | +| specify / plan / tasks | `.cursor/plans/` + frontmatter to-dos | +| clarify | Ask questions HITL | +| analyze / converge | audits + `/plan-review-triage` | + +Keep kit-native HITL, staging→prod, and post-ship audits. Do not import a second constitution file or auto-append residuals without triage. + +### File, run, audit, triage + +Stay on the main commands. Specialist slashes remain the contracts and are invoked from those mains. + +1. **File** — `/dogfood ` writes a private note. Filing is not ingest. +2. **Run** — `/continue-plan`, `/run-plan`, `/run-plan-all`, or `/backlog-add`. When Unprocessed is non-empty, those commands may Ask `Analyze inbox now` / `Enqueue Fix now` / `Not now`. Analysis and memory WRITE start only after that HITL. Notes become plans or memory, never `plan-monitor-*.md`. +3. **Audit** — when `externalPlanReview.enabled` is true, `/run-plan` and `/run-plan-all` arm, wait, and rearm leftover wait budget on exit 3. Use `/plan-external-review` only for paste or manual re-arm. +4. **Triage** — after wait exit 0, the same run continues into `/plan-review-triage` (no silent-Ack). Prefer Ack / Fix nits when residuals are process-only. + +Details: [external plan review](external-plan-review.md). Routing ADR: `2026-08-14_main-command-dogfood-audit-routing.md`. + ### 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). @@ -178,10 +202,10 @@ Pack contract: [claude-cli-kit-load.md](claude-cli-kit-load.md). This is **not** ### 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). Session kit-load (`CLAUDE.md` / `/agent-kit`) is a different surface; see [Claude Code CLI (session kit-load)](#claude-code-cli-session-kit-load). +When `/run-plan` finishes all implementable to-dos, you can get a second-agent check of the shipped work. **Default path when enabled:** `/run-plan` / `/run-plan-all` arm, wait, and continue into `/plan-review-triage`. Artifacts ship with L0; the feature stays opt-in (`enabled: false` by default). Session kit-load (`CLAUDE.md` / `/agent-kit`) is a different surface; see [Claude Code CLI (session kit-load)](#claude-code-cli-session-kit-load). 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`) +2. **Chat path:** when enabled (`mode: autonomous`), `/run-plan` / `/run-plan-all` arm `--force --autonomous --wait-monitor` (background/inspectable PTY). Paste (`--paste-only`) is the fallback when spawn is unavailable. Never a silent agent-shell `claude -p`. 3. **Headless / CI:** `agent-kit run-plan` may arm the launcher with `--force` (`claude -p`) in the runner shell 4. **Exhaustion Ask:** if not enabled and `offerOnExhausted` is not `false`, chat must Ask `Run review now` / `Always enable automatic` / `Not now`. `Not now` is per-session only (no persist) 5. **Manual:** `/plan-external-review` anytime after a plan is done diff --git a/docs/marketplace.md b/docs/marketplace.md index 77170e0..8683f76 100644 --- a/docs/marketplace.md +++ b/docs/marketplace.md @@ -117,5 +117,5 @@ Future nicety (not required for this to-do): `agent-kit search ` over `re - [x] Builder emits version/category into `registry.json` - [x] CONTRIBUTING quality gate documented - [x] This marketplace doc + plugin.json thesis/version note -- [ ] Live Cursor Marketplace submission (publisher ops / HITL) - packaging ready at **5.2.0** on the public mirror (explicit component paths). Submit remains operator HITL; this box stays unchecked until a real submission. +- [x] Live Cursor Marketplace submission (publisher ops / HITL) - operator submitted 2026-08-14. Packaging ready at **5.2.0** on the public mirror (explicit component paths). Listing may still be pending Cursor review; no listing URL or submission id recorded yet. - [x] Phase B cutover so public catalog is not overwritten by private sync diff --git a/docs/repository-readiness-onboarding.md b/docs/repository-readiness-onboarding.md index 17aec8e..6315d4e 100644 --- a/docs/repository-readiness-onboarding.md +++ b/docs/repository-readiness-onboarding.md @@ -374,4 +374,4 @@ Cursor's Ask questions tool provides clickable option buttons. When unavailable - A docs and operations repository on a self-hosted Git service is not classified as greenfield or GitHub without evidence. - The slash picker shows one Agent Kit onboarding entry. - `/start-project` receives a prepared repository profile and asks only for the deliverable goal. -- `/start-project` blocks planning only on unresolved essential checks (`essential: true` in `pillars[].checks[]`). Non-essential pending items such as `collaboration.provider` / `confirm-provider` are warnings only and must not halt Broad Intake or the Gate A / Gate B HITL contract. +- `/start-project` blocks planning only on unresolved essential checks (`essential: true` in `pillars[].checks[]`). Non-essential pending items such as `collaboration.provider` / `confirm-provider` are warnings only and must not halt Broad Intake or the Gate A / Gate B HITL contract. `/backlog-add` must not Ask on that check (one-line advisory or silence, then Broad Intake → write Ask). diff --git a/package.json b/package.json index d66ed4f..491ad4c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-kit", - "version": "5.2.1", + "version": "5.3.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", @@ -32,7 +32,7 @@ "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", + "test:root-node": "node --test .cursor/scripts/plan-external-review-progress-gate.test.mjs .cursor/scripts/plan-external-review-cloud-backend.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/package.json b/packages/cli/package.json index 01823d2..d5b8a4c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@dadado/agent-kit-cli", - "version": "5.2.1", + "version": "5.3.0", "description": "Agent Kit CLI: HITL framework install and tooling for AI-assisted IDEs (rules, skills, plan/handoff, context).", "license": "PolyForm-Noncommercial-1.0.0", "type": "module", diff --git a/packages/cli/src/dashboard/guards.test.ts b/packages/cli/src/dashboard/guards.test.ts index 0b0cb84..7d5f694 100644 --- a/packages/cli/src/dashboard/guards.test.ts +++ b/packages/cli/src/dashboard/guards.test.ts @@ -725,3 +725,202 @@ describe("serve.mjs HTTP auth exemption for share shell", () => { } }, 30_000); }); + +describe("broadcast multi-instance port allocation", () => { + const alpha = "/tmp/workspace-alpha"; + const beta = "/tmp/workspace-beta"; + + const free = { listening: false, repoRoot: null, acceptsToken: false, tokenGated: false }; + const ownBroadcast = { + listening: true, + repoRoot: alpha, + acceptsToken: true, + tokenGated: false, + lanReachable: true, + }; + const ownLoopback = { + listening: true, + repoRoot: alpha, + acceptsToken: true, + tokenGated: false, + lanReachable: false, + }; + const foreignLoopback = { + listening: true, + repoRoot: beta, + acceptsToken: true, + tokenGated: false, + lanReachable: false, + }; + const foreignBroadcast = { + listening: true, + repoRoot: null, + acceptsToken: false, + tokenGated: true, + }; + const stranger = { listening: true, repoRoot: null, acceptsToken: false, tokenGated: false }; + + it("classifies listeners by workspace and bind surface", async () => { + const { classifyBroadcastListener } = await import("../../../../dashboard/lib/guards.mjs"); + + expect(classifyBroadcastListener(free, alpha)).toBe("free"); + expect(classifyBroadcastListener(ownBroadcast, alpha)).toBe("self-broadcast"); + // Loopback /dashboard ignores ?token= and answers 200: root+token is not enough. + expect(classifyBroadcastListener(ownLoopback, alpha)).toBe("self-other-mode"); + expect(classifyBroadcastListener(foreignLoopback, alpha)).toBe("foreign"); + expect(classifyBroadcastListener(foreignBroadcast, alpha)).toBe("token-gated"); + expect(classifyBroadcastListener(stranger, alpha)).toBe("unknown"); + // No LAN IPv4 to probe → root + token match is accepted. + expect(classifyBroadcastListener({ ...ownBroadcast, lanReachable: null }, alpha)).toBe( + "self-broadcast", + ); + expect(classifyBroadcastListener({ ...ownBroadcast, repoRoot: `${alpha}/` }, alpha)).toBe( + "self-broadcast", + ); + }); + + it("walks past a loopback panel on the preferred port instead of demanding a kill", async () => { + const { portCandidatesForRepoRoot, resolveBroadcastPort } = await import( + "../../../../dashboard/lib/guards.mjs" + ); + const candidates = portCandidatesForRepoRoot(alpha); + const held = new Map([[candidates[0], ownLoopback]]); + + const picked = resolveBroadcastPort({ + repoRoot: alpha, + probe: (port) => held.get(port) || free, + }); + expect(picked.port).toBe(candidates[1]); + expect(picked.reuse).toBe(false); + expect(picked.explicit).toBe(false); + expect(picked.skipped).toEqual([ + { port: candidates[0], kind: "self-other-mode", repoRoot: alpha }, + ]); + }); + + it("skips foreign, token-gated, and unidentified listeners without killing them", async () => { + const { portCandidatesForRepoRoot, resolveBroadcastPort } = await import( + "../../../../dashboard/lib/guards.mjs" + ); + const candidates = portCandidatesForRepoRoot(alpha); + const held = new Map([ + [candidates[0], foreignLoopback], + [candidates[1], foreignBroadcast], + [candidates[2], stranger], + ]); + + const picked = resolveBroadcastPort({ + repoRoot: alpha, + probe: (port) => held.get(port) || free, + }); + expect(picked.port).toBe(candidates[3]); + expect(picked.skipped.map((entry) => entry.kind)).toEqual([ + "foreign", + "token-gated", + "unknown", + ]); + }); + + it("reuses this workspace's own broadcast listener", async () => { + const { portCandidatesForRepoRoot, resolveBroadcastPort } = await import( + "../../../../dashboard/lib/guards.mjs" + ); + const candidates = portCandidatesForRepoRoot(alpha); + const held = new Map([ + [candidates[0], ownLoopback], + [candidates[1], ownBroadcast], + ]); + + const picked = resolveBroadcastPort({ + repoRoot: alpha, + probe: (port) => held.get(port) || free, + }); + expect(picked).toMatchObject({ port: candidates[1], reuse: true, explicit: false }); + }); + + it("honours an explicit free PORT and refuses an occupied one with an honest reason", async () => { + const { resolveBroadcastPort } = await import("../../../../dashboard/lib/guards.mjs"); + + expect(resolveBroadcastPort({ repoRoot: alpha, envPort: "4444", probe: () => free })).toEqual({ + port: 4444, + reuse: false, + explicit: true, + skipped: [], + }); + + expect( + resolveBroadcastPort({ repoRoot: alpha, envPort: "4444", probe: () => ownBroadcast }), + ).toMatchObject({ port: 4444, reuse: true, explicit: true }); + + expect(() => + resolveBroadcastPort({ repoRoot: alpha, envPort: "4444", probe: () => ownLoopback }), + ).toThrow(/not a broadcast listener/); + expect(() => + resolveBroadcastPort({ repoRoot: alpha, envPort: "4444", probe: () => foreignLoopback }), + ).toThrow(/never touches another workspace/); + expect(() => + resolveBroadcastPort({ repoRoot: alpha, envPort: "4444", probe: () => foreignBroadcast }), + ).toThrow(/owner this token cannot confirm/); + expect(() => + resolveBroadcastPort({ repoRoot: alpha, envPort: "4444", probe: () => stranger }), + ).toThrow(/unidentified process is there/); + }); + + it("never tells the operator to kill anything it does not own", async () => { + const { resolveBroadcastPort } = await import("../../../../dashboard/lib/guards.mjs"); + for (const info of [foreignLoopback, foreignBroadcast, stranger]) { + let message = ""; + try { + resolveBroadcastPort({ repoRoot: alpha, envPort: "4444", probe: () => info }); + } catch (err) { + message = err instanceof Error ? err.message : String(err); + } + expect(message).not.toMatch(/kill/i); + expect(message).not.toMatch(/lsof/); + } + }); + + it("describes who holds a port without suggesting a kill", async () => { + const { describeBroadcastListener } = await import("../../../../dashboard/lib/guards.mjs"); + + expect(describeBroadcastListener("foreign", { port: 3401, repoRoot: beta })).toBe( + `3401: another workspace (${beta})`, + ); + expect(describeBroadcastListener("foreign", { port: 3401 })).toBe("3401: another workspace"); + expect(describeBroadcastListener("self-other-mode", { port: 3402 })).toMatch( + /this workspace, but not a broadcast listener/, + ); + expect(describeBroadcastListener("token-gated", { port: 3403 })).toMatch( + /owner this token cannot confirm/, + ); + expect(describeBroadcastListener("unknown", {})).toBe("an unidentified process"); + // Unmapped kinds degrade to the safest wording rather than throwing. + expect(describeBroadcastListener("nonsense", { port: 3404 })).toBe( + "3404: an unidentified process", + ); + for (const kind of ["foreign", "token-gated", "unknown", "exhausted"]) { + expect(describeBroadcastListener(kind, { port: 3405, repoRoot: beta })).not.toMatch(/kill/i); + } + }); + + it("refuses when the whole per-workspace range is held by other instances", async () => { + const { resolveBroadcastPort, DEFAULT_PORT_RANGE } = await import( + "../../../../dashboard/lib/guards.mjs" + ); + let thrown: Error | null = null; + try { + resolveBroadcastPort({ + repoRoot: alpha, + probe: () => foreignLoopback, + opts: { range: 4 }, + }); + } catch (err) { + thrown = err instanceof Error ? err : new Error(String(err)); + } + expect(thrown?.message).toMatch(/No free Mission Control broadcast port/); + expect(DEFAULT_PORT_RANGE).toBe(256); + const skipped = (thrown as unknown as { broadcast?: { skipped?: unknown[] } })?.broadcast + ?.skipped; + expect(skipped).toHaveLength(4); + }); +}); diff --git a/packages/cli/src/lifecycle/overlay-known-hashes.ts b/packages/cli/src/lifecycle/overlay-known-hashes.ts index 070ef8b..1420aa2 100644 --- a/packages/cli/src/lifecycle/overlay-known-hashes.ts +++ b/packages/cli/src/lifecycle/overlay-known-hashes.ts @@ -134,4 +134,20 @@ export const KNOWN_SHIPPED_OVERLAY_HASHES: ReadonlySet = new Set([ "6a5f8795a4a26b419f167e249576b798781c95308dc1ea50958351de713231d4", "4009c5e2faf9775d5708cdff0b5f9da80fd3ce390cc42aed0bb94272f6b05b1b", "a8e070fae187908ef7b2cf2a41605e3079329403b1802b5fc67e83294c96a070", + "81186b528105f2873818ea91f51d20c95a68c0c4b3d128aa8896dd38a1bb3159", + "b2c7db73a0e18bd475651001722aa34b39276af9f73f0921102b680eb42bea0e", + "d03445b726cb6afe2ff37a936de97605cc60e441896e06385abfc15a127e2f45", + "f5ef970adaf4cfa7af551e35cb41b4052576109f5444a280b1e26562338b39e8", + "fe3f8e2339a7f183545f2aad6b7f02d3687277ca67597fa7620bfee269392bcd", + "b2489e381ea7daaef7299e5aede437a8baaa39baaddaac849efbf0a6ccb84a2b", + "0ae7d076904fd342de3ba38745f38fed8ca5c1f02d7b099d27deeb0a9be79740", + "15ca459811f0f050f4eeff77d4567b45c0790beb77904ecf37e145d65a858c77", + "1dc329c0c0695a4395bb7c30db0fb7b6fb06bcf5fdc1d6a475656ac6a525aa5f", + "35bb0008ef52af147a45fba667f7dceb841d537ad5e185317672577afbcbc189", + "42b701d224f4e690dc027d66fcc1214bab31a0ae0c50ef982124f57d4dae4982", + "65272f85a32f8fe95ed19d0823fa9d1d6dee5bc18a0244f7b553ea5162ba9b36", + "73cd50bac290b84df6245d3647e686dee8ec3b0434736d08fdba6e30d342ef0a", + "61b635ea8a08171062bd329a4615d7426eb787eb796271aa882f55f39db56810", + "86afbea8f64de68a79ad5e374f3132bdbe2582b94321fb3d438314838e36c776", + "65cc1c0293b145e48ed73ad0ca9ab33cbba5ed834a5bfb31f195ed30c2f143df", ]);