From 2a3af3577e0863b8924b7edc8e10eb03916ee02b Mon Sep 17 00:00:00 2001 From: agent-kit-sync Date: Sat, 1 Aug 2026 17:45:01 +0000 Subject: [PATCH] chore: sync private v4.8.9 (99ad2c3) --- .cursor-plugin/plugin.json | 2 +- .cursor/agent-kit.json | 2 +- .cursor/commands/agent-kit-onboard.md | 2 +- .cursor/commands/backlog-add.md | 1 + .cursor/commands/cursor-update-awareness.md | 64 + .cursor/commands/dashboard.md | 2 +- .cursor/commands/dogfood.md | 138 ++ .cursor/commands/git-staging.md | 2 +- .cursor/commands/plan-external-review.md | 6 +- .cursor/commands/plan-review-triage.md | 1 + .cursor/commands/run-plan-all.md | 4 + .cursor/commands/run-plan.md | 6 +- .cursor/context/config.example.json | 10 + .cursor/context/templates/handoff.md | 2 +- .cursor/rules/cursor-plan-handoff.mdc | 2 +- .cursor/rules/memory-loop.mdc | 2 + .cursor/scripts/plan-external-review.sh | 569 ++++- .cursor/scripts/run-plan-all-consolidate.sh | 184 +- .github/workflows/ci.yml | 25 + CHANGELOG.md | 176 +- README.md | 38 +- autogit/gitupdate.md | 10 +- dashboard/dashboard-data.mjs | 312 ++- dashboard/dashboard.html | 1973 +++++++++++++---- dashboard/lib/semantic-model.mjs | 195 +- dashboard/lib/terminal-snapshot.mjs | 127 ++ docs/CONTRIBUTING.md | 23 +- docs/README.md | 4 + docs/agent-kit-manifest.md | 17 +- docs/agentkit-landing.md | 38 + docs/bootstrap.md | 15 +- docs/capability-inventory.md | 403 ++++ docs/consumer-configuration.md | 108 + docs/cursor-native-audit.md | 3 + docs/cursor-update-awareness.md | 59 + docs/drift-inventory.md | 12 +- docs/external-plan-review.md | 33 +- docs/five-layer-claim-matrix.md | 39 + docs/getting-started.md | 16 +- docs/layers-spec.md | 4 +- docs/migrate-consumer.md | 2 + docs/npm-publish-checklist.md | 1 + docs/repository-boundaries.md | 4 +- git-hooks/README.md | 18 +- git-hooks/pre-push | 41 +- install.md | 10 +- package.json | 18 +- packages/cli/README.md | 59 + packages/cli/package.json | 2 +- packages/cli/src/commands/cursor-awareness.ts | 73 + packages/cli/src/commands/doctor.ts | 14 + packages/cli/src/commands/update.test.ts | 89 + packages/cli/src/commands/update.ts | 17 +- .../dashboard/field-report-prompts.test.ts | 23 +- .../cli/src/dashboard/live-refresh.test.ts | 17 + .../dashboard/plugin-ux-validation.test.ts | 1038 ++++++++- .../cli/src/dashboard/semantic-model.test.ts | 307 ++- .../src/dashboard/terminal-snapshot.test.ts | 122 + .../src/docs/staging-lint-evidence.test.ts | 115 + packages/cli/src/hooks/hard-rules.ts | 11 +- packages/cli/src/hooks/session-start.test.ts | 163 +- packages/cli/src/hooks/session-start.ts | 157 +- packages/cli/src/index.ts | 4 + .../cli/src/invariants/hooks-health.test.ts | 58 +- packages/cli/src/invariants/hooks-health.ts | 96 +- .../cli/src/invariants/shell-guard.test.ts | 114 +- packages/cli/src/invariants/shell-guard.ts | 112 +- packages/cli/src/lifecycle/apply.test.ts | 28 +- packages/cli/src/lifecycle/apply.ts | 98 +- .../lifecycle/cursor-update-awareness.test.ts | 186 ++ .../src/lifecycle/cursor-update-awareness.ts | 478 ++++ .../fixtures/cursor-changelog-excerpt.html | 9 + packages/cli/src/lifecycle/l0.test.ts | 8 + packages/cli/src/lifecycle/l0.ts | 8 + .../cli/src/lifecycle/overlay-known-hashes.ts | 56 + packages/cli/src/lifecycle/overlay.test.ts | 215 ++ packages/cli/src/lifecycle/overlay.ts | 128 ++ packages/cli/src/lifecycle/report.ts | 6 + packages/cli/src/lifecycle/sync.ts | 5 + packages/cli/src/registry/install.ts | 8 + scripts/verify-cli-dashboard-pack.mjs | 94 +- 81 files changed, 7847 insertions(+), 794 deletions(-) create mode 100644 .cursor/commands/cursor-update-awareness.md create mode 100644 .cursor/commands/dogfood.md create mode 100644 dashboard/lib/terminal-snapshot.mjs create mode 100644 docs/agentkit-landing.md create mode 100644 docs/capability-inventory.md create mode 100644 docs/consumer-configuration.md create mode 100644 docs/cursor-update-awareness.md create mode 100644 docs/five-layer-claim-matrix.md create mode 100644 packages/cli/README.md create mode 100644 packages/cli/src/commands/cursor-awareness.ts create mode 100644 packages/cli/src/commands/update.test.ts create mode 100644 packages/cli/src/dashboard/terminal-snapshot.test.ts create mode 100644 packages/cli/src/docs/staging-lint-evidence.test.ts create mode 100644 packages/cli/src/lifecycle/cursor-update-awareness.test.ts create mode 100644 packages/cli/src/lifecycle/cursor-update-awareness.ts create mode 100644 packages/cli/src/lifecycle/fixtures/cursor-changelog-excerpt.html create mode 100644 packages/cli/src/lifecycle/overlay-known-hashes.ts create mode 100644 packages/cli/src/lifecycle/overlay.test.ts create mode 100644 packages/cli/src/lifecycle/overlay.ts diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 7ddb703..b507464 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -13,6 +13,6 @@ "anti-slop" ], "license": "MIT", - "version": "4.8.4", + "version": "4.8.9", "repository": "https://github.com/agent-kit-startup/agent-kit" } diff --git a/.cursor/agent-kit.json b/.cursor/agent-kit.json index 4725044..4b32495 100644 --- a/.cursor/agent-kit.json +++ b/.cursor/agent-kit.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "version": "4.8.4", + "version": "4.8.9", "protected": [ ".cursor/HANDOFF.md", ".cursor/agents/test-suites.md", diff --git a/.cursor/commands/agent-kit-onboard.md b/.cursor/commands/agent-kit-onboard.md index c6bff05..93957c3 100644 --- a/.cursor/commands/agent-kit-onboard.md +++ b/.cursor/commands/agent-kit-onboard.md @@ -100,6 +100,6 @@ When complete: - `Next: /start-project` when the user wants to plan a deliverable. - `Next: finish setup` when no deliverable should start now. -After essentials are ready, Mission Control is **optional** and **not** an essential readiness check. Consumer L0 installs the `/dashboard` command text but not `dashboard/**`. If the operator wants the panel, point them to an agent-kit checkout that includes `dashboard/start.mjs` (loopback `http://127.0.0.1:3333`). Do not block `/start-project` on Mission Control. Do not ask about skins or external review before essentials (Hard Stop 1). +After essentials are ready, Mission Control is **optional** and **not** an essential readiness check. Consumer L0 installs the `/dashboard` command text but not `dashboard/**`. If the operator wants the panel, `agent-kit dashboard` serves it from the installed CLI (4.8.2 onward); on older pins point them to an agent-kit checkout that includes `dashboard/start.mjs` (loopback `http://127.0.0.1:3333`). Do not block `/start-project` on Mission Control. Do not ask about skins or external review before essentials (Hard Stop 1). Agent Personas remain available through later personalization or settings. External review is offered only when a plan reaches exhaustion. diff --git a/.cursor/commands/backlog-add.md b/.cursor/commands/backlog-add.md index 0cf0237..2cb2586 100644 --- a/.cursor/commands/backlog-add.md +++ b/.cursor/commands/backlog-add.md @@ -120,3 +120,4 @@ Vague-goal clarify and write confirmation **must** use Ask questions per `.curso - ADR: `.cursor/memory/decisions/2026-07-26_backlog-crud-commands-contract.md` - Disposition gate for `/start-project`: `.cursor/memory/decisions/2026-07-25_start-project-plan-disposition-gate.md` +- Cursor product-update gaps may route here via `/cursor-update-awareness` (Ask → `/backlog-add`) diff --git a/.cursor/commands/cursor-update-awareness.md b/.cursor/commands/cursor-update-awareness.md new file mode 100644 index 0000000..391cb33 --- /dev/null +++ b/.cursor/commands/cursor-update-awareness.md @@ -0,0 +1,64 @@ +# Command: /cursor-update-awareness + +## Goal + +Run an **opt-in advisory** check for Cursor product updates (changelog + `docs/cursor-native-audit.md` inventory), then route **confirmed** gaps into the existing conveyor with HITL. Never apply kit/IDE changes. Never auto-create Field Reports or public issues. + +**Detection source ADR:** `.cursor/memory/decisions/2026-08-01_cursor-update-detection-source.md` (changelog fetch primary; sessionStart = delivery; readiness = optional storage only). + +## When to Use + +- After a Cursor IDE upgrade or when `cursorUpdateCheck.enabled` sessionStart nudge mentions gaps +- When reviewing whether Agent Kit should adopt a new Cursor surface (hooks, MCP, skills, commands, SDK) +- Before `/backlog-add` or `/dogfood` for Cursor-integration work + +## Hard stops + +1. **Check ≠ apply.** CLI and this command only report. No silent rewrite of `.cursor/`, no Marketplace submit, no native-audit version-prose refresh (owned by parked `submit-cursor-marketplace`). +2. **No auto Field Reports** and no auto GitHub issues. +3. **Lane separation:** `/dogfood` stays factory vs consumer aware (ADR `2026-07-31_dogfood-factory-consumer-lanes.md`). Do not write consumer notes into factory `dogfood/` without an explicit operator bridge. +4. **HITL before enqueue.** Confirmed gaps go through Ask → `/backlog-add` or Ask → `/dogfood`, never silent backlog rows. +5. **Never `/git-prod`.** + +## Check-only (CLI) + +```bash +agent-kit cursor-awareness --check [--json] [--respect-prefs] [--stamp] [--offline] +``` + +- Fetches `https://cursor.com/changelog` (override via `cursorUpdateCheck.changelogUrl`) unless `--offline`. +- Diffs against `docs/cursor-native-audit.md` (open Action items, refresh staleness) and validates `docs/cursor-3-features.md` presence. +- Prefs in `.cursor/context/config.json` under `cursorUpdateCheck` (`enabled` default `false`, `intervalDays`, `lastSeenCursorVersion`). Distinct from kit `updateCheck`. +- `applyRecommended` and `fieldReportRecommended` are always `false`. + +## What to Do + +1. **Run the check** (prefer CLI JSON): + ```bash + agent-kit cursor-awareness --check --json + ``` + Fallback: inventory-only `agent-kit cursor-awareness --check --offline --json`. + +2. **Summarize gaps** for the operator (id, severity, evidence, suggestedRoute). If status is `current` or `skipped-*`, report and stop. + +3. **Confirm routing via Ask questions** (chat numbered-list fallback). One question: + + > Cursor awareness found N advisory gap(s). Route confirmed work? + + Options (labels exact): + - `Enqueue via /backlog-add` + - `File /dogfood note` + - `Not now` + +4. **Handlers:** + - `Enqueue via /backlog-add`: hand off to `/backlog-add` with a goal summarizing the confirmed gaps (Broad Intake + write Ask still apply). Do not activate or run the new plan. + - `File /dogfood note`: hand off to `/dogfood` with a hygiene-stripped topic/summary. Respect factory vs consumer lane. + - `Not now` / skipped: stop. Optionally offer enabling `cursorUpdateCheck.enabled` for future sessionStart nudges (do not mutate config without Ask). + +5. **Out of scope:** refreshing Marketplace plugin version prose; kit self-release (`/update` / `updateCheck`); auto-remediation of product code. + +## Related + +- Kit consumer autoupdate (separate): `/update`, ADR `2026-07-27_consumer-autoupdate-check-opt-in.md` +- Dogfood ingest: `/dogfood`, ADR `2026-07-31_dogfood-ingest-contract.md` +- Triage residuals path: `/plan-review-triage` → Write residuals → `/backlog-add` diff --git a/.cursor/commands/dashboard.md b/.cursor/commands/dashboard.md index d028c84..6aa679c 100644 --- a/.cursor/commands/dashboard.md +++ b/.cursor/commands/dashboard.md @@ -6,7 +6,7 @@ Start (or reuse) Mission Control for **this Cursor workspace only**, then open t Local-dev only. Read-only. No HITL gate. -**Terminal counterpart:** `agent-kit dashboard` from the workspace cwd. After a CLI publish that ships Path C, the installed package includes `dashboard/start.mjs`. Fallbacks: env `MISSION_CONTROL_KIT_ROOT` / `AGENT_KIT_HOME`, sibling `../agent-kit`, or `node "$KIT_ROOT/dashboard/start.mjs"` with `MISSION_CONTROL_REPO_ROOT` set to this git root. Until Path C is on npm, do not assume `@dadado/agent-kit-cli@4.8.0` has the panel assets. +**Terminal counterpart:** `agent-kit dashboard` from the workspace cwd. The installed package includes `dashboard/start.mjs` from 4.8.2 onward. Fallbacks: env `MISSION_CONTROL_KIT_ROOT` / `AGENT_KIT_HOME`, sibling `../agent-kit`, or `node "$KIT_ROOT/dashboard/start.mjs"` with `MISSION_CONTROL_REPO_ROOT` set to this git root. On 4.8.0 or an older pin the panel assets are absent. ## When to Use diff --git a/.cursor/commands/dogfood.md b/.cursor/commands/dogfood.md new file mode 100644 index 0000000..75c4af0 --- /dev/null +++ b/.cursor/commands/dogfood.md @@ -0,0 +1,138 @@ +# Command: /dogfood + +## Goal + +File a private dogfood note from the current chat or explicit arguments into the local inbox. Factory (`agent-kit-dev`) writes to `dogfood/`; consumer projects write to `.cursor/dogfood/`. Never syncs upstream; never creates a public issue automatically. + +## When to Use + +- You hit Agent Kit friction, a bug, or a surprising behavior and want it tracked for internal analysis. +- You want a dated record of a session pattern without turning it into a public issue or PR. +- A memory WRITE is not yet warranted; the note is raw material for later triage. + +## Usage + +``` +/dogfood [one-line summary] +``` + +Examples: +- `/dogfood plan-handoff context lost after long run` +- `/dogfood update-refresh personalization dropped` +- `/dogfood stale-pack-id install failed on pack migration` + +Without arguments, summarize the current chat turn into the topic and body. + +## Hard stops + +1. **Detect lane before writing.** + - **Factory lane** — this checkout is the canonical `agent-kit-dev` repository (`origin` remote contains `agent-kit-dev`, or `dogfood/` already exists at repo root). Write to `dogfood/cursor__.md` and update `dogfood/README.md` Unprocessed index. + - **Consumer lane** — any other project with an Agent Kit install (`.cursor/agent-kit.json` exists). Write to `.cursor/dogfood/cursor__.md` and a local index. Do **not** track the folder in git; it should already be gitignored by the base install. + - **Unknown lane** — stop and ask the operator which lane to use. +2. **Hygiene strip (mandatory).** Remove before writing: + - Consumer workspace names, project names, domain names, or external product names. + - People's names, Slack channels, client IDs, or organization names. + - Chat-transient phrasing ("as I mentioned", "dear user", "conforme falamos"). + - Any value that looks like a secret, token, or credential. +3. **Session origin ≠ product use case.** The note describes an Agent Kit system pattern, not a specific consumer's business process. See `2026-07-17_session-origin-not-product-usecase.md`. +4. **No public issue or PR without explicit HITL.** If the operator wants a public issue, use `/dogfood` → local save first, then offer a separate `/contribute` or `gh issue create` step after the hygiene strip is verified. +5. **No Field Report cards.** Routine dogfood filing does not create or update Field Report cadence. + +## What to Do + +### Step 1: Determine lane + +Check in order: +1. `git remote get-url origin` (or `git config remote.origin.url`) contains `agent-kit-dev` → factory lane. +2. A `dogfood/` directory exists at the repo root and contains this README → factory lane. +3. `.cursor/agent-kit.json` exists and the repo is not `agent-kit-dev` → consumer lane. +4. Neither → stop and ask: "This doesn't look like the factory or a migrated consumer. Save to `dogfood/` (factory) or `.cursor/dogfood/` (consumer)?" + +### Step 2: Build filename and body + +1. Normalize the topic: lowercase, spaces/hyphens/underscores to underscores, strip punctuation and trailing date. Keep it short (≤ 40 chars). +2. Date suffix: `YYYYMMDD` from today (`YYYY_MM_DD` for readability). +3. Filename: `cursor__.md`. +4. Body template: + ```markdown + # Dogfood: + + - **Date:** + - **Lane:** factory | consumer + - **Source:** chat summary | explicit /dogfood args + + ## Observation + + + + ## Impact + + + + ## Triage (initial) + + - Fix now / Park / Ignore + - **Tags:** + ``` +5. Run the hygiene strip. If you cannot strip enough context to make it generic, file the note but flag it as `needs-anonymization` in the body and stop before any memory WRITE or public issue. + +### Step 3: Write and index + +**Factory lane:** +- Write `dogfood/cursor__.md`. +- Append the file to the `### Unprocessed Files` section of `dogfood/README.md` with a one-line summary and the capture date. + +**Consumer lane:** +- Ensure `.cursor/dogfood/` exists (create if missing). +- Write `.cursor/dogfood/cursor__.md`. +- Write or append to `.cursor/dogfood/README.md` with the same Unprocessed/Processed structure as the factory README. +- The folder is local-only; do not `git add` it. + +### Step 4: Cross-repo bridge (optional, operator-initiated only) + +A consumer project does **not** write directly into the factory repo. If the operator wants a note from `.cursor/dogfood/` to reach the canonical `agent-kit-dev` inbox, the supported path is a manual bridge: + +1. **Configure factory root** (optional). In `.cursor/context/config.json` add: + ```json + { + "dogfood": { + "factoryRoot": "/absolute/path/to/agent-kit-dev" + } + } + ``` + The path is advisory only; the command never writes there automatically. +2. **Operator copies the file** with `cp` or the IDE file explorer from `.cursor/dogfood/cursor__.md` to `dogfood/cursor__.md` in the factory checkout. +3. **Re-apply hygiene** in the factory context before committing. The file must be re-reviewed because the factory README index and triage cycle are separate from the consumer inbox. +4. **No bridge for routine friction.** Most consumer notes should stay in `.cursor/dogfood/` as local project memory. Only copy patterns that are clearly Agent Kit system gaps. + +If `dogfood.factoryRoot` is absent, omit the bridge step and file locally. Never invent a factory path or guess from repo history. + +### Step 5: Optional public issue (HITL only) + +After filing locally, you may offer a public GitHub issue **only if**: +1. The pattern is an upstream Agent Kit gap, not a project-specific workaround. +2. The hygiene strip has already been applied (no consumer identities, no session-origin detail). +3. The operator explicitly agrees via Ask questions with options: + - `Create public issue` + - `Keep local only` + +If the operator chooses `Create public issue`: +- Use `gh issue create` against the public `agent-kit-startup/agent-kit` repository. +- Title format: `[Dogfood] `. +- Body: a concise, anonymized summary plus the local dogfood file path for reference. +- Do **not** paste the full local dogfood file if it contains any non-public detail. +- Never create a Field Report card or cadence warning for this step. + +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. + +## 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-07-17_session-origin-not-product-usecase.md` — hygiene +- Cursor product-update gaps may route here via `/cursor-update-awareness` (Ask → `/dogfood`) diff --git a/.cursor/commands/git-staging.md b/.cursor/commands/git-staging.md index 93891c9..e4dc5a1 100644 --- a/.cursor/commands/git-staging.md +++ b/.cursor/commands/git-staging.md @@ -6,7 +6,7 @@ Follow the **git staging** routine to bring local changes to the pre-production 1. **Read** the "Prompt: git staging" section in `autogit/gitupdate.md` (when it exists). 2. **Staging hygiene (monitors):** if `git status` shows untracked or unrelated dirty `.cursor/memory/plan-monitor-*.md`, **warn** before commit. Stage memory/monitor files **add-by-name only**; never broad `git add` of `.cursor/memory/` WIP into a product commit (ADR `decisions/2026-07-27_plan-monitor-consumer-awareness.md`, external-review staging hygiene). -3. **Lint evidence (required when code/format paths change):** before claiming staging-ready, **run** the repo formatter/linter on touched files and **record the command + result** (pass/fail) in the worker summary or tick notes. Writing `Staging ready: yes` or the contract string alone is **not** evidence. Pure markdown / docs-only with no applicable linter: state `none applicable`. Same gate as `/run-plan` Staging-ready lint gate. +3. **Lint evidence (required when code/format paths change):** before claiming staging-ready, **run** the repo formatter/linter on touched files and **record the command + result** (pass/fail) in the worker summary or tick notes. Writing `Staging ready: yes` or the contract string alone is **not** evidence. Pure markdown / docs-only with no applicable linter: state `none applicable`. Same gate as `/run-plan` Staging-ready lint gate. **Dashboard CSS/HTML only** (`dashboard/dashboard.html` and similar, outside Biome scope): record `Tests: none applicable (dashboard-CSS); covered by plugin-ux-validation` when the UX suite pins the change (ADR `decisions/2026-07-29_dashboard-css-lint-evidence-convention.md`); do not claim Biome covered the HTML. 4. Run in order: validation (not on `main`), CHANGELOG (`[Unreleased]`), checkout staging, pull, working branch, Conventional Commits, push, MR/PR (**always `--base staging` / target `staging`**), merge, cleanup. 5. **Never** commit directly to `main`. 6. On completion: update `.cursor/HANDOFF.md` (phase in staging); memory-loop WRITE if it applies. diff --git a/.cursor/commands/plan-external-review.md b/.cursor/commands/plan-external-review.md index 22a8782..754c257 100644 --- a/.cursor/commands/plan-external-review.md +++ b/.cursor/commands/plan-external-review.md @@ -47,7 +47,8 @@ If any are missing: stop. Do **not** claim a review ran. Tell the user to run `a ### What "operator-visible" means (smoke notes) - **Autonomous success:** chat arm **must** use `--force --autonomous --wait-monitor`. The launcher prefers a background/inspectable PTY (no OS Terminal focus by default; `--focus-terminal` / `AGENT_KIT_AUDIT_FOCUS_TERMINAL=1` restores activate), then polls until a **fresh** monitor exists (`mtime >= arm epoch` or content sentinel). Exit `0` = fresh ready; `3` = timeout; `4` = soft-fail while waiting. Spawn-only exit 0 without wait is **not** review done. **Chat continuation:** AwaitShell until `0|3|4`; on `0` run `/plan-review-triage` Ask in the same session. Do **not** stop at Final HANDOFF "after monitor lands" or require typing `done`. ADR: `decisions/2026-07-27_audits-wait-freshness-enforce.md`. -- **Autonomous soft-fail:** missing `claude` → tip + exit `4` when `--wait-monitor` was requested (Field Report owed). Background spawn unavailable → falls back to `--paste-only` UX with an honest "NOT running yet" banner. Soft-fail does **not** invent a monitor or run triage as if review completed. +- **Autonomous soft-fail:** missing `claude` → tip + exit `4` when `--wait-monitor` was requested (Field Report owed). Background spawn unavailable → falls back to `--paste-only` UX with an honest "NOT running yet" banner. A **silent PTY** (spawn succeeded, no scrollback within the progress-gate grace window) is reported as a failed launch: the launcher disposes the session it just spawned, prints the paste fallback, and soft-fails instead of burning the wait budget. A **session-cap refusal** (detached `agent-kit-audit-*` sessions at the cap) never spawns at all. Soft-fail does **not** invent a monitor or run triage as if review completed. +- **Exit 3 is timeout-only:** it means the freshness gate was not satisfied inside the budget, never that the review finished. A monitor that appears later, including one written by a different or later arm, does **not** convert a `3` into success. Leave the target Field Report **owed** and re-arm. ADR: `decisions/2026-07-30_audits-pty-progress-gate-zombie-policy.md`. - **Paste-only:** clipboard + printed interactive one-liner; review starts only after the operator pastes into their Cursor Terminal. After paste (Claude running), the session still waits for the monitor file then continues into triage Ask when possible. - **`--dry-run`:** resolves mode/plan and prints `background-cmd` / `paste-cmd` / `focus-terminal` without spawning Claude (useful for smoke). @@ -92,6 +93,8 @@ Script behavior (ADR): - `mode: "autonomous"` (non-headless) → background/inspectable PTY auto-launch; soft-fallback to paste-only - Missing `mode` key → paste-compatible default (`--print` when no flag; chat should pass `--paste-only` or set autonomous) - Interactive and headless launches pass Claude CLI `--permission-mode auto` +- Post-spawn progress gate: samples PTY scrollback before the monitor wait; silent PTY → early abort (`AGENT_KIT_AUDIT_PROGRESS_TIMEOUT`, default 60s, `0` disables); channels without a scrollback API stay advisory +- Session pressure: warns at `AGENT_KIT_AUDIT_SESSION_WARN` detached `agent-kit-audit-*` sessions, refuses to spawn at `AGENT_KIT_AUDIT_SESSION_CAP`; reap is opt-in (`--reap-audit-sessions`), detached-only, past `AGENT_KIT_AUDIT_REAP_MIN_AGE` - `--paste-only` copies the interactive one-liner via `pbcopy` / `xclip` / `xsel` / `clip.exe` when available - Never `/git-prod`; never broad `git add` - Does **not** register a Cursor native `stop` hook @@ -120,6 +123,7 @@ After a successful autonomous arm in chat, **do not** hand off with "run `/plan- - ADR: `.cursor/memory/decisions/2026-07-20_optional-claude-code-plan-review.md` - Audits contract: `.cursor/memory/decisions/2026-07-27_audits-autonomous-plan-review-contract.md` - Post-spawn watch + continue: `.cursor/memory/decisions/2026-07-27_audits-post-spawn-monitor-watch-continue.md` +- PTY progress gate, session cap, exit 3 honesty: `.cursor/memory/decisions/2026-07-30_audits-pty-progress-gate-zombie-policy.md` - Related: `.cursor/memory/decisions/2026-07-19_stop-hook-no-hitl-interference.md` (no stop-hook auto agent) - Prompt: `.cursor/context/templates/plan-external-review-prompt.md` - Monitor template: `.cursor/context/templates/plan-monitor.md` diff --git a/.cursor/commands/plan-review-triage.md b/.cursor/commands/plan-review-triage.md index abe6638..72a91d9 100644 --- a/.cursor/commands/plan-review-triage.md +++ b/.cursor/commands/plan-review-triage.md @@ -269,6 +269,7 @@ Agent: Writes one plan file + Backlog row; ## Residuals plan (or Triage note) on - HITL contract: `.cursor/rules/hitl-ask-questions.mdc` - Related: `.cursor/commands/plan-external-review.md` - Local dismiss without triage: `.cursor/commands/field-report-resolve.md` +- Cursor product-update gaps may also enter triage via `/cursor-update-awareness` → Ask → `/backlog-add` / `/dogfood` - Decision: `.cursor/memory/decisions/2026-07-28_triage-write-residuals-via-backlog.md` - Decision: `.cursor/memory/decisions/2026-07-26_backlog-crud-commands-contract.md` - Decision: `.cursor/memory/decisions/2026-07-25_mission-control-field-report-dismissals.md` diff --git a/.cursor/commands/run-plan-all.md b/.cursor/commands/run-plan-all.md index 8de95de..e56e93f 100644 --- a/.cursor/commands/run-plan-all.md +++ b/.cursor/commands/run-plan-all.md @@ -137,6 +137,8 @@ Use the safe helper after the confirm Ask grants consolidations. Canonical launc | Drop / archive | `.cursor/scripts/run-plan-all-consolidate.sh --drop PLAN.plan.md --apply --approved` (refuse overwrite unless `--force-overwrite`) | | HANDOFF queue rewrite | `.cursor/scripts/run-plan-all-consolidate.sh --rewrite-queue --queue "a.plan.md,b.plan.md" --cursor 0 --status running --activate a.plan.md --apply --approved` | +`--rewrite-queue` validates and normalizes `--outcomes` (including multiline) **before** any HANDOFF mutation, then applies Plan/Mode/queue/cursor/status/outcomes as one atomic rewrite. Invalid outcomes (for example a line that looks like a HANDOFF machine field) refuse with the file untouched. Backlog CRUD callers still cannot rewrite the queue. + Queue field shape aligns with `serializeRunPlanAllQueueFields` in `packages/cli/src/plan-loop/run-plan-all-orchestrator.ts` (machine-field bullets only). Never `/git-prod` from this path. ## Execute the Queue @@ -181,6 +183,8 @@ Read `externalPlanReview` before the queue confirm Ask and at each advance: Never steal `/git-prod` confirmation. Chat never runs silent headless `--force` / `claude -p` in the agent shell. Spawn-only exit 0 without `--wait-monitor` is **not** review done. Never stop at Final HANDOFF "when monitors exist, run triage" after arming: wait (freshness) then continue (mid-batch waits for file only; queue-end waits then triage Ask with explicit paths). ADR: `2026-07-27_audits-autonomous-plan-review-contract.md` (supersedes queue-end-only); wait freshness: `2026-07-27_audits-wait-freshness-enforce.md`. +**Exit 3 stays timeout-only across the queue.** A mid-queue or queue-end arm that returns `3` reviewed nothing: leave that plan Field Report **owed**, keep its path out of the queue-end triage list, and never narrate it as reviewed. Monitors that show up later, including monitors written by a different arm or a later queue position, do **not** retroactively upgrade an earlier `3`. Exit `4` covers the launcher soft-fails: missing `claude`, background spawn unavailable, a **silent PTY** early abort (spawn succeeded but produced no scrollback in the grace window), and a **session-cap refusal** (detached `agent-kit-audit-*` pile at the cap, so nothing spawned). Advance the queue on soft-fail, but record the target as owed, never as reviewed. ADR: `2026-07-30_audits-pty-progress-gate-zombie-policy.md`. + ### External plan review (legacy heading) Same table as **Audits (mid-batch + queue end)** above. Keep Field Report owed rows (`buildOwedReviewItems`). Mid-batch: one arm+wait (or one batch wait_all) before advance; no unwatched multi-Terminal fan-out; triage via `/plan-review-triage` at queue-end with explicit path list. Queue-end chat path: wait then triage Ask. diff --git a/.cursor/commands/run-plan.md b/.cursor/commands/run-plan.md index 26ebf61..a722089 100644 --- a/.cursor/commands/run-plan.md +++ b/.cursor/commands/run-plan.md @@ -106,9 +106,9 @@ After Final HANDOFF when the run stopped because all implementable to-dos are do 6. **Post-arm monitor watch + continue (chat required):** after arming, **do not** stop at Final HANDOFF "when the monitor lands, run `/plan-review-triage`" or wait for the operator to type `done`. Chat autonomous arm **always** includes `--wait-monitor`. In the **same session**: 1. AwaitShell / block on the launcher until exit `0` (fresh monitor ready), `3` (timeout), or `4` (soft-fail while waiting). Wait success requires a **fresh** monitor after arm start (mtime/arm-epoch or content sentinel); pre-existing files are not ready. 2. On **exit 0:** run `/plan-review-triage` Ask for that monitor path (findings-only; no silent-Ack / auto-fix). - 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. + 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. - ADR: `decisions/2026-07-27_audits-wait-freshness-enforce.md` (follow-on to `decisions/2026-07-27_audits-post-spawn-monitor-watch-continue.md`). + 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). 9. **Headless / CI only:** `agent-kit run-plan` may arm `.cursor/scripts/plan-external-review.sh` or `--force` (print / `claude -p`). That path is not chat; tips/disabled do not fail the loop. Chat and CI stay split on purpose (see memory decision `2026-07-25_external-review-chat-visible-vs-ci-headless`). Post-spawn wait+triage Ask is **chat/session only**. @@ -322,7 +322,7 @@ Prefer `run_in_background: false` so the orchestrator reviews the return in the ### Review the return - Does the summary match the format? Any blocking gaps? -- **Staging-ready lint gate:** if `Staging ready: yes` and the to-do changed formatted/linted files, require `Tests:` or `Validation:` with applicable formatter/linter commands and pass results. Reject (re-dispatch once, or mark not staging-ready) when lint evidence is missing. Pure markdown / docs-only with no repo linter: allow yes when the summary states none applicable. Do not demand a full-repo lint when focused checks on touched files suffice. Background: post-merge format PRs (`plan-monitor-dashboard-field-report-and-skins.md`; errors `2026-07-21_ci-biome-blocked-440-publish.md`, `2026-07-23_biome-format-blocked-446-tag-ci.md`). +- **Staging-ready lint gate:** if `Staging ready: yes` and the to-do changed formatted/linted files, require `Tests:` or `Validation:` with applicable formatter/linter commands and pass results. Reject (re-dispatch once, or mark not staging-ready) when lint evidence is missing. Pure markdown / docs-only with no repo linter: allow yes when the summary states none applicable. **Dashboard CSS/HTML only** (`dashboard/dashboard.html` and similar, outside Biome scope): record `Tests: none applicable (dashboard-CSS); covered by plugin-ux-validation` when the UX suite pins the change (ADR `decisions/2026-07-29_dashboard-css-lint-evidence-convention.md`); do not claim Biome covered the HTML. Do not demand a full-repo lint when focused checks on touched files suffice. Background: post-merge format PRs (`plan-monitor-dashboard-field-report-and-skins.md`; errors `2026-07-21_ci-biome-blocked-440-publish.md`, `2026-07-23_biome-format-blocked-446-tag-ci.md`). - **Findings contract:** confirm the worker wrote the plan section (or agreed artifact). If missing and the summary carries findings, use the **fallback transcription** path once (label secondhand; note gap in HANDOFF). Do not treat chat/summary-only findings as done. Findings authorship rules are unchanged; lint gate applies only when formatted/linted files were also edited. - **Remediation gate:** if the worker returned findings, read `autoRemediate` and follow section 4 (no silent product fix when false; fix-agent vs residuals plan). Reject a review-worker summary that changed product paths unless the to-do explicitly authorized product edits. - Worker failed or went out of scope: fix with **one** focused re-dispatch, or pause and ask; do not implement product code in the main window diff --git a/.cursor/context/config.example.json b/.cursor/context/config.example.json index c09309d..72aa5a5 100644 --- a/.cursor/context/config.example.json +++ b/.cursor/context/config.example.json @@ -18,6 +18,9 @@ }, "autoHandoff": false, "interTickCooldownMs": 0, + "dogfood": { + "factoryRoot": null + }, "fieldReportReviewCadence": { "enabled": true, "tickThreshold": 3 @@ -27,6 +30,13 @@ "intervalDays": 7, "lastCheckedAt": null }, + "cursorUpdateCheck": { + "enabled": false, + "intervalDays": 7, + "lastCheckedAt": null, + "lastSeenCursorVersion": null, + "changelogUrl": "https://cursor.com/changelog" + }, "updateApply": { "auto": false }, diff --git a/.cursor/context/templates/handoff.md b/.cursor/context/templates/handoff.md index c4c5c7d..1fd500e 100644 --- a/.cursor/context/templates/handoff.md +++ b/.cursor/context/templates/handoff.md @@ -14,7 +14,7 @@ Machine fields below must stay as `- **Field:**` bullets (Mission Control parses ### Gaps voice (Flight Log) -`- **Gaps:**` is operator residuals for Mission Control Flight Log (Live / Earlier), not a system-status dump. Flight Log uses palette-by-type notification chrome (`ok` / `advice` / `prompt` / `residual` / `warning`); OK must not look like a yellow residual debit. +`- **Gaps:**` is operator residuals for Mission Control Flight Log (NOW / Earlier), not a system-status dump. Flight Log uses palette-by-type notification chrome (`ok` / `advice` / `prompt` / `residual` / `warning`); OK must not look like a yellow residual debit. | Say | Avoid | |-----|--------| diff --git a/.cursor/rules/cursor-plan-handoff.mdc b/.cursor/rules/cursor-plan-handoff.mdc index 98c6886..60e8ed8 100644 --- a/.cursor/rules/cursor-plan-handoff.mdc +++ b/.cursor/rules/cursor-plan-handoff.mdc @@ -63,7 +63,7 @@ Mission Control parses **machine fields** as `- **Field:**` bullets only. Do **n - **Parked plans:** none ``` -**Gaps voice:** `- **Gaps:**` is short operator residuals for Flight Log (Live / Earlier), not mid-batch monitor paths, cadence WARNING ids, or `/git-prod` boilerplate. Prefer exact `none` when only audit/queue plumbing changed. Do not write `none. Residuals…` as Gaps body when the intent is OK (put pointers in Instruction). Say/avoid examples: `.cursor/context/templates/handoff.md` and ADR `2026-07-27_mc-flight-log-panel.md`. +**Gaps voice:** `- **Gaps:**` is short operator residuals for Flight Log (NOW / Earlier), not mid-batch monitor paths, cadence WARNING ids, or `/git-prod` boilerplate. Prefer exact `none` when only audit/queue plumbing changed. Do not write `none. Residuals…` as Gaps body when the intent is OK (put pointers in Instruction). Say/avoid examples: `.cursor/context/templates/handoff.md` and ADR `2026-07-27_mc-flight-log-panel.md`. **Mode on API/usage limit hard stop:** set `- **Mode:**` to the prior run mode plus `— STOPPED: API/usage limit` (example: `run-plan (orchestrated) — STOPPED: API/usage limit`). Mission Control matches `STOPPED` in Mode. Mirror the stop reason in `- **Gaps:**` and `- **Instruction for the next agent:**` (recovery: named model switch and/or wait; resume via `/continue-plan` or `/run-plan` after confirm). diff --git a/.cursor/rules/memory-loop.mdc b/.cursor/rules/memory-loop.mdc index aebb7fe..7b659ac 100644 --- a/.cursor/rules/memory-loop.mdc +++ b/.cursor/rules/memory-loop.mdc @@ -50,6 +50,8 @@ H1 title = short summary. Compact body: ## Format: decisions (`decisions/*.md`) - **Date:** YYYY-MM-DD +- **Status:** Proposed | Accepted | Deprecated | Superseded by `` (required for new writes; evidence policy progression) +- **Evidence:** optional paths to code, tests, runtime matrices, or ledgers when the decision is Accepted - **Context:** 1-2 sentences - **Decision:** what was chosen - **Discarded alternative:** brief diff --git a/.cursor/scripts/plan-external-review.sh b/.cursor/scripts/plan-external-review.sh index 15affa5..f99020f 100755 --- a/.cursor/scripts/plan-external-review.sh +++ b/.cursor/scripts/plan-external-review.sh @@ -30,6 +30,7 @@ # .cursor/scripts/plan-external-review.sh --force --autonomous --wait-monitor [plan] # .cursor/scripts/plan-external-review.sh --wait-monitor [--wait-timeout SECONDS] [plan] # .cursor/scripts/plan-external-review.sh --focus-terminal ... # rollback: OS window focus +# .cursor/scripts/plan-external-review.sh --reap-audit-sessions [--dry-run] [plan] # # Modes: # autonomous (config mode=autonomous, or --autonomous): spawn interactive Claude in an @@ -55,16 +56,62 @@ # already-running review. Does not switch to invisible agent-shell claude -p. # --wait-timeout SECONDS: poll budget for --wait-monitor (default 900). # +# Progress gate (post-spawn PTY activity): +# 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 polls for the first PTY output before entering the monitor wait. A silent PTY +# (no non-whitespace scrollback inside the grace window, or a session that vanished) is +# treated as a failed launch: print the diagnosis, dispose only the session this run +# spawned, print the paste fallback, then soft-fail instead of burning the remaining +# --wait-timeout. Channels without a scrollback API (Terminal.app, Linux/Windows +# emulators) degrade to advisory and proceed to the normal wait. +# +# Session lifecycle (cap, warn, opt-in reap): +# Kit-owned audit sessions are named agent-kit-audit--, where is an +# 8-hex workspace token derived from the repo ROOT. Cap, warn, count, and opt-in reap +# only consider sessions owned by THIS workspace (strict pattern match). Legacy +# unscoped agent-kit-audit- names and other workspaces' tokens are never counted +# or disposed by this process (operator may quit them manually). Attached sessions are +# never counted as pile pressure. At or above the warn threshold it warns and prints +# the dispose command; at or above the hard cap it refuses to spawn, prints the dispose +# instructions plus the paste fallback, and soft-fails without entering the monitor wait +# (no audit starts, Field Report stays owed). +# Reaping is opt-in (--reap-audit-sessions or AGENT_KIT_AUDIT_REAP=1) and disposes only +# detached, workspace-owned sessions whose age is at or above AGENT_KIT_AUDIT_REAP_MIN_AGE. +# Attached sessions are never touched, an unknown age counts as too young to reap, and +# --dry-run only previews. No pkill, no wildcard kill, nothing outside the owned namespace. +# +# Environment: +# AGENT_KIT_AUDIT_PROGRESS_TIMEOUT progress-gate grace window in seconds (default 60). +# 0 disables the gate; a non-integer value prints a tip +# and falls back to 60. +# AGENT_KIT_AUDIT_SESSION_WARN warn at or above this many detached workspace-owned +# agent-kit-audit--* sessions (default 5). 0 disables +# the warning; a non-integer value prints a tip and falls +# back to 5. +# AGENT_KIT_AUDIT_SESSION_CAP refuse to spawn at or above this many detached +# workspace-owned sessions (default 20). 0 disables the +# refusal; a non-integer value prints a tip and falls +# back to 20. +# AGENT_KIT_AUDIT_REAP_MIN_AGE age floor in seconds for opt-in reaping (default 3600). +# A non-integer value prints a tip and falls back to 3600. +# AGENT_KIT_AUDIT_REAP 1/true: same as --reap-audit-sessions (opt-in disposal +# of detached workspace-owned sessions past the age floor). +# AGENT_KIT_AUDIT_FOCUS_TERMINAL 1/true: rollback to OS Terminal activate / emulator focus. +# # Exit codes: # 0 ok / fresh monitor ready (with --wait-monitor) / soft-fail tip when NOT waiting # (missing claude/template: tip + exit 0 when --wait-monitor is off) # 2 usage / argument error # 3 --wait-monitor timeout (no fresh monitor within budget) # 4 soft-fail while --wait-monitor was requested (e.g. missing claude on autonomous -# arm, or background spawn fell back to paste-only without a waitable arm) +# 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. # # 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 set -euo pipefail @@ -84,19 +131,101 @@ DRY_RUN=0 WAIT_MONITOR=0 WAIT_TIMEOUT=900 WAIT_ARM_EPOCH="" +# Post-spawn PTY activity gate grace window (seconds). 0 disables. +PROGRESS_TIMEOUT=60 +# Kit-owned audit session namespace. Cap/reap only touch workspace-owned names (see token). +AUDIT_SESSION_NS_PREFIX="agent-kit-audit-" +# 8-hex token from ROOT so concurrent workspaces do not share cap/reap scope. +audit_workspace_token() { + local hash="" + if command -v shasum >/dev/null 2>&1; then + hash="$(printf '%s' "$ROOT" | shasum -a 256 2>/dev/null | awk '{print substr($1,1,8)}')" + elif command -v sha256sum >/dev/null 2>&1; then + hash="$(printf '%s' "$ROOT" | sha256sum 2>/dev/null | awk '{print substr($1,1,8)}')" + elif command -v openssl >/dev/null 2>&1; then + hash="$(printf '%s' "$ROOT" | openssl dgst -sha256 2>/dev/null | awk '{print substr($NF,1,8)}')" + else + hash="$(printf '%s' "$ROOT" | cksum 2>/dev/null | awk '{printf "%08x", $1}' | head -c 8)" + fi + if ! [[ "$hash" =~ ^[0-9a-f]{8}$ ]]; then + hash="$(printf '%s' "$ROOT" | cksum 2>/dev/null | awk '{printf "%08x", $1}' | head -c 8)" + fi + printf '%s' "$hash" +} +AUDIT_WS_TOKEN="$(audit_workspace_token)" +AUDIT_SESSION_OWNED_PREFIX="${AUDIT_SESSION_NS_PREFIX}${AUDIT_WS_TOKEN}-" +# Detached workspace-owned sessions: warn at or above WARN, refuse to spawn at or above CAP. 0 disables. +AUDIT_SESSION_WARN=5 +AUDIT_SESSION_CAP=20 +# Age floor (seconds) for opt-in reaping. Younger sessions are left alone even when reaping is on. +AUDIT_REAP_MIN_AGE=3600 +# Opt-in destructive disposal (--reap-audit-sessions / AGENT_KIT_AUDIT_REAP). Never the default. +REAP_SESSIONS=0 FOCUS_TERMINAL=0 # Set by launch_background_terminal on success: tmux|screen|macos-terminal|linux-emulator|windows-terminal LAUNCH_CHANNEL="" LAUNCH_ATTACH_HINT="" +# Multiplexer session this invocation created (tmux/screen only). Empty for emulator channels. +LAUNCH_SESSION_NAME="" PLAN_ARG="" PLAN_ARGS=() +# 0 when name is a strict workspace-owned audit session for THIS ROOT. +is_owned_audit_session() { + local name="$1" + # Strict: agent-kit-audit-<8hex>- and token must match this workspace. + if [[ "$name" =~ ^agent-kit-audit-([0-9a-f]{8})-([0-9]+)$ ]]; then + [[ "${BASH_REMATCH[1]}" == "$AUDIT_WS_TOKEN" ]] + return $? + fi + return 1 +} + +# Build a fresh owned session name for this PID (collision-resistant across workspaces). +make_audit_session_name() { + printf '%s%s' "$AUDIT_SESSION_OWNED_PREFIX" "$$" +} + if [[ "${AGENT_KIT_AUDIT_FOCUS_TERMINAL:-}" == "1" || "${AGENT_KIT_AUDIT_FOCUS_TERMINAL:-}" == "true" ]]; then FOCUS_TERMINAL=1 fi +# Bad env value is advisory, never a hard error: the gate must not break an audit arm. +if [[ -n "${AGENT_KIT_AUDIT_PROGRESS_TIMEOUT:-}" ]]; then + if [[ "${AGENT_KIT_AUDIT_PROGRESS_TIMEOUT}" =~ ^[0-9]+$ ]]; then + PROGRESS_TIMEOUT="${AGENT_KIT_AUDIT_PROGRESS_TIMEOUT}" + else + echo "tip: AGENT_KIT_AUDIT_PROGRESS_TIMEOUT must be a non-negative integer (got: ${AGENT_KIT_AUDIT_PROGRESS_TIMEOUT}); using ${PROGRESS_TIMEOUT}" >&2 + fi +fi + +# Same advisory contract for the session-lifecycle knobs: a bad value tips and falls back. +resolve_int_env() { + local var_name="$1" + local fallback="$2" + local raw="${!var_name:-}" + if [[ -z "$raw" ]]; then + printf '%s' "$fallback" + return 0 + fi + if [[ "$raw" =~ ^[0-9]+$ ]]; then + printf '%s' "$raw" + return 0 + fi + echo "tip: ${var_name} must be a non-negative integer (got: ${raw}); using ${fallback}" >&2 + printf '%s' "$fallback" +} + +AUDIT_SESSION_WARN="$(resolve_int_env AGENT_KIT_AUDIT_SESSION_WARN "$AUDIT_SESSION_WARN")" +AUDIT_SESSION_CAP="$(resolve_int_env AGENT_KIT_AUDIT_SESSION_CAP "$AUDIT_SESSION_CAP")" +AUDIT_REAP_MIN_AGE="$(resolve_int_env AGENT_KIT_AUDIT_REAP_MIN_AGE "$AUDIT_REAP_MIN_AGE")" + +if [[ "${AGENT_KIT_AUDIT_REAP:-}" == "1" || "${AGENT_KIT_AUDIT_REAP:-}" == "true" ]]; then + REAP_SESSIONS=1 +fi + usage() { - sed -n '2,70p' "$0" | sed 's/^# \{0,1\}//' + sed -n '2,111p' "$0" | sed 's/^# \{0,1\}//' } while [[ $# -gt 0 ]]; do @@ -145,6 +274,10 @@ while [[ $# -gt 0 ]]; do FOCUS_TERMINAL=1 shift ;; + --reap-audit-sessions) + REAP_SESSIONS=1 + shift + ;; --wait-timeout) if [[ $# -lt 2 || -z "${2:-}" ]]; then echo "error: --wait-timeout requires SECONDS" >&2 @@ -368,8 +501,13 @@ resolve_launch_mode() { } # Escape a string for embedding inside an AppleScript double-quoted literal. +# Rejects control characters (including newlines) so shell payloads never enter AppleScript. applescript_escape() { local s="$1" + if [[ "$s" == *$'\n'* || "$s" == *$'\r'* || "$s" == *$'\0'* ]]; then + echo "error: applescript_escape refused control characters in payload" >&2 + return 1 + fi s="${s//\\/\\\\}" s="${s//\"/\\\"}" printf '%s' "$s" @@ -377,7 +515,8 @@ applescript_escape() { # Spawn interactive Claude in an inspectable background PTY (or focused Terminal when # --focus-terminal / AGENT_KIT_AUDIT_FOCUS_TERMINAL). Never agent-shell claude -p. -# Sets LAUNCH_CHANNEL + LAUNCH_ATTACH_HINT on success. Returns 0 on success. +# Sets LAUNCH_CHANNEL + LAUNCH_ATTACH_HINT (and LAUNCH_SESSION_NAME on tmux/screen) +# on success. Returns 0 on success. # ADR: decisions/2026-07-28_audits-headless-terminal-honesty.md launch_background_terminal() { local shell_cmd="$1" @@ -385,13 +524,15 @@ launch_background_terminal() { uname_s="$(uname -s 2>/dev/null || echo unknown)" LAUNCH_CHANNEL="" LAUNCH_ATTACH_HINT="" - session_name="agent-kit-audit-$$" + LAUNCH_SESSION_NAME="" + session_name="$(make_audit_session_name)" # Prefer detached multiplexers (true headless/inspectable PTY, no OS window focus). if [[ "$FOCUS_TERMINAL" -eq 0 ]] && command -v tmux >/dev/null 2>&1; then if tmux new-session -d -s "$session_name" bash -lc "$shell_cmd" >/dev/null 2>&1; then LAUNCH_CHANNEL="tmux" LAUNCH_ATTACH_HINT="tmux attach -t $session_name" + LAUNCH_SESSION_NAME="$session_name" return 0 fi fi @@ -399,14 +540,23 @@ launch_background_terminal() { if screen -dmS "$session_name" bash -lc "$shell_cmd" >/dev/null 2>&1; then LAUNCH_CHANNEL="screen" LAUNCH_ATTACH_HINT="screen -r $session_name" + LAUNCH_SESSION_NAME="$session_name" return 0 fi fi - # macOS Terminal.app: default without activate (no focus steal); --focus-terminal adds activate. + # macOS Terminal.app: never embed shell_cmd in AppleScript. Write a temp runner and + # pass only the quoted path (closes PLAN_EXTERNAL_REVIEW_APPLESCRIPT_INJECTION class). if [[ "$uname_s" == "Darwin" ]] && command -v osascript >/dev/null 2>&1; then - local esc - esc="$(applescript_escape "$shell_cmd")" + local cmd_file run_line esc + cmd_file="$(mktemp "${TMPDIR:-/tmp}/agent-kit-audit-cmd.XXXXXX")" || return 1 + printf '%s\n' "$shell_cmd" >"$cmd_file" + chmod u+x "$cmd_file" 2>/dev/null || true + run_line="bash $(printf '%q' "$cmd_file")" + if ! esc="$(applescript_escape "$run_line")"; then + rm -f "$cmd_file" + return 1 + fi if [[ "$FOCUS_TERMINAL" -eq 1 ]]; then if osascript </dev/null 2>&1 || true fi # Linux / Windows emulators (may open a window; last resort before paste-only). @@ -473,6 +624,376 @@ EOF return 1 } +# Non-whitespace scrollback bytes for a spawned session. Prints -1 when the channel has +# no scrollback API (advisory only). screen -X hardcopy pads a blank buffer on some +# builds, so raw file size lies: count non-whitespace bytes instead. +pty_scrollback_bytes() { + local channel="$1" + local name="$2" + if [[ -z "$name" ]]; then + printf '%s' "-1" + return 0 + fi + local count="" + case "$channel" in + screen) + local tmpfile + tmpfile="$(mktemp "${TMPDIR:-/tmp}/agent-kit-audit-hardcopy.XXXXXX" 2>/dev/null || true)" + if [[ -z "$tmpfile" ]]; then + printf '%s' "-1" + return 0 + fi + # -p 0 is required: without an explicit window target a detached session writes an + # empty hardcopy even when the PTY has output (observed on macOS screen 4.00). + screen -S "$name" -p 0 -X hardcopy "$tmpfile" >/dev/null 2>&1 || true + count="$(tr -d '[:space:]' < "$tmpfile" 2>/dev/null | wc -c | tr -d '[:space:]' || true)" + rm -f "$tmpfile" >/dev/null 2>&1 || true + ;; + tmux) + count="$(tmux capture-pane -p -t "$name" 2>/dev/null | tr -d '[:space:]' | wc -c | tr -d '[:space:]' || true)" + ;; + *) + printf '%s' "-1" + return 0 + ;; + esac + if ! [[ "$count" =~ ^[0-9]+$ ]]; then + count=0 + fi + printf '%s' "$count" +} + +# 0 when the named session still exists. Channels without a session handle answer 0 +# (unknown lifecycle is not evidence of death). +pty_session_alive() { + local channel="$1" + local name="$2" + if [[ -z "$name" ]]; then + return 1 + fi + case "$channel" in + screen) + # screen -ls exits 1 while listing sessions, so capture first (pipefail is on). + local listing + listing="$(screen -ls 2>/dev/null || true)" + if printf '%s\n' "$listing" | grep -q "\.${name}[[:space:]]"; then + return 0 + fi + return 1 + ;; + tmux) + if tmux has-session -t "$name" >/dev/null 2>&1; then + return 0 + fi + return 1 + ;; + *) + return 0 + ;; + esac +} + +# Dispose only the session this invocation spawned. No-op when the name is empty or the +# session is already gone. Never touches any other session (including other workspaces). +dispose_launched_session() { + local channel="$1" + local name="$2" + if [[ -z "$name" ]]; then + echo "audits: no session handle to dispose (channel: ${channel:-unknown})" + return 0 + fi + if ! is_owned_audit_session "$name"; then + echo "audits: refuse dispose of non-owned session $name (workspace token ${AUDIT_WS_TOKEN})" + return 0 + fi + case "$channel" in + screen) + if pty_session_alive screen "$name"; then + screen -S "$name" -X quit >/dev/null 2>&1 || true + fi + echo "audits: disposed screen session $name" + ;; + tmux) + if pty_session_alive tmux "$name"; then + tmux kill-session -t "$name" >/dev/null 2>&1 || true + fi + echo "audits: disposed tmux session $name" + ;; + *) + echo "audits: no disposal path for channel ${channel:-unknown}" + ;; + esac + return 0 +} + +# One line per existing workspace-owned session: channelnamestateage_seconds. +# state is attached|detached; age_seconds is -1 when it cannot be determined (callers must +# treat unknown age as too young to reap). Prints nothing and succeeds when there is none. +# ADR: decisions/2026-07-30_audits-pty-progress-gate-zombie-policy.md +list_audit_sessions() { + local now + now="$(date +%s)" + + if command -v screen >/dev/null 2>&1; then + # screen -ls exits 1 while listing sessions, so capture first (pipefail is on). + local listing sockdir="" line pid name marker state socket mtime age + listing="$(screen -ls 2>/dev/null || true)" + while IFS= read -r line; do + if [[ "$line" =~ ^[0-9]+[[:space:]]+Sockets?[[:space:]]+in[[:space:]]+(.+)\.$ ]]; then + sockdir="${BASH_REMATCH[1]}" + fi + done <<< "$listing" + while IFS= read -r line; do + if ! [[ "$line" =~ ^[[:space:]]+([0-9]+)\.([^[:space:]]+)[[:space:]]+\((.*)\) ]]; then + continue + fi + pid="${BASH_REMATCH[1]}" + name="${BASH_REMATCH[2]}" + marker="${BASH_REMATCH[3]}" + # Workspace ownership + strict pattern (rejects prefix pollution / foreign tokens). + if ! is_owned_audit_session "$name"; then + continue + fi + if [[ "$marker" =~ [Aa]ttached ]]; then + state="attached" + else + state="detached" + fi + age="-1" + socket="$sockdir/$pid.$name" + if [[ -n "$sockdir" && -e "$socket" ]]; then + mtime="$(file_mtime_epoch "$socket")" + if [[ "$mtime" =~ ^[0-9]+$ && "$mtime" -gt 0 && "$now" -ge "$mtime" ]]; then + age=$((now - mtime)) + fi + fi + printf 'screen\t%s\t%s\t%s\n' "$name" "$state" "$age" + done <<< "$listing" + fi + + if command -v tmux >/dev/null 2>&1; then + # No server running / no sessions: skip silently. + local tmux_out t_name t_attached t_created t_state t_age + tmux_out="$(tmux list-sessions -F '#{session_name} #{session_attached} #{session_created}' 2>/dev/null || true)" + while read -r t_name t_attached t_created; do + [[ -z "$t_name" ]] && continue + if ! is_owned_audit_session "$t_name"; then + continue + fi + if [[ "$t_attached" =~ ^[0-9]+$ && "$t_attached" -gt 0 ]]; then + t_state="attached" + else + t_state="detached" + fi + t_age="-1" + if [[ "$t_created" =~ ^[0-9]+$ && "$now" -ge "$t_created" ]]; then + t_age=$((now - t_created)) + fi + printf 'tmux\t%s\t%s\t%s\n' "$t_name" "$t_state" "$t_age" + done <<< "$tmux_out" + fi + + return 0 +} + +# Detached kit-owned sessions only: attached sessions are operator work in progress, not pile +# pressure, and are never disposed. +count_audit_sessions() { + local count + count="$(list_audit_sessions | awk -F'\t' '$3 == "detached"' | wc -l | tr -d '[:space:]' || true)" + if ! [[ "$count" =~ ^[0-9]+$ ]]; then + count=0 + fi + printf '%s' "$count" +} + +# Operator disposal instructions. Namespace-scoped by design: never a bare quit on an +# unrelated session, never pkill, never a wildcard kill. +print_dispose_instructions() { + cat < # reap then launch a new audit + AGENT_KIT_AUDIT_REAP_MIN_AGE=0 lowers the age floor for one run. +Inspect first, then dispose one session at a time: + screen -ls # or: tmux ls + screen -S -X quit # or: tmux kill-session -t +This workspace token: ${AUDIT_WS_TOKEN} (sessions: ${AUDIT_SESSION_OWNED_PREFIX}) +Legacy unscoped agent-kit-audit- names are not owned here; quit them manually if needed. +Policy: .cursor/memory/decisions/2026-07-30_audits-pty-progress-gate-zombie-policy.md +EOF +} + +# Opt-in disposal of stale workspace-owned sessions. Called only when REAP_SESSIONS is 1. +# Skips attached sessions, unknown ages, foreign/legacy names, and anything younger than +# the age floor, printing one line per decision. Under --dry-run it lists candidates and +# kills nothing. +reap_audit_sessions() { + local channel name state age seen=0 + while IFS=$'\t' read -r channel name state age; do + [[ -z "$name" ]] && continue + # Belt and braces: list_audit_sessions already filters to owned strict names. + if ! is_owned_audit_session "$name"; then + echo "audits: reap skip $name (not owned by workspace ${AUDIT_WS_TOKEN})" + continue + fi + seen=$((seen + 1)) + if [[ "$state" == "attached" ]]; then + echo "audits: reap skip $name (attached; operator-owned)" + continue + fi + if [[ "$age" == "-1" ]]; then + echo "audits: reap skip $name (age unknown; treated as too young)" + continue + fi + if [[ "$age" -lt "$AUDIT_REAP_MIN_AGE" ]]; then + echo "audits: reap skip $name (age ${age}s below min-age ${AUDIT_REAP_MIN_AGE}s)" + continue + fi + if [[ "$DRY_RUN" -eq 1 ]]; then + echo "audits: reap candidate $name (channel: $channel, age ${age}s; dry-run, not disposed)" + continue + fi + dispose_launched_session "$channel" "$name" + done < <(list_audit_sessions) + if [[ "$seen" -eq 0 ]]; then + echo "audits: reap found no ${AUDIT_SESSION_OWNED_PREFIX}* sessions" + fi + return 0 +} + +# Pre-spawn pressure gate: reap when opted in, then warn or refuse on detached pile size. +# A refusal never spawns and never enters the monitor wait. +audit_session_pressure_gate() { + local kind="${1:-review}" + if [[ "$REAP_SESSIONS" -eq 1 ]]; then + echo "audits: reaping detached ${AUDIT_SESSION_OWNED_PREFIX}* sessions (min-age: ${AUDIT_REAP_MIN_AGE}s)" + reap_audit_sessions + fi + local count + count="$(count_audit_sessions)" + if [[ "$AUDIT_SESSION_CAP" -ne 0 && "$count" -ge "$AUDIT_SESSION_CAP" ]]; then + cat < .cursor/plans/archive/" } -upsert_handoff_field() { - # args: label value_file_or_inline - # Rewrites or appends "- **Label:** value" in HANDOFF. - local label="$1" - local value="$2" +# Upsert "- **Label:** value" inside a working copy (never mutates HANDOFF directly). +upsert_field_in_file() { + local file="$1" + local label="$2" + local value="$3" local tmp tmp="$(mktemp)" - if [[ ! -f "$HANDOFF" ]]; then - { - echo "# Handoff - run-plan-all queue" - echo "" - echo "- **Plan:** \`none\`" - echo "- **Last updated:** $(date '+%Y-%m-%d %H:%M')" - echo "- **Mode:** run-plan-all" - } >"$HANDOFF" - fi - - if grep -q "^- \\*\\*${label}:\\*\\*" "$HANDOFF"; then - # Replace first matching line only + if grep -q "^- \\*\\*${label}:\\*\\*" "$file"; then awk -v lab="$label" -v val="$value" ' BEGIN { done=0 } { @@ -307,55 +301,100 @@ upsert_handoff_field() { } print } - ' "$HANDOFF" >"$tmp" + ' "$file" >"$tmp" else - # Append before trailing blank or at EOF - cat "$HANDOFF" >"$tmp" + cat "$file" >"$tmp" printf '\n- **%s:** %s\n' "$label" "$value" >>"$tmp" fi - mv "$tmp" "$HANDOFF" + mv "$tmp" "$file" } -replace_outcomes_block() { - local outcomes_text="$1" +# Replace Queue outcomes block using a side file (supports multiline; never awk -v). +replace_outcomes_in_file() { + local file="$1" + local outcomes_file="$2" local tmp tmp="$(mktemp)" - awk -v outcomes="$outcomes_text" ' - BEGIN { skip=0; done=0 } + awk -v ofile="$outcomes_file" ' + BEGIN { + n = 0 + while ((getline line < ofile) > 0) { + n++ + lines[n] = line + } + close(ofile) + skip = 0 + done = 0 + } + function emit_outcomes( i) { + print "- **Queue outcomes:**" + for (i = 1; i <= n; i++) { + if (lines[i] != "") print " " lines[i] + } + } { if ($0 ~ /^- \*\*Queue outcomes:\*\*/) { - print "- **Queue outcomes:**" - n = split(outcomes, lines, "\n") - for (i = 1; i <= n; i++) { - if (lines[i] != "") print " " lines[i] - } - skip=1 - done=1 + emit_outcomes() + skip = 1 + done = 1 next } if (skip == 1) { if ($0 ~ /^- \*\*/ || $0 ~ /^#/ || $0 ~ /^$/) { - skip=0 - # fall through to print this line + skip = 0 } else if ($0 ~ /^ / || $0 ~ /^[[:space:]]*-/) { next } else { - skip=0 + skip = 0 } } if (skip == 0) print } END { - if (!done) { - print "- **Queue outcomes:**" - n = split(outcomes, lines, "\n") - for (i = 1; i <= n; i++) { - if (lines[i] != "") print " " lines[i] - } - } + if (!done) emit_outcomes() } - ' "$HANDOFF" >"$tmp" - mv "$tmp" "$HANDOFF" + ' "$file" >"$tmp" + mv "$tmp" "$file" +} + +# Validate and normalize Queue outcomes BEFORE any HANDOFF mutation. +# Prints normalized body lines (without the "- **Queue outcomes:**" header) to stdout. +# Empty / "none" become "- none". (Bash argv cannot carry embedded NUL; no NUL check.) +normalize_outcomes() { + local raw="$1" + # Strip CR so Windows pastes do not create phantom lines + raw="${raw//$'\r'/}" + if [[ -z "$raw" || "$raw" == "none" ]]; then + printf '%s\n' "- none" + return 0 + fi + local line + local any=0 + while IFS= read -r line || [[ -n "$line" ]]; do + # Trim trailing whitespace only; preserve leading bullet / indent intent + line="$(printf '%s' "$line" | sed 's/[[:space:]]*$//')" + [[ -z "$line" ]] && continue + # Refuse machine-field lookalikes that would corrupt HANDOFF structure + if [[ "$line" =~ ^-\ \*\*[^*]+:\*\* ]]; then + die "--outcomes line looks like a HANDOFF machine field (refusing): $line" + fi + printf '%s\n' "$line" + any=1 + done <<< "$raw" + if [[ "$any" -eq 0 ]]; then + printf '%s\n' "- none" + fi +} + +# Atomic replace of dest with src (same-filesystem mv). Cleans src on success. +atomic_replace_file() { + local src="$1" + local dest="$2" + local staged + staged="$(mktemp "${dest}.XXXXXX")" + cat "$src" >"$staged" + mv "$staged" "$dest" + rm -f "$src" } cmd_rewrite_queue() { @@ -396,6 +435,16 @@ cmd_rewrite_queue() { die "--cursor $CURSOR out of range for queue length ${#items[@]}" fi + # Validate / normalize outcomes BEFORE any HANDOFF write (Q7/Q8/Q9). + local outcomes_file + outcomes_file="$(mktemp)" + normalize_outcomes "$OUTCOMES" >"$outcomes_file" || { + rm -f "$outcomes_file" + die "outcomes validation failed" + } + local outcomes_display + outcomes_display="$(cat "$outcomes_file")" + local queue_bracket="[" local i for i in "${!items[@]}"; do @@ -407,11 +456,8 @@ cmd_rewrite_queue() { queue_bracket+="]" local cursor_line="${CURSOR} (current: ${items[$CURSOR]})" - - local outcomes_display="$OUTCOMES" - if [[ "$OUTCOMES" == "none" ]]; then - outcomes_display="- none" - fi + local stamp + stamp="$(date '+%Y-%m-%d %H:%M')" info "rewrite HANDOFF queue fields:" info " Plan (activate): $activate" @@ -419,21 +465,43 @@ cmd_rewrite_queue() { info " Run queue: $queue_bracket" info " Queue cursor: $cursor_line" info " Queue status: $STATUS" - info " Queue outcomes: $outcomes_display" + info " Queue outcomes:" + while IFS= read -r item || [[ -n "$item" ]]; do + [[ -n "$item" ]] && info " $item" + done <<< "$outcomes_display" if [[ "$DRY_RUN" -eq 1 ]]; then + rm -f "$outcomes_file" info "dry-run: HANDOFF not written (pass --apply --approved to mutate)" return 0 fi - upsert_handoff_field "Plan" "\`${activate}\`" - upsert_handoff_field "Last updated" "$(date '+%Y-%m-%d %H:%M')" - upsert_handoff_field "Mode" "run-plan-all" - upsert_handoff_field "Run queue" "$queue_bracket" - upsert_handoff_field "Queue cursor" "$cursor_line" - upsert_handoff_field "Queue status" "$STATUS" - replace_outcomes_block "$outcomes_display" - info "HANDOFF queue fields updated" + # Build the full rewrite in a working copy, then atomically replace HANDOFF. + local work + work="$(mktemp)" + if [[ -f "$HANDOFF" ]]; then + cat "$HANDOFF" >"$work" + else + { + echo "# Handoff - run-plan-all queue" + echo "" + echo "- **Plan:** \`none\`" + echo "- **Last updated:** $stamp" + echo "- **Mode:** run-plan-all" + } >"$work" + fi + + upsert_field_in_file "$work" "Plan" "\`${activate}\`" + upsert_field_in_file "$work" "Last updated" "$stamp" + upsert_field_in_file "$work" "Mode" "run-plan-all" + upsert_field_in_file "$work" "Run queue" "$queue_bracket" + upsert_field_in_file "$work" "Queue cursor" "$cursor_line" + upsert_field_in_file "$work" "Queue status" "$STATUS" + replace_outcomes_in_file "$work" "$outcomes_file" + rm -f "$outcomes_file" + + atomic_replace_file "$work" "$HANDOFF" + info "HANDOFF queue fields updated (atomic)" } cmd_merge_checklist() { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d7e347..5bdb5a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,31 @@ jobs: - name: Test run: pnpm test + - name: Authority graph parity + if: github.repository != 'agent-kit-startup/agent-kit' + run: pnpm evidence:authority-graph:check + + - name: Public-deny-link guard + if: github.repository != 'agent-kit-startup/agent-kit' + run: pnpm check:public-deny-links + + - name: Evidence checks + if: github.repository != 'agent-kit-startup/agent-kit' + run: | + pnpm evidence:codebase-findings:check + pnpm evidence:risk-hotspots:check + pnpm evidence:knowledge-classification:check + + - name: Guard generated CLI dashboard is untracked + run: | + set -euo pipefail + tracked="$(git ls-files packages/cli/dashboard || true)" + if [ -n "$tracked" ]; then + echo "::error::packages/cli/dashboard must remain gitignored generated output:" + echo "$tracked" + exit 1 + fi + - name: Build run: pnpm build diff --git a/CHANGELOG.md b/CHANGELOG.md index f535a35..d0822fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,162 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and ## [Unreleased] +## [4.8.9] - 2026-08-01 + +### Fixed + +- Public storefront CI: skip private-only evidence steps (`authority-graph`, `public-deny-links`, findings/hotspots/knowledge checks) when `github.repository` is `agent-kit-startup/agent-kit` so Path C mirrors of `ci.yml` do not fail on missing private scripts (v4.8.5–v4.8.8 tags retained) + +## [4.8.8] - 2026-08-01 + +### Fixed + +- Regenerate `docs/evidence/knowledge-classification.json` (`summary.totalObjectCount` 682→715) so tag CI `evidence:knowledge-classification:check` passes (v4.8.5–v4.8.7 tags retained) + +## [4.8.7] - 2026-08-01 + +### Fixed + +- Public docs index: drop relative link from `docs/README.md` to denied `docs/evidence/config-tab-write-verification.md` so `pnpm check:public-deny-links` passes on tag CI (v4.8.5/v4.8.6 tags retained) + +## [4.8.6] - 2026-08-01 + +### Fixed + +- Biome import order in `session-start.test.ts` so tag CI lint passes after the 4.8.5 typecheck unblock (v4.8.5 tag retained; do not force-move) + +## [4.8.5] - 2026-08-01 + +### Added + +- Landing page redesign & consumer copy rewrite (plan `agentkit-landing-redesign-copy`): STK design system identity, Mission Control mockup tokens, consumer feature copy, and deployment config for `agent.startupkit.com.br` (`.cursor/context/landing-agentkit/`, `docs/agentkit-landing.md`) + +- Public Agent Kit landing page note (`docs/agentkit-landing.md`) pointing at live `https://startupkit.com.br/agentkit` (WP page id 3001); design inventory and HTML source under `.cursor/context/landing-agentkit/` (plan `agentkit-landing-startupkit`) +- Crew / Team Member framework (plan `crew-monitor-profession-masking`): ADRs for Team Member composition (`2026-08-01_crew-team-member-framework`), core vs specialist gap inventory (`2026-08-01_crew-core-specialist-gap-inventory`), and Crew-tab composition contract (`2026-08-01_crew-tab-composition-contract`); glossary ADR amended with default software lexicon display-mask contract (kind ids unchanged); Crew Monitor chip glosses and actor fallbacks use profession masks (Tech Lead / Scrum Master / Full-Stack Developer / Product Owner / DevOps Engineer; Squad / Engineering Manager / Platform Engineer); `crewTeam` config shape documented as contract-only in `docs/consumer-configuration.md` (not wired; heavy UI follow-up) +- Cursor update awareness (plan `cursor-update-awareness-auto-dogfood`): ADR for changelog-primary detection (`2026-08-01_cursor-update-detection-source`); CLI `agent-kit cursor-awareness --check` (opt-in `cursorUpdateCheck.*`, never apply / never Field Reports); L0 slash `/cursor-update-awareness` routes confirmed gaps via Ask → `/backlog-add` or `/dogfood`; sessionStart nudge when enabled; docs `docs/cursor-update-awareness.md` +- Consumer configuration inventory (`docs/consumer-configuration.md`): every consumer-configurable knob in one doc (session config keys, dashboard skin, install-time choices, repo profile, CLI flags and env vars) with where-defined / read-by evidence, a writable-via-tab column, and a copy-paste snippet per knob; pointer added from getting-started (plan `consumer-config-inventory-copy-crud`) +- Mission Control Config tab copy-CRUD fallback: per-fieldset Copy config.json snippet buttons (Session, Update check, Audits, Agent Personas) that build the fragment from current form values via a shared `collectMissionConfigPayload` and copy to clipboard only (no new write surface); dead-control hints (backend is claude-only; `updateApply.auto` never writable); actions bar names the manual paste path for server-down / non-loopback / read-only deploys +- Mission Control responsive grid (plan `mc-grid-responsive-ui-design-tokens`, Phase 1): mid-width 2-column overview grid (701-1023px) with row-major IA collapse, very-thin sidebar mode (max 339px) with tighter ladder values, and a fullscreen zen toggle (header enter button, floating exit, Escape restore); grid extends the locked Current mission → Flight Log → Checklist → Crew monitor IA without reopening it (ADR `2026-07-29_mc-main-tabs-order`) +- Crew Monitor row wording contract (plan `crew-monitor-wording-readability`): natural-voice verbs (`running` / `awaiting` / `done` / `parked` / `shipped`) replace robotic `· tick ·` / `· step ·` / `· handoff ·` separators; todo id / gate / progress count leads the label and the plan filename moves last; actor fallback is a short human `crew` label instead of the full plan filename; `.feed-label` renders structured spans so the low-signal plan filename ellipsises first, with the full label on a `title` tooltip (ADR `2026-07-27_crew-monitor-vs-plan-monitor-glossary` amend, `2026-07-27_mc-flight-log-panel` decision 17 amend) +- Mission Control icon design-system sweep (Phase 2): 16px / stroke-1.5 / currentColor / round caps-joins contract enforced across `spaceIconSvg` at all render sizes; four sub-stroke glyphs (radar, gear, rocket window, chip) redrawn above the legibility floor, `more-sections` dots respaced to positive clearance, header home icon door widened; legibility floor documented and pinned by a new UX test suite +- Mission Control card and empty-state unification (Phase 3): card surfaces on the overview / health / config families consolidated onto the `--mc-card-padding` / `--mc-radius-*` / `--mc-space-*` density ladder (new documented `--mc-card-padding-dense` token); denser list rows (terminals/memory) and pill badges may keep tighter radii off the 4/8/12 ladder; healthcenter severity chrome unified into one `{tone, label, token}` mapping with presence-pulse halo and fallback-hex hygiene (presentation only, check semantics unchanged); empty states scale with card density across every viewport mode; both skins verified skin-neutral +- Mission Control "busy outside the plan" live state (Phase 4): text-only busy chip on Current mission and Flight Log headers when terminals show fresh run-loop activity while the mission is not executing; derivation shares the Crew feed run-loop evidence with a 10-minute freshness window (`BUSY_OUTSIDE_PLAN_FRESH_MS`), rides the existing advice-family blue tokens on both skins, and leaves Flight Log kinds, Gaps voice, and locked labels untouched +- Mission Control UX overhaul (plan `mc-flight-log-dynamic-action-command`): dot semantics table (colored dots signal good / important / attention / neutral-idle, always paired with a label or icon) applied across all tabs, URL-hash deep-linkable tabs, and a uniform copy-only CTA pattern +- Flight Log one-command contract: every entry kind (Gaps NOW/Earlier, operator Warnings, quiet open-triage) renders one dynamically-labeled action button (`Copy fix prompt` / `Copy recovery prompt` / `Copy follow-up prompt` / `Copy triage command`); Gaps/Warnings compose prompt + path, quiet open-triage copies `/plan-review-triage ` only (ADR `2026-07-27_mc-flight-log-panel` decisions 11/13) +- Mission Control tab redesigns: Config grid form with Save pinned on top; Memory live recent-errors panel with green/red icon panels and error-o-meter KPIs from `.cursor/memory/errors/`; Git promotion flow lanes + readable commit graph + staging-hygiene hints; Health vitals diagnosis dashboard with per-problem Copy fix prompt CTAs; Commands / Skills / Agents card grids with copy-only CRUD CTAs and registry-driven lock badges; Crew Monitor timestamped feed with agent initials + action; Plans v2 actionable rows with live progress bar from frontmatter to-do counts; Processes live narrated list via deterministic `describeProcess` heuristics (no LLM; accepted design for local dashboard) +- Design evidence: nine catalogued UX prints under `.cursor/context/mc-ux-prints/` with per-print analysis and per-tab issue inventory +- `/dogfood` slash command ships as an L0 artifact (`packages/cli/src/lifecycle/l0.ts` + Port B install table), closing the packaging gap that left consumers without the command file (EXT-201) +- `.cursor/context/config.example.json` documents the advisory `dogfood.factoryRoot` key referenced by the `/dogfood` cross-repo bridge (EXT-208) +- `/dogfood` slash command for filing private dogfood notes into factory `dogfood/` or consumer `.cursor/dogfood/`, with optional public-issue HITL path (never auto-creates issues or Field Reports) +- Factory self-consumer local apply loop: `agent-kit update --seed-overlay` seeds the managed-hash ledger on first update; docs in `docs/CONTRIBUTING.md`, `docs/bootstrap.md`, and `docs/getting-started.md` +- ADRs for dogfood factory/consumer lanes, ingest contract, and factory-as-pseudo-consumer local apply loop + +### Fixed + +- Landing G/H closeout (`close-queue-end-t-r-landing-residuals`): annotate curated PNG byte-identical copies in INVENTORY; reconcile COPY.md CTA hierarchy to shipped GitHub-primary buttons; R15 Closed-by on T/R + landing monitors +- Landing A–E (`close-queue-end-t-r-landing-residuals`): publish page 3001 as Custom HTML (wpautop brs cleared); WCAG AA contrast tokens (`--blue-cta` / `--blue-pill` / secondary muted); `:focus-visible` on `.ak-btn`; demote landing hero to `h2`; document page excerpt vs theme meta description +- U4 terminal snapshot (`close-queue-end-t-r-landing-residuals`): trim partial first body line on windowed over-cap path; document 4KB head-meta contract (U5 note) +- U2/U3 test hygiene (`close-queue-end-t-r-landing-residuals`): ENOENT fallback spawn test emits `close` after `error` so the settled guard is exercised; remove tautological actor-mask length pins from `plugin-ux-validation.test.ts` +- U1 / T6 honesty (`close-queue-end-t-r-landing-residuals`): live `agent-kit cursor-awareness --check --json` recorded under `docs/evidence/runtime/cursor-awareness-u1-2026-08-01/`; append-only T6 Closed-by correction on `plan-monitor-close-two-monitor-still-open-residuals.md`; R14 stage of mid-batch T/R + landing plan monitors with `_index.md` +- Residuals T1–T8 + R1/R2/R4/R5/R7 (`close-queue-end-two-monitor-t-r-residuals`): Positioning inventory counts 25/89; cursor-awareness stamp-guard reachability test + one-shot advise/stamp docs; sessionStart changelog-ahead / single-fallback hook tests; terminal snapshot head+tail window via `TERMINAL_HEAD_META_BYTES`; Crew actor-mask noshrink acceptance + Squad outside core slots; composition ADR `registry/schemas/` deferral; R14 monitor+_index same-commit staging; `update.test.ts` timeout 15s under parallel load +- Residuals A–E + R1–R8 (`close-two-monitor-still-open-residuals`): Biome format on `plugin-ux-validation.test.ts`; terminal head-meta + tail-body (`dashboard/lib/terminal-snapshot.mjs`) so over-cap files keep pid/cwd/exit; restore `updateApply.auto` silent L0 overwrite risk copy; dogfood monitor Closed-by count 660→682; glossary ADR plan-segment wording; Cursor changelog extract anchors to release labels + plausibility bound (stops `49.511` stamp poison); sessionStart nudge gated on `changelog-ahead`; spawn timeout above fetch + double-fallback guard; capability-inventory 27/18 counts; conveyor reverse pointers; staging R14 reinforce; network closeout expectation doc +- Knowledge ledger CHANGELOG object count matches `summary.totalObjectCount` (682) in `docs/evidence/knowledge-classification.json` (EXT-210; refreshed after queue-end monitor land) +- `DOGFOOD_INBOX_HINT` resolution documented: hint stays unconditional because `/dogfood` ships as L0 (EXT-201); no lane-conditional guard required (EXT-212) +- Crew Monitor tooltips use pre-truncation `labelFull` (plan filename restored on `handoff` / `agent_step` titles); plan segment marked from `refs.plan`; feed separators include spaces for readable row copy; quiet open-triage copy drops the duplicated path line; Activity tab stays on plain `.activity-label` (residuals plan Phase 1) +- Mission Control grid residuals: fullscreen exit clears top-tabs; layered Escape dismisses menus before exiting zen; header transport consumes `healthSeverityChrome` (degraded stays orange); terminal lastOutput tail-slices capped files; very-thin sidebar lets Crew feed actor/verb ellipsis; legibility-floor pins raised (residuals plan Phase 2) +- Consumer configuration inventory adds an `implemented?` column; Config tab gains copy-only snippets for never-writable `updateApply.auto` / `dogfood.factoryRoot` (allowlist unchanged); clipboard-failure toast truncates long snippets; durable write-path matrix at `docs/evidence/config-tab-write-verification.md` (residuals plan Phase 3) +- Knowledge classification fixture lane: generate and check both use `--handoff-fixture`; artifact stamps fixture path; `update.test.ts` fixture imports `KIT_VERSION`; npm publish checklist requires `sync-cli-dashboard.mjs` before Path C promote; five queue-end monitors carry `## Closed by residuals plan` (residuals plan Phase 4) +- Risk-hotspot scorer is a pure function of committed ledgers: churn derives from `delivery-reconciliation.json` rows only (no live `git diff` on frozen SHAs), so `docs/evidence/codebase-risk-hotspots.json` reproduces in shallow CI clones (EXT-203) +- Knowledge classification corpus is git-index (tracked) files only; gitignored `.cursor/plans/**` no longer leaks into `docs/evidence/knowledge-classification.json`; HANDOFF-listed gitignored plans are reported as `missingPlans`; the `--check` staleness comparison normalizes commit provenance so the artifact is no longer stale-by-construction after any commit (EXT-203) +- `agent-kit update` preserves `installedAt` when no version change occurs and `update.test.ts` asserts the preserved value (ADR factory-pseudo-consumer decision 4, EXT-205) +- CHANGELOG hotspot counts match the committed artifact tally (90 pending:audit-risk-hotspots, deferred:census 1820, 91 reviewed) (EXT-204) +- `dogfood-command-pseudo-consumer.plan.md` marks `phase3-fix-personalization-preserve` completed, matching the shipped personalization preserve fix (EXT-206) +- `agent-kit update` preserves `manifest.personalization` and `overrides` instead of dropping them during no-op L0 syncs +- `sessionStart` dogfood inbox hint now recognizes both factory `dogfood/` and consumer `.cursor/dogfood/` unprocessed files and mentions `/dogfood` +- Public storefront: add sync-allowed `docs/five-layer-claim-matrix.md` and `pnpm check:public-deny-links` to prevent README→denied-path regressions +- Capability inventory: positioning table row count 87→89; CHANGELOG no longer claims every cell is verbatim +- Authority graph: registry publicationRoute is public-PR Phase B SoT, not private sync-public (AUDIT-004) +- Public sync: replace remaining `forEach` with `for...of` so `biome check scripts/sync-public.mjs` is clean (AUDIT-006) +- Knowledge classification focused tests: assert current HANDOFF active/parked counts and archive storefront path (AUDIT-003) +- Evidence ledgers: `batchId` is the full `contentHash` (no scheme-prefix truncation); disposition rows de-duped; hotspot/findings coverage counts sum to unique batches +- Evidence scripts: `evidence:codebase-findings:check` and `evidence:risk-hotspots:check` no longer regenerate ledgers (prevents wiping consolidated hotspot reviews; AUDIT-001) +- Install/docs Path C honesty: consumer guidance names the published floor (`4.8.2` onward) instead of relative "until that publish" hedges; Port B registry fetch prefers Port A and requires Ask before alternate URLs (RC-001, INSTALL_MD_MISSING_VALIDATION) +- Drift/capability inventories: replace stale ahead-count and closed surface-census claims with lane-qualified freshness metadata (RC-003, RC-006) +- Public sync: exclude `docs/evidence/**` from the allowlist and hard-prohibit it so generated ledgers no longer trip the denylist (closes SYNC_DENYLIST_EVIDENCE) +- Public sync: reject path traversal / escaping symlinks, allowlist GitHub remotes only, and redact tokens in sync errors (closes SYNC_PUBLIC_* / PUBLIC_SYNC_MANIFEST_* hotspot findings) +- CLI: `agent-kit --version` reports package version via citty `meta.version` (closes CLI_VERSION_FLAG) +- Mission Control: soft wall-clock budget skips optional collectors so `dashboard-data.mjs` fails soft under load (closes MC_DASHBOARD_DATA_SLOW) +- Git pre-push: refuse unsafe remote ref names and warn that `--no-verify` skips tag immutability (closes GIT_HOOKS_*) +- Authority graph: CLI dashboard copy is a generated output (not a writable mirror); CI runs `evidence:authority-graph:check` and refuses a tracked `packages/cli/dashboard/` +- npm storefront README: `packages/cli/README.md` ships in the packed tarball; `scripts/verify-cli-dashboard-pack.mjs` asserts non-empty `package/README.md` (closes PLUGIN_README_ABSENT for the CLI package) +- Queue consolidate helper: `--rewrite-queue` validates/normalizes `--outcomes` (including multiline) before any HANDOFF write and applies Plan/Mode/queue/cursor/status/outcomes as one atomic temp+replace rewrite (closes QUEUE_REWRITE_NONATOMIC) +- Audit launcher sessions: names are `agent-kit-audit--` with an 8-hex workspace token; cap/warn/reap only count or dispose strict workspace-owned sessions (closes AUDIT_SESSION_UNSCOPED); macOS Terminal spawn writes a temp runner instead of embedding shell_cmd in AppleScript + +### Added + +- Five-layer README claim matrix: `docs/evidence/five-layer-claim-matrix.md` classifies prompt/HITL, context/memory, safeguards, iterative review, and workflow coordination as shipped core, optional pack, planned, or unsupported with lane-qualified evidence +- Independent final audit matrix: `docs/evidence/independent-final-audit.json` plus operator `docs/evidence/release-gate-checklist.md` (BIGFIX Phase 10) +- Cross-repo parity matrix: `docs/evidence/cross-repo-parity.json` records private/public/npm/release/registry/Marketplace lane verdicts and clean-room evidence (BIGFIX Phase 9) +- Guidance claim matrix and stale-claim gate: `docs/evidence/guidance-claim-matrix.md` plus `scripts/check-guidance-stale-claims.mjs` (blocks revived Path C / drift hedges) +- Memory index gate: `scripts/validate-memory-index.mjs` requires every `decisions/` and `errors/` file to be linked from `_index.md`; supersession Status applied for BIGFIX proposal links (`docs/evidence/memory-adr-reconciliation.md`) +- Plan review audit `plan-review-repo-wide-evidence-functionality-bigfix`: Phase 5 consolidation verified against the committed artifact (90 pending:audit-risk-hotspots, deferred:census 1820, zero-unassigned partition, ownership map intact) +- Finding-to-remediation ownership map: all 64 ledger findings (6 inherited + 58 hotspot) carry `ownerRemediation` Phase 6 owners (`fix-confirmed-orchestration-defects`, `fix-package-storefront`, `remediate-code-findings`, docs rebuilds, or `none-required`); `CLI_VERSION_FLAG` restored into inheritedFindings from runtime evidence +- Hotspot audit evidence (tick 5 / complete): 90 risk hotspots pending:audit-risk-hotspots; coverage 91 reviewed batches (58 newFindings preserved); deferred:census 1820 +- Hotspot audit evidence (tick 4): findings ledger to 58 newFindings / 81 reviewed batches; hotspot set 80 reviewed + 10 pending (deferred:census 1820) +- Hotspot audit evidence (tick 3): expands findings ledger to 46 newFindings / 56 reviewed batches; hotspot set now 55 reviewed + 35 pending (deferred:census 1820); dispositions reconciled by contentHash +- Hotspot audit evidence (tick 2): expands `docs/evidence/codebase-findings.json` with nine additional findings (AppleScript/tmux session issues in plan-external-review, git-hooks bypass/injection, sync-public path traversal and command exec, manifest inclusion bypass); coverage 31 reviewed batches; hotspot dispositions reconciled by contentHash (60 pending, deferred:census 1820) +- Hotspot audit evidence (tick 1): `docs/evidence/codebase-findings.json` adds seven findings from the top risk batches (high: plan-external-review background launch sanitization; medium: audit session lifecycle, install.md registry validation, shell-guard `ALLOW_MAIN_PUSH` bypass breadth; plus positives for resolve.ts and the risk scorer); `docs/evidence/codebase-risk-hotspots.json` marks five hotspot batches reviewed (85 remain pending) +- Deterministic codebase risk-hotspot scorer: `scripts/score-codebase-risk-surface.mjs` ranks unique content batches from authority criticality, missing tests, publication route, inherited findings, security path globs, and delivery churn; emits `docs/evidence/codebase-risk-hotspots.json` (top-15 per domain + global top; non-hotspots `deferred:census`); `pnpm evidence:risk-hotspots` / `:check`; ADR `2026-07-30_bigfix-phase5-risk-hotspots-not-census` +- ADR `2026-07-30_evidence-policy-lane-qualified-hierarchy`: lane-qualified evidence classes, conflict hierarchies, anti-circularity and supersession rules validated against the three BIGFIX ledger fixtures +- Authority graph generator: `docs/evidence/authority-graph.json` maps 15 artifact families from authority through generators, mirrors, consumers, and publication routes; validates zero cycles, zero undeclared multiple authorities; generator `scripts/generate-authority-graph.mjs` with `pnpm evidence:authority-graph` and focused tests +- Runtime evidence for Phase 3 CLI/install packaging audit: `docs/evidence/runtime/audit-cli-install-packaging-2026-07-30/` (matrix, command logs, three code-confirmed findings: broken `--version`, public-sync denylist hits in evidence ledgers, missing plugin README) +- Runtime evidence for Phase 3 orchestration audit: `docs/evidence/runtime/audit-orchestration-runtime-2026-07-30/` (behavior matrix, failure injection, 59 + 233 focused tests, and findings for non-atomic multiline queue rewrite, unscoped audit sessions, and slow Mission Control data generation) +- Delivery reconciliation artifact: `docs/evidence/delivery-reconciliation.json` classifies eight cross-lane states, eight contradicted claims, and eight remediation items against frozen file, history, artifact, authority, and runtime evidence +- Deterministic knowledge classification ledger: `docs/evidence/knowledge-classification.json` covers 682 plans, memory, ADR, monitor, context, project-context, and index-row objects with zero unclassified; generate and check share the `--handoff-fixture` lane and stamp the real handoff input path (EXT-211); the census corpus is git-index (tracked) files only, so the artifact is reproducible in a clean clone; separates epistemic labels from HANDOFF active/backlog/parked state and records reproducible working-tree freshness identities +- Audit launcher post-spawn progress gate: after an autonomous background spawn, `plan-external-review.sh` samples PTY scrollback (tmux `capture-pane`, screen `hardcopy` with an explicit window target) for a grace window (`AGENT_KIT_AUDIT_PROGRESS_TIMEOUT`, default 60s, `0` disables) before entering the monitor wait; a silent PTY is reported as a failed launch, disposes only the session it spawned, prints the paste fallback, and soft-fails instead of burning the full `--wait-timeout`; channels without a scrollback API degrade to advisory +- Audit session cap and dispose policy: the launcher counts detached `agent-kit-audit-*` sessions before spawning, warns at `AGENT_KIT_AUDIT_SESSION_WARN` (default 5) and refuses to spawn at `AGENT_KIT_AUDIT_SESSION_CAP` (default 20); reaping is opt-in (`--reap-audit-sessions` / `AGENT_KIT_AUDIT_REAP`), covers detached kit-owned sessions past `AGENT_KIT_AUDIT_REAP_MIN_AGE` (default 3600s) only, never touches attached sessions, and previews under `--dry-run` +- ADR `2026-07-30_audits-pty-progress-gate-zombie-policy`: PTY progress gate, zombie session lifecycle, and exit 3 as timeout-only; follow-on to wait-freshness and headless-terminal honesty +- `docs/capability-inventory.md`: per-capability catalog of every shipped surface (25 slash commands, 25 rules, 13 named subagents, 9 skills, 5 Cursor-native hooks, 3 Git hooks, 17 CLI commands plus 5 subsystems, 12 Mission Control sections, 7 registry packs, 3 personas, root and kit scripts, templates), with counts verified against the filesystem; inventory only, no positioning prose +- README: demo YouTube link at the top (after H1) for the public storefront and private factory README +- Docs-contract test pinning staging lint-evidence clause across git-staging / run-plan / gitupdate; ADR for dashboard-CSS `none applicable` + plugin-ux-validation coverage +- `agent-kit doctor` / hooks-health: soft advisory when versioned `git-hooks/*` differs from or is missing under `.git/hooks/*` (install remains operator `cp`; see `git-hooks/README.md`); does not flip hooks status alone +- Consumer L0 overlay: managed-content hash ledger (`.cursor/agent-kit.managed-hashes.json`) preserves customized agents/skills/commands on update while unedited kit files still refresh; ADR `2026-07-29_consumer-l0-overlay-agents-optional` +- Shell-guard R3: documentation and `agent-kit doctor` warning when `ALLOW_MAIN_PUSH=1` is session-exported (disables main-push protection for all agent Shell commands until unset) + +### Changed + +- README / CLI storefront: five-layer production-agent positioning with explicit non-claims (no autonomous self-improvement, no general graph runtime, no hosted control plane); Mission Control called out as a local workspace cockpit; `packages/cli/README.md` clarifies HITL operating-layer install scope +- Audits exit-code honesty in L0 and docs: `/run-plan`, `/run-plan-all`, `/plan-external-review`, and `docs/external-plan-review.md` state that exit `3` is timeout-only, that a monitor appearing later (or from a different arm or queue position) never upgrades it to success, and that exit `4` now also covers a silent-PTY early abort and a session-cap refusal +- `docs/capability-inventory.md`: positioning-surface table recording every identity-bearing string with its real line number, current text (quoted literally where short; elided or paraphrased for long blocks), and publication route (`allowlist-synced`, `public-repo-PR-only`, or `private`), each route citing the deciding `scripts/public-sync.manifest` rule; confirms `_legacy/**` is allowlist-synced, so its stale descriptions ship today, while `registry/**` is excluded and routes through a public-repo PR +- `docs/capability-inventory.md`: themed delta section narrating what shipped since the 4.4.0 anchor across 11 themes (Mission Control, multi-plan queue orchestration, autonomous external review, backlog CRUD, agent personas, quota hard-stop contract, `/hotfix`, consumer autoupdate check, Path C packaging, repository readiness, consumer overlay protection) +- CHANGELOG `[4.8.0]`: re-file entries that were Added in substance (Flight Log panel, opt-in LAN broadcast, audit launcher wait/freshness flags, related ADRs) out of `### Fixed` into `### Added`; entries moved verbatim +- Staging-ready lint gate: `/run-plan` documents dashboard-CSS `none applicable` + plugin-ux-validation clause (parity with `/git-staging`); `autogit/gitupdate.md` no longer lists `dashboard/` as Biome/ESLint scope (`dashboard.html` is outside Biome) +- Handoff template + plan-handoff rule: Flight Log Gaps-voice writer guidance Live → NOW (matches shipped UI); quiet Flight Log placeholder NOW + `aria-label="No Gaps now"` pinned in plugin-ux-validation; ADR `2026-07-27_mc-flight-log-panel` Earlier `:hover` muted / `:focus-visible` full kind token +- Docs: `agent-kit-manifest` lists three `.cursor/` kit files (adds `agent-kit.managed-hashes.json` with commit recommendation); `layers-spec` scopes the golden rule to agents/skills/commands overlay trees (rules still clobber); `migrate-consumer` notes committing the ledger +- Mission Control Healthcenter: agents check `autofix: null` (No Autofix mapped) because `ok` is constant-true; failDetail marked intentionally unreachable +- Dogfood / local `agent-kit` (PATH link to `packages/cli/dist`): after the guard-shell `ALLOW_MAIN_PUSH` fix, run `pnpm --filter @dadado/agent-kit-cli build` so the resolved binary honors the env gate; `packages/cli/dist` is gitignored, so the rebuilt bin ships to other machines only on the next npm publish / promote +- Mission Control Doctor Agents check: L0-optional (empty `.cursor/agents/` is not a hard fail; check id `agents` retained) +- Mission Control Flight Log: current Gaps card label **Live** → **NOW** (Earlier / All clear unchanged; header SSE `#statusLabel` Live unchanged); ADR amend `2026-07-27_mc-flight-log-panel.md` +- Mission Control Flight Log: Earlier `:hover` stays muted `color-mix`; `:focus-visible` uses full kind token for keyboard contrast; base ring uses `--border-active` +- Mission Control Flight Log UX pins: NOW hover `border-color` per kind, Earlier hover `color-mix`, whitespace-tolerant `--accent` anti-regression +- Mission Control Flight Log: behavioural `new Function` tests for quiet-gate helpers (`isFlightLogQuiet` / `resolveFlightLogCurrent` and siblings) +- Close triage batch residuals: Closed-by appends on flight-log hover, close-audit, and git-prod-promote monitors (R14/R15); plan `close-triage-batch-residuals` exhausted +- Docs: layers-spec / agent-kit-manifest / migrate-consumer / bootstrap describe overlay protect without blanket `agents/**` globs + +### Fixed + +- Consumer overlay tests: pack-installed agent (clean-code → `cleancode-refactor`) customize then reinstall asserts `preserved-customized`; L0 user-agent check restated as non-membership evidence +- Consumer overlay: ledger-absent first `update` no longer clobbers customized kit-owned agents/skills/commands; compare local content to known shipped hashes (Option A) so unedited kit files still refresh and seed the managed-hash ledger +- Docs-contract staging lint-evidence ADR pin: `existsSync` skip when `.cursor/memory/**` is absent so public mirror CI does not ENOENT after `v*` sync +- Guard shell tests: `afterEach` uses `delete process.env.ALLOW_MAIN_PUSH` (not `= undefined`); explicit deny asserts for unset and `ALLOW_MAIN_PUSH=0`; thin-adapter ADR amend documents authorized `/git-prod` env exception +- Guard shell: narrow `ALLOW_MAIN_PUSH=1` to documented `/git-prod` forms (`git push main` / `HEAD:main`); still deny `--force` / `-f` / `--force-with-lease` / `--no-verify` / `prod` / `master` / `--all` / `--tags` (and force refspecs) even when the env is set; bare push without env stays denied +- Guard shell: honor `ALLOW_MAIN_PUSH=1` (inline before env strip, or process env) so authorized `/git-prod` `git push origin main` is allowed; bare pushes stay denied (parity with `git-hooks/pre-push`) +- Guard shell: strip all quote characters in push refspec normalization so prefixed/embedded forms (`+'main'`, `ma'in'`, …) deny like surrounding quotes; non-regression allows for staging/mainline hold +- Guard shell: strip backslashes in push refspec normalization so shell-collapse forms (`\main`, `ma\in`, `mai\n`) deny like quotes; staging/mainline-like branches still allow +- `git-hooks/pre-push`: block force-update/delete of `refs/tags/v*` unless `ALLOW_TAG_FORCE=1` (new tag creates still allowed; aligns with gitupdate §9.5) +- Close queue-end five-monitor residuals batch: queue-end triage Write residuals closed remaining items from five mid-batch monitors (PRs #519–#529) without emptying Still open tables per R15 hygiene; ready for promote +- Close queue-end BIGFIX five-layer residuals: nine to-dos shipped as five direct commits on `staging` rather than through PR-per-phase; PR-per-phase remains the intended delivery contract and this deviation is documented for the parent plan + ## [4.8.4] - 2026-07-29 ### Fixed @@ -115,23 +271,25 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and - Mission Control Crew Monitor: denser `agent_step` feed for active-plan completed/running to-dos (kinds `run_plan` / `handoff` / `delivery` / `agent_step`; display cap 20); glossary amend (`2026-07-27_crew-monitor-vs-plan-monitor-glossary.md`) - README Cockpit + getting-started / external-plan-review: Flight Log Live/Earlier + Warnings; Crew Monitor denser step feed -### Fixed - -- Mission Control: delete orphaned Field Report attention-stack render helpers + CSS from `dashboard.html` (Flight Log Gaps-only); retarget pinning tests; rewrite `/field-report-resolve` and getting-started so they no longer describe Resolve all / Review all MC UI +### Added - Mission Control **Flight Log** (ex-Field Report card): HANDOFF Gaps only as current (large) + past (smaller) clickable cards; gitignored `.cursor/context/flight-log.json` history ledger (cap 15); ADR `2026-07-27_mc-flight-log-panel.md` - Opt-in Mission Control LAN broadcast: `/dashboard-broadcast`, `agent-kit dashboard-broadcast`, `npm run dashboard:broadcast`; non-loopback bind requires `MISSION_CONTROL_TOKEN`; static/snapshot/SSE gated; config writes stay loopback-only (`2026-07-27_mission-control-opt-in-lan-broadcast.md`) - ADR: Mission Control opt-in personal LAN broadcast (`/dashboard-broadcast` + token gate); narrowly supersedes personal-local-only for trusted LAN only; `/dashboard` stays loopback-first (`2026-07-27_mission-control-opt-in-lan-broadcast.md`) - ADR: `/plan-review-triage` batch HITL when multi-path outcomes are uniform (one Ask; durable heading on every target; sequential fallback when mixed); Field Report **Review all** paste target unchanged (`2026-07-27_plan-review-triage-batch-uniform-hitl.md`) -- Dogfood memory: wait-monitor false-ready on pre-existing `plan-monitor-*.md` (existence-only poll); freshness gate + mandatory chat `--wait-monitor` (`errors/2026-07-27_audits-wait-monitor-stale-preexisting.md`; ADR `2026-07-27_audits-wait-freshness-enforce.md`) - Plan audits launcher: `--wait-monitor` freshness (`mtime >= arm-epoch` or ``); dry-run prints arm-epoch and stale/missing; exit `3` on stale timeout - ADR: mandatory chat `--wait-monitor` with freshness gate and same-session triage Ask; ban Final HANDOFF "after monitor lands" as happy-path continue (`2026-07-27_audits-wait-freshness-enforce.md`) -- Dogfood memory: cadence WARNING already-clear claim-check is a no-op close (empty ledger + dismissed window id = `subject_resolved`; cancel triage/product-fix when owed set empty) (`decisions/2026-07-27_cadence-warning-already-clear-claim-check.md`) -- Dogfood memory: autonomous launch then manual `done` continuation dual-fence (`errors/2026-07-27_audits-autonomous-launch-manual-done-continuation.md`); docs Troubleshooting row for wait-then-triage - Plan audits launcher: optional `--wait-monitor` / `--wait-timeout` (default 900s) to poll for `plan-monitor-.md` after visible arm or standalone; exit `0` created/ok, `3` timeout, `4` soft-fail while waiting; dry-run prints wait path/timeout (ADR `2026-07-27_audits-post-spawn-monitor-watch-continue.md`) - ADR: post-spawn monitor watch then continue to `/plan-review-triage` Ask after visible autonomous audit arm (honesty until monitor file exists; triage and `/git-prod` HITL intact) (`2026-07-27_audits-post-spawn-monitor-watch-continue.md`) - `externalPlanReview` config keys for autonomous audits: `mode` (`paste` | `autonomous`), `midBatchAudits`, `preflight` (`off` | `warn` | `block`); Mission Control Config allowlist + UI; example + docs + guards tests - L0 audits pre-flight on `/continue-plan`, `/run-plan`, `/run-plan-all`, `/hotfix` (`preflight` off/warn/block); exhaustion and `/run-plan-all` mid-batch arming prefer visible autonomous launch when `mode: autonomous` + +### Fixed + +- Mission Control: delete orphaned Field Report attention-stack render helpers + CSS from `dashboard.html` (Flight Log Gaps-only); retarget pinning tests; rewrite `/field-report-resolve` and getting-started so they no longer describe Resolve all / Review all MC UI +- Dogfood memory: wait-monitor false-ready on pre-existing `plan-monitor-*.md` (existence-only poll); freshness gate + mandatory chat `--wait-monitor` (`errors/2026-07-27_audits-wait-monitor-stale-preexisting.md`; ADR `2026-07-27_audits-wait-freshness-enforce.md`) +- Dogfood memory: cadence WARNING already-clear claim-check is a no-op close (empty ledger + dismissed window id = `subject_resolved`; cancel triage/product-fix when owed set empty) (`decisions/2026-07-27_cadence-warning-already-clear-claim-check.md`) +- Dogfood memory: autonomous launch then manual `done` continuation dual-fence (`errors/2026-07-27_audits-autonomous-launch-manual-done-continuation.md`); docs Troubleshooting row for wait-then-triage - Dogfood memory: paste dual-fence, invisible agent-shell `claude -p`, and bare `/plan-review-triage` footguns (`errors/2026-07-27_audits-*`); prefer-autonomous decision note - Audits ADR: autonomous plan review contract (visible auto-launch, mid-batch + queue-end audits, audits pre-flight on plan-run commands; paste-only demoted to fallback) (`2026-07-27_audits-autonomous-plan-review-contract.md`) - Plan audits launcher: visible auto-launch (`--autonomous` / config `mode: "autonomous"`) via macOS Terminal.app or Linux emulator; `--paste-only` fallback; `--dry-run`; mid-batch `--batch` arms; soft-fail if `claude` missing; headless CLI always passes `--print` @@ -378,12 +536,12 @@ Follows 4.5.1. Version 4.6.0 was withdrawn after release because it carried an u ### Added - `agent-kit dashboard` / `npm run dashboard` (`dashboard/start.mjs`): terminal counterpart to `/dashboard`; detach-starts Mission Control if needed, waits for HTTP 200, prints the URL, and opens the default browser -- Decision record: Mission Control actions are copy-only and name a paste destination ([mission-control-copy-only-paste-destinations](.cursor/memory/decisions/2026-07-25_mission-control-copy-only-paste-destinations.md)), superseding the 2026-07-24 protocol-open record +- Decision record: Mission Control actions are copy-only and name a paste destination (`.cursor/memory/decisions/2026-07-25_mission-control-copy-only-paste-destinations.md`), superseding the 2026-07-24 protocol-open record - Mission Control plugin UX validation suite (`packages/cli/src/dashboard/plugin-ux-validation.test.ts`): narrow/mid layout media queries, reduced-motion, keyboard accordion/focus preservation, empty states, copy-only CTAs that name their paste destination, SSE+polling fallback, Cockpit anchors plus the More sections menu with no horizontal tab track, Cockpit order (Current mission → Monitor → Field Report → Checklist), nav/heading label parity, the space icon set, lifecycle visual keys, and hardening regressions (XSS helpers, read-only serve, loopback CORS) - Mission Control Field Report renders `missionControl.attention` below Current mission and carries only what waits on a human reply: agent prompts awaiting an answer, external reviews awaiting triage, and the active handoff gate, each with a copy CTA and an empty state - Mission Control Field Report source, agent prompts awaiting a reply: a transcript surfaces when its last agent-question tool call has no user entry after it, read from the project transcript store (30-day window, 60 files, 1 MB per file, 8 items rendered, `subagents/` transcripts ignored), with a copy action for the past-chat picker (`packages/cli/src/dashboard/field-report-prompts.test.ts`) - Mission Control Field Report source, external reviews awaiting triage: a `.cursor/memory/plan-monitor-.md` report counts as triaged when it carries a triage heading or when a plan other than the reviewed plan names the report slug or the reviewed plan; untriaged reports copy the triage command with the report path (`packages/cli/src/dashboard/external-reports.test.ts`) -- Decision record: Mission Control Field Report source contract, covering both detection rules and the weaker signals rejected against real local data ([mission-control-field-report-source-contract](.cursor/memory/decisions/2026-07-25_mission-control-field-report-source-contract.md)) +- Decision record: Mission Control Field Report source contract, covering both detection rules and the weaker signals rejected against real local data (`.cursor/memory/decisions/2026-07-25_mission-control-field-report-source-contract.md`) - Shared inline SVG space-theme icon set with accessible names, used by the Cockpit section headings and the navigation, with no icon font, sprite fetch, or frontend dependency - Static asset regression test (`packages/cli/src/dashboard/static-assets.test.ts`): every root-absolute `src`/`href` in the panel markup must resolve to a real file under `dashboard/`, so an asset URL cannot 404 silently - Mission Control semantic snapshot model (`missionControl.now` / `activity` / `attention` / classified `plans`) in `dashboard/lib/semantic-model.mjs`, wired into `dashboard-data.mjs` (schema 1.2.0) with fixture tests @@ -396,7 +554,7 @@ Follows 4.5.1. Version 4.6.0 was withdrawn after release because it carried an u - Mission Control Processes section: Copy PID CTA per process row (no kill/restart) - Mission Control Git section: bounded dirty `files[]` from `git status --short` (paths only), status badges, and copy-to-clipboard staged/unstaged `git diff` commands per file - Error memory entry: public sync PR merge-blocked by ruleset and merge-commit method ([public-sync-pr-merge-blocked-ruleset](errors/2026-07-24_public-sync-pr-merge-blocked-ruleset.md)) -- Decision record: Mission Control local-only security posture ([mission-control-local-only-security](.cursor/memory/decisions/2026-07-24_mission-control-local-only-security.md)) +- Decision record: Mission Control local-only security posture (`.cursor/memory/decisions/2026-07-24_mission-control-local-only-security.md`) - Repository personalization profile (`.cursor/context/personalization.json`, `.cursor/project-context.md`, `AGENTS.md`) with matching manifest packs and protected paths in `.cursor/agent-kit.json` - `docs-repo` core skill and `cursor-skills-node` community skill - DevOps scaffolding templates: `templates/CODEOWNERS` and GitLab CI templates for content, Node plus Docker, and frontend plus Firebase repositories diff --git a/README.md b/README.md index a348927..21d7702 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Agent Kit +[Watch the demo on YouTube](https://www.youtube.com/watch?v=9mrAg6Mczfg) + **Turn your AI coding agent into one that runs the whole workflow: plan it, build it, ship it, and remember it across long projects.** Long AI coding sessions fall apart when the context window fills up. Agent Kit fixes this with a small operating layer that handles planning, handoff between chats, and structured git flow. The agent builds against a checkable plan and writes down where it stopped so any fresh chat picks up exactly where the last one left off. @@ -7,9 +9,10 @@ Long AI coding sessions fall apart when the context window fills up. Agent Kit f ## Why you'd want it - **No more lost context.** The agent keeps a short state file; new chat, one command, and it's caught up. -- **Work against real plans.** To-dos you can watch tick off, not vibes. +- **Work against real plans.** To-dos you can watch tick off, not vibes. Confirmations stay human-in-the-loop (Ask questions), not unchecked autonomy. - **Built-in DevOps discipline.** Staging-first git flow prevents history chaos. - **Production needs confirmation.** Agent can push to staging alone; promoting to `main` always asks first. +- **Operational learning, not model training.** Memory and optional external review keep findings durable across chats; they do not retrain the model. - **Clean history everywhere.** Commits and docs describe the software, not chat chatter. ## Features @@ -17,16 +20,30 @@ Long AI coding sessions fall apart when the context window fills up. Agent Kit f | Feature | What you get | |---------|----------------| | **Plans + HITL gates** | `/start-project` Broad Intake, then two gates (write plan, then first unit). Confirmations use Ask questions (clickable options; chat fallback when the tool is unavailable). | -| **Phase handoff** | `.cursor/HANDOFF.md` plus Context Guardian and native hooks (`sessionStart` / `preCompact`) so a fresh chat resumes without re-briefing. | -| **Manual or continuous run** | `/continue-plan` (one phase per chat) or `/run-plan` (runs to the end; picks worker orchestration or in-session loop; headless via `agent-kit run-plan`). | +| **Phase handoff** | `.cursor/HANDOFF.md` plus Context Guardian and native hooks (`sessionStart` / `preCompact`) so a fresh chat resumes without re-briefing. Local workspace state; not a hosted sync plane. | +| **Manual or continuous run** | `/continue-plan` (one phase per chat) or `/run-plan` (runs to the end; picks worker orchestration or in-session loop; headless via `agent-kit run-plan`). `/run-plan-all` queues multiple plans sequentially. Plan/queue orchestration, not a general graph runtime. | | **Staging → prod git** | `/git-staging` for automatic promote to `origin/staging`; `/git-prod` only after explicit confirmation. Direct commits to `main` are blocked. | | **Memory loop** | Resolved errors and tradeoff decisions in `.cursor/memory/` so the next chat can reuse them. | | **Repository readiness** | Install scans the repo, applies safe local fixes, and writes a readiness snapshot. `/agent-kit-onboard` resolves remaining decisions one at a time before `/start-project`. | | **Agent Personas** | Mode-aware chat/CLI chrome only: Autopilot (`/continue-plan`), Night Shift (`/run-plan`), Ghost Runner (CLI). Configure after readiness or set `agentPersona` in `.cursor/context/config.json`. Never changes commits, HANDOFF, memory, or product docs. | -| **Optional external plan review** | After a plan is exhausted, arm Claude Code for a gap monitor; triage with `/plan-review-triage`. Opt-in via config. | +| **Optional external plan review** | After a plan is exhausted, arm Claude Code for a gap monitor; triage with `/plan-review-triage`. Opt-in via config. Findings-only by default (no silent product auto-fix). | | **Skills + domain packs** | Registry skills and optional L1 packs (clean code, context tools, and more). Install/update via CLI; contribute upstream with `agent-kit contribute`. | | **Output hygiene** | Chat can be light; commits, docs, HANDOFF, and memory stay professional and inheritable. | +### Production-agent layers (L0) + +How the kit maps to a five-layer production-agent lens. Classifications and public evidence anchors: [five-layer claim matrix](docs/five-layer-claim-matrix.md). Documentation alone is not proof of behavior. + +| Layer | What ships in core | Explicit non-claim | +|-------|--------------------|--------------------| +| Prompt + HITL | Plan gates, Ask questions, `/git-prod` confirmation | Not full autonomy without review | +| Context + memory | HANDOFF, hooks, memory loop, personas (chrome only) | Not a hosted control plane or cloud HANDOFF sync | +| Safeguards | Staging-first git, shell/secrets hooks, output hygiene | Not a guarantee that every install is production-ready | +| Iterative review | Opt-in external monitor, triage, Field Report cadence | Not autonomous model self-improvement | +| Workflow coordination | `/run-plan`, `/run-plan-all`, headless CLI, local Mission Control | Not a general graph / DAG engine | + +Released consumer lane (npm / public GitHub) is version-qualified separately from private staging. Pin or check `@dadado/agent-kit-cli` when you need a reproducible floor. + Deep dives: [getting started](docs/getting-started.md), [personas contract](docs/personas-contract.md), [creating personas](docs/creating-personas.md), [external plan review](docs/external-plan-review.md), [domain packs](docs/domain-packs.md). ## Install @@ -73,9 +90,9 @@ Two ways to drive a plan: ### Dashboard -**Mission Control** is a local panel over the Agent Kit runtime state. It binds to loopback by default and serves only its own static files. Actions stay copy-only (clipboard + paste destination). The Config section is a narrow exception: it may merge allowlisted session prefs into `.cursor/context/config.json` via loopback `PUT`/`PATCH /api/config` (no git, process, or prod mutations). Opt-in LAN: `/dashboard-broadcast` (token-gated). Production-ship constraints: [Getting started - Mission Control production-ship constraints](docs/getting-started.md#mission-control-production-ship-constraints). +**Mission Control** is a local panel over the Agent Kit runtime state. It binds to loopback by default and serves only its own static files. It is a cockpit for one workspace, not a hosted multi-tenant control plane. Actions stay copy-only (clipboard + paste destination). The Config section is a narrow exception: it may merge allowlisted session prefs into `.cursor/context/config.json` via loopback `PUT`/`PATCH /api/config` (no git, process, or prod mutations). Opt-in LAN: `/dashboard-broadcast` (token-gated). Production-ship constraints: [Getting started - Mission Control production-ship constraints](docs/getting-started.md#mission-control-production-ship-constraints). -The panel source lives under `dashboard/` in this repository (and the public agent-kit tree). **L0 install does not copy `dashboard/` into your app.** After a CLI publish that includes Path C (ships `dashboard/**` inside `@dadado/agent-kit-cli`), `agent-kit dashboard` resolves the panel from the installed package and snapshots your workspace via `MISSION_CONTROL_REPO_ROOT`. Until that publish lands, use a kit checkout (`MISSION_CONTROL_KIT_ROOT` / `AGENT_KIT_HOME` / sibling `../agent-kit`) or pin/reinstall once the Unreleased packaging ships. Do not assume npm `@dadado/agent-kit-cli@4.8.0` already includes Path C. +The panel source lives under `dashboard/` in this repository (and the public agent-kit tree). **L0 install does not copy `dashboard/` into your app.** The published CLI ships `dashboard/**` inside `@dadado/agent-kit-cli` from 4.8.2 onward, so `agent-kit dashboard` resolves the panel from the installed package and snapshots your workspace via `MISSION_CONTROL_REPO_ROOT`. On 4.8.0 or an older pin the panel assets are absent: upgrade the CLI, or point it at a kit checkout (`MISSION_CONTROL_KIT_ROOT` / `AGENT_KIT_HOME` / sibling `../agent-kit`). ```bash # From a consumer workspace (snapshots this repo; UI from CLI package or kit host) @@ -97,18 +114,20 @@ npm run start:dashboard Then open the **printed** URL if the browser did not open (with `PORT` unset, each workspace gets a stable port in `3333–3588`; do not assume `:3333`). In Cursor chat, `/dashboard` does the same start-and-open flow via the IDE browser. For trusted LAN, use `/dashboard-broadcast` (never silent `HOST=0.0.0.0` without a token). -**If `agent-kit dashboard` says no `dashboard/start.mjs`:** (1) reinstall a CLI version that ships `dashboard/` (Path C; see CHANGELOG Unreleased / publish checklist), or (2) set `MISSION_CONTROL_KIT_ROOT` / `AGENT_KIT_HOME`, or (3) keep a sibling `../agent-kit` checkout. L0 alone never places the panel binary in your project tree. +**If `agent-kit dashboard` says no `dashboard/start.mjs`:** the installed CLI is older than 4.8.2. (1) Upgrade or pin `@dadado/agent-kit-cli@4.8.2` or newer, or (2) set `MISSION_CONTROL_KIT_ROOT` / `AGENT_KIT_HOME`, or (3) keep a sibling `../agent-kit` checkout. L0 alone never places the panel binary in your project tree. The Cockpit reads as one page in four sections, each reachable from the primary navigation: | Section | What it answers | |---------|------------------| | Current mission | The plan in flight: status, progress, friendly Mode labels, and previous/current/next todo | -| Flight Log | HANDOFF Gaps log (**Live** / **Earlier**, wipe on new flight; cap 15 within a flight) plus operator Warnings (Quota pause, Heads up); palette-by-type notification chrome (`ok` / `advice` / `prompt` / `residual` / `warning`); clipboard icon; clickable copy text/path; **All clear** when idle (no literal `none` as a yellow Live debit) | +| Flight Log | HANDOFF Gaps log (**NOW** / **Earlier**, wipe on new flight; cap 15 within a flight) plus operator Warnings (Quota pause, Heads up); palette-by-type notification chrome (`ok` / `advice` / `prompt` / `residual` / `warning`); clipboard icon; **one dynamically-labeled action button per entry** (composed prompt + document path; `Copy fix prompt` / `Copy recovery prompt` / `Copy follow-up prompt` / `Copy triage command`) with the toast naming the chat input as paste destination; **All clear** when idle (no literal `none` as a yellow NOW debit) | | Checklist | What remains: recent plan cards, parked and incomplete plans, and readiness notes | | Crew Monitor | Live agent/crew feed: ticks, handoffs, deliveries, and denser `agent_step` rows for active-plan to-dos (cap 20) | -Plans, Activity, Agents, Skills, Commands, Health, Git, Memory, Terminals, Processes, and Config live in the More sections menu next to those links, with their counts. **Health** is a Healthcenter for the same seven workspace checks (`plans`, `handoff`, `agents`, `commands`, `memory`, `git`, `config`): live severity, expand/detail per check, and Autofix/Fix controls that only copy a command or path and name where to paste it (chat, terminal, or file picker). Snapshot/serve errors are distinct from per-check fails. Health does not reorder main cockpit tabs and does not duplicate Checklist readiness. +Plans, Activity, Agents, Skills, Commands, Health, Git, Memory, Terminals, Processes, and Config live in the More sections menu next to those links, with their counts; tabs are deep-linkable via URL hash. Colored dots only signal state (good / important / attention) and are always paired with a label or icon; decorative dots are stripped. **Health** is a Healthcenter for the same seven workspace checks (`plans`, `handoff`, `agents`, `commands`, `memory`, `git`, `config`): vitals-style diagnosis cards, live severity, expand/detail per check, and per-problem Copy fix prompt CTAs plus Autofix/Fix controls that only copy a command or path and name where to paste it (chat, terminal, or file picker). Snapshot/serve errors are distinct from per-check fails. Health does not reorder main cockpit tabs and does not duplicate Checklist readiness. + +Section highlights: **Config** is a grid form with Save pinned in a top actions bar, per-fieldset copy-snippet buttons for when the write path is unavailable, and dead-control hints (backend is claude-only; `updateApply.auto` never writable). The full consumer knob inventory lives in [docs/consumer-configuration.md](docs/consumer-configuration.md). **Memory** pairs a live recent-errors panel (from `.cursor/memory/errors/`) with green/red icon panels and an error-o-meter KPI strip (counts, rates, top tags). **Git** shows promotion flow lanes (work → staging → main, ahead/behind vs both), a readable commit graph, and staging-hygiene hints. **Commands**, **Skills**, and **Agents** are card grids with copy-only CRUD CTAs and lock badges on kit-managed items. **Plans** rows are status-aware (resume/run/edit/archive prompts) with a live progress bar from frontmatter to-do counts. **Processes** lists live processes with a generated per-process description of what each one is doing. Every action copies text and names where to paste it: repo-relative paths go to the file picker, slash commands to the chat input, chat references to the past-chat picker, and shell commands, PIDs, and commit shas to the terminal. The panel cannot open a file or a chat, and no label claims it can. @@ -118,6 +137,7 @@ Full routine: `autogit/gitupdate.md` after install. | Guide | What's in it | |-------|--------------| +| [Five-layer claim matrix](docs/five-layer-claim-matrix.md) | Public five-layer positioning (core / optional / planned / unsupported) | | [Getting started](docs/getting-started.md) | Install, commands, day-to-day workflow | | [Repository readiness](docs/repository-readiness-onboarding.md) | Install discovery, `/agent-kit-onboard`, and deliverable boundary | | [Bootstrap](docs/bootstrap.md) | Exactly what lands in your project, and why there's no nested folder | diff --git a/autogit/gitupdate.md b/autogit/gitupdate.md index 3ef50a9..9cf624f 100644 --- a/autogit/gitupdate.md +++ b/autogit/gitupdate.md @@ -275,11 +275,11 @@ This section contains the detailed prompts that should be followed when commands - Make the requested changes (including CHANGELOG.md update if necessary). - Review with `git status -sb` to ensure only expected files were modified. - **Validation**: Confirm there's no attempt to modify `origin/main` directly. - - **Lint evidence (staging-ready):** when the diff touches formatted/linted paths (e.g. `*.ts` / `*.tsx` / `*.js` / `*.mjs` under `packages/`, `dashboard/`, or other Biome/ESLint scopes), **run** the focused linter on those files (e.g. `pnpm exec biome check `) **before** commit and **record the exact command + pass/fail output** in the tick / worker summary. Claiming `Staging ready: yes` or pasting the contract phrase without that recorded run is invalid. Pure markdown / docs-only with no applicable repo linter: record `Tests: none applicable` (or `Validation: none applicable`). Aligns with `/run-plan` Staging-ready lint gate (background: Biome-red merges fixed only after the fact). + - **Lint evidence (staging-ready):** when the diff touches formatted/linted paths (e.g. `*.ts` / `*.tsx` / `*.js` / `*.mjs` under `packages/` or other Biome/ESLint scopes), **run** the focused linter on those files (e.g. `pnpm exec biome check `) **before** commit and **record the exact command + pass/fail output** in the tick / worker summary. Claiming `Staging ready: yes` or pasting the contract phrase without that recorded run is invalid. **`dashboard/dashboard.html` is outside Biome** (`biome check dashboard/dashboard.html` processes nothing): for dashboard CSS/HTML-only diffs, record `Tests: none applicable (dashboard-CSS); covered by plugin-ux-validation` when the UX suite pins the change (ADR `decisions/2026-07-29_dashboard-css-lint-evidence-convention.md`); do not claim Biome covered the HTML. Pure markdown / docs-only with no applicable repo linter: record `Tests: none applicable` (or `Validation: none applicable`). Aligns with `/run-plan` Staging-ready lint gate (background: Biome-red merges fixed only after the fact). #### 7. **Stage and commit with semantic message** - Add relevant files with `git add` **by name**. If `git status` shows untracked or unrelated dirty `.cursor/memory/plan-monitor-*.md`, **warn** and do **not** broad-`git add` `.cursor/memory/` WIP into a product commit (ADR `decisions/2026-07-27_plan-monitor-consumer-awareness.md`). - - **Monitor closeout (R14):** when a tick intentionally stages a `plan-monitor-*.md` (and/or `_index.md` Audits row), add those paths **by name**. Prefer a separate docs/memory commit when the same PR also has large product diffs. Never sweep unrelated monitor WIP. **An `_index.md` Audits row and its target monitor file must land in the same commit** (no index link without the file). ADR: `decisions/2026-07-29_plan-monitor-staging-hygiene-r14-r15.md`. + - **Monitor closeout (R14):** when a tick intentionally stages a `plan-monitor-*.md` (and/or `_index.md` Audits row), add those paths **by name**. Prefer a separate docs/memory commit when the same PR also has large product diffs. Never sweep unrelated monitor WIP. **An `_index.md` Audits row and its target monitor file must land in the same commit** (no index link without the file). Product commits must not pick up unrelated untracked monitors (dogfood residual R7). ADR: `decisions/2026-07-29_plan-monitor-staging-hygiene-r14-r15.md`. - Create a commit following [Conventional Commits](https://www.conventionalcommits.org/): - `feat:` for new features - `fix:` for bug fixes @@ -382,7 +382,9 @@ This section contains the detailed prompts that should be followed when commands #### 9. **Publish main (PRODUCTION)** - **WARNING**: This is the critical step that updates production. - - Run `ALLOW_MAIN_PUSH=1 git push origin main` to send changes to production (the local `pre-push` hook blocks bare pushes to `main`; this env gate is the authorized `/git-prod` path — see `git-hooks/README.md`). + - Run `ALLOW_MAIN_PUSH=1 git push origin main` to send changes to production (the local `pre-push` hook and the agent Shell `guard shell` both block bare pushes to `main`; this env gate is the authorized `/git-prod` path — see `git-hooks/README.md`). + - Agent Shell: use the same inline form. CLI SoT (`agent-kit guard shell`) honors `ALLOW_MAIN_PUSH=1` before stripping env prefixes; bare `git push origin main` stays denied. + - **IMPORTANT**: Avoid setting `ALLOW_MAIN_PUSH=1` as a persistent session environment variable (e.g., `export ALLOW_MAIN_PUSH=1` in terminal or IDE). This disables main-push protection for all subsequent agent Shell commands until unset. Use the inline prefix form `ALLOW_MAIN_PUSH=1 git push origin main` for authorized single commands only. - If push fails (e.g., protected branch), inform user and provide alternative instructions. - **NEVER** force push (`--force` or `--force-with-lease`) without explicit user authorization. - Prefer `ALLOW_MAIN_PUSH=1` over `--no-verify` so other hooks still run. @@ -396,7 +398,7 @@ This section contains the detailed prompts that should be followed when commands - Do **not** `git push --force` (or delete-and-recreate in place) an existing `vX.Y.Z` that already pointed at another SHA. Consumers and mirrors may have resolved the old tip. - If tag CI fails after the first push: fix on a new commit, bump to the next patch (or hold), cut a **new** annotated tag on the fixed commit, push that new tag. Do not rewrite history of a published `v*`. - If the tag was never pushed remotely and only exists locally on a bad tip: delete the **local** tag (`git tag -d vX.Y.Z`) and recreate on the fixed commit, then push once. - - Optional hardening: GitHub ruleset protecting `v*` from force-update/deletion; local `pre-push` today only gates `main`/`master` (see `git-hooks/pre-push`), so discipline + ruleset matter for tags. + - Optional hardening: GitHub ruleset protecting `v*` from force-update/deletion; local `pre-push` blocks force-update/delete of `refs/tags/v*` unless `ALLOW_TAG_FORCE=1` (see `git-hooks/pre-push`). #### 10. **Sync staging (optional)** - Run `git checkout staging` to return to staging branch. diff --git a/dashboard/dashboard-data.mjs b/dashboard/dashboard-data.mjs index 174ae74..f62b7d3 100644 --- a/dashboard/dashboard-data.mjs +++ b/dashboard/dashboard-data.mjs @@ -24,6 +24,7 @@ import { buildMissionControlView, collectDeferredCheckIds, collectReadinessPendingFromReport, + describeProcess, detectAwaitingPrompt, dismissedAttentionIds, extractChatSnippet, @@ -37,15 +38,26 @@ import { serializeFlightLogLedger, serializeMissionTimingLedger, } from "./lib/semantic-model.mjs"; +import { MAX_TERMINAL_BYTES, buildTerminalSnapshotFields } from "./lib/terminal-snapshot.mjs"; const KIT_ROOT = resolve(import.meta.dirname, ".."); /** Snapshot root: consumer workspace when MISSION_CONTROL_REPO_ROOT is set, else kit tree. */ const ROOT = resolveSnapshotRepoRoot(process.env, KIT_ROOT); const MAX_TERMINALS = 20; const MAX_PROCESSES = 25; -const MAX_TERMINAL_BYTES = 64 * 1024; -const MAX_LAST_OUTPUT_LINES = 15; -const MAX_LAST_OUTPUT_CHARS = 1200; +const MAX_GIT_GRAPH_LINES = 25; +const MAX_GIT_GRAPH_LINE_CHARS = 160; + +/** Soft wall-clock budget for optional collectors (transcripts, reports, ps). */ +const SNAPSHOT_STARTED_MS = Date.now(); +const SNAPSHOT_BUDGET_MS = (() => { + const raw = process.env.AGENT_KIT_DASHBOARD_DATA_BUDGET_MS; + const n = raw != null && raw !== "" ? Number(raw) : 12_000; + return Number.isFinite(n) && n > 0 ? n : 12_000; +})(); +function withinSnapshotBudget(reserveMs = 400) { + return Date.now() - SNAPSHOT_STARTED_MS + reserveMs < SNAPSHOT_BUDGET_MS; +} // Agent-prompt scan bounds (fs half of the detection contract in semantic-model.mjs). const MAX_TRANSCRIPT_FILES = 60; // cap directory reads per snapshot @@ -82,31 +94,6 @@ function redactTerminalOutput(text) { return out; } -/** Last N lines of terminal body after YAML header, char-capped and redacted. */ -function extractLastOutput(rawContent) { - const lines = rawContent.split("\n"); - let headerEnd = 0; - let dashCount = 0; - for (let i = 0; i < lines.length; i++) { - if (lines[i].trim() === "---") { - dashCount++; - if (dashCount === 2) { - headerEnd = i + 1; - break; - } - } - } - if (headerEnd === 0) headerEnd = 10; - - const bodyLines = lines.slice(headerEnd).filter((l) => l.trim() && !l.startsWith("---")); - if (bodyLines.length === 0) return null; - - const tail = bodyLines.slice(-MAX_LAST_OUTPUT_LINES); - let text = redactTerminalOutput(tail.join("\n")); - text = truncateStr(text, MAX_LAST_OUTPUT_CHARS); - return text?.trim() ? text : null; -} - const SNAPSHOT = { _schema: { version: "1.2.0", @@ -119,18 +106,20 @@ const SNAPSHOT = { "System metadata: repoRoot, listen port, handoff state, allowlisted config summary, package info, version, name, contextPacks", agents: "Agent definitions from .cursor/agents/*.md", commands: "Slash commands from .cursor/commands/*.md", - memory: "Memory records: error count, decision count, recent decisions", - git: "Git repository state: branch, dirty status, commit, ahead/behind, bounded files[]", + memory: + "Memory records: error count, decision count, recent decisions, recent parsed errors, error-o-meter stats", + git: "Git repository state: branch, dirty status, commit, ahead/behind, bounded files[], promotion flow vs staging/main, graph lines, staging hygiene", terminals: "Active Cursor terminal sessions with metadata, output line count, and capped lastOutput", - processes: "Running process snapshots (node, serve.mjs, git operations)", + processes: + "Running process snapshots (node, serve.mjs, git operations) with elapsed time and a generated narration per process", skills: "Available skills discovered in .cursor/skills/", health: "Aggregated health status with per-check results", missionControl: "Normalized now/activity/attention/plans view model (source-backed; bounded)", }, }, generatedAt: new Date().toISOString(), - dashboardDataVersion: "1.2.0", + dashboardDataVersion: "1.3.0", plans: [], system: { repoRoot: ROOT, @@ -243,9 +232,123 @@ if (existsSync(commandsDir)) { } } +// 4b. Kit-managed marker: commands, skills, and agents listed in registry/registry.json are +// owned by kit updates (read-only from the dashboard). No registry: all project-local. +const kitCommandPaths = new Set(); +const kitSkillDirs = new Set(); +const kitAgentPaths = new Set(); +const registryFile = join(ROOT, "registry", "registry.json"); +if (existsSync(registryFile)) { + try { + const registry = JSON.parse(readFileSync(registryFile, "utf8")); + const entries = Array.isArray(registry?.artifacts) ? registry.artifacts : []; + for (const entry of entries) { + if (entry?.kind === "command" && typeof entry?.path === "string") { + kitCommandPaths.add(entry.path); + } + if ( + entry?.kind === "skill" && + typeof entry?.path === "string" && + entry.path.startsWith("registry/skills/") + ) { + kitSkillDirs.add(entry.path.replace(/^registry\/skills\//, ".cursor/skills/")); + } + if (entry?.kind === "agent" && typeof entry?.path === "string") { + kitAgentPaths.add(entry.path); + } + } + } catch { + // Unreadable registry: degrade to all-editable rather than locking everything. + } +} +for (const c of SNAPSHOT.commands) { + c.kitManaged = kitCommandPaths.has(c.path); +} +for (const a of SNAPSHOT.agents) { + a.kitManaged = kitAgentPaths.has(a.path); +} + // 5. Memory const memoryErrorsDir = join(ROOT, ".cursor", "memory", "errors"); const memoryDecisionsDir = join(ROOT, ".cursor", "memory", "decisions"); +const MAX_MEMORY_RECENT_ERRORS = 12; // cap parsed entries shipped per snapshot +const MAX_MEMORY_ERROR_BYTES = 64 * 1024; // skip oversized entries, degrade quietly + +/** Parse one `.cursor/memory/errors/*.md` entry into the KPI-friendly shape. */ +function parseMemoryErrorFile(dir, file) { + const id = file.replace(/\.md$/, ""); + const path = `.cursor/memory/errors/${file}`; + const full = join(dir, file); + let modifiedAt = null; + try { + modifiedAt = statSync(full).mtime.toISOString(); + } catch { + modifiedAt = null; + } + let raw = ""; + try { + raw = readFileSync(full, "utf-8").slice(0, MAX_MEMORY_ERROR_BYTES); + } catch { + return { + id, + path, + title: id, + date: "", + error: "", + cause: "", + solution: "", + files: "", + tags: [], + modifiedAt, + }; + } + const field = (...names) => { + for (const name of names) { + const m = raw.match(new RegExp(`^- \\*\\*${name}:\\*\\*\\s*(.+)$`, "im")); + if (m) return truncateStr(m[1].trim(), 600); + } + return ""; + }; + const titleMatch = raw.match(/^#\s+(.+)$/m); + const tags = field("Tags") + .split(",") + .map((t) => t.trim()) + .filter(Boolean); + return { + id, + path, + title: titleMatch ? truncateStr(titleMatch[1].trim(), 200) : id, + date: field("Date", "Data"), + error: field("Error", "Erro"), + cause: field("Cause", "Causa"), + solution: field("Solution", "Solução", "Solucao"), + files: field("Files", "Arquivos"), + tags, + modifiedAt, + }; +} + +/** Error-o-meter aggregates: counts, rates, and top tags across parsed entries. */ +function computeMemoryErrorStats(entries) { + const total = entries.length; + const now = Date.now(); + const DAY_MS = 24 * 60 * 60 * 1000; + const last30d = entries.filter((e) => { + const t = Date.parse(e.date || e.modifiedAt || ""); + return Number.isFinite(t) && now - t <= 30 * DAY_MS; + }).length; + const weeklyRate = Math.round((last30d / 30) * 7 * 10) / 10; + const tagCounts = new Map(); + for (const e of entries) { + for (const t of e.tags || []) tagCounts.set(t, (tagCounts.get(t) || 0) + 1); + } + const topTags = [...tagCounts.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .slice(0, 6) + .map(([tag, count]) => ({ tag, count })); + return { total, last30d, weeklyRate, topTags }; +} + if (existsSync(memoryErrorsDir)) { const errorFiles = readdirSync(memoryErrorsDir).filter((f) => f.endsWith(".md")); SNAPSHOT.memory.errors = errorFiles.length; @@ -259,6 +362,11 @@ if (existsSync(memoryErrorsDir)) { } return { id, modifiedAt }; }); + const parsedErrors = errorFiles + .map((f) => parseMemoryErrorFile(memoryErrorsDir, f)) + .sort((a, b) => String(b.date || b.id).localeCompare(String(a.date || a.id))); + SNAPSHOT.memory.recentErrors = parsedErrors.slice(0, MAX_MEMORY_RECENT_ERRORS); + SNAPSHOT.memory.errorStats = computeMemoryErrorStats(parsedErrors); } if (existsSync(memoryDecisionsDir)) { const files = readdirSync(memoryDecisionsDir).filter((f) => f.endsWith(".md")); @@ -314,6 +422,44 @@ try { recentLog = []; } + // Promotion flow state: ahead/behind of HEAD vs BOTH origin/staging and + // origin/main, plus staging vs main (pending promotion count). + const countDivergence = (range) => { + try { + const out = execSync(`git rev-list --left-right --count ${range}`, gitOpts).trim(); + const [left, right] = out.split(/\s+/).map((n) => Number.parseInt(n, 10) || 0); + return { ahead: right, behind: left }; + } catch { + return null; + } + }; + const flow = { + vsStaging: countDivergence("origin/staging...HEAD"), + vsMain: countDivergence("origin/main...HEAD"), + stagingVsMain: countDivergence("origin/main...origin/staging"), + }; + + // Readable graph (branch lanes + merges) as pre-rendered text lines. + let graphLines = []; + try { + graphLines = execSync( + `git log --graph --oneline --decorate --date-order --all -n ${MAX_GIT_GRAPH_LINES}`, + gitOpts, + ) + .trimEnd() + .split("\n") + .filter(Boolean) + .map((line) => truncateStr(line, MAX_GIT_GRAPH_LINE_CHARS)); + } catch { + graphLines = []; + } + + // Staging hygiene: untracked plan-monitor WIP (add-by-name only, never a + // broad git add of .cursor/memory/; ADR 2026-07-29 staging hygiene R14/R15). + const monitorWip = parsed.files + .filter((f) => f.untracked && /^\.cursor\/memory\/plan-monitor-.+\.md$/.test(f.path)) + .map((f) => f.path); + SNAPSHOT.git = { branch: truncateStr(branch, MAX_STRING.branch), dirty: parsed.total > 0, @@ -323,6 +469,9 @@ try { lastCommit: truncateStr(lastCommit, MAX_STRING.lastCommit), ahead, behind, + flow, + graph: graphLines, + hygiene: { monitorWip }, }; SNAPSHOT._gitRecentLog = recentLog; } catch { @@ -344,26 +493,24 @@ if (existsSync(terminalProjectPath)) { for (const file of files) { const full = join(terminalProjectPath, file); const raw = readFileSync(full, "utf-8"); - // Cap huge terminal dumps: only header meta + a line count estimate is needed - const content = raw.length > MAX_TERMINAL_BYTES ? raw.slice(0, MAX_TERMINAL_BYTES) : raw; - const lines = content.split("\n"); - const meta = {}; - for (const line of lines.slice(0, 15)) { - if (line.startsWith("pid:")) meta.pid = line.slice(4).trim(); - if (line.startsWith("cwd:")) meta.cwd = line.slice(4).trim(); - if (line.startsWith("command:")) meta.lastCommand = line.slice(8).trim(); - if (line.startsWith("last_command:")) meta.lastCommand = line.slice(13).trim(); - if (line.startsWith("last_exit_code:")) meta.lastExitCode = line.slice(15).trim(); - } - const outputLines = lines.slice(10).filter((l) => { - return l.trim() && !l.startsWith("---"); - }).length; - const lastOutput = extractLastOutput(content); + // Meta from file head; body/output from tail-cap so over-cap terminals keep pid/cwd/exit + // (plain tail-slice previously dropped the header and blanked exit-code dots). + const { meta, outputLines, lastOutput } = buildTerminalSnapshotFields(raw, { + maxBytes: MAX_TERMINAL_BYTES, + redact: redactTerminalOutput, + truncate: truncateStr, + }); const entry = { id: file, ...redactTerminalMeta(meta), outputLines, }; + // File mtime feeds the busy-outside-plan freshness window (semantic model). + try { + entry.updatedAt = statSync(full).mtime.toISOString(); + } catch { + // Missing mtime only disables the busy freshness signal for this terminal. + } if (lastOutput) entry.lastOutput = lastOutput; SNAPSHOT.terminals.push(entry); } @@ -431,6 +578,7 @@ if (existsSync(skillsDir)) { title: titleMatch ? titleMatch[1].trim() : relativeDir.split("/").pop(), description: descMatch ? descMatch[1].trim().slice(0, 150) : "", file: fullPath.replace(`${ROOT}/`, ""), + kitManaged: kitSkillDirs.has(`.cursor/skills/${relativeDir}`), }); } } @@ -443,36 +591,43 @@ if (existsSync(skillsDir)) { // 13. Process scanning (capped list: the UI only needs a sample of relevant procs) try { - const psOutput = execSync("ps -axo pid=,pcpu=,pmem=,command=", { - encoding: "utf-8", - timeout: 3000, - }).trim(); - if (psOutput) { - const interesting = []; - for (const line of psOutput.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) continue; - if (!/node|git|serve\.mjs|dashboard/i.test(trimmed)) continue; - if (/grep|dashboard-data/.test(trimmed)) continue; - const parts = trimmed.split(/\s+/); - const pid = parts[0]; - const cpu = parts[1]; - const mem = parts[2]; - const cmd = parts.slice(3).join(" ") || "unknown"; - let label = "other"; - if (cmd.includes("serve.mjs") || cmd.includes("node dashboard")) label = "dashboard-server"; - else if (/\bgit\b/.test(cmd)) label = "git"; - else if (cmd.includes("node")) label = "node"; - interesting.push({ - pid, - cpu, - mem, - command: truncateStr(cmd, MAX_STRING.processCommand), - label, - }); - if (interesting.length >= MAX_PROCESSES) break; + if (!withinSnapshotBudget(500)) { + SNAPSHOT.processes = []; + } else { + const psOutput = execSync("ps -axo pid=,pcpu=,pmem=,etime=,command=", { + encoding: "utf-8", + timeout: 3000, + }).trim(); + if (psOutput) { + const interesting = []; + for (const line of psOutput.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + if (!/node|git|serve\.mjs|dashboard/i.test(trimmed)) continue; + if (/grep|dashboard-data/.test(trimmed)) continue; + const parts = trimmed.split(/\s+/); + const pid = parts[0]; + const cpu = parts[1]; + const mem = parts[2]; + const etime = parts[3]; + const cmd = parts.slice(4).join(" ") || "unknown"; + let label = "other"; + if (cmd.includes("serve.mjs") || cmd.includes("node dashboard")) label = "dashboard-server"; + else if (/\bgit\b/.test(cmd)) label = "git"; + else if (cmd.includes("node")) label = "node"; + interesting.push({ + pid, + cpu, + mem, + etime, + command: truncateStr(cmd, MAX_STRING.processCommand), + label, + description: describeProcess({ label, command: cmd, cpu, etime }), + }); + if (interesting.length >= MAX_PROCESSES) break; + } + SNAPSHOT.processes = interesting; } - SNAPSHOT.processes = interesting; } } catch { SNAPSHOT.processes = []; @@ -483,7 +638,8 @@ const checks = [ { id: "plans", label: "Plans directory", ok: existsSync(plansDir) && SNAPSHOT.plans.length > 0 }, // Present + parseable HANDOFF is healthy even when Plan is none/null (idle). { id: "handoff", label: "HANDOFF.md", ok: !!SNAPSHOT.system.handoff }, - { id: "agents", label: "Agents", ok: SNAPSHOT.agents.length > 0 }, + // L0-optional: empty .cursor/agents/ is healthy (packs/skills may add agents later). + { id: "agents", label: "Agents", ok: true }, { id: "commands", label: "Commands", ok: SNAPSHOT.commands.length > 0 }, { id: "memory", @@ -518,6 +674,7 @@ SNAPSHOT.health.status = checks.every((c) => c.ok) * never an error state. */ function collectAgentPrompts() { + if (!withinSnapshotBudget(800)) return []; const projectsDir = resolve(process.env.HOME || "~", ".cursor", "projects"); const slug = ROOT.replace(/\//g, "-").replace(/^-/, ""); const transcriptsDir = join(projectsDir, slug, "agent-transcripts"); @@ -592,6 +749,7 @@ function collectAgentPrompts() { * directory yields an empty list, never an error state. */ function collectExternalReports() { + if (!withinSnapshotBudget(600)) return []; const memoryDir = join(ROOT, ".cursor", "memory"); if (!existsSync(memoryDir)) return []; diff --git a/dashboard/dashboard.html b/dashboard/dashboard.html index 92a2c69..907186a 100644 --- a/dashboard/dashboard.html +++ b/dashboard/dashboard.html @@ -67,6 +67,8 @@ --mc-space-xl: 16px; --mc-space-2xl: 24px; --mc-card-padding: 16px; + /* Dense list-card padding (agent/command/skill/process cards, health rows, recent plans). */ + --mc-card-padding-dense: 10px 12px; --mc-header-pad-x: 24px; /* Trailing pad tighter so More (...) sits closer to the IDE ellipsis column. */ --mc-header-pad-x-end: 8px; @@ -135,6 +137,8 @@ .top-nav-anchor:focus-visible, .header-brand:focus-visible, .header-home-btn:focus-visible, +.header-fullscreen-btn:focus-visible, +.fullscreen-exit-btn:focus-visible, .section-home-back:focus-visible, .nav-more-btn:focus-visible, .nav-more-item:focus-visible, @@ -343,6 +347,75 @@ cursor: not-allowed; } +/* ===== Fullscreen (zen) viewport mode ===== + Hides the 32px app header so the grid/stack reclaims the full panel height. + Enter control lives in the header; a floating exit control (or Escape) restores chrome. */ +.header-fullscreen-btn { + background: transparent; + border: none; + color: var(--text-secondary); + width: var(--mc-header-control-size); + height: var(--mc-header-control-size); + padding: 0; + border-radius: var(--mc-radius-chrome); + cursor: pointer; + font-family: inherit; + transition: background 0.2s, color 0.2s, opacity 0.2s; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} +.header-fullscreen-btn:hover { + background: var(--bg-card-hover); + color: var(--text-primary); +} +.header-fullscreen-btn svg { + width: var(--mc-chrome-icon-size); + height: var(--mc-chrome-icon-size); + display: block; + flex-shrink: 0; +} +.fullscreen-exit-btn { + position: fixed; + top: var(--mc-space-md); + right: var(--mc-space-md); + z-index: 70; + width: var(--mc-header-control-size); + height: var(--mc-header-control-size); + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--mc-radius-chrome); + color: var(--text-secondary); + cursor: pointer; + font-family: inherit; + transition: background 0.2s, color 0.2s; +} +.fullscreen-exit-btn:hover { + background: var(--bg-card-hover); + color: var(--text-primary); +} +.fullscreen-exit-btn[hidden] { + display: none; +} +.fullscreen-exit-btn svg { + width: var(--mc-chrome-icon-size); + height: var(--mc-chrome-icon-size); + display: block; + flex-shrink: 0; +} +body.mc-fullscreen .header { + display: none; +} +/* Reserve trailing space so the fixed exit control does not cover top-tabs. */ +body.mc-fullscreen .top-tabs-row { + padding-right: calc(var(--mc-header-control-size) + var(--mc-space-md) * 2 + var(--mc-space-sm)); +} + /* ===== Layout ===== */ .main { display: flex; @@ -626,6 +699,7 @@ .dot-blue { background: var(--blue); box-shadow: 0 0 6px var(--blue-bg); } .dot-purple { background: var(--purple); box-shadow: 0 0 6px var(--purple-bg); } .dot-cyan { background: var(--cyan); box-shadow: 0 0 6px var(--cyan-bg); } +.dot-orange { background: var(--orange); box-shadow: 0 0 6px var(--orange-bg); } .dot-gray { background: #3a4a5a; box-shadow: 0 0 6px rgba(58,74,90,0.3); } .dot-pulse::after { content: ''; @@ -639,6 +713,8 @@ .dot-green.dot-pulse::after { border-color: var(--green); } .dot-blue.dot-pulse::after { border-color: var(--blue); } .dot-yellow.dot-pulse::after { border-color: var(--yellow); } +.dot-red.dot-pulse::after { border-color: var(--red); } +.dot-orange.dot-pulse::after { border-color: var(--orange); } @keyframes pulse { 0% { opacity: 0.6; transform: scale(1); } 100% { opacity: 0; transform: scale(2.5); } @@ -759,29 +835,66 @@ outline-offset: 1px; } -/* ===== Agent List ===== */ -.agent-list { display: flex; flex-direction: column; gap: 4px; } -.agent-item { +/* ===== Agents ===== */ +.agent-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 10px; +} +.agent-card { + display: flex; + flex-direction: column; + gap: 6px; + padding: var(--mc-card-padding-dense); + border-radius: var(--mc-radius); + background: var(--bg-card); + border: 1px solid var(--border); +} +.agent-card:hover { border-color: var(--border-active); } +.agent-card-head { display: flex; align-items: center; + justify-content: space-between; gap: 8px; - padding: 8px 10px; - border-radius: 6px; - font-size: 13px; - color: var(--text-secondary); - transition: all 0.15s; } -.agent-item:hover { background: var(--bg-card-hover); color: var(--text-primary); } -.agent-item .agent-icon { - width: 20px; height: 20px; +.agent-name { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-primary); + font-family: var(--mc-font-mono); +} +.agent-monogram { + width: 18px; + height: 18px; border-radius: 4px; - display: flex; + display: inline-flex; align-items: center; justify-content: center; - font-size: 10px; + font-size: 9px; font-weight: 700; + background: var(--bg-card-hover); + color: var(--text-secondary); flex-shrink: 0; } +.agent-desc { + font-size: 11px; + color: var(--text-secondary); + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.agent-file { + font-size: 10px; + color: var(--text-muted); + font-family: var(--mc-font-mono); + word-break: break-all; +} +.agent-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 2px; } +.agent-create-btn { margin-left: auto; } /* ===== Terminal List ===== */ .terminal-list { display: flex; flex-direction: column; gap: 4px; } @@ -816,8 +929,52 @@ font-size: 11px; color: var(--text-muted); } -.healthcenter-presence .dot-pulse { - animation: pulse 1.6s ease-in-out infinite; +/* Presence liveness rides the shared .dot-pulse::after halo; no element-level + pulse (it inflated the solid dot 250% and faded it out each cycle). */ +.health-vitals { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 8px; +} +.health-vital-card { + display: flex; + flex-direction: column; + gap: 6px; + padding: var(--mc-card-padding-dense); + border: 1px solid var(--border); + border-radius: var(--mc-radius); + background: var(--bg-card); +} +.health-vital-card[data-state="attention"] { + border-color: var(--border-active); +} +.health-vital-name { + font-size: 12px; + font-weight: 600; + color: var(--text-primary); +} +.health-vital-score { + font-size: 11px; + color: var(--text-muted); +} +.health-vital-state { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; +} +.health-vital-card[data-state="pass"] .health-vital-state { color: var(--green); } +.health-vital-card[data-state="attention"] .health-vital-state { color: var(--red); } +.health-group { + display: flex; + flex-direction: column; + gap: 8px; +} +.health-group-title { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); } .health-grid { display: grid; @@ -828,7 +985,7 @@ display: flex; flex-direction: column; gap: 0; - border-radius: var(--mc-radius-sm, 6px); + border-radius: var(--mc-radius); background: var(--bg-card); border: 1px solid var(--border); overflow: hidden; @@ -838,15 +995,11 @@ .health-card.is-expanded { border-color: var(--border-active); } -.health-card[data-severity="ok"] { border-left: 3px solid var(--green, #3fb950); } -.health-card[data-severity="warning"] { border-left: 3px solid var(--yellow, #d29922); } -.health-card[data-severity="degraded"] { border-left: 3px solid var(--orange, #db6d28); } -.health-card[data-severity="error"] { border-left: 3px solid var(--red, #f85149); } .health-item { display: flex; align-items: center; gap: 8px; - padding: 10px 12px; + padding: var(--mc-card-padding-dense); font-size: 12px; color: var(--text-secondary); cursor: pointer; @@ -870,6 +1023,10 @@ color: var(--text-muted); flex-shrink: 0; } +.health-item-sev[data-sev="ok"] { color: var(--green); } +.health-item-sev[data-sev="warning"] { color: var(--yellow); } +.health-item-sev[data-sev="degraded"] { color: var(--orange); } +.health-item-sev[data-sev="error"] { color: var(--red); } .health-item-chevron { color: var(--text-muted); font-size: 10px; @@ -913,12 +1070,6 @@ .health-autofix-btn:hover { background: var(--bg-card-hover); } -html[data-dashboard-skin="cursor"] .health-card { - border-radius: 8px; -} -html[data-dashboard-skin="legacy"] .health-card { - border-radius: 4px; -} /* ===== Git ===== */ .git-info { @@ -936,6 +1087,98 @@ .git-label { color: var(--text-muted); } .git-value { color: var(--text-secondary); font-family: var(--mc-font-mono); font-size: 12px; } +/* Git tab v2: promotion flow lanes, readable graph, staging hygiene */ +.git-flow { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 12px; +} +.git-flow-row { + display: flex; + align-items: center; + gap: 10px; + font-size: 12px; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-card); + flex-wrap: wrap; +} +.git-flow-lane { + font-weight: 600; + color: var(--text-primary); + flex-shrink: 0; +} +.git-flow-arrow { color: var(--text-muted); flex-shrink: 0; } +.git-flow-state { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--text-secondary); + flex: 1; + min-width: 0; +} +.git-flow-badge { + font-size: 10px; + font-weight: 600; + padding: 1px 8px; + border-radius: 999px; + border: 1px solid var(--border); + color: var(--text-secondary); + background: var(--bg-secondary); + font-family: var(--mc-font-mono); + flex-shrink: 0; +} +.git-flow-badge.is-ahead { color: var(--yellow); border-color: rgba(234,179,8,0.3); } +.git-flow-badge.is-behind { color: var(--orange); border-color: rgba(249,115,22,0.3); } +.git-flow-badge.is-sync { color: var(--green); border-color: rgba(34,197,94,0.3); } +.git-flow-cta { margin-left: auto; flex-shrink: 0; } +.git-graph { + margin: 0; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-secondary); + font-family: var(--mc-font-mono); + font-size: 11px; + line-height: 1.5; + color: var(--text-secondary); + overflow-x: hidden; + white-space: pre-wrap; + word-break: break-all; + max-height: 420px; + overflow-y: auto; +} +.git-graph-title { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + margin: 12px 0 6px; +} +.git-hygiene { + display: flex; + align-items: flex-start; + gap: 10px; + font-size: 12px; + padding: 10px 12px; + border: 1px solid rgba(234,179,8,0.35); + border-radius: 8px; + background: var(--yellow-bg); + color: var(--text-secondary); + margin-bottom: 12px; + flex-wrap: wrap; +} +.git-hygiene-body { flex: 1; min-width: 200px; } +.git-hygiene-title { font-weight: 600; color: var(--yellow); margin-bottom: 2px; } +.git-hygiene-paths { + font-family: var(--mc-font-mono); + font-size: 11px; + color: var(--text-muted); + margin-top: 4px; + word-break: break-all; +} + /* ===== Memory ===== */ .memory-list { display: flex; @@ -953,6 +1196,91 @@ } .memory-item:hover { background: var(--bg-card-hover); } +/* Memory tab v2: error-o-meter KPIs, green/red icon panels, interactive error rows */ +.memory-kpi-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--mc-space-xl); + margin-bottom: var(--mc-space-xl); +} +@media (max-width: 900px) { .memory-kpi-grid { grid-template-columns: 1fr 1fr; } } +.memory-kpi { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--mc-radius-lg); + padding: var(--mc-space-lg) var(--mc-card-padding); +} +.memory-kpi-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); } +.memory-kpi-value { font-size: 24px; font-weight: 700; color: var(--text-primary); margin-top: 4px; } +.memory-kpi-sub { font-size: 11px; color: var(--text-muted); margin-top: 2px; } +.memory-tag-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; } +.memory-tag { + font-size: 11px; + padding: 2px 8px; + border-radius: 999px; + background: var(--red-bg); + color: var(--red); + font-family: var(--mc-font-mono); +} +.memory-panel { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--mc-radius-lg); padding: var(--mc-card-padding); } +.memory-panel-header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; } +.memory-panel-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 6px; + font-size: 13px; + flex-shrink: 0; +} +.memory-panel-icon-green { color: var(--green); background: var(--green-bg); } +.memory-panel-icon-red { color: var(--red); background: var(--red-bg); } +.memory-panel-title { font-size: 13px; font-weight: 600; color: var(--text-primary); } +.memory-panel-sub { font-size: 11px; color: var(--text-muted); margin-left: auto; } +.memory-error-card { + border: 1px solid var(--border); + border-radius: var(--mc-radius); + margin-bottom: 6px; + overflow: hidden; +} +.memory-error-head { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 10px; + background: transparent; + border: none; + cursor: pointer; + text-align: left; + color: var(--text-secondary); + font-size: 12px; +} +.memory-error-head:hover { background: var(--bg-card-hover); } +.memory-error-head:focus-visible { outline: 2px solid var(--accent, var(--green)); outline-offset: -2px; } +.memory-error-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-primary); } +.memory-error-date { font-size: 11px; color: var(--text-muted); font-family: var(--mc-font-mono); flex-shrink: 0; } +.memory-error-chevron { transition: transform 0.15s ease; color: var(--text-muted); flex-shrink: 0; } +.memory-error-card.is-expanded .memory-error-chevron { transform: rotate(90deg); } +.memory-error-detail { display: none; padding: 10px 12px; border-top: 1px solid var(--border); font-size: 12px; } +.memory-error-card.is-expanded .memory-error-detail { display: block; } +.memory-error-field { margin-bottom: 8px; } +.memory-error-field-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin-bottom: 2px; } +.memory-error-field-body { color: var(--text-secondary); white-space: pre-wrap; word-break: break-word; } +.memory-error-files { font-family: var(--mc-font-mono); font-size: 11px; color: var(--text-muted); word-break: break-word; } +.memory-error-actions { display: flex; gap: 8px; margin-top: 10px; } +.memory-error-copy-btn { + font-size: 12px; + padding: 5px 12px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg-elevated, var(--bg-card)); + color: var(--text-primary); + cursor: pointer; +} +.memory-error-copy-btn:hover { background: var(--bg-card-hover); } + /* ===== Activity Timeline ===== */ .activity-list { display: flex; @@ -1037,22 +1365,46 @@ /* ===== Commands ===== */ .command-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); - gap: 6px; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 10px; } -.command-item { +.command-card { display: flex; - align-items: center; + flex-direction: column; gap: 6px; - padding: 6px 10px; - border-radius: 6px; + padding: var(--mc-card-padding-dense); + border-radius: var(--mc-radius); background: var(--bg-card); border: 1px solid var(--border); +} +.command-card:hover { border-color: var(--border-active); } +.command-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.command-name { font-size: 12px; - color: var(--text-secondary); + color: var(--text-primary); font-family: var(--mc-font-mono); } -.command-item:hover { border-color: var(--border-active); color: var(--text-primary); } +.command-lock, .skill-lock, .agent-lock { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 10px; + color: var(--text-muted); +} +.command-lock svg, .skill-lock svg, .agent-lock svg { display: block; } +.command-file { + font-size: 10px; + color: var(--text-muted); + font-family: var(--mc-font-mono); + word-break: break-all; +} +.command-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 2px; } +.command-create-btn { margin-left: auto; } /* ===== Loading ===== */ .loading { @@ -1084,7 +1436,7 @@ gap: var(--mc-chrome-label-gap); letter-spacing: 0; } -.section-title .dot { width: 12px; height: 12px; } +/* Section titles carry no dots: identity comes from the label text (dot semantics table). */ .section-subtitle { font-size: var(--mc-chrome-subtitle-size); color: var(--text-muted); @@ -1093,39 +1445,75 @@ } /* ===== Processes ===== */ -.processes-table { - width: 100%; - border-collapse: collapse; - font-size: 12px; - font-family: var(--mc-font-mono); +.processes-note { + padding: 9px 12px; + margin-bottom: 10px; + border-radius: 8px; + background: var(--blue-bg); + border: 1px solid rgba(59, 130, 246, 0.25); + color: var(--text-secondary); + font-size: 11.5px; + line-height: 1.5; } -.processes-table th { - text-align: left; - padding: 8px 10px; - color: var(--text-muted); +.processes-note strong { + color: var(--blue); font-weight: 600; - border-bottom: 1px solid var(--border); - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.05em; } -.processes-table td { - padding: 7px 10px; - border-bottom: 1px solid var(--border); - color: var(--text-secondary); +.process-list { + display: flex; + flex-direction: column; + gap: 8px; } -.processes-table tr:hover td { - background: var(--bg-card-hover); +.process-card { + padding: var(--mc-card-padding-dense); + border-radius: var(--mc-radius); + background: var(--bg-card); + border: 1px solid var(--border); } -.processes-table .process-label { +.process-card:hover { + border-color: var(--border-active); +} +.process-card-head { display: flex; align-items: center; - gap: 6px; + gap: 10px; } -.processes-table .process-label .dot { width: 8px; height: 8px; } -.processes-table .process-actions { +.process-label-badge { + font-size: 10px; + font-weight: 600; + padding: 2px 8px; + border-radius: 4px; + background: var(--blue-bg); + color: var(--blue); + letter-spacing: 0.03em; +} +.process-card-head .process-actions { + margin-left: auto; + white-space: nowrap; +} +.process-desc { + margin-top: 6px; + color: var(--text-primary); + font-size: 12px; + line-height: 1.5; +} +.process-meta { + margin-top: 4px; + display: flex; + gap: 12px; + flex-wrap: wrap; + color: var(--text-muted); + font-size: 11px; + font-family: var(--mc-font-mono); +} +.process-cmd { + margin-top: 4px; + color: var(--text-muted); + font-size: 11px; + font-family: var(--mc-font-mono); + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; - text-align: right; } .process-copy-btn { font-size: 10px; @@ -1145,61 +1533,45 @@ } /* ===== Skills ===== */ -.skills-category { - margin-bottom: 20px; -} -.skills-category-title { - font-size: 12px; - font-weight: 600; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.06em; - margin-bottom: 10px; - padding: 0 2px; +.skill-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 10px; } -.skills-tags { +.skill-card { display: flex; - flex-wrap: wrap; + flex-direction: column; gap: 6px; -} -.skill-tag { - display: inline-flex; - align-items: center; - gap: 5px; - padding: 5px 10px; - border-radius: 6px; + padding: var(--mc-card-padding-dense); + border-radius: var(--mc-radius); background: var(--bg-card); border: 1px solid var(--border); - font-size: 12px; - color: var(--text-secondary); - cursor: default; - transition: all 0.15s; - max-width: 280px; } -.skill-tag:hover { - border-color: var(--border-active); - color: var(--text-primary); +.skill-card:hover { border-color: var(--border-active); } +.skill-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; } -.skill-tag .skill-cat-dot { - width: 6px; - height: 6px; - border-radius: 50%; - flex-shrink: 0; +.skill-name { + font-size: 12px; + color: var(--text-primary); } -.skill-tag .skill-title { - font-weight: 500; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; +.skill-cat { + font-size: 10px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; } -.skill-tag .skill-desc { - font-size: var(--mc-chrome-meta-size); - font-weight: var(--mc-chrome-meta-weight); +.skill-file { + font-size: 10px; color: var(--text-muted); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + font-family: var(--mc-font-mono); + word-break: break-all; } +.skill-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 2px; } +.skill-create-btn { margin-left: auto; } /* ===== Context Packs ===== */ .context-pack-item { @@ -1282,16 +1654,6 @@ .live-indicator.live-reconnecting { animation: pulse-text 1.5s ease-in-out infinite; } -.live-pulse-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--green); - position: relative; -} -.live-pulse-dot.live-dot-yellow { - background: var(--yellow); -} /* Decorative ring pulses removed; header transport owns freshness */ .live-activity-feed { max-height: 320px; @@ -1378,6 +1740,21 @@ width: 1em; text-align: center; } +.live-activity-feed .monitor-row .monitor-row-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + min-width: 18px; + height: 18px; + border-radius: 4px; + font-size: 9px; + font-weight: 700; + line-height: 1; + flex-shrink: 0; + background: var(--bg-card-hover); + color: var(--text-secondary); +} .live-activity-feed .monitor-row .feed-label { flex: 1; min-width: 0; @@ -1387,6 +1764,32 @@ color: var(--text-secondary); font-size: 12px; font-weight: 400; + display: flex; + align-items: baseline; + gap: 6px; +} +/* Structured label spans: actor + verb never shrink; the plan filename is the + low-signal segment and ellipsises first. */ +.live-activity-feed .monitor-row .feed-seg { + flex-shrink: 0; + white-space: nowrap; +} +.live-activity-feed .monitor-row .feed-sep { + flex-shrink: 0; + color: var(--text-muted); +} +.live-activity-feed .monitor-row .feed-seg-mid { + flex-shrink: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} +.live-activity-feed .monitor-row .feed-seg-plan { + flex-shrink: 3; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text-muted); } .live-activity-feed .monitor-row .feed-time { color: var(--text-muted); @@ -1398,7 +1801,7 @@ margin-left: auto; } .activity-feed-empty { - padding: 24px; + padding: var(--mc-space-2xl); text-align: center; color: var(--text-muted); font-size: 12px; @@ -1469,47 +1872,21 @@ } /* ===== Empty State ===== */ +/* Empty states scale with the card density ladder (viewport modes shrink + --mc-card-padding), so they stay intentional from fullscreen to thin sidebar. */ .empty-state { text-align: center; - padding: 40px 20px; + padding: calc(var(--mc-card-padding) * 2.5) calc(var(--mc-card-padding) * 1.25); color: var(--text-muted); font-size: 13px; } /* ===== Clickable Cursors (Phase 1) ===== */ .plan-card { cursor: pointer; } -.command-item { cursor: pointer; } -.agent-item { cursor: pointer; } .health-item { cursor: pointer; } .terminal-item { cursor: pointer; } .memory-item { cursor: pointer; } -/* ===== Agent Expanded View ===== */ -.agent-details { - max-height: 0; - overflow: hidden; - transition: max-height 0.3s ease, opacity 0.3s ease; - opacity: 0; - padding: 0 10px; -} -.agent-details.open { - max-height: 200px; - opacity: 1; - padding: 8px 10px; -} -.agent-details p { - font-size: 12px; - color: var(--text-secondary); - line-height: 1.5; -} -.agent-details .agent-path { - font-size: 11px; - color: var(--text-muted); - font-family: var(--mc-font-mono); - margin-top: 4px; - word-break: break-all; -} - /* ===== Copied Toast ===== */ .copied-toast { position: fixed; @@ -1654,10 +2031,23 @@ /* ===== Config form (session prefs) ===== */ .config-form { - display: flex; - flex-direction: column; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 16px; - max-width: 520px; +} +.config-actions { + grid-column: 1 / -1; + display: flex; + align-items: center; + gap: 12px; +} +.config-fieldset-wide { + grid-column: 1 / -1; +} +.config-grid-2 { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 4px 16px; } .config-fieldset { border: 1px solid var(--border); @@ -1666,11 +2056,28 @@ padding: 12px 14px 14px; margin: 0; } -.config-fieldset legend { - padding: 0 6px; - font-size: var(--mc-chrome-subtitle-size); - font-weight: var(--mc-chrome-subtitle-weight); - color: var(--text-muted); +.config-fieldset legend { + padding: 0 6px; + font-size: var(--mc-chrome-subtitle-size); + font-weight: var(--mc-chrome-subtitle-weight); + color: var(--text-muted); +} +.btn-config-copy { + align-self: flex-start; + margin-top: 10px; + background: none; + border: 1px solid var(--border); + color: var(--text-muted); + padding: 3px 10px; + border-radius: 4px; + cursor: pointer; + font-size: 11px; + font-family: inherit; + transition: all 0.15s; +} +.btn-config-copy:hover { + color: var(--text-primary); + border-color: var(--text-muted); } .config-row { display: flex; @@ -1889,12 +2296,12 @@ /* ===== Phase 3: Empty State CTAs ===== */ .empty-state-cta { text-align: center; - padding: 32px 20px; + padding: calc(var(--mc-card-padding) * 2) calc(var(--mc-card-padding) * 1.25); color: var(--text-muted); font-size: 13px; } .empty-state-cta.compact { - padding: 16px 10px; + padding: var(--mc-card-padding) var(--mc-space-lg); font-size: 12px; } .empty-state-cta .empty-state-icon { @@ -1942,7 +2349,7 @@ background: var(--bg-card); border: 1px solid var(--border); color: var(--text-secondary); - padding: var(--mc-space-md) 18px; + padding: var(--mc-space-md) var(--mc-space-xl); border-radius: var(--mc-radius-chrome); cursor: pointer; font-size: var(--mc-chrome-label-size); @@ -2075,6 +2482,8 @@ .plan-card-footer { flex-wrap: wrap; gap: var(--mc-space-xs); } .health-grid { grid-template-columns: 1fr 1fr; } .command-grid { grid-template-columns: 1fr 1fr; } + .skill-grid { grid-template-columns: 1fr 1fr; } + .agent-grid { grid-template-columns: 1fr 1fr; } .git-row { flex-wrap: wrap; gap: var(--mc-space-xs); } .terminal-popup { max-width: 95vw; padding: 14px; } } @@ -2093,12 +2502,60 @@ .terminal-popup { max-width: 95vw; padding: 14px; } } +/* ===== Very thin sidebar (~<340px): tightest chrome, single-column card grids ===== */ +@media (max-width: 339px) { + :root { + --mc-card-padding: 10px; + --mc-header-pad-x: 8px; + --mc-header-pad-x-end: 2px; + --mc-content-pad: 8px; + } + .header { gap: var(--mc-space-sm); } + .header-right { gap: var(--mc-space-xs); } + .header-workspace { display: none; } + .health-grid, + .command-grid, + .skill-grid, + .agent-grid { grid-template-columns: 1fr; } + .live-activity-feed { max-height: 180px; } + /* Flex feed-label: allow actor/verb to ellipsis so fixed spans do not hard-clip. */ + .live-activity-feed .monitor-row .feed-seg-actor, + .live-activity-feed .monitor-row .feed-seg-verb { + flex-shrink: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } +} + .overview-stack { display: flex; flex-direction: column; gap: 12px; } +/* ===== Mid panel (~701-1023px): two-column overview grid, IA row-major ===== + Collapse order follows the locked IA (Current mission -> Flight Log -> Checklist + -> Crew monitor): Current mission spans row 1, Flight Log + Checklist share row 2, + Crew monitor spans row 3. Page keeps scrolling; the one-fold lock starts at 1024px. */ +@media (min-width: 701px) and (max-width: 1023px) { + #section-overview.active .overview-stack { + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-areas: + "now now" + "attention checklist" + "monitor monitor"; + } + #section-overview.active #now-execution-panel { grid-area: now; } + #section-overview.active #attention-panel { grid-area: attention; } + #section-overview.active #recent-plans-panel { grid-area: checklist; } + #section-overview.active #hero-activity { + grid-area: monitor; + margin-bottom: 0; + } +} + /* Desktop one-fold overview: 2x2 fixed-height grid; page does not scroll. Additive under min-width 1024px only. Non-overview sections keep .content scroll. Subheader (Cockpit anchors) hides: all four panels are visible; More lives in the header. */ @@ -2208,7 +2665,7 @@ display: flex; flex-direction: column; gap: var(--mc-space-sm); - padding: 10px var(--mc-space-lg); + padding: var(--mc-card-padding-dense); border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); @@ -2437,6 +2894,25 @@ margin: 0 0 8px; line-height: 1.4; } +.plan-next-action { + padding-top: 10px; + font-size: var(--mc-chrome-meta-size); + color: var(--text-secondary); + line-height: 1.4; +} +.plan-next-action-label { + color: var(--text-muted); + font-weight: var(--mc-chrome-label-weight); +} +.plan-accordion-actions .plan-action-primary, +.plan-accordion-actions .plan-action-primary:hover { + background: var(--blue); + border-color: transparent; + color: #fff; +} +.plan-accordion-actions .plan-action-primary:hover { + filter: brightness(1.08); +} .plan-todo-list-full { display: flex; flex-direction: column; @@ -2448,7 +2924,7 @@ /* Flight Log empty state reuses attention-empty className with empty-state-cta. */ .attention-empty { - padding: 14px 10px; + padding: var(--mc-space-lg) var(--mc-space-md); font-size: 12px; color: var(--text-muted); line-height: 1.45; @@ -2456,7 +2932,7 @@ .empty-state-cta.attention-empty, .empty-state-cta.activity-feed-empty { /* Shared renderer already sets padding; keep panel chrome size. */ - padding: 14px 10px; + padding: var(--mc-space-lg) var(--mc-space-md); } /* ===== Now execution panel (primary Current work) ===== */ @@ -2505,6 +2981,21 @@ color: var(--text-muted); background: var(--bg-card); } +/* Busy-outside-plan chip: live run-loop activity while the mission is not + executing. Advice-family blue tokens (kind palette); text-only so the dot + and icon contracts stay untouched. */ +.mc-busy-chip { + display: inline-flex; + align-items: center; + font-size: var(--mc-chrome-meta-size); + font-weight: var(--mc-chrome-meta-weight); + letter-spacing: 0.02em; + padding: 3px var(--mc-space-md); + border-radius: var(--mc-radius-pill); + color: var(--blue); + background: var(--blue-bg); + text-transform: uppercase; +} .now-plan-name { margin-top: 10px; font-size: 15px; @@ -2753,8 +3244,8 @@ width: 100%; text-align: left; margin: 0; - padding: 10px 12px; - border-radius: 8px; + padding: var(--mc-card-padding-dense); + border-radius: var(--mc-radius); border: 1px solid var(--border); background: var(--bg-elevated, var(--bg-card)); color: var(--text-primary); @@ -2762,10 +3253,10 @@ font: inherit; line-height: 1.4; } -/* Base hover/focus: keyboard-visible ring; kind rules below set colors (no unset --accent → yellow). */ +/* Base hover/focus: keyboard-visible ring via --border-active (not invisible --border); kind rules below set colors. */ .flight-log-card:hover, .flight-log-card:focus-visible { - outline: 2px solid var(--border); + outline: 2px solid var(--border-active); outline-offset: 1px; } .flight-log-card-current { @@ -2829,37 +3320,49 @@ .flight-log-card-past.flight-log-kind-residual { border-color: color-mix(in srgb, var(--yellow) 35%, var(--border)); } -.flight-log-card-past.flight-log-kind-residual:hover, -.flight-log-card-past.flight-log-kind-residual:focus-visible { +/* Earlier: muted color-mix on hover; full kind token on focus-visible for ≥3:1 keyboard ring. */ +.flight-log-card-past.flight-log-kind-residual:hover { border-color: color-mix(in srgb, var(--yellow) 55%, var(--border)); outline-color: color-mix(in srgb, var(--yellow) 55%, var(--border)); } +.flight-log-card-past.flight-log-kind-residual:focus-visible { + border-color: var(--yellow); + outline-color: var(--yellow); +} .flight-log-card-past.flight-log-kind-advice { border-color: color-mix(in srgb, var(--blue) 35%, var(--border)); } -.flight-log-card-past.flight-log-kind-advice:hover, -.flight-log-card-past.flight-log-kind-advice:focus-visible { +.flight-log-card-past.flight-log-kind-advice:hover { border-color: color-mix(in srgb, var(--blue) 55%, var(--border)); outline-color: color-mix(in srgb, var(--blue) 55%, var(--border)); } +.flight-log-card-past.flight-log-kind-advice:focus-visible { + border-color: var(--blue); + outline-color: var(--blue); +} .flight-log-card-past.flight-log-kind-ok { border-color: color-mix(in srgb, var(--green) 30%, var(--border)); } -.flight-log-card-past.flight-log-kind-ok:hover, -.flight-log-card-past.flight-log-kind-ok:focus-visible { +.flight-log-card-past.flight-log-kind-ok:hover { border-color: color-mix(in srgb, var(--green) 50%, var(--border)); outline-color: color-mix(in srgb, var(--green) 50%, var(--border)); } +.flight-log-card-past.flight-log-kind-ok:focus-visible { + border-color: var(--green); + outline-color: var(--green); +} .flight-log-card-past.flight-log-kind-warning { border-color: color-mix(in srgb, var(--orange, var(--yellow)) 30%, var(--border)); } -.flight-log-card-past.flight-log-kind-warning:hover, -.flight-log-card-past.flight-log-kind-warning:focus-visible { +.flight-log-card-past.flight-log-kind-warning:hover { border-color: color-mix(in srgb, var(--orange, var(--yellow)) 50%, var(--border)); outline-color: color-mix(in srgb, var(--orange, var(--yellow)) 50%, var(--border)); } +.flight-log-card-past.flight-log-kind-warning:focus-visible { + border-color: var(--orange, var(--yellow)); + outline-color: var(--orange, var(--yellow)); +} .flight-log-card-warning { - padding: 10px 12px; border-color: color-mix(in srgb, var(--orange, var(--yellow)) 70%, var(--border)); background: color-mix(in srgb, var(--orange-bg, var(--yellow-bg)) 55%, var(--bg-elevated, var(--bg-card))); font-size: 12px; @@ -2987,6 +3490,7 @@ + +
@@ -3187,8 +3690,46 @@ } // ===== Sections ===== +/** Navigable section ids (More menu + hash deep links). */ +const SECTION_IDS = [ + 'overview', + 'plans', + 'activity', + 'agents', + 'skills', + 'commands', + 'health', + 'git', + 'memory', + 'terminals', + 'processes', + 'config', +]; + +/** Read the active section from the URL hash (`#health` -> `health`). */ +function sectionFromHash() { + try { + const raw = (location.hash || '').replace(/^#\/?/, '').split('/')[0]; + return SECTION_IDS.includes(raw) ? raw : 'overview'; + } catch { + return 'overview'; + } +} + +/** Keep the URL hash in sync so every tab is a shareable deep link. */ +function syncSectionHash(id) { + try { + const target = `#${id}`; + if (location.hash !== target && history.replaceState) { + history.replaceState(null, '', target); + } + } catch { + // file:// or restricted contexts: hash sync is best-effort + } +} + /** Active pane id (overview = Cockpit page; others = dropdown destinations). */ -let activeSectionId = 'overview'; +let activeSectionId = sectionFromHash(); /** Last Cockpit in-page anchor while overview is active. */ let activeCockpitAnchor = 'now-execution-panel'; /** Activity tab source filter chip id (`all` or a source key). */ @@ -3215,6 +3756,25 @@ return showSection('overview'); } +/** Fullscreen (zen) viewport mode: hide the app header; Escape or the floating exit control restores chrome. */ +function toggleMcFullscreen(force) { + const on = typeof force === 'boolean' ? force : !document.body.classList.contains('mc-fullscreen'); + document.body.classList.toggle('mc-fullscreen', on); + const enterBtn = document.getElementById('fullscreenToggleBtn'); + if (enterBtn) enterBtn.setAttribute('aria-pressed', on ? 'true' : 'false'); + const exitBtn = document.getElementById('fullscreenExitBtn'); + if (exitBtn) exitBtn.hidden = !on; + return false; +} +document.addEventListener('keydown', (e) => { + if (e.key !== 'Escape' || !document.body.classList.contains('mc-fullscreen')) return; + // Layered Escape: dismiss open menus first; only then exit fullscreen. + if (typeof isNavMoreOpen === 'function' && isNavMoreOpen()) return; + if (typeof openRecentPlanActionsKey !== 'undefined' && openRecentPlanActionsKey) return; + e.preventDefault(); + toggleMcFullscreen(false); +}); + /** Sync header Home chrome + section-title back control with active section. */ function updateHeaderHomeChrome() { const onOverview = activeSectionId === 'overview'; @@ -3271,12 +3831,19 @@ } updateHeaderHomeChrome(); + syncSectionHash(activeSectionId); const content = document.getElementById('content'); if (content) content.scrollTop = 0; return false; } +/** Back/forward or pasted deep links switch tabs. */ +window.addEventListener('hashchange', () => { + const id = sectionFromHash(); + if (id !== activeSectionId) showSection(id); +}); + /** * Anchor scroll still travelling. A snapshot re-render restores the scroll * position captured when it started, which would strand a smooth scroll part @@ -3536,14 +4103,15 @@ return 'red'; } +// Dot semantics: only state signals survive. completed = good, cancelled = +// attention. pending / in_progress are steady states and get no dot (the +// status text on the row stays the source of truth). function statusDot(status) { const map = { completed: 'dot-green', - in_progress: 'dot-blue dot-pulse', - pending: 'dot-gray', cancelled: 'dot-red', }; - return map[status] || 'dot-gray'; + return map[status] || null; } // ===== Client diagnostic activity ===== @@ -3621,11 +4189,12 @@ const map = { // Monitor live kinds: distinct resting color+icon (no kind-tag text, no left rail). // run_plan follows .now-status-executing green; delivery uses cyan to avoid green wash. - run_plan: { icon: '\u25b6', tag: 'tick', gloss: 'tick - live execution', color: 'var(--green)', bg: 'var(--green-bg)' }, - handoff: { icon: '\u23f8', tag: 'handoff', gloss: 'handoff - awaiting gate', color: 'var(--yellow)', bg: 'var(--yellow-bg)' }, - agent_step: { icon: '\u25c9', tag: 'step', gloss: 'step - agent/task unit', color: 'var(--purple)', bg: 'var(--purple-bg)' }, - plan_progress: { icon: '\u2691', tag: 'plan', gloss: 'plan - milestone', color: 'var(--orange)', bg: 'var(--orange-bg)' }, - delivery: { icon: '\u2714', tag: 'delivery', gloss: 'delivery - shipped unit', color: 'var(--cyan)', bg: 'var(--cyan-bg)' }, + // Gloss strings are default software lexicon display masks (kind ids unchanged). + run_plan: { icon: '\u25b6', tag: 'tick', gloss: 'Tech Lead - live execution', color: 'var(--green)', bg: 'var(--green-bg)' }, + handoff: { icon: '\u23f8', tag: 'handoff', gloss: 'Scrum Master - awaiting gate', color: 'var(--yellow)', bg: 'var(--yellow-bg)' }, + agent_step: { icon: '\u25c9', tag: 'step', gloss: 'Full-Stack Developer - task unit', color: 'var(--purple)', bg: 'var(--purple-bg)' }, + plan_progress: { icon: '\u2691', tag: 'plan', gloss: 'Product Owner - milestone', color: 'var(--orange)', bg: 'var(--orange-bg)' }, + delivery: { icon: '\u2714', tag: 'delivery', gloss: 'DevOps Engineer - shipped unit', color: 'var(--cyan)', bg: 'var(--cyan-bg)' }, commit: { icon: '\u25cf', tag: 'commit', gloss: 'commit', color: 'var(--orange)', bg: 'var(--orange-bg)' }, agent: { icon: '\u25c6', tag: 'agent', gloss: 'agent', color: 'var(--purple)', bg: 'var(--purple-bg)' }, skill: { icon: '\u2726', tag: 'skill', gloss: 'skill', color: 'var(--orange)', bg: 'var(--orange-bg)' }, @@ -3637,12 +4206,12 @@ // Locked BMP map (hotfix 2026-07-27): feat✦ fix⚙ docs✎ chore⚒ pr⑂ ship✈ if (kind === 'delivery' && commitType) { const subtypes = { - feat: { icon: '\u2726', tag: 'delivery', gloss: 'delivery - feat', color: 'var(--purple)', bg: 'var(--purple-bg)' }, - fix: { icon: '\u2699', tag: 'delivery', gloss: 'delivery - fix', color: 'var(--red)', bg: 'var(--red-bg)' }, - docs: { icon: '\u270e', tag: 'delivery', gloss: 'delivery - docs', color: 'var(--blue)', bg: 'var(--blue-bg)' }, - chore: { icon: '\u2692', tag: 'delivery', gloss: 'delivery - chore', color: 'var(--orange)', bg: 'var(--orange-bg)' }, - pr: { icon: '\u2442', tag: 'delivery', gloss: 'delivery - PR', color: 'var(--cyan)', bg: 'var(--cyan-bg)' }, - ship: { icon: '\u2708', tag: 'delivery', gloss: 'delivery - shipped unit', color: 'var(--cyan)', bg: 'var(--cyan-bg)' }, + feat: { icon: '\u2726', tag: 'delivery', gloss: 'DevOps Engineer - feat', color: 'var(--purple)', bg: 'var(--purple-bg)' }, + fix: { icon: '\u2699', tag: 'delivery', gloss: 'DevOps Engineer - fix', color: 'var(--red)', bg: 'var(--red-bg)' }, + docs: { icon: '\u270e', tag: 'delivery', gloss: 'DevOps Engineer - docs', color: 'var(--blue)', bg: 'var(--blue-bg)' }, + chore: { icon: '\u2692', tag: 'delivery', gloss: 'DevOps Engineer - chore', color: 'var(--orange)', bg: 'var(--orange-bg)' }, + pr: { icon: '\u2442', tag: 'delivery', gloss: 'DevOps Engineer - PR', color: 'var(--cyan)', bg: 'var(--cyan-bg)' }, + ship: { icon: '\u2708', tag: 'delivery', gloss: 'DevOps Engineer - shipped unit', color: 'var(--cyan)', bg: 'var(--cyan-bg)' }, }; if (subtypes[commitType]) return subtypes[commitType]; } @@ -3834,6 +4403,38 @@ return ''; } +/** + * Crew feed time: source timestamp first, then the client first-seen stamp so + * every row stays timestamped even when the emitter omits `at`. + */ +function crewEventTime(ev, info) { + const source = semanticEventTime(ev, info); + if (source) return source; + const seenAt = ev && ev.id ? semanticSeenAt.get(ev.id) : null; + return seenAt ? escapeHtml(relativeTime(seenAt)) : ''; +} + +/** + * Crew feed actor: kit agent id, else Engineering Manager for delivery, else + * Squad when a plan is present (never the full plan filename), else Platform + * Engineer. Default software lexicon masks; mirrors briefActivityActor. + */ +function crewEventActor(ev) { + if (ev && ev.agent) return String(ev.agent); + if (ev && ev.kind === 'delivery') return 'Engineering Manager'; + if (ev && ev.refs && ev.refs.plan) return 'Squad'; + return 'Platform Engineer'; +} + +/** Avatar-style monogram: first letters of the first two kebab/space parts, else first two chars. */ +function agentInitials(id) { + const clean = String(id || '').trim(); + if (!clean) return ''; + const parts = clean.split(/[-_\s.]+/).filter(Boolean); + if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase(); + return clean.slice(0, 2).toUpperCase(); +} + /** * Mission Control is read-only: it copies text to the clipboard and a human * pastes it. The panel cannot open a file, a chat, or an editor, so no label, @@ -3984,6 +4585,59 @@ }; } +/** + * Plans tab v2: next actionable to-do (in_progress first, else first pending). + * Terminal plans return null (no next step to surface). + */ +function planNextActionTodo(items) { + if (!Array.isArray(items) || items.length === 0) return null; + const current = items.find((t) => t.status === 'in_progress'); + if (current) return { id: current.id || '', content: current.content || '' }; + const next = items.find((t) => t.status === 'pending'); + if (next) return { id: next.id || '', content: next.content || '' }; + return null; +} + +/** + * Plans tab v2: status-aware copy-only actions. Chat commands name the chat + * input as paste destination; the path action names the file picker (open = + * copy path). The first action is the row's primary next step per lifecycle: + * active resumes, backlog runs, parked resumes, exhausted (incomplete) + * resumes, completed opens/archives. + */ +function planTabActions(p) { + const basename = checklistPlanBasename(p); + const resume = { kind: 'chat', label: 'Copy resume prompt', command: `/continue-plan ${basename}` }; + const run = { kind: 'chat', label: 'Copy run command', command: `/run-plan ${basename}` }; + const edit = { kind: 'chat', label: 'Copy edit command', command: `/backlog-edit ${basename}` }; + const archive = { kind: 'chat', label: 'Copy archive command', command: `/archive-plan ${basename}` }; + const path = { kind: 'path', label: 'Copy plan path' }; + switch (p?.lifecycle) { + case 'executing': + case 'awaiting_user': + return [resume, run, path]; + case 'backlog': + return [run, edit, path]; + case 'parked': + return [resume, path]; + case 'incomplete': + return [resume, run, path]; + case 'completed': + return [path, archive]; + default: + return [resume, path]; + } +} + +function renderPlanTabActionButton(action, p, key, idx) { + const primary = idx === 0 ? ' plan-action-primary' : ''; + const focusKey = `plan-act-${escapeAttr(key)}-${idx}`; + if (action.kind === 'path') { + return ``; + } + return ``; +} + let openRecentPlanActionsKey = null; /** Anchor the fixed Actions menu above or below the trigger; escape panel overflow. */ @@ -4342,6 +4996,118 @@ `; } +// ===== Git tab v2: promotion flow lanes + graph + staging hygiene (Phase 4) ===== +/** Divergence badge: sync (green), ahead (yellow), behind (orange). */ +function gitFlowBadge(div) { + if (!div) return 'no upstream'; + const bits = []; + if (div.ahead > 0) bits.push(`ahead ${div.ahead}`); + if (div.behind > 0) bits.push(`behind ${div.behind}`); + if (!bits.length) bits.push('in sync'); + return bits.join(' '); +} + +/** + * State label + dot per Phase 1 dot semantics (state only, always paired + * with a label). tone: green = good/in sync, yellow = important (promotion + * pending or branch ahead), red never used here (git surface is read-only). + */ +function gitFlowStateLabel(div, { syncText, aheadText, behindText }) { + if (!div) return { tone: null, text: 'upstream unavailable' }; + if (div.ahead === 0 && div.behind === 0) return { tone: 'green', text: syncText }; + if (div.ahead > 0 && div.behind === 0) return { tone: 'yellow', text: aheadText(div.ahead) }; + if (div.ahead === 0 && div.behind > 0) return { tone: 'yellow', text: behindText(div.behind) }; + return { tone: 'yellow', text: `diverged (ahead ${div.ahead}, behind ${div.behind})` }; +} + +function gitFlowDot(tone) { + return tone ? `` : ''; +} + +function renderGitFlowRow({ from, to, div, texts, cta }) { + const state = gitFlowStateLabel(div, texts); + return ` +
+ ${escapeHtml(from)} + + ${escapeHtml(to)} + ${gitFlowDot(state.tone)}${escapeHtml(state.text)}${gitFlowBadge(div)} + ${cta || ''} +
+ `; +} + +/** Promotion flow lanes: work -> staging -> main (what Cursor's git panel does not show). */ +function renderGitFlowCard(git) { + const flow = git?.flow || {}; + const branch = git?.branch || 'HEAD'; + const rows = []; + rows.push(renderGitFlowRow({ + from: branch, + to: 'origin/staging', + div: flow.vsStaging, + texts: { + syncText: 'Nothing to promote', + aheadText: (n) => `${n} commit${n !== 1 ? 's' : ''} ready for staging`, + behindText: (n) => `${n} commit${n !== 1 ? 's' : ''} behind staging`, + }, + cta: flow.vsStaging && flow.vsStaging.ahead > 0 + ? `` + : '', + })); + rows.push(renderGitFlowRow({ + from: 'origin/staging', + to: 'origin/main', + div: flow.stagingVsMain, + texts: { + syncText: 'main is current', + aheadText: (n) => `${n} commit${n !== 1 ? 's' : ''} awaiting promotion to main`, + behindText: (n) => `staging is ${n} commit${n !== 1 ? 's' : ''} behind main`, + }, + cta: flow.stagingVsMain && flow.stagingVsMain.ahead > 0 + ? `` + : '', + })); + rows.push(renderGitFlowRow({ + from: branch, + to: 'origin/main', + div: flow.vsMain, + texts: { + syncText: 'In sync with main', + aheadText: (n) => `${n} commit${n !== 1 ? 's' : ''} not yet in main`, + behindText: (n) => `${n} commit${n !== 1 ? 's' : ''} behind main`, + }, + cta: '', + })); + return `
${rows.join('')}
`; +} + +/** Readable git graph: pre-rendered branch lanes + merges from git log --graph. */ +function renderGitGraphCard(git) { + const lines = Array.isArray(git?.graph) ? git.graph : []; + if (!lines.length) return ''; + return ` +
Recent history (all branches, ${lines.length} lines)
+
${escapeHtml(lines.join('\n'))}
+ `; +} + +/** Staging hygiene: untracked plan-monitor WIP (add-by-name only; ADR 2026-07-29 R14). */ +function renderGitHygieneHint(git) { + const wip = git?.hygiene?.monitorWip || []; + if (!wip.length) return ''; + return ` +
+
+
Staging hygiene: ${wip.length} untracked plan-monitor file${wip.length !== 1 ? 's' : ''}
+
Stage monitor files add-by-name only; never a broad git add of .cursor/memory/.
+
${wip.map((p) => escapeHtml(p)).join('
')}
+
+ +
+ `; +} + // ===== Copy to clipboard + toast (Phase 1) ===== /** Fallback confirmation. Callers should pass a destination-aware message. */ function copyToastLabel(text) { @@ -4359,7 +5125,9 @@ } throw new Error('Clipboard API unavailable'); } catch { - showToast(`Copy failed - select manually: ${text}`, true); + const raw = String(text || ''); + const preview = raw.length > 120 ? `${raw.slice(0, 117)}...` : raw; + showToast(`Copy failed - select manually: ${preview}`, true); } } @@ -4441,10 +5209,14 @@ return `
- Config + Config Session prefs in .cursor/context/config.json
+
+ + Writes to .cursor/context/config.json via the loopback allowlist. Write path unavailable (server down, non-loopback, read-only deploy)? Use a per-fieldset Copy snippet button and paste into the file by hand. +
Read-only

Onboarded: ${escapeHtml(onboarded)} · Onboarding status: ${escapeHtml(onboardingStatus)}

@@ -4459,7 +5231,7 @@
- 0 disables cooldown (default for named-model runs). On Auto continuous /run-plan, recommend 15000 ms. + 0 disables; recommend 15000 on Auto continuous /run-plan.
@@ -4468,8 +5240,10 @@
- Warn after this many completed /run-plan ticks (or each /run-plan-all queue complete) when unreviewed work remains. Default 3. + Warn when unreviewed work remains after N ticks. Default 3.
+ +
Update check (opt-in) @@ -4480,10 +5254,12 @@
- Notify only. Apply stays via /update Ask confirm. updateApply.auto is never editable here. + Notify only; apply stays via the /update confirm. updateApply.auto is never writable here.
+ +
-
+
Audits (external plan review)
@@ -4502,6 +5278,7 @@ + claude is the only backend today.
@@ -4509,7 +5286,7 @@ - Missing mode keeps paste behavior for existing installs. Autonomous auto-launches a visible review (Phase 2+). + Missing mode keeps paste behavior. Autonomous auto-launches a visible review.
@@ -4524,8 +5301,9 @@ Check owed/untriaged audits before plan-run commands. Missing key = off.
+
-
+
Agent Personas
@@ -4534,19 +5312,17 @@ Chat/CLI tone only. Interface Skins stay in the More menu.
- ${modeRows} +
+ ${modeRows} +
+
-
`; } -async function saveMissionConfig(event) { - if (event && typeof event.preventDefault === 'function') event.preventDefault(); - const btn = document.getElementById('config-save-btn'); - if (btn) btn.disabled = true; - +function collectMissionConfigPayload() { const autoHandoff = !!document.getElementById('config-autoHandoff')?.checked; const cooldownRaw = document.getElementById('config-interTickCooldownMs')?.value; const interTickCooldownMs = Number.parseInt(String(cooldownRaw || '0'), 10); @@ -4587,6 +5363,51 @@ const el = document.getElementById(`config-persona-${mode.id}`); if (el && el.value) payload.agentPersona.modes[mode.id] = el.value; } + return payload; +} + +/* Copy-only CRUD fallback: snippets reflect current form values and go to the + clipboard only. No write surface is added; paste target is config.json. */ +const CONFIG_SNIPPET_GROUPS = { + session: ['autoHandoff', 'interTickCooldownMs', 'fieldReportReviewCadence'], + updateCheck: ['updateCheck'], + audits: ['externalPlanReview'], + personas: ['agentPersona'], +}; + +function copyConfigSnippet(group) { + const keys = CONFIG_SNIPPET_GROUPS[group]; + if (!keys) return false; + const payload = collectMissionConfigPayload(); + const snippet = {}; + for (const key of keys) snippet[key] = payload[key]; + void copyToClipboard(JSON.stringify(snippet, null, 2), { + toastMessage: 'Copied config snippet - paste into .cursor/context/config.json', + }); + return false; +} + +/* Static copy-only snippets for knobs the tab can never write (allowlist unchanged). */ +const CONFIG_STATIC_SNIPPETS = { + updateApply: { updateApply: { auto: false } }, + dogfood: { dogfood: { factoryRoot: '/absolute/path/to/agent-kit-dev' } }, +}; + +function copyStaticConfigSnippet(id) { + const snippet = CONFIG_STATIC_SNIPPETS[id]; + if (!snippet) return false; + void copyToClipboard(JSON.stringify(snippet, null, 2), { + toastMessage: 'Copied config snippet - paste into .cursor/context/config.json (not writable via tab)', + }); + return false; +} + +async function saveMissionConfig(event) { + if (event && typeof event.preventDefault === 'function') event.preventDefault(); + const btn = document.getElementById('config-save-btn'); + if (btn) btn.disabled = true; + + const payload = collectMissionConfigPayload(); try { const res = await fetch('/api/config', { @@ -4620,17 +5441,6 @@ return false; } -// ===== Toggle agent details (Phase 1) ===== -function toggleAgentDetails(id) { - const el = document.getElementById('agent-details-' + id); - if (!el) return; - el.classList.toggle('open'); - const arrow = document.getElementById('agent-arrow-' + id); - if (arrow) { - arrow.style.transform = el.classList.contains('open') ? 'rotate(180deg)' : 'rotate(0deg)'; - } -} - // ===== Healthcenter: per-check expand + Autofix map (copy-only) ===== /** Fixed v1 check set: plans, handoff, agents, commands, memory, git, config. */ const HEALTH_CHECK_META = { @@ -4645,9 +5455,10 @@ autofix: { text: '/continue-plan', subject: '/continue-plan', destination: 'chatInput', label: 'Autofix' }, }, agents: { - okDetail: 'At least one agent is cataloged under .cursor/agents/.', - failDetail: 'No agents found. Kit install or onboard may be incomplete.', - autofix: { text: '/agent-kit-onboard', subject: '/agent-kit-onboard', destination: 'chatInput', label: 'Autofix' }, + okDetail: 'Agents are optional in L0; empty .cursor/agents/ is healthy. Cataloged agents (if any) are listed in Agents.', + // Intentionally unreachable: dashboard-data keeps agents.ok constant-true (L0-optional). + failDetail: 'Intentionally unreachable (agents ok is constant-true; L0-optional inventory).', + autofix: null, }, commands: { okDetail: 'Slash commands are available under .cursor/commands/.', @@ -4671,6 +5482,14 @@ }, }; +/** Vitals grouping: checks grouped into vital systems for the diagnosis dashboard. */ +const HEALTH_VITAL_GROUPS = [ + { label: 'Planning spine', checks: ['plans', 'handoff'] }, + { label: 'Agent surface', checks: ['agents', 'commands'] }, + { label: 'Memory loop', checks: ['memory'] }, + { label: 'Workspace', checks: ['git', 'config'] }, +]; + function healthCheckSeverity(check, aggregateStatus) { if (check && check.ok) return 'ok'; if (aggregateStatus === 'degraded') return 'degraded'; @@ -4678,19 +5497,25 @@ return 'warning'; } +/* Single severity chrome mapping (ok | warning | degraded | error): tone drives + the dot class, label drives text/aria, token names the [data-sev] CSS color. */ +const HEALTH_SEVERITY_CHROME = { + ok: { tone: 'green', label: 'ok', token: 'green' }, + warning: { tone: 'yellow', label: 'warn', token: 'yellow' }, + degraded: { tone: 'orange', label: 'degraded', token: 'orange' }, + error: { tone: 'red', label: 'error', token: 'red' }, +}; + +function healthSeverityChrome(sev) { + return HEALTH_SEVERITY_CHROME[sev] || { tone: 'gray', label: sev || 'unknown', token: 'text-muted' }; +} + function healthSeverityLabel(sev) { - if (sev === 'ok') return 'ok'; - if (sev === 'warning') return 'warn'; - if (sev === 'degraded') return 'degraded'; - if (sev === 'error') return 'error'; - return sev || 'unknown'; + return healthSeverityChrome(sev).label; } function healthDotClass(sev) { - if (sev === 'ok') return 'dot-green'; - if (sev === 'warning') return 'dot-yellow'; - if (sev === 'degraded') return 'dot-yellow'; - return 'dot-red'; + return `dot-${healthSeverityChrome(sev).tone}`; } function toggleHealthCheck(checkId) { @@ -4708,6 +5533,57 @@ } } +// ===== Memory tab: interactive error rows (expand + copy fix prompt) ===== +function toggleMemoryError(idx) { + const card = document.getElementById('memory-error-' + idx); + if (!card) return; + const open = card.classList.toggle('is-expanded'); + const btn = card.querySelector('.memory-error-head'); + if (btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false'); +} + +/** + * One expandable error row: header toggles detail (error / cause / solution / + * files / tags), and a copy-only fix prompt names the chat input as destination. + */ +function renderMemoryErrorRow(entry, idx) { + const title = entry.title || entry.id; + const dateLabel = entry.date || (entry.modifiedAt ? entry.modifiedAt.slice(0, 10) : ''); + const fixPrompt = [ + `Fix this recorded problem class: ${title}`, + entry.error ? `Symptom: ${entry.error}` : '', + entry.solution ? `Known solution direction: ${entry.solution}` : '', + entry.path || `.cursor/memory/errors/${entry.id}.md`, + ].filter(Boolean).join('\n'); + const fields = [ + ['Error', entry.error], + ['Cause', entry.cause], + ['Solution', entry.solution], + ].filter(([, body]) => body); + return ` +
+ +
+ ${fields.map(([label, body]) => ` +
+
${escapeHtml(label)}
+
${escapeHtml(body)}
+
+ `).join('')} + ${entry.files ? `
Files
${escapeHtml(entry.files)}
` : ''} + ${(entry.tags || []).length > 0 ? `
${entry.tags.map(t => `${escapeHtml(t)}`).join('')}
` : ''} +
+ +
+
+
+ `; +} + /** @deprecated Prefer toggleHealthCheck; kept for any leftover callers. */ function showHealthInfo(el, ok) { const parent = el.parentElement; @@ -4797,7 +5673,8 @@ if (!statusDot || !statusLabel) return; const healthStatus = data?.health?.status || 'ok'; - const healthTone = healthStatus === 'ok' ? 'green' : healthStatus === 'warning' ? 'yellow' : 'red'; + const healthChrome = healthSeverityChrome(healthStatus); + const healthTone = healthChrome.tone; if (sseReconnecting) { statusDot.className = 'dot dot-yellow dot-pulse reconnecting-pulse'; @@ -4813,10 +5690,14 @@ statusDot.className = healthStatus === 'ok' ? 'dot dot-green' : `dot dot-${healthTone} dot-pulse`; - statusLabel.textContent = healthStatus === 'ok' ? 'Live' : healthStatus === 'warning' ? 'Warning' : 'Degraded'; + statusLabel.textContent = healthStatus === 'ok' + ? 'Live' + : healthChrome.label.charAt(0).toUpperCase() + healthChrome.label.slice(1); } else { statusDot.className = `dot dot-${healthStatus === 'ok' ? 'yellow' : healthTone}`; - statusLabel.textContent = healthStatus === 'ok' ? 'Polling' : healthStatus === 'warning' ? 'Warning' : 'Degraded'; + statusLabel.textContent = healthStatus === 'ok' + ? 'Polling' + : healthChrome.label.charAt(0).toUpperCase() + healthChrome.label.slice(1); } if (sseTimer) { @@ -4934,6 +5815,7 @@ nextTodo: now.nextTodo?.id || null, modifiedAt: now.modifiedAt, lifecycle: now.lifecycle, + busyOutsidePlan: now.busyOutsidePlan?.active === true, }); } @@ -4998,7 +5880,6 @@ return { scrollTop: contentEl ? contentEl.scrollTop : 0, focusKey, - openAgents: Array.from(document.querySelectorAll('.agent-details.open')).map((el) => el.id), panelScrolls: capturePanelScrollOffsets(), openRecentPlanActionsKey, }; @@ -5006,14 +5887,6 @@ function restoreUiState(state) { if (!state) return; - for (const id of state.openAgents || []) { - const el = document.getElementById(id); - if (!el) continue; - el.classList.add('open'); - const arrowId = id.replace(/^agent-details-/, 'agent-arrow-'); - const arrow = document.getElementById(arrowId); - if (arrow) arrow.style.transform = 'rotate(180deg)'; - } const contentEl = document.getElementById('content'); if (contentEl) { if (pendingAnchorScroll && Date.now() < pendingAnchorScroll.until) { @@ -5096,8 +5969,20 @@ .split('/') .pop(); const enriched = byKey.get(key) || {}; - const completed = raw.todos?.completed ?? enriched.progress?.completed ?? 0; - const total = raw.todos?.total ?? enriched.progress?.total ?? 0; + // Live status bar data: frontmatter to-do items are the source of truth. + // Summary fields only fill in when items are absent (malformed frontmatter). + const items = Array.isArray(raw.todos?.items) ? raw.todos.items : []; + const fromItems = items.length > 0; + const completed = fromItems + ? items.filter((t) => t.status === 'completed').length + : (raw.todos?.completed ?? enriched.progress?.completed ?? 0); + const total = fromItems + ? items.length + : (raw.todos?.total ?? enriched.progress?.total ?? 0); + const inProgress = fromItems + ? items.filter((t) => t.status === 'in_progress').length + : (raw.todos?.inProgress ?? 0); + const nextActionTodo = planNextActionTodo(items); let lifecycle = enriched.lifecycle; if (!lifecycle) { if (total > 0 && completed >= total && (raw.todos?.inProgress || 0) === 0) { @@ -5113,14 +5998,16 @@ overview: raw.overview || enriched.overview || '', modifiedAt: raw.modifiedAt || enriched.modifiedAt || null, progressPct: - typeof raw.progress === 'number' - ? raw.progress - : total > 0 - ? Math.round((completed / total) * 100) + total > 0 + ? Math.round((completed / total) * 100) + : typeof raw.progress === 'number' + ? raw.progress : 0, - progressLabel: enriched.progress?.label || `${completed} of ${total}`, + progressLabel: `${completed} of ${total} complete`, progressCompleted: completed, progressTotal: total, + progressInProgress: inProgress, + nextActionTodo, lifecycle, // /run-plan-all layer from the semantic model (display-only; the panel // never writes the queue). 'none' / null outside queue mode. @@ -5383,21 +6270,26 @@ ${queueRolePill(p)} ${escapeHtml(p.progressLabel)} - + ${escapeHtml(p.overview || '\u2014')} ${fmtDate(p.modifiedAt)} ${pct}% + ${p.progressInProgress > 0 ? `${p.progressInProgress} in progress` : ''} + ${p.nextActionTodo ? `Next: ${escapeHtml(p.nextActionTodo.id)}` : ''}

Narrow panels open one plan at a time. Shift-click a header (or turn Multiple open on) to keep more expanded.

+ ${p.nextActionTodo + ? `
Next action: ${escapeHtml(p.nextActionTodo.id)}${p.nextActionTodo.content ? ` \u2014 ${escapeHtml(p.nextActionTodo.content)}` : ''}
` + : ''}
- + ${planTabActions(p).map((action, idx) => renderPlanTabActionButton(action, p, key, idx)).join('')}
${todos.length > 0 ? `
@@ -5410,7 +6302,7 @@ title="${escapeAttr(pathActionTitle(p.path))}" onclick="${copyRepoPathHandler(p.path)}" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();${copyRepoPathHandler(p.path)}}"> - + ${statusDot(t.status) ? `` : ''} ${escapeHtml(t.id || '')}${t.content ? ` \u2014 ${escapeHtml(t.content)}` : ''} ${escapeHtml(t.status)}
`, @@ -5517,6 +6409,10 @@ * Space-theme Cockpit/nav icon set (inline SVG, no font/fetch/dependency). * Shared chrome SVG shell with header refresh / nowMetaIconSvg: * viewBox 0 0 16 16, stroke 1.5, round caps/joins, CSS box via --mc-chrome-icon-size. + * Legibility floor (holds at every render size, hero included): no feature + * smaller than the 1.5 stroke; stroke-edge clearance between adjacent + * features >= 1 unit. Detail that cannot meet the floor is drawn filled + * (fill="currentColor" stroke="none"), never as a sub-stroke stroked shape. * Static path markup only; never concatenate untrusted text into the SVG. * @param {'current-mission'|'monitor'|'field-report'|'checklist'|'more-sections'|'overview'|'plans'|'activity'|'agents'|'skills'|'skins'|'commands'|'health'|'git'|'memory'|'terminals'|'processes'|'config'} kind * @param {{ decorative?: boolean }} [opts] decorative true (default): aria-hidden next to a visible label. @@ -5546,18 +6442,16 @@ config: 'Config', }; const paths = { - // Rocket (mission) + // Rocket (mission) — window is a solid dot (a stroked ring here would sit below the stroke floor) 'current-mission': '' + - '' + + '' + '', - // Radar (monitor) — concentric rings + sweep wedge (not a gauge needle) + // Radar (monitor) — two rings + sweep wedge; a third ring or centre dot would sit below the stroke floor monitor: '' + - '' + - '' + - '' + - '', + '' + + '', // Clipboard (Flight Log) — matches copy/source affordance; not PTT radio 'field-report': '' + @@ -5567,16 +6461,16 @@ checklist: '' + '', - // Ellipsis (more sections) — radii sized for stroke 1.5 + // Ellipsis (more sections) — solid dots: stroked rings at 4px spacing land on exact tangency 'more-sections': - '' + - '' + - '', - // House (overview / home) — roof + walls + door as one coherent glyph + '' + + '' + + '', + // House (overview / home) — roof + walls + door as one coherent glyph; door interior stays above the stroke floor overview: '' + '' + - '', + '', // Document (plans) plans: '' + @@ -5588,10 +6482,10 @@ agents: '' + '', - // Gear (skills) + // Gear (skills) — ring + 8 spokes outside it; arcs/teeth cut into the ring would sink below the stroke floor skills: '' + - '', + '', // Overlapping swatches (skins) — distinct from Skills gear skins: '' + @@ -5609,11 +6503,11 @@ '' + '' + '', - // Chip (memory) + // Chip (memory) — two internal lines 3 units apart; a third line would drop clearance below the stroke floor memory: '' + '' + - '', + '', // Screen (terminals) terminals: '' + @@ -5735,9 +6629,10 @@ } /** - * Gaps card on Flight Log (Live large / Earlier smaller). Labels locked. + * Gaps card on Flight Log (NOW large / Earlier smaller). Labels locked. * Kind selects Mission Control palette tokens (typed notification chrome). - * @param {{ text?: string, at?: string|null, sourcePath?: string, variant?: string, kind?: string }} entry + * One composed action button per entry (semantic model action; fallback compose). + * @param {{ text?: string, at?: string|null, sourcePath?: string, variant?: string, kind?: string, action?: { label?: string, command?: string, sourcePath?: string } }} entry * @param {number} idx */ function renderFlightLogCard(entry, idx) { @@ -5748,7 +6643,7 @@ ? entry.sourcePath.trim() : '.cursor/HANDOFF.md'; const variant = entry?.variant === 'past' ? 'past' : 'current'; - const label = variant === 'current' ? 'Live' : 'Earlier'; + const label = variant === 'current' ? 'NOW' : 'Earlier'; const kind = flightLogMessageKind(text, { kind: entry?.kind }); const kindClass = flightLogKindClassName(kind); const metaBits = []; @@ -5756,8 +6651,16 @@ metaBits.push(escapeHtml(String(entry.at))); } metaBits.push(escapeHtml(sourcePath)); - const copyTextHandler = copyForPasteHandler(text, 'Gaps text', 'chatInput'); - const copyPathHandler = copyRepoPathHandler(sourcePath); + const action = entry?.action && typeof entry.action === 'object' ? entry.action : null; + const actionLabel = + typeof action?.label === 'string' && action.label.trim() + ? action.label.trim() + : 'Copy fix prompt'; + const actionCommand = + typeof action?.command === 'string' && action.command.trim() + ? action.command + : `${variant === 'current' ? 'Act on these open residuals:' : 'Act on these earlier residuals:'}\n${text}\n${sourcePath}`; + const copyActionHandler = copyForPasteHandler(actionCommand, 'fix prompt', 'chatInput'); const cardClass = variant === 'current' ? `flight-log-card flight-log-card-current ${kindClass}` @@ -5769,8 +6672,7 @@
${escapeHtml(text)}
${metaBits.join(' · ')}
- - +
`; @@ -5778,8 +6680,9 @@ /** * Operator Warning card on Flight Log (API/usage or orchestrator heads-up). - * No Review all / Resolve all / cadence CTAs. - * @param {{ id?: string, kind?: string, title?: string, text?: string, sourcePath?: string }} warning + * No Review all / Resolve all / cadence CTAs. One composed action button + * per entry (semantic model action; fallback compose). + * @param {{ id?: string, kind?: string, title?: string, text?: string, sourcePath?: string, action?: { label?: string, command?: string, sourcePath?: string } }} warning * @param {number} idx */ function renderFlightLogWarningCard(warning, idx) { @@ -5794,8 +6697,17 @@ ? warning.sourcePath.trim() : '.cursor/HANDOFF.md'; const kind = typeof warning?.kind === 'string' ? warning.kind : 'warning'; - const copyTextHandler = copyForPasteHandler(text, 'Warning text', 'chatInput'); - const copyPathHandler = copyRepoPathHandler(sourcePath); + const actionSubject = kind === 'api_limit' ? 'recovery prompt' : 'follow-up prompt'; + const action = warning?.action && typeof warning.action === 'object' ? warning.action : null; + const actionLabel = + typeof action?.label === 'string' && action.label.trim() + ? action.label.trim() + : `Copy ${actionSubject}`; + const actionCommand = + typeof action?.command === 'string' && action.command.trim() + ? action.command + : `${kind === 'api_limit' ? 'Resume after this quota pause:' : 'Act on this heads-up:'}\n${text}\n${sourcePath}`; + const copyActionHandler = copyForPasteHandler(actionCommand, actionSubject, 'chatInput'); const aria = `${title}: ${text}`; return `
@@ -5803,8 +6715,7 @@
${escapeHtml(text)}
${escapeHtml(sourcePath)}
- - +
`; @@ -5812,9 +6723,9 @@ /** * Quiet-state open-triage row (untriaged external review). Prompt chrome; - * per-row Copy triage + path only. No Review all / Resolve all. + * one composed triage-command button per row. No Review all / Resolve all. * Kind class from flightLogKindClassName('prompt') (SoT map; not a literal). - * @param {{ id?: string, label?: string, sourcePath?: string, action?: { target?: string, label?: string, subject?: string } }} item + * @param {{ id?: string, label?: string, sourcePath?: string, action?: { target?: string, label?: string, subject?: string, command?: string } }} item * @param {number} idx */ function renderFlightLogQuietOpenTriageCard(item, idx) { @@ -5831,8 +6742,19 @@ typeof item?.action?.target === 'string' && item.action.target.trim() ? item.action.target.trim() : `/plan-review-triage ${sourcePath}`; - const copyTriageHandler = copyForPasteHandler(triageCmd, 'triage command', 'chatInput'); - const copyPathHandler = copyRepoPathHandler(sourcePath); + const actionSubject = + typeof item?.action?.subject === 'string' && item.action.subject.trim() + ? item.action.subject.trim() + : 'triage command'; + const actionLabel = + typeof item?.action?.label === 'string' && item.action.label.trim() + ? item.action.label.trim() + : 'Copy triage command'; + const actionCommand = + typeof item?.action?.command === 'string' && item.action.command.trim() + ? item.action.command + : triageCmd; + const copyActionHandler = copyForPasteHandler(actionCommand, actionSubject, 'chatInput'); const aria = `Review: ${label}`; const kindClass = flightLogKindClassName('prompt'); return ` @@ -5841,8 +6763,7 @@
${escapeHtml(label)}
${escapeHtml(sourcePath)}
- - +
`; @@ -5922,6 +6843,7 @@ sourcePath: e.sourcePath || sourcePath, variant: 'past', kind: e.kind || null, + action: e.action && typeof e.action === 'object' ? e.action : null, }, idx, ), @@ -5967,11 +6889,15 @@ sourcePath, variant: 'current', kind: typeof fl?.currentKind === 'string' ? fl.currentKind : null, + action: + fl?.currentAction && typeof fl.currentAction === 'object' + ? fl.currentAction + : null, }, 0, ) - : `
- Live + : `
+ NOW
Quiet · nothing live
`; body = `${warningsBlock}
${currentCard}${pastCards}
`; @@ -5995,6 +6921,7 @@
${spaceIconSvg('field-report')}Flight Log + ${renderBusyOutsideChip(d.missionControl?.now)} ${escapeHtml(countLabel)}
${body}
@@ -6092,7 +7019,9 @@ ) .join('\n') : ''; - return `${current}\0${past}\0${warnings}\0${openTriages}`; + // Busy-outside-plan header chip must re-render the card on flip. + const busy = (d.missionControl?.now ?? d.now)?.busyOutsidePlan?.active === true; + return `${current}\0${past}\0${warnings}\0${openTriages}\0${busy ? 'busy' : ''}`; } // Step progress bar: one segment per plan step (completed / current / remaining). @@ -6228,6 +7157,14 @@ `; } +// Live "busy outside the plan" header chip (Current mission + Flight Log). +// Text-only on the advice-family blue tokens; not a Flight Log entry, so the +// Gaps voice, kind, and NOW/Earlier/All clear label contracts stay untouched. +function renderBusyOutsideChip(now) { + if (!now?.busyOutsidePlan?.active) return ''; + return `Busy \u00b7 outside plan`; +} + function renderNowExecutionPanel(d, nowChanged) { const now = d.missionControl?.now || null; const handoff = d.system?.handoff || null; @@ -6246,6 +7183,7 @@ const updatedSource = now?.modifiedAt || handoff?.lastUpdated || null; const statusClass = `now-status now-status-${meta.key}${meta.live ? ' now-status-live' : ''}`; + const busyChip = renderBusyOutsideChip(now); // True idle: no HANDOFF plan reference (or non-mission status). Reserve empty IDLE for that case. if (!showMission) { @@ -6258,6 +7196,7 @@ ${escapeHtml(meta.label)} + ${busyChip}
${renderEmptyStateCta({ @@ -6289,6 +7228,7 @@ ${escapeHtml(meta.label)} + ${busyChip}
${escapeHtml(planName)}
@@ -6465,14 +7405,7 @@ gitDot.className = 'dot dot-green'; } - // Terminals / processes: steady state color only - const termDot = document.getElementById('navTerminalsDot'); - termDot.className = `dot dot-${d.terminals.length > 0 ? 'blue' : 'gray'}`; - - const procDot = document.getElementById('navProcessesDot'); - if (procDot) { - procDot.className = `dot dot-${d.processes.length > 0 ? 'green' : 'gray'}`; - } + // Terminals / processes nav items carry count badges only (no decorative dots). // Build full HTML string once const parts = []; @@ -6520,15 +7453,42 @@ const info = semanticEventInfo(ev.kind, ev.refs?.commitType); const gloss = info.gloss || info.tag || ev.kind || 'event'; const staggerIdx = Math.min(idx, 5); + const actor = crewEventActor(ev); + const initials = agentInitials(actor); const actionAttrs = activityTargetAttributes(ev, 'monitor-activity', { kindGloss: gloss }); const rowA11y = actionAttrs ? actionAttrs : ` aria-label="${escapeAttr(`${gloss}: ${ev.label || ''}`)}"`; + // Structured label spans: actor + verb stay fixed; later segments + // shrink, and the low-signal plan filename ellipsises first. + // Prefer refs.plan over sniffing the rendered string (truncation-safe). + const planRef = ev.refs && ev.refs.plan ? String(ev.refs.plan) : ''; + const planBase = planRef.replace(/\.plan\.md$/i, ''); + const feedSegs = String(ev.label || '').split(' · '); + const feedLabelHtml = feedSegs + .map((seg, i) => { + const isPlanSeg = Boolean(planRef) && ( + seg === planRef || + seg === planBase || + /\.plan\.md/i.test(seg) + ); + const cls = i === 0 + ? 'feed-seg feed-seg-actor' + : i === 1 + ? 'feed-seg feed-seg-verb' + : isPlanSeg + ? 'feed-seg feed-seg-plan' + : 'feed-seg feed-seg-mid'; + return `${escapeHtml(seg)}`; + }) + .join(''); + const feedTitle = ev.labelFull || ev.label || ''; return `
+ - ${escapeHtml(ev.label)} - ${semanticEventTime(ev, info)} + ${feedLabelHtml} + ${crewEventTime(ev, info)}
`; }).join('')}
@@ -6541,7 +7501,7 @@ parts.push(`
- Plans + Plans ${d.plans.length} total
${renderPlansAccordion(d)} @@ -6549,13 +7509,18 @@ `); // ===== Agents ===== + const agents = d.agents || []; + const agentLockSvg = ``; + const agentCreatePrompt = 'Create a new agent definition under .cursor/agents/ (markdown file with a description plus the behavior body). Ask me for the name and behavior first.'; + const agentEditableCount = agents.filter(a => a.kitManaged !== true).length; parts.push(`
- Agents - ${d.agents.length} available + Agents + ${agents.length} agents · ${agentEditableCount} editable +
- ${d.agents.length === 0 + ${agents.length === 0 ? renderEmptyStateCta({ headline: 'No agents aboard', supportHtml: 'Add .md files under .cursor/agents/.', @@ -6568,34 +7533,49 @@ }, }) : ''} -
- ${d.agents.map(a => { - const colors = ['#3b82f6','#a855f7','#22c55e','#eab308','#ef4444','#06b6d4','#f97316','#ec4899']; - const c = colors[a.id.length % colors.length]; - const agentId = a.id.replace(/[^a-zA-Z0-9_-]/g, '_'); +
+ ${agents.map(a => { + const kitManaged = a.kitManaged === true; + const lockBadge = kitManaged + ? `${agentLockSvg}Kit managed` + : ''; + const agentPath = a.path || `.cursor/agents/${a.file || `${a.id}.md`}`; + const usePrompt = `Use this agent definition when it matches the task:\n${agentPath}`; + const useBtn = ``; + const editPrompt = `Edit this agent definition file (markdown body):\n${agentPath}`; + const editBtn = kitManaged + ? '' + : ``; + const deletePrompt = `Delete this agent definition file after confirming with me:\n${agentPath}`; + const deleteBtn = kitManaged + ? '' + : ``; return ` -
- ${escapeHtml(a.id.slice(0, 2).toUpperCase())} - ${escapeHtml(a.id)} - ${escapeHtml(a.description || '')} - \u25bc -
-
-

${escapeHtml(a.description || 'No description available.')}

-
${escapeHtml(a.path || 'Path not available')}
+
+
+ ${escapeHtml(a.id)} + ${lockBadge}
- `; +
${escapeHtml(a.description || 'No description available.')}
+
${escapeHtml(agentPath)}
+
${useBtn}${editBtn}${deleteBtn}
+
+ `; }).join('')}
`); // ===== Commands ===== + const commandLockSvg = ``; + const commandCreatePrompt = 'Create a new slash command under .cursor/commands/ (plain markdown body, no frontmatter). Ask me for the name and behavior first.'; + const commandEditableCount = d.commands.filter(c => c.kitManaged !== true).length; parts.push(`
- Commands - ${d.commands.length} slash commands + Commands + ${d.commands.length} slash commands · ${commandEditableCount} editable +
${d.commands.length === 0 ? renderEmptyStateCta({ @@ -6614,10 +7594,28 @@
${d.commands.map(c => { const slash = `/${c.id}`; + const cmdPath = c.path || `.cursor/commands/${c.file || `${c.id}.md`}`; + const kitManaged = c.kitManaged === true; + const lockBadge = kitManaged + ? `${commandLockSvg}Kit managed` + : ''; + const runBtn = ``; + const editPrompt = `Edit this slash command file (plain markdown body, no frontmatter):\n${cmdPath}`; + const editBtn = kitManaged + ? '' + : ``; + const deletePrompt = `Delete this slash command file after confirming with me:\n${cmdPath}`; + const deleteBtn = kitManaged + ? '' + : ``; return ` -
- - ${escapeHtml(slash)} +
+
+ ${escapeHtml(slash)} + ${lockBadge} +
+
${escapeHtml(cmdPath)}
+
${runBtn}${editBtn}${deleteBtn}
`; }).join('')} @@ -6631,7 +7629,7 @@ const passing = checks.filter(c => c.ok).length; const isSnapshotError = healthStatus === 'error' && checks.length === 0; const isOffline = !isSnapshotError && checks.length === 0; - const presenceTone = healthStatus === 'ok' ? 'green' : healthStatus === 'warning' ? 'yellow' : 'red'; + const presenceTone = healthSeverityChrome(healthStatus).tone; const presencePulse = healthStatus !== 'ok' ? ' dot-pulse' : ''; const presenceLabel = isSnapshotError ? 'Snapshot error' @@ -6679,44 +7677,73 @@ }, }); } else { - healthBody = ` -
- ${checks.map(c => { - const id = c.id || c.label || 'check'; - const meta = HEALTH_CHECK_META[id] || { - okDetail: 'Check is passing.', - failDetail: 'Check is failing. Review configuration for this item.', - autofix: null, - }; - const sev = healthCheckSeverity(c, healthStatus); - const detail = c.ok ? meta.okDetail : meta.failDetail; - const autofix = !c.ok && meta.autofix ? meta.autofix : null; - const autofixBtn = autofix - ? `` - : (c.ok - ? `No Autofix needed.` - : `No Autofix mapped for this check.`); + const checkId = (c) => c.id || c.label || 'check'; + const renderCheckCard = (c) => { + const id = checkId(c); + const meta = HEALTH_CHECK_META[id] || { + okDetail: 'Check is passing.', + failDetail: 'Check is failing. Review configuration for this item.', + autofix: null, + }; + const sev = healthCheckSeverity(c, healthStatus); + const detail = c.ok ? meta.okDetail : meta.failDetail; + const autofix = !c.ok && meta.autofix ? meta.autofix : null; + const fixPrompt = [`Fix this failing health check: ${c.label || id}`, detail].join('\n'); + const fixBtn = ``; + const autofixBtn = autofix + ? `` + : ''; + const actions = c.ok + ? `No action needed.` + : `${fixBtn}${autofixBtn}`; + return ` +
+ +
+

${escapeHtml(detail)}

+
${actions}
+
+
`; + }; + const grouped = HEALTH_VITAL_GROUPS.map(g => ({ + label: g.label, + items: g.checks.map(id => checks.find(c => checkId(c) === id)).filter(Boolean), + })).filter(g => g.items.length > 0); + const knownIds = new Set(HEALTH_VITAL_GROUPS.flatMap(g => g.checks)); + const others = checks.filter(c => !knownIds.has(checkId(c))); + if (others.length > 0) grouped.push({ label: 'Other checks', items: others }); + const vitalsStrip = ` +
+ ${grouped.map(g => { + const total = g.items.length; + const okCount = g.items.filter(c => c.ok).length; + const state = okCount === total ? 'pass' : 'attention'; return ` -
- -
-

${escapeHtml(detail)}

-
${autofixBtn}
-
+
+ ${escapeHtml(g.label)} + ${okCount}/${total} passing + ${state === 'pass' ? 'Pass' : 'Attention'}
`; }).join('')}
`; + healthBody = `${vitalsStrip} + ${grouped.map(g => ` +
+
${escapeHtml(g.label)}
+
+ ${g.items.map(renderCheckCard).join('')} +
+
`).join('')}`; } parts.push(`
- Health ${escapeHtml(subtitle)}
@@ -6724,7 +7751,6 @@
${escapeHtml(presenceLabel)} - · same seven checks · copy-only Autofix
${healthBody}
@@ -6735,9 +7761,8 @@ parts.push(`
- Git - ${escapeHtml(d.git?.branch || '\u2014')} + ${escapeHtml(d.git?.branch || '\u2014')}${d.git?.branch ? (d.git?.dirty ? ` · ${d.git.dirtyCount} changed` : ' · clean') : ''}
${!d.git?.branch ? renderEmptyStateCta({ @@ -6746,28 +7771,15 @@ }) : ''}
+ ${renderGitHygieneHint(d.git)} + ${renderGitFlowCard(d.git)}
-
- Branch - ${escapeHtml(d.git?.branch || '\u2014')} -
-
- Working tree - ${d.git?.dirty ? `${d.git.dirtyCount} file${d.git.dirtyCount !== 1 ? 's' : ''} modified` : 'Clean'} -
Last commit ${escapeHtml(d.git?.lastCommit || '\u2014')}
-
- Ahead of origin/main - ${d.git?.ahead ?? '\u2014'} -
-
- Behind origin/main - ${d.git?.behind ?? '\u2014'} -
+ ${renderGitGraphCard(d.git)} ${d.git?.dirty ? renderGitFileList(d.git) : ''}
@@ -6775,49 +7787,79 @@ // ===== Memory ===== const recentDecisions = d.memory?.recentDecisions || []; + const recentErrors = d.memory?.recentErrors || []; + const errorStats = d.memory?.errorStats || null; parts.push(`
- Memory + Memory ${d.memory.errors || 0} errors \u00b7 ${d.memory.decisions || 0} decisions
-
-
-
- Error Records - -
-
${d.memory.errors || 0}
-
resolved errors in memory
+
+
+
Error-o-meter
+
${errorStats ? errorStats.total : (d.memory.errors || 0)}
+
resolved errors recorded
-
-
- Decisions - +
+
Last 30 days
+
${errorStats ? errorStats.last30d : 0}
+
new error entries
+
+
+
Weekly rate
+
${errorStats ? errorStats.weeklyRate : 0}
+
errors per week (30d avg)
+
+
+
Top tags
+
+ ${errorStats && errorStats.topTags.length > 0 + ? errorStats.topTags.map(t => `${escapeHtml(t.tag)} ×${t.count}`).join('') + : 'no tags recorded'}
-
${d.memory.decisions || 0}
-
architectural decisions
- ${recentDecisions.length > 0 ? ` -
-
- Recent Decisions +
+
+
+ + Healthy memory + ${d.memory.decisions || 0} decisions recorded
-
- ${recentDecisions.map(dd => { - const memPath = dd.path || `.cursor/memory/decisions/${dd.id || dd}.md`; - const memId = dd.id || dd; - return ` -
- - ${escapeHtml(memId)} -
- `; - }).join('')} + ${recentDecisions.length > 0 ? ` +
+ ${recentDecisions.map(dd => { + const memPath = dd.path || `.cursor/memory/decisions/${dd.id || dd}.md`; + const memId = dd.id || dd; + return ` +
+ ${escapeHtml(memId)} +
+ `; + }).join('')} +
+ ` : renderEmptyStateCta({ + headline: 'No decisions yet', + support: 'Accepted tradeoffs land in .cursor/memory/decisions/ as ADR-lite entries.', + compact: true, + })} +
+
+
+ + Recent errors + ${recentErrors.length} of ${d.memory.errors || 0}
+ ${recentErrors.length > 0 + ? recentErrors.map((entry, idx) => renderMemoryErrorRow(entry, idx)).join('') + : renderEmptyStateCta({ + headline: 'No errors recorded', + support: 'Investigated errors with cause and fix land in .cursor/memory/errors/.', + compact: true, + })}
- ` : ''} +
`); @@ -6826,7 +7868,6 @@ parts.push(`
- Terminals ${d.terminals.length} active
@@ -6872,55 +7913,40 @@ parts.push(`
- Processes ${processes.length} running
+
+ Live ps snapshot. Agent chats spawned inside the IDE do not appear here; the Crew monitor on the Overview tab tracks that activity. +
${processes.length === 0 ? renderEmptyStateCta({ headline: 'All quiet', - support: 'No relevant processes running.', + support: 'No relevant processes running. Node, git, and dashboard processes appear here while active.', }) : ` -
- - - - - - - - - - - - - ${processes.map(p => { - const dotClass = p.label === 'dashboard-server' ? 'dot-green dot-pulse' - : p.label === 'node' ? 'dot-blue' - : p.label === 'git' ? 'dot-yellow' - : 'dot-gray'; - const pidStr = String(p.pid ?? ''); - return ` - - - - - - - - - `; - }).join('')} - -
ProcessPIDCPU%MEM%Command
- - - ${escapeHtml(p.label || 'other')} - - ${escapeHtml(pidStr)}${escapeHtml(p.cpu || '\u2014')}${escapeHtml(p.mem || '\u2014')}${escapeHtml(p.command || '\u2014')} - -
+
+ ${processes.map(p => { + const pidStr = String(p.pid ?? ''); + return ` +
+
+ ${escapeHtml(p.label || 'other')} + + + +
+ ${p.description ? `
${escapeHtml(p.description)}
` : ''} +
+ PID ${escapeHtml(pidStr)} + CPU ${escapeHtml(p.cpu || '\u2014')}% + MEM ${escapeHtml(p.mem || '\u2014')}% + ${p.etime ? `up ${escapeHtml(p.etime)}` : ''} +
+
${escapeHtml(p.command || '\u2014')}
+
+ `; + }).join('')}
`}
@@ -6928,19 +7954,15 @@ // ===== Skills ===== const skills = d.skills || []; - const categories = {}; - skills.forEach(s => { - const cat = s.category || 'other'; - if (!categories[cat]) categories[cat] = []; - categories[cat].push(s); - }); - const catColors = ['#a855f7','#3b82f6','#22c55e','#eab308','#f97316','#06b6d4','#ec4899','#ef4444']; - let catIdx = 0; + const skillLockSvg = ``; + const skillCreatePrompt = 'Create a new Agent Skill under .cursor/skills/ (directory with a SKILL.md markdown body). Ask me for the name and behavior first.'; + const skillEditableCount = skills.filter(s => s.kitManaged !== true).length; parts.push(`
- Skills - ${skills.length} discovered + Skills + ${skills.length} skills · ${skillEditableCount} editable +
${skills.length === 0 ? renderEmptyStateCta({ @@ -6955,23 +7977,38 @@ focusKey: 'cta-skills-path', }, }) - : Object.entries(categories).map(([cat, items]) => { - const catColor = catColors[catIdx++ % catColors.length]; - return ` -
-
${escapeHtml(cat)}
-
- ${items.map(s => ` -
- - ${escapeHtml(s.title)} - ${s.description ? `\u2014 ${escapeHtml(s.description)}` : ''} -
- `).join('')} + : ''} +
+ ${skills.map(s => { + const skillFile = s.file || `.cursor/skills/${s.id}/SKILL.md`; + const skillDir = skillFile.replace(/\/SKILL\.md$/, ''); + const kitManaged = s.kitManaged === true; + const lockBadge = kitManaged + ? `${skillLockSvg}Kit managed` + : ''; + const usePrompt = `Read this skill file and follow its instructions:\n${skillFile}`; + const useBtn = ``; + const editPrompt = `Edit this skill file (SKILL.md markdown body):\n${skillFile}`; + const editBtn = kitManaged + ? '' + : ``; + const deletePrompt = `Delete this skill directory after confirming with me:\n${skillDir}`; + const deleteBtn = kitManaged + ? '' + : ``; + return ` +
+
+ ${escapeHtml(s.title)} + ${lockBadge}
+ ${escapeHtml(s.category || 'root')} +
${escapeHtml(skillFile)}
+
${useBtn}${editBtn}${deleteBtn}
`; - }).join('')} + }).join('')} +
`); @@ -7011,7 +8048,7 @@ parts.push(`
- Activity + Activity ${escapeHtml(activityCountLabel)} · Last refreshed ${escapeHtml(relativeTime(d.generatedAt))}
${renderActivityFilterChips()} diff --git a/dashboard/lib/semantic-model.mjs b/dashboard/lib/semantic-model.mjs index 63fb844..436abb7 100644 --- a/dashboard/lib/semantic-model.mjs +++ b/dashboard/lib/semantic-model.mjs @@ -452,6 +452,15 @@ export function serializeFlightLogLedger(ledger) { * @param {string | null | undefined} liveGaps * @param {{ nowMs?: number, sourcePath?: string, pastCap?: number, flightKey?: string | null }} [opts] */ +/** + * Compose one copy-only Flight Log action per the locked composed-command + * spec: dynamic label, a paste-ready command (action prompt, then the + * document path on its own line), and the referenced document path. + */ +function flightLogCopyAction(label, prompt, path) { + return { label, command: `${prompt}\n${path}`, sourcePath: path }; +} + export function observeFlightLog(ledger, liveGaps, opts = {}) { const nowMs = typeof opts.nowMs === "number" && Number.isFinite(opts.nowMs) ? opts.nowMs : Date.now(); @@ -487,6 +496,11 @@ export function observeFlightLog(ledger, liveGaps, opts = {}) { const pastWithKind = past.map((entry) => ({ ...entry, kind: classifyFlightLogMessageKind(entry.text), + action: flightLogCopyAction( + "Copy fix prompt", + `Act on these earlier residuals:\n${entry.text}`, + entry.sourcePath || ".cursor/HANDOFF.md", + ), })); return { ledger: nextLedger, @@ -495,6 +509,13 @@ export function observeFlightLog(ledger, liveGaps, opts = {}) { currentKind: classifyFlightLogMessageKind(current), past: pastWithKind, sourcePath, + currentAction: current + ? flightLogCopyAction( + "Copy fix prompt", + `Act on these open residuals:\n${current}`, + sourcePath, + ) + : null, }, }; } @@ -517,7 +538,7 @@ export const FLIGHT_LOG_QUIET_OPEN_TRIAGES_CAP = 5; * WARNING cards, Review/Resolve CTAs, and Field Report attention kinds. * @param {object|null|undefined} handoff - parseHandoffMarkdown result * @param {{ cap?: number }} [opts] - * @returns {{ id: string, kind: 'api_limit'|'orchestrator_heads_up', severity: 'warning', title: string, text: string, sourcePath: string }[]} + * @returns {{ id: string, kind: 'api_limit'|'orchestrator_heads_up', severity: 'warning', title: string, text: string, sourcePath: string, action: { label: string, command: string, sourcePath: string } }[]} */ export function buildFlightLogWarnings(handoff, opts = {}) { const cap = @@ -545,6 +566,11 @@ export function buildFlightLogWarnings(handoff, opts = {}) { title: "Quota pause", text: truncateStr(text.trim(), MAX_SEMANTIC_LABEL), sourcePath, + action: flightLogCopyAction( + "Copy recovery prompt", + `Resume after this quota pause:\n${truncateStr(text.trim(), MAX_SEMANTIC_LABEL)}`, + sourcePath, + ), }); } @@ -562,6 +588,11 @@ export function buildFlightLogWarnings(handoff, opts = {}) { title: "Heads up", text: truncateStr(text.trim(), MAX_SEMANTIC_LABEL), sourcePath, + action: flightLogCopyAction( + "Copy follow-up prompt", + `Act on this heads-up:\n${truncateStr(text.trim(), MAX_SEMANTIC_LABEL)}`, + sourcePath, + ), }); } } @@ -2142,16 +2173,18 @@ export function formatDeliveryActivity(logLines, { plans = [], limit = MAX_GIT_A /** * Actor segment for Monitor return-brief labels. - * Kit agent id, else orchestrator for delivery, else plan ref, else system. + * Kit agent id, else Engineering Manager for delivery, else Squad when a plan + * is present (never the full plan filename), else Platform Engineer. + * Default software lexicon display masks (resolution kinds unchanged). * @param {string|null|undefined} agent * @param {{ kind?: string, plan?: string|null }} [opts] */ export function briefActivityActor(agent, { kind, plan } = {}) { const kit = normalizeKitAgentId(agent); if (kit) return kit; - if (kind === "delivery") return "orchestrator"; - if (plan) return String(plan); - return "system"; + if (kind === "delivery") return "Engineering Manager"; + if (plan) return "Squad"; + return "Platform Engineer"; } /** @@ -2184,28 +2217,30 @@ export function formatPlanHandoffActivity({ now, handoff, plans }) { if (now?.status === "executing" && now.currentTodo) { const planRef = now.planFile || handoff?.plan || "plan"; const actor = briefActivityActor(agentFromPlan, { kind: "run_plan", plan: planRef }); + const fullLabel = `${actor} \u00b7 running \u00b7 ${now.currentTodo.id} \u00b7 ${planRef}`; events.push({ id: activityId("run_plan", [now.planFile, now.currentTodo.id]), kind: "run_plan", at: now.modifiedAt || null, agent: agentFromPlan, - label: truncateStr( - `${actor} \u00b7 tick \u00b7 ${planRef} \u00b7 ${now.currentTodo.id}`, - MAX_SEMANTIC_LABEL, - ), + label: truncateStr(fullLabel, MAX_SEMANTIC_LABEL), + labelFull: fullLabel, sourcePath: now.planPath || null, refs: { plan: now.planFile, todo: now.currentTodo.id }, }); } else if (now?.status === "awaiting_user") { const planRef = now.planFile || handoff?.plan || "plan"; const actor = briefActivityActor(agentFromPlan, { kind: "handoff", plan: planRef }); - const gate = now.nextTodo?.id ? `next ${now.nextTodo.id}` : "awaiting user"; + const gate = now.nextTodo?.id ? `next ${now.nextTodo.id}` : "user input"; + const visible = `${actor} \u00b7 awaiting \u00b7 ${gate}`; + const fullLabel = `${visible} \u00b7 ${planRef}`; events.push({ id: activityId("handoff", [now.planFile, "awaiting"]), kind: "handoff", at: now.modifiedAt || null, agent: agentFromPlan, - label: truncateStr(`${actor} \u00b7 handoff \u00b7 ${gate}`, MAX_SEMANTIC_LABEL), + label: truncateStr(visible, MAX_SEMANTIC_LABEL), + labelFull: fullLabel, sourcePath: ".cursor/HANDOFF.md", refs: { plan: now.planFile }, }); @@ -2232,15 +2267,15 @@ export function formatPlanHandoffActivity({ now, handoff, plans }) { kind: "agent_step", plan: planRef, }); + const visible = `${actor} \u00b7 ${row.phase} \u00b7 ${row.todo.id}`; + const fullLabel = `${visible} \u00b7 ${planRef}`; events.push({ id: activityId("agent_step", [planRef, row.todo.id, row.phase]), kind: "agent_step", at: now.modifiedAt || null, agent: agentFromPlan, - label: truncateStr( - `${actor} \u00b7 step \u00b7 ${row.todo.id} \u00b7 ${row.phase}`, - MAX_SEMANTIC_LABEL, - ), + label: truncateStr(visible, MAX_SEMANTIC_LABEL), + labelFull: fullLabel, sourcePath: now.planPath || activePlan.path || null, refs: { plan: now.planFile || activePlan.file, todo: row.todo.id, phase: row.phase }, }); @@ -2264,16 +2299,15 @@ export function formatPlanHandoffActivity({ now, handoff, plans }) { const kitAgent = normalizeKitAgentId(plan.agent); const planRef = plan.file || plan.id || "plan"; const actor = briefActivityActor(kitAgent, { kind: "plan_progress", plan: planRef }); - const verb = stillParked ? "parked" : "plan"; + const verb = stillParked ? "parked" : "done"; + const fullLabel = `${actor} \u00b7 ${verb} \u00b7 ${stats.completed}/${stats.total} \u00b7 ${planRef}`; events.push({ id: activityId("plan_progress", [plan.file, stillParked ? "parked" : "done"]), kind: "plan_progress", at: plan.modifiedAt || null, agent: kitAgent, - label: truncateStr( - `${actor} \u00b7 ${verb} \u00b7 ${planRef} \u00b7 ${stats.completed}/${stats.total}`, - MAX_SEMANTIC_LABEL, - ), + label: truncateStr(fullLabel, MAX_SEMANTIC_LABEL), + labelFull: fullLabel, sourcePath: plan.path || null, refs: { plan: plan.file }, }); @@ -2282,6 +2316,13 @@ export function formatPlanHandoffActivity({ now, handoff, plans }) { return events; } +/** + * Explicit run-plan loop lines in terminal output. Shared detection for the + * Crew feed (formatTerminalRunEvidence) and the busy-outside-plan derivation + * so both surfaces agree on what counts as run-loop evidence. + */ +const TERMINAL_RUN_EVIDENCE_RE = /\/run-plan|LOOP_TICK_RESULT|Night shift:.*run-plan/i; + /** * Narrow execution evidence from terminal lastOutput (explicit run-plan lines only). */ @@ -2290,7 +2331,7 @@ export function formatTerminalRunEvidence(terminals, { limit = 3 } = {}) { for (const t of terminals || []) { if (events.length >= limit) break; const out = t?.lastOutput || ""; - if (!out || !/\/run-plan|LOOP_TICK_RESULT|Night shift:.*run-plan/i.test(out)) { + if (!out || !TERMINAL_RUN_EVIDENCE_RE.test(out)) { continue; } const line = @@ -2303,7 +2344,7 @@ export function formatTerminalRunEvidence(terminals, { limit = 3 } = {}) { id: activityId("run_plan", ["term", t.id, line.slice(0, 40)]), kind: "run_plan", at: null, - label: truncateStr(`system \u00b7 tick \u00b7 ${line}`, MAX_SEMANTIC_LABEL), + label: truncateStr(`system \u00b7 running \u00b7 ${line}`, MAX_SEMANTIC_LABEL), sourcePath: null, refs: { terminal: t.id }, }); @@ -2311,6 +2352,35 @@ export function formatTerminalRunEvidence(terminals, { limit = 3 } = {}) { return events; } +/** Freshness window for busy-outside-plan evidence (terminal file mtime). */ +export const BUSY_OUTSIDE_PLAN_FRESH_MS = 10 * 60 * 1000; +/** Cap busy-outside-plan evidence rows (terminal ids only; not rendered as feed). */ +export const MAX_BUSY_OUTSIDE_PLAN_EVIDENCE = 3; + +/** + * "Busy outside the plan" live state. True when the Current mission is not + * executing (idle, awaiting, or completed) yet at least one terminal shows + * fresh run-loop evidence (same detection as the Crew feed) inside the + * freshness window. In-plan execution never raises this flag: the normal + * executing chrome owns that state. Evidence rows are terminal ids plus the + * observed mtime, never terminal output bodies. + */ +export function deriveBusyOutsidePlan({ now, terminals, nowMs = Date.now() } = {}) { + const inactive = { active: false, evidence: [] }; + if (!now || now.status === "executing") return inactive; + const evidence = []; + for (const t of terminals || []) { + if (evidence.length >= MAX_BUSY_OUTSIDE_PLAN_EVIDENCE) break; + const out = t?.lastOutput || ""; + if (!out || !TERMINAL_RUN_EVIDENCE_RE.test(out)) continue; + const ms = Date.parse(t?.updatedAt || ""); + if (!Number.isFinite(ms)) continue; + if (!Number.isFinite(nowMs) || nowMs - ms > BUSY_OUTSIDE_PLAN_FRESH_MS) continue; + evidence.push({ terminal: t.id || null, at: new Date(ms).toISOString() }); + } + return evidence.length > 0 ? { active: true, evidence } : inactive; +} + /** * Merge activity streams newest-first, dedupe by id, bound length. */ @@ -3438,6 +3508,8 @@ function shapeExternalReportItem(report, group) { label: "Copy triage command", subject: "triage command", pasteDestination: "chatInput", + command: `/plan-review-triage ${report.path}`, + sourcePath: report.path, }, }); } @@ -3511,6 +3583,10 @@ export function buildMissionControlView({ { nowMs, todoItems: activeForTiming?.todos?.items || [] }, ); const now = withMissionTiming(nowBase, timing); + // Live "busy outside the plan" flag: fresh run-loop terminal evidence while + // the mission is not executing. Attached to the now slice so the existing + // now fingerprint drives SSE re-render on change. + now.busyOutsidePlan = deriveBusyOutsidePlan({ now, terminals, nowMs }); const { ledger: nextFlightLogLedger, flightLog } = observeFlightLog( parseFlightLogLedger(flightLogLedger), now.gaps, @@ -3594,3 +3670,78 @@ export function buildMissionControlView({ flightLogLedger: nextFlightLogLedger, }; } + +/** Cap for the generated per-process narration line. */ +export const MAX_PROCESS_DESCRIPTION = 160; + +const PROCESS_PORT_RE = /(?:--port[=\s]|PORT=|:)(\d{4,5})\b/; +const PROCESS_GIT_SUB_RE = /\bgit\s+([a-z][a-z-]*)/i; +const PROCESS_NODE_SCRIPT_RE = /(?:^|\s)(?:\S*\/)*([\w.-]+\.(?:mjs|cjs|js|ts))(?:\s|$)/; +const PROCESS_PKG_RUN_RE = /^(npm|pnpm|yarn|bun|npx)\s+([\w:.-]+)/; + +const PROCESS_GIT_ACTIONS = Object.freeze({ + add: "Staging changes", + checkout: "Switching branches", + clone: "Cloning a repository", + commit: "Recording a commit", + diff: "Comparing changes", + fetch: "Fetching updates from the remote", + log: "Reading the commit history", + merge: "Merging branches", + pull: "Pulling updates from the remote", + push: "Pushing commits to the remote", + rebase: "Rebasing commits", + status: "Checking the working tree status", + switch: "Switching branches", +}); + +function describeProcessBase(label, command) { + if (label === "dashboard-server" || /serve\.mjs|node dashboard/.test(command)) { + const port = command.match(PROCESS_PORT_RE); + return port + ? `Serving the Mission Control dashboard on port ${port[1]}` + : "Serving the Mission Control dashboard"; + } + const gitSub = command.match(PROCESS_GIT_SUB_RE); + if (label === "git" || gitSub) { + const sub = gitSub ? gitSub[1].toLowerCase() : null; + if (sub && PROCESS_GIT_ACTIONS[sub]) return PROCESS_GIT_ACTIONS[sub]; + if (sub) return `Running git ${sub}`; + return "Running a git operation"; + } + if (label === "node" || /\bnode\b/.test(command)) { + const script = command.match(PROCESS_NODE_SCRIPT_RE); + if (script) return `Running the ${script[1]} Node script`; + return "Running a Node.js process"; + } + const pkgRun = command.match(PROCESS_PKG_RUN_RE); + if (pkgRun) return `Running ${pkgRun[1]} ${pkgRun[2]}`; + const bin = (command.split(/\s+/)[0] || "").split("/").pop(); + if (bin) return `Running ${bin}`; + return "Running an unrecognized process"; +} + +/** + * Deterministic per-process narration for the Processes tab ("what is it + * doing right now"). Pure heuristics over the ps snapshot fields (label, + * command, cpu, etime); no external calls. One short sentence. + * + * Design choice (accepted): heuristics replace LLM narration for the local + * dashboard (no latency, no API cost, deterministic tests). README/CHANGELOG + * "narrated" / "generated" language means this function, not an AI call. + */ +export function describeProcess(proc) { + const command = String(proc?.command || "").trim(); + const label = String(proc?.label || "other"); + const base = describeProcessBase(label, command); + const cpuRaw = String(proc?.cpu ?? "").trim(); + const cpuNum = Number.parseFloat(cpuRaw); + const hasCpu = cpuRaw !== "" && Number.isFinite(cpuNum); + const signal = !hasCpu ? null : cpuNum >= 50 ? "busy" : cpuNum >= 10 ? "active" : "idle"; + const etime = String(proc?.etime || "").trim(); + const detail = [signal, hasCpu ? `${cpuRaw}% CPU` : null, etime ? `up ${etime}` : null] + .filter(Boolean) + .join(", "); + const text = detail ? `${base} (${detail}).` : `${base}.`; + return truncateStr(text, MAX_PROCESS_DESCRIPTION); +} diff --git a/dashboard/lib/terminal-snapshot.mjs b/dashboard/lib/terminal-snapshot.mjs new file mode 100644 index 0000000..410be13 --- /dev/null +++ b/dashboard/lib/terminal-snapshot.mjs @@ -0,0 +1,127 @@ +/** + * Terminal file parsing for Mission Control snapshots. + * Header meta always comes from the file head; body/output may be tail-capped. + * + * Contract (U5): over-cap files parse meta from the first `TERMINAL_HEAD_META_BYTES` + * (4096) only. If the second `---` falls past that window, `splitTerminalHeader` + * falls back to `min(10, lines)` on the head slice — keep headers compact. + */ + +export const MAX_TERMINAL_BYTES = 64 * 1024; +export const MAX_LAST_OUTPUT_LINES = 15; +export const MAX_LAST_OUTPUT_CHARS = 1200; +/** Enough bytes to cover the YAML-ish header even on noisy files. */ +export const TERMINAL_HEAD_META_BYTES = 4096; + +/** + * Split a Cursor terminal dump into header lines (file start) and body lines. + * Header ends after the second `---` line, or after 10 lines if missing. + */ +export function splitTerminalHeader(raw) { + const text = String(raw ?? ""); + const lines = text.split("\n"); + let headerEnd = 0; + let dashCount = 0; + const scanLimit = Math.min(lines.length, 40); + for (let i = 0; i < scanLimit; i++) { + if (lines[i].trim() === "---") { + dashCount++; + if (dashCount === 2) { + headerEnd = i + 1; + break; + } + } + } + if (headerEnd === 0) headerEnd = Math.min(10, lines.length); + return { + headerLines: lines.slice(0, headerEnd), + bodyLines: lines.slice(headerEnd), + headerEnd, + }; +} + +/** Parse pid/cwd/command/exit from header lines (or first 15 of a head slice). */ +export function parseTerminalMeta(headerLines) { + const meta = {}; + const lines = Array.isArray(headerLines) ? headerLines : String(headerLines ?? "").split("\n"); + for (const line of lines.slice(0, 15)) { + if (line.startsWith("pid:")) meta.pid = line.slice(4).trim(); + if (line.startsWith("cwd:")) meta.cwd = line.slice(4).trim(); + if (line.startsWith("command:")) meta.lastCommand = line.slice(8).trim(); + if (line.startsWith("last_command:")) meta.lastCommand = line.slice(13).trim(); + if (line.startsWith("last_exit_code:")) meta.lastExitCode = line.slice(15).trim(); + } + return meta; +} + +/** + * Tail-cap body lines by approximate UTF-16 byte budget so lastOutput stays fresh + * without dropping the file-head meta. + */ +export function tailCapBodyLines(bodyLines, maxBytes = MAX_TERMINAL_BYTES) { + const lines = Array.isArray(bodyLines) ? bodyLines : []; + if (lines.length === 0) return []; + const joined = lines.join("\n"); + if (joined.length <= maxBytes) return lines; + const tail = joined.slice(-maxBytes); + // Drop a partial first line after the byte cut. + const cut = tail.indexOf("\n"); + const cleaned = cut >= 0 ? tail.slice(cut + 1) : tail; + return cleaned.split("\n"); +} + +/** + * Last N non-empty body lines, char-capped. Caller supplies already-capped body lines + * and an optional redact(text) → text. + */ +export function extractLastOutputFromBody(bodyLines, options = {}) { + const maxLines = options.maxLines ?? MAX_LAST_OUTPUT_LINES; + const maxChars = options.maxChars ?? MAX_LAST_OUTPUT_CHARS; + const redact = typeof options.redact === "function" ? options.redact : (t) => t; + const truncate = + typeof options.truncate === "function" ? options.truncate : (t, n) => String(t).slice(0, n); + + const filtered = bodyLines.filter((l) => l.trim() && !l.startsWith("---")); + if (filtered.length === 0) return null; + + const tail = filtered.slice(-maxLines); + let text = redact(tail.join("\n")); + text = truncate(text, maxChars); + return text?.trim() ? text : null; +} + +/** + * Build terminal snapshot fields from a raw terminal file. + * Meta always from head; lastOutput/outputLines from tail-capped body. + * Large files use a head window + tail window before any split/join so peak + * memory stays ~head+tail instead of a full-file line array (T7/T8). + */ +export function buildTerminalSnapshotFields(raw, options = {}) { + const maxBytes = options.maxBytes ?? MAX_TERMINAL_BYTES; + const headBytes = options.headMetaBytes ?? TERMINAL_HEAD_META_BYTES; + const text = String(raw ?? ""); + + let headerLines; + let cappedBody; + + if (text.length <= headBytes + maxBytes) { + const split = splitTerminalHeader(text); + headerLines = split.headerLines; + cappedBody = tailCapBodyLines(split.bodyLines, maxBytes); + } else { + const head = text.slice(0, headBytes); + const split = splitTerminalHeader(head); + headerLines = split.headerLines; + // Windowed path: tail is exactly maxBytes, so tailCapBodyLines' `<= maxBytes` + // short-circuit would skip the partial-first-line trim. Drop it here first (U4). + const tail = text.slice(-maxBytes); + const cut = tail.indexOf("\n"); + const cleaned = cut >= 0 ? tail.slice(cut + 1) : tail; + cappedBody = cleaned.split("\n"); + } + + const meta = parseTerminalMeta(headerLines); + const outputLines = cappedBody.filter((l) => l.trim() && !l.startsWith("---")).length; + const lastOutput = extractLastOutputFromBody(cappedBody, options); + return { meta, outputLines, lastOutput, headerLines, bodyLines: cappedBody }; +} diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 780b39f..dd05790 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -17,7 +17,7 @@ New to the kit? Here's where things land and how to test before your PR: - **Skills:** Community contributions go under `registry/skills/community//SKILL.md` - see the [new skill vs improving existing](#new-skill-vs-improving-an-existing-one) section below - **Core changes:** CLI features, base rules, and templates live in their respective folders (`packages/cli/`, `.cursor/`, etc.) - **Test locally:** `pnpm install && pnpm lint && pnpm test` from the repo root -- **Mission Control pack (Path C):** After the CLI publish that ships it, `@dadado/agent-kit-cli` includes `dashboard/**` in the npm tarball (synced from repo-root SoT at build/`prepack`). Do not assume the current published tag already has it; check [CHANGELOG](../CHANGELOG.md) Unreleased / the release notes. Local pack check: `node scripts/verify-cli-dashboard-pack.mjs`. Version bump stays `/git-prod` HITL ([npm-publish-checklist.md](npm-publish-checklist.md)). +- **Mission Control pack (Path C):** `@dadado/agent-kit-cli` includes `dashboard/**` in the npm tarball from 4.8.2 onward (synced from repo-root SoT at build/`prepack`). Local pack check: `node scripts/verify-cli-dashboard-pack.mjs`. To confirm a published tag, run `npm pack @dadado/agent-kit-cli@` and inspect the tarball for `package/dashboard/`. Version bump stays `/git-prod` HITL ([npm-publish-checklist.md](npm-publish-checklist.md)). See [getting-started.md](getting-started.md) for the full development setup and workflow details. @@ -49,6 +49,27 @@ Other local CLI commands follow the same pattern: pnpm --filter @dadado/agent-kit-cli start -- status --cwd /path/to/your-project ``` +### Factory self-consumer (local apply loop) + +This repo can act as its own consumer to validate L0 changes before a public release. This is distinct from the public consumer update-check and from the public sync mirror. + +1. **Build the CLI** from the current source: + ```bash + pnpm --filter @dadado/agent-kit-cli build + ``` +2. **First seed** (only when `.cursor/agent-kit.managed-hashes.json` is absent): + ```bash + pnpm --filter @dadado/agent-kit-cli start -- update --cwd . --seed-overlay + ``` + `--seed-overlay` records current local overlay files as the managed baseline so future updates can distinguish kit drift from local customization. +3. **Subsequent local refreshes**: + ```bash + pnpm --filter @dadado/agent-kit-cli start -- update --cwd . + ``` + The factory checkout resolves its own `registry/` as the source (cwd has `registry/registry.json`). The public update-check is skipped because the registry is local (`skipped-factory`). + +Do not use this path in a public consumer project; consumers should rely on the public release tag and `/update` HITL. + ## Standards - Conventional Commits diff --git a/docs/README.md b/docs/README.md index 8a62882..66fff60 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@ Agent Kit is a HITL framework for AI-assisted IDEs: plan, handoff, staging-to-pr - [Contribute upstream](contribute-upstream.md) - `agent-kit contribute` return channel + gate - [Public launch](public-launch.md) - go/no-go + append-only sync - [Public launch announcement](public-launch-announcement.md) - copy-paste launch text (chat / social) +- [Agent Kit landing](agentkit-landing.md) - public marketing page at startupkit.com.br/agentkit - [Topology private × public](topology-private-public.md) - Fase 7 registry-canonical public - [Marketplace catalog](marketplace.md) - versioning, CLI add, Cursor plugin, quality gate - [Review layers](review-camadas.md) - final HITL / go-no-go pass @@ -18,11 +19,14 @@ Agent Kit is a HITL framework for AI-assisted IDEs: plan, handoff, staging-to-pr - [Creating Agent Personas](creating-personas.md) - persona pack format, placement, contribute checklist - [Agent Personas contract](personas-contract.md) - persona pack schema, mode defaults, acceptance rules (also summarized in the root [README Features](../README.md#features)) - [External plan review](external-plan-review.md) - opt-in Claude Code monitor after plan exhaustion +- [Consumer configuration](consumer-configuration.md) - every consumer knob (session config, skin, install choices, CLI flags/env) with copy snippets +- Config tab write verification - durable allowlist PATCH matrix for Mission Control Config (private factory evidence under `docs/evidence/`; not public-synced) - [Cursor 3.0 Features](cursor-3-features.md) - how Agent Kit uses native IDE features - [Cursor-native audit](cursor-native-audit.md) - hooks.json, plugin, rule modes, VS Code/Windsurf gaps - [Coherence inventory](coherence-inventory.md) - classification of rules, skills, hooks, agents, commands - [Drift inventory](drift-inventory.md) - per-workspace kit copies, L0 candidates, L3 uniques - [Layers specification](layers-spec.md) - L0–L3 model, precedence, nomenclature +- [Five-layer claim matrix](five-layer-claim-matrix.md) - public five-layer positioning (core / optional / planned / unsupported) - [Domain packs (L1)](domain-packs.md) - seven discipline packs and membership - [Agent Kit manifest](agent-kit-manifest.md) - `.cursor/agent-kit.json` schema (version, packs, protected L3) - [Repository Boundaries](repository-boundaries.md) - three-layer cheat sheet (local / private / public), npm, sync diff --git a/docs/agent-kit-manifest.md b/docs/agent-kit-manifest.md index 53dee5c..e9ab61c 100644 --- a/docs/agent-kit-manifest.md +++ b/docs/agent-kit-manifest.md @@ -6,14 +6,15 @@ The commands read it directly: `update` uses it to refresh the right files, `dif **Machine-readable schema:** [schemas/agent-kit.manifest.schema.json](../schemas/agent-kit.manifest.schema.json). Layer model behind the fields: [layers-spec.md](layers-spec.md). -## Two files under `.cursor/` +## Three files under `.cursor/` -| File | Role | Written by | -|------|------|------------| -| **`agent-kit.json`** | The manifest (this doc): what's installed | `install` / `add` / `update`; bootstrap / `@install.md` | -| **`agent-kit.config.json`** | Guided-setup **profile** (stack, IDE, git workflow) | `agent-kit init` | +| File | Role | Written by | Commit? | +|------|------|------------|---------| +| **`agent-kit.json`** | The manifest (this doc): what's installed | `install` / `add` / `update`; bootstrap / `@install.md` | **Yes** (version with the project) | +| **`agent-kit.config.json`** | Guided-setup **profile** (stack, IDE, git workflow) | `agent-kit init` | **No** (typically gitignored; local profile) | +| **`agent-kit.managed-hashes.json`** | Managed-content hash ledger for the consumer overlay (last kit-owned content under agents / skills / commands) | `install` / `add` / `update` when overlay paths are applied | **Yes** (recommended): commit so the ledger survives clone and teammates do not re-trigger first-update preserve/seed ambiguity | -They're separate on purpose: the profile drives the guided setup; the manifest drives updates against the kit's source. +The profile and the manifest stay separate on purpose: the profile drives guided setup; the manifest drives updates against the kit's source. The ledger is kit-written state for overlay preservation, not a substitute for either. ## Fields @@ -46,7 +47,9 @@ Every install should protect session and project-unique state (also gitignored w Do **not** protect the whole `.cursor/context/**` tree: kit L0 ships `templates/**` and `config.example.json` there. Older manifests that listed `.cursor/context/**` are normalized on `install`/`update` to the session globs above. -Add your project's own rules/skills/commands as extra patterns or `overrides` entries. Don't list a kit file as protected just to keep a local edit - that quietly forks it. Use an override or contribute the change upstream instead. +Do **not** blanket-protect `.cursor/agents/**`, `.cursor/skills/**`, or `.cursor/commands/**`: that blocks pack and `agent-kit add` installs. User-added basenames in those trees already survive update; kit-owned files with local drift are preserved via the consumer overlay (managed-content hashes in `.cursor/agent-kit.managed-hashes.json`). Prefer distinct basenames or `overrides` for intentional forks; use `diff` / contribute when you want upstream to absorb a local edit. See [layers-spec.md](layers-spec.md) and decision `2026-07-29_consumer-l0-overlay-agents-optional.md`. + +Add your project's own domain rules as extra `protected` patterns or `overrides` entries when they live outside the overlay trees. ## Example diff --git a/docs/agentkit-landing.md b/docs/agentkit-landing.md new file mode 100644 index 0000000..8748344 --- /dev/null +++ b/docs/agentkit-landing.md @@ -0,0 +1,38 @@ +# Agent Kit Public Landing (agent.startupkit.com.br) + +**New domain:** [https://agent.startupkit.com.br](https://agent.startupkit.com.br) (static deployment) +**Previous:** `startupkit.com.br/agentkit` (WordPress page ID 3001, deprecated due to wpautop issues) + +**Design system:** STK visual identity + Mission Control tokens (`dashboard/dashboard.html`) +**Source of record:** `.cursor/context/landing-agentkit/page-content.html` (complete HTML/CSS/assets) + +**Product claims:** track shipped **4.8.4** +**Positioning:** human-in-the-loop framework (ADR `2026-07-09_framework-hitl-positioning.md`) +**Copy style:** STK conversational tone adapted for developers + +## Deployment Configuration + +### Static Site Setup +- **Hosting:** Static files (HTML/CSS/JS) on CDN or dedicated hosting +- **DNS:** Configure `agent.startupkit.com.br` CNAME or A record +- **Assets:** Self-contained in `page-content.html` (no external dependencies) +- **SSL:** Required for production deployment + +### Domain Migration Strategy +```bash +# From WordPress setup +OLD: startupkit.com.br/agentkit (WordPress page ID 3001) +NEW: agent.startupkit.com.br (clean static deployment) + +# DNS Configuration +Type: CNAME or A record +Host: agent +Domain: startupkit.com.br +Target: [hosting provider endpoint] +``` + +### SEO Configuration +- **Title:** Agent Kit — Plan, build, ship, and remember AI coding projects +- **Meta description:** Stop losing context between AI chats. Agent Kit provides checkable plans, smart handoffs, and staging-first git workflow for long coding projects. +- **H1 hierarchy:** Page title → Hero H2 → Section headers +- **OpenGraph:** Include for social sharing (title, description, image) diff --git a/docs/bootstrap.md b/docs/bootstrap.md index 14889c3..7907eb1 100644 --- a/docs/bootstrap.md +++ b/docs/bootstrap.md @@ -65,16 +65,27 @@ The kit can update itself against the same source without ever touching your pla |---------|------| | `agent-kit add ` | Add a pack or skill | | `agent-kit update --check` | **Notify only:** compare installed version to the latest public release tag (no L0 writes) | -| `agent-kit update` | Explicit apply: refresh installed rules/commands; skips protected files | +| `agent-kit update` | Explicit apply: refresh installed rules/commands; skips protected files; preserves customized agents/skills/commands overlay | | `agent-kit diff` | Show what's changed vs the latest | | `agent-kit status` | Version, installed packs, readiness summary | | `agent-kit doctor` | Refresh or repair repository readiness | **Check ≠ apply.** Opt-in session nudges use `updateCheck.enabled` in `.cursor/context/config.json` (default `false`). When enabled, `sessionStart` may advise that a newer public release exists; it never rewrites `.cursor/`. Applying still requires `/update` with Ask confirmation (or an explicit terminal `agent-kit update`). `updateApply.auto` defaults to `false` and is not a silent background path. +**Cursor product updates (separate):** opt-in `cursorUpdateCheck.enabled` (default `false`) plus `agent-kit cursor-awareness --check` / `/cursor-update-awareness` report advisory gaps vs `docs/cursor-native-audit.md`. Confirmed work routes through Ask → `/backlog-add` or `/dogfood`. Never auto Field Reports. See [cursor-update-awareness.md](cursor-update-awareness.md). + Factory/dogfood installs (manifest registry URL `agent-kit-dev` or pre-prod refs such as `staging`) skip the public check with a warning so the factory is not treated as a consumer. -This path is distinct from **public sync** (factory → public mirror) and from **remote-cache auto-refresh** (refreshing a cloned registry tree on resolve). +This path is distinct from three other lanes: + +| Lane | Direction | Trigger | L0 writes? | +|---|---|---|---| +| **Public consumer update** | public release → consumer project | `agent-kit update` or `/update` HITL | Yes, explicit | +| **Factory self-consumer** | factory source → factory `.cursor/` | `agent-kit update --cwd . --seed-overlay` (first), then `agent-kit update --cwd .` | Yes, local loop | +| **Public sync** | factory → public mirror | `v*` tag CI or manual workflow | No L0 writes; opens public PR | +| **Remote-cache refresh** | registry git → local cache | `--refresh` on resolve | No; refreshes cache before apply | + +The factory self-consumer loop is documented for maintainers in [CONTRIBUTING.md](CONTRIBUTING.md). ## Moving off an old nested copy diff --git a/docs/capability-inventory.md b/docs/capability-inventory.md new file mode 100644 index 0000000..62fadad --- /dev/null +++ b/docs/capability-inventory.md @@ -0,0 +1,403 @@ +# Capability inventory + +Agent Kit capability catalog grouped by surface family. Lists every shipped capability with one line per item. + +**Status (2026-07-31):** Capability counts verified against the working tree on private `staging` @ `7e5315d` (package floor `4.8.4`). Normative intent lives in commands/rules/CLI; this catalog is **derived documentation** and is not proof of runtime behavior. Evidence lanes: `docs/evidence/artifact-ledger-summary.md`, `docs/evidence/delivery-reconciliation.json` (RC-003/RC-004). Five-layer README positioning claims: `docs/evidence/five-layer-claim-matrix.md`. + +Real counts: 27 commands, 25 rules, 13 agents, 9 skills, 5 Cursor hooks, 18 CLI commands (plus 5 subsystems), 7 packs, 3 personas, Mission Control dashboard, Git hooks, root scripts, and auxiliary tooling. Positioning table below lists **25 unique paths / 89 literal rows** (see §Positioning surfaces) and is an open enumeration (not a closed surface census); the npm storefront `packages/cli/README.md` is a required surface. + +--- + +## Slash commands (.cursor/commands/ - 27) + +- `/start-project` - Plan creation with two-gate HITL (broad intake, write confirm, optional Gate B start unit) +- `/backlog-add` - Enqueue plan under HANDOFF Backlog without activation +- `/backlog-edit` - Edit backlog plan markdown after confirmation +- `/backlog-delete` - Remove from HANDOFF Backlog and archive plan file +- `/backlog-cancel` - Soft-cancel open to-dos, drop Backlog row, keep plan file +- `/agent-kit-onboard` - Repository readiness check with essential/non-essential pillars +- `/continue-plan` - Resume plan from HANDOFF state, confirm next unit, execute one phase +- `/run-plan` - Continuous execution mode with orchestrated or in-session loop strategies +- `/run-plan-loop` - In-session continuous execution without Task dispatch +- `/run-plan-orchestrated` - Task dispatch strategy for continuous execution +- `/run-plan-all` - Multi-plan queue orchestration with dispatched workers per plan +- `/hotfix` - Narrow urgent mini-plan with immediate run-plan execution +- `/handoff` - Manual context handoff with preference setting +- `/summary` - Session summary with takeaways +- `/dashboard` - Mission Control UI for plans, queue, monitors +- `/dashboard-broadcast` - Broadcast updates across Mission Control sessions +- `/tips` - UX helper tips for Agent Kit usage +- `/update` - Consumer-mode layer update from public registry +- `/cursor-update-awareness` - Advisory Cursor product-update check (changelog + inventory; HITL conveyor) +- `/git-staging` - Staging branch promotion with CHANGELOG and MR workflow +- `/git-prod` - Production promotion from staging with HITL confirmation +- `/plan-external-review` - External plan review launcher with audit modes +- `/plan-review-triage` - Triage choice after external review (residuals/fixes/ack) +- `/field-report-resolve` - Resolve Field Report findings with structured closure +- `/archive-plan` - Move parked plan to archive with status update +- `/context-status` - Context window and memory status report +- `/dogfood` - File a private dogfood note into the factory or consumer inbox + +--- + +## Rules (.cursor/rules/ - 25) + +### Core rules (always applied) +- `cursor-plan-handoff.mdc` - Multi-phase plan execution with HANDOFF contract +- `context-guardian.mdc` - Context window monitoring with automatic handoff +- `cursor-skills-git-workflow.mdc` - Staging-to-production Git flow enforcement +- `cursor-skills-general.mdc` - Base CURSOR-SKILLS principles and conventions +- `ux-tone.mdc` - Chat tone guidelines with persona chrome support +- `agent-output-hygiene.mdc` - Chat vs repository content separation +- `docs-professional-standard.mdc` - Project documentation voice and inheritance standard +- `memory-loop.mdc` - Cross-chat learning persistence in .cursor/memory/ +- `hitl-ask-questions.mdc` - Human-in-the-loop confirmations via Ask questions tool +- `git-secrets-safety.mdc` - Git commit safety with secrets validation + +### Stack rules (requestable) +- `cursor-skills-clickup.mdc` - ClickUp integration conventions +- `cursor-skills-n8n.mdc` - n8n workflow editing patterns +- `cursor-skills-api.mdc` - REST/GraphQL API development standards +- `cursor-skills-devops.mdc` - CI/CD and infrastructure guidance +- `cursor-skills-groovy.mdc` - Groovy syntax and integration rules +- `cursor-skills-integrations.mdc` - Webhooks, microservices, database patterns +- `cursor-skills-json.mdc` - JSON validation and formatting standards +- `cursor-skills-mobile.mdc` - React Native, Flutter, Expo development +- `cursor-skills-node.mdc` - Node.js, Express, NestJS, Next.js standards +- `cursor-skills-php.mdc` - PHP, Laravel, Symfony, WordPress conventions +- `cursor-skills-prompts.mdc` - Agent prompt creation and versioning +- `cursor-skills-python.mdc` - Python, Django, Flask, FastAPI standards +- `cursor-skills-sql.mdc` - SQL and database schema patterns +- `cursor-skills-testing.mdc` - Testing conventions and QA routines +- `cursor-skills-webdesign.mdc` - HTML, CSS, JavaScript, React standards + +--- + +## Named subagents (.cursor/agents/ - 13) + +- `cleancode-refactor` - Architecture and readability refactoring +- `clickup-tasks` - ClickUp task creation and management via MCP +- `context-librarian` - Working memory summarization and Context Pack management +- `docs-repo` - README, ADR, and repository documentation maintenance +- `git-autogit` - Staging-to-production Git workflow automation (dogfood-only) +- `json-guardian` - JSON validation and normalization (demoted to skill-first) +- `memory-extractor` - Cross-session learning extraction and deduplication +- `n8n-workflows` - n8n workflow editing and documentation (demoted to skill-first) +- `prompts-agents` - Agent prompt creation in Markdown (demoted to skill-first) +- `security-reviewer` - Security review for auth, PII, secrets, injection +- `sql-schema` - SQL schema creation and modification (demoted to skill-first) +- `tech-lead` - Technology decisions, ADRs, architecture tradeoffs +- `test-suites` - Test suite maintenance and E2E testing + +--- + +## Skills (.cursor/skills/ - 9) + +### Core skills (2) +- `clean-code` - AI code slop removal and clean patterns +- `docs-repo` - Repository documentation with professional standard + +### Community skills (7) +- `clickup` - ClickUp task management via MCP +- `cursor-skills-node` - Node.js development standards +- `json-data-config` - JSON validation, formatting, manipulation +- `n8n-workflows` - n8n workflow creation and editing +- `prompts-markdown` - Agent prompt structure and versioning +- `sql-postgres` - PostgreSQL schema and query patterns +- `ux-message-flows` - Conversational UX for chat agents + +--- + +## Cursor-native hooks (.cursor/hooks.json - 5) + +- `sessionStart` - Session initialization with HANDOFF reading +- `preCompact` - Context window warning before compaction +- `beforeShellExecution` - Shell command validation and safety +- `afterFileEdit` - Schema validation after file edits +- `beforeSubmitPrompt` - Secrets detection before prompt submission + +--- + +## CLI commands (packages/cli/src/commands/ - 18) + +- `add` - Add new components to Agent Kit installation +- `contribute` - Contribution workflow helpers +- `cursor-awareness` - Advisory Cursor product-update awareness check (`cursorUpdateCheck`) +- `dashboard-broadcast` - Mission Control broadcast management +- `dashboard` - Mission Control UI server +- `diff` - Compare Agent Kit versions and changes +- `doctor` - Installation health check and diagnostics +- `guard` - Safety validation for various operations +- `handoff` - Context handoff utilities +- `hook` - Hook management and installation +- `init` - Initialize new Agent Kit installation +- `install` - Install Agent Kit components from registry +- `monitors` - Plan monitor management and status +- `run-plan` - Plan execution orchestration +- `scan` - Workspace scanning and analysis +- `status` - Agent Kit installation status +- `update` - Update Agent Kit from registry +- `validate` - Validation utilities for various formats + +### CLI subsystems (5) + +- Manifest system - Agent Kit installation tracking +- Registry system - Component distribution and versioning +- Dashboard system - Mission Control UI with React components +- Plan-loop system - Continuous execution orchestration +- Lifecycle system - L0-L3 layer management + +--- + +## Mission Control sections (dashboard/dashboard.html - 12) + +Top navigation carries the primary sections; the rest are reachable from the More menu. + +- `overview` (Home) - Cockpit landing grouped into Now Execution, Attention, and Recent Plans +- `plans` - Active, backlog, parked, and archived plan management +- `activity` - Unified feed of plan, git, and audit events +- `agents` - Installed named subagents and their routing signals +- `commands` - Installed slash commands +- `skills` - Installed skills by layer +- `processes` - Running plan loops and background jobs +- `terminals` - Terminal session inspection +- `git` - Branch state, staging-to-production position, and recent commits +- `health` - Health Center checks and installation diagnostics +- `memory` - Errors, decisions, monitors, and audits from `.cursor/memory/` +- `config` - Settings, persona selection, and run-plan configuration + +--- + +## Registry packs (registry/packs/ - 7) + +- `clean-code` - Code quality and simplification tools +- `context-management` - Advanced context and memory tools +- `cybersec` - Security review and hardening tools +- `devops` - CI/CD and infrastructure tools +- `engineering-architecture` - ADRs and technical decision tools +- `project-management` - Optional PM tool integrations +- `quality` - Testing and QA workflow tools + +--- + +## Agent personas (registry/personas/core/ - 3) + +- `autopilot` - Cockpit checklist chrome for manual /continue-plan ticks +- `ghost-runner` - Stealth CLI chrome for headless agent-kit run-plan ticks +- `night-shift` - Late-shift chat chrome for continuous /run-plan loops + +--- + +## Audit and review tooling + +- Plan monitor system - External review orchestration with autonomous/paste modes +- Field Report system - Structured findings tracking with cadence management +- Plan-review-triage - Post-audit resolution workflow with residuals handling +- Memory-loop integration - Learning persistence across chat sessions +- Git workflow spine - Staging-to-production promotion with audit gates + +--- + +## Git hooks (git-hooks/ - 3) + +- `pre-commit` - Block direct commits to main/master branches +- `pre-push` - Block direct pushes to main/master and protect v* tags +- `prepare-commit-msg` - Remove Co-authored-by trailer from Cursor + +--- + +## Root scripts (scripts/ - 11) + +- `build-registry.mjs` - Build registry/registry.json from skills and packs +- `build-registry.sh` - Legacy shell version of registry builder +- `new-skill.sh` - Skill generator with SKILL.md template +- `plan-external-review.sh` - Thin forwarder to the implementation in `.cursor/scripts/` +- `plan-loop.sh` - Thin wrapper around agent-kit run-plan CLI +- `run-plan-all-consolidate.sh` - Thin forwarder to the implementation in `.cursor/scripts/` +- `sync-cli-dashboard.mjs` - Sync dashboard SoT into CLI package +- `sync-public.mjs` - Sync private repo to public mirror +- `trigger-public-sync-after-prod.sh` - GitHub Actions workflow trigger +- `verify-cli-dashboard-pack.mjs` - Verify CLI pack includes dashboard files +- Config files: `public-sync.manifest`, `public-sync.denylist` + +--- + +## Agent Kit scripts (.cursor/scripts/ - 3) + +Canonical implementations. The same-named files under `scripts/` are thin forwarders, not copies. + +- `field-report-cadence-bump.sh` - Field Report activity ledger and cadence warnings +- `plan-external-review.sh` - External plan review launcher (autonomous, paste, and wait-monitor modes) +- `run-plan-all-consolidate.sh` - Multi-plan queue consolidation + +--- + +## Templates (.cursor/context/templates/ - 9) + +- `adr.md` - Architectural decision record template +- `checklist-n8n.md` - n8n workflow checklist template +- `command-worker-prompt.md` - Worker subagent instruction template +- `context-pack.md` - Working memory and session state template +- `handoff.md` - Context handoff between agents template +- `plan-external-review-prompt.md` - External review prompt template +- `plan-monitor.md` - Plan monitoring and audit template +- `plan.md` - Structured plan creation template +- `task-brief.md` - Task specification and brief template + +--- + +## Recent capability delta (since 4.4.0) + +Capabilities shipped since the 4.4.0 anchor (2026-07-21) through [Unreleased], organized by theme. The anchor represents the last deliberate identity entry before the inventory enumeration began. + +### Mission Control +Interactive dashboard introduced in 4.7.0, expanded through 4.8.4. Browser-based cockpit with live plan monitoring, git status, process tracking, and terminal inspection. Includes Field Report (later Flight Log) for review triage, Checklist for plan lifecycle management, and Crew Monitor for activity feeds. Flight Log UI iteration across 4.8.0-4.8.4 with Live/Earlier gaps display and typed notification chrome. + +### Multi-plan queue orchestration +Queue execution system introduced in 4.8.0. `/run-plan-all` orchestrates multiple plans with dispatched Task workers, queue confirmation asks, role-priority sorting, and mission control integration. Checklist displays queue roles (NEXT UP, QUEUED, executing). Mission control reflects queue state in current mission and plan cards. + +### Autonomous external review +External plan review system matured across 4.8.0-4.8.1. Autonomous audit launch via Terminal.app with `--wait-monitor` freshness gates, post-spawn triage continuation, and config-driven preflight blocking. Mid-batch arms for `/run-plan-all` queues, batch uniform triage asks, and findings-only remediation contracts. + +### Backlog CRUD +Plan backlog management commands introduced in 4.8.0. `/backlog-add`, `/backlog-edit`, `/backlog-delete`, `/backlog-cancel` for plan lifecycle without activation. HANDOFF backlog fields parsing, Mission Control backlog status display, and `/start-project` disposition gates for active plan conflicts. + +### Agent personas +Character pack system introduced in 4.4.0, evolved through 4.8.0. Built-in personas (autopilot, night-shift, ghost-runner) with mode-aware chat chrome, CLI tick banners, and workspace skin configuration. Mission Control config UI for persona selection. Legacy workspace skins terminology migrated to agent personas. + +### Quota hard-stop contract +API usage limit enforcement system introduced across 4.8.0-4.8.1. Hard-stop detection with HANDOFF mode tokens, `/continue-plan` pre-flight refusal, cooldown recommendations for continuous runs, and operator recovery guidance. Context guardian quota-blocked session handling with model switch recommendations. + +### Hotfix command +Narrow urgent work command introduced in 4.8.0. `/hotfix` creates mini-plans (≤4 to-dos) with immediate `/run-plan` execution for time-sensitive fixes. Confirm-and-run workflow distinct from regular plan creation. + +### Consumer autoupdate check +Update notification system introduced in 4.8.0. `agent-kit update --check` with config preferences for check intervals and auto-apply settings. SessionStart advisory notifications and Mission Control config toggles. Registry-based version comparison with protected paths respect. + +### Path C packaging +CLI dashboard packaging: Mission Control `dashboard/**` ships inside `@dadado/agent-kit-cli` from **4.8.2** onward (verified in artifact ledger for 4.8.2–4.8.4). Multi-workspace isolation with stable ports, bundled asset discovery, and kit-host fallbacks for older pins. + +### Repository readiness +Comprehensive onboarding system introduced in 4.5.0. `/agent-kit-onboard` namespaced journey with essential/non-essential pillar checks, evidence-based personalization, and merge-safe profile management. CLI doctor integration with hooks health and installation diagnostics. + +### Consumer overlay protection +Hash-based content preservation system introduced in [Unreleased]. Managed-content ledger (`.cursor/agent-kit.managed-hashes.json`) preserves customized agents/skills/commands during updates while refreshing unedited kit files. L0 overlay golden rule scoped to agent trees. + +--- + +## Total verified counts + +| Surface | Estimated | Actual | Notes | +|---------|-----------|--------|-------| +| Slash commands | 25 | 26 | +1 from `/dogfood` | +| Rules | 25 | 25 | Matches estimate | +| Named agents | 13 | 13 | Matches estimate | +| Skills | 9 | 9 | Matches estimate | +| Cursor-native hooks | - | 5 | Not estimated | +| Git hooks | - | 3 | Not estimated | +| CLI commands | 13 | 18 | Higher than estimate | +| CLI subsystems | 5 | 5 | Matches estimate | +| Registry packs | - | 7 | Not estimated | +| Personas | - | 3 | Not estimated | +| Mission Control sections | - | 12 | Not estimated | +| Root scripts | - | 10 | Plus 2 sync config files | +| Agent Kit scripts | - | 3 | Not estimated | +| Templates | - | 9 | Not estimated | + +--- + +## Positioning surfaces (identity text and publication routes) + +Enumerated identity literals and publication routes from `scripts/public-sync.manifest`. This table is a working inventory, not a completeness proof. Do not treat row count as an authority for product positioning; re-validate literals against the files and the published npm/public lanes (`docs/evidence/guidance-claim-matrix.md`). + +| Path | Line | Current literal text | Publication route | +|------|------|---------------------|------------------| +| `README.md` | 5 | Turn your AI coding agent into one that runs the whole workflow: plan it, build it, ship it, and remember it across long projects. | allowlist-synced (line 20) | +| `README.md` | 7 | Long AI coding sessions fall apart when the context window fills up. Agent Kit fixes this with a small operating layer that handles planning, handoff between chats, and structured git flow. The agent builds against a checkable plan and writes down where it stopped so any fresh chat picks up exactly where the last one left off. | allowlist-synced (line 20) | +| `README.md` | 11 | No more lost context. The agent keeps a short state file; new chat, one command, and it's caught up. | allowlist-synced (line 20) | +| `README.md` | 12 | Work against real plans. To-dos you can watch tick off, not vibes. Confirmations stay human-in-the-loop (Ask questions), not unchecked autonomy. | allowlist-synced (line 20) | +| `README.md` | 13 | Built-in DevOps discipline. Staging-first git flow prevents history chaos. | allowlist-synced (line 20) | +| `README.md` | 14 | Production needs confirmation. Agent can push to staging alone; promoting to `main` always asks first. | allowlist-synced (line 20) | +| `README.md` | 15 | Operational learning, not model training. Memory and optional external review keep findings durable across chats; they do not retrain the model. | allowlist-synced (line 20) | +| `README.md` | 16 | Clean history everywhere. Commits and docs describe the software, not chat chatter. | allowlist-synced (line 20) | +| `README.md` | 22 | Plans + HITL gates, `/start-project` Broad Intake, then two gates (write plan, then first unit). Confirmations use Ask questions (clickable options; chat fallback when the tool is unavailable). | allowlist-synced (line 20) | +| `README.md` | 23 | Phase handoff, `.cursor/HANDOFF.md` plus Context Guardian and native hooks (`sessionStart` / `preCompact`) so a fresh chat resumes without re-briefing. Local workspace state; not a hosted sync plane. | allowlist-synced (line 20) | +| `README.md` | 24 | Manual or continuous run, `/continue-plan` (one phase per chat) or `/run-plan` … `/run-plan-all` queues multiple plans sequentially. Plan/queue orchestration, not a general graph runtime. | allowlist-synced (line 20) | +| `README.md` | 25 | Staging → prod git, `/git-staging` for automatic promote to `origin/staging`; `/git-prod` only after explicit confirmation. Direct commits to `main` are blocked. | allowlist-synced (line 20) | +| `README.md` | 26 | Memory loop, Resolved errors and tradeoff decisions in `.cursor/memory/` so the next chat can reuse them. | allowlist-synced (line 20) | +| `README.md` | 27 | Repository readiness, Install scans the repo, applies safe local fixes, and writes a readiness snapshot. `/agent-kit-onboard` resolves remaining decisions one at a time before `/start-project`. | allowlist-synced (line 20) | +| `README.md` | 28 | Agent Personas, Mode-aware chat/CLI chrome only: Autopilot (`/continue-plan`), Night Shift (`/run-plan`), Ghost Runner (CLI). Configure after readiness or set `agentPersona` in `.cursor/context/config.json`. Never changes commits, HANDOFF, memory, or product docs. | allowlist-synced (line 20) | +| `README.md` | 29 | Optional external plan review, After a plan is exhausted, arm Claude Code for a gap monitor; triage with `/plan-review-triage`. Opt-in via config. Findings-only by default (no silent product auto-fix). | allowlist-synced (line 20) | +| `README.md` | 30 | Skills + domain packs, Registry skills and optional L1 packs (clean code, context tools, and more). Install/update via CLI; contribute upstream with `agent-kit contribute`. | allowlist-synced (line 20) | +| `README.md` | 31 | Output hygiene, Chat can be light; commits, docs, HANDOFF, and memory stay professional and inheritable. | allowlist-synced (line 20) | +| `README.md` | 33 | Production-agent layers (L0) five-layer table + link to `docs/evidence/five-layer-claim-matrix.md` | allowlist-synced (line 20) | +| `README.md` | 93 | Mission Control is a local panel over the Agent Kit runtime state. … It is a cockpit for one workspace, not a hosted multi-tenant control plane. Actions stay copy-only (clipboard + paste destination). | allowlist-synced (line 20) | +| `README.md` | 104 | The Cockpit reads as one page in four sections, each reachable from the primary navigation | allowlist-synced (line 20) | +| `README.md` | 108 | The plan in flight: status, progress, friendly Mode labels, and previous/current/next todo | allowlist-synced (line 20) | +| `README.md` | 109 | HANDOFF Gaps log (**NOW** / **Earlier**, wipe on new flight; cap 15 within a flight) plus operator Warnings (Quota pause, Heads up); palette-by-type notification chrome (`ok` / `advice` / `prompt` / `residual` / `warning`); clipboard icon; clickable copy text/path; **All clear** when idle | allowlist-synced (line 20) | +| `README.md` | 110 | What remains: recent plan cards, parked and incomplete plans, and readiness notes | allowlist-synced (line 20) | +| `README.md` | 111 | Live agent/crew feed: ticks, handoffs, deliveries, and denser `agent_step` rows for active-plan to-dos (cap 20) | allowlist-synced (line 20) | +| `README.md` | 119 | Docs | allowlist-synced (line 20) | +| `README.md` | 123 | Install, commands, day-to-day workflow | allowlist-synced (line 20) | +| `README.md` | 124 | Install discovery, `/agent-kit-onboard`, and deliverable boundary | allowlist-synced (line 20) | +| `README.md` | 125 | Exactly what lands in your project, and why there's no nested folder | allowlist-synced (line 20) | +| `README.md` | 126 | How the base install, optional packs, and your local files layer together | allowlist-synced (line 20) | +| `README.md` | 127 | Optional bundles: clean code, DevOps, testing, and more | allowlist-synced (line 20) | +| `README.md` | 128 | Mode defaults, `agentPersona` config, hygiene boundary ([create / contribute](docs/creating-personas.md)) | allowlist-synced (line 20) | +| `README.md` | 129 | Opt-in Claude Code monitor after `/run-plan` exhaustion | allowlist-synced (line 20) | +| `README.md` | 130 | The `.cursor/agent-kit.json` file | allowlist-synced (line 20) | +| `README.md` | 131 | Working on the kit itself (includes contributor quickstart) | allowlist-synced (line 20) | +| `README.md` | 132 | Everything else | allowlist-synced (line 20) | +| `README.md` | 134 | For maintainers | allowlist-synced (line 20) | +| `README.md` | 136 | Two GitHub repos, one product | allowlist-synced (line 20) | +| `README.md` | 140 | Factory: CLI, sync tooling, dogfood. Daily flow: `git staging` → `git prod` → allowlist sync. | allowlist-synced (line 20) | +| `README.md` | 141 | Storefront and **canonical registry** (`registry/**`). Consumers install from here; registry PRs land here. | allowlist-synced (line 20) | +| `README.md` | 143 | Projects that install Agent Kit receive only `.cursor/` + `autogit/` + the manifest, never the whole monorepo. | allowlist-synced (line 20) | +| `README.md` | 145 | **Three layers:** local scratch (HANDOFF/plans, gitignored) · private Git (factory) · public (storefront + registry SoT). Full cheat sheet: [docs/repository-boundaries.md](docs/repository-boundaries.md#cheat-sheet-three-layers). | allowlist-synced (line 20) | +| `package.json` | 4 | HITL framework for AI-assisted IDEs: plan, handoff, staging-to-prod, memory loop; project-aware setup for Cursor, VS Code, and Windsurf. | allowlist-synced (line 23) | +| `packages/cli/package.json` | 4 | Agent Kit CLI: HITL framework install and tooling for AI-assisted IDEs (rules, skills, plan/handoff, context). | allowlist-synced (packages/** line 39) | +| `packages/cli/README.md` | 3 | Agent Kit CLI: HITL operating-layer install and tooling for AI-assisted IDEs (rules, skills, plan/handoff, staging-to-prod, memory). It installs local workspace contracts; it is not a hosted control plane or graph workflow runtime. | allowlist-synced (packages/** line 39); npm pack storefront (`prepublishOnly` → `scripts/verify-cli-dashboard-pack.mjs`) | +| `packages/cli/src/index.ts` | 24 | HITL framework for AI-assisted IDEs | allowlist-synced (packages/** line 39) | +| `.cursor-plugin/plugin.json` | 5 | HITL framework for AI-assisted IDEs — plan, handoff, staging→prod, memory loop, anti-slop. Stack skills via agent-kit add. | allowlist-synced (.cursor-plugin/** line 69) | +| `.cursor-plugin/plugin.json` | 7-13 | ["agents","hitl","handoff","git-staging","context","multi-ide","anti-slop"] | allowlist-synced (.cursor-plugin/** line 69) | +| `docs/README.md` | 3 | Agent Kit is a HITL framework for AI-assisted IDEs: plan, handoff, staging-to-prod git flow, and memory across long projects. Install generates Cursor-first project setup; VS Code and Windsurf get partial generators (parity Low / Minimal per [cursor-native-audit.md](cursor-native-audit.md)). Mechanizable invariants live in the CLI so non-Cursor paths can run the same checks. | allowlist-synced (docs/** line 54) | +| `docs/getting-started.md` | 3 | Agent Kit keeps your AI coding agent working against a plan and stops you from losing context when a chat gets too long. This guide covers installing it, the commands you get, and how a normal day looks. | allowlist-synced (docs/** line 54) | +| `docs/CONTRIBUTING.md` | 3 | Agent Kit is a HITL framework for AI-assisted IDEs. Contributions welcome - from skills to CLI features to docs. | allowlist-synced (docs/** line 54) | +| `docs/github-about.md` | 8 | Human-in-the-loop harness for AI-assisted IDEs - plans, context handoff, memory loop, and staging→prod git workflow with explicit confirmation before production. | allowlist-synced (docs/** line 54) | +| `docs/github-about.md` | 14 | HITL framework for AI-assisted IDEs: plan → handoff → staging → prod, with a skill registry and opt-in stack packs. | allowlist-synced (docs/** line 54) | +| `docs/github-about.md` | 20 | Harness human-in-the-loop para IDEs com IA: planos, handoff de contexto, memory loop e fluxo git staging→prod com confirmação explícita antes de produção. | allowlist-synced (docs/** line 54) | +| `docs/github-about.md` | 25 | `ai-assisted-development` `cursor` `vscode` `windsurf` `developer-tools` `cli` `monorepo` `agent-kit` `prompt-engineering` `skills` `templates` `handoff` `context-management` `human-in-the-loop` | allowlist-synced (docs/** line 54) | +| `docs/cursor-native-audit.md` | 30 | HITL framework for AI-assisted IDEs (plan, handoff, staging→prod, memory loop) | allowlist-synced (docs/** line 54) | +| `docs/public-launch-announcement.md` | 12 | Para resolver esse e outros problemas, como a falta de um fluxo de DevOps estruturado, conexão segura com ferramentas e versionamento, ao longo de um ano, fui desenvolvendo o *Agent Kit*! | allowlist-synced (docs/** line 54) | +| `docs/public-launch-announcement.md` | 17 | É uma camada operacional leve que transforma seu IDE (Cursor, VS Code, etc.) em um framework que gerencia o planejamento, o handoff entre chats e o fluxo de Git / DevOps estruturado para você focar no que importa. | allowlist-synced (docs/** line 54) | +| `docs/public-launch-announcement.md` | 10 | Se você usa o Cursor ou outro IDE com IA assistida para codar, já deve ter passado pelo clássico problema de ver a IA se perder e alucinar quando o chat fica muito longo e o contexto enche. | allowlist-synced (docs/** line 54) | +| `docs/public-launch-announcement.md` | 16 | O que é o Agent Kit? | allowlist-synced (docs/** line 54) | +| `docs/public-launch-announcement.md` | 19 | O que ele resolve? | allowlist-synced (docs/** line 54) | +| `docs/public-launch-announcement.md` | 20 | Onboarding & Setup Inteligente: Ele analisa o seu projeto, descobre o que está faltando e gera regras, comandos e skills personalizados sob medida para a sua stack e padrões de código. | allowlist-synced (docs/** line 54) | +| `docs/public-launch-announcement.md` | 21 | Sem perda de contexto: Ele mantém o estado do seu projeto vivo. Abriu um chat novo? Um comando e a IA já sabe exatamente onde parou. | allowlist-synced (docs/** line 54) | +| `docs/public-launch-announcement.md` | 22 | Planos de verdade: "vibecoding" mas nem tanto. A IA trabalha em cima de to-dos reais que você acompanha passo a passo no loop. | allowlist-synced (docs/** line 54) | +| `docs/public-launch-announcement.md` | 23 | DevOps integrado: Fluxo de Git seguro com staging automático e commits limpos. | allowlist-synced (docs/** line 54) | +| `docs/public-launch-announcement.md` | 24 | Segurança em produção: A IA pode subir para staging sozinha, mas promover para `main` sempre exige sua confirmação direta. Hooks nativos protegem a IA de fazer isso alucinando. | allowlist-synced (docs/** line 54) | +| `docs/public-launch-announcement.md` | 26 | Como usar? | allowlist-synced (docs/** line 54) | +| `docs/public-launch-announcement.md` | 30 | `npx @dadado/agent-kit-cli install` | allowlist-synced (docs/** line 54) | +| `docs/public-launch-announcement.md` | 35 | Depois de instalado, você ganha comandos como `/agent-kit-onboard`, `/start-project` e `/continue-plan` diretamente no chat do seu editor. | allowlist-synced (docs/** line 54) | +| `install.md` | 1 | Agent Kit - Installation | allowlist-synced (line 32) | +| `install.md` | 3 | You are the installer. Set up the kit **in the user's project** without copying the entire Agent Kit monorepo into it. | allowlist-synced (line 32) | +| `registry/registry.json` | 8-342 | 34 skill/pack descriptions across line range (e.g., "Remove AI code slop", "HITL framework install and tooling", "Create and update ClickUp tasks via MCP") | public-repo-PR-only (excluded line 89) | +| `registry/packs/clean-code/pack.json` | 5 | Deslop, simplicity, and surgical refactors for AI-assisted codebases. | public-repo-PR-only (excluded line 89) | +| `registry/packs/context-management/pack.json` | 5 | Advanced context packs, librarian/extractor agents, and window-budget helpers beyond L0 guardian, memory-loop, and native phase/context hooks. | public-repo-PR-only (excluded line 89) | +| `registry/packs/cybersec/pack.json` | 5 | Security review, secrets/PII awareness, and pre-merge hardening. Includes git-secrets-safety as a pack double-check (also L0 always-on). | public-repo-PR-only (excluded line 89) | +| `registry/packs/devops/pack.json` | 5 | CI/CD scaffolding templates and infra guidance beyond the structural git staging→prod spine. | public-repo-PR-only (excluded line 89) | +| `registry/packs/engineering-architecture/pack.json` | 5 | ADRs, technology tradeoffs, docs honesty, and tech-lead style decisions - stack-agnostic. | public-repo-PR-only (excluded line 89) | +| `registry/packs/project-management/pack.json` | 5 | Optional PM-tool adapters and project sizing helpers. Structural plan/handoff stays in L0. | public-repo-PR-only (excluded line 89) | +| `registry/packs/quality/pack.json` | 5 | Testing conventions, QA routines, and review checklists, not language runtimes. | public-repo-PR-only (excluded line 89) | +| `dashboard/dashboard.html` | 7 | Mission Control | allowlist-synced (dashboard/** line 43) | +| `_legacy/v2/plugin.json` | 5 | Context memory, handoff between agents, Git homolog/prod workflow, skills, rules, and hooks for Cursor. | allowlist-synced (_legacy/** line 85) | +| `_legacy/v2/skills-registry.json` | 5 | Cria e atualiza tasks no ClickUp via MCP seguindo convenções do workspace (títulos, descrições, status, prioridade, subtarefas). Use ao criar/editar tarefas, planejar sprints ou fazer breakdown de features. | allowlist-synced (_legacy/** line 85) | +| `_legacy/v2/skills-registry.json` | 6 | Valida, formata e manipula JSON (configs, payloads, workflows n8n, respostas de IA). Use ao editar .json, configs, código que parseia JSON ou quando o usuário mencionar JSON, payload, schema. | allowlist-synced (_legacy/** line 85) | +| `_legacy/v2/skills-registry.json` | 7 | Cria, edita e documenta workflows n8n (nodes, webhooks, Execute Workflow, credenciais, import/export JSON). Use ao trabalhar com n8n, workflows de automação ou quando o usuário mencionar n8n. | allowlist-synced (_legacy/** line 85) | +| `_legacy/v2/skills-registry.json` | 8 | Cria e edita prompts de agentes em Markdown (estrutura, sistema/usuário, versionamento). Use ao editar prompt-*.md, prompts/*.md ou quando o usuário mencionar prompt de agente, instruções de IA. | allowlist-synced (_legacy/** line 85) | +| `_legacy/v2/skills-registry.json` | 9 | Escreve e revisa SQL para Postgres (DDL, DML, índices, constraints). Use ao editar .sql, schemas para n8n ou quando o usuário mencionar tabelas, migrations, Postgres. | allowlist-synced (_legacy/** line 85) | +| `_legacy/v2/skills-registry.json` | 10 | Boas práticas para fluxos conversacionais em agentes de chat (mensagens curtas, tom, confirmações, handoff). Use ao criar prompts de chat, WhatsApp, Telegram ou revisar fluxos de atendimento. | allowlist-synced (_legacy/** line 85) | +| `_legacy/v2/skills-registry.json` | 11 | Remove AI-generated code slop (redundant comments, unnecessary try/catch, any casts, deep nesting). Use after AI-assisted coding sessions or before code review to clean up machine-generated patterns. | allowlist-synced (_legacy/** line 85) | +| `skills-registry.json` | 5-10 | Create and update ClickUp tasks via MCP..., Validate, format, and manipulate JSON..., Create, edit and document n8n workflows..., Create and edit agent prompts in Markdown..., Write and review SQL for Postgres..., Best practices for conversational flows... | allowlist-synced (no exclusion matches) | diff --git a/docs/consumer-configuration.md b/docs/consumer-configuration.md new file mode 100644 index 0000000..70dca18 --- /dev/null +++ b/docs/consumer-configuration.md @@ -0,0 +1,108 @@ +# Consumer configuration + +Every knob a consumer of Agent Kit can configure or personalize, in one place: where it is defined, what reads it, whether the Mission Control Config tab can write it, and the copy-paste snippet to use when the tab write path is unavailable (server down, non-loopback, read-only deploy). + +## Where configuration lives + +| Layer | Location | Scope | +|-------|----------|-------| +| Session config | `.cursor/context/config.json` (template: `.cursor/context/config.example.json`) | Per-workspace agent behavior and Mission Control prefs | +| Dashboard skin | Browser `localStorage` key `agent-kit:dashboard-skin` | Visual chrome of Mission Control only; never written to the repo | +| Install manifest | `.cursor/agent-kit.json` | What the installer laid down (profile, skills, packs, registry ref) | +| Personalization result | `.cursor/context/personalization.json` | What the personalization generator applied or skipped | +| Repo profile | `.cursor/agent-kit.config.json` | Scanner-detected repository profile; read-only, regenerated by scan | + +## Session config knobs (`.cursor/context/config.json`) + +Writable via tab = the Mission Control Config tab (More menu) can save the key through the loopback allowlist (`PATCH /api/config`, guards in `dashboard/lib/guards.mjs`). + +| Knob | Where defined | Read by | Implemented? | Writable via tab | Copy snippet | +|------|---------------|---------|--------------|------------------|--------------| +| `autoHandoff` (boolean) | `config.example.json` | `context-guardian` rule (chat agents) | Yes | Yes (Session) | `{ "autoHandoff": true }` | +| `interTickCooldownMs` (integer 0..3600000) | `config.example.json` | `/run-plan`, `/run-plan-all` tick pacing | Yes | Yes (Session) | `{ "interTickCooldownMs": 15000 }` | +| `fieldReportReviewCadence.enabled` (boolean) | `config.example.json` | `dashboard/lib/semantic-model.mjs` (Flight Log cadence) | Yes | Yes (Session) | `{ "fieldReportReviewCadence": { "enabled": true } }` | +| `fieldReportReviewCadence.tickThreshold` (integer 1..100) | `config.example.json` | `dashboard/lib/semantic-model.mjs` | Yes | Yes (Session) | `{ "fieldReportReviewCadence": { "tickThreshold": 3 } }` | +| `updateCheck.enabled` (boolean) | `config.example.json` | `packages/cli/src/lifecycle/check-updates.ts` | Yes | Yes (Update check) | `{ "updateCheck": { "enabled": true } }` | +| `updateCheck.intervalDays` (integer 1..365) | `config.example.json` | `check-updates.ts` | Yes | Yes (Update check) | `{ "updateCheck": { "intervalDays": 7 } }` | +| `updateCheck.lastCheckedAt` | stamped by CLI/hooks | `check-updates.ts` | Yes (system) | No (system-stamped) | not editable | +| `cursorUpdateCheck.enabled` (boolean) | `config.example.json` | `packages/cli/src/lifecycle/cursor-update-awareness.ts` | Yes | No (edit config.json; MC allowlist not widened) | `{ "cursorUpdateCheck": { "enabled": true } }` | +| `cursorUpdateCheck.intervalDays` (integer ≥1) | `config.example.json` | `cursor-update-awareness.ts` | Yes | No | `{ "cursorUpdateCheck": { "intervalDays": 7 } }` | +| `cursorUpdateCheck.lastCheckedAt` / `lastSeenCursorVersion` | stamped by CLI/hooks | `cursor-update-awareness.ts` | Yes (system) | No (system-stamped) | not editable | +| `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.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" } }` | +| `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` | +| `crewTeam` (`lexiconPack`, `members[]`) | composition contract ADR `2026-08-01_crew-tab-composition-contract.md` | planned Crew (Team) tab; not wired | No (contract only) | No | see ADR schema sketch; do not widen Config allowlist until a follow-up implements it | +| `onboarded`, `onboarding.status`, `onboarding.contractVersion` | onboarding flows | readiness, Mission Control | Yes | No (display-only in the Read-only fieldset) | written by `/agent-kit-onboard` | +| `onboarding.checks` | readiness scanner | readiness | Yes | Never | not editable | +| `dogfood.factoryRoot` (absolute path or null) | `config.example.json`, `.cursor/commands/dogfood.md` | `/dogfood` bridge step | Yes | No | `{ "dogfood": { "factoryRoot": "/absolute/path/to/agent-kit-dev" } }` | + +## Dashboard skin (localStorage-only) + +| Knob | Where defined | Read by | Writable via tab | Notes | +|------|---------------|---------|------------------|-------| +| Interface Skin (`legacy` \| `cursor`) | `dashboard/dashboard.html` (More menu, Skins group) | Mission Control paint path | No (by design) | Stored in browser `localStorage` under `agent-kit:dashboard-skin`. Interface Skins are a local visual preference and are never written to repo config; Agent Personas own chat tone. | + +## Install-time choices + +Made once by `install.md` / the CLI wizard (`packages/cli/src/utils/prompts.ts`) and recorded in the manifest. Not writable via the Config tab; change by re-running the installer or editing the manifest by hand. + +| Choice | Recorded in | Notes | +|--------|-------------|-------| +| IDE target | `.cursor/agent-kit.json` (implicit tree) | wizard select | +| Plan / profile | `.cursor/agent-kit.json` → `profile` | wizard select | +| Git workflow | `.cursor/agent-kit.config.json` → `git.workflow` | wizard select, scanner evidence | +| Skills and packs | `.cursor/agent-kit.json` → `skills`, `packs` | wizard multiselect | +| Agent persona | `.cursor/context/config.json` → `agentPersona` | wizard select; afterwards writable via the Config tab | +| Registry source | `.cursor/agent-kit.json` → `registry.url`, `registry.ref` | install argument or wizard | +| Personalization items | `.cursor/context/personalization.json` → `items[]` | generator result (`applied`, `skipped-customized`, `recommended-confirmation`) | +| Protected paths | `.cursor/agent-kit.json` → `protected` | never overwritten by kit updates | + +## Repo profile (read-only) + +`.cursor/agent-kit.config.json` is the scanner-detected profile (purpose, stack, git, infra, services, context sources). It is regenerated by `agent-kit scan` and stays out of Config-tab scope by design. + +## CLI flags and environment variables + +Invocation-time inputs; not persisted configuration. The table lists consumer-facing flags and env vars (not an exhaustive dump of every internal helper). + +| Surface | Flags / env vars | Notes | +|---------|------------------|-------| +| `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_NO_OPEN`, `AGENT_KIT_DASHBOARD_DATA_TIMEOUT_MS`, `AGENT_KIT_DASHBOARD_DATA_BUDGET_MS` | Kit-root fallback is documented in README Path C and `/dashboard`. | +| Registry / hooks | `AGENT_KIT_REGISTRY`, `ALLOW_MAIN_PUSH` | `ALLOW_MAIN_PUSH=1` disables main-push protection for the session (doctor warns). | + +## How the Config tab saves + +The Config tab (Mission Control, More menu) renders five fieldsets (Read-only, Session, Update check, Audits, Agent Personas) and saves through `PATCH /api/config` on the loopback server: + +- **Allowlist-only:** unknown top-level or nested keys are rejected with 400 (`validateConfigWriteBody` in `dashboard/lib/guards.mjs`). +- **Merge-safe:** the patch merges into the existing file; non-editable nests are preserved (`mergeConfigAllowlist`). +- **Loopback-only:** the remote address must be loopback; the LAN broadcast token never unlocks writes (`handleConfigWrite` in `dashboard/serve.mjs`). +- **Path-locked:** writes go only to `.cursor/context/config.json` under the snapshot repo root (`resolveContextConfigPath`). +- **`updateApply.auto` is never writable** through this API. Keep it `false` unless you accept silent L0 overwrite risk; change it only by editing `config.json` by hand (or via explicit `/update` / CLI apply flows). + +## When the write path is unavailable + +If the dashboard server is down, the panel is reached over LAN (writes stay loopback-only), or the deploy is read-only: + +1. Open `.cursor/context/config.json` in the editor. +2. Paste the snippet from the table above and merge it into the existing object (snippets are fragments, not full files). +3. Save. Chat commands and the CLI read the file on the next tick; Mission Control reflects the change on the next snapshot refresh. + +The Config tab also offers per-fieldset copy-snippet buttons that place the same fragments on the clipboard. Copy-only: snippets never write anything by themselves. + +## Constraints (by design) + +- Widening the write allowlist requires a new decision record, not a side effect of other work (`.cursor/memory/decisions/2026-07-26_mission-control-config-write-allowlist.md`). +- UI CTAs in Mission Control are copy-only; `/api/config` is the single narrow write exception (`.cursor/memory/decisions/2026-07-25_mission-control-copy-only-paste-destinations.md`). +- Interface Skins stay localStorage-only and out of repo config. +- The repo profile (`.cursor/agent-kit.config.json`) is read-only for consumers. diff --git a/docs/cursor-native-audit.md b/docs/cursor-native-audit.md index 060d6ba..54bf9c9 100644 --- a/docs/cursor-native-audit.md +++ b/docs/cursor-native-audit.md @@ -2,6 +2,8 @@ Audit of Cursor-specific artifacts in the Agent Kit repository: what exists, what is missing, and how VS Code and Windsurf compare. Living audit; last refreshed **2026-07-19** (post EN sweep on staging). +**Awareness check (advisory):** `agent-kit cursor-awareness --check` and `/cursor-update-awareness` diff Cursor changelog signals against this inventory without mutating it. Version-prose / Marketplace packaging refresh remains on the parked Marketplace plan. See [cursor-update-awareness.md](cursor-update-awareness.md). + ## Summary | Area | Status | Notes | @@ -236,5 +238,6 @@ Acceptable for private SoT until Phase B registry cutover defines minimum dogfoo ## References - [Cursor 3.0 Features](cursor-3-features.md) +- [Cursor update awareness](cursor-update-awareness.md) - [Coherence inventory](coherence-inventory.md) - Decision: structural harness vs stack (maintainers' decision log, private repo) diff --git a/docs/cursor-update-awareness.md b/docs/cursor-update-awareness.md new file mode 100644 index 0000000..3692904 --- /dev/null +++ b/docs/cursor-update-awareness.md @@ -0,0 +1,59 @@ +# Cursor update awareness + +Opt-in advisory for Cursor product updates (releases, changelog entries, new MCP/hooks/skills/commands/SDK surfaces). Agent Kit reports gaps against the native-audit inventory and routes confirmed work through existing HITL conveyors. + +## Contract + +| Rule | Behavior | +|------|----------| +| Check ≠ apply | `agent-kit cursor-awareness --check` never rewrites `.cursor/` or IDE state | +| Opt-in | `cursorUpdateCheck.enabled` defaults to `false` in `.cursor/context/config.json` | +| No Field Reports | `fieldReportRecommended` is always `false` | +| Conveyor | Confirmed gaps → Ask → `/backlog-add` or `/dogfood` (lane-aware) | +| Separate from kit update | Kit self-release uses `updateCheck` / `/update` (ADR `2026-07-27_consumer-autoupdate-check-opt-in.md`) | +| Native-audit prose | Marketplace / version-prose refresh stays on parked `submit-cursor-marketplace` | + +## Detection source + +ADR: `.cursor/memory/decisions/2026-08-01_cursor-update-detection-source.md` + +1. **Primary:** fetch Cursor changelog (`https://cursor.com/changelog`, overridable via `cursorUpdateCheck.changelogUrl`) +2. **Delivery:** sessionStart nudge when opt-in is enabled (same pattern as kit `updateCheck`) +3. **Inventory:** diff open Action items and refresh staleness in `docs/cursor-native-audit.md`; validate `docs/cursor-3-features.md` presence +4. **Readiness:** may store last-seen Cursor product version later; not the probe today (`ide: cursor` only) + +## CLI + +```bash +agent-kit cursor-awareness --check [--json] [--respect-prefs] [--stamp] [--offline] +``` + +## Slash command + +`/cursor-update-awareness` runs the check, summarizes gaps, then Ask-routes to `/backlog-add` or `/dogfood`. + +## Prefs (`cursorUpdateCheck`) + +| Key | Default | Notes | +|-----|---------|-------| +| `enabled` | `false` | Opt-in for sessionStart + `--respect-prefs`. Changelog extract + stamp plausibility landed (R1/R2). **Advise/stamp semantics (T3): one-shot** — sessionStart passes `--stamp`, so a `changelog-ahead` nudge advances `lastSeenCursorVersion` on that run; ignoring the nudge means it will not reappear for the same version (interval + baseline). Operators who want repeat nudges should leave `enabled=false` and run `agent-kit cursor-awareness --check` manually (or use `/cursor-update-awareness`) without relying on sessionStart. Default stays `false`; enabling accepts one-shot delivery. | +| `intervalDays` | `7` | Minimum days between respected checks | +| `lastCheckedAt` | `null` | Stamped by CLI/hooks (`--stamp`) | +| `lastSeenCursorVersion` | `null` | Baseline for changelog-ahead detection | +| `changelogUrl` | `https://cursor.com/changelog` | HTTPS only | + +See `.cursor/context/config.example.json`. + +## Advise / stamp (one-shot sessionStart) + +sessionStart always invokes the check with `--stamp` when `cursorUpdateCheck.enabled` is true. That intentionally **baselines** `lastSeenCursorVersion` on the same run that can emit a `changelog-ahead` nudge. Combined with `intervalDays`, the nudge is **one-shot per version**: an ignored nudge does not keep firing until the human acts. This is accepted kit behavior (T3); ack-before-stamp is not implemented. Manual `/cursor-update-awareness` or CLI `--check` without sessionStart remains available for operators who prefer explicit checks. + +## Closeout expectation (network-dependent) + +When shipping or closing work that depends on the live changelog fetch, run one real end-to-end check (`agent-kit cursor-awareness --check` against the configured HTTPS source, or a recorded HTML fixture that includes CSS noise plus a release label) before claiming `Gaps: none` in HANDOFF. Unit tests with injected `changelogBody` alone are not sufficient for that claim. + +## Related + +- [Cursor-native audit](cursor-native-audit.md) +- [Cursor 3 features map](cursor-3-features.md) +- [Bootstrap](bootstrap.md) (check ≠ apply for kit updates) diff --git a/docs/drift-inventory.md b/docs/drift-inventory.md index 38ab353..513645d 100644 --- a/docs/drift-inventory.md +++ b/docs/drift-inventory.md @@ -1,8 +1,16 @@ # Drift inventory - workspaces × Agent Kit -Snapshot of how Agent Kit (or a folder copy) appears across local workspaces. Used as input to the layer model (L0–L3), manifest, and CLI lifecycle. Counts are from a filesystem scan on **2026-07-19**; they will drift again until distribution stops being “copy the folder”. +Historical snapshot of how Agent Kit (or a folder copy) appeared across local workspaces. Used as input to the layer model (L0–L3), manifest, and CLI lifecycle. -**Source of truth (SoT):** Agent Kit monorepo at this repo. **Product release:** per `package.json` current version. **`package.json` / plugin manifest** version checked against current state. Staging is **18 commits ahead** of `main` with EN sweep + breaking L1 pack id rename in `[Unreleased]`. +**Evidence class:** maintained documentation (derived). Fleet workspace counts are from a filesystem scan on **2026-07-19** and are **historical**; they are not current lane state. + +**Lane freshness (recomputed 2026-07-31):** +- Private SoT branch: `staging` @ `7e5315d9c58c7ddc7e7d908626f44ba056085354` +- Private `main` @ `67391111910e9e6a7b2496ce8e5821a30b51c900` +- Ahead of `main` at that observation: **105** commits / **53** first-parent merges (replaces the obsolete fixed ahead-count from the 2026-07-19 snapshot) +- Product version at observation: `4.8.4` (`package.json` / `packages/cli/package.json`) + +Regenerate ahead-counts with `git rev-list --count main..staging` before citing them elsewhere. Do not treat this file as proof of current delivery. ## Summary diff --git a/docs/external-plan-review.md b/docs/external-plan-review.md index 951fa4e..39a2711 100644 --- a/docs/external-plan-review.md +++ b/docs/external-plan-review.md @@ -102,7 +102,7 @@ Use `/plan-review-triage` to process the monitor with clickable options: - **Fix nits only:** Address small issues directly (typos, formatting, obvious omissions) - **Ack and stop:** Note findings for future reference without immediate action -Mission Control **Flight Log** shows HANDOFF Gaps (**Live** + **Earlier** history; wipe on new plan/queue flight; cap 15 within a flight) plus an operator Warnings lane (Quota pause, Heads up), with palette-by-type notification chrome (`ok` / `advice` / `prompt` / `residual` / `warning`). When Gaps and Warnings are empty, Flight Log may surface bounded untriaged external-review rows (per-row Copy triage command / path only; All clear when truly clear). Write Gaps in short operator voice (exact `none` when only mid-batch/cadence plumbing changed; avoid `none.…` OK notes; see handoff template). External-review triage decisions run via chat `/plan-review-triage` (HITL SoT; autonomous audit arming), not as Review all / Resolve all CTAs on that card. Multi-path walks skip already-triaged or no-open-residual monitors with a one-line note. When remaining monitors share a uniform outcome class, `/plan-review-triage` uses **one** Ask for the set and still writes a durable triage heading on every target; uniform Write residuals runs one Broad Intake then may enqueue one combined backlog plan. Mixed outcomes stay sequential (ADRs `2026-07-27_plan-review-triage-batch-uniform-hitl.md`, `2026-07-28_triage-write-residuals-via-backlog.md`). Cadence ledger scripts may remain for L0 tick bumps; Flight Log UI does not surface cadence WARNING rows (ADR `2026-07-27_mc-flight-log-panel.md`). Wait/mtime and mid-batch wait are owned by the wait-freshness contract (`2026-07-27_audits-wait-freshness-enforce.md`). +Mission Control **Flight Log** shows HANDOFF Gaps (**NOW** + **Earlier** history; wipe on new plan/queue flight; cap 15 within a flight) plus an operator Warnings lane (Quota pause, Heads up), with palette-by-type notification chrome (`ok` / `advice` / `prompt` / `residual` / `warning`). When Gaps and Warnings are empty, Flight Log may surface bounded untriaged external-review rows (per-row **Copy triage command** button whose payload is `/plan-review-triage `; All clear when truly clear). Every Flight Log entry kind renders **one dynamically-labeled action button** whose payload composes the act-on-the-matter prompt with the referenced document path (`Copy fix prompt` for Gaps NOW/Earlier, `Copy recovery prompt` for Quota pause, `Copy follow-up prompt` for Heads up, `Copy triage command` for quiet open-triage); the toast names the chat input as paste destination (ADR `2026-07-27_mc-flight-log-panel.md` decisions 11/13 amendment). Write Gaps in short operator voice (exact `none` when only mid-batch/cadence plumbing changed; avoid `none.…` OK notes; see handoff template). External-review triage decisions run via chat `/plan-review-triage` (HITL SoT; autonomous audit arming), not as Review all / Resolve all CTAs on that card. Multi-path walks skip already-triaged or no-open-residual monitors with a one-line note. When remaining monitors share a uniform outcome class, `/plan-review-triage` uses **one** Ask for the set and still writes a durable triage heading on every target; uniform Write residuals runs one Broad Intake then may enqueue one combined backlog plan. Mixed outcomes stay sequential (ADRs `2026-07-27_plan-review-triage-batch-uniform-hitl.md`, `2026-07-28_triage-write-residuals-via-backlog.md`). Cadence ledger scripts may remain for L0 tick bumps; Flight Log UI does not surface cadence WARNING rows (ADR `2026-07-27_mc-flight-log-panel.md`). Wait/mtime and mid-batch wait are owned by the wait-freshness contract (`2026-07-27_audits-wait-freshness-enforce.md`). ## Configuration options @@ -139,7 +139,11 @@ The launcher starts Claude with `--permission-mode auto` in interactive and head **Background/inspectable auto-launch (`mode: "autonomous"` or `--autonomous`):** prefers tmux/screen detached PTY, then macOS Terminal.app `do script` **without** `activate`, then Linux/Windows emulators. Soft-falls back to `--paste-only` when spawn is unavailable. Soft-fails with tip + exit 0 when `claude` is missing (Field Report owed). Never runs silent `claude -p` in a chat agent shell. Rollback to OS window focus: `--focus-terminal` or `AGENT_KIT_AUDIT_FOCUS_TERMINAL=1`. ADR: `.cursor/memory/decisions/2026-07-28_audits-headless-terminal-honesty.md`. -**Post-spawn monitor watch (`--wait-monitor`):** chat autonomous arms **must** pass `--force --autonomous --wait-monitor`. The launcher records an arm epoch, then polls until `.cursor/memory/plan-monitor-.md` is **fresh** (`mtime >= arm epoch`, or a content sentinel line `` / `updated`), or until `--wait-timeout` (default 900s). Pre-arm files are ignored (existence alone is not ready). Exit codes: `0` fresh ready (soft-fail tip + exit 0 only when wait is off), `3` timeout, `4` soft-fail while waiting. Dry-run prints wait path, timeout, arm-epoch, and stale/missing status. Spawn-only exit 0 without wait is not review done. ADRs: `.cursor/memory/decisions/2026-07-27_audits-wait-freshness-enforce.md`, `.cursor/memory/decisions/2026-07-27_audits-post-spawn-monitor-watch-continue.md`. +**Post-spawn monitor watch (`--wait-monitor`):** chat autonomous arms **must** pass `--force --autonomous --wait-monitor`. The launcher records an arm epoch, then polls until `.cursor/memory/plan-monitor-.md` is **fresh** (`mtime >= arm epoch`, or a content sentinel line `` / `updated`), or until `--wait-timeout` (default 900s). Pre-arm files are ignored (existence alone is not ready). Exit codes: `0` fresh ready (soft-fail tip + exit 0 only when wait is off), `3` timeout, `4` soft-fail while waiting. Dry-run prints wait path, timeout, arm-epoch, and stale/missing status. Spawn-only exit 0 without wait is not review done. Exit `3` is **timeout only**: it never means review done, and a monitor written afterwards by a later or separate arm does not convert it into success (leave the target Field Report owed and re-arm). ADRs: `.cursor/memory/decisions/2026-07-27_audits-wait-freshness-enforce.md`, `.cursor/memory/decisions/2026-07-27_audits-post-spawn-monitor-watch-continue.md`. + +**Post-spawn 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`. + +**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. @@ -151,6 +155,8 @@ The launcher starts Claude with `--permission-mode auto` in interactive and head .cursor/scripts/plan-external-review.sh --force --autonomous --wait-monitor --wait-timeout 900 --dry-run my-plan.plan.md .cursor/scripts/plan-external-review.sh --wait-monitor --wait-timeout 5 --dry-run my-plan.plan.md bash -n .cursor/scripts/plan-external-review.sh +AGENT_KIT_AUDIT_SESSION_CAP=1 .cursor/scripts/plan-external-review.sh --force --autonomous --wait-monitor --dry-run my-plan.plan.md +.cursor/scripts/plan-external-review.sh --reap-audit-sessions --dry-run # Stale pre-arm file → exit 3; touch/rewrite after arm or add freshness sentinel → exit 0 ``` @@ -241,6 +247,29 @@ bash -n .cursor/scripts/plan-external-review.sh - Fix: launcher freshness gate (`mtime >= arm epoch` or ``). Chat L0 always passes `--wait-monitor` on autonomous arm; exit `0` is required before triage Ask. Mid-batch: one arm+wait (or one `--batch` + wait_all), no unwatched multi-Terminal fan-out. - Dogfood: `.cursor/memory/errors/2026-07-27_audits-wait-monitor-stale-preexisting.md`. ADR: `decisions/2026-07-27_audits-wait-freshness-enforce.md`. +**Silent PTY: spawn succeeded, empty scrollback:** + +- Symptom: the launcher prints `background terminal launched`, the session stays detached with a 0-byte hardcopy, no monitor is ever written, and `--wait-monitor` burns its full budget to exit `3`. +- Fix: the post-spawn progress gate samples scrollback for 60s (`AGENT_KIT_AUDIT_PROGRESS_TIMEOUT`) and aborts early on silence. It measures the launcher banner first, then requires scrollback growth beyond that banner, so a launch that only prints the pre-exec banner (no Claude output) is still caught. Sampling counts non-whitespace bytes and targets window 0 explicitly (`screen -S -p 0 -X hardcopy`), because a detached session writes a padded blank buffer, or nothing at all, when no window is targeted. Read the diagnosis, keep the Field Report owed, and re-arm with the printed paste fallback or attach the channel manually. Exit `3` is timeout-only and is never review done; monitors written later by another arm do not convert it into success. +- ADR: `.cursor/memory/decisions/2026-07-30_audits-pty-progress-gate-zombie-policy.md`. + +**Accumulated detached audit sessions:** + +- Symptom: repeated autonomous arms leave `agent-kit-audit--` sessions detached (each one a PTY that never produced a monitor), and later arms keep adding to the pile. +- Policy (preventive, not cleanup of a known pile): the launcher counts detached **workspace-owned** sessions before every autonomous spawn, warns at or above `AGENT_KIT_AUDIT_SESSION_WARN` (default 5), and refuses to spawn at or above `AGENT_KIT_AUDIT_SESSION_CAP` (default 20) instead of adding one more. Attached sessions are operator work in progress and are never counted or disposed. Foreign-workspace and legacy unscoped `agent-kit-audit-` names are out of scope for cap/reap. +- Inspect: `screen -ls` (or `tmux ls`). +- Dispose (opt-in, detached kit-owned sessions past the age floor only): + +```bash +.cursor/scripts/plan-external-review.sh --reap-audit-sessions --dry-run # preview, kills nothing +.cursor/scripts/plan-external-review.sh --reap-audit-sessions # pure disposal, no audit starts +.cursor/scripts/plan-external-review.sh --reap-audit-sessions --force --autonomous # reap then launch a new audit +AGENT_KIT_AUDIT_REAP_MIN_AGE=0 .cursor/scripts/plan-external-review.sh --reap-audit-sessions --dry-run +``` + +- Manual, one session at a time: `screen -S -X quit` or `tmux kill-session -t `. Never a wildcard kill or `pkill` on the namespace. +- ADR: `.cursor/memory/decisions/2026-07-30_audits-pty-progress-gate-zombie-policy.md`. + **Claude CLI not found:** - External review skips with a tip message and exit 0 (exit 4 when `--wait-monitor` was requested) diff --git a/docs/five-layer-claim-matrix.md b/docs/five-layer-claim-matrix.md new file mode 100644 index 0000000..be4abf9 --- /dev/null +++ b/docs/five-layer-claim-matrix.md @@ -0,0 +1,39 @@ +# Five-layer claim matrix (public) + +Public storefront summary of how Agent Kit maps to a five-layer production-agent lens. Documentation alone is not proof of behavior. The private factory keeps a fuller evidence ledger under `docs/evidence/` (sync-denied); this page cites only paths that ship on the public lane. + +Classification: **shipped core** (L0), **optional pack** (L1/L2), **planned**, **unsupported**. + +## Layer summary + +| Layer | What ships in core | Explicit non-claim | +|-------|--------------------|--------------------| +| Prompt + HITL | Plan gates, Ask questions, `/git-prod` confirmation | Not full autonomy without review | +| Context + memory | HANDOFF, hooks, memory loop, personas (chrome only) | Not a hosted control plane or cloud HANDOFF sync | +| Safeguards | Staging-first git, shell/secrets hooks, output hygiene | Not a guarantee that every install is production-ready | +| Iterative review | Opt-in external monitor, triage, Field Report cadence | Not autonomous model self-improvement | +| Workflow coordination | `/run-plan`, `/run-plan-all`, headless CLI, local Mission Control | Not a general graph / DAG engine | + +## Claim anchors (public paths) + +| Layer | Representative claims | Class | Public evidence | +|-------|----------------------|-------|-----------------| +| Prompt + HITL | Broad Intake + Gate A/B; Ask questions; prod promote HITL; backlog CRUD confirms | shipped core | `.cursor/commands/start-project.md`, `.cursor/rules/hitl-ask-questions.mdc`, `.cursor/commands/git-prod.md`, backlog commands | +| Context + memory | HANDOFF resume; Context Guardian hooks; memory loop; personas chrome-only | shipped core | `.cursor/rules/cursor-plan-handoff.mdc`, `.cursor/hooks.json`, `.cursor/rules/memory-loop.mdc`, `docs/personas-contract.md` | +| Safeguards | Staging-first hooks; shell/secrets guards; output hygiene | shipped core | `git-hooks/pre-commit`, `git-hooks/pre-push`, `.cursor/hooks.json`, `.cursor/rules/agent-output-hygiene.mdc` | +| Iterative review | Opt-in external plan review; findings-only default; durable audits | shipped core | `docs/external-plan-review.md`, `.cursor/scripts/plan-external-review.sh`, `.cursor/commands/plan-review-triage.md` | +| Workflow | Manual vs continuous run; multi-plan queue; headless runner; local Mission Control | shipped core | `.cursor/commands/run-plan.md`, `.cursor/commands/run-plan-all.md`, `scripts/plan-loop.sh`, `dashboard/` | +| Optional packs | Domain / stack packs beyond L0 | optional pack | `docs/domain-packs.md`, `docs/layers-spec.md` | +| Marketplace | Cursor Marketplace plugin submission | planned | Release-gate / parked submission checklist (not claimed shipped) | + +## Explicit unsupported list + +1. Autonomous model self-improvement or self-training. +2. General graph / DAG execution engine. +3. Hosted multi-tenant control plane or cloud HANDOFF sync. +4. Guaranteed production readiness without lane-qualified release evidence. +5. Silent auto-remediation of product code from external review when `autoRemediate` is false (default). + +## Lane note + +Released consumer lane (npm / public GitHub) is version-qualified separately from private staging. Pin or check `@dadado/agent-kit-cli` when you need a reproducible floor. See [getting started](getting-started.md), [layers](layers-spec.md), and [external plan review](external-plan-review.md). diff --git a/docs/getting-started.md b/docs/getting-started.md index f9b3549..18e8ce8 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -14,7 +14,7 @@ npx @dadado/agent-kit-cli install Unpinned `npx` resolves to the latest publish. Pin a version when you need a reproducible install: `npx @dadado/agent-kit-cli@x.y.z install` (replace `x.y.z` with a version from npm). -That's the whole install for kit L0. It drops a small set of rules and slash commands into `.cursor/`, a git routine into `autogit/`, and a manifest (`.cursor/agent-kit.json`) that records what was installed so the kit can update itself later without touching your work. Mission Control's `dashboard/` server is **not** copied into your project; the panel runs from the CLI package (Path C, after the publish that ships it) or from an agent-kit checkout. See [Mission Control production-ship constraints](#mission-control-production-ship-constraints). +That's the whole install for kit L0. It drops a small set of rules and slash commands into `.cursor/`, a git routine into `autogit/`, and a manifest (`.cursor/agent-kit.json`) that records what was installed so the kit can update itself later without touching your work. Mission Control's `dashboard/` server is **not** copied into your project; the panel runs from the CLI package (4.8.2 onward) or from an agent-kit checkout. See [Mission Control production-ship constraints](#mission-control-production-ship-constraints). Want a few extra bundles up front? Add packs (clean code, context tools, and more - see [domain packs](domain-packs.md)): @@ -48,7 +48,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. After a CLI publish that ships Path C, `agent-kit dashboard` resolves `dashboard/start.mjs` from the installed package; until then use a kit checkout or env/sibling discovery. Loopback only (`127.0.0.1`) by default. Opt-in LAN: `/dashboard-broadcast` / `npm run dashboard:broadcast` (token-gated). 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: `/dashboard-broadcast` / `npm run dashboard:broadcast` (token-gated). Posture: [Mission Control production-ship constraints](#mission-control-production-ship-constraints). ## The commands you get @@ -60,6 +60,7 @@ Keep this path light. No extra runtime packages beyond the CLI (`@clack/prompts` | `agent-kit add ` | Add one skill or pack later | | `agent-kit status` | Show install state, readiness summary, and profile origin | | `agent-kit update --check` | Notify-only version compare vs public tags (no L0 writes) | +| `agent-kit cursor-awareness --check` | Opt-in advisory: Cursor changelog vs native-audit inventory (no apply) | | `agent-kit update` | Explicit apply: pull latest rules/commands; leaves your own files alone | | `agent-kit diff` | Show what changed between what you have and the latest | | `agent-kit contribute` | Send an improvement you made locally back upstream | @@ -109,6 +110,8 @@ Do not re-author Gate A/B or continuous tick contracts here; link L0 commands wh Personas change **chat tone and CLI tick banners only**. Defaults by mode: Autopilot for `/continue-plan`, Night Shift for `/run-plan`, Ghost Runner for `agent-kit run-plan`. They never alter commits, HANDOFF, memory, or product documentation. Configure them after readiness (personalization step, Mission Control **Config** under More, or edit `agentPersona` in `.cursor/context/config.json`). Contract and contribute path: [personas-contract.md](personas-contract.md), [creating-personas.md](creating-personas.md). +For the full list of consumer-configurable knobs (session config, dashboard skin, install-time choices, CLI flags) with copy-paste snippets for each, see [consumer-configuration.md](consumer-configuration.md). + ### Less babysitting - **`/run-plan`** - the agent works through the plan to the end, checking off to-dos and pushing to staging when there's something to commit (Night Shift chat chrome by default). It picks the best execution strategy itself: worker delegation when your setup supports it (keeps the main chat from filling up), a same-chat loop otherwise. It never promotes to production on its own. The old `/run-plan-loop` and `/run-plan-orchestrated` still work as deprecated aliases. @@ -172,9 +175,9 @@ Mission Control is a **local, single-developer** observability panel. Treat it a Source of truth: `.cursor/memory/decisions/2026-07-27_mission-control-personal-local-only-posture.md` (default product goal), `.cursor/memory/decisions/2026-07-27_mission-control-opt-in-lan-broadcast.md` (opt-in LAN path), plus `.cursor/memory/decisions/2026-07-24_mission-control-local-only-security.md` and `.cursor/memory/decisions/2026-07-26_mission-control-config-write-allowlist.md` (technical guards). -**Where the panel runs:** Consumer `npx` / `install.md` installs kit L0 (including the `/dashboard` command text) but **does not** copy `dashboard/**` into the app tree. Snapshot root is always the operator workspace. The UI host is either (1) a published `@dadado/agent-kit-cli` that ships `dashboard/**` (Path C; Unreleased until that publish), or (2) an agent-kit checkout (`MISSION_CONTROL_KIT_ROOT` / `AGENT_KIT_HOME` / sibling `../agent-kit` / monorepo `dashboard/`). Start with `/dashboard`, `npm run dashboard`, `agent-kit dashboard`, or `node dashboard/start.mjs` (see root README). Several workspaces may run concurrent instances: each gets a stable listen port from its repo root (see printed URL / `system.port`); Mission Control never kills another workspace's listener. +**Where the panel runs:** Consumer `npx` / `install.md` installs kit L0 (including the `/dashboard` command text) but **does not** copy `dashboard/**` into the app tree. Snapshot root is always the operator workspace. The UI host is either (1) a published `@dadado/agent-kit-cli` that ships `dashboard/**` (Path C, 4.8.2 onward), or (2) an agent-kit checkout (`MISSION_CONTROL_KIT_ROOT` / `AGENT_KIT_HOME` / sibling `../agent-kit` / monorepo `dashboard/`). Start with `/dashboard`, `npm run dashboard`, `agent-kit dashboard`, or `node dashboard/start.mjs` (see root README). Several workspaces may run concurrent instances: each gets a stable listen port from its repo root (see printed URL / `system.port`); Mission Control never kills another workspace's listener. -**First failure (no `dashboard/start.mjs`):** reinstall a CLI version whose npm package includes `dashboard/` (check CHANGELOG Unreleased / publish checklist; do not assume `@dadado/agent-kit-cli@4.8.0` has Path C), or point env/sibling at a kit tree. Do not expect Port B alone to place the panel binary in the project. +**First failure (no `dashboard/start.mjs`):** the installed CLI is older than 4.8.2 (4.8.0 has no Path C assets; 4.8.1 was never published). Upgrade to 4.8.2+, or point env/sibling at a kit tree. Do not expect Port B alone to place the panel binary in the project. The exact git steps behind staging and production live in `autogit/gitupdate.md`; plan modes in `autogit/plan-routine.md`. Both are installed with the kit. Native hooks are listed in [layers-spec.md](layers-spec.md) (L0). @@ -187,5 +190,10 @@ If you're developing the kit (not just using it): 3. Try the scanner: `pnpm --filter @dadado/agent-kit-cli start scan` 4. Install into a test project: `pnpm --filter @dadado/agent-kit-cli start install --cwd /path/to/project` 5. Check this repo's own install: `pnpm --filter @dadado/agent-kit-cli start status` +6. Refresh this repo's own L0 from local source (factory self-consumer): + - First time: `pnpm --filter @dadado/agent-kit-cli start -- update --cwd . --seed-overlay` + - Later: `pnpm --filter @dadado/agent-kit-cli start -- update --cwd .` + + This is a local maintainer loop, not a public consumer update. See [CONTRIBUTING](CONTRIBUTING.md) for the three-way distinction (public consumer / factory self-consumer / public sync). See the root [README](../README.md) for the big picture and [CONTRIBUTING](CONTRIBUTING.md) for how changes flow. diff --git a/docs/layers-spec.md b/docs/layers-spec.md index 73ab1e4..01b5aa9 100644 --- a/docs/layers-spec.md +++ b/docs/layers-spec.md @@ -205,7 +205,9 @@ Only what is unique to the repo: - Local skills/commands not in the registry - `.cursor/HANDOFF.md`, `.cursor/plans/`, `.cursor/memory/`, `.cursor/context/` -**Golden rule:** never hand-edit an installed L0–L2 file to “fix the project”. Override via L3 or contribute upstream. +**Golden rule (overlay trees):** prefer not to hand-edit kit-owned files under `.cursor/agents/`, `.cursor/skills/`, or `.cursor/commands/` to “fix the project”. Those three trees use the consumer overlay: local drift is preserved (`preserved-customized`) via the managed-content ledger. For other L0–L2 paths (including pack `rule` members under `.cursor/rules/`), in-place edits are still overwritten on `update`; use a distinct L3 basename, an explicit manifest `overrides` entry, or contribute upstream. + +**Consumer overlay (agents / skills / commands):** user-added basenames under `.cursor/agents/`, `.cursor/skills/`, and `.cursor/commands/` survive `update` (they are not in the apply set unless a pack/skill targets them). Kit-owned files in those trees that diverge from the managed-content ledger are preserved (`preserved-customized`) instead of silent overwrite; unedited kit files still refresh. Pack rules under `.cursor/rules/` are **not** in this overlay and still clobber on drift. Do not blanket-protect `.cursor/agents/**` (or skills/commands) in `protected` — that blocks pack / `agent-kit add` installs. See decision `2026-07-29_consumer-l0-overlay-agents-optional.md`. Protected paths are listed in the manifest so `update` skips them. diff --git a/docs/migrate-consumer.md b/docs/migrate-consumer.md index 0f35f09..1954d7d 100644 --- a/docs/migrate-consumer.md +++ b/docs/migrate-consumer.md @@ -27,6 +27,8 @@ pnpm --filter @dadado/agent-kit-cli start -- install \ .cursor/commands/YOUR_PROJECT-only.md ``` +User-added agents/skills/commands with distinct basenames do not need blanket `protected` globs (consumer overlay preserves them and customized kit-owned overlay files). Prefer committing `.cursor/agent-kit.managed-hashes.json` with the project so the ledger survives clone. Avoid `.cursor/agents/**` / `.cursor/skills/**` / `.cursor/commands/**` blankets that block pack install. + 4. **Add registry skills** the project already used: ```bash diff --git a/docs/npm-publish-checklist.md b/docs/npm-publish-checklist.md index e7c33f4..1b2b68b 100644 --- a/docs/npm-publish-checklist.md +++ b/docs/npm-publish-checklist.md @@ -58,6 +58,7 @@ pnpm --filter @dadado/agent-kit-cli publish --dry-run --access public - [ ] Review the tarball file list (only `packages/cli/dist`, `packages/cli/dashboard/**` after Path C, and declared `files`; no `.cursor/`, secrets, or private memory). - [ ] Confirm reported version matches `packages/cli/package.json`. - [ ] Path C pack gate (no live npm tag required): `node scripts/verify-cli-dashboard-pack.mjs` exits 0 (`dashboard/start.mjs` + `start-broadcast.mjs` present). Version bump for the publish that first ships Path C remains `/git-prod` HITL (R3); do not bump from the verify script. +- [ ] Before Path C promote / publish: run `node scripts/sync-cli-dashboard.mjs` so `packages/cli/dashboard/` matches `dashboard/` SoT (Crew Monitor and other panel changes ship in the tarball). Dry-run does not replace registry verification; it does not contact npm to confirm 404 vs published state. diff --git a/docs/repository-boundaries.md b/docs/repository-boundaries.md index 1bbf0d8..25234fc 100644 --- a/docs/repository-boundaries.md +++ b/docs/repository-boundaries.md @@ -100,12 +100,12 @@ Registry skills and rules are agent instructions executed with filesystem and gi **Q: Why keep private `agent-kit-dev` when public looks like a complete monorepo?** **A:** Private is the **factory**; public is the **storefront**. Even after Phase B, private still handles: -- CLI development and packaging (`packages/cli`), including Path C sync of repo-root `dashboard/` into the CLI pack at build/`prepack` (public sync allowlists product docs; the npm tarball is what ships the panel binary after that publish) +- CLI development and packaging (`packages/cli`), including Path C sync of repo-root `dashboard/` into the CLI pack at build/`prepack` (public sync allowlists product docs; the npm tarball is what ships the panel to consumers, from 4.8.2 onward) - Sync tooling and scripts (`scripts/sync-public.mjs`) - Denylist patterns and git workflow enforcement - Dogfood session memory (`.cursor/memory/`) -Public is the **registry source of truth** plus allowlisted product files for URL installation. The sync preserves public-owned `registry/**` when replacing the allowlist tree. The CLI npm package may include `dashboard/**` after a Path C publish; that is packaging, not a change to the public-sync allowlist. +Public is the **registry source of truth** plus allowlisted product files for URL installation. The sync preserves public-owned `registry/**` when replacing the allowlist tree. The CLI npm package includes `dashboard/**` from 4.8.2 onward; that is packaging, not a change to the public-sync allowlist. **Q: What about plans, handoff, and session state?** diff --git a/git-hooks/README.md b/git-hooks/README.md index ffd1727..4340767 100644 --- a/git-hooks/README.md +++ b/git-hooks/README.md @@ -7,7 +7,7 @@ Local guards for the DevOps spine (staging -> prod flow). Git doesn't version `. | Hook | What it does | |------|--------------| | `pre-commit` | Aborts direct commit to `main`/`master`. Work goes to working branch or `staging`. | -| `pre-push` | Aborts direct push to `main`/`master` on any remote, unless `ALLOW_MAIN_PUSH=1` (used by `/git-prod`). | +| `pre-push` | Aborts direct push to `main`/`master` on any remote, unless `ALLOW_MAIN_PUSH=1` (used by `/git-prod`). Also aborts force-update or delete of `refs/tags/v*` unless `ALLOW_TAG_FORCE=1`. | | `prepare-commit-msg` | Removes the `Co-authored-by: Cursor` trailer from commit message. | ## Install @@ -32,8 +32,24 @@ After merging `staging` into `main` locally, publish with the env gate (keeps th ALLOW_MAIN_PUSH=1 git push origin main ``` +The same form works from the Cursor agent Shell: `agent-kit guard shell` (CLI SoT; thin beforeShellExecution adapter) allows that command when `ALLOW_MAIN_PUSH=1` is present (inline or process env), matching `pre-push`. Bare `git push origin main` stays denied in both places. + +**WARNING**: Avoid exporting `ALLOW_MAIN_PUSH=1` in your IDE session or terminal environment (e.g., `export ALLOW_MAIN_PUSH=1`). This disables main-push protection for every subsequent agent Shell command until unset, not just the intended `/git-prod` push. Use the inline prefix form `ALLOW_MAIN_PUSH=1 git push origin main` for authorized single commands only. + Do **not** set `ALLOW_MAIN_PUSH` for everyday pushes. Accidental `git push origin main` stays blocked. +## Immutable `v*` tags + +`pre-push` blocks force-updating or deleting `refs/tags/v*` (new tag creates still allowed). Aligns with `autogit/gitupdate.md` §9.5: if tag CI fails after the first push, cut a **new** patch tag rather than rewriting the published one. + +Emergency rewrite (rare): + +```sh +ALLOW_TAG_FORCE=1 git push --force origin vX.Y.Z +``` + +**Optional GitHub ruleset (operator):** on the private repo, add a ruleset for `v*` tags with "Restrict deletions" and "Block force pushes" so server-side policy matches the local hook even when `--no-verify` is used. + ## Emergency override When really necessary (rare), skip all hooks once: diff --git a/git-hooks/pre-push b/git-hooks/pre-push index be23e99..027005a 100644 --- a/git-hooks/pre-push +++ b/git-hooks/pre-push @@ -1,23 +1,58 @@ #!/bin/sh # Block direct pushes to the production branch (main/master) on any remote. # main is promoted only via `git prod` (merge from staging). Feature/staging pushes are fine. +# Also block force-updating or deleting published v* tags (immutability). # Install: cp git-hooks/pre-push .git/hooks/pre-push && chmod +x .git/hooks/pre-push # # Authorized prod push (from the `git prod` routine): # ALLOW_MAIN_PUSH=1 git push origin main # +# Authorized tag rewrite (emergency only; prefer a new patch tag): +# ALLOW_TAG_FORCE=1 git push --force origin vX.Y.Z +# +# WARNING: `git push --no-verify` skips this entire hook (branch + tag immutability). +# Prefer ALLOW_MAIN_PUSH=1 / ALLOW_TAG_FORCE=1 so only the intended gate is bypassed. +# Never use --no-verify for routine work; it can corrupt published v* tags. +# # Git feeds pre-push lines on stdin: +zero=0000000000000000000000000000000000000000 while read -r local_ref local_sha remote_ref remote_sha; do + # Fail closed on pathological ref names (shell metacharacters / control chars). + case "$remote_ref" in + *[!A-Za-z0-9._/-]*|"") + echo "BLOCKED: refusing unsafe remote ref name." >&2 + exit 1 + ;; + esac case "$remote_ref" in refs/heads/main|refs/heads/master) if [ "${ALLOW_MAIN_PUSH:-}" = "1" ]; then - echo "pre-push: ALLOW_MAIN_PUSH=1 - allowing push to $remote_ref (git prod)." >&2 + echo "pre-push: ALLOW_MAIN_PUSH=1 - allowing push to ${remote_ref} (git prod)." >&2 continue fi - echo "BLOCKED: direct push to '$remote_ref'." >&2 + echo "BLOCKED: direct push to '${remote_ref}'." >&2 echo "Promote via the staging -> prod flow ('git prod'), not a direct push." >&2 echo "Authorized override: ALLOW_MAIN_PUSH=1 git push origin main" >&2 - echo "Emergency only: git push --no-verify" >&2 + echo "Do not use git push --no-verify (skips tag immutability too)." >&2 + exit 1 + ;; + refs/tags/v*) + # New tag create (remote absent) is allowed. Force-move or delete is blocked. + if [ "${remote_sha}" = "${zero}" ]; then + continue + fi + if [ "${ALLOW_TAG_FORCE:-}" = "1" ]; then + echo "pre-push: ALLOW_TAG_FORCE=1 - allowing update/delete of ${remote_ref}." >&2 + continue + fi + if [ "${local_sha}" = "${zero}" ]; then + echo "BLOCKED: deleting immutable tag '${remote_ref}'." >&2 + else + echo "BLOCKED: force-moving immutable tag '${remote_ref}' (${remote_sha} -> ${local_sha})." >&2 + fi + echo "Published v* tags must not move. Cut a new patch tag on the fixed commit instead." >&2 + echo "See autogit/gitupdate.md section 9.5. Emergency override: ALLOW_TAG_FORCE=1" >&2 + echo "Do not use git push --no-verify (disables all pre-push protections)." >&2 exit 1 ;; esac diff --git a/install.md b/install.md index 23aad7e..d93a012 100644 --- a/install.md +++ b/install.md @@ -26,7 +26,7 @@ Optional L1 packs (separate command): npx @dadado/agent-kit-cli install --pack clean-code,context-management ``` -After: `agent-kit status` (or `npx @dadado/agent-kit-cli status`). Kit L0 does **not** copy Mission Control's `dashboard/` tree into the project. The panel runs from a CLI package that ships `dashboard/` (Path C, after that publish) or from an agent-kit checkout (see [Getting started - Mission Control](docs/getting-started.md#mission-control-production-ship-constraints)). If `/dashboard` or `agent-kit dashboard` reports missing `start.mjs`, reinstall a Path C CLI or set kit-host env/sibling; do not assume older npm tags include the panel assets. +After: `agent-kit status` (or `npx @dadado/agent-kit-cli status`). Kit L0 does **not** copy Mission Control's `dashboard/` tree into the project. The panel runs from the CLI package, which ships `dashboard/` from 4.8.2 onward, or from an agent-kit checkout (see [Getting started - Mission Control](docs/getting-started.md#mission-control-production-ship-constraints)). If `/dashboard` or `agent-kit dashboard` reports missing `start.mjs`, the installed CLI predates 4.8.2: upgrade it, or set kit-host env/sibling. Contributors working from a kit monorepo checkout: use the local CLI examples in [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) (Working on the kit). Do not paste monorepo `pnpm --filter` commands into a consumer project. @@ -104,6 +104,8 @@ Copy **only** these artifacts (same content from SoT / registry), not the monore | `.cursor/commands/plan-external-review.md` | idem | | `.cursor/commands/plan-review-triage.md` | idem | | `.cursor/commands/field-report-resolve.md` | idem | +| `.cursor/commands/dogfood.md` | idem | +| `.cursor/commands/cursor-update-awareness.md` | idem | | `.cursor/context/templates/plan.md` | idem | | `.cursor/context/templates/context-pack.md` | idem | | `.cursor/context/templates/task-brief.md` | idem | @@ -134,9 +136,13 @@ chmod +x .cursor/hooks/agent/*.sh .cursor/hooks/pre-commit/check-secrets.sh Managed `agent-kit install` / `update` already preserves the executable bit via `copyFile`. -If the agent has the Agent Kit monorepo open as workspace, use those paths. If only in consumer project, fetch from the public registry URL: `https://raw.githubusercontent.com/agent-kit-startup/agent-kit/main/` + each file path. Use **Ask questions** tool for any registry source confirmation: +If the agent has the Agent Kit monorepo open as workspace, use those paths. If only in consumer project, prefer **Port A** (`npx @dadado/agent-kit-cli install`) so files come from the integrity-checked npm package. Port B raw fetches have **no package checksum**: treat them as a fallback only. + +Default public base URL: `https://raw.githubusercontent.com/agent-kit-startup/agent-kit/main/` + each file path. Use **Ask questions** for any registry source confirmation: Options: `Fetch from public registry` / `Use different registry URL` / `Skip registry for now` +When the operator picks **Use different registry URL**, stop and require an explicit trust decision before fetching. Do not silently substitute an untrusted host. Prefer pinning a known public commit SHA (or tag) in the URL path over floating `main` when the operator needs reproducibility. After copy, run `agent-kit doctor` / `agent-kit status` when the CLI is available so the install can be validated. + **Fallback:** if Ask questions tool unavailable, ask the same options in chat as numbered list. ### 3. Manifest `.cursor/agent-kit.json` diff --git a/package.json b/package.json index 6d0e476..81d70cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-kit", - "version": "4.8.4", + "version": "4.8.9", "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": "MIT", @@ -14,6 +14,22 @@ "start:dashboard": "node dashboard/serve.mjs", "git:trigger-public-sync": "bash scripts/trigger-public-sync-after-prod.sh", "registry:build": "node scripts/build-registry.mjs", + "evidence:file-ledger": "node scripts/generate-file-ledger.mjs", + "evidence:file-ledger:check": "node scripts/generate-file-ledger.mjs --check && node --test scripts/generate-file-ledger.test.mjs", + "evidence:history-ledger": "node scripts/generate-history-ledger.mjs", + "evidence:history-ledger:check": "node scripts/generate-history-ledger.mjs --check && node --test scripts/generate-history-ledger.test.mjs", + "evidence:artifact-ledger": "node scripts/generate-artifact-ledger.mjs", + "evidence:artifact-ledger:check": "node scripts/generate-artifact-ledger.mjs --check && node --test scripts/generate-artifact-ledger.test.mjs", + "evidence:authority-graph": "node scripts/generate-authority-graph.mjs", + "evidence:authority-graph:check": "node scripts/generate-authority-graph.mjs --check && node --test scripts/generate-authority-graph.test.mjs", + "evidence:knowledge-classification": "node scripts/generate-knowledge-classification.mjs --handoff-fixture scripts/fixtures/handoff-knowledge-test.md", + "evidence:knowledge-classification:check": "node scripts/generate-knowledge-classification.mjs --check --handoff-fixture scripts/fixtures/handoff-knowledge-test.md && node --test scripts/generate-knowledge-classification.test.mjs", + "evidence:codebase-findings": "node scripts/generate-codebase-findings.mjs", + "evidence:codebase-findings:check": "node --test scripts/generate-codebase-findings.test.mjs", + "evidence:risk-hotspots": "node scripts/score-codebase-risk-surface.mjs", + "evidence:risk-hotspots:check": "node --test scripts/score-codebase-risk-surface.test.mjs", + "check:public-deny-links": "node scripts/check-public-deny-links.mjs", + "check:public-deny-links:test": "node --test scripts/check-public-deny-links.test.mjs", "build": "turbo run build", "dev": "turbo run dev", "lint": "turbo run lint", diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000..6ac8bb6 --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,59 @@ +# @dadado/agent-kit-cli + +Agent Kit CLI: HITL operating-layer install and tooling for AI-assisted IDEs (rules, skills, plan/handoff, staging-to-prod, memory). It installs local workspace contracts; it is not a hosted control plane or graph workflow runtime. + +## Install + +From your project root (Node.js 20+): + +```bash +npx @dadado/agent-kit-cli install +``` + +Unpinned `npx` resolves to the latest publish. Pin a version when you need a reproducible install: + +```bash +npx @dadado/agent-kit-cli@x.y.z install +``` + +Optional L1 packs: + +```bash +npx @dadado/agent-kit-cli install --pack clean-code,context-management +``` + +Install writes L0 kit files under `.cursor/`, plus `autogit/` and `.cursor/agent-kit.json`. It does not copy the Agent Kit monorepo into your project. + +After install, in Cursor run `/agent-kit-onboard`, then `/start-project` when you have a deliverable. + +## Mission Control + +From package version **4.8.2** onward, this npm package includes Mission Control panel assets under `dashboard/`. In a consumer workspace after install: + +```bash +agent-kit dashboard +``` + +The panel binds to loopback by default, serves its own static files, and snapshots the current workspace. L0 install does **not** copy `dashboard/` into your app; `agent-kit dashboard` resolves the panel from the installed package. + +Older tags before 4.8.2 do not include those assets. Prefer a current pin, or point `MISSION_CONTROL_KIT_ROOT` / `AGENT_KIT_HOME` at an agent-kit checkout that contains `dashboard/`. + +## Common commands + +| Command | Purpose | +|---------|---------| +| `agent-kit install` | Bootstrap L0 (+ optional packs) and write `agent-kit.json` | +| `agent-kit status` | Show installed kit version and profile | +| `agent-kit doctor` | Diagnose repository readiness | +| `agent-kit update` | Re-apply L0/packs/skills from the registry | +| `agent-kit dashboard` | Start Mission Control for this workspace | +| `agent-kit add ` | Install a skill or L1 pack | +| `agent-kit run-plan` | Headless continuous plan runner (never promotes to production) | + +Run `agent-kit --help` or `agent-kit --help` for the full surface. + +## Docs + +- Public repository and guides: https://github.com/agent-kit-startup/agent-kit +- Install contract (chat / no-CLI fallback): https://raw.githubusercontent.com/agent-kit-startup/agent-kit/main/install.md +- Getting started: https://github.com/agent-kit-startup/agent-kit/blob/main/docs/getting-started.md diff --git a/packages/cli/package.json b/packages/cli/package.json index 5741675..ccfe036 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@dadado/agent-kit-cli", - "version": "4.8.4", + "version": "4.8.9", "description": "Agent Kit CLI: HITL framework install and tooling for AI-assisted IDEs (rules, skills, plan/handoff, context).", "type": "module", "bin": { diff --git a/packages/cli/src/commands/cursor-awareness.ts b/packages/cli/src/commands/cursor-awareness.ts new file mode 100644 index 0000000..86ca962 --- /dev/null +++ b/packages/cli/src/commands/cursor-awareness.ts @@ -0,0 +1,73 @@ +import { defineCommand } from "citty"; +import { checkCursorUpdateAwareness } from "../lifecycle/cursor-update-awareness.js"; +import { logger } from "../utils/logger.js"; + +export const cursorAwarenessCommand = defineCommand({ + meta: { + name: "cursor-awareness", + description: + "Opt-in advisory: diff Cursor changelog / native-audit inventory for gaps (never apply, never Field Reports)", + }, + args: { + cwd: { + type: "string", + default: process.cwd(), + }, + check: { + type: "boolean", + description: "Check-only (default true; apply is never supported)", + default: true, + }, + json: { + type: "boolean", + description: "Print machine-readable JSON", + default: false, + }, + "respect-prefs": { + type: "boolean", + description: + "Honor cursorUpdateCheck.enabled and intervalDays from .cursor/context/config.json", + default: false, + }, + stamp: { + type: "boolean", + description: "Persist cursorUpdateCheck.lastCheckedAt / lastSeenCursorVersion", + default: false, + }, + offline: { + type: "boolean", + description: "Skip changelog network fetch; inventory-only advisory", + default: false, + }, + }, + async run({ args }) { + // Apply is never supported; --check is informational only. + void args.check; + + const result = await checkCursorUpdateAwareness(args.cwd, { + respectPrefs: Boolean(args["respect-prefs"]), + stamp: Boolean(args.stamp), + offline: Boolean(args.offline), + }); + + if (args.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + const line = `[${result.status}] ${result.message}`; + if (result.status === "error") logger.error(line); + else if (result.status === "gaps-found") logger.warn(line); + else if (result.status.startsWith("skipped-")) logger.warn(line); + else logger.info(line); + if (result.gaps.length > 0 && !args.json) { + for (const gap of result.gaps.slice(0, 12)) { + logger.info(`- ${gap.id} (${gap.severity}): ${gap.evidence} → ${gap.suggestedRoute}`); + } + if (result.gaps.length > 12) { + logger.info(`… and ${result.gaps.length - 12} more (use --json)`); + } + } + } + + if (result.status === "error") process.exitCode = 2; + }, +}); diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index ebfb63d..7bd68fc 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -54,6 +54,20 @@ function printDoctorSummary(result: DoctorResult): void { console.log(` - ${reason}`); } } + if (result.hooks.advisories.length > 0) { + console.log("hooks advisories (soft; install via cp, see git-hooks/README.md):"); + for (const tip of result.hooks.advisories.slice(0, 5)) { + console.log(` - ${tip}`); + } + } + + // Check for ALLOW_MAIN_PUSH environment variable + if (process.env.ALLOW_MAIN_PUSH === "1") { + console.log("⚠️ WARNING: ALLOW_MAIN_PUSH=1 is set in environment"); + console.log(" This disables main-push protection for agent Shell commands."); + console.log(" Consider unsetting it: unset ALLOW_MAIN_PUSH"); + } + console.log( nextAction ? `Next: ${nextAction.recommendation}` diff --git a/packages/cli/src/commands/update.test.ts b/packages/cli/src/commands/update.test.ts new file mode 100644 index 0000000..7bb59ef --- /dev/null +++ b/packages/cli/src/commands/update.test.ts @@ -0,0 +1,89 @@ +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { KIT_VERSION } from "../lifecycle/version.js"; +import { updateCommand } from "./update.js"; + +const mockSyncFromManifest = vi.hoisted(() => + vi.fn(async (_registryRoot: string, _projectRoot: string, _manifest: unknown) => ({ + written: [], + removed: [], + collisions: [], + skippedProtected: [], + missing: [], + unchanged: [".cursor/rules/ux-tone.mdc"], + preservedCustomized: [], + })), +); + +vi.mock("../lifecycle/sync.js", () => ({ + syncFromManifest: mockSyncFromManifest, +})); + +describe("updateCommand", () => { + // Under full-suite parallel load this test can exceed the default 5s + // (passes alone ~4s); raise headroom so "tests green" claims stay honest (R7). + it("preserves personalization and overrides on apply", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ak-update-preserve-")); + await mkdir(path.join(root, ".cursor"), { recursive: true }); + const personalization = { + contractVersion: 1, + generatorVersion: KIT_VERSION, + origin: "repository-profile", + resultPath: ".cursor/context/personalization.json", + }; + await writeFile( + path.join(root, ".cursor", "agent-kit.json"), + JSON.stringify( + { + schemaVersion: 1, + version: KIT_VERSION, + profile: "ops", + packs: ["clean-code"], + skills: ["json-data-config"], + protected: [".cursor/HANDOFF.md"], + overrides: [{ path: ".cursor/rules/custom.mdc", note: "local" }], + personalization, + registry: { url: "https://github.com/agent-kit-startup/agent-kit", ref: "main" }, + installedAt: "2026-07-30T00:00:00.000Z", + }, + null, + 2, + ), + "utf8", + ); + + await ( + updateCommand.run as unknown as (ctx: { args: Record }) => Promise + )({ + args: { + _: [], + cwd: root, + check: false, + json: false, + "respect-prefs": false, + stamp: false, + "seed-overlay": false, + registry: undefined as unknown as string, + url: undefined as unknown as string, + ref: undefined as unknown as string, + refresh: false, + }, + }); + + const saved = JSON.parse(await readFile(path.join(root, ".cursor", "agent-kit.json"), "utf8")); + expect(saved.personalization).toEqual(personalization); + expect(saved.overrides).toEqual([{ path: ".cursor/rules/custom.mdc", note: "local" }]); + expect(saved.profile).toBe("ops"); + expect(saved.packs).toEqual(["clean-code"]); + expect(saved.skills).toEqual(["json-data-config"]); + expect(saved.registry).toEqual({ + url: "https://github.com/agent-kit-startup/agent-kit", + ref: "main", + }); + // ADR factory-pseudo-consumer decision 4: no version change must keep the + // original installedAt value, not a fresh timestamp. + expect(saved.installedAt).toBe("2026-07-30T00:00:00.000Z"); + }, 15_000); +}); diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index d476030..919a769 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -1,6 +1,7 @@ import { defineCommand } from "citty"; import { buildManifest, saveManifest } from "../lifecycle/apply.js"; import { checkForUpdates } from "../lifecycle/check-updates.js"; +import { seedManagedHashLedger } from "../lifecycle/overlay.js"; import { logApplyStats } from "../lifecycle/report.js"; import { REGISTRY_CLI_ARGS, resolveRegistryFromCli } from "../lifecycle/resolve-cli.js"; import { syncFromManifest } from "../lifecycle/sync.js"; @@ -41,6 +42,12 @@ export const updateCommand = defineCommand({ description: "Persist updateCheck.lastCheckedAt after a network check", default: false, }, + "seed-overlay": { + type: "boolean", + description: + "Seed the managed-hash ledger from current local overlay files before applying (factory/dogfood only; consumers should not use this)", + default: false, + }, ...REGISTRY_CLI_ARGS, }, async run({ args }) { @@ -90,12 +97,20 @@ export const updateCommand = defineCommand({ packs: existing.packs, skills: existing.skills, protected: existing.protected, + personalization: existing.personalization, registryUrl: registry.url ?? existing.registry?.url, registryRef: registry.ref ?? existing.registry?.ref, }); - // Preserve overrides from existing manifest + // Preserve optional metadata from existing manifest if (existing.overrides?.length) next.overrides = existing.overrides; + if (next.version === existing.version && existing.installedAt) { + next.installedAt = existing.installedAt; + } + if (args["seed-overlay"]) { + await seedManagedHashLedger(args.cwd); + logger.info("Seeded managed-hash ledger from current local overlay files."); + } const stats = await syncFromManifest(registry.root, args.cwd, next); await saveManifest(args.cwd, next); logApplyStats(stats); diff --git a/packages/cli/src/dashboard/field-report-prompts.test.ts b/packages/cli/src/dashboard/field-report-prompts.test.ts index 6067bc3..e91517e 100644 --- a/packages/cli/src/dashboard/field-report-prompts.test.ts +++ b/packages/cli/src/dashboard/field-report-prompts.test.ts @@ -586,12 +586,23 @@ describe("Flight Log panel contract (dashboard.html)", () => { expect(dashboardHtml).not.toContain("Resolve all"); }); - it("exposes copy text and HANDOFF path actions on Flight Log cards", () => { - expect(dashboardHtml).toContain("Copy text"); - expect(dashboardHtml).toContain("Gaps text"); - expect(dashboardHtml).toContain("copyForPasteHandler(text, 'Gaps text', 'chatInput')"); - expect(dashboardHtml).toContain("copyRepoPathHandler(sourcePath)"); - expect(dashboardHtml).toContain("PATH_COPY_LABEL"); + it("exposes one composed copy action per Flight Log entry", () => { + expect(dashboardHtml).not.toContain("Copy text"); + expect(dashboardHtml).not.toContain("flight-log-copy-text-"); + expect(dashboardHtml).not.toContain("flight-log-copy-path-"); + expect(dashboardHtml).not.toContain("flight-log-warning-copy-text-"); + expect(dashboardHtml).not.toContain("flight-log-warning-copy-path-"); + expect(dashboardHtml).not.toContain("flight-log-open-triage-path-"); + expect(dashboardHtml).toContain( + "copyForPasteHandler(actionCommand, 'fix prompt', 'chatInput')", + ); + expect(dashboardHtml).toContain( + "copyForPasteHandler(actionCommand, actionSubject, 'chatInput')", + ); + expect(dashboardHtml).toContain("flight-log-action-"); + expect(dashboardHtml).toContain("flight-log-warning-action-"); + expect(dashboardHtml).toContain("flight-log-open-triage-action-"); + expect(dashboardHtml).toContain("fl?.currentAction"); }); it("does not keep orphaned Field Report attention render helpers", () => { diff --git a/packages/cli/src/dashboard/live-refresh.test.ts b/packages/cli/src/dashboard/live-refresh.test.ts index c03dec0..121d24e 100644 --- a/packages/cli/src/dashboard/live-refresh.test.ts +++ b/packages/cli/src/dashboard/live-refresh.test.ts @@ -30,6 +30,23 @@ describe("dashboard-data: handoff health", () => { }); }); +describe("dashboard-data: agents health L0-optional", () => { + it("keeps check id agents but does not hard-fail on empty inventory", () => { + expect(dataSource).toMatch(/id:\s*["']agents["'][\s\S]*?ok:\s*true\b/); + expect(dataSource).not.toMatch( + /id:\s*["']agents["'][\s\S]*?ok:\s*SNAPSHOT\.agents\.length\s*>\s*0/, + ); + }); + + it("maps Healthcenter agents autofix to null (ok is constant-true)", () => { + const agentsMeta = dashboardHtml.match(/agents:\s*\{[\s\S]*?autofix:\s*null,?\s*\n\s*\},/)?.[0]; + expect(agentsMeta).toBeTruthy(); + expect(agentsMeta).toContain("Intentionally unreachable"); + expect(agentsMeta).toContain("autofix: null"); + expect(agentsMeta).not.toMatch(/autofix:\s*\{/); + }); +}); + describe("live-refresh: watch coverage", () => { it("covers every in-repo path that dashboard-data reads", () => { const root = "/repo"; diff --git a/packages/cli/src/dashboard/plugin-ux-validation.test.ts b/packages/cli/src/dashboard/plugin-ux-validation.test.ts index e0b9781..6d935fa 100644 --- a/packages/cli/src/dashboard/plugin-ux-validation.test.ts +++ b/packages/cli/src/dashboard/plugin-ux-validation.test.ts @@ -200,6 +200,7 @@ describe("plugin-ux-validation: narrow shell + a11y chrome", () => { expect(dashboardHtml).toContain("--mc-radius-lg: 12px"); expect(dashboardHtml).toContain("--mc-radius-pill: 999px"); expect(dashboardHtml).toContain("--mc-card-padding: 16px"); + expect(dashboardHtml).toContain("--mc-card-padding-dense: 10px 12px"); expect(dashboardHtml).toContain("--mc-header-pad-x: 24px"); expect(dashboardHtml).toContain("--mc-header-pad-x-end: 8px"); expect(dashboardHtml).toContain("--mc-content-pad: 24px"); @@ -226,6 +227,38 @@ describe("plugin-ux-validation: narrow shell + a11y chrome", () => { ); expect(dashboardHtml).toMatch(/\.card\s*\{[^}]*padding:\s*var\(--mc-card-padding\)/); expect(dashboardHtml).toMatch(/\.plan-card\s*\{[^}]*padding:\s*var\(--mc-card-padding\)/); + // Unified card contract (G9): dense list cards share the dense padding + // token and the ladder radius; no per-card pixel one-offs. + expect(dashboardHtml).toMatch( + /\.agent-card\s*\{[^}]*padding:\s*var\(--mc-card-padding-dense\)/, + ); + expect(dashboardHtml).toMatch(/\.agent-card\s*\{[^}]*border-radius:\s*var\(--mc-radius\)/); + expect(dashboardHtml).toMatch( + /\.command-card\s*\{[^}]*padding:\s*var\(--mc-card-padding-dense\)/, + ); + expect(dashboardHtml).toMatch( + /\.skill-card\s*\{[^}]*padding:\s*var\(--mc-card-padding-dense\)/, + ); + expect(dashboardHtml).toMatch( + /\.process-card\s*\{[^}]*padding:\s*var\(--mc-card-padding-dense\)/, + ); + expect(dashboardHtml).toMatch( + /\.recent-plan-card\s*\{[^}]*padding:\s*var\(--mc-card-padding-dense\)/, + ); + expect(dashboardHtml).toMatch( + /\.flight-log-card\s*\{[^}]*padding:\s*var\(--mc-card-padding-dense\)/, + ); + expect(dashboardHtml).toMatch(/\.flight-log-card\s*\{[^}]*border-radius:\s*var\(--mc-radius\)/); + expect(dashboardHtml).toMatch( + /\.memory-panel\s*\{[^}]*border-radius:\s*var\(--mc-radius-lg\)[^}]*padding:\s*var\(--mc-card-padding\)/, + ); + // Empty states scale with the card density ladder across viewport modes. + expect(dashboardHtml).toMatch( + /\.empty-state\s*\{[^}]*padding:\s*calc\(var\(--mc-card-padding\) \* 2\.5\)/, + ); + expect(dashboardHtml).toMatch( + /\.empty-state-cta\s*\{[^}]*padding:\s*calc\(var\(--mc-card-padding\) \* 2\)/, + ); expect(dashboardHtml).toMatch( /\.header-version\s*\{[^}]*font-size:\s*var\(--mc-chrome-meta-size\)/, ); @@ -270,10 +303,15 @@ describe("plugin-ux-validation: narrow shell + a11y chrome", () => { dashboardHtml.match(/id="navMoreMenu"[\s\S]*?id="navSkinsLabel"/)?.[0] ?? ""; expect(moreMenuBlock.length).toBeGreaterThan(0); expect(moreMenuBlock).not.toMatch(/ { ); }); + it("renders the Commands tab as an actionable card grid with CRUD CTAs and lock markers", () => { + // Card grid replaces the flat chip list; decorative row dots stay out. + expect(dashboardHtml).toContain("command-card"); + expect(dashboardHtml).toContain("command-card-head"); + expect(dashboardHtml).toContain("command-actions"); + expect(dashboardHtml).not.toContain("command-item"); + // Per-card actions are explicit copy-only buttons (clear run/edit/delete affordances). + expect(dashboardHtml).toContain("Copy run command"); + expect(dashboardHtml).toContain("Copy edit prompt"); + expect(dashboardHtml).toContain("Copy delete prompt"); + // Kit-managed commands get a lock badge and no fake edit/delete affordances. + expect(dashboardHtml).toContain("command-lock"); + expect(dashboardHtml).toContain("Kit managed"); + expect(dashboardHtml).toContain("read-only here"); + expect(dashboardHtml).toContain("c.kitManaged === true"); + expect(dashboardHtml).toContain('data-kit-managed="true"'); + expect(dashboardHtml).toMatch(/kitManaged\s*\?\s*''\s*:\s*`"); + expect(dashboardHtml).toContain("commandEditableCount"); + }); + + it("renders the Skills tab as an actionable card grid with CRUD CTAs and lock markers", () => { + // Card grid replaces the flat category chip list; decorative category dots stay out. + expect(dashboardHtml).toContain("skill-card"); + expect(dashboardHtml).toContain("skill-card-head"); + expect(dashboardHtml).toContain("skill-actions"); + expect(dashboardHtml).not.toContain("skills-category"); + expect(dashboardHtml).not.toContain("skill-tag"); + // Per-card actions are explicit copy-only buttons (clear use/edit/delete affordances). + expect(dashboardHtml).toContain("Copy use prompt"); + expect(dashboardHtml).toContain("Copy edit prompt"); + expect(dashboardHtml).toContain("Copy delete prompt"); + // Kit-managed skills get a lock badge and no fake edit/delete affordances. + expect(dashboardHtml).toContain("skill-lock"); + expect(dashboardHtml).toContain("Kit managed"); + expect(dashboardHtml).toContain("read-only here"); + expect(dashboardHtml).toContain("s.kitManaged === true"); + expect(dashboardHtml).toContain('data-kit-managed="true"'); + expect(dashboardHtml).toMatch(/kitManaged\s*\?\s*''\s*:\s*` { + // Card grid replaces the accordion roster; decorative row dots and hash-color icons stay out. + expect(dashboardHtml).toContain("agent-card"); + expect(dashboardHtml).toContain("agent-card-head"); + expect(dashboardHtml).toContain("agent-actions"); + expect(dashboardHtml).not.toContain("agent-item"); + expect(dashboardHtml).not.toContain("agent-details"); + expect(dashboardHtml).not.toContain("agent-icon"); + expect(dashboardHtml).not.toContain("toggleAgentDetails"); + // Per-card actions are explicit copy-only buttons (clear use/edit/delete affordances). + expect(dashboardHtml).toContain("Copy use prompt"); + expect(dashboardHtml).toContain("Copy edit prompt"); + expect(dashboardHtml).toContain("Copy delete prompt"); + // Kit-managed agents get a lock badge and no fake edit/delete affordances. + expect(dashboardHtml).toContain("agent-lock"); + expect(dashboardHtml).toContain("Kit managed"); + expect(dashboardHtml).toContain("read-only here"); + expect(dashboardHtml).toContain("a.kitManaged === true"); + expect(dashboardHtml).toContain('data-kit-managed="true"'); + expect(dashboardHtml).toMatch(/kitManaged\s*\?\s*''\s*:\s*` { + // Per entry: avatar monogram (agent initials) + kind chip + action label + timestamp. + expect(dashboardHtml).toContain("monitor-row-avatar"); + expect(dashboardHtml).toContain("function agentInitials(id)"); + expect(dashboardHtml).toContain("function crewEventActor(ev)"); + expect(dashboardHtml).toContain("function crewEventTime(ev, info)"); + // Actor resolution mirrors briefActivityActor: kit agent, else Engineering + // Manager (delivery), else Squad when a plan is present, else Platform Engineer. + expect(dashboardHtml).toContain("if (ev && ev.agent) return String(ev.agent);"); + expect(dashboardHtml).toContain( + "if (ev && ev.kind === 'delivery') return 'Engineering Manager';", + ); + expect(dashboardHtml).toContain("if (ev && ev.refs && ev.refs.plan) return 'Squad';"); + expect(dashboardHtml).toContain("return 'Platform Engineer';"); + // Structured label spans: actor + verb fixed, plan filename ellipsises first; + // full context stays on the title tooltip. + expect(dashboardHtml).toContain("feed-seg-actor"); + expect(dashboardHtml).toContain("feed-seg-verb"); + expect(dashboardHtml).toContain("feed-seg-plan"); + expect(dashboardHtml).toContain(".split(' · ')"); + expect(dashboardHtml).toContain("ev.labelFull || ev.label || ''"); + expect(dashboardHtml).toContain( + '${feedLabelHtml}', + ); + expect(dashboardHtml).toContain(''); + expect(dashboardHtml).toContain("ev.refs && ev.refs.plan"); + expect(dashboardHtml).toMatch( + /\.live-activity-feed \.monitor-row \.feed-seg-plan\s*\{[^}]*flex-shrink:\s*3/, + ); + expect(dashboardHtml).toMatch( + /\.live-activity-feed \.monitor-row \.feed-seg\s*\{[^}]*flex-shrink:\s*0/, + ); + // Row order: avatar, chip, label, time. + expect(dashboardHtml).toMatch( + /monitor-row-avatar[\s\S]*?monitor-row-chip[\s\S]*?feed-label[\s\S]*?feed-time/, + ); + // Timestamp fallback: first-seen stamp when the emitter omits `at`. + expect(dashboardHtml).toContain("semanticSeenAt.get(ev.id)"); + expect(dashboardHtml).toContain("${crewEventTime(ev, info)}"); + // Avatar styling: neutral square chip, not a state dot. + expect(dashboardHtml).toMatch( + /\.live-activity-feed \.monitor-row \.monitor-row-avatar\s*\{[^}]*width:\s*18px/, + ); + expect(dashboardHtml).toMatch( + /\.live-activity-feed \.monitor-row \.monitor-row-avatar\s*\{[^}]*background:\s*var\(--bg-card-hover\)/, + ); + }); + + it("keeps the Activity tab on the plain activity-label (no Crew feed spans)", () => { + // Contract: Crew Monitor structured spans stay scoped; Activity tab is plain. + expect(dashboardHtml).toContain('id="section-activity"'); + expect(dashboardHtml).toContain('${escapeHtml(ev.label)}'); + expect(dashboardHtml).not.toMatch( + /id="section-activity"[\s\S]*feed-seg-actor[\s\S]*<\/div>\s*<\/div>\s*`/, + ); + // New Crew CSS must stay under .live-activity-feed .monitor-row, not .activity-label. + expect(dashboardHtml).toMatch(/\.live-activity-feed \.monitor-row \.feed-seg-plan/); + expect(dashboardHtml).not.toMatch(/\.activity-label[\s\S]{0,80}feed-seg/); + }); + it("renders Flight Log Gaps stack (live + earlier) without Field Report review CTAs", () => { expect(dashboardHtml).toContain("function renderFlightLogCard(entry, idx)"); expect(dashboardHtml).toContain("function renderAttentionPanel(d, attentionChanged)"); @@ -534,17 +743,102 @@ describe("plugin-ux-validation: narrow shell + a11y chrome", () => { expect(dashboardHtml).toContain("flight-log-kind-ok"); expect(dashboardHtml).toContain("data-flight-log-kind"); expect(dashboardHtml).toContain("function flightLogMessageKind(text, opts"); - expect(dashboardHtml).toContain("'Live'"); + expect(dashboardHtml).toContain("'NOW'"); expect(dashboardHtml).toContain("'Earlier'"); + expect(dashboardHtml).toContain("variant === 'current' ? 'NOW' : 'Earlier'"); + // Quiet placeholder (past empty, warnings present): label + a11y stay NOW, not Live. + expect(dashboardHtml).toContain('NOW'); + expect(dashboardHtml).toContain('aria-label="No Gaps now"'); + expect(dashboardHtml).not.toContain('aria-label="No live Gaps"'); expect(dashboardHtml).not.toContain("Current Gaps"); expect(dashboardHtml).not.toContain("Past Gaps"); - expect(dashboardHtml).toContain("Copy text"); + expect(dashboardHtml).not.toContain("Copy text"); + expect(dashboardHtml).toContain("flight-log-action-"); expect(dashboardHtml).not.toContain("Review all"); expect(dashboardHtml).not.toContain("Resolve all"); expect(dashboardHtml).not.toContain("vscode://"); expect(dashboardHtml).not.toContain("cursor://"); }); + it("behaviourally exercises Flight Log quiet-gate helpers (five monitor scenarios)", () => { + const sources = [ + /function resolveFlightLogCurrent\(d\) \{[\s\S]*?\n\}/, + /function flightLogHasPastEntries\(fl\) \{[\s\S]*?\n\}/, + /function flightLogHasWarningEntries\(fl\) \{[\s\S]*?\n\}/, + /function isFlightLogQuiet\(d\) \{[\s\S]*?\n\}/, + ].map((re) => { + const match = dashboardHtml.match(re); + expect(match, `dashboard.html must define ${re.source}`).not.toBeNull(); + return match?.[0]; + }); + const { + resolveFlightLogCurrent, + flightLogHasPastEntries, + flightLogHasWarningEntries, + isFlightLogQuiet, + } = new Function( + `${sources.join("\n")}\nreturn { resolveFlightLogCurrent, flightLogHasPastEntries, flightLogHasWarningEntries, isFlightLogQuiet };`, + )() as { + resolveFlightLogCurrent: (d: unknown) => string | null; + flightLogHasPastEntries: (fl: unknown) => boolean; + flightLogHasWarningEntries: (fl: unknown) => boolean; + isFlightLogQuiet: (d: unknown) => boolean; + }; + + // 1) Plan:none + handoff Gaps → residual-B case (not quiet; current from handoff) + const handoffGaps = { + system: { handoff: { gaps: "Need triage on shell quote strip" } }, + missionControl: { flightLog: { current: null, past: [], warnings: [] } }, + }; + expect(isFlightLogQuiet(handoffGaps)).toBe(false); + expect(resolveFlightLogCurrent(handoffGaps)).toBe("Need triage on shell quote strip"); + + // 2) Genuinely quiet + const quiet = { + missionControl: { flightLog: { current: null, past: [], warnings: [] } }, + }; + expect(isFlightLogQuiet(quiet)).toBe(true); + expect(resolveFlightLogCurrent(quiet)).toBeNull(); + + // 3) Gaps from missionControl.now (first fallback rung) + const nowGaps = { + missionControl: { + now: { gaps: "API/usage limit hard stop" }, + flightLog: { current: null, past: [], warnings: [] }, + }, + }; + expect(isFlightLogQuiet(nowGaps)).toBe(false); + expect(resolveFlightLogCurrent(nowGaps)).toBe("API/usage limit hard stop"); + + // 4) Past entries present → not quiet; no current + const withPast = { + missionControl: { + flightLog: { + current: null, + past: [{ text: "Earlier residual closed" }], + warnings: [], + }, + }, + }; + expect(isFlightLogQuiet(withPast)).toBe(false); + expect(resolveFlightLogCurrent(withPast)).toBeNull(); + expect(flightLogHasPastEntries(withPast.missionControl.flightLog)).toBe(true); + + // 5) Text-less warning dropped (mirrors render-side filter) → quiet + const textlessWarning = { + missionControl: { + flightLog: { + current: null, + past: [], + warnings: [{ kind: "cadence", text: " " }], + }, + }, + }; + expect(flightLogHasWarningEntries(textlessWarning.missionControl.flightLog)).toBe(false); + expect(isFlightLogQuiet(textlessWarning)).toBe(true); + expect(resolveFlightLogCurrent(textlessWarning)).toBeNull(); + }); + it("pins Flight Log OK-normalize regex parity and CSS kind rules", () => { // Shared classifier rule groups must stay aligned across semantic-model.mjs // classifyFlightLogMessageKind and the inline dashboard.html flightLogMessageKind @@ -591,9 +885,17 @@ describe("plugin-ux-validation: narrow shell + a11y chrome", () => { }); it("pins Flight Log kind-aware hover/focus chrome (not yellow-only via unset --accent)", () => { - // Regression: shared hover must not fall back to yellow via unset --accent. - expect(dashboardHtml).not.toContain("border-color: var(--accent, var(--yellow));"); - expect(dashboardHtml).not.toContain("outline: 2px solid var(--accent, var(--yellow));"); + // Regression: shared hover must not fall back to yellow via unset --accent (whitespace-tolerant). + expect(dashboardHtml).not.toMatch( + /border-color:\s*var\(\s*--accent\s*,\s*var\(\s*--yellow\s*\)\s*\)\s*;/, + ); + expect(dashboardHtml).not.toMatch( + /outline:\s*2px\s+solid\s+var\(\s*--accent\s*,\s*var\(\s*--yellow\s*\)\s*\)\s*;/, + ); + // Base fallback must stay visible (not same as rest border ≈1.1:1). + expect(dashboardHtml).toMatch( + /\.flight-log-card:hover,\s*\n\.flight-log-card:focus-visible\s*\{[^}]*outline:\s*2px solid var\(--border-active\)/, + ); const kindHoverTokens: Array<{ kind: string; token: string }> = [ { kind: "ok", token: "var(--green)" }, @@ -610,7 +912,7 @@ describe("plugin-ux-validation: narrow shell + a11y chrome", () => { expect(dashboardHtml).toContain( `.flight-log-card-past.flight-log-kind-${kind}:focus-visible`, ); - // Live hover/focus outline follows the kind palette token. + // NOW (current) hover/focus outline + border follow the kind palette token. const liveHoverBlock = dashboardHtml.match( new RegExp( `\\.flight-log-card-current\\.flight-log-kind-${kind}:hover,\\s*` + @@ -618,9 +920,31 @@ describe("plugin-ux-validation: narrow shell + a11y chrome", () => { ), ); expect(liveHoverBlock?.[0]).toContain(`outline-color: ${token}`); + expect(liveHoverBlock?.[0]).toContain(`border-color: ${token}`); + // Earlier: muted color-mix on hover; full kind token on keyboard focus. + const pastHoverBlock = dashboardHtml.match( + new RegExp(`\\.flight-log-card-past\\.flight-log-kind-${kind}:hover\\s*\\{[^}]+\\}`), + ); + expect(pastHoverBlock?.[0]).toMatch(/outline-color:\s*color-mix\(/); + expect(pastHoverBlock?.[0]).toMatch(/border-color:\s*color-mix\(/); + const pastFocusBlock = dashboardHtml.match( + new RegExp( + `\\.flight-log-card-past\\.flight-log-kind-${kind}:focus-visible\\s*\\{[^}]+\\}`, + ), + ); + expect(pastFocusBlock?.[0]).toContain(`outline-color: ${token}`); } expect(dashboardHtml).toContain(".flight-log-card-warning:hover"); expect(dashboardHtml).toContain(".flight-log-card-warning:focus-visible"); + + // Every flight-log-card root template emits a kind class or the warning class. + const cardRoots = [ + ...dashboardHtml.matchAll(/(?:class="|`)flight-log-card(?![a-z-])[^"'`]*/g), + ].map((m) => m[0]); + expect(cardRoots.length).toBeGreaterThan(0); + for (const tpl of cardRoots) { + expect(/flight-log-kind-|flight-log-card-warning|\$\{kindClass\}/.test(tpl)).toBe(true); + } }); it("renders Flight Log operator Warnings lane without cadence Review/Resolve CTAs", () => { @@ -755,6 +1079,35 @@ describe("plugin-ux-validation: narrow shell + a11y chrome", () => { expect(dashboardHtml).not.toMatch(/\.now-status-executing\s*\{[^}]*box-shadow/); }); + it("pins the busy-outside-plan chip chrome on Current mission and Flight Log", () => { + // Advice-family blue tokens on the pinned pill ladder; text-only (no dot, no icon). + expect(dashboardHtml).toMatch(/\.mc-busy-chip\s*\{[^}]*color:\s*var\(--blue\)/); + expect(dashboardHtml).toMatch(/\.mc-busy-chip\s*\{[^}]*background:\s*var\(--blue-bg\)/); + expect(dashboardHtml).toMatch( + /\.mc-busy-chip\s*\{[^}]*border-radius:\s*var\(--mc-radius-pill\)/, + ); + expect(dashboardHtml).toMatch( + /\.mc-busy-chip\s*\{[^}]*font-size:\s*var\(--mc-chrome-meta-size\)/, + ); + expect(dashboardHtml).not.toMatch(/\.mc-busy-chip\s*\{[^}]*box-shadow/); + expect(dashboardHtml).not.toMatch(/\.mc-busy-chip[^{]*:hover/); + const chipFn = dashboardHtml.match(/function renderBusyOutsideChip\([\s\S]*?\n\}/); + expect(chipFn).not.toBeNull(); + expect(chipFn?.[0]).toContain("busyOutsidePlan?.active"); + expect(chipFn?.[0]).not.toContain("spaceIconSvg"); + expect(chipFn?.[0]).not.toContain("flight-log-card"); + // Both headers render the chip; SSE fingerprints flip on the busy flag. + const nowPanelFn = dashboardHtml.match(/function renderNowExecutionPanel\([\s\S]*?\n\}/); + expect(nowPanelFn?.[0]).toContain("renderBusyOutsideChip(now)"); + const attentionFn = dashboardHtml.match(/function renderAttentionPanel\([\s\S]*?\n\}/); + expect(attentionFn?.[0]).toContain("renderBusyOutsideChip(d.missionControl?.now)"); + expect(dashboardHtml).toMatch(/busyOutsidePlan: now\.busyOutsidePlan\?\.active === true/); + const flightLogFingerprintFn = dashboardHtml.match( + /function flightLogFingerprint\([\s\S]*?\n\}/, + ); + expect(flightLogFingerprintFn?.[0]).toContain("busyOutsidePlan"); + }); + it("demotes Mode and Updated to icon-led discreet meta with accessible names", () => { expect(dashboardHtml).toContain("function renderNowMeta(modeLabel, updatedSource, now)"); expect(dashboardHtml).toContain("function nowMetaIconSvg(kind, opts)"); @@ -1032,8 +1385,12 @@ describe("plugin-ux-validation: unified Activity feed", () => { expect(dashboardHtml).toMatch(/chore:\s+\{\s*icon:\s*'\\u2692'/); expect(dashboardHtml).toMatch(/pr:\s+\{\s*icon:\s*'\\u2442'/); expect(dashboardHtml).toMatch(/ship:\s+\{\s*icon:\s*'\\u2708'/); - expect(dashboardHtml).toContain("gloss: 'delivery - feat'"); - expect(dashboardHtml).toContain("gloss: 'tick - live execution'"); + expect(dashboardHtml).toContain("gloss: 'DevOps Engineer - feat'"); + expect(dashboardHtml).toContain("gloss: 'Tech Lead - live execution'"); + expect(dashboardHtml).toContain("gloss: 'Scrum Master - awaiting gate'"); + expect(dashboardHtml).toContain("gloss: 'Full-Stack Developer - task unit'"); + expect(dashboardHtml).toContain("gloss: 'Product Owner - milestone'"); + expect(dashboardHtml).toContain("gloss: 'DevOps Engineer - shipped unit'"); expect(dashboardHtml).toContain("kindGloss: gloss"); expect(dashboardHtml).toMatch( /\.live-activity-feed \.monitor-row \.feed-time\s*\{[^}]*margin-left:\s*auto/, @@ -1330,24 +1687,110 @@ describe("plugin-ux-validation: SSE + overview model wiring", () => { expect(dashboardHtml).toMatch( /#section-overview\.active \.overview-stack\s*\{[^}]*overflow:\s*hidden/, ); - expect(dashboardHtml).toContain("grid-template-areas:"); - expect(dashboardHtml).toContain('"now attention"'); - expect(dashboardHtml).toContain('"checklist monitor"'); + // Grid-area pins scoped to the one-fold block (residual B): future grid + // extensions elsewhere in the file must not false-pass or false-fail these. + const oneFold = dashboardHtml.match( + /@media \(min-width: 1024px\)\s*\{[\s\S]*?\n\}\n\n\/\* ===== Recent plan cards/, + ); + expect(oneFold).not.toBeNull(); + const block = oneFold?.[0] ?? ""; + expect(block).toContain("grid-template-areas:"); + expect(block).toContain('"now attention"'); + expect(block).toContain('"checklist monitor"'); expect(dashboardHtml).toMatch( /#section-overview\.active \.overview-stack\s*\{[^}]*grid-template-rows:\s*minmax\(0,\s*1fr\)\s+minmax\(0,\s*1fr\)/, ); - expect(dashboardHtml).toMatch( + expect(block).toMatch( /#section-overview\.active #now-execution-panel\s*\{[^}]*grid-area:\s*now/, ); - expect(dashboardHtml).toMatch( - /#section-overview\.active #hero-activity\s*\{[^}]*grid-area:\s*monitor/, + expect(block).toMatch(/#section-overview\.active #hero-activity\s*\{[^}]*grid-area:\s*monitor/); + expect(block).toMatch( + /#section-overview\.active #attention-panel\s*\{[^}]*grid-area:\s*attention/, ); - expect(dashboardHtml).toMatch( + expect(block).toMatch( + /#section-overview\.active #recent-plans-panel\s*\{[^}]*grid-area:\s*checklist/, + ); + }); + + it("collapses the overview to a two-column IA grid at mid widths (701-1023px)", () => { + const mid = dashboardHtml.match( + /@media \(min-width: 701px\) and \(max-width: 1023px\)\s*\{[\s\S]*?\n\}\n\n\/\* Desktop one-fold/, + ); + expect(mid).not.toBeNull(); + const block = mid?.[0] ?? ""; + expect(block).toMatch(/#section-overview\.active \.overview-stack\s*\{[^}]*display:\s*grid/); + expect(block).toMatch( + /#section-overview\.active \.overview-stack\s*\{[^}]*grid-template-columns:\s*1fr 1fr/, + ); + // Row-major collapse follows the locked IA: Current mission -> Flight Log + // -> Checklist -> Crew monitor. + expect(block).toContain("grid-template-areas:"); + expect(block).toContain('"now now"'); + expect(block).toContain('"attention checklist"'); + expect(block).toContain('"monitor monitor"'); + expect(block).toMatch( + /#section-overview\.active #now-execution-panel\s*\{[^}]*grid-area:\s*now/, + ); + expect(block).toMatch( /#section-overview\.active #attention-panel\s*\{[^}]*grid-area:\s*attention/, ); - expect(dashboardHtml).toMatch( + expect(block).toMatch( /#section-overview\.active #recent-plans-panel\s*\{[^}]*grid-area:\s*checklist/, ); + expect(block).toMatch(/#section-overview\.active #hero-activity\s*\{[^}]*grid-area:\s*monitor/); + // Mid grid is stacked (page scrolls): the one-fold height lock stays at >=1024px. + expect(block).not.toMatch(/height:\s*100%/); + expect(block).not.toMatch(/overflow:\s*hidden/); + // Mobile stack stays DOM-order flex; no CSS order property anywhere on the stack. + expect(dashboardHtml).not.toMatch(/\.overview-stack\s*\{[^}]*order:/); + }); + + it("adds a very thin sidebar mode (~<340px) with single-column card grids", () => { + expect(dashboardHtml).toContain("@media (max-width: 339px)"); + const thin = dashboardHtml.match(/@media \(max-width: 339px\)\s*\{[\s\S]*?\n\}\n/); + expect(thin).not.toBeNull(); + const block = thin?.[0] ?? ""; + expect(block).toContain("--mc-card-padding: 10px"); + expect(block).toContain("--mc-content-pad: 8px"); + expect(block).toMatch( + /\.health-grid,[\s\S]*?\.agent-grid\s*\{[^}]*grid-template-columns:\s*1fr;/, + ); + // Thin mode tightens the shared ladder; it does not fork it with new tokens. + expect(block).not.toMatch( + /--mc-(?!card-padding|header-pad-x|header-pad-x-end|content-pad)[a-z-]+:/, + ); + }); + + it("adds a fullscreen viewport mode toggle with floating exit and Escape restore", () => { + expect(dashboardHtml).toMatch(/class="header-right"[\s\S]*?id="fullscreenToggleBtn"/); + expect(dashboardHtml).toMatch(/id="fullscreenToggleBtn"[^>]*aria-pressed="false"/); + expect(dashboardHtml).toContain('id="fullscreenExitBtn"'); + expect(dashboardHtml).toMatch(/id="fullscreenExitBtn"[^>]*hidden/); + expect(dashboardHtml).toContain("function toggleMcFullscreen("); + expect(dashboardHtml).toMatch( + /addEventListener\('keydown'[\s\S]*?classList\.contains\('mc-fullscreen'\)/, + ); + expect(dashboardHtml).toContain("Layered Escape"); + expect(dashboardHtml).toContain("isNavMoreOpen()"); + expect(dashboardHtml).toMatch(/body\.mc-fullscreen \.top-tabs-row\s*\{[^}]*padding-right:/); + expect(dashboardHtml).toMatch(/body\.mc-fullscreen \.header\s*\{[^}]*display:\s*none/); + expect(dashboardHtml).toMatch(/\.fullscreen-exit-btn\[hidden\]\s*\{[^}]*display:\s*none/); + // Fullscreen controls follow the chrome icon contract (16px, stroke 1.5, currentColor). + const enterBtn = dashboardHtml.match(/id="fullscreenToggleBtn"[^>]*>([\s\S]*?)<\/button>/); + expect(enterBtn).not.toBeNull(); + expect(enterBtn?.[1]).toContain('stroke="currentColor"'); + expect(enterBtn?.[1]).toContain('stroke-width="1.5"'); + expect(enterBtn?.[1]).toContain('width="16" height="16"'); + // Skin-neutral: fullscreen chrome uses shared surface tokens only. + const fsCss = dashboardHtml.match(/\.fullscreen-exit-btn\s*\{[\s\S]*?\n\}/); + expect(fsCss?.[0]).toContain("var(--bg-secondary)"); + expect(fsCss?.[0]).not.toMatch(/#[0-9a-fA-F]{3,6}/); + }); + + it("routes header transport health tone through healthSeverityChrome", () => { + expect(dashboardHtml).toContain("function updateTransportChrome()"); + expect(dashboardHtml).toContain("const healthChrome = healthSeverityChrome(healthStatus);"); + expect(dashboardHtml).toContain("const healthTone = healthChrome.tone;"); }); it("hides the Cockpit subheader on desktop; More menu stays in the header", () => { @@ -1518,6 +1961,39 @@ describe("plugin-ux-validation: SSE + overview model wiring", () => { ); }); + it("applies the dot semantics table: dots only signal good / important / attention", () => { + // Section titles carry no identity dots; the label text is the identity. + const sectionTitles = dashboardHtml.match(/
[\s\S]*?<\/div>/g) ?? []; + expect(sectionTitles.length).toBeGreaterThan(0); + for (const title of sectionTitles) { + expect(title).not.toContain('class="dot'); + } + expect(dashboardHtml).not.toContain(".section-title .dot"); + // Decorative per-row dots are stripped (skills categories, processes labels). + expect(dashboardHtml).not.toContain("skill-cat-dot"); + expect(dashboardHtml).not.toContain("live-pulse-dot"); + expect(dashboardHtml).not.toContain("navTerminalsDot"); + expect(dashboardHtml).not.toContain("navProcessesDot"); + // Plan to-do dots survive only for good (completed) and attention (cancelled). + const statusDotFn = dashboardHtml.match(/function statusDot\(status\) \{[\s\S]*?\n\}/); + expect(statusDotFn).not.toBeNull(); + expect(statusDotFn?.[0]).toContain("completed: 'dot-green'"); + expect(statusDotFn?.[0]).toContain("cancelled: 'dot-red'"); + expect(statusDotFn?.[0]).not.toContain("in_progress"); + expect(statusDotFn?.[0]).not.toContain("pending"); + // Surviving state dots stay paired with labels: health checks keep severity text. + expect(dashboardHtml).toContain("healthDotClass"); + expect(dashboardHtml).toContain("health-item-sev"); + }); + + it("syncs tabs with the URL hash for deep-linkable navigation", () => { + expect(dashboardHtml).toContain("function sectionFromHash()"); + expect(dashboardHtml).toContain("function syncSectionHash(id)"); + expect(dashboardHtml).toContain("window.addEventListener('hashchange'"); + expect(dashboardHtml).toContain("history.replaceState"); + expect(dashboardHtml).toContain("let activeSectionId = sectionFromHash()"); + }); + it("does not offer a Copy start header control (terminal: npm run dashboard / agent-kit dashboard)", () => { expect(dashboardHtml).not.toContain("btn-copy-start"); expect(dashboardHtml).not.toContain("copyStartCommand"); @@ -1767,11 +2243,13 @@ describe("cockpit-validation: icons, assets, and accessible names", () => { expect(labelled).toContain(`aria-label="${name}"`); expect(labelled).toContain(`title="${name}"`); } - // Home house: roof meets walls (no split roof/base gap). + // Home house: roof meets walls (no split roof/base gap); door interior stays above the stroke floor. const home = spaceIconSvg("overview"); expect(home).toContain("M2.5 7.5L8 2.75l5.5 4.75"); expect(home).toContain("M4.25 7.75v6.5h7.5v-6.5"); + expect(home).toContain("M6.5 14.25v-3.25h3v3.25"); expect(home).not.toContain("M4.5 10v4h3M11.5 10v4h-3"); + expect(home).not.toContain("M6.75 14.25v-3.25h2.5v3.25"); // Skins palette swatches (not Skills gear). const skins = spaceIconSvg("skins"); expect(skins).toContain(' { expect(dashboardHtml).not.toMatch(/font-awesome|material-icons|iconify/i); }); + it("keeps every space-icon glyph at or above the stroke-1.5 legibility floor", () => { + const spaceIconSvg = loadSpaceIconSvg(); + const kinds = [ + "current-mission", + "monitor", + "field-report", + "checklist", + "more-sections", + "overview", + "plans", + "activity", + "agents", + "skills", + "skins", + "commands", + "health", + "git", + "memory", + "terminals", + "processes", + "config", + ]; + // No stroked circle may be smaller than the stroke that draws it + // (filled accents opt out via stroke="none" and stay solid at any size). + for (const kind of kinds) { + const svg = spaceIconSvg(kind); + for (const m of svg.matchAll(/]*\sr="([\d.]+)"([^>]*)\/?>/g)) { + if ((m[2] || "").includes('stroke="none"')) continue; + expect( + Number(m[1]), + `${kind} stroked circle radius ${m[1]} is below the stroke-1.5 floor`, + ).toBeGreaterThan(0.75); + } + } + // Radar: two rings (1.25 clearance), no centre dot below the floor. + const radar = spaceIconSvg("monitor"); + expect(radar).toContain(''); + expect(radar).not.toContain(''); + expect(radar).not.toContain('r=".55"'); + // Gear: spokes sit outside the ring instead of arcs burying into it. + const gear = spaceIconSvg("skills"); + expect(gear).toContain(''); + expect(gear).toContain("M8 5V3.5"); + expect(gear).not.toContain("M10.5 8a2.5 2.5 0 000-2.5"); + // Rocket window: solid dot, not a sub-stroke ring. + const rocket = spaceIconSvg("current-mission"); + expect(rocket).toContain(''); + expect(rocket).not.toContain('r=".9"'); + // Chip: two internal lines with 1.5 clearance, not three at 0.5. + const chip = spaceIconSvg("memory"); + expect(chip).toContain("M6 6.5h4M6 9.5h2.5"); + expect(chip).not.toContain("M6 8.5h4"); + // Home door interior pin (paired with overview glyph contract). + const home = spaceIconSvg("overview"); + // Ellipsis: adjacent dots must clear each other (no tangency or fusion). + const more = spaceIconSvg("more-sections"); + const dots = [...more.matchAll(/]*)\/?>/g)].map( + (m) => ({ + cx: Number(m[1]), + reach: Number(m[2]) + ((m[3] || "").includes('stroke="none"') ? 0 : 0.75), + }), + ); + expect(dots).toHaveLength(3); + for (let i = 1; i < dots.length; i++) { + const gap = dots[i].cx - dots[i - 1].cx - dots[i].reach - dots[i - 1].reach; + expect(gap, `more-sections dots ${i - 1}/${i} fuse (gap ${gap})`).toBeGreaterThanOrEqual(1); + } + // Chip internal lines: vertical clearance between the two strokes is ≥ 1 unit. + const chipPaths = [...chip.matchAll(/M6 ([\d.]+)h/g)].map((m) => Number(m[1])); + expect(chipPaths.length).toBeGreaterThanOrEqual(2); + expect(chipPaths[1] - chipPaths[0], "memory chip line clearance").toBeGreaterThanOrEqual(1); + // Home door interior: opening height stays above the stroke floor (≥ 1 unit usable). + expect(home).toMatch(/M6\.5 14\.25v-([\d.]+)h/); + const doorDrop = Number(home.match(/M6\.5 14\.25v-([\d.]+)h/)?.[1] || 0); + expect(doorDrop, "overview door interior height").toBeGreaterThanOrEqual(3); + }); + it("gives every terminal expand control a name that says which terminal", () => { expect(dashboardHtml).toMatch( /class="terminal-expand-btn"[^>]*aria-label="Expand the captured output for terminal \$\{escapeAttr\(t\.id\)\}"/, @@ -1877,6 +2432,58 @@ describe("plugin-ux-validation: hardening regressions", () => { expect(dashboardHtml).toContain('id="config-save-btn"'); }); + it("pins Config tab chrome: grid layout, Save on top, trimmed hints", () => { + // Grid layout, not vertical-only. + expect(dashboardHtml).toMatch( + /\.config-form\s*\{[^}]*display:\s*grid[^}]*grid-template-columns/, + ); + // Save Configuration pinned in a top actions bar, before the first fieldset. + expect(dashboardHtml).toContain('class="config-actions"'); + expect(dashboardHtml).toMatch( + /
{ + // One copy-snippet button per writable fieldset (Read-only fieldset has none). + expect(dashboardHtml).toContain('data-focus-key="config-copy-session"'); + expect(dashboardHtml).toContain('data-focus-key="config-copy-updateCheck"'); + expect(dashboardHtml).toContain('data-focus-key="config-copy-audits"'); + expect(dashboardHtml).toContain('data-focus-key="config-copy-personas"'); + // Never-writable knobs still get copy-only static snippets (allowlist unchanged). + expect(dashboardHtml).toContain('data-focus-key="config-copy-updateApply"'); + expect(dashboardHtml).toContain('data-focus-key="config-copy-dogfood"'); + expect(dashboardHtml).toContain("function copyStaticConfigSnippet("); + expect(dashboardHtml).toContain("CONFIG_STATIC_SNIPPETS"); + expect(dashboardHtml.match(/btn-config-copy/g)?.length).toBeGreaterThanOrEqual(6); + // Snippet builder shares the save payload and stays copy-only (no fetch). + expect(dashboardHtml).toContain("function collectMissionConfigPayload("); + expect(dashboardHtml).toContain("function copyConfigSnippet("); + const copyFn = dashboardHtml.match(/function copyConfigSnippet\([\s\S]*?\n\}/)?.[0]; + expect(copyFn).toBeTruthy(); + expect(copyFn).toContain("copyToClipboard"); + expect(copyFn).not.toContain("fetch("); + expect(copyFn).not.toContain("/api/config"); + const staticFn = dashboardHtml.match(/function copyStaticConfigSnippet\([\s\S]*?\n\}/)?.[0]; + expect(staticFn).toBeTruthy(); + expect(staticFn).not.toContain("fetch("); + expect(staticFn).not.toContain("/api/config"); + // Clipboard failure toast truncates long snippets instead of dumping the full body. + expect(dashboardHtml).toContain("const preview = raw.length > 120"); + // Dead-control hints: backend is claude-only; updateApply.auto never writable. + expect(dashboardHtml).toContain("claude is the only backend today."); + expect(dashboardHtml).toContain("updateApply.auto is never writable here."); + }); + it("preserves loopback default bind and localhost CORS allowlist", () => { expect(resolveBindHost(undefined)).toBe(DEFAULT_HOST); expect(DEFAULT_HOST).toBe("127.0.0.1"); @@ -2246,6 +2853,85 @@ describe("cockpit checklist: plan cards only (notes moved to Field Report)", () }); }); +describe("plans tab v2: actionable rows, live status bar, next action", () => { + const plansRenderer = () => + dashboardHtml.match(/function renderPlansAccordion\([\s\S]*?(?=\nfunction \w+)/)?.[0]; + + it("derives the live status bar from frontmatter to-do counts, not enriched labels", () => { + // To-do items are the source of truth for counts (summary fields are only + // a fallback when items are absent). + expect(dashboardHtml).toContain("items.filter((t) => t.status === 'completed').length"); + expect(dashboardHtml).toContain("items.filter((t) => t.status === 'in_progress').length"); + expect(dashboardHtml).toContain("progressLabel: `${completed} of ${total} complete`"); + expect(dashboardHtml).not.toContain("progressLabel: enriched.progress?.label"); + expect(dashboardHtml).toContain("progressInProgress: inProgress"); + expect(dashboardHtml).toContain("nextActionTodo,"); + // Bar carries real progressbar semantics with the live value. + const renderer = plansRenderer(); + expect(renderer).toBeTruthy(); + expect(renderer).toContain('role="progressbar"'); + expect(renderer).toContain('aria-valuenow="${pct}"'); + expect(renderer).toContain('aria-valuemax="100"'); + expect(renderer).toContain('aria-valuetext="${escapeAttr(p.progressLabel)}"'); + expect(renderer).not.toContain('role="img"'); + // Live signals stay visible as text: in-progress count and next to-do id. + expect(renderer).toContain("${p.progressInProgress} in progress"); + expect(renderer).toContain("Next: ${escapeHtml(p.nextActionTodo.id)}"); + }); + + it("computes the next actionable to-do (in_progress first, else first pending)", () => { + expect(dashboardHtml).toContain("function planNextActionTodo(items)"); + expect(dashboardHtml).toContain("items.find((t) => t.status === 'in_progress')"); + expect(dashboardHtml).toContain("items.find((t) => t.status === 'pending')"); + const renderer = plansRenderer(); + expect(renderer).toContain('class="plan-next-action"'); + expect(renderer).toContain("Next action:"); + }); + + it("renders status-aware copy-only actions naming the chat input as paste destination", () => { + expect(dashboardHtml).toContain("function planTabActions(p)"); + // Per-lifecycle affordances: active/incomplete resume, backlog runs/edits, + // parked resumes, completed opens (copy path) / archives. + expect(dashboardHtml).toContain("case 'executing':"); + expect(dashboardHtml).toContain("case 'awaiting_user':"); + expect(dashboardHtml).toContain("case 'backlog':"); + expect(dashboardHtml).toContain("case 'parked':"); + expect(dashboardHtml).toContain("case 'incomplete':"); + expect(dashboardHtml).toContain("case 'completed':"); + // Locked clipboard labels and commands (basename from plan.file). + expect(dashboardHtml).toContain("Copy resume prompt"); + expect(dashboardHtml).toContain("Copy run command"); + expect(dashboardHtml).toContain("Copy edit command"); + expect(dashboardHtml).toContain("Copy archive command"); + expect(dashboardHtml).toContain("Copy plan path"); + expect(dashboardHtml).toContain("command: `/continue-plan ${basename}`"); + expect(dashboardHtml).toContain("command: `/run-plan ${basename}`"); + expect(dashboardHtml).toContain("command: `/backlog-edit ${basename}`"); + expect(dashboardHtml).toContain("command: `/archive-plan ${basename}`"); + // Chat commands go through the chat-input paste helpers; path stays a + // file-picker copy. First action per row is visually primary. + expect(dashboardHtml).toContain( + "copyForPasteHandler(action.command, action.command, 'chatInput')", + ); + expect(dashboardHtml).toContain("copyActionTitle(action.command, 'chatInput')"); + expect(dashboardHtml).toContain("copyRepoPathHandler(p.path)"); + expect(dashboardHtml).toContain("plan-action-primary"); + const renderer = plansRenderer(); + expect(renderer).toContain( + "planTabActions(p).map((action, idx) => renderPlanTabActionButton(action, p, key, idx))", + ); + }); + + it("keeps to-do status dots limited to good/attention states (status via label)", () => { + const statusDot = dashboardHtml.match(/function statusDot\(status\) \{[\s\S]*?\n\}/)?.[0]; + expect(statusDot).toBeTruthy(); + expect(statusDot).toContain("completed: 'dot-green'"); + expect(statusDot).toContain("cancelled: 'dot-red'"); + expect(statusDot).not.toContain("pending"); + expect(statusDot).not.toContain("in_progress"); + }); +}); + function LIFECYCLE_SORT_RANK_SOURCE() { const match = dashboardHtml.match(/const LIFECYCLE_SORT_RANK = \{[\s\S]*?\};/); expect(match).not.toBeNull(); @@ -2673,7 +3359,88 @@ describe("plugin-ux-validation: Healthcenter (More → Health)", () => { for (const id of ["plans", "handoff", "agents", "commands", "memory", "git", "config"]) { expect(dashboardHtml).toContain(`${id}:`); } - expect(dashboardHtml).toContain("same seven checks · copy-only Autofix"); + }); + + it("renders a vitals diagnosis dashboard grouped by vital system", () => { + const healthBlock = dashboardHtml.match( + /\/\/ ===== Healthcenter \(More → Health\) =====[\s\S]*?(?=\n {2}\/\/ ===== Git =====)/, + )?.[0]; + expect(healthBlock).toBeTruthy(); + expect(healthBlock).toContain("HEALTH_VITAL_GROUPS"); + expect(healthBlock).toContain('class="health-vitals"'); + expect(healthBlock).toContain("health-vital-card"); + expect(healthBlock).toContain("health-group-title"); + for (const label of ["Planning spine", "Agent surface", "Memory loop", "Workspace"]) { + expect(dashboardHtml).toContain(`label: '${label}'`); + } + expect(healthBlock).toContain("passing"); + expect(healthBlock).toContain("'Attention'"); + expect(dashboardHtml).toMatch(/\.health-vitals\s*\{/); + expect(dashboardHtml).toMatch( + /\.health-vital-card\[data-state="pass"\][^{]*\{[^}]*var\(--green/, + ); + expect(dashboardHtml).toMatch( + /\.health-vital-card\[data-state="attention"\][^{]*\{[^}]*var\(--red/, + ); + }); + + it("strips left-bar highlight slop and colors severity via the Cursor palette", () => { + expect(dashboardHtml).not.toMatch(/\.health-card\[data-severity[^\]]*\]\s*\{[^}]*border-left/); + expect(dashboardHtml).not.toContain("same seven checks"); + expect(dashboardHtml).toMatch(/\.health-item-sev\[data-sev="ok"\]\s*\{[^}]*var\(--green/); + expect(dashboardHtml).toMatch(/\.health-item-sev\[data-sev="warning"\]\s*\{[^}]*var\(--yellow/); + expect(dashboardHtml).toMatch( + /\.health-item-sev\[data-sev="degraded"\]\s*\{[^}]*var\(--orange/, + ); + expect(dashboardHtml).toMatch(/\.health-item-sev\[data-sev="error"\]\s*\{[^}]*var\(--red/); + expect(dashboardHtml).toContain('class="health-item-sev" data-sev='); + // Token hygiene: no misleading fallback hexes on severity/radius rules. + expect(dashboardHtml).not.toContain("var(--mc-radius-sm, 6px)"); + expect(dashboardHtml).not.toContain("var(--green, #3fb950)"); + expect(dashboardHtml).not.toContain("var(--yellow, #d29922)"); + expect(dashboardHtml).not.toContain("var(--red, #f85149)"); + }); + + it("unifies severity chrome on one {tone, label, token} mapping across all call sites", () => { + expect(dashboardHtml).toContain("HEALTH_SEVERITY_CHROME"); + expect(dashboardHtml).toContain( + "degraded: { tone: 'orange', label: 'degraded', token: 'orange' }", + ); + // Presence dot, card dot, and severity label all read the same mapping. + expect(dashboardHtml).toContain( + "const presenceTone = healthSeverityChrome(healthStatus).tone;", + ); + expect(dashboardHtml).toMatch( + /function healthDotClass\(sev\) \{\s*return `dot-\$\{healthSeverityChrome\(sev\)\.tone\}`;/, + ); + expect(dashboardHtml).toMatch( + /function healthSeverityLabel\(sev\) \{\s*return healthSeverityChrome\(sev\)\.label;/, + ); + // Degraded tone exists as a dot and carries a matching pulse halo; the red + // halo fallback hole (grey currentColor) is closed. + expect(dashboardHtml).toMatch(/\.dot-orange\s*\{[^}]*background:\s*var\(--orange\)/); + expect(dashboardHtml).toMatch( + /\.dot-orange\.dot-pulse::after\s*\{[^}]*border-color:\s*var\(--orange\)/, + ); + expect(dashboardHtml).toMatch( + /\.dot-red\.dot-pulse::after\s*\{[^}]*border-color:\s*var\(--red\)/, + ); + // Presence liveness rides the ::after halo, not an element-level pulse that + // inflated the solid dot 250% and faded it out each cycle. + expect(dashboardHtml).not.toMatch(/\.healthcenter-presence \.dot-pulse\s*\{[^}]*animation/); + }); + + it("gives every problem row a copy-paste fix prompt CTA naming the chat input", () => { + const healthBlock = dashboardHtml.match( + /\/\/ ===== Healthcenter \(More → Health\) =====[\s\S]*?(?=\n {2}\/\/ ===== Git =====)/, + )?.[0]; + expect(healthBlock).toBeTruthy(); + expect(healthBlock).toContain("Copy fix prompt"); + expect(healthBlock).toContain("copyForPasteHandler(fixPrompt, 'fix prompt', 'chatInput')"); + expect(healthBlock).toContain("copyActionTitle('fix prompt', 'chatInput')"); + expect(healthBlock).toContain("Fix this failing health check: ${c.label || id}"); + expect(healthBlock).toContain("No action needed."); + expect(healthBlock).not.toContain("No Autofix mapped for this check."); }); it("maps Autofix to copyForPaste destinations (no Open/protocol)", () => { @@ -2697,13 +3464,236 @@ describe("plugin-ux-validation: Healthcenter (More → Health)", () => { expect(healthBlock).toContain("Health offline"); }); - it("keeps dual-skin Healthcard radius and reduced-motion press on .health-item", () => { - expect(dashboardHtml).toMatch( - /html\[data-dashboard-skin="cursor"\] \.health-card\s*\{[^}]*border-radius:\s*8px/, + it("unifies Health card radius on the ladder (skin-neutral) with reduced-motion press on .health-item", () => { + expect(dashboardHtml).toMatch(/\.health-card\s*\{[^}]*border-radius:\s*var\(--mc-radius\)/); + // Structure stays skin-neutral: no per-skin radius overrides on health cards. + expect(dashboardHtml).not.toMatch( + /html\[data-dashboard-skin="(?:cursor|legacy)"\] \.health-card\s*\{/, ); expect(dashboardHtml).toMatch( - /html\[data-dashboard-skin="legacy"\] \.health-card\s*\{[^}]*border-radius:\s*4px/, + /\.health-vital-card\s*\{[^}]*border-radius:\s*var\(--mc-radius\)/, ); expect(dashboardHtml).toMatch(/\.health-item:active\s*\{[^}]*transform:\s*scale\(0\.98\)/); }); }); + +describe("plugin-ux-validation: Memory tab (error-o-meter + live recent errors)", () => { + const memoryBlock = dashboardHtml.match( + /\/\/ ===== Memory =====[\s\S]*?(?=\n {2}\/\/ ===== Terminals =====)/, + )?.[0]; + + it("renders the error-o-meter KPI grid from memory.errorStats", () => { + expect(memoryBlock).toBeTruthy(); + expect(memoryBlock).toContain("d.memory?.recentErrors || []"); + expect(memoryBlock).toContain("d.memory?.errorStats || null"); + expect(memoryBlock).toContain('class="memory-kpi-grid"'); + expect(memoryBlock).toContain("Error-o-meter"); + expect(memoryBlock).toContain("errorStats.last30d"); + expect(memoryBlock).toContain("errorStats.weeklyRate"); + expect(memoryBlock).toContain("errorStats.topTags"); + expect(dashboardHtml).toMatch(/\.memory-kpi-grid\s*\{/); + expect(dashboardHtml).toMatch(/\.memory-tag\s*\{/); + }); + + it("lays out green and red icon-led panels side by side", () => { + expect(memoryBlock).toContain("memory-panel-icon-green"); + expect(memoryBlock).toContain("memory-panel-icon-red"); + expect(memoryBlock).toContain("Healthy memory"); + expect(memoryBlock).toContain("Recent errors"); + expect(memoryBlock).toContain("renderEmptyStateCta"); + expect(dashboardHtml).toMatch(/\.memory-panel-icon-green\s*\{[^}]*var\(--green\)/); + expect(dashboardHtml).toMatch(/\.memory-panel-icon-red\s*\{[^}]*var\(--red\)/); + }); + + it("renders interactive error rows with expand detail and copy fix-prompt", () => { + expect(memoryBlock).toContain( + "recentErrors.map((entry, idx) => renderMemoryErrorRow(entry, idx)).join('')", + ); + expect(dashboardHtml).toContain("function renderMemoryErrorRow(entry, idx)"); + expect(dashboardHtml).toContain("function toggleMemoryError(idx)"); + expect(dashboardHtml).toContain("memory-error-card"); + expect(dashboardHtml).toContain("memory-error-detail"); + expect(dashboardHtml).toContain("Copy fix prompt"); + expect(dashboardHtml).toContain("copyForPasteHandler(fixPrompt, 'fix prompt', 'chatInput')"); + expect(dashboardHtml).toContain("copyActionTitle('fix prompt', 'chatInput')"); + const rowFn = dashboardHtml.match( + /function renderMemoryErrorRow\(entry, idx\) \{[\s\S]*?\n\}/, + )?.[0]; + expect(rowFn).toBeTruthy(); + expect(rowFn).toContain("Fix this recorded problem class: ${title}"); + expect(rowFn).toContain("aria-expanded"); + }); + + it("keeps the Memory tab free of decorative dots", () => { + expect(memoryBlock).not.toMatch(/class="dot/); + expect(memoryBlock).not.toContain("dot-green"); + expect(memoryBlock).not.toContain("dot-red"); + }); + + it("wires live memory parsing in dashboard-data.mjs", () => { + const dataSource = readFileSync(resolve(repoRoot, "dashboard/dashboard-data.mjs"), "utf8"); + expect(dataSource).toContain("function parseMemoryErrorFile(dir, file)"); + expect(dataSource).toContain("function computeMemoryErrorStats(entries)"); + expect(dataSource).toContain( + "SNAPSHOT.memory.recentErrors = parsedErrors.slice(0, MAX_MEMORY_RECENT_ERRORS)", + ); + expect(dataSource).toContain( + "SNAPSHOT.memory.errorStats = computeMemoryErrorStats(parsedErrors)", + ); + expect(dataSource).toContain("weeklyRate"); + expect(dataSource).toContain("topTags"); + }); +}); + +describe("plugin-ux-validation: Git tab (promotion flow + graph + staging hygiene)", () => { + const gitBlock = dashboardHtml.match( + /\/\/ ===== Git =====[\s\S]*?(?=\n {2}\/\/ ===== Memory =====)/, + )?.[0]; + + it("renders promotion flow lanes for work -> staging -> main", () => { + expect(gitBlock).toBeTruthy(); + expect(gitBlock).toContain("${renderGitHygieneHint(d.git)}"); + expect(gitBlock).toContain("${renderGitFlowCard(d.git)}"); + expect(gitBlock).toContain("${renderGitGraphCard(d.git)}"); + expect(dashboardHtml).toContain("function renderGitFlowCard(git)"); + expect(dashboardHtml).toContain("function renderGitFlowRow({ from, to, div, texts, cta })"); + const flowFn = dashboardHtml.match(/function renderGitFlowCard\(git\) \{[\s\S]*?\n\}/)?.[0]; + expect(flowFn).toBeTruthy(); + // Three lanes: branch vs staging, staging vs main, branch vs main. + expect(flowFn).toContain("to: 'origin/staging'"); + expect(flowFn).toContain("from: 'origin/staging'"); + expect(flowFn).toContain("to: 'origin/main'"); + expect(flowFn).toContain("awaiting promotion to main"); + // CTA gated on ahead, never unconditional. + expect(flowFn).toContain("flow.vsStaging && flow.vsStaging.ahead > 0"); + expect(flowFn).toContain("flow.stagingVsMain && flow.stagingVsMain.ahead > 0"); + expect(flowFn).toContain("Copy /git-staging"); + expect(flowFn).toContain("Copy /git-prod"); + expect(dashboardHtml).toMatch(/\.git-flow-row\s*\{/); + expect(dashboardHtml).toMatch(/\.git-flow-badge\.is-ahead\s*\{[^}]*var\(--yellow\)/); + expect(dashboardHtml).toMatch(/\.git-flow-badge\.is-sync\s*\{[^}]*var\(--green\)/); + }); + + it("keeps flow badges and state labels honest (sync / ahead / behind)", () => { + const sources = [ + /function gitFlowBadge\(div\) \{[\s\S]*?\n\}/, + /function gitFlowStateLabel\(div, \{ syncText, aheadText, behindText \}\) \{[\s\S]*?\n\}/, + ].map((re) => { + const match = dashboardHtml.match(re); + expect(match, `dashboard.html must define ${re.source}`).not.toBeNull(); + return match?.[0]; + }); + const { gitFlowBadge, gitFlowStateLabel } = new Function( + `${sources.join("\n")}\nreturn { gitFlowBadge, gitFlowStateLabel };`, + )() as { + gitFlowBadge: (div: { ahead: number; behind: number } | null) => string; + gitFlowStateLabel: ( + div: { ahead: number; behind: number } | null, + texts: { + syncText: string; + aheadText: (n: number) => string; + behindText: (n: number) => string; + }, + ) => { tone: string | null; text: string }; + }; + expect(gitFlowBadge(null)).toContain("no upstream"); + expect(gitFlowBadge({ ahead: 0, behind: 0 })).toContain("is-sync"); + expect(gitFlowBadge({ ahead: 2, behind: 0 })).toContain("is-ahead"); + expect(gitFlowBadge({ ahead: 0, behind: 3 })).toContain("is-behind"); + const texts = { + syncText: "sync", + aheadText: (n: number) => `ahead ${n}`, + behindText: (n: number) => `behind ${n}`, + }; + expect(gitFlowStateLabel({ ahead: 0, behind: 0 }, texts)).toEqual({ + tone: "green", + text: "sync", + }); + expect(gitFlowStateLabel({ ahead: 1, behind: 0 }, texts).tone).toBe("yellow"); + expect(gitFlowStateLabel({ ahead: 0, behind: 1 }, texts).tone).toBe("yellow"); + expect(gitFlowStateLabel({ ahead: 1, behind: 1 }, texts).text).toContain("diverged"); + expect(gitFlowStateLabel(null, texts).tone).toBeNull(); + }); + + it("renders a readable git graph block (no decorative per-commit dots)", () => { + expect(dashboardHtml).toContain("function renderGitGraphCard(git)"); + const graphFn = dashboardHtml.match(/function renderGitGraphCard\(git\) \{[\s\S]*?\n\}/)?.[0]; + expect(graphFn).toBeTruthy(); + expect(graphFn).toContain('class="git-graph"'); + expect(graphFn).toContain("aria-label"); + expect(graphFn).toContain("escapeHtml(lines.join('\\n'))"); + expect(graphFn).not.toContain("dot"); + expect(dashboardHtml).toMatch(/\.git-graph\s*\{[^}]*var\(--mc-font-mono\)/); + // Old stat rows are gone; flow lanes carry ahead/behind now. + expect(gitBlock).not.toContain("Ahead of origin/main"); + expect(gitBlock).not.toContain("Behind origin/main"); + // Dots inside the Git tab only appear paired with state labels (flow/hygiene). + expect(gitBlock).not.toContain("dot-gray"); + for (const dotMatch of gitBlock?.match(/ { + expect(dashboardHtml).toContain("function renderGitHygieneHint(git)"); + const hintFn = dashboardHtml.match(/function renderGitHygieneHint\(git\) \{[\s\S]*?\n\}/)?.[0]; + expect(hintFn).toBeTruthy(); + expect(hintFn).toContain("git?.hygiene?.monitorWip || []"); + expect(hintFn).toContain("if (!wip.length) return '';"); + expect(hintFn).toContain("add-by-name only"); + expect(hintFn).toContain("Copy /git-staging"); + expect(dashboardHtml).toMatch(/\.git-hygiene\s*\{[^}]*var\(--yellow-bg\)/); + }); + + it("wires flow, graph, and hygiene data in dashboard-data.mjs", () => { + const dataSource = readFileSync(resolve(repoRoot, "dashboard/dashboard-data.mjs"), "utf8"); + expect(dataSource).toContain("git rev-list --left-right --count ${range}"); + expect(dataSource).toContain('countDivergence("origin/staging...HEAD")'); + expect(dataSource).toContain('countDivergence("origin/main...HEAD")'); + expect(dataSource).toContain('countDivergence("origin/main...origin/staging")'); + expect(dataSource).toContain("git log --graph --oneline --decorate --date-order --all"); + expect(dataSource).toContain("MAX_GIT_GRAPH_LINES"); + expect(dataSource).toContain("/^\\.cursor\\/memory\\/plan-monitor-.+\\.md$/"); + expect(dataSource).toContain("hygiene: { monitorWip }"); + }); +}); + +describe("plugin-ux-validation: processes tab narration", () => { + const processesSection = + dashboardHtml.match(/id="section-processes"[\s\S]*?id="section-skills"/)?.[0] ?? ""; + + it("renders a narrated live list with an informational (not alarm) note", () => { + // Informational note uses the blue informational palette, never attention red. + const noteCss = dashboardHtml.match(/\.processes-note\s*\{[\s\S]*?\n\}/)?.[0] ?? ""; + expect(noteCss).toContain("var(--blue-bg)"); + expect(noteCss).not.toContain("var(--red"); + expect(processesSection).toContain('class="processes-note"'); + expect(processesSection).toContain("Live ps snapshot."); + // Crew monitor pointer keeps IDE-spawned agent activity discoverable. + expect(processesSection).toContain("Crew monitor"); + // Live list chrome: per-process card with the generated narration. + expect(processesSection).toContain('class="process-list"'); + expect(processesSection).toContain('class="process-card"'); + expect(processesSection).toContain('class="process-label-badge"'); + expect(processesSection).toContain('class="process-desc"'); + expect(processesSection).toContain("escapeHtml(p.description)"); + expect(processesSection).toContain("escapeHtml(p.etime)"); + // Honest empty state, no decorative filler. + expect(processesSection).toContain("All quiet"); + expect(processesSection).toContain("appear here while active"); + // Copy-only action kept, terminal as paste destination. + expect(processesSection).toContain("Copy PID"); + // Dot semantics: no decorative dots in the Processes tab. + expect(processesSection).not.toMatch(/class="dot/); + // The old table chrome is gone. + expect(dashboardHtml).not.toContain("processes-table"); + }); + + it("ships per-process narration fields from dashboard-data.mjs", () => { + const dataSource = readFileSync(resolve(repoRoot, "dashboard/dashboard-data.mjs"), "utf8"); + expect(dataSource).toContain("ps -axo pid=,pcpu=,pmem=,etime=,command="); + expect(dataSource).toContain( + "description: describeProcess({ label, command: cmd, cpu, etime })", + ); + }); +}); diff --git a/packages/cli/src/dashboard/semantic-model.test.ts b/packages/cli/src/dashboard/semantic-model.test.ts index 0cc0635..24019a8 100644 --- a/packages/cli/src/dashboard/semantic-model.test.ts +++ b/packages/cli/src/dashboard/semantic-model.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + BUSY_OUTSIDE_PLAN_FRESH_MS, FLIGHT_LOG_LEDGER_REL, FLIGHT_LOG_PAST_CAP, FLIGHT_LOG_QUIET_OPEN_TRIAGES_CAP, @@ -28,6 +29,8 @@ import { collectDeferredCheckIds, collectReadinessPendingFromReport, deliverySupersededShas, + deriveBusyOutsidePlan, + describeProcess, emptyCadenceLedger, emptyFlightLogLedger, emptyMissionTimingLedger, @@ -1211,7 +1214,7 @@ describe("formatDeliveryActivity", () => { plan: null, commitType: "docs", }); - expect(events[1].label).toContain("orchestrator · shipped ·"); + expect(events[1].label).toContain("Engineering Manager · shipped ·"); expect(events[1].label).toContain("docs: update plan-review-triage for multi-path walk"); expect(events[1].label).toContain("PR #320 · 74954c7"); expect(events[1].label).not.toMatch(/\(#320\)/); @@ -1275,9 +1278,9 @@ describe("formatDeliveryActivity", () => { ]); expect(events[1].refs.commits).toEqual(["528901c", "6051036"]); expect(events[0].label).not.toContain("field-report-owed-external-review"); - // Plan attribution stays on refs; label actor is orchestrator when agent is not a kit id. + // Plan attribution stays on refs; label actor is Engineering Manager when agent is not a kit id. expect(events[1].refs.plan).toBe("monitor-agent-activity-focus.plan.md"); - expect(events[1].label).toContain("orchestrator · shipped ·"); + expect(events[1].label).toContain("Engineering Manager · shipped ·"); }); it("honors a limit option like other activity producers", () => { @@ -1305,9 +1308,13 @@ describe("formatPlanHandoffActivity + mergeActivity", () => { plans: samplePlans, }); expect(planEvents[0].kind).toBe("run_plan"); - expect(planEvents[0].label).toMatch(/· tick ·/); + expect(planEvents[0].label).toMatch(/· running ·/); expect(planEvents[0].label).toContain("semantic-snapshot-model"); expect(planEvents[0].label).toContain("mission-control-plugin-ux.plan.md"); + // Meaningful info first: todo id lands before the plan filename. + expect(planEvents[0].label.indexOf("semantic-snapshot-model")).toBeLessThan( + planEvents[0].label.indexOf("mission-control-plugin-ux.plan.md"), + ); const merged = mergeActivity([ planEvents, @@ -1335,7 +1342,7 @@ describe("formatPlanHandoffActivity + mergeActivity", () => { }); expect(planEvents[0].agent).toBe("docs-repo"); expect(planEvents[0].kind).toBe("run_plan"); - expect(planEvents[0].label).toMatch(/^docs-repo · tick ·/); + expect(planEvents[0].label).toMatch(/^docs-repo · running ·/); }); it("emits denser agent_step rows for active-plan completed/running todos", () => { @@ -1370,10 +1377,46 @@ describe("formatPlanHandoffActivity + mergeActivity", () => { expect(steps.length).toBe(3); expect(steps.map((e) => e.refs?.todo)).toEqual(["step-a", "step-b", "step-c"]); expect(steps.map((e) => e.refs?.phase)).toEqual(["done", "done", "running"]); + // Natural voice: phase word first, then the todo id; no robotic "step" separator. + // "generalPurpose" is not a kit agent id, so the actor falls back to Squad. + expect(steps[0].label).toMatch(/^Squad · done · step-a/); + expect(steps[2].label).toMatch(/^Squad · running · step-c/); expect(steps.every((e) => MONITOR_ACTIVITY_KINDS.includes(e.kind))).toBe(true); expect(planEvents.some((e) => e.kind === "run_plan")).toBe(true); }); + it("labels handoff rows as awaiting with the gate first", () => { + const handoff = { + plan: "mission-control-plugin-ux.plan.md", + mode: "START-PROJECT Gate A complete, awaiting Gate B", + parkedPlans: [], + nextTodos: "`semantic-snapshot-model`", + }; + const pendingPlan = { + ...samplePlans[0], + todos: { + ...samplePlans[0].todos, + inProgress: 0, + items: samplePlans[0].todos.items.map((todo) => ({ + ...todo, + status: "pending", + })), + }, + }; + const now = buildCurrentExecution([pendingPlan], handoff); + expect(now.status).toBe("awaiting_user"); + const planEvents = formatPlanHandoffActivity({ + now, + handoff, + plans: samplePlans, + }); + const gate = planEvents.find((e) => e.kind === "handoff"); + expect(gate.label).toMatch(/^Squad · awaiting · next \S+/); + expect(gate.label).not.toContain("mission-control-plugin-ux.plan.md"); + expect(gate.labelFull).toContain("mission-control-plugin-ux.plan.md"); + expect(gate.labelFull.startsWith(gate.label)).toBe(true); + }); + it("sets agent on plan_progress from plan.agent when it is a kit agent id", () => { const completedPlan = { ...samplePlans[1], @@ -1397,7 +1440,7 @@ describe("formatPlanHandoffActivity + mergeActivity", () => { const progress = events.filter((e) => e.kind === "plan_progress"); expect(progress.length).toBeGreaterThan(0); expect(progress[0].agent).toBe("tech-lead"); - expect(progress[0].label).toMatch(/^tech-lead · plan ·/); + expect(progress[0].label).toMatch(/^tech-lead · done ·/); expect(progress[0].label).toContain("2/2"); // Monitor hero: live actions + denser agent_step; milestones stay on Activity expect(MONITOR_ACTIVITY_KINDS).toEqual(["run_plan", "handoff", "delivery", "agent_step"]); @@ -1429,11 +1472,108 @@ describe("formatPlanHandoffActivity + mergeActivity", () => { }); describe("briefActivityActor", () => { - it("prefers kit agent, then delivery orchestrator, then plan, then system", () => { + it("prefers kit agent, then delivery Engineering Manager, then Squad, then Platform Engineer", () => { expect(briefActivityActor("docs-repo", { kind: "run_plan" })).toBe("docs-repo"); - expect(briefActivityActor(null, { kind: "delivery", plan: "x.plan.md" })).toBe("orchestrator"); - expect(briefActivityActor(null, { kind: "run_plan", plan: "x.plan.md" })).toBe("x.plan.md"); - expect(briefActivityActor(null, { kind: "handoff" })).toBe("system"); + expect(briefActivityActor(null, { kind: "delivery", plan: "x.plan.md" })).toBe( + "Engineering Manager", + ); + // Lexicon fallback: never the full plan filename in the actor slot. + expect(briefActivityActor(null, { kind: "run_plan", plan: "x.plan.md" })).toBe("Squad"); + expect(briefActivityActor(null, { kind: "agent_step", plan: "x.plan.md" })).toBe("Squad"); + expect(briefActivityActor(null, { kind: "handoff", plan: "x.plan.md" })).toBe("Squad"); + expect(briefActivityActor(null, { kind: "plan_progress", plan: "x.plan.md" })).toBe("Squad"); + expect(briefActivityActor(null, { kind: "handoff" })).toBe("Platform Engineer"); + }); +}); + +describe("deriveBusyOutsidePlan", () => { + const nowMs = Date.parse("2026-07-31T20:00:00.000Z"); + const freshAt = new Date(nowMs - 60_000).toISOString(); + const staleAt = new Date(nowMs - BUSY_OUTSIDE_PLAN_FRESH_MS - 60_000).toISOString(); + const runEvidenceTerminal = { + id: "7.txt", + updatedAt: freshAt, + lastOutput: "LOOP_TICK_RESULT tick ok", + }; + + it("is inactive without a now slice or without terminals", () => { + expect(deriveBusyOutsidePlan({ terminals: [runEvidenceTerminal], nowMs })).toEqual({ + active: false, + evidence: [], + }); + expect(deriveBusyOutsidePlan({ now: { status: "idle" }, nowMs })).toEqual({ + active: false, + evidence: [], + }); + }); + + it("activates on fresh run-loop terminal evidence while the mission is idle", () => { + const result = deriveBusyOutsidePlan({ + now: { status: "idle" }, + terminals: [runEvidenceTerminal], + nowMs, + }); + expect(result.active).toBe(true); + expect(result.evidence).toEqual([{ terminal: "7.txt", at: freshAt }]); + }); + + it("activates while awaiting_user or completed, not only idle", () => { + for (const status of ["awaiting_user", "completed"]) { + const result = deriveBusyOutsidePlan({ + now: { status }, + terminals: [runEvidenceTerminal], + nowMs, + }); + expect(result.active).toBe(true); + } + }); + + it("stays inactive while the mission is executing (in-plan chrome owns the state)", () => { + const result = deriveBusyOutsidePlan({ + now: { status: "executing" }, + terminals: [runEvidenceTerminal], + nowMs, + }); + expect(result.active).toBe(false); + expect(result.evidence).toEqual([]); + }); + + it("ignores stale evidence outside the freshness window", () => { + const stale = { ...runEvidenceTerminal, updatedAt: staleAt }; + expect( + deriveBusyOutsidePlan({ now: { status: "idle" }, terminals: [stale], nowMs }).active, + ).toBe(false); + }); + + it("ignores terminals without run-loop evidence or without a parseable mtime", () => { + const noEvidence = { id: "8.txt", updatedAt: freshAt, lastOutput: "npm test passed" }; + const noMtime = { id: "9.txt", lastOutput: "LOOP_TICK_RESULT tick ok" }; + const badMtime = { ...runEvidenceTerminal, id: "10.txt", updatedAt: "not-a-date" }; + const result = deriveBusyOutsidePlan({ + now: { status: "idle" }, + terminals: [noEvidence, noMtime, badMtime], + nowMs, + }); + expect(result.active).toBe(false); + }); + + it("buildMissionControlView attaches busyOutsidePlan to the now slice", () => { + const idleView = buildMissionControlView({ terminals: [runEvidenceTerminal], nowMs }); + expect(idleView.now.status).toBe("idle"); + expect(idleView.now.busyOutsidePlan?.active).toBe(true); + + const executingView = buildMissionControlView({ + plans: samplePlans, + handoff: { + plan: "mission-control-plugin-ux.plan.md", + mode: "run-plan (in-session loop)", + parkedPlans: [], + }, + terminals: [runEvidenceTerminal], + nowMs, + }); + expect(executingView.now.status).toBe("executing"); + expect(executingView.now.busyOutsidePlan?.active).toBe(false); }); }); @@ -2703,3 +2843,150 @@ describe("listFlightLogQuietOpenTriages", () => { expect(view.flightLogQuietOpenTriagesCap).toBe(FLIGHT_LOG_QUIET_OPEN_TRIAGES_CAP); }); }); + +describe("Flight Log composed action commands", () => { + it("Gaps NOW carries a Copy fix prompt action with the HANDOFF path", () => { + const { flightLog } = observeFlightLog(null, "Enqueue residuals for F1", { + nowMs: Date.parse("2026-07-28T01:00:00.000Z"), + sourcePath: ".cursor/HANDOFF.md", + }); + expect(flightLog.currentAction?.label).toBe("Copy fix prompt"); + expect(flightLog.currentAction?.sourcePath).toBe(".cursor/HANDOFF.md"); + expect(flightLog.currentAction?.command).toBe( + "Act on these open residuals:\nEnqueue residuals for F1\n.cursor/HANDOFF.md", + ); + }); + + it("Gaps NOW action is null when there are no live gaps", () => { + const { flightLog } = observeFlightLog(null, null, { + nowMs: Date.parse("2026-07-28T01:00:00.000Z"), + }); + expect(flightLog.current).toBeNull(); + expect(flightLog.currentAction).toBeNull(); + }); + + it("Gaps Earlier entries carry a Copy fix prompt action with the entry sourcePath", () => { + const { ledger } = observeFlightLog(null, "first gap", { + nowMs: Date.parse("2026-07-28T01:00:00.000Z"), + sourcePath: ".cursor/HANDOFF.md", + }); + const { flightLog } = observeFlightLog(ledger, "second gap", { + nowMs: Date.parse("2026-07-28T02:00:00.000Z"), + sourcePath: ".cursor/HANDOFF.md", + }); + expect(flightLog.past).toHaveLength(1); + const earlier = flightLog.past[0]; + expect(earlier.action?.label).toBe("Copy fix prompt"); + expect(earlier.action?.sourcePath).toBe(".cursor/HANDOFF.md"); + expect(earlier.action?.command).toBe( + "Act on these earlier residuals:\nfirst gap\n.cursor/HANDOFF.md", + ); + }); + + it("api_limit warning carries a Copy recovery prompt action", () => { + const warnings = buildFlightLogWarnings({ + mode: "run-plan (orchestrated) — STOPPED: API/usage limit", + gaps: "API/usage limit; cursor on phase-2", + instruction: "", + }); + const w = warnings.find((x) => x.kind === "api_limit"); + expect(w?.action?.label).toBe("Copy recovery prompt"); + expect(w?.action?.sourcePath).toBe(".cursor/HANDOFF.md"); + expect(w?.action?.command).toContain("Resume after this quota pause:"); + expect(w?.action?.command.endsWith("\n.cursor/HANDOFF.md")).toBe(true); + }); + + it("orchestrator_heads_up warning carries a Copy follow-up prompt action", () => { + const warnings = buildFlightLogWarnings({ + mode: "manual", + gaps: "Heads up: rebalance the queue before the next tick", + instruction: "", + }); + const w = warnings.find((x) => x.kind === "orchestrator_heads_up"); + expect(w?.action?.label).toBe("Copy follow-up prompt"); + expect(w?.action?.sourcePath).toBe(".cursor/HANDOFF.md"); + expect(w?.action?.command).toContain("Act on this heads-up:"); + expect(w?.action?.command.endsWith("\n.cursor/HANDOFF.md")).toBe(true); + }); + + it("quiet open-triage rows carry the slash-command payload with monitor sourcePath", () => { + const report = parseExternalReport({ + file: "plan-monitor-widget-rollout.md", + content: + "# Monitor log - widget-rollout\n\n**Plan:** `widget-rollout.plan.md`\n\n### Residual items for human attention\n\n1. Fix the live blocker.\n", + modifiedAt: "2026-07-28T12:00:00.000Z", + }); + const view = buildMissionControlView({ + plans: samplePlans, + handoff: { + plan: "mission-control-plugin-ux.plan.md", + mode: "manual", + }, + externalReports: [report], + nowMs: Date.parse("2026-07-28T12:00:00.000Z"), + }); + const row = view.flightLog.quietOpenTriages[0]; + expect(row.action?.label).toBe("Copy triage command"); + expect(row.action?.pasteDestination).toBe("chatInput"); + expect(row.action?.sourcePath).toBe(row.sourcePath); + expect(row.action?.command).toBe(`/plan-review-triage ${row.sourcePath}`); + }); +}); + +describe("describeProcess", () => { + it("narrates the dashboard server, with its port when present", () => { + expect( + describeProcess({ + label: "dashboard-server", + command: "node dashboard/serve.mjs --port 3333", + cpu: "0.2", + etime: "02:10:11", + }), + ).toBe("Serving the Mission Control dashboard on port 3333 (idle, 0.2% CPU, up 02:10:11)."); + expect( + describeProcess({ label: "dashboard-server", command: "node dashboard/serve.mjs" }), + ).toBe("Serving the Mission Control dashboard."); + }); + + it("narrates git operations by subcommand", () => { + expect(describeProcess({ label: "git", command: "git push origin staging", cpu: "12.5" })).toBe( + "Pushing commits to the remote (active, 12.5% CPU).", + ); + expect(describeProcess({ label: "git", command: "git bisect start", cpu: "0.0" })).toBe( + "Running git bisect (idle, 0.0% CPU).", + ); + }); + + it("narrates node scripts by file name", () => { + expect( + describeProcess({ + label: "node", + command: "node /tmp/scripts/build.mjs --watch", + cpu: "55.0", + }), + ).toBe("Running the build.mjs Node script (busy, 55.0% CPU)."); + expect(describeProcess({ label: "node", command: "node", cpu: "1.0" })).toBe( + "Running a Node.js process (idle, 1.0% CPU).", + ); + }); + + it("falls back to package runners and binary names for other processes", () => { + expect(describeProcess({ label: "other", command: "pnpm vitest run", cpu: "30.0" })).toBe( + "Running pnpm vitest (active, 30.0% CPU).", + ); + expect(describeProcess({ label: "other", command: "/usr/bin/SCREEN -dmS audit bash" })).toBe( + "Running SCREEN.", + ); + }); + + it("degrades gracefully on empty input and caps long narrations", () => { + expect(describeProcess({})).toBe("Running an unrecognized process."); + expect(describeProcess(null)).toBe("Running an unrecognized process."); + const long = describeProcess({ + label: "node", + command: `node /tmp/${"a".repeat(200)}.mjs`, + }); + expect(long.endsWith("\u2026")).toBe(true); + expect(long.length).toBeLessThanOrEqual(161); + }); +}); diff --git a/packages/cli/src/dashboard/terminal-snapshot.test.ts b/packages/cli/src/dashboard/terminal-snapshot.test.ts new file mode 100644 index 0000000..696647c --- /dev/null +++ b/packages/cli/src/dashboard/terminal-snapshot.test.ts @@ -0,0 +1,122 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { + MAX_TERMINAL_BYTES, + buildTerminalSnapshotFields, + parseTerminalMeta, + splitTerminalHeader, +} from "../../../../dashboard/lib/terminal-snapshot.mjs"; + +const repoRoot = resolve(fileURLToPath(import.meta.url), "../../../../.."); +const dataSource = readFileSync(resolve(repoRoot, "dashboard/dashboard-data.mjs"), "utf8"); + +function makeOverCapTerminal(bodyPadBytes) { + const header = [ + "---", + "pid: 424242", + "cwd: /Users/macos/Documents/Git/agent-kit", + "last_command: pnpm test", + "last_exit_code: 0", + "---", + "", + ].join("\n"); + // Short lines so the last-output char cap still keeps the fresh tail marker. + const padLine = "pad"; + const linesNeeded = Math.ceil(bodyPadBytes / (padLine.length + 1)); + const body = Array.from({ length: linesNeeded }, (_, i) => `line-${i} ${padLine}`).join("\n"); + const tailMarker = "FRESH_TAIL_OUTPUT_MARKER"; + return `${header}${body}\n${tailMarker}\n`; +} + +describe("terminal-snapshot: head meta + tail body", () => { + it("parses meta from the file head even when the body exceeds MAX_TERMINAL_BYTES", () => { + const raw = makeOverCapTerminal(MAX_TERMINAL_BYTES + 8_000); + expect(raw.length).toBeGreaterThan(MAX_TERMINAL_BYTES); + + const { meta, lastOutput, outputLines } = buildTerminalSnapshotFields(raw, { + maxBytes: MAX_TERMINAL_BYTES, + }); + + expect(meta.pid).toBe("424242"); + expect(meta.cwd).toBe("/Users/macos/Documents/Git/agent-kit"); + expect(meta.lastCommand).toBe("pnpm test"); + expect(meta.lastExitCode).toBe("0"); + expect(outputLines).toBeGreaterThan(0); + expect(lastOutput).toContain("FRESH_TAIL_OUTPUT_MARKER"); + }); + + it("keeps meta when a plain tail-slice would have dropped the header", () => { + const raw = makeOverCapTerminal(MAX_TERMINAL_BYTES + 4_000); + const naiveTail = raw.slice(-MAX_TERMINAL_BYTES); + const naiveMeta = parseTerminalMeta(naiveTail.split("\n").slice(0, 15)); + expect(naiveMeta.pid).toBeUndefined(); + expect(naiveMeta.lastExitCode).toBeUndefined(); + + const { meta } = buildTerminalSnapshotFields(raw); + expect(meta.pid).toBe("424242"); + expect(meta.lastExitCode).toBe("0"); + }); + + it("splits header after the second ---", () => { + const raw = "---\npid: 1\ncwd: /tmp\n---\nhello\n"; + const { headerLines, bodyLines } = splitTerminalHeader(raw); + expect(headerLines.join("\n")).toContain("pid: 1"); + expect(bodyLines.join("\n")).toContain("hello"); + }); + + it("uses TERMINAL_HEAD_META_BYTES head window on oversized files (T7/T8)", () => { + const raw = makeOverCapTerminal(MAX_TERMINAL_BYTES + 200_000); + expect(raw.length).toBeGreaterThan(MAX_TERMINAL_BYTES + 4096); + + const { meta, lastOutput } = buildTerminalSnapshotFields(raw, { + maxBytes: MAX_TERMINAL_BYTES, + headMetaBytes: 4096, + }); + + expect(meta.pid).toBe("424242"); + expect(lastOutput).toContain("FRESH_TAIL_OUTPUT_MARKER"); + }); + + it("trims partial first body line on windowed over-cap path (U4)", () => { + const header = ["---", "pid: 99", "cwd: /tmp", "---", ""].join("\n"); + const maxBytes = 200; + const headMetaBytes = 64; + const longLine = `PARTIAL${"X".repeat(300)}COMPLETE`; + const suffix = "\nTAIL_OK\n"; + const pad = "p\n".repeat(5000); + const raw = `${header}${pad}${longLine}${suffix}`; + expect(raw.length).toBeGreaterThan(headMetaBytes + maxBytes); + const sliceStart = raw.length - maxBytes; + const longStart = raw.indexOf(longLine); + expect(sliceStart).toBeGreaterThan(longStart); + expect(sliceStart).toBeLessThan(longStart + longLine.length); + + const naiveTail = raw.slice(-maxBytes); + const cut = naiveTail.indexOf("\n"); + expect(cut).toBeGreaterThan(0); + const partialFirst = naiveTail.slice(0, cut); + expect(partialFirst.length).toBeGreaterThan(0); + expect(partialFirst).not.toBe(longLine); + + const { bodyLines, lastOutput, meta } = buildTerminalSnapshotFields(raw, { + maxBytes, + headMetaBytes, + }); + expect(meta.pid).toBe("99"); + expect(lastOutput).toContain("TAIL_OK"); + expect(bodyLines.some((l) => l === partialFirst)).toBe(false); + expect(bodyLines[0]).not.toBe(partialFirst); + }); +}); + +describe("dashboard-data wires terminal-snapshot lib", () => { + it("imports buildTerminalSnapshotFields and does not parse meta from a tail-only content slice", () => { + expect(dataSource).toMatch(/from ['"]\.\/lib\/terminal-snapshot\.mjs['"]/); + expect(dataSource).toContain("buildTerminalSnapshotFields"); + expect(dataSource).not.toMatch( + /const content = raw\.length > MAX_TERMINAL_BYTES \? raw\.slice\(-MAX_TERMINAL_BYTES\)/, + ); + }); +}); diff --git a/packages/cli/src/docs/staging-lint-evidence.test.ts b/packages/cli/src/docs/staging-lint-evidence.test.ts new file mode 100644 index 0000000..2492425 --- /dev/null +++ b/packages/cli/src/docs/staging-lint-evidence.test.ts @@ -0,0 +1,115 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const repoRoot = resolve(fileURLToPath(import.meta.url), "../../../../.."); + +/** + * Mechanical pin: staging-ready lint-evidence clause must remain in the three + * staging / run-plan surfaces. Removing the clause fails this test (close-audit B). + * ADR pin below is private-factory only (public-sync excludes `.cursor/memory/**`); + * skip when the file is absent so public mirror CI does not ENOENT. + */ +describe("docs-contract: staging lint-evidence clause", () => { + const surfaces = [ + ".cursor/commands/git-staging.md", + ".cursor/commands/run-plan.md", + "autogit/gitupdate.md", + ] as const; + + for (const rel of surfaces) { + it(`keeps lint-evidence / Staging ready gate prose in ${rel}`, () => { + const body = readFileSync(resolve(repoRoot, rel), "utf8"); + // Contract string alone is not evidence (must be stated). + expect(body).toMatch(/Staging ready:\s*yes|Staging-ready lint|lint evidence/i); + expect(body).toMatch( + /not\s+(?:evidence|the contract)|contract (?:string|phrase) alone|without that (?:evidence|recorded run) is invalid/i, + ); + expect(body).toMatch(/none applicable|no applicable/i); + }); + } + + it("documents dashboard-CSS none-applicable / plugin-ux-validation coverage convention", () => { + // Private-factory ADR; public mirror never receives `.cursor/memory/**`. + const adrPath = resolve( + repoRoot, + ".cursor/memory/decisions/2026-07-29_dashboard-css-lint-evidence-convention.md", + ); + if (!existsSync(adrPath)) { + return; + } + + const adr = readFileSync(adrPath, "utf8"); + expect(adr).toMatch(/plugin-ux-validation/i); + expect(adr).toMatch(/none applicable/i); + expect(adr).toMatch(/dashboard\.html|dashboard-CSS/i); + }); + + it("pins dashboard-CSS lint clause on run-plan Staging-ready gate", () => { + const body = readFileSync(resolve(repoRoot, ".cursor/commands/run-plan.md"), "utf8"); + expect(body).toMatch(/dashboard-CSS/i); + expect(body).toMatch(/plugin-ux-validation/i); + expect(body).toMatch(/none applicable \(dashboard-CSS\)/i); + }); + + it("pins dashboard-CSS lint clause on git-staging", () => { + const body = readFileSync(resolve(repoRoot, ".cursor/commands/git-staging.md"), "utf8"); + expect(body).toMatch(/dashboard-CSS/i); + expect(body).toMatch(/plugin-ux-validation/i); + }); + + it("keeps gitupdate out of claiming Biome on dashboard.html", () => { + const body = readFileSync(resolve(repoRoot, "autogit/gitupdate.md"), "utf8"); + expect(body).toMatch(/dashboard\/dashboard\.html`? is outside Biome/i); + expect(body).toMatch(/dashboard-CSS/i); + // Old incorrect scope listed dashboard/ alongside packages/ as Biome/ESLint. + expect(body).not.toMatch(/under `packages\/`, `dashboard\//); + }); +}); + +/** + * Mechanical pin: audit PTY honesty clauses must stay in L0 commands and the + * external-plan-review docs. Removing or weakening the exit-3, progress-gate, + * or session-cap/warn clauses fails this test. + */ +describe("docs-contract: audit PTY honesty clauses", () => { + it("pins exit 3 = timeout-only across L0 audit commands", () => { + for (const rel of [ + ".cursor/commands/run-plan.md", + ".cursor/commands/run-plan-all.md", + ".cursor/commands/plan-external-review.md", + ]) { + const body = readFileSync(resolve(repoRoot, rel), "utf8"); + expect(body).toMatch(/exit[\s`]+3/i); + expect(body).toMatch(/timeout[\s-]*only/i); + expect(body).toMatch( + /(?:do|does)(?:\*\*)?[\s*]+not(?:\*\*)?[\s*]+(?:retroactively\s+)?(?:convert|upgrade|rewrite)/i, + ); + } + }); + + it("pins exit 3 = timeout-only in external-plan-review docs", () => { + const body = readFileSync(resolve(repoRoot, "docs/external-plan-review.md"), "utf8"); + expect(body).toMatch(/exit[\s`]+3/i); + expect(body).toMatch(/timeout[\s-]*only/i); + expect(body).toMatch( + /never[\s]+review[\s]+done|(?:do|does)(?:\*\*)?[\s*]+not(?:\*\*)?[\s*]+(?:retroactively\s+)?(?:convert|upgrade|rewrite)/i, + ); + }); + + it("pins post-spawn progress gate banner-baseline behavior in docs", () => { + const body = readFileSync(resolve(repoRoot, "docs/external-plan-review.md"), "utf8"); + expect(body).toMatch(/progress gate/i); + expect(body).toMatch(/banner|pre-exec|growth beyond/i); + expect(body).toMatch(/scrollback/i); + }); + + it("pins session cap/warn concurrency note in docs", () => { + const body = readFileSync(resolve(repoRoot, "docs/external-plan-review.md"), "utf8"); + expect(body).toMatch(/AGENT_KIT_AUDIT_SESSION_CAP/i); + expect(body).toMatch(/AGENT_KIT_AUDIT_SESSION_WARN/i); + expect(body).toMatch(/detached/i); + expect(body).toMatch(/concurrency|healthy autonomous/i); + }); +}); diff --git a/packages/cli/src/hooks/hard-rules.ts b/packages/cli/src/hooks/hard-rules.ts index 9615365..dc055fc 100644 --- a/packages/cli/src/hooks/hard-rules.ts +++ b/packages/cli/src/hooks/hard-rules.ts @@ -13,12 +13,21 @@ export const HARD_RULES = `# Agent Kit session hard rules (manual mode default) 10. **Backlog CRUD never activates.** \`/backlog-add\` enqueues (Broad Intake + write Ask + plan file + HANDOFF Backlog) without park, activate, or Gate B. \`/backlog-edit\` / \`/backlog-delete\` / \`/backlog-cancel\` require Ask confirm before mutate; delete archives from Backlog, cancel is soft in place. No Field Report cards for routine backlog CRUD. 11. **HANDOFF machine fields are bullet fields, not \`##\` headings.** Mission Control parses \`- **Plan:**\`, \`- **Backlog plans:**\`, \`- **Parked plans:**\`, \`- **Run queue:**\` (etc.). Canonical Plan: \`- **Plan:** \\\`name.plan.md\\\`\` or \`none\`. Nested backlog/parked rows: \`- \\\`other.plan.md\\\`\`. Never invent \`## Backlog plans\` / \`## Parked plans\` / \`## Run queue\` headings in place of those fields (Checklist / Current mission go empty or idle).`; +// EXT-202: hint stays unconditional; /dogfood is an L0 artifact (EXT-201), so +// consumers receive the command file and the inbox copy is accurate without a +// lane-conditional guard (EXT-212 documents that resolution). export const DOGFOOD_INBOX_HINT = `## Dogfood inbox -Unprocessed files are listed under \`dogfood/README.md\` (### Unprocessed Files). Follow the ingest ritual there (detect → analyze → memory WRITE → triage). Do not auto-start analysis unless the user asks.`; +Unprocessed files are listed under \`dogfood/README.md\` (factory) or \`.cursor/dogfood/README.md\` (consumer). To file a new note, use \`/dogfood [summary]\`. Follow the ingest ritual (detect → analyze → memory WRITE → triage). Do not auto-start analysis unless the user asks.`; export const UPDATE_CHECK_NUDGE = `## Agent Kit update available Installed **v{installed}**; latest public **v{latest}**. This is an advisory only (no files were changed). To apply, run \`/update\` and confirm via Ask questions. Bare \`agent-kit update\` is an explicit operator invoke, not a background job.`; + +export const CURSOR_AWARENESS_NUDGE = `## Cursor product-update awareness + +Advisory gaps vs \`docs/cursor-native-audit.md\` (check-only; no apply; no Field Reports). + +Run \`/cursor-update-awareness\` (or \`agent-kit cursor-awareness --check\`) and confirm routing via Ask → \`/backlog-add\` or \`/dogfood\`.`; diff --git a/packages/cli/src/hooks/session-start.test.ts b/packages/cli/src/hooks/session-start.test.ts index 7cc8eb6..279158d 100644 --- a/packages/cli/src/hooks/session-start.test.ts +++ b/packages/cli/src/hooks/session-start.test.ts @@ -1,11 +1,16 @@ +import { EventEmitter } from "node:events"; import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { CURSOR_AWARENESS_NUDGE } from "./hard-rules.js"; import { buildPreCompactUserMessage } from "./pre-compact.js"; import { + type CursorAwarenessSpawn, buildSessionStartAdditionalContext, + cursorAwarenessSection, parseUnprocessedDogfoodItems, + shouldEmitCursorAwarenessNudge, } from "./session-start.js"; describe("parseUnprocessedDogfoodItems", () => { @@ -133,4 +138,160 @@ describe("buildSessionStartAdditionalContext", () => { expect(additional_context).toContain("Optional readiness item: `pick-persona`"); expect(additional_context).toContain("does not block"); }); + + it("surfaces dogfood inbox hint for factory dogfood/README.md", async () => { + const root = await fixtureRoot(); + await mkdir(path.join(root, "dogfood"), { recursive: true }); + await writeFile( + path.join(root, "dogfood", "README.md"), + "### Unprocessed Files\n\n- `cursor_example_2026_07_31.md` - example\n\n### Processed Files\n", + "utf8", + ); + const { additional_context } = await buildSessionStartAdditionalContext(root); + expect(additional_context).toContain("## Dogfood inbox"); + expect(additional_context).toContain("/dogfood"); + }); + + it("surfaces dogfood inbox hint for consumer .cursor/dogfood/README.md", async () => { + const root = await fixtureRoot(); + await mkdir(path.join(root, ".cursor", "dogfood"), { recursive: true }); + await writeFile( + path.join(root, ".cursor", "dogfood", "README.md"), + "### Unprocessed Files\n\n- `cursor_example_2026_07_31.md` - example\n\n### Processed Files\n", + "utf8", + ); + const { additional_context } = await buildSessionStartAdditionalContext(root); + expect(additional_context).toContain("## Dogfood inbox"); + expect(additional_context).toContain("/dogfood"); + }); + + it("does not surface dogfood inbox hint when no unprocessed items exist", async () => { + const root = await fixtureRoot(); + await mkdir(path.join(root, "dogfood"), { recursive: true }); + await writeFile( + path.join(root, "dogfood", "README.md"), + "### Unprocessed Files\n\n*None*\n\n### Processed Files\n", + "utf8", + ); + const { additional_context } = await buildSessionStartAdditionalContext(root); + expect(additional_context).not.toContain("## Dogfood inbox"); + }); +}); + +describe("cursor awareness sessionStart gate (T4)", () => { + it("shouldEmitCursorAwarenessNudge requires changelog-ahead", () => { + expect( + shouldEmitCursorAwarenessNudge({ + status: "gaps-found", + gaps: [{ id: "open-action-A4" }], + }), + ).toBe(false); + expect( + shouldEmitCursorAwarenessNudge({ + status: "gaps-found", + gaps: [{ id: "changelog-ahead" }], + }), + ).toBe(true); + expect(shouldEmitCursorAwarenessNudge({ status: "current", gaps: [] })).toBe(false); + }); + + it("omits nudge when gaps lack changelog-ahead", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ak-cursor-nudge-")); + await mkdir(path.join(root, ".cursor", "context"), { recursive: true }); + await writeFile( + path.join(root, ".cursor", "context", "config.json"), + JSON.stringify({ cursorUpdateCheck: { enabled: true } }), + "utf8", + ); + + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter; + }; + child.stdout = new EventEmitter(); + const spawnFn = vi.fn(() => { + queueMicrotask(() => { + child.stdout.emit( + "data", + Buffer.from( + JSON.stringify({ + status: "gaps-found", + gaps: [{ id: "open-action-A4" }], + }), + ), + ); + child.emit("close", 0); + }); + return child as unknown as ReturnType; + }) as unknown as CursorAwarenessSpawn; + + const section = await cursorAwarenessSection(root, { spawnFn }); + expect(section).toBeNull(); + }); + + it("emits nudge only when changelog-ahead is present", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ak-cursor-nudge-")); + await mkdir(path.join(root, ".cursor", "context"), { recursive: true }); + await writeFile( + path.join(root, ".cursor", "context", "config.json"), + JSON.stringify({ cursorUpdateCheck: { enabled: true } }), + "utf8", + ); + + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter; + }; + child.stdout = new EventEmitter(); + const spawnFn = vi.fn(() => { + queueMicrotask(() => { + child.stdout.emit( + "data", + Buffer.from( + JSON.stringify({ + status: "gaps-found", + gaps: [{ id: "changelog-ahead" }], + }), + ), + ); + child.emit("close", 0); + }); + return child as unknown as ReturnType; + }) as unknown as CursorAwarenessSpawn; + + const section = await cursorAwarenessSection(root, { spawnFn }); + expect(section).toBe(CURSOR_AWARENESS_NUDGE); + }); + + it("ENOENT on primary starts exactly one fallback spawn", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ak-cursor-nudge-")); + await mkdir(path.join(root, ".cursor", "context"), { recursive: true }); + await writeFile( + path.join(root, ".cursor", "context", "config.json"), + JSON.stringify({ cursorUpdateCheck: { enabled: true } }), + "utf8", + ); + + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter; + }; + child.stdout = new EventEmitter(); + const spawnMock = vi.fn(() => { + queueMicrotask(() => { + // Real ENOENT spawn emits error then close; both must hit the + // settled/fallbackStarted guard so runFallback runs exactly once. + child.emit("error", Object.assign(new Error("not found"), { code: "ENOENT" })); + child.emit("close", 1); + }); + return child as unknown as ReturnType; + }); + const spawnFn = spawnMock as unknown as CursorAwarenessSpawn; + const runFallback = vi.fn(async () => ({ + status: "gaps-found", + gaps: [{ id: "changelog-ahead" }], + })); + + const section = await cursorAwarenessSection(root, { spawnFn, runFallback }); + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(runFallback).toHaveBeenCalledTimes(1); + expect(section).toBe(CURSOR_AWARENESS_NUDGE); + }); }); diff --git a/packages/cli/src/hooks/session-start.ts b/packages/cli/src/hooks/session-start.ts index e0ac148..96960c2 100644 --- a/packages/cli/src/hooks/session-start.ts +++ b/packages/cli/src/hooks/session-start.ts @@ -2,10 +2,19 @@ import { spawn } from "node:child_process"; import { access, readFile } from "node:fs/promises"; import path from "node:path"; import { validateHandoffText } from "../invariants/handoff-schema.js"; -import { DOGFOOD_INBOX_HINT, HARD_RULES, UPDATE_CHECK_NUDGE } from "./hard-rules.js"; +import { CHANGELOG_FETCH_TIMEOUT_MS } from "../lifecycle/cursor-update-awareness.js"; +import { + CURSOR_AWARENESS_NUDGE, + DOGFOOD_INBOX_HINT, + HARD_RULES, + UPDATE_CHECK_NUDGE, +} from "./hard-rules.js"; const NONE_PLACEHOLDERS = new Set(["none", "n/a", "empty", "nil"]); +/** Must exceed CHANGELOG_FETCH_TIMEOUT_MS so the child can finish before parent kill. */ +export const CURSOR_AWARENESS_SPAWN_TIMEOUT_MS = CHANGELOG_FETCH_TIMEOUT_MS + 3_000; + export interface SessionStartPayload { workspace_roots?: string[]; } @@ -135,17 +144,20 @@ async function readinessSection(root: string): Promise { } async function dogfoodInboxSection(root: string): Promise { - const dogfoodDir = path.join(root, "dogfood"); - if (!(await fileExists(dogfoodDir))) return null; - const readme = path.join(dogfoodDir, "README.md"); - if (!(await fileExists(readme))) return null; - try { - const text = await readFile(readme, "utf8"); - if (!parseUnprocessedDogfoodItems(text).length) return null; - return DOGFOOD_INBOX_HINT; - } catch { - return null; + const candidateReadmes = [ + path.join(root, "dogfood", "README.md"), + path.join(root, ".cursor", "dogfood", "README.md"), + ]; + for (const readme of candidateReadmes) { + if (!(await fileExists(readme))) continue; + try { + const text = await readFile(readme, "utf8"); + if (parseUnprocessedDogfoodItems(text).length) return DOGFOOD_INBOX_HINT; + } catch { + // ignore and try next + } } + return null; } async function loadUpdateCheckPrefs(root: string): Promise | null> { @@ -230,6 +242,126 @@ async function updateCheckSection(root: string): Promise { return UPDATE_CHECK_NUDGE.replace("{installed}", installed).replace("{latest}", latest); } +async function loadCursorUpdateCheckPrefs(root: string): Promise | null> { + try { + const data = JSON.parse( + await readFile(path.join(root, ".cursor", "context", "config.json"), "utf8"), + ) as Record; + const uc = data.cursorUpdateCheck; + if (!uc || typeof uc !== "object" || (uc as Record).enabled !== true) { + return null; + } + return uc as Record; + } catch { + return null; + } +} + +function runCursorAwarenessJson(root: string): Promise | null> { + return new Promise((resolve) => { + const child = spawn( + process.execPath, + [ + process.argv[1] ?? "", + "cursor-awareness", + "--check", + "--json", + "--respect-prefs", + "--stamp", + "--cwd", + root, + ], + { stdio: ["ignore", "pipe", "ignore"], timeout: CURSOR_AWARENESS_SPAWN_TIMEOUT_MS }, + ); + let out = ""; + child.stdout?.on("data", (chunk: Buffer) => { + out += chunk.toString("utf8"); + }); + child.on("error", () => resolve(null)); + child.on("close", () => { + try { + const parsed = JSON.parse(out.trim()) as Record; + resolve(parsed && typeof parsed === "object" ? parsed : null); + } catch { + resolve(null); + } + }); + }); +} + +/** True when the check result warrants a sessionStart Cursor-update nudge. */ +export function shouldEmitCursorAwarenessNudge(result: Record | null): boolean { + if (!result || result.status !== "gaps-found") return false; + if (result.applyRecommended === true || result.fieldReportRecommended === true) return false; + const gaps = Array.isArray(result.gaps) ? result.gaps : []; + return gaps.some( + (g) => g && typeof g === "object" && (g as { id?: string }).id === "changelog-ahead", + ); +} + +export type CursorAwarenessSpawn = typeof spawn; + +/** + * sessionStart Cursor-awareness section. Primary spawn is `agent-kit`; on ENOENT / + * empty failure, exactly one fallback via `process.execPath` + argv[1]. + * Inject `spawnFn` / `runFallback` in tests. + */ +export async function cursorAwarenessSection( + root: string, + deps: { + spawnFn?: CursorAwarenessSpawn; + runFallback?: (root: string) => Promise | null>; + } = {}, +): Promise { + if ((await loadCursorUpdateCheckPrefs(root)) === null) return null; + const spawnFn = deps.spawnFn ?? spawn; + const runFallback = deps.runFallback ?? runCursorAwarenessJson; + const result = await new Promise | null>((resolve) => { + let settled = false; + let fallbackStarted = false; + const finish = (value: Record | null) => { + if (settled) return; + settled = true; + resolve(value); + }; + const startFallback = () => { + if (settled || fallbackStarted) return; + fallbackStarted = true; + void runFallback(root).then(finish); + }; + const child = spawnFn( + "agent-kit", + ["cursor-awareness", "--check", "--json", "--respect-prefs", "--stamp", "--cwd", root], + { + stdio: ["ignore", "pipe", "ignore"], + timeout: CURSOR_AWARENESS_SPAWN_TIMEOUT_MS, + shell: false, + }, + ); + let out = ""; + child.stdout?.on("data", (chunk: Buffer) => { + out += chunk.toString("utf8"); + }); + child.on("error", () => { + startFallback(); + }); + child.on("close", (code) => { + if (settled || fallbackStarted) return; + if (code !== 0 && !out.trim()) { + startFallback(); + return; + } + try { + finish(JSON.parse(out.trim()) as Record); + } catch { + startFallback(); + } + }); + }); + if (!shouldEmitCursorAwarenessNudge(result)) return null; + return CURSOR_AWARENESS_NUDGE; +} + export async function buildSessionStartAdditionalContext( rootDir: string, _payload: SessionStartPayload = {}, @@ -251,6 +383,9 @@ export async function buildSessionStartAdditionalContext( const updateNudge = await updateCheckSection(root); if (updateNudge) parts.push(updateNudge); + const cursorNudge = await cursorAwarenessSection(root); + if (cursorNudge) parts.push(cursorNudge); + const formatWarnings = validateHandoffText(handoffFull); if (formatWarnings.length) { const bullet = formatWarnings.map((w) => `- ${w.message}`).join("\n"); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 9725ec3..bbd2215 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -2,6 +2,7 @@ import { defineCommand, runMain } from "citty"; import { addCommand } from "./commands/add.js"; import { contributeCommand } from "./commands/contribute.js"; +import { cursorAwarenessCommand } from "./commands/cursor-awareness.js"; import { dashboardBroadcastCommand } from "./commands/dashboard-broadcast.js"; import { dashboardCommand } from "./commands/dashboard.js"; import { diffCommand } from "./commands/diff.js"; @@ -17,11 +18,13 @@ import { scanCommand } from "./commands/scan.js"; import { statusCommand } from "./commands/status.js"; import { updateCommand } from "./commands/update.js"; import { validateCommand } from "./commands/validate.js"; +import { KIT_VERSION } from "./lifecycle/version.js"; const main = defineCommand({ meta: { name: "agent-kit", description: "HITL framework for AI-assisted IDEs", + version: KIT_VERSION, }, subCommands: { init: initCommand, @@ -31,6 +34,7 @@ const main = defineCommand({ doctor: doctorCommand, status: statusCommand, update: updateCommand, + "cursor-awareness": cursorAwarenessCommand, diff: diffCommand, contribute: contributeCommand, handoff: handoffCommand, diff --git a/packages/cli/src/invariants/hooks-health.test.ts b/packages/cli/src/invariants/hooks-health.test.ts index c807b2a..5a77c95 100644 --- a/packages/cli/src/invariants/hooks-health.test.ts +++ b/packages/cli/src/invariants/hooks-health.test.ts @@ -2,7 +2,7 @@ import { chmod, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { assessHooksHealth } from "./hooks-health.js"; +import { assessGitHooksInstallDrift, assessHooksHealth } from "./hooks-health.js"; const ADAPTERS = [ "session-start.sh", @@ -57,6 +57,7 @@ describe("assessHooksHealth", () => { const root = await mkdtemp(path.join(tmpdir(), "ak-hooks-")); const report = await assessHooksHealth(root); expect(report.status).toBe("missing"); + expect(report.advisories).toEqual([]); }); it("reports active for full Node adapter wiring", async () => { @@ -65,6 +66,7 @@ describe("assessHooksHealth", () => { const report = await assessHooksHealth(root); expect(report.status).toBe("active"); expect(report.reasons).toEqual([]); + expect(report.advisories).toEqual([]); }); it("degrades when adapters are missing on disk", async () => { @@ -130,4 +132,58 @@ describe("assessHooksHealth", () => { expect(report.status).toBe("degraded"); expect(report.reasons.some((r) => r.includes("stop"))).toBe(true); }); + + it("keeps status active when only git-hooks install drift advisories", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ak-hooks-")); + await writeWiredHooks(root); + await mkdir(path.join(root, "git-hooks"), { recursive: true }); + await mkdir(path.join(root, ".git", "hooks"), { recursive: true }); + await writeFile(path.join(root, "git-hooks", "pre-push"), "#!/bin/sh\n# canonical\n", "utf8"); + await writeFile(path.join(root, ".git", "hooks", "pre-push"), "#!/bin/sh\n# stale\n", "utf8"); + const report = await assessHooksHealth(root); + expect(report.status).toBe("active"); + expect(report.reasons).toEqual([]); + expect( + report.advisories.some((a) => a.includes("git-hooks drift") && a.includes("pre-push")), + ).toBe(true); + expect(report.advisories.some((a) => a.includes("cp git-hooks/"))).toBe(true); + }); +}); + +describe("assessGitHooksInstallDrift", () => { + it("returns empty when git-hooks folder absent", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ak-drift-")); + expect(await assessGitHooksInstallDrift(root)).toEqual([]); + }); + + it("advises when installed hook is missing", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ak-drift-")); + await mkdir(path.join(root, "git-hooks"), { recursive: true }); + await mkdir(path.join(root, ".git", "hooks"), { recursive: true }); + await writeFile(path.join(root, "git-hooks", "pre-commit"), "#!/bin/sh\n", "utf8"); + const tips = await assessGitHooksInstallDrift(root); + expect(tips.some((t) => t.includes("pre-commit") && t.includes("missing"))).toBe(true); + }); + + it("advises when contents differ", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ak-drift-")); + await mkdir(path.join(root, "git-hooks"), { recursive: true }); + await mkdir(path.join(root, ".git", "hooks"), { recursive: true }); + await writeFile(path.join(root, "git-hooks", "pre-push"), "A\n", "utf8"); + await writeFile(path.join(root, ".git", "hooks", "pre-push"), "B\n", "utf8"); + const tips = await assessGitHooksInstallDrift(root); + expect(tips.some((t) => t.includes("differs") && t.includes("pre-push"))).toBe(true); + }); + + it("returns empty when installed matches canonical", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ak-drift-")); + await mkdir(path.join(root, "git-hooks"), { recursive: true }); + await mkdir(path.join(root, ".git", "hooks"), { recursive: true }); + const body = "#!/bin/sh\nexit 0\n"; + for (const name of ["pre-commit", "pre-push", "prepare-commit-msg"] as const) { + await writeFile(path.join(root, "git-hooks", name), body, "utf8"); + await writeFile(path.join(root, ".git", "hooks", name), body, "utf8"); + } + expect(await assessGitHooksInstallDrift(root)).toEqual([]); + }); }); diff --git a/packages/cli/src/invariants/hooks-health.ts b/packages/cli/src/invariants/hooks-health.ts index addbf08..3126aec 100644 --- a/packages/cli/src/invariants/hooks-health.ts +++ b/packages/cli/src/invariants/hooks-health.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { constants, access, readFile, stat } from "node:fs/promises"; +import { constants, access, readFile, readdir, stat } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; @@ -7,9 +7,14 @@ const execFileAsync = promisify(execFile); export type HooksHealthStatus = "active" | "degraded" | "missing"; +/** Canonical kit hook scripts compared against installed `.git/hooks/` copies. */ +export const GIT_HOOK_CANONICAL_NAMES = ["pre-commit", "pre-push", "prepare-commit-msg"] as const; + export interface HooksHealthReport { status: HooksHealthStatus; reasons: string[]; + /** Soft advisories (e.g. git-hooks install drift). Do not flip status alone. */ + advisories: string[]; hooksJsonPath: string; expectedEvents: string[]; wiredEvents: string[]; @@ -69,10 +74,93 @@ function commandLooksLikeAdapter(command: string): string | null { return m?.[1] ?? null; } +/** + * Soft advisory: compare versioned `git-hooks/*` to installed `.git/hooks/*`. + * Install is operator `cp` (see git-hooks/README.md); drift does not degrade status. + * When `core.hooksPath` already points at `git-hooks`, skip (no install copy). + */ +export async function assessGitHooksInstallDrift(rootDir: string): Promise { + const root = path.resolve(rootDir); + const canonicalDir = path.join(root, "git-hooks"); + if (!(await exists(canonicalDir))) return []; + + let hooksPathCfg = ""; + try { + const { stdout } = await execFileAsync( + "git", + ["-C", root, "config", "--get", "core.hooksPath"], + { encoding: "utf8" }, + ); + hooksPathCfg = stdout.trim(); + } catch { + /* unset or not a git repo */ + } + if (hooksPathCfg) { + const resolved = path.isAbsolute(hooksPathCfg) + ? path.normalize(hooksPathCfg) + : path.normalize(path.join(root, hooksPathCfg)); + if (resolved === path.normalize(canonicalDir)) { + return []; + } + } + + const gitDir = path.join(root, ".git"); + if (!(await exists(gitDir))) return []; + // Worktree / bare: `.git` may be a file; only compare when hooks live under `.git/hooks`. + try { + const st = await stat(gitDir); + if (!st.isDirectory()) return []; + } catch { + return []; + } + + const installedDir = path.join(gitDir, "hooks"); + const advisories: string[] = []; + const installHint = + "Install or refresh with: `cp git-hooks/ .git/hooks/ && chmod +x .git/hooks/` (see git-hooks/README.md)"; + + let names: string[] = [...GIT_HOOK_CANONICAL_NAMES]; + try { + const listed = await readdir(canonicalDir); + const fromDisk = listed.filter((n) => + (GIT_HOOK_CANONICAL_NAMES as readonly string[]).includes(n), + ); + if (fromDisk.length > 0) names = fromDisk; + } catch { + /* use default names */ + } + + for (const name of names) { + const canonical = path.join(canonicalDir, name); + const installed = path.join(installedDir, name); + if (!(await exists(canonical))) continue; + if (!(await exists(installed))) { + advisories.push( + `git-hooks drift: \`.git/hooks/${name}\` missing (canonical \`git-hooks/${name}\` present). ${installHint}`, + ); + continue; + } + try { + const [a, b] = await Promise.all([readFile(canonical, "utf8"), readFile(installed, "utf8")]); + if (a !== b) { + advisories.push( + `git-hooks drift: \`.git/hooks/${name}\` differs from \`git-hooks/${name}\`. ${installHint}`, + ); + } + } catch { + advisories.push( + `git-hooks drift: could not compare \`git-hooks/${name}\` with \`.git/hooks/${name}\`. ${installHint}`, + ); + } + } + + return advisories; +} + /** * Visible fail-open posture: active when hooks.json wires Node adapters that * exist, are executable, and the CLI resolves; degraded otherwise; missing - * when no hooks.json. + * when no hooks.json. Git-hooks install drift is advisory only. */ export async function assessHooksHealth(rootDir: string): Promise { const root = path.resolve(rootDir); @@ -80,11 +168,13 @@ export async function assessHooksHealth(rootDir: string): Promise { + afterEach(() => { + // Node coerces env values to strings: `= undefined` sets the literal "undefined". + // biome-ignore lint/performance/noDelete: process.env must be removed, not set to "undefined" + delete process.env.ALLOW_MAIN_PUSH; + }); + it("allows benign git status", () => { expect(evaluateShellCommand("git status").permission).toBe("allow"); }); @@ -35,6 +41,73 @@ describe("evaluateShellCommand", () => { expect(evaluateShellCommand("git push origin HEAD:main").rule).toBe("git-push-main"); }); + it("allows push to main when ALLOW_MAIN_PUSH=1 is inline (git-prod path)", () => { + expect(evaluateShellCommand("ALLOW_MAIN_PUSH=1 git push origin main").permission).toBe("allow"); + expect(evaluateShellCommand("ALLOW_MAIN_PUSH=1 git push origin HEAD:main").permission).toBe( + "allow", + ); + }); + + it("allows push to main when process.env.ALLOW_MAIN_PUSH=1", () => { + process.env.ALLOW_MAIN_PUSH = "1"; + expect(evaluateShellCommand("git push origin main").permission).toBe("allow"); + expect(evaluateShellCommand("git push origin HEAD:main").permission).toBe("allow"); + }); + + it("denies bare push to main when ALLOW_MAIN_PUSH is unset", () => { + // biome-ignore lint/performance/noDelete: process.env must be removed, not set to "undefined" + delete process.env.ALLOW_MAIN_PUSH; + expect(evaluateShellCommand("git push origin main").permission).toBe("deny"); + expect(evaluateShellCommand("git push origin main").rule).toBe("git-push-main"); + }); + + it("denies bare push to main when ALLOW_MAIN_PUSH=0", () => { + process.env.ALLOW_MAIN_PUSH = "0"; + expect(evaluateShellCommand("git push origin main").permission).toBe("deny"); + expect(evaluateShellCommand("git push origin main").rule).toBe("git-push-main"); + }); + + it("does not let ALLOW_MAIN_PUSH on a later segment authorize an earlier push", () => { + expect(evaluateShellCommand("git push origin main && ALLOW_MAIN_PUSH=1 echo ok").rule).toBe( + "git-push-main", + ); + }); + + it("denies unsafe main-push forms even when ALLOW_MAIN_PUSH=1 is present", () => { + const denyWhileEnv: string[] = [ + "ALLOW_MAIN_PUSH=1 git push --force origin main", + "ALLOW_MAIN_PUSH=1 git push -f origin main", + "ALLOW_MAIN_PUSH=1 git push --force-with-lease origin main", + "ALLOW_MAIN_PUSH=1 git push --no-verify origin main", + "ALLOW_MAIN_PUSH=1 git push origin prod", + "ALLOW_MAIN_PUSH=1 git push origin master", + "ALLOW_MAIN_PUSH=1 git push origin main --tags", + "ALLOW_MAIN_PUSH=1 git push origin main --all", + "ALLOW_MAIN_PUSH=1 git push --tags --all origin main", + "ALLOW_MAIN_PUSH=1 git push origin +main", + ]; + for (const cmd of denyWhileEnv) { + const r = evaluateShellCommand(cmd); + expect(r.permission, cmd).toBe("deny"); + expect(r.rule, cmd).toBe("git-push-main"); + } + + process.env.ALLOW_MAIN_PUSH = "1"; + const denyWithProcessEnv: string[] = [ + "git push --force origin main", + "git push --force-with-lease origin main", + "git push --no-verify origin main", + "git push origin prod", + "git push origin master", + "git push origin main --tags --all", + ]; + for (const cmd of denyWithProcessEnv) { + const r = evaluateShellCommand(cmd); + expect(r.permission, cmd).toBe("deny"); + expect(r.rule, cmd).toBe("git-push-main"); + } + }); + it("denies force-refspec and refs/heads/ pushes to main", () => { expect(evaluateShellCommand("git push origin +main").rule).toBe("git-push-main"); expect(evaluateShellCommand("git push origin refs/heads/main").rule).toBe("git-push-main"); @@ -46,6 +119,45 @@ describe("evaluateShellCommand", () => { expect(evaluateShellCommand('git push origin "+main"').rule).toBe("git-push-main"); }); + it("denies prefixed and embedded quote push forms that shell collapses to main", () => { + const denyForms = [ + "+'main'", + '+"main"', + "ma'in'", + 'm"ain"', + "''main''", + "'refs/heads'/main", + "+refs/'heads'/main", + ]; + for (const dest of denyForms) { + expect(evaluateShellCommand(`git push origin ${dest}`).rule).toBe("git-push-main"); + } + }); + + it("denies backslash push forms that shell collapses to main", () => { + // JS source needs \\ so the refspec token retains a literal backslash. + const denyForms = ["\\main", "ma\\in", "mai\\n", "'\\main'", '"ma\\in"', "+\\main"]; + for (const dest of denyForms) { + expect(evaluateShellCommand(`git push origin ${dest}`).rule).toBe("git-push-main"); + } + }); + + it("does not over-block staging or mainline-like branches after quote/backslash strip", () => { + const allowForms = [ + "'staging'", + '"+staging"', + "'mainline'", + '"feature/main-fix"', + '"refs/heads/staging"', + "\\staging", + "sta\\ging", + "\\mainline", + ]; + for (const dest of allowForms) { + expect(evaluateShellCommand(`git push origin ${dest}`).permission).toBe("allow"); + } + }); + it("denies bare push / force HEAD when current branch is protected", () => { expect(evaluateShellCommand("git push", { currentBranch: "main" }).rule).toBe("git-push-main"); expect( diff --git a/packages/cli/src/invariants/shell-guard.ts b/packages/cli/src/invariants/shell-guard.ts index 882d5f8..0406d89 100644 --- a/packages/cli/src/invariants/shell-guard.ts +++ b/packages/cli/src/invariants/shell-guard.ts @@ -26,19 +26,98 @@ export function normalizeShellCommand(command: string): string { return command.replace(/\s+/g, " ").trim(); } -/** - * Split into shell segments and strip leading env assignments so - * `node … --command "git checkout -- x"` does not false-positive. - */ -export function shellInvocationHeads(command: string): string[] { +/** Split on shell combinators; keeps leading `ENV=val` on each segment. */ +export function shellSegments(command: string): string[] { const normalized = normalizeShellCommand(command); if (!normalized) return []; return normalized .split(/(?:&&|\|\||[;|])/) - .map((part) => part.trim().replace(/^(?:\w+=\S+\s+)*/, "")) + .map((part) => part.trim()) .filter(Boolean); } +/** + * Strip leading env assignments from a single segment so + * `node … --command "git checkout -- x"` does not false-positive. + */ +export function stripLeadingEnvAssignments(segment: string): string { + return segment.replace(/^(?:\w+=\S+\s+)*/, ""); +} + +/** + * Split into shell segments and strip leading env assignments so + * `node … --command "git checkout -- x"` does not false-positive. + */ +export function shellInvocationHeads(command: string): string[] { + return shellSegments(command).map(stripLeadingEnvAssignments).filter(Boolean); +} + +/** True when ALLOW_MAIN_PUSH=1 is set inline on the segment or in process.env. */ +function segmentHasAllowMainPushEnv(segment: string): boolean { + if (process.env.ALLOW_MAIN_PUSH === "1") return true; + const leading = segment.match(/^(?:\w+=\S+\s+)*/)?.[0] ?? ""; + return /(?:^|\s)ALLOW_MAIN_PUSH=1(?:\s|$)/.test(leading); +} + +/** Flags that must never ride with an authorized /git-prod main push. */ +function hasForbiddenMainPushFlags(head: string): boolean { + for (const t of head.split(/\s+/).filter(Boolean)) { + if ( + t === "--force" || + t === "-f" || + t === "--force-with-lease" || + t === "--no-verify" || + t === "--all" || + t === "--tags" + ) { + return true; + } + } + return false; +} + +/** + * Documented /git-prod push shapes only: `git push main` or + * `git push HEAD:main` (optional refs/heads/ on dest). No force + * refspec, no master/prod, no forbidden flags. + */ +function isAuthorizedProdMainPush(head: string): boolean { + if (!/^(?:[\w./-]+\/)?git\s+push\b/.test(head)) return false; + if (hasForbiddenMainPushFlags(head)) return false; + + const after = head.replace(/^(?:[\w./-]+\/)?git\s+push\b/, "").trim(); + const positional: string[] = []; + for (const t of after.split(/\s+/).filter(Boolean)) { + if (t.startsWith("-")) continue; + positional.push(t); + } + // Documented forms are exactly . + const refspec = positional.length === 2 ? positional[1] : undefined; + if (!refspec) return false; + + const unquoted = refspec.replace(/['"\\]/g, ""); + if (unquoted.startsWith("+")) return false; + + if (unquoted.includes(":")) { + const src = unquoted.slice(0, unquoted.lastIndexOf(":")); + if (src !== "HEAD") return false; + } + + const dest = unquoted.includes(":") ? unquoted.slice(unquoted.lastIndexOf(":") + 1) : unquoted; + return normalizePushRefspecToken(dest) === "main"; +} + +/** + * True when this segment authorizes the documented /git-prod main push. + * Requires ALLOW_MAIN_PUSH=1 (inline or process.env) *and* an authorized + * shape; env alone must not bypass force / --no-verify / master / prod / --all|--tags. + * Must see env before strip: `shellInvocationHeads` removes `ALLOW_MAIN_PUSH=1`. + */ +export function segmentAllowsMainPush(segment: string): boolean { + if (!segmentHasAllowMainPushEnv(segment)) return false; + return isAuthorizedProdMainPush(stripLeadingEnvAssignments(segment)); +} + function anyHeadMatches(command: string, re: RegExp): boolean { return shellInvocationHeads(command).some((head) => re.test(head)); } @@ -48,18 +127,16 @@ function isProtectedBranch(name: string | null | undefined): boolean { } /** - * Strip surrounding quotes, force `+`, and `refs/heads/` so protected-name checks see bare branch names. - * Closes `git push origin +main` / `refs/heads/main` / `'main'` / `"+main"` bypasses. + * Delete all quote chars, backslashes, force `+`, and `refs/heads/` so protected-name + * checks see bare branch names. + * Closes surrounding (`'main'`, `"+main"`), prefixed (`+'main'`), embedded (`ma'in'`), + * and shell-collapse backslash forms (`\main`, `ma\in`, `mai\n`). + * Refnames containing quotes/backslashes are pathological; over-blocking risk is negligible. */ export function normalizePushRefspecToken(token: string): string { let t = token.trim(); - // Shell-quoted refspecs (`'main'`, `"+main"`) must normalize before + / refs/heads/. - if ( - (t.startsWith("'") && t.endsWith("'") && t.length >= 2) || - (t.startsWith('"') && t.endsWith('"') && t.length >= 2) - ) { - t = t.slice(1, -1).trim(); - } + // Shell quote/backslash forms collapse to the same dest; strip before + / refs/heads/. + t = t.replace(/['"\\]/g, ""); if (t.startsWith("+")) t = t.slice(1); if (t.startsWith("refs/heads/")) t = t.slice("refs/heads/".length); if (t.startsWith("origin/")) t = t.slice("origin/".length); @@ -155,7 +232,10 @@ export const SHELL_DENY_RULES: Array<{ id: "git-push-main", description: "direct push to main/master/prod bypasses staging", test: (cmd, opts) => - shellInvocationHeads(cmd).some((head) => { + shellSegments(cmd).some((segment) => { + // Authorized /git-prod path (parity with git-hooks/pre-push). + if (segmentAllowsMainPush(segment)) return false; + const head = stripLeadingEnvAssignments(segment); if (!/^(?:[\w./-]+\/)?git\s+push\b/.test(head)) return false; if (pushHeadHasProtectedDest(head)) { return true; diff --git a/packages/cli/src/lifecycle/apply.test.ts b/packages/cli/src/lifecycle/apply.test.ts index 59afc0e..8a6e6a8 100644 --- a/packages/cli/src/lifecycle/apply.test.ts +++ b/packages/cli/src/lifecycle/apply.test.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { DEFAULT_PROTECTED_PATHS } from "../manifest/types.js"; -import { copyRegistryFile } from "./apply.js"; +import { buildManifest, copyRegistryFile } from "./apply.js"; import { migrateLegacyOnboardCommand } from "./onboard-migration.js"; import { installL0 } from "./sync.js"; @@ -101,4 +101,30 @@ describe("lifecycle apply L3", () => { const outcome2 = await copyRegistryFile(kitRoot, project, rel, rel, []); expect(outcome2).toBe("unchanged"); }); + + it("buildManifest preserves personalization and optional metadata", () => { + const personalization = { + contractVersion: 1, + generatorVersion: "4.8.4", + origin: "repository-profile" as const, + resultPath: ".cursor/context/personalization.json", + }; + const manifest = buildManifest({ + version: "4.8.4", + profile: "ops", + packs: ["clean-code"], + skills: ["json-data-config"], + protected: [".cursor/HANDOFF.md"], + personalization, + registryUrl: "https://github.com/agent-kit-startup/agent-kit", + registryRef: "main", + }); + expect(manifest.version).toBe("4.8.4"); + expect(manifest.personalization).toEqual(personalization); + expect(manifest.registry).toEqual({ + url: "https://github.com/agent-kit-startup/agent-kit", + ref: "main", + }); + expect(manifest.protected).toContain(".cursor/HANDOFF.md"); + }); }); diff --git a/packages/cli/src/lifecycle/apply.ts b/packages/cli/src/lifecycle/apply.ts index 2500b25..966d387 100644 --- a/packages/cli/src/lifecycle/apply.ts +++ b/packages/cli/src/lifecycle/apply.ts @@ -7,10 +7,23 @@ import { MANIFEST_SCHEMA_VERSION, } from "../manifest/types.js"; import { writeJson } from "../utils/fs.js"; +import { + type ManagedHashLedger, + contentHash, + isConsumerOverlayPath, + loadManagedHashLedger, + saveManagedHashLedger, + shouldPreserveCustomizedOverlay, +} from "./overlay.js"; import { resolveContained, toPosixRel } from "./paths.js"; -import { isProtectedPath, normalizeProtectedGlobs, resolveProtectedGlobs } from "./protected.js"; +import { isProtectedPath, normalizeProtectedGlobs } from "./protected.js"; -export type CopyOutcome = "written" | "skipped-protected" | "missing-source" | "unchanged"; +export type CopyOutcome = + | "written" + | "skipped-protected" + | "missing-source" + | "unchanged" + | "preserved-customized"; export interface ApplyStats { written: string[]; @@ -19,6 +32,7 @@ export interface ApplyStats { skippedProtected: string[]; missing: string[]; unchanged: string[]; + preservedCustomized: string[]; } export function emptyStats(): ApplyStats { @@ -29,6 +43,7 @@ export function emptyStats(): ApplyStats { skippedProtected: [], missing: [], unchanged: [], + preservedCustomized: [], }; } @@ -39,11 +54,23 @@ export function mergeStats(into: ApplyStats, from: ApplyStats): ApplyStats { into.skippedProtected.push(...from.skippedProtected); into.missing.push(...from.missing); into.unchanged.push(...from.unchanged); + into.preservedCustomized.push(...from.preservedCustomized); return into; } +export interface CopyRegistryOptions { + /** When set, overlay ledger is read/written once by the caller across many copies. */ + managedHashes?: ManagedHashLedger; + /** Persist ledger after mutation (default true when managedHashes provided or auto-loaded). */ + persistManagedHashes?: boolean; +} + /** * Copy a file from registry root → project root, skipping L3 protected paths. + * Consumer overlay paths (agents/skills/commands) preserve local customizations + * when the local hash diverges from the managed ledger (or, when the ledger is + * absent, when local content is not a known shipped kit hash); unedited kit + * files refresh. */ export async function copyRegistryFile( registryRoot: string, @@ -51,6 +78,7 @@ export async function copyRegistryFile( sourceRel: string, targetRel: string, protectedGlobs: readonly string[], + options: CopyRegistryOptions = {}, ): Promise { const targetNorm = targetRel.split(path.sep).join("/"); if (isProtectedPath(targetNorm, protectedGlobs)) { @@ -73,13 +101,70 @@ export async function copyRegistryFile( existing = null; } const next = await readFile(sourceAbs, "utf8"); - if (existing === next) return "unchanged"; + if (existing === next) { + if (isConsumerOverlayPath(targetNorm)) { + await touchOverlayHash(projectRoot, targetNorm, next, options); + } + return "unchanged"; + } + + if (existing !== null && isConsumerOverlayPath(targetNorm)) { + const ledger = options.managedHashes ?? (await loadManagedHashLedger(projectRoot)); + const recorded = ledger.hashes[targetNorm]; + if (shouldPreserveCustomizedOverlay(existing, recorded)) { + // Ledger tracks last-managed kit content, not the local body. On + // ledger-absent preserve, seed with incoming kit hash so subsequent + // updates still see local≠managed and keep preserving. + if (!recorded) { + const managedHash = contentHash(next); + ledger.hashes[targetNorm] = managedHash; + if (options.managedHashes) { + options.managedHashes.hashes[targetNorm] = managedHash; + } + if (options.persistManagedHashes !== false) { + await saveManagedHashLedger(projectRoot, ledger); + } + } + return "preserved-customized"; + } + await mkdir(path.dirname(targetAbs), { recursive: true }); + await copyFile(sourceAbs, targetAbs); + ledger.hashes[targetNorm] = contentHash(next); + if (options.managedHashes) { + options.managedHashes.hashes[targetNorm] = ledger.hashes[targetNorm]; + } + if (options.persistManagedHashes !== false) { + await saveManagedHashLedger(projectRoot, ledger); + } + return "written"; + } await mkdir(path.dirname(targetAbs), { recursive: true }); await copyFile(sourceAbs, targetAbs); + if (isConsumerOverlayPath(targetNorm)) { + await touchOverlayHash(projectRoot, targetNorm, next, options); + } return "written"; } +async function touchOverlayHash( + projectRoot: string, + targetNorm: string, + content: string, + options: CopyRegistryOptions, +): Promise { + const ledger = options.managedHashes ?? (await loadManagedHashLedger(projectRoot)); + const hash = contentHash(content); + if (ledger.hashes[targetNorm] === hash) return; + ledger.hashes[targetNorm] = hash; + if (options.managedHashes) { + options.managedHashes.hashes[targetNorm] = hash; + } + if (options.persistManagedHashes !== false) { + await saveManagedHashLedger(projectRoot, ledger); + } +} + export function recordOutcome(stats: ApplyStats, targetRel: string, outcome: CopyOutcome): void { const rel = targetRel.split(path.sep).join("/"); switch (outcome) { @@ -95,6 +180,9 @@ export function recordOutcome(stats: ApplyStats, targetRel: string, outcome: Cop case "unchanged": stats.unchanged.push(rel); break; + case "preserved-customized": + stats.preservedCustomized.push(rel); + break; } } @@ -106,7 +194,9 @@ export async function saveManifest( const payload = { ...manifest, schemaVersion: MANIFEST_SCHEMA_VERSION, - installedAt: new Date().toISOString(), + // Preserve installedAt on no-op updates (ADR factory-pseudo-consumer + // decision 4); a version change earns a fresh install timestamp. + installedAt: manifest.installedAt ?? new Date().toISOString(), }; await writeJson(target, payload); return toPosixRel(projectRoot, target); diff --git a/packages/cli/src/lifecycle/cursor-update-awareness.test.ts b/packages/cli/src/lifecycle/cursor-update-awareness.test.ts new file mode 100644 index 0000000..a1cbf0a --- /dev/null +++ b/packages/cli/src/lifecycle/cursor-update-awareness.test.ts @@ -0,0 +1,186 @@ +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { CURSOR_AWARENESS_SPAWN_TIMEOUT_MS } from "../hooks/session-start.js"; +import { + CHANGELOG_FETCH_TIMEOUT_MS, + checkCursorUpdateAwareness, + compareCursorVersion, + extractLatestCursorVersion, + isPlausibleCursorVersion, + parseInventoryRefreshed, + parseOpenActionIds, + readCursorUpdateCheckPrefs, + stampCursorUpdateCheck, +} from "./cursor-update-awareness.js"; + +const fixtureHtml = readFileSync( + path.join( + path.dirname(fileURLToPath(import.meta.url)), + "fixtures", + "cursor-changelog-excerpt.html", + ), + "utf8", +); + +function writeInventory(cwd: string, body: string): void { + mkdirSync(path.join(cwd, "docs"), { recursive: true }); + writeFileSync(path.join(cwd, "docs", "cursor-native-audit.md"), body, "utf8"); + writeFileSync( + path.join(cwd, "docs", "cursor-3-features.md"), + `| Feature | What it does | How Agent Kit uses it | +|---------|-----------|----------------------| +| Plans | Native Cursor plans | Agent Kit generates plans | +`, + "utf8", + ); +} + +describe("cursor-update-awareness helpers", () => { + it("defaults cursorUpdateCheck to opt-in false", () => { + expect(readCursorUpdateCheckPrefs({})).toMatchObject({ + enabled: false, + intervalDays: 7, + lastSeenCursorVersion: null, + }); + }); + + it("extracts latest Cursor version from changelog text", () => { + expect(extractLatestCursorVersion("3.0 Apr 2 · Changelog\n3.6 May 29")).toBe("3.6"); + expect(extractLatestCursorVersion("ignore 2026 noise and 3.11.2")).toBe("3.11.2"); + }); + + it("anchors extraction on recorded changelog HTML (not CSS 49.511)", () => { + expect(fixtureHtml).toContain("49.511"); + expect(extractLatestCursorVersion(fixtureHtml)).toBe("3.11"); + expect(isPlausibleCursorVersion("49.511")).toBe(false); + expect(isPlausibleCursorVersion("3.11")).toBe(true); + }); + + it("keeps spawn timeout above changelog fetch timeout", () => { + expect(CURSOR_AWARENESS_SPAWN_TIMEOUT_MS).toBeGreaterThan(CHANGELOG_FETCH_TIMEOUT_MS); + }); + + it("compares loose Cursor versions", () => { + expect(compareCursorVersion("3.6", "3.5")).toBe(1); + expect(compareCursorVersion("3.6", "3.6.0")).toBe(0); + expect(compareCursorVersion("3.5", "3.6")).toBe(-1); + }); + + it("parses inventory refresh date and open actions", () => { + const md = + "Living audit; last refreshed **2026-07-19**.\n\n| ID | Status | Action |\n|----|--------|--------|\n| A4 | Open | Align hooks |\n| A5 | Open | Add AGENTS.md |\n| A1 | ✅ Done | Fix |\n"; + expect(parseInventoryRefreshed(md)).toBe("2026-07-19"); + expect(parseOpenActionIds(md)).toEqual(["A4", "A5"]); + }); +}); + +describe("checkCursorUpdateAwareness", () => { + it("reports open inventory actions as advisory gaps (offline)", async () => { + const cwd = mkdtempSync(path.join(tmpdir(), "cursor-awareness-")); + writeInventory( + cwd, + "Living audit; last refreshed **2026-07-19**.\n\n| ID | Status | Action |\n|----|--------|--------|\n| A4 | Open | Align hooks |\n", + ); + + const result = await checkCursorUpdateAwareness(cwd, { offline: true }); + expect(result.applyRecommended).toBe(false); + expect(result.fieldReportRecommended).toBe(false); + expect(result.status).toBe("gaps-found"); + expect(result.openActionIds).toEqual(["A4"]); + expect(result.gaps.some((g) => g.id === "open-action-A4")).toBe(true); + expect(result.conveyorHint).toContain("/backlog-add"); + }); + + it("detects changelog ahead of lastSeen baseline", async () => { + const cwd = mkdtempSync(path.join(tmpdir(), "cursor-awareness-")); + writeInventory( + cwd, + "Living audit; last refreshed **2026-07-19**.\n\n| ID | Status | Action |\n|----|--------|--------|\n| A1 | ✅ Done | Fix |\n", + ); + mkdirSync(path.join(cwd, ".cursor", "context"), { recursive: true }); + writeFileSync( + path.join(cwd, ".cursor", "context", "config.json"), + JSON.stringify({ + cursorUpdateCheck: { + enabled: true, + lastSeenCursorVersion: "3.0", + }, + }), + "utf8", + ); + + const result = await checkCursorUpdateAwareness(cwd, { + changelogBody: "3.6 May 29 · Changelog Cursor 3.6", + }); + expect(result.status).toBe("gaps-found"); + expect(result.latestCursorVersion).toBe("3.6"); + expect(result.gaps.some((g) => g.id === "changelog-ahead")).toBe(true); + }); + + it("refuses to stamp implausible latest versions as baseline", async () => { + const cwd = mkdtempSync(path.join(tmpdir(), "cursor-awareness-")); + writeInventory( + cwd, + "Living audit; last refreshed **2026-07-19**.\n\n| ID | Status | Action |\n|----|--------|--------|\n| A1 | ✅ Done | Fix |\n", + ); + mkdirSync(path.join(cwd, ".cursor", "context"), { recursive: true }); + writeFileSync( + path.join(cwd, ".cursor", "context", "config.json"), + JSON.stringify({ + cursorUpdateCheck: { + enabled: true, + lastSeenCursorVersion: "3.0", + }, + }), + "utf8", + ); + + await checkCursorUpdateAwareness(cwd, { + stamp: true, + // Only CSS noise: extract returns null; stamp must not write 49.511. + changelogBody: '', + }); + const cfg = JSON.parse( + readFileSync(path.join(cwd, ".cursor", "context", "config.json"), "utf8"), + ) as { cursorUpdateCheck: { lastSeenCursorVersion: string | null } }; + expect(cfg.cursorUpdateCheck.lastSeenCursorVersion).toBe("3.0"); + }); + + it("stampCursorUpdateCheck withholds lastSeen on implausible non-null version (T2)", async () => { + const cwd = mkdtempSync(path.join(tmpdir(), "cursor-awareness-")); + mkdirSync(path.join(cwd, ".cursor", "context"), { recursive: true }); + writeFileSync( + path.join(cwd, ".cursor", "context", "config.json"), + JSON.stringify({ + cursorUpdateCheck: { + enabled: true, + lastSeenCursorVersion: "3.0", + }, + }), + "utf8", + ); + + // Reach the stamp guard directly with a non-null implausible token (extractor bypass). + await stampCursorUpdateCheck(cwd, { lastSeenCursorVersion: "49.511" }); + const cfg = JSON.parse( + readFileSync(path.join(cwd, ".cursor", "context", "config.json"), "utf8"), + ) as { + cursorUpdateCheck: { + lastSeenCursorVersion: string | null; + lastCheckedAt: string | null; + }; + }; + expect(cfg.cursorUpdateCheck.lastSeenCursorVersion).toBe("3.0"); + expect(cfg.cursorUpdateCheck.lastCheckedAt).toBeTruthy(); + }); + + it("skips when respectPrefs and disabled", async () => { + const cwd = mkdtempSync(path.join(tmpdir(), "cursor-awareness-")); + writeInventory(cwd, "last refreshed **2026-07-19**\n"); + const result = await checkCursorUpdateAwareness(cwd, { respectPrefs: true, offline: true }); + expect(result.status).toBe("skipped-disabled"); + }); +}); diff --git a/packages/cli/src/lifecycle/cursor-update-awareness.ts b/packages/cli/src/lifecycle/cursor-update-awareness.ts new file mode 100644 index 0000000..445be2a --- /dev/null +++ b/packages/cli/src/lifecycle/cursor-update-awareness.ts @@ -0,0 +1,478 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { readJson, writeJson } from "../utils/fs.js"; + +/** Official Cursor product changelog (detection source SoT). */ +export const DEFAULT_CURSOR_CHANGELOG_URL = "https://cursor.com/changelog"; + +/** + * In-child changelog fetch timeout. sessionStart spawn timeout must exceed this + * (see CURSOR_AWARENESS_SPAWN_TIMEOUT_MS in session-start.ts). + */ +export const CHANGELOG_FETCH_TIMEOUT_MS = 15_000; + +/** Cursor product majors stay low; CSS/layout tokens on the changelog page are 30–50+. */ +export const CURSOR_VERSION_MAJOR_MAX = 20; + +const INVENTORY_REL = path.join("docs", "cursor-native-audit.md"); +const FEATURES_REL = path.join("docs", "cursor-3-features.md"); + +export type CursorAwarenessStatus = + | "current" + | "gaps-found" + | "skipped-disabled" + | "skipped-interval" + | "error"; + +export type CursorAwarenessSeverity = "info" | "advisory" | "stale"; + +export type CursorAwarenessRoute = "backlog-add" | "dogfood" | "none"; + +export interface CursorUpdateCheckPrefs { + enabled: boolean; + intervalDays: number; + lastCheckedAt: string | null; + lastSeenCursorVersion: string | null; + changelogUrl: string; +} + +export interface CursorAwarenessGap { + id: string; + severity: CursorAwarenessSeverity; + path: string; + evidence: string; + suggestedRoute: CursorAwarenessRoute; +} + +export interface CursorAwarenessResult { + status: CursorAwarenessStatus; + /** Always false: check never applies kit or IDE changes. */ + applyRecommended: false; + /** Always false: never auto Field Reports. */ + fieldReportRecommended: false; + inventoryPath: string; + featuresPath: string; + changelogUrl: string | null; + latestCursorVersion: string | null; + lastSeenCursorVersion: string | null; + inventoryRefreshed: string | null; + openActionIds: string[]; + gaps: CursorAwarenessGap[]; + message: string; + conveyorHint: string; +} + +export interface CursorAwarenessOptions { + /** When true, honor cursorUpdateCheck.enabled + intervalDays. */ + respectPrefs?: boolean; + /** Persist lastCheckedAt / lastSeenCursorVersion after a successful network or inventory check. */ + stamp?: boolean; + /** Skip network changelog fetch; inventory-only advisory. */ + offline?: boolean; + /** Override changelog URL (HTTPS). */ + changelogUrl?: string; + /** Injected changelog body (tests). */ + changelogBody?: string; + /** Injected fetch implementation (tests). */ + fetchText?: (url: string) => Promise; +} + +const DEFAULT_PREFS: CursorUpdateCheckPrefs = { + enabled: false, + intervalDays: 7, + lastCheckedAt: null, + lastSeenCursorVersion: null, + changelogUrl: DEFAULT_CURSOR_CHANGELOG_URL, +}; + +const CONVEYOR_HINT = + "Confirmed gaps: Ask HITL then `/backlog-add` or `/dogfood` (lane-aware). Never auto Field Reports. Native-audit version prose owned by parked Marketplace plan."; + +export function readCursorUpdateCheckPrefs(config: unknown): CursorUpdateCheckPrefs { + if (!config || typeof config !== "object" || Array.isArray(config)) { + return { ...DEFAULT_PREFS }; + } + const raw = (config as Record).cursorUpdateCheck; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return { ...DEFAULT_PREFS }; + } + const uc = raw as Record; + const intervalDays = + typeof uc.intervalDays === "number" && Number.isInteger(uc.intervalDays) && uc.intervalDays >= 1 + ? uc.intervalDays + : DEFAULT_PREFS.intervalDays; + const changelogUrl = + typeof uc.changelogUrl === "string" && uc.changelogUrl.startsWith("https://") + ? uc.changelogUrl + : DEFAULT_PREFS.changelogUrl; + return { + enabled: uc.enabled === true, + intervalDays, + lastCheckedAt: typeof uc.lastCheckedAt === "string" ? uc.lastCheckedAt : null, + lastSeenCursorVersion: + typeof uc.lastSeenCursorVersion === "string" ? uc.lastSeenCursorVersion : null, + changelogUrl, + }; +} + +function intervalElapsed(lastCheckedAt: string | null, intervalDays: number): boolean { + if (!lastCheckedAt) return true; + const last = Date.parse(lastCheckedAt); + if (Number.isNaN(last)) return true; + return Date.now() - last >= intervalDays * 24 * 60 * 60 * 1000; +} + +async function loadContextConfig(cwd: string): Promise | null> { + const configPath = path.join(cwd, ".cursor", "context", "config.json"); + return readJson>(configPath); +} + +/** Persist cursorUpdateCheck prefs; refuses implausible lastSeenCursorVersion values. */ +export async function stampCursorUpdateCheck( + cwd: string, + patch: { lastSeenCursorVersion?: string | null }, +): Promise { + const configPath = path.join(cwd, ".cursor", "context", "config.json"); + const existing = (await loadContextConfig(cwd)) ?? {}; + const prev = + existing.cursorUpdateCheck && typeof existing.cursorUpdateCheck === "object" + ? { ...(existing.cursorUpdateCheck as Record) } + : {}; + const nextSeen = + patch.lastSeenCursorVersion !== undefined ? patch.lastSeenCursorVersion : undefined; + // Refuse to persist implausible versions (e.g. CSS 49.511) as the baseline. + if (nextSeen !== undefined && nextSeen !== null && !isPlausibleCursorVersion(nextSeen)) { + existing.cursorUpdateCheck = { + ...DEFAULT_PREFS, + ...prev, + lastCheckedAt: new Date().toISOString(), + }; + await writeJson(configPath, existing); + return; + } + existing.cursorUpdateCheck = { + ...DEFAULT_PREFS, + ...prev, + lastCheckedAt: new Date().toISOString(), + ...(nextSeen !== undefined ? { lastSeenCursorVersion: nextSeen } : {}), + }; + await writeJson(configPath, existing); +} + +/** True when major is in the plausible Cursor product range (rejects CSS/layout noise). */ +export function isPlausibleCursorVersion(version: string): boolean { + const parts = String(version).split("."); + if (parts.length < 2 || parts.length > 3) return false; + const major = Number(parts[0]); + const minor = Number(parts[1]); + const patch = parts.length === 3 ? Number(parts[2]) : 0; + if ([major, minor, patch].some((n) => Number.isNaN(n) || n < 0)) return false; + if (major >= 2000 || major > CURSOR_VERSION_MAJOR_MAX) return false; + if (minor > 999 || patch > 999) return false; + return true; +} + +/** Extract highest plausible Cursor product version from changelog HTML/text. */ +export function extractLatestCursorVersion(changelogText: string): string | null { + const text = String(changelogText ?? ""); + const candidates: string[] = []; + + const push = (major: string, minor: string, patch?: string) => { + const version = patch !== undefined ? `${major}.${minor}.${patch}` : `${major}.${minor}`; + if (isPlausibleCursorVersion(version)) candidates.push(version); + }; + + // Prefer cursor.com/changelog release labels: 3.11 + for (const m of text.matchAll(/(\d+)\.(\d+)(?:\.(\d+))?<\/span>/gi)) { + push(m[1] ?? "", m[2] ?? "", m[3]); + } + + // Plain-text / markdown changelog lines: "3.6 May 29 · Changelog" + for (const m of text.matchAll( + /\b(\d+)\.(\d+)(?:\.(\d+))?\b(?=[^\n]{0,48}(?:Changelog|Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec))/gi, + )) { + push(m[1] ?? "", m[2] ?? "", m[3]); + } + + // Fallback: any plausible token (never CSS/layout majors like 49.511). + if (candidates.length === 0) { + for (const m of text.matchAll(/\b(\d+)\.(\d+)(?:\.(\d+))?\b/g)) { + push(m[1] ?? "", m[2] ?? "", m[3]); + } + } + + let best: string | null = null; + let bestScore = -1; + for (const version of candidates) { + const parts = version.split(".").map(Number); + const score = (parts[0] ?? 0) * 1_000_000 + (parts[1] ?? 0) * 1_000 + (parts[2] ?? 0); + if (score > bestScore) { + bestScore = score; + best = version; + } + } + return best; +} + +/** Compare loose Cursor product versions (X.Y or X.Y.Z). Returns -1 / 0 / 1. */ +export function compareCursorVersion(a: string, b: string): number { + const pa = a.split(".").map((p) => Number(p)); + const pb = b.split(".").map((p) => Number(p)); + const len = Math.max(pa.length, pb.length); + for (let i = 0; i < len; i++) { + const da = pa[i] ?? 0; + const db = pb[i] ?? 0; + if (Number.isNaN(da) || Number.isNaN(db)) return 0; + if (da !== db) return da < db ? -1 : 1; + } + return 0; +} + +/** Parse `last refreshed **YYYY-MM-DD**` from the inventory intro. */ +export function parseInventoryRefreshed(markdown: string): string | null { + const m = markdown.match(/last refreshed\s+\*\*(\d{4}-\d{2}-\d{2})\*\*/i); + return m?.[1] ?? null; +} + +/** Open action-item IDs from the Action items table (`| ID | Open |`). */ +export function parseOpenActionIds(markdown: string): string[] { + const ids: string[] = []; + for (const line of markdown.split("\n")) { + const m = line.match(/^\|\s*([A-Z]\d+)\s*\|\s*Open\s*\|/i); + if (m?.[1]) ids.push(m[1]); + } + return ids; +} + +/** Feature names from the cursor-3-features table (first column). */ +export function parseFeatureMapNames(markdown: string): string[] { + const names: string[] = []; + let inTable = false; + for (const line of markdown.split("\n")) { + if (line.startsWith("| Feature |")) { + inTable = true; + continue; + } + if (inTable) { + if (!line.startsWith("|")) break; + if (line.includes("---")) continue; + const cells = line.split("|").map((c) => c.trim()); + const name = cells[1]; + if (name && name.toLowerCase() !== "feature") names.push(name); + } + } + return names; +} + +async function defaultFetchText(url: string): Promise { + if (!url.startsWith("https://")) { + throw new Error(`Refusing non-HTTPS changelog URL: ${url}`); + } + const res = await fetch(url, { + headers: { Accept: "text/html,text/plain;q=0.9,*/*;q=0.8" }, + redirect: "follow", + signal: AbortSignal.timeout(CHANGELOG_FETCH_TIMEOUT_MS), + }); + if (!res.ok) { + throw new Error(`Changelog fetch failed: HTTP ${res.status}`); + } + return res.text(); +} + +function baseResult( + partial: Omit< + CursorAwarenessResult, + "applyRecommended" | "fieldReportRecommended" | "conveyorHint" + >, +): CursorAwarenessResult { + return { + ...partial, + applyRecommended: false, + fieldReportRecommended: false, + conveyorHint: CONVEYOR_HINT, + }; +} + +/** + * Advisory Cursor product-update awareness check. + * Diffs changelog / inventory signals; never applies; never recommends Field Reports. + */ +export async function checkCursorUpdateAwareness( + cwd: string, + options: CursorAwarenessOptions = {}, +): Promise { + const inventoryPath = path.join(cwd, INVENTORY_REL); + const featuresPath = path.join(cwd, FEATURES_REL); + const prefs = readCursorUpdateCheckPrefs(await loadContextConfig(cwd)); + const changelogUrl = options.changelogUrl ?? prefs.changelogUrl; + + if (options.respectPrefs) { + if (!prefs.enabled) { + return baseResult({ + status: "skipped-disabled", + inventoryPath: INVENTORY_REL, + featuresPath: FEATURES_REL, + changelogUrl, + latestCursorVersion: null, + lastSeenCursorVersion: prefs.lastSeenCursorVersion, + inventoryRefreshed: null, + openActionIds: [], + gaps: [], + message: + "cursorUpdateCheck.enabled is false (opt-in). Set true in .cursor/context/config.json to nudge.", + }); + } + if (!intervalElapsed(prefs.lastCheckedAt, prefs.intervalDays)) { + return baseResult({ + status: "skipped-interval", + inventoryPath: INVENTORY_REL, + featuresPath: FEATURES_REL, + changelogUrl, + latestCursorVersion: null, + lastSeenCursorVersion: prefs.lastSeenCursorVersion, + inventoryRefreshed: null, + openActionIds: [], + gaps: [], + message: `Within cursorUpdateCheck.intervalDays (${prefs.intervalDays}); last check ${prefs.lastCheckedAt}.`, + }); + } + } + + let inventoryMd: string; + try { + inventoryMd = await readFile(inventoryPath, "utf8"); + } catch { + return baseResult({ + status: "error", + inventoryPath: INVENTORY_REL, + featuresPath: FEATURES_REL, + changelogUrl, + latestCursorVersion: null, + lastSeenCursorVersion: prefs.lastSeenCursorVersion, + inventoryRefreshed: null, + openActionIds: [], + gaps: [], + message: `Missing inventory at ${INVENTORY_REL}.`, + }); + } + + const inventoryRefreshed = parseInventoryRefreshed(inventoryMd); + const openActionIds = parseOpenActionIds(inventoryMd); + const gaps: CursorAwarenessGap[] = []; + + for (const id of openActionIds) { + gaps.push({ + id: `open-action-${id}`, + severity: "advisory", + path: INVENTORY_REL, + evidence: `Action item ${id} is Open in the native-audit inventory`, + suggestedRoute: "backlog-add", + }); + } + + if (inventoryRefreshed) { + const refreshedMs = Date.parse(inventoryRefreshed); + if (!Number.isNaN(refreshedMs)) { + const ageDays = (Date.now() - refreshedMs) / (24 * 60 * 60 * 1000); + if (ageDays > 45) { + gaps.push({ + id: "inventory-stale", + severity: "stale", + path: INVENTORY_REL, + evidence: `Inventory last refreshed ${inventoryRefreshed} (>45 days). Version-prose refresh owned by parked Marketplace plan; awareness reports only.`, + suggestedRoute: "none", + }); + } + } + } + + try { + const featuresMd = await readFile(featuresPath, "utf8"); + if (parseFeatureMapNames(featuresMd).length === 0) { + gaps.push({ + id: "features-map-empty", + severity: "info", + path: FEATURES_REL, + evidence: "Secondary feature map has no Feature table rows", + suggestedRoute: "none", + }); + } + } catch { + gaps.push({ + id: "features-map-missing", + severity: "info", + path: FEATURES_REL, + evidence: "Secondary feature map missing; changelog/inventory check continues", + suggestedRoute: "none", + }); + } + + let latestCursorVersion: string | null = null; + if (!options.offline) { + try { + const body = + options.changelogBody ?? (await (options.fetchText ?? defaultFetchText)(changelogUrl)); + latestCursorVersion = extractLatestCursorVersion(body); + if ( + latestCursorVersion && + prefs.lastSeenCursorVersion && + compareCursorVersion(latestCursorVersion, prefs.lastSeenCursorVersion) > 0 + ) { + gaps.push({ + id: "changelog-ahead", + severity: "advisory", + path: changelogUrl, + evidence: `Changelog latest ${latestCursorVersion} is ahead of lastSeenCursorVersion ${prefs.lastSeenCursorVersion}`, + suggestedRoute: "backlog-add", + }); + } else if (latestCursorVersion && !prefs.lastSeenCursorVersion) { + gaps.push({ + id: "changelog-baseline", + severity: "info", + path: changelogUrl, + evidence: `Changelog latest ${latestCursorVersion}; no lastSeenCursorVersion baseline yet (stamp to baseline)`, + suggestedRoute: "none", + }); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return baseResult({ + status: "error", + inventoryPath: INVENTORY_REL, + featuresPath: FEATURES_REL, + changelogUrl, + latestCursorVersion: null, + lastSeenCursorVersion: prefs.lastSeenCursorVersion, + inventoryRefreshed, + openActionIds, + gaps, + message: `Changelog fetch error: ${msg}`, + }); + } + } + + if (options.stamp) { + await stampCursorUpdateCheck(cwd, { + lastSeenCursorVersion: latestCursorVersion ?? prefs.lastSeenCursorVersion, + }); + } + + const status: CursorAwarenessStatus = gaps.length > 0 ? "gaps-found" : "current"; + const message = + status === "current" + ? "No advisory Cursor-update gaps vs inventory (check-only)." + : `Found ${gaps.length} advisory gap(s). ${CONVEYOR_HINT}`; + + return baseResult({ + status, + inventoryPath: INVENTORY_REL, + featuresPath: FEATURES_REL, + changelogUrl: options.offline ? null : changelogUrl, + latestCursorVersion, + lastSeenCursorVersion: prefs.lastSeenCursorVersion, + inventoryRefreshed, + openActionIds, + gaps, + message, + }); +} diff --git a/packages/cli/src/lifecycle/fixtures/cursor-changelog-excerpt.html b/packages/cli/src/lifecycle/fixtures/cursor-changelog-excerpt.html new file mode 100644 index 0000000..2270b8b --- /dev/null +++ b/packages/cli/src/lifecycle/fixtures/cursor-changelog-excerpt.html @@ -0,0 +1,9 @@ + + +-11.91-34.623-34.24-34.623h-88.61v69.236h89.34c20.47 0 33.51-12.281 33.51-34.623z">0 mb-v5 ">
+
+3.0 Apr 2 · Changelog
+3.6 May 29 · Changelog
+
+ diff --git a/packages/cli/src/lifecycle/l0.test.ts b/packages/cli/src/lifecycle/l0.test.ts index a4eec60..d7dac71 100644 --- a/packages/cli/src/lifecycle/l0.test.ts +++ b/packages/cli/src/lifecycle/l0.test.ts @@ -163,4 +163,12 @@ describe("canonical L0 inventory", () => { expect(JSON.parse(manifest).version).toBe(KIT_VERSION); expect(JSON.parse(plugin).version).toBe(KIT_VERSION); }); + + it("wires citty meta.version to KIT_VERSION for --version", async () => { + const indexSrc = await readRepositoryFile("packages/cli/src/index.ts"); + expect(indexSrc).toMatch( + /import\s+\{\s*KIT_VERSION\s*\}\s+from\s+["']\.\/lifecycle\/version\.js["']/, + ); + expect(indexSrc).toMatch(/version:\s*KIT_VERSION/); + }); }); diff --git a/packages/cli/src/lifecycle/l0.ts b/packages/cli/src/lifecycle/l0.ts index 260c3a6..d024c86 100644 --- a/packages/cli/src/lifecycle/l0.ts +++ b/packages/cli/src/lifecycle/l0.ts @@ -118,6 +118,14 @@ export const L0_ARTIFACTS: readonly L0Artifact[] = [ source: ".cursor/commands/field-report-resolve.md", target: ".cursor/commands/field-report-resolve.md", }, + { + source: ".cursor/commands/dogfood.md", + target: ".cursor/commands/dogfood.md", + }, + { + source: ".cursor/commands/cursor-update-awareness.md", + target: ".cursor/commands/cursor-update-awareness.md", + }, // Context (templates + example config; private config.json is not L0) { source: ".cursor/context/templates/plan-external-review-prompt.md", diff --git a/packages/cli/src/lifecycle/overlay-known-hashes.ts b/packages/cli/src/lifecycle/overlay-known-hashes.ts new file mode 100644 index 0000000..2c582b4 --- /dev/null +++ b/packages/cli/src/lifecycle/overlay-known-hashes.ts @@ -0,0 +1,56 @@ +/** + * Known shipped content hashes for consumer overlay paths + * (`.cursor/agents|skills|commands`). Used when the managed-hash ledger + * is absent so unedited kit files can still refresh while customized + * bodies are preserved (Option A). Append prior hashes when overlay + * content changes across releases. + */ +export const KNOWN_SHIPPED_OVERLAY_HASHES: ReadonlySet = new Set([ + "082bdbc584be3f3d8aa6e9d7b9f4e076450f3ff48f9147a1deb557e31475036b", + "15566765ec95dad3bc3160f20ac910e01a13471df536e6d990f4b263700784a9", + "1681e6cf7b80a67cd999b94b8b6e16eb8caed0e11b32905848ba733da956e902", + "1974a5af0e4ba2bb0fc7e1872ef9ab70e323323ab216958c31731bcdd82c8241", + "1e58e0d4f2e8459b95a40d1b1e72c5c85a87230cbde945840a84073fe3a20422", + "285668d08f1b15d96286d27e29cc4a67e126e097fb8a940bdde33ccf25bd2bcd", + "3f3354825ff06ff9f9ae1e29b71784fef4250020c23c1b490940cf06b2be0f28", + "4bf23acbc9bd4e0f8468e61f4970e182600b72d9025d29e81ddbd83cb7b1710a", + "4c5167d0ebde685f266dce32c516a7c43876e0520647b93f07f2e2147e6a7b2f", + "4dc4efcd1b43c643d3889fb349df8d4964d0867c3a67eb67e2ec168fbb70fc75", + "4ff844cf789cef4b1e809a55ca006316cd964bcdb2557d91c916a08e30ad15f6", + "533b41599c25698e5746166096a68fd4080efb5e16287b2a1ed5f73295cda446", + "570c3f91fd31a4cc1e56e997c85feab5c6756f45fc73dc741afe573912111231", + "5d302d207521c53b9fa814ee0eba1376a5b2bfd2983c0373657c9fdf9884541b", + "621bc6cecf7c710a97e93463ca9b8872a1fcab1325c358cc8be65b418eebb339", + "69715048da3963947aa8a39e7ac30ec01632fec862bfcb9559c46764b4833be0", + "6a42e76fc038ca69ddad18541b8df161770c58980fefaf9e5e80d87407074bec", + "6a9af9e8a95dbb279347166c43dcee245ca673f9687841086b49911c54944c26", + "6b66ed7747ea2d19cb457f5af656e3c07f9f563f7f32b3951fa4ae5f27f0ac46", + "76ae85bbc336c6f435d9653e8f254ae49774b4ebaecf7335697115933eb8797c", + "8108b202c829627c3c2df33575016ebe942df42acf4f1cc393900be3b82b402c", + "8619500b0c7024390d43e3aaefa2a2d731728d7c664aaf90e0acdd067f3397ce", + "8a11c80b0fbf6c4aabf60eec05b8cca7dccf5bca472787a8afa3028fd8fbedfa", + "8b25659727063013e8bf6d7925f815b2630c2919b21e6fdac354362bf122bfab", + "8c7edbacc74b1431fbecb04c9857c0e7a9cb1578fc6ca6367be888dbcec3c98f", + "9439da963acbe5dd88a3aa4d6ba4fb297db666ce8f2c9d3917dbc6d6e517f9aa", + "98d96e25f67aa9c234cb0c422f0d701c22a04bfe184fb4eb68295003d429552b", + "99031145609af46e7e0f315006bf633f47002eccece8eb7af8b5efd762313ade", + "9a3cea681399b42ef930a0720802378fe1b06ee73370df9ede9d0309c3c60ab2", + "9d905aca0123f3018c81c579217104f1749d8da802f3837b2407fbad8f2d803f", + "a08d04f8efa121ad498f96873778c22630c9172c07c216839e348cd0bb7c2292", + "b5c7a10df90d209be07159b7ff9bfa24e869538a0b6026bb5ce79e1cdd5b2528", + "c96d522be440fbb638dbe953cebf1bb31185c431ac2e14446369cfb7d155a951", + "cf2bd11891b934484171fa705f470de284e3874402e1e487750735dbc7692857", + "d4659e4bb6fd6022559fc632b17db6a39007c04bc0ded59017a0dacc013632ed", + "d484243167e13c34f9df7ff378baf2967f7cbfcd88170d4d32e745efbcae7480", + "d96aa15dde45147aa40db64684688a4b5c9ad660dcd16c55156b31933a42c37e", + "d9ecd90f80ebc25db73ebed27c32596b9cfce518b3026a3a4d5787321ba6324a", + "dd01d051dbab257e16609b02c983bad6a576ed8473781b4f1df9ef2eb5f85795", + "de6efabde6a1d69357926b88b5a4ed3eec19e11b82597cb999f5365f952305b8", + "e03b5764b908f9ed5c592108fbc11d5962d9256fd129a63ab4a39c8efaa2ef3e", + "e102e77f9f520f938d26861978249e4d85a8c8755d48c8e79b21fef9423325ee", + "e511e04064ffb8c429b659d2ae2e0f223e8cc51039ae816753095a5f7a160595", + "ebd41f8df764a14b3460016fafcfdde98c4c3a59a3c17f773451ef061fc42f95", + "f061432a2606049717c0cbf098ef07b1da4df2b136c4cb9cdaa7ea44c876bcd6", + "f061d36a1180293519fbb560bbf659c621bd90885e995fca6eaf2eda9e418a50", + "f28731a82f4b394a5765fdefe2d1b667810831f6dcea400ca62bc66e1d65371c", +]); diff --git a/packages/cli/src/lifecycle/overlay.test.ts b/packages/cli/src/lifecycle/overlay.test.ts new file mode 100644 index 0000000..8f5a991 --- /dev/null +++ b/packages/cli/src/lifecycle/overlay.test.ts @@ -0,0 +1,215 @@ +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { DEFAULT_PROTECTED_PATHS } from "../manifest/types.js"; +import { installPack } from "../registry/install.js"; +import { copyRegistryFile, emptyStats, recordOutcome } from "./apply.js"; +import { KNOWN_SHIPPED_OVERLAY_HASHES } from "./overlay-known-hashes.js"; +import { + MANAGED_HASHES_REL, + contentHash, + isConsumerOverlayPath, + loadManagedHashLedger, + seedManagedHashLedger, + shouldPreserveCustomizedOverlay, +} from "./overlay.js"; +import { installL0 } from "./sync.js"; + +const kitRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../.."); + +describe("consumer overlay path detection", () => { + it("matches agents, skills, and commands only", () => { + expect(isConsumerOverlayPath(".cursor/agents/foo.md")).toBe(true); + expect(isConsumerOverlayPath(".cursor/skills/core/clean-code/SKILL.md")).toBe(true); + expect(isConsumerOverlayPath(".cursor/commands/start-project.md")).toBe(true); + expect(isConsumerOverlayPath(".cursor/rules/ux-tone.mdc")).toBe(false); + expect(isConsumerOverlayPath(".cursor/HANDOFF.md")).toBe(false); + }); + + it("preserves customized or unknown when ledger-absent; refreshes known shipped", () => { + const custom = "custom\n"; + expect(shouldPreserveCustomizedOverlay(custom, undefined)).toBe(true); + expect(shouldPreserveCustomizedOverlay(custom, contentHash(custom))).toBe(false); + expect(shouldPreserveCustomizedOverlay(custom, contentHash("kit\n"))).toBe(true); + + const knownBody = "shipped kit body\n"; + const known = new Set([contentHash(knownBody)]); + expect(shouldPreserveCustomizedOverlay(knownBody, undefined, known)).toBe(false); + expect(shouldPreserveCustomizedOverlay(custom, undefined, known)).toBe(true); + }); +}); + +describe("consumer overlay apply policy", () => { + it("preserves customized kit command when ledger marks prior managed hash", async () => { + const project = await mkdtemp(path.join(tmpdir(), "agent-kit-overlay-preserve-")); + const cmdRel = ".cursor/commands/summary.md"; + await mkdir(path.join(project, ".cursor/commands"), { recursive: true }); + const custom = "# Custom summary\nlocal edit\n"; + await writeFile(path.join(project, cmdRel), custom, "utf8"); + + // Seed ledger as if the previous managed content was kit-owned (not custom). + const ledgerPath = path.join(project, MANAGED_HASHES_REL); + await writeFile( + ledgerPath, + JSON.stringify( + { + schemaVersion: 1, + hashes: { [cmdRel]: contentHash("# Command: /summary\n\nManaged.\n") }, + }, + null, + 2, + ), + "utf8", + ); + + const outcome = await copyRegistryFile(kitRoot, project, cmdRel, cmdRel, [ + ...DEFAULT_PROTECTED_PATHS, + ]); + expect(outcome).toBe("preserved-customized"); + expect(await readFile(path.join(project, cmdRel), "utf8")).toBe(custom); + }); + + it("preserves customized kit command when no ledger present (R1)", async () => { + const project = await mkdtemp(path.join(tmpdir(), "agent-kit-overlay-no-ledger-custom-")); + const cmdRel = ".cursor/commands/summary.md"; + await mkdir(path.join(project, ".cursor/commands"), { recursive: true }); + const custom = "# Custom summary\nlocal edit before ledger existed\n"; + await writeFile(path.join(project, cmdRel), custom, "utf8"); + // Intentionally no managed-hashes ledger (upgrade-into-overlay case). + + const kitContent = await readFile(path.join(kitRoot, cmdRel), "utf8"); + expect(custom).not.toBe(kitContent); + + const outcome = await copyRegistryFile(kitRoot, project, cmdRel, cmdRel, [ + ...DEFAULT_PROTECTED_PATHS, + ]); + expect(outcome).toBe("preserved-customized"); + expect(await readFile(path.join(project, cmdRel), "utf8")).toBe(custom); + + const ledger = await loadManagedHashLedger(project); + // Seeded with incoming kit hash as last-managed so later updates keep preserving. + expect(ledger.hashes[cmdRel]).toBe(contentHash(kitContent)); + }); + + it("refreshes unedited kit command when no ledger and local matches known shipped hash", async () => { + const project = await mkdtemp(path.join(tmpdir(), "agent-kit-overlay-no-ledger-refresh-")); + const registry = await mkdtemp(path.join(tmpdir(), "agent-kit-overlay-registry-")); + const cmdRel = ".cursor/commands/summary.md"; + await mkdir(path.join(project, ".cursor/commands"), { recursive: true }); + await mkdir(path.join(registry, ".cursor/commands"), { recursive: true }); + + const shipped = await readFile(path.join(kitRoot, cmdRel), "utf8"); + expect(KNOWN_SHIPPED_OVERLAY_HASHES.has(contentHash(shipped))).toBe(true); + + const newer = `${shipped}\n\n`; + await writeFile(path.join(project, cmdRel), shipped, "utf8"); + await writeFile(path.join(registry, cmdRel), newer, "utf8"); + // No ledger. + + const outcome = await copyRegistryFile(registry, project, cmdRel, cmdRel, [ + ...DEFAULT_PROTECTED_PATHS, + ]); + expect(outcome).toBe("written"); + expect(await readFile(path.join(project, cmdRel), "utf8")).toBe(newer); + const ledger = await loadManagedHashLedger(project); + expect(ledger.hashes[cmdRel]).toBe(contentHash(newer)); + }); + + it("refreshes unedited kit command when local hash matches ledger", async () => { + const project = await mkdtemp(path.join(tmpdir(), "agent-kit-overlay-refresh-")); + const cmdRel = ".cursor/commands/summary.md"; + await mkdir(path.join(project, ".cursor/commands"), { recursive: true }); + + const kitContent = await readFile(path.join(kitRoot, cmdRel), "utf8"); + // Local matches ledger (unedited); registry source differs → refresh. + const oldManaged = "# Command: /summary\n\nold managed\n"; + await writeFile(path.join(project, cmdRel), oldManaged, "utf8"); + await writeFile( + path.join(project, MANAGED_HASHES_REL), + JSON.stringify({ schemaVersion: 1, hashes: { [cmdRel]: contentHash(oldManaged) } }, null, 2), + "utf8", + ); + + const outcome = await copyRegistryFile(kitRoot, project, cmdRel, cmdRel, [ + ...DEFAULT_PROTECTED_PATHS, + ]); + expect(outcome).toBe("written"); + expect(await readFile(path.join(project, cmdRel), "utf8")).toBe(kitContent); + const ledger = await loadManagedHashLedger(project); + expect(ledger.hashes[cmdRel]).toBe(contentHash(kitContent)); + }); + + it("documents that user-added agent basenames are outside the L0 apply set", async () => { + // L0 has no .cursor/agents/ sources; installL0 never writes that tree. + // This only proves basename survival via non-membership, not overlay preserve. + const project = await mkdtemp(path.join(tmpdir(), "agent-kit-overlay-user-agent-")); + await mkdir(path.join(project, ".cursor/agents"), { recursive: true }); + const userAgent = "# My local agent\n"; + await writeFile(path.join(project, ".cursor/agents/my-local-agent.md"), userAgent, "utf8"); + + const stats = await installL0(kitRoot, project, [...DEFAULT_PROTECTED_PATHS]); + expect(stats.written.some((p) => p.includes(".cursor/commands/"))).toBe(true); + expect(stats.written.some((p) => p.includes(".cursor/agents/"))).toBe(false); + expect(await readFile(path.join(project, ".cursor/agents/my-local-agent.md"), "utf8")).toBe( + userAgent, + ); + }); + + it("preserves customized pack-installed agent on reinstall (R5)", async () => { + const project = await mkdtemp(path.join(tmpdir(), "agent-kit-overlay-pack-agent-")); + const protectedGlobs = [...DEFAULT_PROTECTED_PATHS]; + const agentRel = ".cursor/agents/cleancode-refactor.md"; + + const first = await installPack(kitRoot, project, "clean-code", { protectedGlobs }); + expect(first.written).toContain(agentRel); + + const kitBody = await readFile(path.join(project, agentRel), "utf8"); + const custom = `${kitBody}\n\n`; + await writeFile(path.join(project, agentRel), custom, "utf8"); + + const second = await installPack(kitRoot, project, "clean-code", { protectedGlobs }); + expect(second.preservedCustomized).toContain(agentRel); + expect(await readFile(path.join(project, agentRel), "utf8")).toBe(custom); + }); + + it("records preserved-customized in ApplyStats via recordOutcome", () => { + const stats = emptyStats(); + recordOutcome(stats, ".cursor/commands/summary.md", "preserved-customized"); + expect(stats.preservedCustomized).toEqual([".cursor/commands/summary.md"]); + }); + + it("seeds managed-hash ledger from current local overlay files", async () => { + const project = await mkdtemp(path.join(tmpdir(), "agent-kit-seed-ledger-")); + const cmdRel = ".cursor/commands/summary.md"; + const skillRel = ".cursor/skills/core/clean-code/SKILL.md"; + await mkdir(path.join(project, ".cursor/commands"), { recursive: true }); + await mkdir(path.join(project, ".cursor/skills/core/clean-code"), { recursive: true }); + await writeFile(path.join(project, cmdRel), "# Local summary\n", "utf8"); + await writeFile(path.join(project, skillRel), "# Local skill\n", "utf8"); + + await seedManagedHashLedger(project); + + const ledger = await loadManagedHashLedger(project); + expect(ledger.hashes[cmdRel]).toBe(contentHash("# Local summary\n")); + expect(ledger.hashes[skillRel]).toBe(contentHash("# Local skill\n")); + }); + + it("does not overwrite existing ledger entries when seeding", async () => { + const project = await mkdtemp(path.join(tmpdir(), "agent-kit-seed-no-clobber-")); + const cmdRel = ".cursor/commands/summary.md"; + await mkdir(path.join(project, ".cursor/commands"), { recursive: true }); + await writeFile(path.join(project, cmdRel), "# Local summary\n", "utf8"); + await writeFile( + path.join(project, MANAGED_HASHES_REL), + JSON.stringify({ schemaVersion: 1, hashes: { [cmdRel]: "existing-hash" } }, null, 2), + "utf8", + ); + + await seedManagedHashLedger(project); + + const ledger = await loadManagedHashLedger(project); + expect(ledger.hashes[cmdRel]).toBe("existing-hash"); + }); +}); diff --git a/packages/cli/src/lifecycle/overlay.ts b/packages/cli/src/lifecycle/overlay.ts new file mode 100644 index 0000000..440770f --- /dev/null +++ b/packages/cli/src/lifecycle/overlay.ts @@ -0,0 +1,128 @@ +/** + * Consumer overlay for agents / skills / commands. + * + * User-added basenames (never targeted by L0/pack/skill apply) already survive + * update. Kit-owned paths under these trees use a managed-content hash ledger: + * local hash matching the last install refreshes; divergence preserves the + * local file (preserved-customized) instead of silent clobber. + * + * Do not blanket-protect `.cursor/agents/**` (or skills/commands): that blocks + * pack / `agent-kit add` installs. + */ +import { createHash } from "node:crypto"; +import type { Dirent } from "node:fs"; +import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { KNOWN_SHIPPED_OVERLAY_HASHES } from "./overlay-known-hashes.js"; +import { resolveContained } from "./paths.js"; + +export const MANAGED_HASHES_REL = ".cursor/agent-kit.managed-hashes.json"; + +export const CONSUMER_OVERLAY_PREFIXES = [ + ".cursor/agents/", + ".cursor/skills/", + ".cursor/commands/", +] as const; + +export type ManagedHashLedger = { + schemaVersion: 1; + hashes: Record; +}; + +export function isConsumerOverlayPath(relPath: string): boolean { + const norm = relPath.split(path.sep).join("/"); + return CONSUMER_OVERLAY_PREFIXES.some((p) => norm.startsWith(p)); +} + +export function contentHash(content: string): string { + return createHash("sha256").update(content, "utf8").digest("hex"); +} + +export async function loadManagedHashLedger(projectRoot: string): Promise { + const abs = resolveContained(projectRoot, MANAGED_HASHES_REL); + try { + const raw = await readFile(abs, "utf8"); + const parsed = JSON.parse(raw) as Partial; + if ( + parsed && + typeof parsed === "object" && + parsed.hashes && + typeof parsed.hashes === "object" + ) { + return { schemaVersion: 1, hashes: { ...parsed.hashes } }; + } + } catch { + // absent or unreadable → empty ledger + } + return { schemaVersion: 1, hashes: {} }; +} + +export async function saveManagedHashLedger( + projectRoot: string, + ledger: ManagedHashLedger, +): Promise { + const abs = resolveContained(projectRoot, MANAGED_HASHES_REL); + await mkdir(path.dirname(abs), { recursive: true }); + const payload: ManagedHashLedger = { + schemaVersion: 1, + hashes: { ...ledger.hashes }, + }; + await writeFile(abs, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); +} + +/** + * Decide whether an overlay path with local≠registry content should refresh + * (unedited vs last managed hash) or be preserved as customized. + * + * Ledger-absent (upgrade into overlay / clone without the state file): compare + * local content to known shipped kit hashes (Option A, same idea as + * `MANAGED_LEGACY_HASHES` in onboard-migration). Known kit body → allow + * refresh; anything else → preserve. When a ledger entry exists, divergence + * from that managed hash preserves as before. + */ +export function shouldPreserveCustomizedOverlay( + localContent: string, + recordedHash: string | undefined, + knownShippedHashes: ReadonlySet = KNOWN_SHIPPED_OVERLAY_HASHES, +): boolean { + const localHash = contentHash(localContent); + if (!recordedHash) { + return !knownShippedHashes.has(localHash); + } + return localHash !== recordedHash; +} + +/** + * Seed the managed-hash ledger from the current local overlay files. + * Use this in factory/dogfood checkouts on first update so the local files + * become the baseline for subsequent refresh-vs-preserve decisions. + * Does not overwrite existing ledger entries. + */ +export async function seedManagedHashLedger(projectRoot: string): Promise { + const ledger = await loadManagedHashLedger(projectRoot); + for (const prefix of CONSUMER_OVERLAY_PREFIXES) { + const prefixPath = resolveContained(projectRoot, prefix); + let entries: Dirent[]; + try { + entries = await readdir(prefixPath, { withFileTypes: true, recursive: true }); + } catch { + continue; + } + for (const entry of entries) { + if (!entry.isFile()) continue; + const parentPath = String(entry.parentPath); + const name = String(entry.name); + const rel = path.relative(projectRoot, path.join(parentPath, name)); + const norm = rel.split(path.sep).join("/"); + if (ledger.hashes[norm]) continue; + try { + const content = await readFile(path.join(parentPath, name), "utf8"); + ledger.hashes[norm] = contentHash(content); + } catch { + // ignore unreadable files + } + } + } + await saveManagedHashLedger(projectRoot, ledger); + return ledger; +} diff --git a/packages/cli/src/lifecycle/report.ts b/packages/cli/src/lifecycle/report.ts index 142562d..d6bc5d3 100644 --- a/packages/cli/src/lifecycle/report.ts +++ b/packages/cli/src/lifecycle/report.ts @@ -18,6 +18,12 @@ export function logApplyStats(stats: ApplyStats): void { logger.warn("Slash collision preserved because the legacy command is customized:"); for (const p of stats.collisions) logger.info(` ! ${p}`); } + if (stats.preservedCustomized.length > 0) { + logger.warn( + `Preserved customized overlay (agents/skills/commands); run diff / contribute to sync upstream: ${stats.preservedCustomized.length}`, + ); + for (const p of stats.preservedCustomized) logger.info(` ! ${p}`); + } if (stats.skippedProtected.length > 0) { logger.warn(`Skipped protected (L3): ${stats.skippedProtected.length}`); for (const p of stats.skippedProtected) logger.info(` ~ ${p}`); diff --git a/packages/cli/src/lifecycle/sync.ts b/packages/cli/src/lifecycle/sync.ts index eb38cb6..14f7e30 100644 --- a/packages/cli/src/lifecycle/sync.ts +++ b/packages/cli/src/lifecycle/sync.ts @@ -10,6 +10,7 @@ import { } from "./apply.js"; import { L0_ARTIFACTS } from "./l0.js"; import { migrateLegacyOnboardCommand } from "./onboard-migration.js"; +import { loadManagedHashLedger, saveManagedHashLedger } from "./overlay.js"; import { resolveProtectedGlobs } from "./protected.js"; export async function installL0( @@ -18,6 +19,8 @@ export async function installL0( protectedGlobs: readonly string[], ): Promise { const stats = emptyStats(); + const managedHashes = await loadManagedHashLedger(projectRoot); + const copyOpts = { managedHashes, persistManagedHashes: false as const }; for (const artifact of L0_ARTIFACTS) { const outcome = await copyRegistryFile( registryRoot, @@ -25,9 +28,11 @@ export async function installL0( artifact.source, artifact.target, protectedGlobs, + copyOpts, ); recordOutcome(stats, artifact.target, outcome); } + await saveManagedHashLedger(projectRoot, managedHashes); const migration = await migrateLegacyOnboardCommand(projectRoot); if (migration === "removed-managed") { stats.removed.push(".cursor/commands/onboard.md"); diff --git a/packages/cli/src/registry/install.ts b/packages/cli/src/registry/install.ts index 593f44f..441edac 100644 --- a/packages/cli/src/registry/install.ts +++ b/packages/cli/src/registry/install.ts @@ -7,6 +7,7 @@ import { mergeStats, recordOutcome, } from "../lifecycle/apply.js"; +import { loadManagedHashLedger, saveManagedHashLedger } from "../lifecycle/overlay.js"; import { resolveContained } from "../lifecycle/paths.js"; import { readJson } from "../utils/fs.js"; import { allSkills, findPack, loadRegistry } from "./client.js"; @@ -98,14 +99,17 @@ export async function installSkill( const category = skill.path.includes("/core/") ? "core" : "community"; const sourceRel = path.posix.join(skill.path, "SKILL.md"); const targetRel = path.posix.join(".cursor", "skills", category, skill.id, "SKILL.md"); + const managedHashes = await loadManagedHashLedger(projectRoot); const outcome = await copyRegistryFile( registryRoot, projectRoot, sourceRel, targetRel, options.protectedGlobs ?? [], + { managedHashes, persistManagedHashes: false }, ); recordOutcome(stats, targetRel, outcome); + await saveManagedHashLedger(projectRoot, managedHashes); return stats; } @@ -157,6 +161,8 @@ export async function installPack( const packManifest = await loadPackManifest(registryRoot, packId); const stats = emptyStats(); const protectedGlobs = options.protectedGlobs ?? []; + const managedHashes = await loadManagedHashLedger(projectRoot); + const copyOpts = { managedHashes, persistManagedHashes: false as const }; for (const member of packManifest.members) { const { sourceRel, targetRel } = targetForMember(member); @@ -166,9 +172,11 @@ export async function installPack( sourceRel, targetRel, protectedGlobs, + copyOpts, ); recordOutcome(stats, targetRel, outcome); } + await saveManagedHashLedger(projectRoot, managedHashes); return stats; } diff --git a/scripts/verify-cli-dashboard-pack.mjs b/scripts/verify-cli-dashboard-pack.mjs index 2fb3b7d..bdd9d9c 100644 --- a/scripts/verify-cli-dashboard-pack.mjs +++ b/scripts/verify-cli-dashboard-pack.mjs @@ -1,9 +1,12 @@ #!/usr/bin/env node /** - * Blank-folder / pack acceptance for Path C (Mission Control dashboard in CLI pack). + * Blank-folder / pack acceptance for the published CLI package. * - * Verifies that an `npm pack` tarball for `@dadado/agent-kit-cli` includes - * `dashboard/start.mjs` (and `start-broadcast.mjs`) without requiring a live npm tag. + * Verifies that an `npm pack` tarball for `@dadado/agent-kit-cli` includes: + * - Path C Mission Control assets (`dashboard/start.mjs`, `start-broadcast.mjs`) + * - A non-empty package README (npm storefront; always packed when present) + * + * Does not require a live npm tag. * * Version bump (publish gate R3) stays owned by `/git-prod` + annotated tag CI. * Do not bump package versions from this script. @@ -12,10 +15,10 @@ * node scripts/verify-cli-dashboard-pack.mjs * node scripts/verify-cli-dashboard-pack.mjs --tarball path/to/dadado-agent-kit-cli-*.tgz * - * Exit 0 on pass; 1 on missing assets or pack failure. + * Exit 0 on pass; 1 on missing assets, empty README, or pack failure. */ import { execFileSync } from "node:child_process"; -import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -24,7 +27,46 @@ const here = dirname(fileURLToPath(import.meta.url)); const repoRoot = join(here, ".."); const cliDir = join(repoRoot, "packages", "cli"); -const REQUIRED = ["package/dashboard/start.mjs", "package/dashboard/start-broadcast.mjs"]; +export const REQUIRED_DASHBOARD = [ + "package/dashboard/start.mjs", + "package/dashboard/start-broadcast.mjs", +]; + +/** Case-insensitive README path under an extracted pack root. */ +export function findPackReadme(extractDir) { + const pkgDir = join(extractDir, "package"); + if (!existsSync(pkgDir)) return null; + const match = readdirSync(pkgDir).find((name) => /^readme(\.md)?$/i.test(name)); + return match ? join(pkgDir, match) : null; +} + +/** + * Assert Path C dashboard members + non-empty README in an extracted pack. + * @returns {{ ok: true } | { ok: false, errors: string[] }} + */ +export function assertPackContents(extractDir) { + const errors = []; + for (const rel of REQUIRED_DASHBOARD) { + if (!existsSync(join(extractDir, rel))) { + errors.push(`missing ${rel}`); + } + } + const readmePath = findPackReadme(extractDir); + if (!readmePath) { + errors.push("missing package/README.md (npm storefront)"); + } else { + const st = statSync(readmePath); + if (!st.isFile()) { + errors.push(`package README is not a file: ${readmePath}`); + } else { + const body = readFileSync(readmePath, "utf8"); + if (body.trim().length === 0) { + errors.push("package/README.md is empty"); + } + } + } + return errors.length ? { ok: false, errors } : { ok: true }; +} function parseArgs(argv) { const out = { tarball: null }; @@ -59,15 +101,17 @@ function packCli() { (f) => f.endsWith(".tgz") && f.startsWith("dadado-agent-kit-cli-"), ); if (!fallback) { - throw new Error(`npm pack did not produce a .tgz under packages/cli (stdout: ${out.slice(0, 200)})`); + throw new Error( + `npm pack did not produce a .tgz under packages/cli (stdout: ${out.slice(0, 200)})`, + ); } return join(cliDir, fallback); } return join(cliDir, name); } -function main() { - const { tarball: given } = parseArgs(process.argv.slice(2)); +export function main(argv = process.argv.slice(2)) { + const { tarball: given } = parseArgs(argv); let tarball = given; let created = false; if (!tarball) { @@ -77,25 +121,33 @@ function main() { } if (!existsSync(tarball)) { console.error(`verify-cli-dashboard-pack: missing tarball ${tarball}`); - process.exit(1); + process.exitCode = 1; + return 1; } const extractDir = mkdtempSync(join(tmpdir(), "ak-pack-verify-")); try { execFileSync("tar", ["-xzf", tarball, "-C", extractDir], { stdio: "inherit" }); - const missing = REQUIRED.filter((rel) => !existsSync(join(extractDir, rel))); - if (missing.length) { - console.error("verify-cli-dashboard-pack: FAIL — missing from pack:"); - for (const m of missing) console.error(` - ${m}`); - const listed = readdirSync(join(extractDir, "package"), { recursive: true }).slice(0, 40); - console.error("sample package/ entries:", listed); - process.exit(1); + const result = assertPackContents(extractDir); + if (!result.ok) { + console.error("verify-cli-dashboard-pack: FAIL — pack contents:"); + for (const m of result.errors) console.error(` - ${m}`); + const pkgRoot = join(extractDir, "package"); + if (existsSync(pkgRoot)) { + const listed = readdirSync(pkgRoot, { recursive: true }).slice(0, 40); + console.error("sample package/ entries:", listed); + } + process.exitCode = 1; + return 1; } - console.log("verify-cli-dashboard-pack: PASS — Path C dashboard assets present in pack."); + console.log( + "verify-cli-dashboard-pack: PASS — Path C dashboard assets and non-empty README present in pack.", + ); console.log(` tarball: ${tarball}`); console.log( " Publish gate: version bump + npm tag remain `/git-prod` HITL (do not bump from this script).", ); + return 0; } finally { rmSync(extractDir, { recursive: true, force: true }); if (created && existsSync(tarball)) { @@ -104,4 +156,8 @@ function main() { } } -main(); +const isDirectRun = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1]); + +if (isDirectRun) { + main(); +}