From c0ab18342ade4d0b92ccadaa515f2f6a0890303a Mon Sep 17 00:00:00 2001 From: agent-kit-sync Date: Wed, 29 Jul 2026 19:26:19 +0000 Subject: [PATCH] chore: sync private v4.8.3 (4e3eb77) --- .cursor-plugin/plugin.json | 2 +- .cursor/agent-kit.json | 2 +- .cursor/commands/git-staging.md | 9 +- CHANGELOG.md | 23 ++++ autogit/gitupdate.md | 10 +- dashboard/dashboard.html | 117 ++++++++++++++---- dashboard/lib/guards.mjs | 20 ++- dashboard/lib/semantic-model.mjs | 61 +++++---- dashboard/lib/triage-heading.mjs | 20 +++ dashboard/start-broadcast.mjs | 46 +++---- dashboard/start.mjs | 50 ++++---- docs/bootstrap.md | 4 +- docs/npm-publish-checklist.md | 25 ++++ install.md | 8 ++ package.json | 2 +- packages/cli/package.json | 2 +- packages/cli/src/commands/guard.ts | 21 +++- .../src/dashboard/external-reports.test.ts | 23 ++++ .../dashboard/field-report-prompts.test.ts | 2 +- packages/cli/src/dashboard/guards.test.ts | 14 +++ .../dashboard/plugin-ux-validation.test.ts | 68 ++++++++-- .../cli/src/dashboard/semantic-model.test.ts | 45 +++++++ packages/cli/src/hooks/session-start.test.ts | 115 ++++++++++++++++- .../cli/src/invariants/hooks-health.test.ts | 111 ++++++++++++++--- packages/cli/src/invariants/hooks-health.ts | 82 +++++++++++- .../src/invariants/monitors-untriaged.test.ts | 79 +++++++++++- .../cli/src/invariants/monitors-untriaged.ts | 4 +- .../cli/src/invariants/secrets-scan.test.ts | 19 +++ packages/cli/src/invariants/secrets-scan.ts | 17 ++- .../cli/src/invariants/shell-guard.test.ts | 42 +++++++ packages/cli/src/invariants/shell-guard.ts | 112 +++++++++++++++-- packages/cli/src/invariants/triage-heading.ts | 13 ++ 32 files changed, 1007 insertions(+), 161 deletions(-) create mode 100644 dashboard/lib/triage-heading.mjs create mode 100644 packages/cli/src/invariants/triage-heading.ts diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index cb50a88..1c68471 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -13,6 +13,6 @@ "anti-slop" ], "license": "MIT", - "version": "4.8.2", + "version": "4.8.3", "repository": "https://github.com/agent-kit-startup/agent-kit" } diff --git a/.cursor/agent-kit.json b/.cursor/agent-kit.json index 662d9f9..3040aae 100644 --- a/.cursor/agent-kit.json +++ b/.cursor/agent-kit.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "version": "4.8.2", + "version": "4.8.3", "protected": [ ".cursor/HANDOFF.md", ".cursor/agents/test-suites.md", diff --git a/.cursor/commands/git-staging.md b/.cursor/commands/git-staging.md index 925c19b..93891c9 100644 --- a/.cursor/commands/git-staging.md +++ b/.cursor/commands/git-staging.md @@ -6,7 +6,8 @@ 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. Run in order: validation (not on `main`), CHANGELOG (`[Unreleased]`), checkout staging, pull, working branch, Conventional Commits, push, MR/PR, merge, cleanup. -4. **Never** commit directly to `main`. -5. On completion: update `.cursor/HANDOFF.md` (phase in staging); memory-loop WRITE if it applies. -6. Optional: update the project's PM tool (ClickUp, Jira, ...) if MCP is configured. +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. +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. +7. Optional: update the project's PM tool (ClickUp, Jira, ...) if MCP is configured. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9067f03..09770ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,34 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and ## [Unreleased] +## [4.8.3] - 2026-07-29 + ### Fixed +- Guard shell: strip surrounding quotes in push refspec normalize so `git push origin 'main'` and `"+main"` deny +- Biome format: compact quiet-cap / classifier parity asserts in `plugin-ux-validation.test.ts` after PR #496 +- Flight Log quiet-cap: drop hardcoded fallback `5`; use SoT field only (missing → 0) and widen UX assertion (residual E) +- Flight Log classifier parity: scope shared-literal asserts to `classifyFlightLogMessageKind` / `flightLogMessageKind` bodies so `normalizeHandoffGaps` copies cannot satisfy them (residual F) +- Secrets scan: mask `json-secret-kv` (`"apiKey": "…"`) values in excerpts the same way as env assignments +- Guard shell: normalize push refspecs (`+` / `refs/heads/`) before protected-branch deny so `git push origin +main` and `refs/heads/main` are blocked +- Flight Log: shared `isFlightLogQuiet(d)` / `resolveFlightLogCurrent(d)` so fingerprint and renderer agree when Plan is none and Gaps come from handoff fallback +- Biome format: split two statements on one line in `plugin-ux-validation.test.ts` (unblocks `pnpm lint` / tag CI) +- Session-start hook: tests for readiness parsing + HANDOFF excerpt assembly (`buildSessionStartAdditionalContext`) +- `/git-prod` pre-tag gate requires `pnpm typecheck` + `pnpm test`; §12.5 documents post-tag `main` exception, scoped Path C smoke, and npm-publish-checklist gains durable cross-lens matrix + partial-row convention +- Doctor hooks health: adapters must exist, be executable, and CLI must resolve; guard shell covers `HEAD --` / `checkout .` / bare push on protected branch; secrets scan masks excerpts (hook omits raw spans); hand-install chmod +x in install.md + bootstrap +- Broad Intake Write-residuals invariant pinned in external-reports doc contract; Flight Log classifier parity covers all shared regex groups; classify pre-truncation (F4) +- Flight Log quiet open-triages: gate `flightLogFingerprint` on quiet Gaps+Warnings; derive cap/kind from SoT; build quiet lane from external reports (not starved attention) +- `TRIAGE_HEADING_RE` / `isReportTriaged`: match only durable headings (`Triage note` / `Follow-up plan` / `Residuals plan`); stop treating tick headings that name `triage-*` to-do ids as triaged (shared SoT in `dashboard/lib/triage-heading.mjs` + CLI parity) +- Path C Mission Control on macOS: escape `@` / `$` in Perl detach-start paths so `node_modules/@dadado/agent-kit-cli/dashboard/serve.mjs` is not stripped by array interpolation (`escapePerlDoubleQuoted`) - Public sync content guard: avoid denylist/secret-pattern false positives in CLI test fixtures (`field-report-prompts` path; split GitHub PAT sample) so `sync-public` can advance the storefront after npm 4.8.2 - Public sync push protection: split Stripe live-key sample in `secrets-scan.test.ts` so GitHub secret scanning does not reject the sync PR branch +### Changed + +- Close audit residuals A–E (`close-audit-residuals-ae`): staging lint-evidence process (#499); quoted push deny (#498); C deferred (quiet helpers still in `dashboard.html`); D–E accepted on monitor Closed-by; monitor + `_index` staged add-by-name (R14) +- `/git-staging` + `autogit/gitupdate.md`: staging-ready requires recorded lint command + result (not the contract string alone); GitHub PRs must use `--base staging` +- Memory Audits index: document curated (not exhaustive) policy for `_index.md` plan-monitor rows (ADR R14/R15 §3); stage five-monitor audit with index row (R14) + ## [4.8.2] - 2026-07-29 ### Fixed diff --git a/autogit/gitupdate.md b/autogit/gitupdate.md index ef2052b..0681244 100644 --- a/autogit/gitupdate.md +++ b/autogit/gitupdate.md @@ -275,10 +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). #### 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. 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). 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 @@ -293,7 +294,7 @@ This section contains the detailed prompts that should be followed when commands #### 9. **Open and merge Merge Request / Pull Request** - **GitLab:** Create the MR with `glab mr create --title "" --description "<description>" --target-branch staging`. Then run `glab mr merge <number>` to merge. If it fails due to authentication, provide the manual creation link and await instructions. - - **GitHub:** Create the PR with `gh pr create --title "<title>" --body "<description>" --base staging`. Then run `gh pr merge <number>` (or the returned number). If it fails due to authentication, provide the manual creation link and await instructions. + - **GitHub:** Create the PR with `gh pr create --title "<title>" --body "<description>" --base staging`. **Always pass `--base staging`** (default base is often `main`; never merge staging work straight to `main`). Then run `gh pr merge <number>` (or the returned number). If it fails due to authentication, provide the manual creation link and await instructions. #### 10. **Cleanup and final update** - Run `git checkout staging` to return to staging branch (needed before deleting working branch). @@ -335,7 +336,7 @@ This section contains the detailed prompts that should be followed when commands - `.cursor/agent-kit.json` - `.cursor-plugin/plugin.json` Do not ship with only root+CLI bumped; L0 version-parity tests fail and tag CI skips publish/sync. - 4. Prefer running focused L0 version-parity (`vitest` on `packages/cli/src/lifecycle/l0.test.ts`) or `pnpm test` on staging **before** the first tag push. + 4. **Required before the first `v*` tag push for this SemVer:** on staging (or the commit about to become `main`), run `pnpm typecheck` and `pnpm test` (or at least focused L0 version-parity: `vitest` on `packages/cli/src/lifecycle/l0.test.ts` **plus** `pnpm typecheck`). Do not treat this as optional: tag CI that fails typecheck skips `publish-npm` / `sync-public` (see `errors/2026-07-29_tag-ci-typecheck-blocked-481-publish.md`). 5. Commit this change to working branch / staging **before** merging to `main` (via MR if necessary). - If Unreleased is already empty and today's release reflects what's in staging, still verify all four manifests match the latest closed CHANGELOG version; bump and commit if they do not. @@ -425,6 +426,9 @@ This section contains the detailed prompts that should be followed when commands | Public sync PR **merged** | `sync-public` may open a PR; do **not** pass this row on CI-green alone. Confirm the public sync PR is **merged** (`gh pr view` / `gh pr list -R <public> --state merged`) before claiming public `main` is current | | Public `main` | Latest commit message like `chore: sync private vX.Y.Z (...)` on the public default branch **after** that merge | | Public GitHub Release | `gh release list -R <public>` shows `vX.Y.Z` as Latest (not a stale older release) | + | Scoped-install Path C smoke (manual or CI) | Install `@dadado/agent-kit-cli@X.Y.Z` into a blank folder under `node_modules/@dadado/…` (no kit checkout) and confirm `agent-kit dashboard` reaches HTTP 200 on loopback; required after Path C / detach-start changes | + + **Post-tag `main` commits:** After a `vX.Y.Z` tag ships, CI-unblock or fixture commits may land on `main` while manifests still say `X.Y.Z`. That is allowed only when documented in the promote notes / HANDOFF (tag and npm tarball describe the tagged commit, not necessarily later `main`). Realign with the next SemVer when product fixes (for example Path C) must reach npm; never force-move the existing `v*` tag. If `sync-public` failed, the sync PR is still open, or the public Release is missing: fix or re-run (`pnpm git:trigger-public-sync`), do not assume success from a green local merge/push or from tag CI alone. Write a memory/dogfood note when the gap was silent (npm green, public storefront stale; or CI green, sync PR unmerged). diff --git a/dashboard/dashboard.html b/dashboard/dashboard.html index 00d2da2..7ebb7ab 100644 --- a/dashboard/dashboard.html +++ b/dashboard/dashboard.html @@ -5582,7 +5582,7 @@ /** * Quiet-state open-triage row (untriaged external review). Prompt chrome; * per-row Copy triage + path only. No Review all / Resolve all. - * Cap mirrors FLIGHT_LOG_QUIET_OPEN_TRIAGES_CAP in semantic-model.mjs. + * 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 {number} idx */ @@ -5603,8 +5603,9 @@ const copyTriageHandler = copyForPasteHandler(triageCmd, 'triage command', 'chatInput'); const copyPathHandler = copyRepoPathHandler(sourcePath); const aria = `Review: ${label}`; + const kindClass = flightLogKindClassName('prompt'); return ` - <div class="flight-log-card flight-log-card-current flight-log-kind-advice" role="listitem" tabindex="0" data-focus-key="flight-log-open-triage-${idx}" data-flight-log-kind="prompt" aria-label="${escapeAttr(aria)}"> + <div class="flight-log-card flight-log-card-current ${kindClass}" role="listitem" tabindex="0" data-focus-key="flight-log-open-triage-${idx}" data-flight-log-kind="prompt" aria-label="${escapeAttr(aria)}"> <span class="flight-log-card-label">Review</span> <div class="flight-log-card-text">${escapeHtml(label)}</div> <div class="flight-log-card-meta">${escapeHtml(sourcePath)}</div> @@ -5616,6 +5617,43 @@ `; } +/** + * Resolve Live Gaps text for Flight Log render + fingerprint (same fallback chain). + * Prefer fl.current; with Plan:none, fall back to now.gaps then system.handoff.gaps. + */ +function resolveFlightLogCurrent(d) { + const fl = d?.missionControl?.flightLog ?? d?.flightLog ?? null; + if (typeof fl?.current === 'string' && fl.current.trim()) return fl.current.trim(); + const nowGaps = d?.missionControl?.now?.gaps ?? d?.now?.gaps; + if (typeof nowGaps === 'string' && nowGaps.trim()) return nowGaps.trim(); + const handoffGaps = d?.system?.handoff?.gaps; + if (typeof handoffGaps === 'string' && handoffGaps.trim()) return handoffGaps.trim(); + return null; +} + +function flightLogHasPastEntries(fl) { + if (!fl || !Array.isArray(fl.past)) return false; + return fl.past.some((e) => { + if (typeof e === 'string') return Boolean(e.trim()); + return Boolean(e && typeof e.text === 'string' && e.text.trim()); + }); +} + +/** Match renderFlightLogWarningCard: text-less warnings do not count. */ +function flightLogHasWarningEntries(fl) { + if (!fl || !Array.isArray(fl.warnings)) return false; + return fl.warnings.some((w) => { + if (typeof w === 'string') return Boolean(w.trim()); + return Boolean(w && typeof w.text === 'string' && w.text.trim()); + }); +} + +/** Shared quiet gate for renderAttentionPanel + flightLogFingerprint. */ +function isFlightLogQuiet(d) { + const fl = d?.missionControl?.flightLog ?? d?.flightLog ?? null; + return !resolveFlightLogCurrent(d) && !flightLogHasPastEntries(fl) && !flightLogHasWarningEntries(fl); +} + /** * Flight Log panel: HANDOFF Gaps log (live large + earlier smaller) + Warnings lane. * When Gaps + Warnings are empty, may show bounded quiet open-triage rows. @@ -5626,22 +5664,22 @@ const sourcePath = (typeof fl?.sourcePath === 'string' && fl.sourcePath.trim()) || '.cursor/HANDOFF.md'; - const current = - typeof fl?.current === 'string' && fl.current.trim() - ? fl.current.trim() - : typeof d.missionControl?.now?.gaps === 'string' && d.missionControl.now.gaps.trim() - ? d.missionControl.now.gaps.trim() - : typeof d.system?.handoff?.gaps === 'string' && d.system.handoff.gaps.trim() - ? d.system.handoff.gaps.trim() - : null; + const current = resolveFlightLogCurrent(d); const past = Array.isArray(fl?.past) ? fl.past : []; const warnings = Array.isArray(fl?.warnings) ? fl.warnings : []; + const quietCap = + typeof d.missionControl?.flightLogQuietOpenTriagesCap === 'number' && + d.missionControl.flightLogQuietOpenTriagesCap > 0 + ? Math.floor(d.missionControl.flightLogQuietOpenTriagesCap) + : 0; const quietOpenTriages = Array.isArray(fl?.quietOpenTriages) ? fl.quietOpenTriages : Array.isArray(d.missionControl?.attention) - ? d.missionControl.attention.filter( - (i) => i && i.kind === 'report' && typeof i.sourcePath === 'string' && i.sourcePath.trim(), - ).slice(0, 5) + ? d.missionControl.attention + .filter( + (i) => i && i.kind === 'report' && typeof i.sourcePath === 'string' && i.sourcePath.trim(), + ) + .slice(0, quietCap) : []; const pastCards = past .filter((e) => e && typeof e.text === 'string' && e.text.trim()) @@ -5675,6 +5713,7 @@ const hasCurrent = Boolean(current); const hasPast = pastCards.length > 0; const hasWarnings = warningCards.length > 0; + // Keep local flags aligned with isFlightLogQuiet(d) for the quiet branch. if (!hasCurrent && !hasPast && !hasWarnings) { if (hasOpenTriages) { body = `<div class="flight-log-stack" role="list" aria-label="Reviews awaiting triage">${openTriageCards}</div>`; @@ -5780,18 +5819,48 @@ return `Previous step elapsed ${formatElapsedPlain(row.elapsedMs)}`; } -function flightLogFingerprint(fl) { +/** + * Fingerprint Flight Log for SSE re-render. Accepts full dashboard `d` or a + * slim prev snapshot `{ flightLog, now, system }` so quiet-gate matches render. + */ +function flightLogFingerprint(d) { + if (!d || typeof d !== 'object') return ''; + const fl = d.missionControl?.flightLog ?? d.flightLog ?? null; if (!fl || typeof fl !== 'object') return ''; - const current = typeof fl.current === 'string' ? fl.current : ''; + const current = resolveFlightLogCurrent(d) || ''; const past = Array.isArray(fl.past) - ? fl.past.map((e) => (e && typeof e.text === 'string' ? e.text : '')).join('\n') + ? fl.past + .map((e) => + typeof e === 'string' ? e : e && typeof e.text === 'string' ? e.text : '', + ) + .join('\n') : ''; const warnings = Array.isArray(fl.warnings) - ? fl.warnings.map((w) => (w && (w.id || w.text) ? String(w.id || w.text) : '')).join('\n') - : ''; - const openTriages = Array.isArray(fl.quietOpenTriages) - ? fl.quietOpenTriages.map((i) => (i && (i.id || i.sourcePath) ? String(i.id || i.sourcePath) : '')).join('\n') + ? fl.warnings + .map((w) => + typeof w === 'string' + ? w + : w && (w.id || w.text) + ? String(w.id || w.text) + : '', + ) + .join('\n') : ''; + // Quiet-only: include open-triage ids when Gaps + Warnings are empty so a new + // monitor does not flash the non-quiet Flight Log card with no visible change. + // Must use the same gate as renderAttentionPanel (Plan:none Gaps fallback). + const openTriages = + isFlightLogQuiet(d) && Array.isArray(fl.quietOpenTriages) + ? fl.quietOpenTriages + .map((i) => + typeof i === 'string' + ? i + : i && (i.id || i.sourcePath) + ? String(i.id || i.sourcePath) + : '', + ) + .join('\n') + : ''; return `${current}\0${past}\0${warnings}\0${openTriages}`; } @@ -6085,8 +6154,12 @@ const nowChanged = prevData && nowFingerprint(d.missionControl?.now) !== nowFingerprint(prevData.now); const attentionChanged = prevData && - flightLogFingerprint(d.missionControl?.flightLog) !== - flightLogFingerprint(prevData.flightLog); + flightLogFingerprint(d) !== + flightLogFingerprint({ + flightLog: prevData.flightLog, + now: prevData.now, + system: prevData.system, + }); prevData = snapshotPrevData(d); dataLoading = false; diff --git a/dashboard/lib/guards.mjs b/dashboard/lib/guards.mjs index 8b0ead4..8d86126 100644 --- a/dashboard/lib/guards.mjs +++ b/dashboard/lib/guards.mjs @@ -29,6 +29,22 @@ export const DEFAULT_PORT_BASE = 3333; */ export const DEFAULT_PORT_RANGE = 256; +/** + * Escape a path/string for embedding in a Perl double-quoted literal. + * Scoped npm package paths contain `@` (e.g. `node_modules/@dadado/...`); + * unescaped `@name` is array interpolation in Perl and strips the scope segment, + * so macOS detach-start via `perl -e` cannot exec `dashboard/serve.mjs`. + * @param {string} value + * @returns {string} + */ +export function escapePerlDoubleQuoted(value) { + return String(value) + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/\$/g, "\\$") + .replace(/@/g, "\\@"); +} + /** * Resolve the repository root Mission Control should snapshot. * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env] @@ -141,7 +157,9 @@ export function sameRepoRoot(a, b) { export function resolveMissionControlPort({ repoRoot, envPort, probe, opts = {} }) { const root = resolve(String(repoRoot || "").trim() || "."); const raw = - envPort != null && String(envPort).trim() !== "" ? Number.parseInt(String(envPort), 10) : NaN; + envPort != null && String(envPort).trim() !== "" + ? Number.parseInt(String(envPort), 10) + : Number.NaN; if (Number.isFinite(raw) && raw > 0) { const info = probe(raw); diff --git a/dashboard/lib/semantic-model.mjs b/dashboard/lib/semantic-model.mjs index 3cb4780..63fb844 100644 --- a/dashboard/lib/semantic-model.mjs +++ b/dashboard/lib/semantic-model.mjs @@ -2,6 +2,10 @@ // Pure Mission Control view-model helpers (testable; no fs/git I/O). import { truncateStr } from "./guards.mjs"; +import { TRIAGE_HEADING_RE, hasTriageHeading } from "./triage-heading.mjs"; + +/** Durable triage heading SoT (shared with CLI `monitors --untriaged`). */ +export { TRIAGE_HEADING_RE, hasTriageHeading }; export const MAX_ACTIVITY = 28; export const MAX_ATTENTION = 15; @@ -62,15 +66,6 @@ export const MAX_CHECKLIST_NOTES = 15; * External review reports are `.cursor/memory/plan-monitor-<slug>.md`. */ export const EXTERNAL_REPORT_FILE_RE = /^plan-monitor-(.+)\.md$/; -/** - * A heading the triage step leaves behind in the report itself. Confirmed - * against the local reports: `## Triage note - residual (A) verified` and - * `## Follow-up plan - hitl_ask_questions_residuals_2026_07_20.plan.md`. - * `/plan-review-triage` must write one of these for every outcome, including - * Ack and stop, so Field Report can clear the untriaged row. - */ -export const TRIAGE_HEADING_RE = /^#{2,6}\s+.*\b(triage|follow-?up plan|residuals plan)\b/im; - /** * Local Field Report dismissals store (IDs only). Valid attention ids that * `/field-report-resolve` may append: External reviews, agent prompts, and @@ -576,21 +571,23 @@ export function buildFlightLogWarnings(handoff, opts = {}) { /** * Bounded untriaged external-review rows for Flight Log quiet state. - * Filters attention to `kind === "report"` only (no cadence, prompts, readiness, - * or bulk FR CTAs). Used when Gaps + Warnings are empty; callers must not mix - * these rows into a non-quiet Gaps/Warnings stack. - * @param {object[]|null|undefined} attention - buildAttentionItems output + * Filters to `kind === "report"` with a non-empty `sourcePath` (no cadence, + * prompts, readiness, or bulk FR CTAs). Prefer calling with + * `buildExternalReportItems(...)` output so the quiet lane is not starved by + * the shared `buildAttentionItems` cap. Callers must not mix these rows into a + * non-quiet Gaps/Warnings stack. + * @param {object[]|null|undefined} reportOrAttentionItems * @param {{ limit?: number }} [opts] * @returns {object[]} */ -export function listFlightLogQuietOpenTriages(attention, opts = {}) { +export function listFlightLogQuietOpenTriages(reportOrAttentionItems, opts = {}) { const limit = typeof opts.limit === "number" && opts.limit > 0 ? Math.floor(opts.limit) : FLIGHT_LOG_QUIET_OPEN_TRIAGES_CAP; - if (!Array.isArray(attention) || attention.length === 0) return []; + if (!Array.isArray(reportOrAttentionItems) || reportOrAttentionItems.length === 0) return []; const out = []; - for (const item of attention) { + for (const item of reportOrAttentionItems) { if (!item || item.kind !== "report") continue; if (typeof item.sourcePath !== "string" || !item.sourcePath.trim()) continue; out.push(item); @@ -1369,26 +1366,33 @@ export function normalizeHandoffGaps(raw) { /** * Classify a Flight Log Gaps/Warning body for palette chrome. + * Runs heuristics on whitespace-collapsed text **before** display truncation so + * long Gaps whose only warning/prompt/advice keyword sits past MAX_SEMANTIC_LABEL + * still match the inline dashboard.html classifier (which does not truncate). * @param {string | null | undefined} text * @param {{ lane?: 'gaps' | 'warning' }} [opts] * @returns {FlightLogMessageKind} */ export function classifyFlightLogMessageKind(text, opts = {}) { if (opts.lane === "warning") return "warning"; - const normalized = typeof text === "string" ? normalizeHandoffGaps(text) : null; - if (normalized == null) return "ok"; + if (!text || typeof text !== "string") return "ok"; + const collapsed = text.replace(/\s+/g, " ").trim(); + if (!collapsed) return "ok"; + if (/^(none|n\/a)$/i.test(collapsed)) return "ok"; + if (/^(none|n\/a)\s*[.:,;\/(\-–—…]/i.test(collapsed)) return "ok"; + if (/^([-–—.…]|empty|no gaps?|cleared|all clear|ok)$/i.test(collapsed)) return "ok"; if ( - /\bAPI\s*\/\s*usage\s+limit\b|\bAPI\s+usage\s+limit\b|\bSTOPPED:\s*API\b/i.test(normalized) || - /\b(hard.?stop|quota\s+pause)\b/i.test(normalized) + /\bAPI\s*\/\s*usage\s+limit\b|\bAPI\s+usage\s+limit\b|\bSTOPPED:\s*API\b/i.test(collapsed) || + /\b(hard.?stop|quota\s+pause)\b/i.test(collapsed) ) { return "warning"; } if ( - /\b(confirm|ask questions|hitl|\bpaste\b|choose\b|approve\b|operator yes)\b/i.test(normalized) + /\b(confirm|ask questions|hitl|\bpaste\b|choose\b|approve\b|operator yes)\b/i.test(collapsed) ) { return "prompt"; } - if (/\b(tip:|advice:|consider\b|recommends?\b|recommended\b|prefer\b)/i.test(normalized)) { + if (/\b(tip:|advice:|consider\b|recommends?\b|recommended\b|prefer\b)/i.test(collapsed)) { return "advice"; } return "residual"; @@ -3544,6 +3548,15 @@ export function buildMissionControlView({ // Field Report attention inbox left the Flight Log card; builders stay // exported for /field-report-resolve + cadence scripts (ADR keep). // Quiet Gaps+Warnings: bounded report rows may surface on Flight Log. + // Build quiet lane from external reports directly (not capped attention) so + // prompt/readiness pressure cannot starve Reviews awaiting triage to All clear. + const dismissedForQuiet = new Set( + (dismissedIds || []).filter((id) => typeof id === "string" && id.length > 0), + ); + const quietReportItems = buildExternalReportItems(externalReports, plans, { + handoff, + archivedPlanFiles, + }).filter((item) => item && !dismissedForQuiet.has(item.id)); const attention = buildAttentionItems({ plans, handoff, @@ -3556,7 +3569,7 @@ export function buildMissionControlView({ cadenceLedger, cadenceConfig, }); - flightLog.quietOpenTriages = listFlightLogQuietOpenTriages(attention); + flightLog.quietOpenTriages = listFlightLogQuietOpenTriages(quietReportItems); // Deprecated: attention owns Field Report rows. Kept empty so older panel // code that still reads the field does not double-render. const checklistNotes = []; @@ -3571,6 +3584,8 @@ export function buildMissionControlView({ plans: classifiedPlans, // Crew Monitor hero display cap (SoT for dashboard.html; no HTML literal). monitorFeedCap: MONITOR_FEED_CAP, + // Quiet open-triage fallback cap for dashboard.html attention mirror. + flightLogQuietOpenTriagesCap: FLIGHT_LOG_QUIET_OPEN_TRIAGES_CAP, // /run-plan-all queue slice (null outside queue mode). Copy-only data: // display order and roles; the panel never writes the queue back. runQueue: buildRunQueueView(handoff), diff --git a/dashboard/lib/triage-heading.mjs b/dashboard/lib/triage-heading.mjs new file mode 100644 index 0000000..71f58c9 --- /dev/null +++ b/dashboard/lib/triage-heading.mjs @@ -0,0 +1,20 @@ +/** + * Durable triage headings written by `/plan-review-triage` (L0). + * + * Shared SoT for Mission Control (`isReportTriaged`) and + * `agent-kit monitors --untriaged`. Match ONLY these heading titles, not tick + * headings that merely name a `triage-*` to-do id (hyphens are word boundaries, + * so `\btriage\b` falsely matched those). + * + * Allowed forms (case-insensitive; optional suffix after the title): + * ## Triage note + * ## Follow-up plan (also "Followup plan") + * ## Residuals plan + */ +export const TRIAGE_HEADING_RE = + /^#{2,6}\s+(?:Triage note|Follow-?up plan|Residuals plan)\b/im; + +/** True when markdown carries a durable triage heading. */ +export function hasTriageHeading(text) { + return TRIAGE_HEADING_RE.test(String(text ?? "")); +} diff --git a/dashboard/start-broadcast.mjs b/dashboard/start-broadcast.mjs index 09cfa31..9c1ef07 100644 --- a/dashboard/start-broadcast.mjs +++ b/dashboard/start-broadcast.mjs @@ -7,13 +7,14 @@ * prints LAN URL(s) with token. Does not weaken loopback `/dashboard`. */ -import { spawn, execFileSync, execSync } from "node:child_process"; +import { execFileSync, execSync, spawn } from "node:child_process"; import { existsSync, openSync } from "node:fs"; +import { platform } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { platform } from "node:os"; import { BROADCAST_TOKEN_ENV, + escapePerlDoubleQuoted, generateBroadcastToken, isLoopbackBindHost, isValidBroadcastToken, @@ -26,7 +27,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, ".."); const SERVE = join(__dirname, "serve.mjs"); const LOG = process.env.MISSION_CONTROL_LOG || "/tmp/mission-control-broadcast.log"; -const PORT = parseInt(process.env.PORT || "3333", 10); +const PORT = Number.parseInt(process.env.PORT || "3333", 10); const READY_TIMEOUT_MS = 20_000; const READY_POLL_MS = 250; @@ -57,11 +58,10 @@ function urlsForProbe(token) { function probeHttp(url) { try { - const code = execFileSync( - "curl", - ["-sf", "-o", "/dev/null", "-w", "%{http_code}", url], - { encoding: "utf8", timeout: 3000 }, - ).trim(); + const code = execFileSync("curl", ["-sf", "-o", "/dev/null", "-w", "%{http_code}", url], { + encoding: "utf8", + timeout: 3000, + }).trim(); return code === "200"; } catch { return false; @@ -70,11 +70,10 @@ function probeHttp(url) { function listeningPids() { try { - const out = execFileSync( - "lsof", - ["-nP", `-iTCP:${PORT}`, "-sTCP:LISTEN", "-t"], - { encoding: "utf8", timeout: 3000 }, - ).trim(); + const out = execFileSync("lsof", ["-nP", `-iTCP:${PORT}`, "-sTCP:LISTEN", "-t"], { + encoding: "utf8", + timeout: 3000, + }).trim(); return out ? out.split(/\n+/).filter(Boolean) : []; } catch { return []; @@ -107,12 +106,13 @@ function detachStart(env) { return; } - const rootEsc = ROOT.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); - const serveEsc = SERVE.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); - const logEsc = LOG.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); - const hostEsc = String(env.HOST).replace(/\\/g, "\\\\").replace(/"/g, '\\"'); - const tokenEsc = String(env[BROADCAST_TOKEN_ENV]).replace(/\\/g, "\\\\").replace(/"/g, '\\"'); - const portEsc = String(PORT); + // Escape @/$ so scoped package paths (node_modules/@scope/...) survive Perl qq. + const rootEsc = escapePerlDoubleQuoted(ROOT); + const serveEsc = escapePerlDoubleQuoted(SERVE); + const logEsc = escapePerlDoubleQuoted(LOG); + const hostEsc = escapePerlDoubleQuoted(String(env.HOST)); + const tokenEsc = escapePerlDoubleQuoted(String(env[BROADCAST_TOKEN_ENV])); + const portEsc = escapePerlDoubleQuoted(String(PORT)); const perl = [ "use POSIX qw(setsid);", "exit if fork;", @@ -169,7 +169,9 @@ function openBrowser(url) { async function main() { const { env, host, token } = resolveBroadcastEnv(); if (isLoopbackBindHost(host)) { - console.error("Broadcast refused: bind host resolved to loopback. Set HOST to a non-loopback address."); + console.error( + "Broadcast refused: bind host resolved to loopback. Set HOST to a non-loopback address.", + ); process.exit(1); } @@ -193,9 +195,7 @@ async function main() { detachStart(env); const ready = await waitReady(urls); if (!ready) { - console.error( - `Mission Control broadcast did not answer within ${READY_TIMEOUT_MS}ms.`, - ); + console.error(`Mission Control broadcast did not answer within ${READY_TIMEOUT_MS}ms.`); console.error(`Check the log: ${LOG}`); process.exit(1); } diff --git a/dashboard/start.mjs b/dashboard/start.mjs index 4be0d0a..c4c547d 100644 --- a/dashboard/start.mjs +++ b/dashboard/start.mjs @@ -15,17 +15,18 @@ * Foreground serve for debugging remains: `npm run start:dashboard`. */ -import { spawn, execFileSync, execSync } from "node:child_process"; +import { execFileSync, execSync, spawn } from "node:child_process"; import { existsSync, openSync } from "node:fs"; +import { platform } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { platform } from "node:os"; import { REPO_ROOT_ENV, - resolveSnapshotRepoRoot, + escapePerlDoubleQuoted, + repoRootLogId, resolveMissionControlPort, + resolveSnapshotRepoRoot, sameRepoRoot, - repoRootLogId, } from "./lib/guards.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -50,18 +51,15 @@ function setPort(port) { PORT = port; URL = `http://${DISPLAY_HOST}:${PORT}/`; DATA_URL = `http://${DISPLAY_HOST}:${PORT}/dashboard-data.json`; - LOG = - process.env.MISSION_CONTROL_LOG || - `/tmp/mission-control-${repoRootLogId(ROOT)}.log`; + LOG = process.env.MISSION_CONTROL_LOG || `/tmp/mission-control-${repoRootLogId(ROOT)}.log`; } function probeHttp(url = URL) { try { - const code = execFileSync( - "curl", - ["-sf", "-o", "/dev/null", "-w", "%{http_code}", url], - { encoding: "utf8", timeout: 3000 }, - ).trim(); + const code = execFileSync("curl", ["-sf", "-o", "/dev/null", "-w", "%{http_code}", url], { + encoding: "utf8", + timeout: 3000, + }).trim(); return code === "200"; } catch { return false; @@ -70,11 +68,10 @@ function probeHttp(url = URL) { function listeningPids(port = PORT) { try { - const out = execFileSync( - "lsof", - ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], - { encoding: "utf8", timeout: 3000 }, - ).trim(); + const out = execFileSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], { + encoding: "utf8", + timeout: 3000, + }).trim(); return out ? out.split(/\n+/).filter(Boolean) : []; } catch { return []; @@ -153,11 +150,12 @@ function detachStart() { } // macOS and other hosts without setsid: Perl double-fork + setsid(). - const rootEsc = KIT_ROOT.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); - const serveEsc = SERVE.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); - const logEsc = LOG.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); - const portEsc = String(PORT); - const snapEsc = ROOT.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + // Escape @/$ so scoped package paths (node_modules/@scope/...) survive Perl qq. + const rootEsc = escapePerlDoubleQuoted(KIT_ROOT); + const serveEsc = escapePerlDoubleQuoted(SERVE); + const logEsc = escapePerlDoubleQuoted(LOG); + const portEsc = escapePerlDoubleQuoted(String(PORT)); + const snapEsc = escapePerlDoubleQuoted(ROOT); const perl = [ "use POSIX qw(setsid);", "exit if fork;", @@ -258,9 +256,7 @@ async function ensureServer() { detachStart(); const ready = await waitReady(); if (!ready) { - console.error( - `Mission Control did not answer ${URL} within ${READY_TIMEOUT_MS}ms.`, - ); + console.error(`Mission Control did not answer ${URL} within ${READY_TIMEOUT_MS}ms.`); console.error(`Check the log: ${LOG}`); process.exit(1); } @@ -280,9 +276,7 @@ async function main() { "Opened in the default browser. In Cursor, Simple Browser or /dashboard also works.", ); } else { - console.log( - "Open that URL in a browser (Cursor: Simple Browser, or run /dashboard in chat).", - ); + console.log("Open that URL in a browser (Cursor: Simple Browser, or run /dashboard in chat)."); } } diff --git a/docs/bootstrap.md b/docs/bootstrap.md index cf20e86..14889c3 100644 --- a/docs/bootstrap.md +++ b/docs/bootstrap.md @@ -29,7 +29,9 @@ Everything under `plans/`, `memory/`, `context/`, and `HANDOFF.md` is **yours** Optional extra: a `prepare-commit-msg` git hook you can copy from the kit's `git-hooks/` folder (one file, not the whole tree). -Native agent hooks need Node and a resolvable `agent-kit` CLI (PATH, `node_modules/.bin`, or built dist). They are separate from git pre-commit hooks: one runs inside the IDE agent loop; the other runs at commit time. `agent-kit doctor` reports `hooks: active | degraded`. +Native agent hooks need Node and a resolvable `agent-kit` CLI (PATH, `node_modules/.bin`, or built dist). They are separate from git pre-commit hooks: one runs inside the IDE agent loop; the other runs at commit time. `agent-kit doctor` reports `hooks: active | degraded` (including missing / non-executable adapters and an unresolvable CLI). + +If you copy `.cursor/hooks/agent/*.sh` by hand (chat / Port B install), run `chmod +x .cursor/hooks/agent/*.sh` afterward. Managed `agent-kit install` preserves the executable bit. ## Installing diff --git a/docs/npm-publish-checklist.md b/docs/npm-publish-checklist.md index 96b0dc8..e7c33f4 100644 --- a/docs/npm-publish-checklist.md +++ b/docs/npm-publish-checklist.md @@ -79,10 +79,35 @@ If the answer is not an explicit **yes**, stop. No tag push, no token change for - [ ] `npm view @dadado/agent-kit-cli version` matches the release. - [ ] Smoke install: `npx @dadado/agent-kit-cli@<version> --help` (or `pnpm dlx`). +- [ ] Scoped Path C smoke (blank folder): install the published package under `node_modules/@dadado/agent-kit-cli` and confirm `agent-kit dashboard` returns HTTP 200 on loopback (required after Path C / detach-start changes; also a `/git-prod` §12.5 row). - [ ] GitHub Actions `publish-npm` job for the tag shows publish success (not skip), when using CI. +## Cross-lens go/no-go (before tagging) + +Use this matrix with dogfood / `/git-prod` Step 12.5. Cite `.cursor/memory/` when a row fails. + +| Lens | Check | Pass criteria | Known footguns / memory | +|------|-------|---------------|-------------------------| +| Externals | Public sync allowlist + denylist | `sync-cli-dashboard.mjs` + `verify-cli-dashboard-pack.mjs` allowlisted; denylist does not scrub required Path C assets | `errors/2026-07-25_public-sync-dashboard-allowlist-gap.md`, `errors/2026-07-25_public-sync-denylist-false-positive.md` | +| DevSecOps | Secrets / private paths out of tarball | Dry-run / pack list has no `.env`, credentials, `.cursor/memory`, private `config.json` | this checklist, `docs/repository-boundaries.md` | +| Cyber | Mission Control bind | Default `/dashboard` loopback; LAN broadcast requires token; no silent non-loopback | ADR opt-in LAN broadcast; Path C bundled host still respects bind rules | +| DevOps | Tag CI publish path | `publish-npm` runs pack verify before publish; Biome/pnpm/setup-node/`typecheck` green on tag | `errors/2026-07-21_ci-biome-blocked-440-publish.md`, `errors/2026-07-23_biome-format-blocked-446-tag-ci.md`, `errors/2026-07-29_tag-ci-typecheck-blocked-481-publish.md` | +| Git | Promote immutability | Four manifests match SemVer; never force-move pushed `v*`; Step 12.5 requires **merged** sync PR | `decisions/2026-07-28_git-prod-version-manifest-parity.md`, `errors/2026-07-28_public-sync-pr-unmerged-skips-release.md` | +| Product | Consumer install honesty | Dual-audience README; Port A/B leave `hooks.json`; Path C dashboard without kit checkout after publish | `errors/2026-07-19_consumer-install-missing-hooks-json.md`, `errors/2026-07-20_consumer-install-footguns.md` | +| Hygiene | Monitor staging | Untracked `plan-monitor-*.md` staged add-by-name only; tree clean before `/git-prod` validation | ADR R14/R15 staging hygiene | + +## Partial acceptance rows + +When a checklist or monitor acceptance box is only partly true, do **not** mark `[x]` alone: + +- Prefer splitting into separate rows (one Met, one Still open), or +- Use `[~]` / label the row **Partial** and keep the caveat in the same cell or an adjacent table. + +Downstream scans should not treat Partial as Met. + ## Related docs - Boundaries and secrets table: [repository-boundaries.md](repository-boundaries.md) - Public mirror launch (separate from npm): [public-launch.md](public-launch.md) - CI workflow: `.github/workflows/ci.yml` (`publish-npm` job) +- `/git-prod` routine: `autogit/gitupdate.md` (Prompt: git prod, §2 pre-tag gate, §12.5) diff --git a/install.md b/install.md index ba90628..23aad7e 100644 --- a/install.md +++ b/install.md @@ -126,6 +126,14 @@ Copy **only** these artifacts (same content from SoT / registry), not the monore | `autogit/gitupdate.md` | `autogit/gitupdate.md` | | `autogit/plan-routine.md` | `autogit/plan-routine.md` | +After copying the agent hook scripts by hand (Port B), make them executable so Cursor can run them: + +```bash +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: Options: `Fetch from public registry` / `Use different registry URL` / `Skip registry for now` diff --git a/package.json b/package.json index 054a786..7fc0d41 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-kit", - "version": "4.8.2", + "version": "4.8.3", "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", diff --git a/packages/cli/package.json b/packages/cli/package.json index d6d3ac5..4478c59 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@dadado/agent-kit-cli", - "version": "4.8.2", + "version": "4.8.3", "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/guard.ts b/packages/cli/src/commands/guard.ts index a69a2d4..9c8047a 100644 --- a/packages/cli/src/commands/guard.ts +++ b/packages/cli/src/commands/guard.ts @@ -1,8 +1,23 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import { defineCommand } from "citty"; import { readStdinJson } from "../hooks/read-stdin-json.js"; import { scanTextForSecrets, secretsAdviseMessage } from "../invariants/secrets-scan.js"; import { evaluateShellCommand } from "../invariants/shell-guard.js"; +const execFileAsync = promisify(execFile); + +async function detectCurrentBranch(): Promise<string | undefined> { + try { + const { stdout } = await execFileAsync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { + encoding: "utf8", + }); + const branch = stdout.trim(); + return branch && branch !== "HEAD" ? branch : undefined; + } catch { + return undefined; + } +} export const guardCommand = defineCommand({ meta: { name: "guard", @@ -31,7 +46,8 @@ export const guardCommand = defineCommand({ const payload = await readStdinJson<{ command?: string }>(); command = typeof payload.command === "string" ? payload.command : ""; } - const result = evaluateShellCommand(command); + const currentBranch = await detectCurrentBranch(); + const result = evaluateShellCommand(command, { currentBranch }); console.log(JSON.stringify(result)); }, }), @@ -59,12 +75,13 @@ export const guardCommand = defineCommand({ return; } // Annotate only: do not block the session (fail-open posture). + // Omit raw excerpts from hook stdout (pattern ids only). console.log( JSON.stringify({ continue: true, user_message: secretsAdviseMessage(hits), agent_message: secretsAdviseMessage(hits), - hits, + hits: hits.map((h) => ({ patternId: h.patternId })), }), ); }, diff --git a/packages/cli/src/dashboard/external-reports.test.ts b/packages/cli/src/dashboard/external-reports.test.ts index 4510031..215a989 100644 --- a/packages/cli/src/dashboard/external-reports.test.ts +++ b/packages/cli/src/dashboard/external-reports.test.ts @@ -193,6 +193,13 @@ describe("/plan-review-triage triage heading contract", () => { expect(planReviewTriageCmd).toMatch(/Ack and stop[\s\S]*must write the heading/i); expect(planReviewTriageCmd).toContain("Never skip the triage heading"); }); + + it("requires Broad Intake before Write residuals propose + write-confirm", () => { + expect(planReviewTriageCmd).toContain("Never skip Broad Intake"); + expect(planReviewTriageCmd).toMatch( + /Broad Intake[\s\S]*Write plan to backlog[\s\S]*Modify proposal first[\s\S]*Cancel/i, + ); + }); }); describe("isReportTriaged", () => { @@ -242,6 +249,22 @@ describe("isReportTriaged", () => { expect(isReportTriaged(followUpHeading, [])).toBe(true); }); + it("does not treat tick headings naming triage-* to-do ids as durable triage", () => { + const parsed = report( + "plan-monitor-quiet-open-triages.md", + "flight-log-quiet-open-triages.plan.md", + "\n## Tick 1 — `adr-quiet-triage-surface` (Phase 0)\n\n### Still open\n\n| A |\n", + ); + expect(isReportTriaged(parsed, [])).toBe(false); + + const residualsHeading = report( + "plan-monitor-hooks.md", + "hooks.plan.md", + "\n## Residuals plan\n\n- **Plan:** `close-residuals.plan.md`\n", + ); + expect(isReportTriaged(residualsHeading, [])).toBe(true); + }); + it("does not mistake the reviewed plan for its own follow-up", () => { const parsed = report( "plan-monitor-repository-readiness-onboarding.md", diff --git a/packages/cli/src/dashboard/field-report-prompts.test.ts b/packages/cli/src/dashboard/field-report-prompts.test.ts index 41f450d..6067bc3 100644 --- a/packages/cli/src/dashboard/field-report-prompts.test.ts +++ b/packages/cli/src/dashboard/field-report-prompts.test.ts @@ -607,7 +607,7 @@ describe("Flight Log panel contract (dashboard.html)", () => { it("keeps Flight Log Gaps-only panel without retired FR stack CTAs", () => { expect(dashboardHtml).toContain("function renderAttentionPanel(d, attentionChanged)"); - expect(dashboardHtml).toContain("function flightLogFingerprint(fl)"); + expect(dashboardHtml).toContain("function flightLogFingerprint(d)"); expect(dashboardHtml).not.toContain("Review all</button>"); expect(dashboardHtml).not.toContain("Resolve all</button>"); expect(dashboardHtml).toContain("pastChatPicker"); diff --git a/packages/cli/src/dashboard/guards.test.ts b/packages/cli/src/dashboard/guards.test.ts index 8129a24..55b9bf1 100644 --- a/packages/cli/src/dashboard/guards.test.ts +++ b/packages/cli/src/dashboard/guards.test.ts @@ -9,6 +9,7 @@ import { MAX_GIT_PATH, allowlistConfig, applyCorsHeaders, + escapePerlDoubleQuoted, isAllowedOrigin, isLoopbackAddress, isSafeRepoRelativePath, @@ -22,6 +23,19 @@ import { const repoRoot = resolve(fileURLToPath(import.meta.url), "../../../../.."); +describe("escapePerlDoubleQuoted", () => { + it("escapes @ so scoped npm package paths survive Perl double-quoted strings", () => { + const path = "/tmp/x/node_modules/@dadado/agent-kit-cli/dashboard/serve.mjs"; + expect(escapePerlDoubleQuoted(path)).toBe( + "/tmp/x/node_modules/\\@dadado/agent-kit-cli/dashboard/serve.mjs", + ); + }); + + it("escapes backslashes, double quotes, and dollar signs", () => { + expect(escapePerlDoubleQuoted('a\\b"c$d')).toBe('a\\\\b\\"c\\$d'); + }); +}); + describe("allowlistConfig", () => { it("keeps only allowlisted config keys and drops nested onboarding checks", () => { const raw = { diff --git a/packages/cli/src/dashboard/plugin-ux-validation.test.ts b/packages/cli/src/dashboard/plugin-ux-validation.test.ts index b463b9f..abae95a 100644 --- a/packages/cli/src/dashboard/plugin-ux-validation.test.ts +++ b/packages/cli/src/dashboard/plugin-ux-validation.test.ts @@ -431,9 +431,33 @@ describe("plugin-ux-validation: narrow shell + a11y chrome", () => { expect(dashboardHtml).toContain("function renderFlightLogQuietOpenTriageCard"); expect(dashboardHtml).toContain("quietOpenTriages"); expect(dashboardHtml).toContain("Reviews awaiting triage"); + expect(dashboardHtml).toContain("flightLogQuietOpenTriagesCap"); + expect(dashboardHtml).toContain("flightLogKindClassName('prompt')"); + expect(dashboardHtml).toContain("function isFlightLogQuiet(d)"); + expect(dashboardHtml).toContain("function resolveFlightLogCurrent(d)"); + expect(dashboardHtml).toContain("isFlightLogQuiet(d)"); + // Plan:none + Gaps: fingerprint must use the same Gaps fallback as the renderer + // so quietOpenTriages changes do not flash a non-quiet card (residual B). + expect(dashboardHtml).toMatch( + /function resolveFlightLogCurrent\(d\)[\s\S]*?missionControl\?\.now\?\.gaps[\s\S]*?system\?\.handoff\?\.gaps/, + ); + expect(dashboardHtml).toMatch( + /function flightLogFingerprint\(d\)[\s\S]*?isFlightLogQuiet\(d\)/, + ); + // Quiet open-triage stack only inside Gaps+Warnings empty gate (residual E). + expect(dashboardHtml).toMatch( + /if\s*\(\s*!hasCurrent\s*&&\s*!hasPast\s*&&\s*!hasWarnings\s*\)\s*\{[\s\S]*?Reviews awaiting triage/, + ); expect(dashboardHtml).not.toContain("No pending attention items."); expect(dashboardHtml).not.toContain("Review all</button>"); expect(dashboardHtml).not.toContain("Resolve all</button>"); + // Cap fallback must not hardcode a second SoT literal beside the view field. + expect(dashboardHtml).not.toMatch( + /attention\.filter\(\s*\([\s\S]*?kind === 'report'[\s\S]*?\)\.slice\(0,\s*5\)/, + ); + // Residual E: quietCap ternary must not fall back to literal 5. + expect(dashboardHtml).not.toMatch(/flightLogQuietOpenTriagesCap[\s\S]{0,220}:\s*5\s*;/); + expect(dashboardHtml).toMatch(/flightLogQuietOpenTriagesCap[\s\S]{0,220}:\s*0\s*;/); expect(dashboardHtml).toContain("No agent activity yet"); expect(dashboardHtml).toContain("Listening"); expect(dashboardHtml).toContain("Quiet cockpit"); @@ -520,18 +544,42 @@ describe("plugin-ux-validation: narrow shell + a11y chrome", () => { }); it("pins Flight Log OK-normalize regex parity and CSS kind rules", () => { - // Shared none./n/a prefix rule must stay case-insensitive and separator-aligned - // across semantic-model.mjs and the inline dashboard.html copy (monitor D). - const noneExact = "/^(none|n\\/a)$/i"; - const nonePrefix = "/^(none|n\\/a)\\s*[.:,;\\/(\\-–—…]/i"; + // Shared classifier rule groups must stay aligned across semantic-model.mjs + // classifyFlightLogMessageKind and the inline dashboard.html flightLogMessageKind + // (close-queue F1 / residual F). Scope to classifier bodies so normalizeHandoffGaps + // copies cannot satisfy the .mjs half. const semanticModel = readFileSync( resolve(repoRoot, "dashboard/lib/semantic-model.mjs"), "utf8", ); - expect(semanticModel).toContain(`${noneExact}.test(text)`); - expect(semanticModel).toContain(`${nonePrefix}.test(text)`); - expect(dashboardHtml).toContain(`${noneExact}.test(raw)`); - expect(dashboardHtml).toContain(`${nonePrefix}.test(raw)`); + const classifyStart = semanticModel.indexOf("export function classifyFlightLogMessageKind"); + expect(classifyStart).toBeGreaterThanOrEqual(0); + const classifyEnd = semanticModel.indexOf( + "\nexport function flightLogKindClass", + classifyStart, + ); + expect(classifyEnd).toBeGreaterThan(classifyStart); + const classifyBody = semanticModel.slice(classifyStart, classifyEnd); + + const htmlKindStart = dashboardHtml.indexOf("function flightLogMessageKind(text"); + expect(htmlKindStart).toBeGreaterThanOrEqual(0); + const htmlKindEnd = dashboardHtml.indexOf("\nfunction flightLogKindClassName", htmlKindStart); + expect(htmlKindEnd).toBeGreaterThan(htmlKindStart); + const htmlKindBody = dashboardHtml.slice(htmlKindStart, htmlKindEnd); + + const sharedLiterals = [ + "/^(none|n\\/a)$/i", + "/^(none|n\\/a)\\s*[.:,;\\/(\\-–—…]/i", + "/^([-–—.…]|empty|no gaps?|cleared|all clear|ok)$/i", + "/\\bAPI\\s*\\/\\s*usage\\s+limit\\b|\\bAPI\\s+usage\\s+limit\\b|\\bSTOPPED:\\s*API\\b/i", + "/\\b(hard.?stop|quota\\s+pause)\\b/i", + "/\\b(confirm|ask questions|hitl|\\bpaste\\b|choose\\b|approve\\b|operator yes)\\b/i", + "/\\b(tip:|advice:|consider\\b|recommends?\\b|recommended\\b|prefer\\b)/i", + ]; + for (const lit of sharedLiterals) { + expect(classifyBody).toContain(lit); + expect(htmlKindBody).toContain(lit); + } // CSS rules must exist for every emitted kind class on Live + Earlier (monitor E). for (const kind of ["residual", "advice", "ok", "warning"] as const) { @@ -1070,7 +1118,7 @@ describe("plugin-ux-validation: SSE + overview model wiring", () => { it("re-renders now + Flight Log from missionControl fingerprints on each snapshot", () => { expect(dashboardHtml).toContain("function nowFingerprint(now)"); - expect(dashboardHtml).toContain("function flightLogFingerprint(fl)"); + expect(dashboardHtml).toContain("function flightLogFingerprint(d)"); expect(dashboardHtml).not.toContain("attentionFingerprint"); expect(dashboardHtml).toContain("renderNowExecutionPanel"); expect(dashboardHtml).toContain("d.missionControl?.now"); @@ -2131,7 +2179,7 @@ describe("cockpit checklist: plan cards only (notes moved to Field Report)", () expect(dashboardHtml).not.toContain("function orderAttentionBySeverity(items)"); expect(dashboardHtml).not.toContain("function renderAttentionItem(item, idx)"); expect(dashboardHtml).toContain("function renderAttentionPanel(d, attentionChanged)"); - expect(dashboardHtml).toContain("function flightLogFingerprint(fl)"); + expect(dashboardHtml).toContain("function flightLogFingerprint(d)"); }); }); diff --git a/packages/cli/src/dashboard/semantic-model.test.ts b/packages/cli/src/dashboard/semantic-model.test.ts index 8fc6857..0cc0635 100644 --- a/packages/cli/src/dashboard/semantic-model.test.ts +++ b/packages/cli/src/dashboard/semantic-model.test.ts @@ -6,6 +6,7 @@ import { FLIGHT_LOG_WARNINGS_CAP, MAX_ACTIVITY, MAX_INVENTORY_ACTIVITY, + MAX_SEMANTIC_LABEL, MONITOR_ACTIVITY_KINDS, MONITOR_AGENT_STEP_EMIT_CAP, MONITOR_FEED_CAP, @@ -195,6 +196,11 @@ describe("parseHandoffMarkdown", () => { "warning", ); expect(classifyFlightLogMessageKind("anything", { lane: "warning" })).toBe("warning"); + // Pre-truncation: keyword past MAX_SEMANTIC_LABEL still classifies (F4). + const longPrefix = "x".repeat(MAX_SEMANTIC_LABEL + 20); + expect( + classifyFlightLogMessageKind(`${longPrefix} tip: prefer named model for continuous runs`), + ).toBe("advice"); expect(flightLogKindClass("residual")).toBe("flight-log-kind-residual"); expect(flightLogKindClass("advice")).toBe("flight-log-kind-advice"); expect(flightLogKindClass("prompt")).toBe("flight-log-kind-advice"); @@ -2657,4 +2663,43 @@ describe("listFlightLogQuietOpenTriages", () => { expect(withGaps.flightLog.current).toBeTruthy(); expect(withGaps.flightLog.quietOpenTriages.length).toBeGreaterThan(0); }); + + it("does not starve quietOpenTriages when attention drops reports under a tight limit", () => { + const debtReport = 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 agentPrompts = Array.from({ length: 20 }, (_, i) => ({ + chatId: `chat-starve-${i}`, + label: `Pending question ${i}?`, + })); + const starvedAttention = buildAttentionItems({ + plans: samplePlans, + handoff: { + plan: "mission-control-plugin-ux.plan.md", + mode: "manual", + }, + agentPrompts, + externalReports: [debtReport], + limit: 3, + }); + expect(starvedAttention.some((a) => a.kind === "report")).toBe(false); + expect(listFlightLogQuietOpenTriages(starvedAttention)).toEqual([]); + + const view = buildMissionControlView({ + plans: samplePlans, + handoff: { + plan: "mission-control-plugin-ux.plan.md", + mode: "manual", + }, + agentPrompts, + externalReports: [debtReport], + nowMs: Date.parse("2026-07-28T12:00:00.000Z"), + }); + expect(view.flightLog.quietOpenTriages.length).toBeGreaterThan(0); + expect(view.flightLog.quietOpenTriages[0].kind).toBe("report"); + expect(view.flightLogQuietOpenTriagesCap).toBe(FLIGHT_LOG_QUIET_OPEN_TRIAGES_CAP); + }); }); diff --git a/packages/cli/src/hooks/session-start.test.ts b/packages/cli/src/hooks/session-start.test.ts index 870eafd..7cc8eb6 100644 --- a/packages/cli/src/hooks/session-start.test.ts +++ b/packages/cli/src/hooks/session-start.test.ts @@ -1,6 +1,12 @@ +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 { buildPreCompactUserMessage } from "./pre-compact.js"; -import { parseUnprocessedDogfoodItems } from "./session-start.js"; +import { + buildSessionStartAdditionalContext, + parseUnprocessedDogfoodItems, +} from "./session-start.js"; describe("parseUnprocessedDogfoodItems", () => { it("skips None placeholders", () => { @@ -21,3 +27,110 @@ describe("buildPreCompactUserMessage", () => { expect(out.user_message).toContain("/continue-plan"); }); }); + +describe("buildSessionStartAdditionalContext", () => { + async function fixtureRoot(): Promise<string> { + const root = await mkdtemp(path.join(tmpdir(), "ak-session-")); + await mkdir(path.join(root, ".cursor", "context"), { recursive: true }); + await writeFile(path.join(root, ".cursor", "agent-kit.json"), '{"schemaVersion":1}\n', "utf8"); + return root; + } + + it("assembles a HANDOFF excerpt when the file exists", async () => { + const root = await fixtureRoot(); + await writeFile( + path.join(root, ".cursor", "HANDOFF.md"), + [ + "# Handoff - sample", + "", + "- **Plan:** `sample.plan.md`", + "- **Gaps:** none", + "- **Instruction for the next agent:** Resume Phase 1.", + "", + ].join("\n"), + "utf8", + ); + const { additional_context } = await buildSessionStartAdditionalContext(root); + expect(additional_context).toContain("## Current HANDOFF.md (excerpt)"); + expect(additional_context).toContain("`sample.plan.md`"); + expect(additional_context).toContain("Resume Phase 1."); + expect(additional_context).not.toContain("No handoff file yet"); + // Hard rules preamble is always first. + expect(additional_context.indexOf("## Current HANDOFF.md")).toBeGreaterThan(0); + }); + + it("notes missing HANDOFF when absent", async () => { + const root = await fixtureRoot(); + const { additional_context } = await buildSessionStartAdditionalContext(root); + expect(additional_context).toContain("## HANDOFF.md"); + expect(additional_context).toContain("No handoff file yet"); + }); + + it("surfaces unresolved essential readiness before optional items", async () => { + const root = await fixtureRoot(); + await writeFile( + path.join(root, ".cursor", "context", "readiness.json"), + JSON.stringify({ + pillars: [ + { + id: "git", + checks: [ + { + id: "git.remote", + essential: true, + status: "pending", + title: "Configure git remote", + actions: [ + { + id: "set-remote", + recommendation: "Add origin remote URL", + }, + ], + }, + { + id: "optional.skin", + essential: false, + status: "pending", + title: "Pick a skin", + }, + ], + }, + ], + }), + "utf8", + ); + const { additional_context } = await buildSessionStartAdditionalContext(root); + expect(additional_context).toContain("## Repository readiness"); + expect(additional_context).toContain("Unresolved essential check: `set-remote`"); + expect(additional_context).toContain("Add origin remote URL"); + expect(additional_context).toContain("`/agent-kit-onboard`"); + expect(additional_context).not.toContain("Optional readiness item: `optional.skin`"); + }); + + it("surfaces optional readiness when no essential remains", async () => { + const root = await fixtureRoot(); + await writeFile( + path.join(root, ".cursor", "context", "readiness.json"), + JSON.stringify({ + pillars: [ + { + id: "ux", + checks: [ + { + id: "persona", + essential: false, + status: "pending", + title: "Confirm persona", + actions: [{ id: "pick-persona", recommendation: "Choose a default persona" }], + }, + ], + }, + ], + }), + "utf8", + ); + const { additional_context } = await buildSessionStartAdditionalContext(root); + expect(additional_context).toContain("Optional readiness item: `pick-persona`"); + expect(additional_context).toContain("does not block"); + }); +}); diff --git a/packages/cli/src/invariants/hooks-health.test.ts b/packages/cli/src/invariants/hooks-health.test.ts index aae28d4..c807b2a 100644 --- a/packages/cli/src/invariants/hooks-health.test.ts +++ b/packages/cli/src/invariants/hooks-health.test.ts @@ -1,9 +1,57 @@ -import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +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"; +const ADAPTERS = [ + "session-start.sh", + "pre-compact.sh", + "guard-shell.sh", + "after-edit-schema.sh", + "secrets-prompt.sh", +] as const; + +async function writeWiredHooks( + root: string, + opts?: { executable?: boolean; withAdapters?: boolean }, +) { + const agent = path.join(root, ".cursor", "hooks", "agent"); + await mkdir(agent, { recursive: true }); + await writeFile( + path.join(root, ".cursor", "hooks.json"), + JSON.stringify({ + version: 1, + hooks: { + sessionStart: [{ command: ".cursor/hooks/agent/session-start.sh" }], + preCompact: [{ command: ".cursor/hooks/agent/pre-compact.sh" }], + beforeShellExecution: [{ command: ".cursor/hooks/agent/guard-shell.sh" }], + afterFileEdit: [{ command: ".cursor/hooks/agent/after-edit-schema.sh" }], + beforeSubmitPrompt: [{ command: ".cursor/hooks/agent/secrets-prompt.sh" }], + }, + }), + "utf8", + ); + await writeFile(path.join(agent, "resolve-agent-kit.sh"), "#!/bin/sh\n", "utf8"); + if (opts?.withAdapters !== false) { + for (const name of ADAPTERS) { + await writeFile(path.join(agent, name), "#!/bin/sh\n", "utf8"); + } + } + if (opts?.executable !== false) { + await chmod(path.join(agent, "resolve-agent-kit.sh"), 0o755); + if (opts?.withAdapters !== false) { + for (const name of ADAPTERS) { + await chmod(path.join(agent, name), 0o755); + } + } + } + const bin = path.join(root, "node_modules", ".bin"); + await mkdir(bin, { recursive: true }); + await writeFile(path.join(bin, "agent-kit"), "#!/bin/sh\n", "utf8"); + await chmod(path.join(bin, "agent-kit"), 0o755); +} + describe("assessHooksHealth", () => { it("reports missing when hooks.json absent", async () => { const root = await mkdtemp(path.join(tmpdir(), "ak-hooks-")); @@ -13,29 +61,29 @@ describe("assessHooksHealth", () => { it("reports active for full Node adapter wiring", async () => { const root = await mkdtemp(path.join(tmpdir(), "ak-hooks-")); - const agent = path.join(root, ".cursor", "hooks", "agent"); - await mkdir(agent, { recursive: true }); - await writeFile( - path.join(root, ".cursor", "hooks.json"), - JSON.stringify({ - version: 1, - hooks: { - sessionStart: [{ command: ".cursor/hooks/agent/session-start.sh" }], - preCompact: [{ command: ".cursor/hooks/agent/pre-compact.sh" }], - beforeShellExecution: [{ command: ".cursor/hooks/agent/guard-shell.sh" }], - afterFileEdit: [{ command: ".cursor/hooks/agent/after-edit-schema.sh" }], - beforeSubmitPrompt: [{ command: ".cursor/hooks/agent/secrets-prompt.sh" }], - }, - }), - "utf8", - ); - await writeFile(path.join(agent, "resolve-agent-kit.sh"), "#!/bin/sh\n", "utf8"); + await writeWiredHooks(root); const report = await assessHooksHealth(root); expect(report.status).toBe("active"); expect(report.reasons).toEqual([]); }); - it("degrades when stop is present", async () => { + it("degrades when adapters are missing on disk", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ak-hooks-")); + await writeWiredHooks(root, { withAdapters: false }); + const report = await assessHooksHealth(root); + expect(report.status).toBe("degraded"); + expect(report.reasons.some((r) => r.includes("missing adapter"))).toBe(true); + }); + + it("degrades when adapters are not executable", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ak-hooks-")); + await writeWiredHooks(root, { executable: false }); + const report = await assessHooksHealth(root); + expect(report.status).toBe("degraded"); + expect(report.reasons.some((r) => r.includes("not executable"))).toBe(true); + }); + + it("degrades when CLI does not resolve", async () => { const root = await mkdtemp(path.join(tmpdir(), "ak-hooks-")); const agent = path.join(root, ".cursor", "hooks", "agent"); await mkdir(agent, { recursive: true }); @@ -49,12 +97,35 @@ describe("assessHooksHealth", () => { beforeShellExecution: [{ command: ".cursor/hooks/agent/guard-shell.sh" }], afterFileEdit: [{ command: ".cursor/hooks/agent/after-edit-schema.sh" }], beforeSubmitPrompt: [{ command: ".cursor/hooks/agent/secrets-prompt.sh" }], - stop: [{ command: "echo no" }], }, }), "utf8", ); await writeFile(path.join(agent, "resolve-agent-kit.sh"), "#!/bin/sh\n", "utf8"); + await chmod(path.join(agent, "resolve-agent-kit.sh"), 0o755); + for (const name of ADAPTERS) { + await writeFile(path.join(agent, name), "#!/bin/sh\n", "utf8"); + await chmod(path.join(agent, name), 0o755); + } + // No local bin/dist; clear PATH so `which agent-kit` cannot hit the host CLI. + const prevPath = process.env.PATH; + process.env.PATH = "/nonexistent-ak-path"; + try { + const report = await assessHooksHealth(root); + expect(report.status).toBe("degraded"); + expect(report.reasons.some((r) => r.includes("CLI not resolvable"))).toBe(true); + } finally { + process.env.PATH = prevPath; + } + }); + + it("degrades when stop is present", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ak-hooks-")); + await writeWiredHooks(root); + const hooksPath = path.join(root, ".cursor", "hooks.json"); + const parsed = JSON.parse(await readFile(hooksPath, "utf8")); + parsed.hooks.stop = [{ command: "echo no" }]; + await writeFile(hooksPath, JSON.stringify(parsed), "utf8"); const report = await assessHooksHealth(root); expect(report.status).toBe("degraded"); expect(report.reasons.some((r) => r.includes("stop"))).toBe(true); diff --git a/packages/cli/src/invariants/hooks-health.ts b/packages/cli/src/invariants/hooks-health.ts index 1e6f69f..addbf08 100644 --- a/packages/cli/src/invariants/hooks-health.ts +++ b/packages/cli/src/invariants/hooks-health.ts @@ -1,5 +1,9 @@ -import { access, readFile } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { constants, access, readFile, stat } from "node:fs/promises"; import path from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); export type HooksHealthStatus = "active" | "degraded" | "missing"; @@ -28,9 +32,47 @@ async function exists(p: string): Promise<boolean> { } } +async function isExecutable(p: string): Promise<boolean> { + try { + await access(p, constants.X_OK); + return true; + } catch { + return false; + } +} + +/** True when `agent-kit` resolves via PATH, local bin, or dogfood dist. */ +export async function resolveAgentKitCli(rootDir: string): Promise<string | null> { + const root = path.resolve(rootDir); + const candidates = [ + path.join(root, "node_modules", ".bin", "agent-kit"), + path.join(root, "packages", "cli", "dist", "index.js"), + ]; + for (const c of candidates) { + if (await exists(c)) return c; + } + try { + const { stdout } = await execFileAsync("which", ["agent-kit"], { encoding: "utf8" }); + const hit = stdout.trim().split("\n")[0]?.trim(); + if (hit) return hit; + } catch { + /* not on PATH */ + } + return null; +} + +function commandLooksLikeAdapter(command: string): string | null { + const trimmed = command.trim(); + if (!trimmed) return null; + // Prefer `.cursor/hooks/agent/*.sh` paths from hooks.json. + const m = trimmed.match(/(\.cursor\/hooks\/agent\/[A-Za-z0-9._-]+\.sh)\b/); + return m?.[1] ?? null; +} + /** - * Visible fail-open posture: active when hooks.json wires Node adapters; - * degraded when missing CLI resolve path or python leftovers; missing when no hooks.json. + * 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. */ export async function assessHooksHealth(rootDir: string): Promise<HooksHealthReport> { const root = path.resolve(rootDir); @@ -65,6 +107,8 @@ export async function assessHooksHealth(rootDir: string): Promise<HooksHealthRep } const hooks = parsed.hooks ?? {}; + const adapterRels = new Set<string>(); + for (const event of EXPECTED_EVENTS) { const list = hooks[event]; if (Array.isArray(list) && list.length > 0) { @@ -75,6 +119,8 @@ export async function assessHooksHealth(rootDir: string): Promise<HooksHealthRep if (command.endsWith(".py") || command.includes("python")) { reasons.push(`${event} still points at a Python script (${command})`); } + const rel = commandLooksLikeAdapter(command); + if (rel) adapterRels.add(rel); } } else { reasons.push(`missing hook event: ${event}`); @@ -84,6 +130,36 @@ export async function assessHooksHealth(rootDir: string): Promise<HooksHealthRep const resolveLib = path.join(root, ".cursor", "hooks", "agent", "resolve-agent-kit.sh"); if (!(await exists(resolveLib))) { reasons.push("missing `.cursor/hooks/agent/resolve-agent-kit.sh` (thin adapter resolver)"); + } else if (!(await isExecutable(resolveLib))) { + reasons.push("`.cursor/hooks/agent/resolve-agent-kit.sh` is not executable (chmod +x)"); + } + + for (const rel of adapterRels) { + const abs = path.join(root, rel); + if (!(await exists(abs))) { + reasons.push(`missing adapter script: \`${rel}\``); + continue; + } + try { + const st = await stat(abs); + if (!st.isFile()) { + reasons.push(`adapter path is not a file: \`${rel}\``); + continue; + } + } catch { + reasons.push(`unreadable adapter script: \`${rel}\``); + continue; + } + if (!(await isExecutable(abs))) { + reasons.push(`adapter not executable: \`${rel}\` (chmod +x)`); + } + } + + const cli = await resolveAgentKitCli(root); + if (!cli) { + reasons.push( + "agent-kit CLI not resolvable (PATH, node_modules/.bin/agent-kit, or packages/cli/dist)", + ); } // stop must never be registered diff --git a/packages/cli/src/invariants/monitors-untriaged.test.ts b/packages/cli/src/invariants/monitors-untriaged.test.ts index 46f2ed4..f84b5ea 100644 --- a/packages/cli/src/invariants/monitors-untriaged.test.ts +++ b/packages/cli/src/invariants/monitors-untriaged.test.ts @@ -1,9 +1,24 @@ +import { readFileSync } from "node:fs"; import { mkdir, mkdtemp, 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 { TRIAGE_HEADING_RE, selectUntriagedMonitors } from "./monitors-untriaged.js"; +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(HERE, "../../../.."); + +function extractTriageHeadingLiteral(source: string): string { + const match = source.match( + /\/\^#\{2,6\}\\s\+\(\?:Triage note\|Follow-\?up plan\|Residuals plan\)\\b\/im/, + ); + if (!match) { + throw new Error("TRIAGE_HEADING_RE literal not found in source"); + } + return match[0]; +} + describe("selectUntriagedMonitors", () => { it("never picks a triaged monitor when untriaged exist", async () => { const root = await mkdtemp(path.join(tmpdir(), "ak-monitors-")); @@ -26,10 +41,72 @@ describe("selectUntriagedMonitors", () => { expect(result.cite).toContain("never newest-mtime-wins"); }); - it("TRIAGE_HEADING_RE matches durable triage headings", () => { + it("TRIAGE_HEADING_RE matches durable triage headings only", () => { expect(TRIAGE_HEADING_RE.test("## Triage note - ack")).toBe(true); expect(TRIAGE_HEADING_RE.test("## Follow-up plan - x.plan.md")).toBe(true); expect(TRIAGE_HEADING_RE.test("## Residuals plan")).toBe(true); + expect(TRIAGE_HEADING_RE.test("### Triage note")).toBe(true); expect(TRIAGE_HEADING_RE.test("## Current state")).toBe(false); + // Tick headings that name triage-* to-do ids must stay untriaged + expect(TRIAGE_HEADING_RE.test("## Tick 1 — `adr-quiet-triage-surface` (Phase 0)")).toBe(false); + expect(TRIAGE_HEADING_RE.test("## Tick 2 (reconstructed) — `l0-triage-step5a` (Phase 1)")).toBe( + false, + ); + expect(TRIAGE_HEADING_RE.test("## Closed by residuals plan")).toBe(false); + }); + + it("keeps CLI TRIAGE_HEADING_RE identical to dashboard SoT", () => { + const cliSrc = readFileSync(path.join(HERE, "triage-heading.ts"), "utf8"); + const dashSrc = readFileSync(path.join(REPO_ROOT, "dashboard/lib/triage-heading.mjs"), "utf8"); + expect(extractTriageHeadingLiteral(cliSrc)).toBe(extractTriageHeadingLiteral(dashSrc)); + }); + + it("keeps monitors with triage-* tick headings in the untriaged set", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ak-monitors-tick-")); + const mem = path.join(root, ".cursor", "memory"); + await mkdir(mem, { recursive: true }); + await writeFile( + path.join(mem, "plan-monitor-quiet-open-triages.md"), + [ + "# Monitor log — flight-log-quiet-open-triages", + "", + "## Tick 1 — `adr-quiet-triage-surface` (Phase 0)", + "", + "### Still open (only these)", + "", + "| ID | What |", + "|----|------|", + "| A | Gate fingerprint |", + "", + ].join("\n"), + "utf8", + ); + await writeFile( + path.join(mem, "plan-monitor-triage-residuals.md"), + [ + "# Monitor log — triage-residuals", + "", + "## Tick 2 (reconstructed) — `l0-triage-step5a` (Phase 1)", + "", + "### Still open (only these)", + "", + "1. Pin Broad Intake", + "", + ].join("\n"), + "utf8", + ); + await writeFile( + path.join(mem, "plan-monitor-already-acked.md"), + "## Triage note - Ack and stop\n\nNo open residuals.\n", + "utf8", + ); + await writeFile(path.join(root, ".cursor", "HANDOFF.md"), "- **Plan:** none\n", "utf8"); + + const result = await selectUntriagedMonitors(root); + const names = result.monitors.map((m) => path.basename(m.path)).sort(); + expect(names).toEqual([ + "plan-monitor-quiet-open-triages.md", + "plan-monitor-triage-residuals.md", + ]); }); }); diff --git a/packages/cli/src/invariants/monitors-untriaged.ts b/packages/cli/src/invariants/monitors-untriaged.ts index e92277d..19a06aa 100644 --- a/packages/cli/src/invariants/monitors-untriaged.ts +++ b/packages/cli/src/invariants/monitors-untriaged.ts @@ -2,11 +2,11 @@ import { execFile } from "node:child_process"; import { readFile, readdir, stat } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; +import { TRIAGE_HEADING_RE } from "./triage-heading.js"; const execFileAsync = promisify(execFile); -/** Same spirit as dashboard TRIAGE_HEADING_RE (untriaged = no triage heading). */ -export const TRIAGE_HEADING_RE = /^#{2,6}\s+.*\b(triage|follow-?up plan|residuals plan)\b/im; +export { TRIAGE_HEADING_RE }; export interface MonitorEntry { path: string; diff --git a/packages/cli/src/invariants/secrets-scan.test.ts b/packages/cli/src/invariants/secrets-scan.test.ts index 6c9de99..660a8b6 100644 --- a/packages/cli/src/invariants/secrets-scan.test.ts +++ b/packages/cli/src/invariants/secrets-scan.test.ts @@ -19,4 +19,23 @@ describe("scanTextForSecrets", () => { const hits = scanTextForSecrets(sample); expect(hits.some((h) => h.patternId === "github-pat")).toBe(true); }); + + it("masks secret material in excerpts", () => { + const sample = `deploy with API_KEY=${"sk"}_${"live"}_abcdefghijklmnop`; + const hits = scanTextForSecrets(sample); + const hit = hits.find((h) => h.patternId === "env-assignment"); + expect(hit).toBeTruthy(); + expect(hit?.excerpt).not.toMatch(/abcdefghijklmnop/); + expect(hit?.excerpt).toContain("*"); + }); + + it("masks json-secret-kv values in excerpts", () => { + const sample = 'config: {"apiKey": "A1b2C3d4E5f6G7h8J9k0"} end'; + const hits = scanTextForSecrets(sample); + const hit = hits.find((h) => h.patternId === "json-secret-kv"); + expect(hit).toBeTruthy(); + expect(hit?.excerpt).not.toMatch(/A1b2C3d4E5f6G7h8J9k0/); + expect(hit?.excerpt).toContain("*"); + expect(hit?.excerpt).toMatch(/apiKey"\s*:\s*"\*+/); + }); }); diff --git a/packages/cli/src/invariants/secrets-scan.ts b/packages/cli/src/invariants/secrets-scan.ts index 88076ba..ef097c9 100644 --- a/packages/cli/src/invariants/secrets-scan.ts +++ b/packages/cli/src/invariants/secrets-scan.ts @@ -31,10 +31,25 @@ const SECRET_PATTERNS: Array<{ id: string; re: RegExp }> = [ }, ]; +function maskSecretExcerpt(raw: string): string { + return raw + .replace(/\b(ghp_|sk-|AKIA)([A-Za-z0-9_]{4,})/g, (_m, p1: string, p2: string) => { + return `${p1}${"*".repeat(Math.min(8, p2.length))}`; + }) + .replace( + /(=\s*['"]?)([^\s'"]{4,})/g, + (_m, p1: string, p2: string) => `${p1}${"*".repeat(Math.min(8, p2.length))}`, + ) + .replace( + /("(?:password|apiKey|api_key|secret|token|auth)"\s*:\s*")([^"]{4,})(")/gi, + (_m, p1: string, p2: string, p3: string) => `${p1}${"*".repeat(Math.min(8, p2.length))}${p3}`, + ); +} + function excerptAround(text: string, index: number, len: number): string { const start = Math.max(0, index - 8); const end = Math.min(text.length, index + len + 8); - return text.slice(start, end).replace(/\s+/g, " "); + return maskSecretExcerpt(text.slice(start, end).replace(/\s+/g, " ")); } export function scanTextForSecrets(text: string): SecretHit[] { diff --git a/packages/cli/src/invariants/shell-guard.test.ts b/packages/cli/src/invariants/shell-guard.test.ts index 3543fea..09f796c 100644 --- a/packages/cli/src/invariants/shell-guard.test.ts +++ b/packages/cli/src/invariants/shell-guard.test.ts @@ -13,6 +13,11 @@ describe("evaluateShellCommand", () => { expect(r.agent_message).toContain("agent-kit guard shell"); }); + it("denies git checkout HEAD -- and git checkout .", () => { + expect(evaluateShellCommand("git checkout HEAD -- src/a.ts").rule).toBe("git-checkout-path"); + expect(evaluateShellCommand("git checkout .").rule).toBe("git-checkout-path"); + }); + it("denies git restore", () => { expect(evaluateShellCommand("git restore README.md").rule).toBe("git-restore"); }); @@ -30,6 +35,28 @@ describe("evaluateShellCommand", () => { expect(evaluateShellCommand("git push origin HEAD:main").rule).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"); + expect(evaluateShellCommand("git push origin +refs/heads/main").rule).toBe("git-push-main"); + }); + + it("denies quoted push refspecs to main", () => { + expect(evaluateShellCommand("git push origin 'main'").rule).toBe("git-push-main"); + expect(evaluateShellCommand('git push origin "+main"').rule).toBe("git-push-main"); + }); + + it("denies bare push / force HEAD when current branch is protected", () => { + expect(evaluateShellCommand("git push", { currentBranch: "main" }).rule).toBe("git-push-main"); + expect( + evaluateShellCommand("git push --force origin HEAD", { currentBranch: "main" }).rule, + ).toBe("git-push-main"); + }); + + it("allows bare push when current branch is staging", () => { + expect(evaluateShellCommand("git push", { currentBranch: "staging" }).permission).toBe("allow"); + }); + it("allows push to staging", () => { expect(evaluateShellCommand("git push origin staging").permission).toBe("allow"); }); @@ -46,4 +73,19 @@ describe("evaluateShellCommand", () => { "git-checkout-path", ); }); + + it("exports SHELL_DENY_RULES covering the named deny ids", () => { + const ids = SHELL_DENY_RULES.map((r) => r.id); + expect(ids).toEqual([ + "git-checkout-path", + "git-restore", + "git-reset-hard", + "git-clean-fd", + "git-push-main", + ]); + for (const rule of SHELL_DENY_RULES) { + expect(typeof rule.test).toBe("function"); + expect(rule.description.length).toBeGreaterThan(0); + } + }); }); diff --git a/packages/cli/src/invariants/shell-guard.ts b/packages/cli/src/invariants/shell-guard.ts index d3e1210..882d5f8 100644 --- a/packages/cli/src/invariants/shell-guard.ts +++ b/packages/cli/src/invariants/shell-guard.ts @@ -12,8 +12,15 @@ export interface ShellGuardResult { rule?: string; } +export interface ShellGuardOptions { + /** Current git branch (abbrev-ref). Used for bare / HEAD pushes. */ + currentBranch?: string | null; +} + const CITE = "agent-kit guard shell (ADR 2026-07-29_cli-invariants-thin-hook-adapters)"; +const PROTECTED_BRANCH_RE = /^(?:main|master|prod)$/; + /** Collapse whitespace / newlines so multiline shell payloads still match. */ export function normalizeShellCommand(command: string): string { return command.replace(/\s+/g, " ").trim(); @@ -36,16 +43,93 @@ function anyHeadMatches(command: string, re: RegExp): boolean { return shellInvocationHeads(command).some((head) => re.test(head)); } +function isProtectedBranch(name: string | null | undefined): boolean { + return typeof name === "string" && PROTECTED_BRANCH_RE.test(name.trim()); +} + +/** + * 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. + */ +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(); + } + 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); + return t; +} + +/** Protected push destination after refspec normalization (+ / refs/heads/). */ +function pushHeadHasProtectedDest(head: string): boolean { + if (/HEAD:(?:refs\/heads\/)?(?:main|master|prod)\b/.test(head)) return true; + if (/(?:^|\s)-(?:u|--set-upstream)\s+\S+\s+(?:main|master|prod)(?:\s|$)/.test(head)) { + return true; + } + const after = head.replace(/^(?:[\w./-]+\/)?git\s+push\b/, ""); + for (const raw of after.split(/\s+/).filter(Boolean)) { + if (raw.startsWith("-")) continue; + const dest = raw.includes(":") ? raw.slice(raw.lastIndexOf(":") + 1) : raw; + if (PROTECTED_BRANCH_RE.test(normalizePushRefspecToken(dest))) return true; + } + return false; +} + +/** True when push has no explicit non-protected branch destination. */ +function isBareOrHeadPushToCurrent(head: string): boolean { + if (!/^(?:[\w./-]+\/)?git\s+push\b/.test(head)) return false; + // Explicit protected destination already handled by git-push-main. + if (pushHeadHasProtectedDest(head)) { + return false; + } + // Explicit safe branch → allow + if ( + /(?:^|\s)\+?(?:refs\/heads\/)?(?:origin\/)?(?:staging|develop|homologacao)(?:\s|$|:)/.test( + head, + ) || + /HEAD:(?:refs\/heads\/)?(?!main|master|prod)[A-Za-z0-9._/-]+/.test(head) + ) { + return false; + } + const after = head.replace(/^(?:[\w./-]+\/)?git\s+push\b/, "").trim(); + const withoutFlags = after + .replace(/(?:^|\s)(?:--force|-f|-u|--set-upstream|--tags|--all|--prune)(?=\s|$)/g, " ") + .replace(/(?:^|\s)--\w[\w-]*(?:=\S+)?/g, " ") + .replace(/\s+/g, " ") + .trim(); + if (!withoutFlags) return true; // bare git push / git push --force + const tokens = withoutFlags.split(/\s+/); + // remote only (origin) or remote + HEAD + if (tokens.length === 1) return true; + if (tokens.length >= 2 && tokens[1] === "HEAD") return true; + if (/\bHEAD\b/.test(withoutFlags) && !/HEAD:/.test(withoutFlags)) return true; + return false; +} + /** Ordered deny rules: first match wins. Exported for vitest parity with hooks. */ export const SHELL_DENY_RULES: Array<{ id: string; description: string; - test: (command: string) => boolean; + test: (command: string, opts?: ShellGuardOptions) => boolean; }> = [ { id: "git-checkout-path", - description: "git checkout -- <paths> discards working-tree edits", - test: (cmd) => anyHeadMatches(cmd, /^(?:[\w./-]+\/)?git\s+checkout\s+--(?:\s|$)/), + description: "git checkout -- / HEAD -- / . discards working-tree edits", + test: (cmd) => + shellInvocationHeads(cmd).some((head) => { + if (!/^(?:[\w./-]+\/)?git\s+checkout\b/.test(head)) return false; + // git checkout -- <paths> OR git checkout <ref> -- <paths> + if (/\s--(?:\s|$)/.test(head)) return true; + // git checkout . + if (/\scheckout\s+\.(?:\s|$)/.test(head)) return true; + return false; + }), }, { id: "git-restore", @@ -70,25 +154,31 @@ export const SHELL_DENY_RULES: Array<{ { id: "git-push-main", description: "direct push to main/master/prod bypasses staging", - test: (cmd) => + test: (cmd, opts) => shellInvocationHeads(cmd).some((head) => { if (!/^(?:[\w./-]+\/)?git\s+push\b/.test(head)) return false; - return ( - /(?:^|\s)(?:origin\/)?(?:main|master|prod)(?:\s|$|:)/.test(head) || - /HEAD:(?:refs\/heads\/)?(?:main|master|prod)\b/.test(head) || - /(?:^|\s)-(?:u|--set-upstream)\s+\S+\s+(?:main|master|prod)(?:\s|$)/.test(head) - ); + if (pushHeadHasProtectedDest(head)) { + return true; + } + // Bare / HEAD push while checked out on a protected branch + if (isProtectedBranch(opts?.currentBranch) && isBareOrHeadPushToCurrent(head)) { + return true; + } + return false; }), }, ]; -export function evaluateShellCommand(command: string): ShellGuardResult { +export function evaluateShellCommand( + command: string, + opts: ShellGuardOptions = {}, +): ShellGuardResult { const normalized = normalizeShellCommand(command); if (!normalized) { return { permission: "allow" }; } for (const rule of SHELL_DENY_RULES) { - if (rule.test(normalized)) { + if (rule.test(normalized, opts)) { const agent_message = `Denied by ${CITE}: ${rule.description} (rule \`${rule.id}\`). Use /git-staging; never discard human hunks or push protected branches from the agent.`; return { permission: "deny", diff --git a/packages/cli/src/invariants/triage-heading.ts b/packages/cli/src/invariants/triage-heading.ts new file mode 100644 index 0000000..c959f01 --- /dev/null +++ b/packages/cli/src/invariants/triage-heading.ts @@ -0,0 +1,13 @@ +/** + * Durable triage headings from `/plan-review-triage` (L0). + * + * Keep in sync with `dashboard/lib/triage-heading.mjs` (SoT). + * `monitors-untriaged.test.ts` asserts identical RegExp source/flags. + * + * Match ONLY these titles, not tick headings that name a `triage-*` to-do id. + */ +export const TRIAGE_HEADING_RE = /^#{2,6}\s+(?:Triage note|Follow-?up plan|Residuals plan)\b/im; + +export function hasTriageHeading(text: unknown): boolean { + return TRIAGE_HEADING_RE.test(String(text ?? "")); +}