diff --git a/.agents/sessions/harness-ui-overhaul-2026-08/PLAN.md b/.agents/sessions/harness-ui-overhaul-2026-08/PLAN.md new file mode 100644 index 0000000000..c6306d3bb3 --- /dev/null +++ b/.agents/sessions/harness-ui-overhaul-2026-08/PLAN.md @@ -0,0 +1,72 @@ +# PLAN — Harness UI Overhaul (Full Sweep) + +Slug: `harness-ui-overhaul-2026-08` +Snapshot: `399c28986835a71e7ee7b45b6dcaf9bf2b9f8ef85181a443bdcfaeebbe6a137c` +Source: SPEC.md (this session), prior harness inventory (399c… snapshot, 22 subsystems) +Current workspace: `feat/task-memory-evidence-pipeline`, clean worktree. + +## Milestones + +### M0 — Session bootstrap (done) +- SPEC.md landed. This PLAN.md + STATUS.md land this turn. Gate clears on session artifacts only (no src/ changes this turn). + +### M1 — Primitive + Completion summary (P1, vertical slice 1) +Goal: one visual language — no more `✅ 3 files edited | ❌ Hooks: 1 failed` pipe-joined text line. + +- [ ] M1-T1 Extract `cli/src/components/renderers/harness-box.tsx` (`HarnessBox`, `HarnessSection`, `HarnessRow`): `borderStyle: single` + `BORDER_CHARS` + `paddingLeft/Right 1 + gap`, theme token prop (`secondary` default, `success/error/warning` for status). Adopt in `PlanBox` + `GateStateBox` (no visual regression). +- [ ] M1-T2 Extend `cli/src/types/chat.ts`: `CompletionSummaryContentBlock { type:'completion-summary', summary: CompletionSummary }` + guard `isCompletionSummaryBlock`. Extend `ContentBlock` union. +- [ ] M1-T3 New renderer `cli/src/components/renderers/completion-summary-box.tsx`: sections Files / Hooks / Review / Tests / Auxiliary / Errors, icon+color mapping (`STATUS_ICON` style: `✓ ✗ ⚠` or reuse emoji but themed), bordered via `HarnessBox`. Empty → null (honor `computeCompletionSummary` null). +- [ ] M1-T4 Wire `cli/src/utils/sdk-event-handlers.ts:handleFinish`: emit typed block instead of `type:'text'` with `formatCompletionSummary(summary)`. Keep `formatCompletionSummary` export for logs/tests. +- [ ] M1-T5 Route in `cli/src/components/blocks/single-block.tsx` (`case 'completion-summary'` → `CompletionSummaryBox`), thread `availableWidth`/`markdownPalette`/`onInsertCommand` as needed (no new props needed, but keep parity with plan/gate). +- [ ] M1-T6 Tests: extend `cli/src/utils/__tests__/completion-summary.test.ts` (data unchanged), add `cli/src/components/__tests__/completion-summary-box.test.tsx` (dark/light, null/empty, mixed verdict), `cli/src/utils/__tests__/sdk-event-handlers.test.ts` for handleFinish block type. +- Validate: `bun --cwd cli run typecheck`, `bun --cwd cli test` (targeted), `tmux-cli` smoke: stream → completion box renders. + +### M2 — Memory interactive box (P1, vertical slice 2) +- [ ] M2-T1 Extend `chat.ts`: `MemoryContentBlock { type:'memory', status:'empty'|'status'|'prune-result', revision?, updatedAt?, goal?, counts?, evidence:{live,stale,total}, stalePaths?, hint?, pruneOutcome? }` or reuse raw `TaskMemoryV1` + reconciled evidence; decide in slice kickoff (prefer minimal view-model to keep renderer pure). +- [ ] M2-T2 New renderer `cli/src/components/renderers/memory-box.tsx`: header `revision · age` (`formatAge`), goal preview 120 chars with expand, counts grid, evidence badge (`fresh/stale` colored), collapsible `Stale paths (5)` via `CollapseButton`/`Button`, empty state copy ("written after your first successful run"), error banner for prune failure reasons. +- [ ] M2-T3 Refactor `cli/src/commands/memory-command.ts`: `handleMemoryCommand` returns `{ blocks: ContentBlock[] }` or typed message payload instead of `string`; keep `string` fallback export for `handleMemoryCommand` tests via wrapper. Preserve `WorkspaceJournalService.create → collectWorkspaceMoves` move-rebinding for both status & prune. +- [ ] M2-T4 Wire `cli/src/commands/command-registry.ts` memory handler: `getSystemMessage(string)` → typed memory block insertion (mirror `appendLocalMessage` but block-aware; add `appendLocalBlocks` helper if needed, keep `appendLocalMessage` for other commands until M3). +- [ ] M2-T5 Interactions: `Button` "Prune stale evidence" → `onInsertCommand('/memory prune')`, hover `borderColor theme.foreground`, `DASHED_BORDER` not used (harness = solid). +- [ ] M2-T6 Tests: update `cli/src/commands/__tests__/memory-command.test.ts` (block shape), add `memory-box.test.tsx`, add move-aware prune integration test (rename fixture → stale rebounds, not deleted). +- Validate: `bun --cwd cli test` memory + command-registry, tmux `/memory` → box, `/memory prune` flow. + +### M3 — Sweep remaining plain-text reports (P2) +Each command gets minimal typed block + box, reusing `HarnessBox`. + +- [ ] M3-T1 `context` (`cli/src/commands/context.ts`): `context` block + `ContextBox` (ledger breakdown, trigger/target budgets). +- [ ] M3-T2 `info` (`cli/src/commands/info.ts`): `info` block + `InfoBox`. +- [ ] M3-T3 `doctor` (`formatOpenbuffProviderStatus` + diagnostics): `doctor` block + `DoctorBox` (split provider status vs diagnostics sections). +- [ ] M3-T4 `index` (`cli/src/commands/index-command.ts`): `index-status` block + `IndexStatusBox`. +- [ ] M3-T5 `plan-status` + `plans` (`formatPlanStatusReport`/`formatPlanListReport`): `plan-status-list` block + `PlanStatusBox` (preserve `STATUS_BADGE` `[active]/[paused]/…`, `progress done/total`, `currentTask`). +- [ ] M3-T6 `help` audit: if it bypasses box system, migrate; otherwise mark out-of-scope with reason in STATUS matrix. +- [ ] M3-T7 Registry wiring: replace `getSystemMessage(string)` calls for each with block helper; keep `appendLocalMessage(string)` deprecated path until all migrated, then remove or keep shim for skills. +- Validate: per-command tests + one combined `command-registry` sweep test; tmux checklist `/context /info /doctor /index /plan-status /plans`. + +### M4 — Hardening + docs +- [ ] M4-T1 OpenTUI safety audit: every box wraps markdown/`span` in ``, no `{' '}` whitespace, `minWidth:0` on flex cols, resize 1→2 col not collapsing. Add to `cli/knowledge.md` "HarnessBox" note. +- [ ] M4-T2 Theme parity: `dark` + `light` via `useTheme()`; `messageTextAttributes` preserved. +- [ ] M4-T3 Update `docs/architecture.md` (CLI TUI section) pending-gate box inventory. +- [ ] M4-T4 `LESSONS.md` capture: string→typed-block migration pattern for future harness surfaces. + +## Dependencies +M1 primitive extraction before M1-T3/M2-T2/M3 boxes. M1-T2 (chat types) before any box. M2-T3 (memory command contract) before registry wiring. M3 can parallelize T1-T5 after harness-box lands. + +## Risks +- `CompletionSummaryBox` icon drift from `formatCompletionSummary` emoji → mitigate: keep string formatter for logs, box uses theme tokens (not emoji parsing). +- `handleFinish` text→block change breaks `sdk-event-handlers.test.ts` snapshots → update snapshots, keep null-path. +- `memory-command` string→blocks breaks existing tests expecting `string` → keep backward-compat wrapper or update test to assert block shape. +- Move-rebinding regression (rename → stale → prune deletes) → explicit test with `WorkspaceJournalService` mock moves. +- OpenTUI reconciler fragility (`` nesting) → reuse `PlanBox`/`GateStateBox` patterns verbatim. + +## Validation gates +- `bun --cwd cli run typecheck` +- `bun --cwd cli test` (or `bun test cli/src/utils/__tests__/completion-summary.test.ts cli/src/utils/__tests__/sdk-event-handlers.test.ts cli/src/commands/__tests__/memory-command.test.ts`) +- `tmux-cli` (via `tmux-cli` agent): streaming → completion box, `/memory`, `/memory prune` (move-aware), `/context /info /doctor /index /plan-status /plans` sweep. +- No new `common/` contract beyond `chat.ts` ContentBlock extension; no `sdk/` provider change. + +## Out of scope +- `read-edit` / `shell-policy` surfaces (separate sessions). +- `common/src/templates` agent examples. + +## Resume pointer +Next concrete step after gate: `M1-T1 + M1-T2` (harness-box + chat types) — single editor slice, no validation bypass. diff --git a/.agents/sessions/harness-ui-overhaul-2026-08/SPEC.md b/.agents/sessions/harness-ui-overhaul-2026-08/SPEC.md new file mode 100644 index 0000000000..75241181bd --- /dev/null +++ b/.agents/sessions/harness-ui-overhaul-2026-08/SPEC.md @@ -0,0 +1,62 @@ +# SPEC — Harness UI Overhaul (Full Sweep) + +## Goal +Unify the Openbuff harness into one visual language. Every system surface that currently emits a plain `string → getSystemMessage → ` must land on the same bordered, themed, OpenTUI-safe renderer system that `PlanBox` / `GateStateBox` / `AgentBranchWrapper` already use. No more split between premium interactive chrome and `console.log`-style dumps for memory or session summaries. + +## Non-goals +- No redesign of already-mature surfaces (`PlanBox`, `GateStateBox`, `AgentBranchWrapper`/`AgentBlockGrid`/`ImplementorRow`/`DiffViewer`, `StatusBar`) beyond extracting a shared primitive they adopt. +- No new backend or provider APIs; no agent-runtime prompt contract change (`task_completed` stays empty). +- No global theming overhaul; reuse existing `ChatTheme` + `BORDER_CHARS` tokens. +- No migration of log/debug artifacts outside `cli/src`. + +## Requirements + +### R1 — Completion summary becomes a first-class box (P1) +- Data source stays `computeCompletionSummary(blocks)` (`cli/src/utils/completion-summary.ts`). +- New typed block `type: 'completion-summary'` + renderer `CompletionSummaryBox` replaces the `formatCompletionSummary()` → `type:'text'` injection in `cli/src/utils/sdk-event-handlers.ts:handleFinish`. +- Sections: Files (edited/failed/unconfirmed/rolled_back/rollback_incomplete), Hooks (passed/failed/skipped), Review verdict (`BLOCKING/NON_BLOCKING/LOOKS_GOOD/…` → `error/warning/success`), Tests, Auxiliary, Errors. Status → border color + icon mapping mirrors `GateStateBox` (success/warning/error). +- `formatCompletionSummary()` retained for logs/fallback; renderer is the TUI source of truth. + +### R2 — Memory becomes interactive and on-system (P1) +- Data source stays `sdk/task-memory-store` + `reconcileTaskMemoryEvidence`/`pruneStale…` via `WorkspaceJournalService`. +- New typed block `type: 'memory'` + `MemoryBox` replaces `string` return from `cli/src/commands/memory-command.ts` (`handleMemoryCommand` → `runStatus`/`runPrune`). +- Layout: header `revision · age` (via `formatAge`), goal preview (120 chars, expand affordance if truncated), counts grid (Decisions · Requirements · Edits / Validations · Blockers · Next actions), evidence `fresh/stale` with colored badge, collapsible `Stale paths (5)` list, empty/no-record state that explains when memory is written. +- Prune affordance: clickable `Button` wired to `onInsertCommand('/memory prune')` (same pattern as `PlanBox` command pills), hover border `theme.secondary → theme.foreground`. Prune failure reasons surfaced verbatim (invalid-record / concurrent-write / write-failed) with no phrasing as "nothing to prune". +- Workspace-move rebinding contract preserved: both status and prune pass `WorkspaceMoveRecord[]` from `WorkspaceJournalService.create`. + +### R3 — Sweep remaining plain-text reports (P2) +Convert every `getSystemMessage(string)` report in `cli/src/commands/command-registry.ts` and helpers to typed blocks/boxes sharing the same primitive: +- `/context` (`cli/src/commands/context.ts` — ledger breakdown) +- `/info` (`cli/src/commands/info.ts`) +- `/doctor` (`formatOpenbuffProviderStatus` + agent diagnostics) +- `/index` (`cli/src/commands/index-command.ts`) +- `/plan-status` + `/plans` (`formatPlanStatusReport`/`formatPlanListReport` — retain `STATUS_BADGE`/`progress done/total` + `currentTask` semantics) +- `/help` if it still bypasses the box system; otherwise leave its existing structured screen. +Each gets a minimal typed block (e.g. `context`, `info`, `doctor`, `index-status`, `plan-status-list`) and a `*Box` renderer. No one-off inline styles. + +### R4 — Shared harness chrome primitive (P2, extracted alongside R1/R2) +- Extract `HarnessBox` (and `HarnessSection`/`HarnessRow` helpers) that codifies the common pattern: `borderStyle:'single' + BORDER_CHARS + theme token + paddingLeft/right 1 + gap`. Adopted by `PlanBox`, `GateStateBox`, `CompletionSummaryBox`, `MemoryBox`, and the sweep boxes. One change propagates. +- Tokens: `DASHED_BORDER_CHARS` / `IMPLEMENTOR_BORDER_CHARS` remain reserved for ghost/implementor contexts; harness uses rounded `BORDER_CHARS`. + +### R5 — Wiring and single-point injection +- `sdk-event-handlers.ts:handleFinish` and `command-registry.ts` (`appendLocalMessage` path) are the only UI injection seams. Change is additive (new block types) not string-format surgery. + +## Acceptance criteria +- AC1: A completed run with edits+hooks+review renders a bordered `CompletionSummaryBox` (not a `| -joined` text line); empty runs produce no box (`computeCompletionSummary` null path unchanged). +- AC2: `/memory` (no record) shows the two-line "not yet written" empty state inside a box; `/memory` with record shows revision/age/goal/counts/evidence + stale-list affordance; `/memory prune` with stale entries shows the button and correctly rebinds renamed-file evidence (move-aware). +- AC3: `/context` `/info` `/doctor` `/index` `/plan-status` `/plans` each render as bordered boxes with themed headings (no raw `lines.join('\n')` text blocks). +- AC4: `SingleBlock` routes all new block types; `chat.ts`→`MessageBlock`→`BlocksRenderer` threading of `onInsertCommand`/`markdownPalette`/`availableWidth` matches `PlanBox` precedent. +- AC5: OpenTUI-safe: every markdown / `` / `` fragment is wrapped in ``; no `{' '}` JSX whitespace; no box-inside-text violations; resize (1→2 column) does not collapse `minWidth`. +- AC6: Theme-correct in `dark` and `light` (`useTheme()` tokens, border `success/error/warning/secondary` mapping, `TextAttributes.DIM/BOLD` where appropriate). +- AC7: Backward-compatible: `formatCompletionSummary` and `formatAge`/`pluralizeEntries` remain exported for logs/tests; block types extend the `ContentBlock` union, never retype existing `text`/`tool`/`agent` fields. +- AC8: Tests: `completion-summary.test.ts` (data), new `completion-summary-box.test.tsx`/`memory-box.test.tsx` (render), `command-registry` integration for move-aware prune, plus one `tmux-cli` smoke for streaming→completion and `/memory` flow. + +## Relevant systems +- `cli/src/types/chat.ts` — `ContentBlock` union, `GateStateStatus`, `PlanArtifactMetadata`. +- `cli/src/types/theme-system.ts` + `cli/src/utils/ui-constants.ts` + `cli/src/hooks/use-theme.tsx` — `ChatTheme`, `BORDER_CHARS`. +- `cli/src/utils/completion-summary.ts` + `cli/src/utils/sdk-event-handlers.ts` (finish seam) + `cli/src/utils/message-block-helpers.ts`. +- `cli/src/commands/memory-command.ts` + `sdk/src/services/task-memory-store.ts` + `common/src/types/task-memory.ts`. +- `cli/src/commands/command-registry.ts` + `cli/src/commands/context.ts`/`info.ts`/`index-command.ts`/`plan-artifacts.ts`. +- `cli/src/components/renderers/{plan-box,gate-state-box}.tsx` + `cli/src/components/blocks/single-block.tsx` + `cli/src/components/message-block.tsx` + `cli/src/utils/markdown-renderer.tsx`. +- `cli/knowledge.md` — autoCollapse, toggle, suggestion-menu, streaming markdown constraints. +- Validation: `cli` Vitest/Bun + `tmux-cli` / `scripts/tmux/tmux-cli.sh`. diff --git a/.agents/sessions/harness-ui-overhaul-2026-08/STATUS.md b/.agents/sessions/harness-ui-overhaul-2026-08/STATUS.md new file mode 100644 index 0000000000..321892e5a8 --- /dev/null +++ b/.agents/sessions/harness-ui-overhaul-2026-08/STATUS.md @@ -0,0 +1,49 @@ +# STATUS — Harness UI Overhaul (Full Sweep) + +Slug: `harness-ui-overhaul-2026-08` +Snapshot: `399c28986835a71e7ee7b45b6dcaf9bf2b9f8ef85181a443bdcfaeebbe6a137c` +Branch: `feat/task-memory-evidence-pipeline` (clean) + +## Current state +- **Phase:** Plan complete — SPEC.md + PLAN.md landed and under gate review. Ready for `M1` execution. +- **Mode:** PLAN (no `cli/src` edits this turn; gate is artifacts-only) + +## Milestone tracker +- M0 Session bootstrap — done ✅ (SPEC.md, PLAN.md) +- M1 Primitive + Completion summary (P1) — pending + - M1-T1 `harness-box.tsx` + adopt in PlanBox/GateStateBox + - M1-T2 `chat.ts` `completion-summary` block + guard + - M1-T3 `completion-summary-box.tsx` + - M1-T4 `sdk-event-handlers.ts:handleFinish` wired + - M1-T5 `single-block.tsx` routing + - M1-T6 tests + tmux smoke +- M2 Memory interactive box (P1) — pending +- M3 Sweep remaining reports (P2) — pending +- M4 Hardening + docs — pending + +## Coverage matrix (plan scope) +| Domain | Shard / File | Covered | +|--------|--------------|---------| +| cli TUI renderers | `cli/src/components/renderers/*` | yes — SPEC R4, M1 | +| cli chat types | `cli/src/types/chat.ts` | yes — R1/R2, M1-T2/M2-T1 | +| cli completion summary | `cli/src/utils/completion-summary.ts` | yes — R1, M1 | +| cli sdk-event-handlers (finish seam) | `cli/src/utils/sdk-event-handlers.ts` | yes — R5, M1-T4 | +| cli memory command | `cli/src/commands/memory-command.ts` | yes — R2, M2 | +| cli command registry + helpers | `cli/src/commands/command-registry.ts` + `context.ts`/`info.ts`/`index-command.ts`/`plan-artifacts.ts` | yes — R3, M3 | +| cli blocks routing | `cli/src/components/blocks/single-block.tsx` | yes — M1-T5/M2 | +| sdk task memory store | `sdk/src/services/task-memory-store.ts` | yes — R2 (data source) | +| common task-memory types | `common/src/types/task-memory.ts` | yes — R2 | +| theme/tokens | `cli/src/types/theme-system.ts` + `ui-constants.ts` + `hooks/use-theme.tsx` | yes — R4 | + +Domains explicitly out-of-scope for this plan: `sdk/provider`, `agent-runtime` prompts, `packages/indexer`, `common/templates`. + +## Validation gates (next) +- `bun --cwd cli run typecheck` +- `bun --cwd cli test` (completion-summary, sdk-event-handlers, memory-command, command-registry) +- `tmux-cli` smoke: streaming→completion box, `/memory` + `/memory prune` (move-aware), `/context /info /doctor /index /plan-status /plans` + +## Resume instructions +1. Read `SPEC.md` + `PLAN.md` in this session dir. +2. Start at `M1-T1 + M1-T2` (harness-box + chat types) — single editor slice. +3. Keep `formatCompletionSummary`/`formatAge` exported for logs/tests (AC7). +4. Preserve move-rebinding: `WorkspaceJournalService.create → collectWorkspaceMoves` in memory status & prune. diff --git a/.agents/sessions/review-gate-correctness/LESSONS.md b/.agents/sessions/review-gate-correctness/LESSONS.md new file mode 100644 index 0000000000..3f83dd59e3 --- /dev/null +++ b/.agents/sessions/review-gate-correctness/LESSONS.md @@ -0,0 +1,100 @@ +# Lessons — review-gate correctness & convergence + +Companion to `PLAN.md` (design) and `STATUS.md` (progress). This file holds decisions and gotchas that outlived the slice that produced them. + +## Decision record: T1.4d — embedder guide fallback + +**Status: implemented** as the hybrid below, with recovery keyed PER POINTER. `common/src/util/guides.ts` owns the guide→body tables and detection; one `ON_DEMAND_GUIDE_FALLBACK_` placeholder per relocated guide in `packages/agent-runtime/src/templates/{types,strings}.ts` is the provider surface; base2 appends, after its pointers, exactly the placeholders whose pointers that mode actually emitted. The record is kept in full because the two rejected framings are the ones a future reader will reach for first. + +Source: `architect` specialist, run against the working tree at `v3:4a19a075615be`. `PLAN.md` framed T1.4d as a choice between (A) gate the disclosure default to workspaces containing `agents/guides/`, (B) have `disclose()` emit the pointer plus an inline section copy when the guide is unreachable, or (C) keep the compact degrade clause only. **Both A and B are wrong as framed, and the defect is more severe than "open follow-up" implied.** + +### The defect is worse than described + +`PLAN.md` says the pointer read "fails" in an embedder workspace. The stronger finding: the advertised read contract is **unsatisfiable by construction**, and no published artifact ships the guides. + +- `cli/release/package.json` and `cli/release-staging/package.json` publish only `index.js`, `http.js`, `postinstall.js`, `README.md`. `sdk/package.json` publishes only `dist`, `README.md`, `CHANGELOG.md`. `cli/scripts/build-binary.ts` copies native libs and wasm only — no `agents/guides` copy step. +- Path rewriting cannot rescue it: `normalizeToolPath` (`packages/agent-runtime/src/tools/handlers/tool/write-file.ts`) rejects absolute and drive-relative paths and enforces project-relative, so a pointer cannot be redirected at a package-root guide directory. Content injection is the only delivery mechanism short of adding the guides to a published `files` list. +- The same defect exists independently of base2: `packages/agent-runtime/src/system-prompt/prompts.ts` emits an `agents/guides/knowledge-files.md` pointer from the runtime package. + +### Why option A cannot work at the layer PLAN.md implies + +Not merely because prompt assembly is synchronous. `cli/scripts/prebuild-agents.ts` imports every module under `agents/` and `JSON.stringify`s the **resolved** definitions into `cli/src/agents/bundled-agents.generated.ts` at CLI build time — inside the openbuff worktree. `cli/package.json` wires that into the build and `cli/src/utils/local-agent-registry.ts` consumes the bundle in the shipped CLI. So any `createBase2`-layer probe is frozen at build time and would resolve "guides present" for **every** embedder: a guaranteed false negative, independent of the sync/async question. + +### Why option B cannot ship as authored text + +`agents/__tests__/base2-progressive-disclosure.test.ts` measures `authoredSurface` (systemPrompt + instructionsPrompt + stepPrompt) **before** placeholder injection and asserts `(off - on) / off >= 0.25`. The disclosed/explicit-off delta *is* the six section bodies, so inlining them as authored text destroys the acceptance metric it was created to protect. + +### Recommended: hybrid C + runtime placeholder + +Keep today's authored surface (compact pointer + "If that guide is unavailable" clause) and recover full bodies through an **additive** runtime placeholder whose provider does the detection. + +- **Detection belongs in** `packages/agent-runtime/src/templates/strings.ts` `toInject` — async-capable, filesystem-capable, keyed on the embedder's `fileContext.projectRoot`. `PATTERNS_INDEX` (with `common/src/util/patterns.ts`) and `FRONTEND_SECTION` are exact precedents, including the collapse-to-empty-string behavior. +- **Single-sourcing:** re-home the six section bodies into `common/` and re-export them unchanged from `agents/base2/quality-prompt-section.ts`. Re-export rather than copy: it keeps `qualitySection` byte-identical under `quality-prompt-snapshot.test.ts` and keeps `review-rubric-parity.test.ts` the single drift owner. `agent-runtime` must not import from `agents/` (`packages/agent-runtime/src/util/base2-tool-tiers.ts` documents this), which is why the bodies move rather than being imported. +- **Shape:** add `ON_DEMAND_GUIDE_FALLBACK` to `placeholderNames` (`packages/agent-runtime/src/templates/types.ts`); in `common/src/util/guides.ts` expose `GUIDE_FALLBACK_SECTIONS`, `findMissingGuides(projectRoot, logger)`, `formatGuideFallbackSections({ missing })` returning `''` when nothing is missing. +- **Additive, never a replacement.** If the placeholder replaces `guideSections` instead of following it, the pointer-presence assertions and the >=25% metric both become **vacuous rather than preserved**. The provider must return `''` in-repo so the resolved in-repo prompt stays byte-identical to today. +- **Keep every degrade clause verbatim.** It is unverified that all embedder entry points (notably SDK-direct consumers of the bundled definitions) run `injectPlaceholders`, so the compact inline clause remains the last line of defense. + +### Metric consequence + +The >=25% authored reduction survives byte-for-byte, because `authoredSurface` is measured pre-injection and a placeholder is a short marker. But that metric is then **structurally blind** to resolved-prompt regrowth in embedder workspaces. Add (do not replace) a resolved-surface budget: inject against both the repo root and a synthetic guide-less temp root, and assert the guide-less resolved surface is no larger than the resolved explicit-off surface. + +### Falsifying test + +Inject placeholders for `createBase2('default')` against a temp root with no `agents/guides/` and assert every relocated body appears in the resolved prompt; inject against the repo root and assert no body appears and the >=25% reduction still holds. Derive the section list from `GUIDE_POINTERS` so the loop cannot pass vacuously. + +### Unknowns the architect could not close + +- Exact token counts per option (requires running `countTokens`). +- Whether every embedder entry point runs `injectPlaceholders` — only the `strings.ts` provider table was inspected. **Still open**, which is why every pointer keeps its compact degrade clause as the last line of defense. +- Whether any publish pipeline outside `cli/release*`, `sdk/package.json`, and `build-binary.ts` copies `agents/guides/*.md`. Evidence is strongly negative but not exhaustive. + +### What implementation added to the record + +- The re-home is a **move plus re-export**, not a copy: `agents/base2/quality-prompt-section.ts` now re-exports the six bodies from `common/src/constants/prompt-sections.ts`, so `qualitySection` stays byte-identical under `quality-prompt-snapshot.test.ts` and every existing consumer import path is unchanged. `gateAwarenessSection` deliberately did NOT move — it is not relocatable to a guide, so it has no fallback body. +- `findMissingGuides` returns `[]` for a falsy or non-string `projectRoot`. "Unknown root" must not mean "everything is missing", or every prompt formatted without a real root regrows by six full sections. +- The resolved-surface budget the architect asked for landed as its own case: inject against a guide-less temp root and against the repo root, and compare against the resolved explicit-off surface. The pre-injection >=25% authored metric is structurally blind to embedder-workspace regrowth, so it was kept AND supplemented rather than replaced. +- `GUIDE_FALLBACK_SECTIONS` is keyed by plain `string`, not base2's `GuidePath` union — `common/` cannot import from `agents/`. The two drift-guard assertions comparing pointer paths to table keys therefore need `String(guide)` widening; comparing the narrower union to `string[]` has no matching `toEqual` overload and fails typecheck rather than at runtime. + +### Recovery must mirror the mode's exclusions (review repair) + +The first implementation emitted ONE `ON_DEMAND_GUIDE_FALLBACK` placeholder whose provider re-inlined all six bodies. That is wrong for two mode-specific reasons, both found in review: + +- **Plan mode omits git-discipline deliberately** (`!planOnly && disclose(GUIDE_PATHS.gitDiscipline)`, pinned by `base2-progressive-disclosure.test.ts`). An all-six recovery handed a guide-less embedder commit/push guidance back in a read-only mode. Fix: one placeholder per pointer (`GuidePointerRow.fallbackPlaceholder`), emitted from the same `buildArray` entry that emits the pointer, so an omitted pointer omits its recovery by construction. +- **The broad-audit body is clause-parameterized.** Plan mode's pointer tail says "do not implement", so recovering `buildBroadAuditSection('proceed to implementation or the answer')` there produced directly contradictory finalize instructions. Fix: `BROAD_AUDIT_FALLBACK_SECTIONS` keyed by `BroadAuditFinalizeClause`, plus a plan-clause placeholder base2 substitutes in plan mode. `GUIDE_FALLBACK_SECTIONS` keeps the implementation variant as the table default because that is what the guide file documents. + +Two further consequences of the same review: + +- **Recovered bodies are recorded in the shared `ContextBudgetLedger`** (`applyMeasure`, category `systemPrompt`, label `guide-fallback:`), the way `getProjectFileTreePrompt`/`getGitChangesPrompt` do. They are the largest block this path adds, so an unrecorded block silently under-counts an embedder's context budget. A collapsed (in-repo) block records nothing, which is what keeps the ledger honest. +- **`findMissingGuides` has no try/catch and no `logger`.** `fs.existsSync` reports a failed probe as `false` instead of throwing and `path.join` only ever sees the type-guarded root plus a literal table key, so the guard was unreachable dead code with an unreachable `logger?.warn` inside it. + +The filesystem probe is memoized once per formatted prompt: six providers now ask the same question, and each one runs only when its placeholder is present in the prompt. + +### Scope note + +This is cross-package (`common/`, `packages/agent-runtime/`, `agents/`, plus a new test), unlike every other Tier 1 item which stayed inside `agents/`. Schedule it as its own slice rather than bundling it with the reviewer-loop work. + +## Gotchas worth carrying forward + +**An advisory channel is only real once it has a display surface on every path that persists it.** The first advisory slice wrote `receipt.advisories` from all three reviewer families but rendered them only on the gate-pass `` block, so intermediate `NON_BLOCKING` receipts and every security/specialist receipt stored advisories invisibly. Review caught the prompt/behavior mismatch ("shown to the user" vs shown only on pass). Two valid fixes exist — narrow the claim or add the surfaces — and they are not equivalent: adding surfaces is the one that keeps the reviewer's mental model true. + +**Advisory display on the aux-pass paths must be conditional on a non-empty list.** `base2.test.ts` and `gate-lifecycle.e2e.test.ts` advance the generator yield by yield, so an unconditional `add_message` on a passing security or specialist gate shifts every subsequent expectation and fails as a confusing off-by-one yield mismatch rather than as "a new message appeared". + +**On the blocker/repair path there is no receipt to read yet.** `recordSuccessfulReviewReceipt` runs only once a finalization verdict exists, so that surface must read `collectReviewerAdvisories(reviewerToolResult)` directly. Using the shared collector (rather than a second inline `result.advisories` read) is what keeps the displayed semantics identical to the persisted ones. + +**Reconstructed inline helpers need their whole closure.** `base2.test.ts` rebuilds `formatGateStateBlock` with `extractInlineFunctionSource` + `new Function`. Extracting the advisory bounding into a shared `boundAdvisoryLines` helper broke that test at call time until the helper was added to the reconstruction list — the typecheck cannot see it, because the reconstruction is string-based. Any new inline helper called by an already-reconstructed one must be added to the same list. + +**Sanitize before the bounds check, and collapse whitespace before stripping controls.** In `formatGateStateBlock` and the CLI's `parseGateStateAdvisories`, collapsing `\s+` first turns tabs/newlines into spaces; stripping `/[\x00-\x1f\x7f]/` first would delete them and glue words together. Applying the 240-char cap after the strip is what makes the bound describe the text actually emitted. On the parse side, sanitizing after the emptiness check would let a controls-only entry pass as non-empty. + +**T1.5's id-keying only bites when the reviewer supplies an id.** Minted `RF--` ids embed the blocker's position in the round's list, so identical text at a different index yields a different id. They are deliberately excluded from `::id:` keying. Consequence for T1.2(c): the round ledger must instruct **verbatim** re-raise text regardless of ids, because bare-string findings still condone on `(class, text)`. + +**The condone/merge logic cannot import from `gate-reviewer.ts`.** It lives inside the serialized `handleSteps` generator (`.toString()` + `new Function(...)`), so module-scope closures are unavailable at reconstruction time. Any change is duplicated by hand into the `` region; `scripts/generate-gate-helpers.ts` is the source of truth and the parity tests enforce it. Always re-run `--check` after touching `base2.ts`. + +**`lastPinnedStateMessage` is an invalidation sentinel, not a history.** `markActiveWorkStateChanged` resets it to `''` on every gate-state write. Any "did this change since last time" comparison must use a separate emitted-value baseline (`lastEmittedPinnedStateMessage`) or the branch is dead. This exact mistake shipped once and was caught by review. + +**A reviewer "crash" is not always a code defect.** Four of this session's gate stalls were a provider switch, a user interrupt, a provider billing error (`预扣费额度失败`, insufficient prepaid credit), and a transient `Unable to connect` whose provider host answered HTTP 200 on a probe moments later. None warranted a bypass. Read the crash detail — and probe the host — before proposing `BYPASS REVIEWER`. + +**A stable review bundle is worth more than an extra slice of progress.** After a specialist crash, the correct move is to end the turn without editing: any edit moves the worktree and invalidates the bundle the specialist must attest against, re-triggering the same snapshot-mismatch refresh that preceded the crash. + +**Do not hand mutating git commands to a subagent.** A basher spawn ran `git stash push --include-untracked` despite an explicit read-only instruction, reverting the entire uncommitted working set; recovery was `git stash apply stash@{0}`. It also produced a misleading test failure (`Unable to find inline stripReviewerVerdictPrefix declaration`) because the test file reconstructs helpers from a `base2.ts` that had just been reverted. Use `git show HEAD:` or the read tools for historical comparison instead. + +**`.base2-test-scratch` has a pre-existing cleanup race.** Two `base2.test.ts` cases can `mkdtemp` into the shared scratch root after `afterAll` removes it, producing `ENOENT` as an unhandled-between-tests error with 0 failures. Predates this work; unrelated to any gate change. diff --git a/.agents/sessions/review-gate-correctness/PLAN.md b/.agents/sessions/review-gate-correctness/PLAN.md new file mode 100644 index 0000000000..6a56ee5bcd --- /dev/null +++ b/.agents/sessions/review-gate-correctness/PLAN.md @@ -0,0 +1,291 @@ +# Review-gate correctness & convergence plan (rev 3) + +> **Progress is tracked outside this file.** This document is the design: conclusions, evidence, tiers, sequencing. It carries no status markers by design, so nothing here goes stale as work lands. +> - `STATUS.md` — what has landed (with verified code citations), what is open and why, and resume instructions. +> - `LESSONS.md` — decision records (incl. the T1.4d architect decision) and gotchas. +> +> Tier 0 is closed and Tier 1 is mostly closed; T1.2(a), T1.2(c), and T1.4d remain open. Tier 2/3 are still gated on the sequencing step-8 re-measurement below. + +Rev 3 supersedes rev 2 after adversarial review by `architect` and `thinker`. Eleven architect findings and three thinker findings are folded in. Material changes from rev 2: + +- **New Tier 0** — a live authority hole: the condoned-pass branch bypasses the gate's own coverage/requirement hard rules. Found by the architect while verifying rev 2's evidence; independently confirmed at `base2.ts:4650-4655`. +- **H2's identity half is restored** as T1.5. Rev 2's decisive drop reason ("adds another persisted structure") was **factually false** — `openReviewerFindings` already is an id-keyed ledger with rehydration wired. Only the changed-files admissibility rule stays dropped. +- **T1.2(c) is withheld** until id-keyed condoning lands. As written it defeats the only convergence mechanism rev 2 keeps. +- **T1.3 resequenced before T1.2(a)** — the "advisory channel" T1.2(a) writes into does not exist yet. +- **New T1.6** — fingerprint cycle detection. The existing no-progress guard compares only against the immediately preceding fingerprint, so an A→B→A oscillation never trips it. Neither rev 1 nor rev 2 saw this. +- Tier gating stated once; T1.1 durability resolved; T1.3 migration hazard named. + +## Governing conclusions + +1. **The current prompt makes termination logically impossible.** "Find ways to improve the code changes" is a search that succeeds on any non-trivial file; "Do not emit `LOOKS_GOOD` while any findings remain" then forbids stopping. The acceptance predicate is unsatisfiable by construction — a divergence proof readable off `code-reviewer.ts` alone, not a probabilistic argument. Fixing the generator (T1.2) is the highest-confidence item here. +2. **Cross-round memory is an optimization for expected-case termination and a necessity for any worst-case bound.** With a stateless reviewer there is no monotone decreasing quantity to induct on. Rev 2's "fix the generator and drop the ledger" was half right: fix the generator *and* repair the memory that already exists. +3. **Locality does not bound the observed pathology.** New findings decompose into repair-induced (scales with edit size; contracts as repairs shrink) and **rediscovery** (findings about code the previous sample happened not to mention — not caused by the edit, so locality does nothing). Rediscovery dominates under an unbounded rubric. This is why "repair edits are small" never saved this loop, and why bounding the rubric precedes any output filtering. +4. **Enforcement belongs on the orchestrator side, never in new required reviewer output fields.** More required fields raise schema-non-compliance probability, and non-compliance routes to `currentPhase = 'blocked'` after one bounded retry — worse than an extra nit round. The condone credit is orchestrator-owned and costs zero protocol risk. + +## Prior attempts (read before proposing anything) + +| Commit | Subject | +|---|---| +| `6b5b035db` | Harden gate TUI and reviewer loop convergence | +| `bf31b9f3d` | harden the base2 specialist reviewer gate and its repair loop | +| `4573e2753` | Harden reviewer and validation gate against stalls, loops, and churn | +| `2d6ad7c27` | fix structured reviewer retry loop | +| `933dd440e` | add explicit MAX_REVIEWER_REPAIR_ROUNDS cap to reviewer-repair loop | +| `ff2ff4e24` | Run reviewer repair loop until findings clear | + +The last two are opposite directions — a cap added, then removed for "run until findings clear." The fossil is still in `common/src/util/gate-repair-budgets.ts`: `DEFAULT_MAX_REVIEWER_REPAIR_ROUNDS` is `null`, commented *"@deprecated Omitted option/env means unlimited, not these values."* + +Every one of those fixes was unfalsifiable when it shipped: no per-round finding telemetry exists, so each author decided on judgment. **Do not add a seventh judgment-based fix without instrumentation.** + +## Evidence base (verified reads) + +| Fact | Location | +|---|---| +| Only `LOOKS_GOOD` finalizes; `NON_BLOCKING` is repair fuel | `gate-reviewer.ts:464-499`, `:319-334` | +| **Condoned pass pre-sets the verdict** | `base2.ts:4121-4127` | +| **…and the derivation is guarded, so the hard rules never run** | `base2.ts:4650-4655` (`if (!reviewerFinalizationVerdict)`) | +| `getReviewerFinalizationVerdict` is the only enforcement of `coverage: missing` and in-scope `missing`/`uncertain` | `gate-reviewer.ts:471-491` | +| Those hard rules are emitted as plain strings the condone capture can absorb | `gate-reviewer.ts:288-315` vs `base2.ts:4529-4539` | +| `recordSuccessfulReviewReceipt` returns early on `BLOCKING` ⇒ all-condoned BLOCKING round passes with **no receipt** | `base2.ts:6927-6932` | +| Fabricated verdict is persisted | `base2.ts:4842-4843` | +| Reviewer prompt is an unbounded generator, and self-contradicts | `code-reviewer.ts` instructionsPrompt | +| Condoning is exact-string after stripping the verdict prefix | `base2.ts:4093-4103`, capture `:4529-4539`, cleared `:4828` | +| Prefix stripping is severity-blind ⇒ NON_BLOCKING→BLOCKING escalation of identical text is swallowed | `base2.ts:4097-4101`, `:4530-4532` | +| **`openReviewerFindings` already is an id-keyed ledger** (`id`, `gateId`, `text`, `status: open\|resolved\|condoned`, `files[]`, `snapshotFingerprint`, `reviewer`) | `gate-state.ts:111-122` | +| …with rehydration already wired | `base2.ts:904` | +| …and `mergeReviewerFindings` already flips records to `condoned` | `base2.ts:5192-5206` | +| …and repair reconciliation is already id-keyed | `base2.ts:4493-4511`, `:4525-4528` | +| Condone credit is unverified self-report; `reviewerRepairHasProgress` (any changed file) short-circuits completeness | `base2.ts:4498-4511` | +| Finding identity has three owners: reviewer-supplied id, FNV hash of text, raw condone text | `gate-reviewer.ts:627-634`; `base2.ts:7459-7466`; `:4090-4101` | +| code-reviewer path is the only one that does **not** correlate reviewer ids (security/specialist do) | `base2.ts:4218-4232` vs `:2047-2054`, `:2688-2696` | +| Object findings render as `[id] summary` ⇒ ids churn every text-keyed identity | `gate-reviewer.ts:548-560` | +| `retainedBlockers` matches by substring | `base2.ts:5214-5221` | +| No-progress guard compares **only** the immediately preceding fingerprint | `base2.ts:4551-4575`; specialist `:2962-2995` | +| Bare-string findings never become `findingRecords` (`if (!id \|\| !text) return []`) | `gate-reviewer.ts:623-634` | +| ⇒ a `LOOKS_GOOD` receipt with bare-string nits records `findings: []` | `base2.ts:7042`, `:7069` | +| Receipt state + parsers already accept optional `severity`/`dimension` | `gate-state.ts:32-43`; `gate-reviewer.ts:639-644` | +| Findings carry `evidence: string[]` prose; `files[]` exists but is populated with the **whole pending set** | `code-reviewer.ts` schema; `base2.ts:4183`, `:4224` | +| Rubric reaches the model only as a pointer, uniquely targeting a `.ts` module | `base2.ts:154-155`, `:516`; siblings `:146-153` | +| Dead-env-canary trap: with a default-ON flag, `envFlag \|\| DEFAULT` can never read the env | `base2.ts:87-94` | +| Repair budgets resolve missing→null→unlimited; surfaced in `/context` | `base2.ts:100-123`; `gate-repair-budgets.ts:28-46`; `cli/src/commands/context.ts` | + +--- + +# Tier 0 — live defect, fix before anything else + +## T0.1 — The condoned pass bypasses the gate's own hard rules + +**This is a defect in shipped code, not in a proposal.** It is also the only item here that can let a genuinely incomplete change through, so it precedes every improvement. + +Mechanism, end to end: + +1. `collectReviewerBlockers` emits coverage/requirement hard rules as plain strings, e.g. `"BLOCKING: test coverage missing for changed behavior (add a case to the relevant *.test.ts)"` (`gate-reviewer.ts:288-315`). +2. A prior repair round reported some finding addressed, so its text entered `condonedFindingTexts` (`base2.ts:4529-4539`). +3. On re-review the condone filter suppresses every collected blocker, so `blockers.length === 0` while `collectedBlockers.length > 0` (`:4093-4103`). +4. That branch assigns `reviewerFinalizationVerdict = 'LOOKS_GOOD'` directly (`:4121-4127`). +5. The derivation at `:4650-4655` is guarded by `if (!reviewerFinalizationVerdict)`, so **`getReviewerFinalizationVerdict` never runs** — and it is the only place `coverage: "missing"` and in-scope `requirementCoverage` `missing`/`uncertain` are enforced (`gate-reviewer.ts:471-491`). +6. `recordSuccessfulReviewReceipt` returns early for a `BLOCKING` verdict (`:6927-6932`), so the pass is credited with **no review receipt at all**, and the fabricated verdict is persisted as `gatePassedReviewerVerdict` (`:4842-4843`). + +Fix — make condoning **blocker suppression only, never verdict authority**: + +``` +// at the condoned-pass branch (base2.ts:4121) +// do NOT assign reviewerFinalizationVerdict here. +// Suppress blockers, then let the normal derivation decide: +const condonedVerdict = getReviewerFinalizationVerdict(reviewerToolResult) +if (condonedVerdict === 'LOOKS_GOOD') { ...credit as today... } +else { keep the round open — coverage/requirement rules still stand } +``` + +Remove the `if (!reviewerFinalizationVerdict)` guard's dependence on the condone path, or at minimum re-run the coverage and requirement checks before crediting. Never let the condone path fabricate a verdict. + +Note the interaction with T1.4b: rev 2 promised to teach implementers that "uncertain blocks exactly like missing," which is currently *not* reliably true. Fixing the code and the guideline together avoids documenting an aspiration. + +Acceptance: an all-condoned round whose receipt carries `coverage: "missing"` or an in-scope `uncertain` requirement does **not** pass. Add e2e coverage in `agents/e2e/gate-lifecycle.e2e.test.ts`. + +## T0.2 — Condone credit must be backed by evidence + +Condoning is credited purely from the repair-editor's self-reported `findingsAddressed`, with no check that the claimed finding was touched. Worse, `reviewerRepairHasProgress` — true when *any* changed file path exists — short-circuits the completeness check, so a receipt with `status: 'blocked'` and unaddressed ids still condones every id it lists (`base2.ts:4498-4511`, `:4525-4539`). + +This is the rubber-stamping risk, located in the orchestrator rather than the reviewer. Fix (zero protocol-failure cost, because no reviewer output changes): + +- Only condone a finding id the receipt both lists in `findingsAddressed` **and** backs with at least one `changedFiles` entry. +- Require `receipt.status === 'completed'` for condoning even when other progress exists. Keep `reviewerRepairHasProgress` for loop-continuation decisions; do not let it authorize condoning. +- Log rejected condone claims via `emitGateTelemetry` so T1.1 can count them. + +Acceptance: a repair receipt that lists ids without changed files condones nothing; the finding stays open. + +--- + +# Tier 1 — ship these + +Gate statement, stated once: **T1.1 data gates Tier 2. Tier 3 follows re-measurement.** No other tier gate exists. + +## T1.1 — Per-round telemetry + shadow mode ← first + +`emitGateTelemetry` already carries `repairRound` and `skipReason`. Add per round: + +- `findingCount`; severity histogram once T1.3 lands (before that, one `unlabeled` bucket) +- `newFindingCount` vs `carriedFindingCount` +- reviewer wall-clock; reviewed-file count +- terminal outcome: `passed` / `blocked` / which `lastReviewerGateSkipReason` +- **rejected condone claims** from T0.2 + +**Durability (architect finding 9).** Rev 2 defined round-over-round comparison against generator locals while forbidding new persisted state — so the counterfactual would silently degrade across a serialized turn, which is exactly when the metric matters (`base2.ts:4863` resets round count only on gate pass; rounds are expected to span serialization). Resolution: derive the comparison from **already-persisted** state — the prior round's `openReviewerFindings` plus `reviewReceipts`. If that proves insufficient, grant T1.1 one explicit persisted field with a `??=` default at `base2.ts:889-917` and say so; do not leave it on locals. + +**Shadow mode.** Compute what a severity threshold *would* decide and log it without enforcing: + +``` +would-suppress: 4 of 6 findings (severity low / unlabeled-hygiene) +would-have-passed-at-round: 1 (actual: 4) +``` + +Acceptance: after N real turns you can state the nit-driven share of rounds and what a threshold would have let through. + +## T1.2 — Fix the generator + +The highest-confidence behavioral item: it removes a logical obstruction, not a heuristic one. + +**(a) Resolve the contradiction.** One rule: `LOOKS_GOOD` when nothing requires a code or contract change; cosmetic observations go to the advisory channel. **Sequenced after T1.3** (architect finding 4) — bare-string findings on a `LOOKS_GOOD` receipt are surfaced nowhere today (`gate-reviewer.ts:275-334` emits no blockers for `LOOKS_GOOD`; `:623-634` drops findings without ids; `base2.ts:7042`/`:7069` therefore record `findings: []`). Shipping (a) before the channel exists silently discards the nits. Acceptance gate: a `LOOKS_GOOD` receipt's advisory findings must appear in `reviewReceipts` and in the CLI. + +**(b) Bound the generator.** Replace "find ways to improve" with a finite completeness criterion: enumerate every violation requiring a change in one pass, then stop. Make "do not drip-feed" the framing rather than an aside. Per conclusion 3, this is what shrinks the rediscovery term; nothing else in this plan does. + +Target the three convergence conditions explicitly: a satisfiable empty set ("nothing requires a change", not "nothing could be improved"); monotonicity under repair (findings must be over properties a repair can clear — taste-based findings are not monotone); and low churn sensitivity (violation-driven, not proportional to code in view). + +**(c) Round ledger as a verification checklist — WITHHELD until T1.5.** The intended prompt: + +``` +Repair round: N. This is a re-review. +Findings raised earlier and reported addressed — verify each is genuinely +fixed and cite the line that fixes it. If a fix is wrong or incomplete, +re-raise it and say why: + - +Files the last repair changed: +``` + +**Why withheld (architect finding 2, decisive).** Condoning is exact-string equality (`base2.ts:4093-4103`). "Re-raise it and say why" changes the text, so the re-raise escapes the filter and re-enters the repair loop — while a *verbatim* re-raise of a genuinely unfixed blocker gets swallowed. T1.2(c) is therefore simultaneously suppressed and loop-amplifying depending on wording: the exact "reword problem" rev 2 cited as grounds to drop H2, reintroduced via prompt. Ship (c) only after T1.5 re-keys condoning on finding id — or, as a fallback, instruct re-raises to repeat the original text verbatim with the reason on a separate line the matcher strips. Also note: shipping (c) before T0.1 would worsen the escaped-defect path. + +## T1.3 — Optional `id` / `severity` / `dimension` metadata + +No thresholding. Nothing gates on it. Extend `code-reviewer.ts` findings to accept the specialist-style object shape **alongside** bare strings, with new fields **optional, never required** (conclusion 4). The plumbing already exists (`gate-state.ts:32-43`, `gate-reviewer.ts:639-644`); only the emitting schema is missing. + +**Also route code-reviewer through id correlation (architect finding 7).** Finding identity currently has three owners, and the code-reviewer path always uses `buildReviewerFindingId(text, index)` (`base2.ts:4218-4232`) while security (`:2047-2054`) and specialist (`:2688-2696`) paths correlate reviewer-supplied ids via `record?.id ?? buildReviewerFindingId(...)`. Without this, T1.3's ids never reach `openReviewerFindings` and T1.5 has nothing to key on. Add the parity assertion to `gate-reviewer` tests. + +**Migration hazard (architect finding 8).** Once findings carry ids, the parser renders each as `[id] summary` (`gate-reviewer.ts:548-560`), changing blocker text — which churns `condonedFindingTexts`, the FNV `buildReviewerFindingId` hashes, and `mergeReviewerFindings`' substring retention (`base2.ts:5214-5221`). Handle sessions mid-loop across the change: either clear `condonedFindingTexts`/`openReviewerFindings` on shape mismatch, or make the matcher strip a leading `[id] ` token. Rollback must not leave `[id] `-prefixed condone keys that can never match. + +Acceptance: severity/dimension appear in telemetry and the advisory list; gate decisions byte-identical; legacy bare-string findings still parse and still **block** (never silently downgraded to advisory). + +## T1.4 — Fix the guidelines + +### T1.4a — the rubric barely reaches the model + +`progressivePromptDisclosure` defaults ON, so `base2.ts:516` emits only `preReviewSelfCheckPointer`, and that pointer (`:154-155`) uniquely targets a TypeScript module while every sibling targets `agents/guides/*.md` (`:146-153`). + +- Create `agents/guides/pre-review-self-check.md` with the full rubric (T1.4b content). +- Repoint to that guide. +- Extend the pointer/guide pairs at `agents/__tests__/base2-progressive-disclosure.test.ts:186-192`. +- Leave `agents/editor/editor.ts:220` interpolating the section in full. + +### T1.4b — mirror what actually blocks + +Add to `preReviewSelfCheckSection`: requirement coverage (`uncertain` blocks like `missing` — subject to T0.1 making that true); file attestation (every pending file read and accounted for; changed tests are first-class targets); coverage naming (name the exact test file and case; `coverage: "missing"` auto-blocks); advisory vs blocking (cosmetic observations do not hold the turn; do not pre-emptively refactor for style). + +Keep the existing 7 bullets. The section is explicitly not byte-frozen (`agents/__tests__/quality-prompt-snapshot.test.ts:59-72`); extend those topic assertions. **Do not edit `qualitySection`** — byte-frozen with a snapshot test and duplicated into `agents/guides/code-craftsmanship.md`. + +### T1.4c — stop rubric/reviewer drift + +Add `agents/__tests__/review-rubric-parity.test.ts` asserting every blocking rule in `code-reviewer.ts` has a matching topic in `preReviewSelfCheckSection` (keyword table: `requirementCoverage`, `uncertain`, `coverage: missing`, `reviewedFiles`, the five dimensions). Follows the `gate-helpers-freshness` / `gate-reviewer-parity` precedent. + +### T1.4d — inline fallback for guide pointers in external workspaces (open) + +Progressive prompt disclosure defaults ON, and every guide pointer names a path under `agents/guides/` that `read_files` resolves against the user's workspace root. Inside the openbuff repo that resolves; in any embedder workspace the read fails and the model silently loses all five relocated sections. Resolution options, not yet chosen: gate the default to workspaces that actually contain `agents/guides/`, or have `disclose()` emit the pointer plus an inline copy of the section when the guide is unreachable. Partially mitigated: every pointer now carries an explicit "if that guide is unavailable" clause with the compact inline rules, so an embedder degrades to summarized guidance instead of a failed read; emitting the full section bodies on an unreachable guide remains open. The guide-pointer comment block in `agents/base2/base2.ts` references this item. + +## T1.5 — Re-key condoning on finding id (H2's identity half, restored) + +Rev 2 dropped this claiming it "adds another persisted structure with its own migration and desync modes." **That was false.** `openReviewerFindings` (`gate-state.ts:111-122`) already carries `id`, `gateId`, `text`, `status: 'open' | 'resolved' | 'condoned'`, `files[]`, `snapshotFingerprint`, `reviewer`; rehydration is wired at `base2.ts:904`; `mergeReviewerFindings` already sets `condoned` (`:5192-5206`); repair reconciliation is already id-keyed (`:4493-4511`). This is a refactor of existing state, not new state. + +Changes: +- Condone by `openReviewerFindings[].id` instead of raw text, in both the filter (`:4090-4101`) and `mergeReviewerFindings` (`:5197-5206`). +- **Key on (verdict class, id), not prefix-stripped text** (architect finding 6). Today both prefixes map to one key, so a NON_BLOCKING finding escalated to BLOCKING with identical text is silently suppressed and can trigger the all-condoned pass. +- Keep reading legacy `condonedFindingTexts` on resume so an in-flight session does not lose convergence progress and restart the loop. +- Unblocks T1.2(c). + +**Still dropped: the changed-files admissibility rule.** Rev 2's reason was imprecise (architect finding 10) — the repair side is structured `{ path: string }[]` (`base2.ts:4500-4503`) and `files[]` exists on every finding (`gate-state.ts:117`); the real defect is that it is populated with the entire pending set (`:4183`, `:4224`), so it carries no per-finding attribution. That makes admissibility a *field-semantics* problem, not an impossibility. Revisit only if T1.1 shows persistent `newFindingCount > 0` on untouched files after T1.2 — and if so, populate `files[]` from the finding's cited path first. + +## T1.6 — Fingerprint cycle detection (new; missed by rev 1 and rev 2) + +The no-progress guard compares only against the **immediately preceding** fingerprint (`base2.ts:4551-4575`), so an A→B→A oscillation changes the fingerprint every round and never trips it. Keep a `Set` of snapshot fingerprints seen this turn in `handleSteps` **loop scope** — no persisted state — and fail closed on a repeat. + +This is strictly stronger than a round cap and is **not** a re-litigation of `933dd440e`/`ff2ff4e24`: it fires on demonstrated non-progress, not a guessed budget. Apply the same treatment to the specialist loop (`:2962-2995`). + +Acceptance: a synthetic A→B→A repair sequence terminates with a `reviewer-repair-cycle` skip reason instead of looping. + +--- + +# Tier 2 — evidence-gated (needs T1.1 data) + +## T2.1 — Severity thresholding + +Unresolved design problem to answer first: **severity is self-reported by the finding's author.** Dimension-binding does not fix it — the reviewer picks the dimension too, so a correctness bug labeled `hygiene` is capped automatically. And severity is a property of finding × context, not of the finding: "unnecessary try/catch" is cosmetic in a script and a swallowed auth error in a permission path, which the reviewer's own security checklist says to flag. + +Candidates, not yet chosen: derive severity from dimension plus the file's risk class (reuse `matchesSecuritySensitiveGlob`) rather than trusting the label; or have a second cheap pass classify severity independently of the finder. + +Needs a kill switch on the `createBase2` option + `OPENBUFF_*` env pattern (`base2.ts:100-123`), surfaced in `/context`. Trap: for a default-ON flag resolve with `??` on an explicit boolean — `envFlag || DEFAULT` is the documented dead canary at `:87-94`. + +**Thresholding may not touch the runaway loop at all** — that loop is driven by findings new each round; if those are medium-or-above, a threshold changes nothing. Rev 1 wrongly presented this as the top fix for both symptoms. + +## T2.2 — Scope re-review to what changed + +After a repair, pass the full pending set for *attestation* but direct deep review only at the repair receipt's `changedFiles`, citing the prior verdict for the rest. Attacks the "runs for a while" cost. Keep `collectReviewerAttestationIssues` unchanged so coverage gaps still fail closed. Promote if T1.1 shows reviewer wall-clock dominates. + +## T2.3 — Requirement ledger through the editor handoff + +Carry verbatim acceptance criteria in the editor handoff `Requirements` field, have the editor self-score each in its receipt, and pass that to the reviewer as *claimed* coverage. Reviewer contradicting a claim is a real finding; silence is not. + +## T2.4 — Nit-ratchet on the no-progress guard + +Requires T1.5's id ledger plus T1.1 data. Revisit only if T1.2 + T1.6 prove insufficient. + +--- + +# Tier 3 — after re-measurement + +- **T3.1** Normalize/dedupe finding text: `dedupeExactStringsPreserveOrder` is exact-match; reuse T1.5's key. (Rev 2 conditioned this on the evidence-gated T2.1, which was incoherent — architect finding 11. It depends on T1.5, which is Tier 1.) +- **T3.2** Docs: `docs/agents-and-tools.md:587` (`LOOKS_GOOD`-only paragraph, budget table) and the repair-loop table in `agents/guides/editor-writers-and-repair.md`. +- **T3.3 / T3.4** Advisory surface: render advisories distinctly in `cli/src/components/renderers/gate-state-box.tsx`; require them in the completion summary and offer a "fix the N nits" followup so the channel is not a silent dumping ground. **Partially pulled forward** — T1.2(a) cannot ship without the minimal version of these. +- **T3.5** Tests for whatever lands. + +--- + +# Sequencing + +1. **T0.1, T0.2** — live authority holes; independent of everything else +2. **T1.1** telemetry + shadow mode (durability resolved per above) +3. **T1.4** guidelines — fully independent, can run parallel with 1–2 +4. **T1.6** cycle detection — small, self-contained, no persisted state +5. **T1.3** optional id/severity + code-reviewer id correlation, with the `[id] ` migration handled +6. **T1.5** id-keyed condoning — unblocks T1.2(c) +7. **T1.2** generator fix: (b) any time after 1; (a) after T1.3 + minimal advisory surface; (c) after T1.5 +8. **Re-measure.** Real decision point: if `newFindingCount` collapses, Tier 2 may be unnecessary +9. Tier 2 individually gated on that data; Tier 3 last + +## Falsification criteria + +- **Escaped-defect rate** — a finding raised in a later turn on a file whose earlier-turn finding was suppressed or downgraded. Rising ⇒ suppression is too loose. Measurable only because T1.1 records suppression decisions. +- **Blocked-turn rate** — share of turns ending in `currentPhase = 'blocked'` (protocol failure, incomplete receipt, no-progress, cycle). Any change that raises this is a regression even if round counts fall. +- **Rejected condone claims** (T0.2) — a nonzero rate is direct evidence the repair-editor was over-claiming, and retroactively justifies T0.1/T0.2. + +## Implementation traps + +- The condone/merge logic (`base2.ts:4084-4148`, `:4521-4540`, `:5187-5229`) lives inside the serialized `handleSteps` generator and **cannot import** from `gate-reviewer.ts` — reconstructed functions lose their module closure. Any id-keying change must be duplicated by hand or moved into the `` region; `scripts/generate-gate-helpers.ts` is the source of truth and `gate-*-parity.test.ts` enforces it. +- New `gate-state.ts` fields need a `??=` default at `base2.ts:889-917`, must stay plain JSON (three explicit "never a Set" / "never a Map" comments), and must handle sessions updating mid-loop. +- `createReviewer` is referenced from `agents/__tests__/code-reviewer.test.ts` and `agents/__tests__/base2-writer-spawn-rules.test.ts`; `docs/agents-and-tools.md` documents a bare-string text-mode contract that persisted sessions and third-party reviewers still emit. Any schema change needs dual-shape fixtures. + +## Missing runtime evidence to collect before Tier 2 + +1. Per-round finding text/id sets from real turns — how often is a re-raise verbatim vs reworded? +2. How often the condoned-pass branch (`base2.ts:4121`) fires, and whether any such pass carried `coverage: "missing"` or an in-scope `uncertain` requirement. That single number decides whether T0.1 is an authority hole in practice or only in theory. + +## Validation per slice + +`agents` typecheck plus `agents/__tests__/gate-reviewer*.test.ts`, `agents/__tests__/base2*.test.ts`, `agents/__tests__/quality-prompt-snapshot.test.ts`. T1.4 additionally `agents/__tests__/base2-progressive-disclosure.test.ts`. T0.1, T0.2, T1.2, T1.3, T1.5, T1.6 additionally `agents/e2e/gate-lifecycle.e2e.test.ts`. diff --git a/.agents/sessions/review-gate-correctness/STATUS.md b/.agents/sessions/review-gate-correctness/STATUS.md new file mode 100644 index 0000000000..664e91268f --- /dev/null +++ b/.agents/sessions/review-gate-correctness/STATUS.md @@ -0,0 +1,66 @@ +# Status — review-gate correctness & convergence + +Companion to `PLAN.md` (rev 3), which is a design document with no progress tracking. This file is the progress record. All citations were verified by reading the files at snapshot `v3:4a19a075615be` (uncommitted, branch `feat/task-memory-evidence-pipeline`); line numbers drift as the files change, so treat them as locators rather than addresses. + +## Current state + +Tier 0 closed. Tier 1 **closed**: T1.2(a) and T1.4d both shipped in this session, leaving only T1.2(c). Tier 2/3 not started **by design** — `PLAN.md` sequencing step 8 gates them on re-measuring `newFindingCount` with the T1.1 telemetry, which now has a durable sink. + +Nothing is committed. The last gate pass was reviewer `LOOKS_GOOD` with zero findings; validation was typecheck clean across 11 packages, agents unit suite 1047 pass / 0 fail, e2e 54/54, agent-runtime + common suites 1808 pass / 0 fail across 77 files, generated gate-helpers region fresh. + +## Landed (verified in source) + +| Item | Evidence | +|---|---| +| **T0.1** condone path is no longer verdict authority | `base2.ts` `receiptHasHardRule` re-asserts the coverage/requirement hard rules before crediting a condoned pass | +| **T0.2** condone credit requires evidence | `base2.ts` `condoneEvidenceIsSufficient` requires `status === 'completed'` **and** non-empty `changedFiles`; rejections emit `condoneClaimsRejected` + `condoneRejectReason` | +| **T1.1** per-round telemetry + shadow mode | `round-findings` event carries `findingCount`, `rawFindingCount`, `newFindingCount`, `carriedFindingCount`, `suppressibleFindingCount`, `escalatedFindingCount`, `wouldPassAtThisRound`. Derived from already-persisted `openReviewerFindings`, so it survives serialization (the durability requirement in T1.1) | +| **T1.2(b)** generator bounded | `code-reviewer.ts` instructionsPrompt: "that REQUIRES A CHANGE, in a single pass, and then stop", plus the three named convergence conditions (satisfiable empty set / monotonicity under repair / low churn sensitivity) | +| **T1.3** optional finding metadata + id correlation | `code-reviewer.ts` `findings.items.anyOf` = [string, object with `required: ['text']`]; `base2.ts` `correlateReviewerFindingRecord` matches `[id]` → exact stripped text → single-unambiguous-substring | +| **T1.4a** rubric reaches the model as a guide | `agents/guides/pre-review-self-check.md` exists and the pointer targets it (previously pointed at a `.ts` module) | +| **T1.4b** rubric mirrors what blocks | `quality-prompt-section.ts` `preReviewSelfCheckSection`: `Test coverage (blocking)`, `Requirement coverage (blocking)` incl. "`uncertain` blocks exactly like `missing`", `Advisory vs blocking` | +| **T1.4c** drift guard | `agents/__tests__/review-rubric-parity.test.ts` — `REVIEWER_SCHEMA_RULES` schema↔rubric table with exact enum comparison, plus a guide-drift sweep | +| **T1.5** condoning re-keyed on (verdict class, identity) | `gate-state.ts` `condonedFindingKeys`; `base2.ts` `condonedKeyMatches` (same-class, plus one-directional BLOCKING→NON_BLOCKING de-escalation), `legacyCondonedTextMatches` (single owner of the pre-T1.5 fallback), `boundCondonedEntries` (200-entry cap) | +| **T1.6** fingerprint cycle detection | `base2.ts` `reviewer-repair-cycle` and `specialist-repair-cycle` skip reasons, turn-scoped fingerprint set, no persisted state | +| **Telemetry sink** (resume step 1) | `common/src/util/gate-telemetry.ts` + `packages/agent-runtime/src/orchestration/gate-telemetry-sink.ts` write the `base2.gate` events to a durable JSONL sink under `.openbuff/` (gitignored), so the Tier 2 gate is now mechanically answerable | +| **Advisory channel** (resume step 2) | `code-reviewer.ts` declares an OPTIONAL additive `advisories` output field; `gate-reviewer.ts` `collectReviewerAdvisories` reads the last `schemaVersion`-shaped entry; `recordSuccessfulReviewReceipt` persists `advisories` + `advisoryCount`; `cli/src/types/chat.ts` / `message-block-helpers.ts` / `gate-state-box.tsx` render them from a real `advisories` field rather than smuggling them into `details` | +| **T1.2(a)** LOOKS_GOOD contradiction resolved | `code-reviewer.ts`: `LOOKS_GOOD` when nothing REQUIRES A CHANGE even with cosmetic observations, which go to `advisories` with `findings` empty. Unblocked by the advisory channel above | +| **T1.4d** embedder guide fallback | `common/src/util/guides.ts` (`FALLBACK_GUIDES`, `GUIDE_FALLBACK_SECTIONS`, `BROAD_AUDIT_FALLBACK_SECTIONS`, `findMissingGuides`, `formatGuideFallbackSection`) + one `ON_DEMAND_GUIDE_FALLBACK_` placeholder per relocated guide in `packages/agent-runtime/src/templates/{types,strings}.ts`, appended additively after base2's pointers. Recovery is per pointer and mirrors each mode's exclusions: plan mode omits git-discipline's recovery exactly as it omits the pointer, and takes the plan-clause broad-audit body. Recovered blocks are recorded in the shared context-budget ledger. See LESSONS.md for why this shape and not the two `PLAN.md` framings | + +Also landed but **not tracked in PLAN.md**: delta-only pinned active-work state ("Win 4a"). `base2.ts` `lastEmittedPinnedStateMessage` is the emitted-block baseline, deliberately distinct from `lastPinnedStateMessage`, which `markActiveWorkStateChanged` resets to `''` on every gate-state write. + +## Open + +### T1.2(c) — round ledger — UNBLOCKED, the only remaining Tier 1 item + +T1.5 landed, so the withhold reason (exact-string condoning turning "re-raise it and say why" into a filter escape) is resolved. + +Carry-forward caveat, not in `PLAN.md`: T1.5's id-keying only bites when the reviewer supplies a stable id. Minted `RF--` ids are deliberately excluded from id-keying because they embed list position. For bare-string findings condoning still falls back to `(class, text)`, so **the ledger must instruct verbatim re-raise text regardless of ids** — the fallback the plan named, not only the id path. + +### Advisory display asymmetry — resolved, with one deliberate non-goal + +The first advisory-channel slice displayed advisories only on the gate-pass `` block, so an intermediate `NON_BLOCKING` receipt and every security/specialist receipt persisted them invisibly. A follow-up slice added an `Advisories (non-blocking; no change required):` block to the reviewer-blocker/repair path (read through `collectReviewerAdvisories(reviewerToolResult)`, because `recordSuccessfulReviewReceipt` has not run yet at that point) and to the security pass, specialist parent-owned-only pass, and specialist normal pass. All three aux surfaces emit ONLY when the bounded list is non-empty — an unconditional yield would shift the generator step sequence that `base2.test.ts` and `gate-lifecycle.e2e.test.ts` advance yield-by-yield. + +Still deliberately absent: no advisory surface on the crash / no-verdict / attestation-failure paths. Those have no trustworthy receipt to read. + +## Not started by design + +Tier 2 (T2.1 severity thresholding, T2.2 scoped re-review, T2.3 requirement ledger through the editor handoff, T2.4 nit-ratchet) is gated on T1.1 data from real turns. Tier 3 follows that. This is the plan's own decision point: if `newFindingCount` collapses after T1.2 + T1.6, Tier 2 may be unnecessary. + +The former blocker is cleared: the telemetry sink now persists the `base2.gate` events, so "after N real turns you can state the nit-driven share of rounds" is mechanically answerable. What remains is accumulating those real turns — the decision is data-gated, not implementation-gated. + +## Resume instructions + +One gate cycle per slice (each edit re-runs validation + reviewer). Steps 1, 2, 3, and 5 of the original sequence are done: + +1. ~~Telemetry sink~~ — done; persists to a gitignored `.openbuff/` JSONL sink. +2. ~~Advisory channel~~ — done; schema + `reviewReceipts` + CLI render, plus the blocker/repair and aux-pass surfaces. +3. ~~T1.2(a) prompt fix~~ — done, after 2. +4. **T1.2(c) round ledger** — the only remaining Tier 1 item, with the verbatim-re-raise instruction above. +5. ~~T1.4d~~ — done via the architect's hybrid recommendation (compact clause retained + additive runtime placeholder). + +After 4, the next decision is Tier 2, and it is data-gated: read the accumulated sink events and re-measure `newFindingCount` before implementing any of T2.1–T2.4. + +## Validation per slice + +`agents` typecheck plus `agents/__tests__/gate-reviewer*.test.ts`, `agents/__tests__/base2*.test.ts`, `agents/__tests__/quality-prompt-snapshot.test.ts`. T1.4-family slices add `agents/__tests__/base2-progressive-disclosure.test.ts`, `common/src/util/__tests__/guides.test.ts`, and `packages/agent-runtime/src/templates/__tests__/strings.test.ts`. Behavioral gate slices add `agents/e2e/gate-lifecycle.e2e.test.ts`. CLI-facing gate-state slices add `cli/src/utils/__tests__/message-block-helpers.test.ts` and `cli/src/components/__tests__/gate-state-box.test.tsx`. Any `base2.ts` change also needs `bun run scripts/generate-gate-helpers.ts --check agents/base2/base2.ts`. diff --git a/.gitignore b/.gitignore index f793b5c0fd..139e4d2259 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,9 @@ docs/bot-detection.md # Base2 gate-test scratch .base2-test-scratch/ +# Local agent state (task memory, gate telemetry JSONL sink) +.openbuff/ + # Local image/screenshot scratch 󰎞_*.webp diff --git a/agents/__tests__/base2-progressive-disclosure.test.ts b/agents/__tests__/base2-progressive-disclosure.test.ts index a85c5f00ae..5f78b83ba6 100644 --- a/agents/__tests__/base2-progressive-disclosure.test.ts +++ b/agents/__tests__/base2-progressive-disclosure.test.ts @@ -1,25 +1,27 @@ import { describe, expect, test } from 'bun:test' -import { existsSync, readFileSync } from 'fs' -import path from 'path' import { countTokens } from '@codebuff/agent-runtime/util/token-counter' -import { createBase2 } from '../base2/base2' +import { GUIDE_FALLBACK_SECTIONS } from '@codebuff/common/util/guides' + +import { createBase2, GUIDE_POINTERS, type GuidePath } from '../base2/base2' import { buildBroadAuditSection, + gateAwarenessSection, gitDisciplineSection, + preReviewSelfCheckSection, qualitySection, - securityReviewSection, - specialistRoutingSection, } from '../base2/quality-prompt-section' +import { PLACEHOLDER } from '../types/secret-agent-definition' +import { describeRepoFileExistence } from './guide-test-utils' // M2/M4 progressive prompt disclosure. Default is ON (M2 flip): each verbose // advisory section is replaced by a compact pointer to an on-demand guide // under agents/guides/, shrinking the authored prompt surface by at least // 25% vs explicit-off. Explicit progressivePromptDisclosure: false keeps the // disclose() helper as a pure passthrough so the verbose bodies stay inline -// verbatim (pre-M4 surface). The OPENBUFF_PROGRESSIVE_PROMPT_DISCLOSURE -// canary can only force ON, never OFF; explicit false always wins. +// verbatim (pre-M4 surface). Resolution is option-driven only: no environment +// variable participates in it. type Base2Agent = ReturnType @@ -35,6 +37,33 @@ function authoredSurface(agent: Base2Agent): string { ].join('\n') } +// Every disclosed mode that emits pointers: default implementation, plan-only, +// and durable-plan execution (EXECUTE_PLAN forwards `progressiveDisclosure` +// through buildExecutePlanInstructionsPrompt, so its pointers must be checked +// too). +function disclosedSurface(): string { + return [ + authoredSurface(createBase2('default')), + authoredSurface(createBase2('default', { planOnly: true })), + authoredSurface(createBase2('default', { executePlan: true })), + ].join('\n') +} + +// Matches the workspace-relative guide path every pointer emits. Global so +// `String.match` collects all of them; both uses go through `match`, which +// resets `lastIndex` itself, so the shared regex stays stateless. +const GUIDE_POINTER_PATH_PATTERN = /agents\/guides\/[A-Za-z0-9._-]+\.md/g + +// Derived from the single exported guide/pointer table in base2.ts so adding, +// renaming, or dropping a relocated guide cannot leave this test asserting a +// stale hardcoded file list. +const SYSTEM_GUIDE_POINTERS = GUIDE_POINTERS.filter( + ({ surface }) => surface === 'system', +) +const INSTRUCTIONS_GUIDE_POINTERS = GUIDE_POINTERS.filter( + ({ surface }) => surface === 'instructions', +) + const BROAD_AUDIT_IMPL = buildBroadAuditSection( 'proceed to implementation or the answer', ) @@ -42,23 +71,117 @@ const BROAD_AUDIT_PLAN = buildBroadAuditSection( 'translate the findings into the durable plan packet below', ) +// `satisfies GuidePath` so a renamed guide path is a compile error rather than a +// silently never-matching comparison in the mode-exclusion assertions below. +const GIT_DISCIPLINE_GUIDE = + 'agents/guides/git-discipline.md' satisfies GuidePath +const BROAD_AUDIT_GUIDE = 'agents/guides/broad-audit.md' satisfies GuidePath + describe('base2 progressive prompt disclosure (M4)', () => { test('flag defaults on: the verbose advisory sections relocate to guides', () => { const agent = createBase2('default') const system = agent.systemPrompt as string - // The four relocatable systemPrompt sections are replaced by pointers. - expect(system).not.toContain(qualitySection) - expect(system).not.toContain(gitDisciplineSection) - expect(system).not.toContain(securityReviewSection) - expect(system).not.toContain(specialistRoutingSection) - expect(system).toContain('agents/guides/code-craftsmanship.md') - expect(system).toContain('agents/guides/git-discipline.md') - expect(system).toContain('agents/guides/security-review.md') - expect(system).toContain('agents/guides/specialist-routing.md') + // Every relocatable systemPrompt section is replaced by its pointer. + // Numeric vacuity guard only: an emptied table must not make the loop below + // pass by iterating nothing. The exact system-surface guide count is owned + // by `GUIDE_PATHS`/`GUIDE_POINTERS` in base2.ts, so it is not restated here. + expect(SYSTEM_GUIDE_POINTERS.length).toBeGreaterThan(0) + for (const { + guide, + sectionName, + section, + pointer, + } of SYSTEM_GUIDE_POINTERS) { + // Labelled so a failure names the section that stayed inline instead of + // dumping two multi-kilobyte strings. + expect( + system.includes(section) + ? `${sectionName} is still inline instead of relocated to ${guide}` + : 'relocated', + ).toBe('relocated') + // The whole pointer, not just its guide path: a pointer stripped down to + // a bare path (no trigger clause, no fetch verb, no degrade clause) must + // fail here. This assertion is the single owner of the pointer wording + // (trigger clause included), so no other case re-asserts substrings of it. + expect( + system.includes(pointer) + ? 'pointed' + : `${guide} pointer is missing from the system prompt`, + ).toBe('pointed') + // Row-level pairing: without this a mis-paired row (guide A with guide + // B's pointer) still passes, because both strings appear somewhere in + // the prompt. + expect(pointer).toContain(guide) + } + // gateAwarenessSection is deliberately NOT relocatable: the gate contract + // (GATE: PENDING/PASSED, pending-set authority, final_response_allowed) + // must stay inline verbatim rather than being compressed into a pointer. + // base2.ts emits it only for `isDefault && !planOnly`, so the plan-only and + // `fast` surfaces omit the section entirely; asserting it inline on those + // surfaces would encode an invariant the prompt does not have. + expect( + system.includes(gateAwarenessSection) + ? 'inline' + : 'default: gate awareness section is not inline verbatim', + ).toBe('inline') + // On the surfaces that omit it, what must not happen is relocation: no + // gate-awareness guide pointer may appear in its place. + const gateOmittedSurfaces = { + plan: createBase2('default', { planOnly: true }).systemPrompt as string, + fast: createBase2('fast').systemPrompt as string, + } + for (const [label, gateSystem] of Object.entries(gateOmittedSurfaces)) { + // Labelled so a failure names the mode whose gate contract moved. + expect( + gateSystem.includes('agents/guides/gate-awareness') + ? `${label}: gate contract was relocated to a guide pointer` + : 'not relocated', + ).toBe('not relocated') + } // The broad-audit section relocates out of the instructions prompt. const instructions = agent.instructionsPrompt as string - expect(instructions).not.toContain(BROAD_AUDIT_IMPL) - expect(instructions).toContain('agents/guides/broad-audit.md') + expect(INSTRUCTIONS_GUIDE_POINTERS.length).toBeGreaterThan(0) + for (const { + guide, + sectionName, + section, + pointer, + } of INSTRUCTIONS_GUIDE_POINTERS) { + expect( + instructions.includes(section) + ? `${sectionName} is still inline instead of relocated to ${guide}` + : 'relocated', + ).toBe('relocated') + expect( + instructions.includes(pointer) + ? 'pointed' + : `${guide} pointer is missing from the instructions prompt`, + ).toBe('pointed') + expect(pointer).toContain(guide) + } + }) + + // Mechanical pointer->file wiring check, independent of the GUIDE_POINTERS + // table the assertions above derive from: every `agents/guides/*.md` path the + // default surface actually emits must exist on disk, so a typo'd pointer such + // as `agents/guides/code-craftmanship.md` fails here even though the table + // itself still lines up. + test('every agents/guides pointer path in the default surface resolves to a real guide file', () => { + const pointerPaths = new Set( + disclosedSurface().match(GUIDE_POINTER_PATH_PATTERN) ?? [], + ) + + // Guard against the regex silently matching nothing (e.g. a pointer format + // change) and making the existence check vacuous: one pointer path per + // relocated section. + expect(pointerPaths.size).toBeGreaterThanOrEqual(GUIDE_POINTERS.length) + for (const pointerPath of pointerPaths) { + // Labelled so a failure names the dangling pointer instead of printing + // `expected false to be true`. + expect( + describeRepoFileExistence(pointerPath, `${pointerPath} pointer`), + ).toBe('exists') + } }) test('flag on with an omitted option is byte-identical to flag explicitly on', () => { @@ -78,25 +201,191 @@ describe('base2 progressive prompt disclosure (M4)', () => { expect(authoredSurface(explicitPlan)).toBe(authoredSurface(implicitPlan)) }) - test('flag on: verbose sections are removed and replaced by guide pointers', () => { - const agent = createBase2('default', { - progressivePromptDisclosure: true, - }) - const system = agent.systemPrompt as string - // The verbose bodies are gone... - expect(system).not.toContain(qualitySection) - expect(system).not.toContain(gitDisciplineSection) - expect(system).not.toContain(securityReviewSection) - expect(system).not.toContain(specialistRoutingSection) - // ...replaced by compact pointers to the guide files. - expect(system).toContain('agents/guides/code-craftsmanship.md') - expect(system).toContain('agents/guides/git-discipline.md') - expect(system).toContain('agents/guides/security-review.md') - expect(system).toContain('agents/guides/specialist-routing.md') + // Every pointer must degrade gracefully for embedders whose workspace has no + // `agents/guides/` directory: without this case the inline fallback clauses + // could be deleted with all other assertions still passing. + test('every emitted guide pointer carries its "If that guide is unavailable" clause', () => { + const pointerLines = disclosedSurface() + .split('\n') + .filter((line) => line.match(GUIDE_POINTER_PATH_PATTERN) !== null) - const instructions = agent.instructionsPrompt as string - expect(instructions).not.toContain(BROAD_AUDIT_IMPL) - expect(instructions).toContain('agents/guides/broad-audit.md') + // Guard against the filter matching nothing (pointer reformatting) and + // making the clause check vacuous: one line per relocated section, and the + // broad-audit pointer is emitted once per mode. + expect(pointerLines.length).toBeGreaterThanOrEqual(GUIDE_POINTERS.length) + for (const line of pointerLines) { + // Labelled so a failure names the pointer that lost its fallback. + expect( + line.includes('If that guide is unavailable') + ? 'degrades' + : `pointer lost its degrade clause: ${line.slice(0, 120)}`, + ).toBe('degrades') + // A pointer that keeps the path but loses the fetch verb leaves the model + // with nothing to act on, so pin the read_files instruction too. + expect( + line.includes('read_files') + ? 'fetchable' + : `pointer lost its read_files instruction: ${line.slice(0, 120)}`, + ).toBe('fetchable') + } + }) + + test('plan mode emits neither the git-discipline pointer nor its section', () => { + // The system prompt gates git-discipline behind `!planOnly`, and every + // GUIDE_POINTERS-derived loop above runs against `createBase2('default')` + // only, so without this case making that disclosure unconditional in plan + // mode (read-only, no commits) would still pass everything else here. + // `satisfies GuidePath` so a renamed guide path is a compile error instead + // of a runtime "git-discipline has no GUIDE_POINTERS row" failure. + const gitDisciplineGuide = GIT_DISCIPLINE_GUIDE + const gitDisciplineRow = GUIDE_POINTERS.find( + ({ guide }) => guide === gitDisciplineGuide, + ) + // Labelled so a renamed guide names the missing row instead of failing on a + // bare undefined dereference. + expect( + gitDisciplineRow ? 'present' : 'git-discipline has no GUIDE_POINTERS row', + ).toBe('present') + if (!gitDisciplineRow) return + + const planSystem = createBase2('default', { planOnly: true }) + .systemPrompt as string + expect(planSystem).not.toContain(gitDisciplineRow.guide) + expect(planSystem).not.toContain(gitDisciplineRow.pointer) + expect(planSystem).not.toContain(gitDisciplineSection) + // Recovery must mirror the exclusion: emitting git-discipline's fallback + // placeholder here would hand a guide-less embedder commit/push guidance + // back in read-only plan mode. + expect(planSystem).not.toContain(gitDisciplineRow.fallbackPlaceholder) + // Vacuity guard: the same pointer IS emitted on the non-plan surface, so + // this case cannot pass merely because the wiring disappeared entirely. + expect(createBase2('default').systemPrompt as string).toContain( + gitDisciplineRow.pointer, + ) + expect(createBase2('default').systemPrompt as string).toContain( + gitDisciplineRow.fallbackPlaceholder, + ) + }) + + test('the guide block is headed when disclosed and keeps one blank line per gap in every mode', () => { + // The block is assembled with buildArray().join('\n\n') precisely so (a) the + // bare pointer sentences do not read as a continuation of `# Repository + // state`, (b) the explicit-off surface does not run + // `# Pre-Review Self-Check` onto qualitySection's last bullet, and (c) plan + // mode's omitted git-discipline entry leaves no stray double blank line. + const heading = '# On-demand guides' + for (const progressivePromptDisclosure of [true, false]) { + for (const planOnly of [false, true]) { + const system = createBase2('default', { + planOnly, + progressivePromptDisclosure, + }).systemPrompt as string + const label = `disclosure=${progressivePromptDisclosure} planOnly=${planOnly}` + // The heading belongs to the disclosed branch only: with disclosure off + // each inline section already carries its own `#` heading. + expect( + system.includes(heading) === progressivePromptDisclosure + ? 'expected' + : `${label}: ${heading} presence does not follow the disclosed branch`, + ).toBe('expected') + // Everything from `# Repository state` to the end of the prompt is the + // guide block plus its two short lead-ins, so a stray double blank line + // (an emptied plan-mode entry) shows up as a triple newline here. + const tail = system.slice(system.indexOf('# Repository state')) + expect( + tail.includes('\n\n\n') + ? `${label}: guide block has a stray double blank line` + : 'single-gap', + ).toBe('single-gap') + // The conditional gate contract is interpolated between + // `# Core Mandates` and `# Openbuff Meta-information`, so that span + // needs the same guard: dropping it in plan mode must not leave a stray + // double blank line behind. + const mandates = system.slice( + system.indexOf('# Core Mandates'), + system.indexOf('# Openbuff Meta-information'), + ) + expect( + mandates.includes('\n\n\n') + ? `${label}: core-mandates span has a stray double blank line` + : 'single-gap', + ).toBe('single-gap') + // ...and exactly one blank line before the meta heading, whether the + // gate section is present (default) or omitted (plan mode), so the + // heading cannot abut the block above it either. + const metaIndex = system.indexOf('# Openbuff Meta-information') + // Guard the index like the neighbouring labelled assertions: a dropped + // heading makes indexOf return -1, and the negative-index slices below + // would then satisfy the blank-line check on garbage instead of naming + // the missing heading. + expect( + metaIndex >= 3 + ? 'present' + : `${label}: # Openbuff Meta-information is missing or has no preceding blank line`, + ).toBe('present') + const oneBlankLineBeforeMeta = + system.slice(metaIndex - 2, metaIndex) === '\n\n' && + system[metaIndex - 3] !== '\n' + expect( + oneBlankLineBeforeMeta + ? 'one blank line' + : `${label}: # Openbuff Meta-information does not follow exactly one blank line`, + ).toBe('one blank line') + // Non-plan default is the surface that carries the gate section: pin + // both of its seams so `# Automated Validation & Review Gate` cannot + // abut the preceding context-pruner bullet and its last bullet cannot + // abut the meta heading. + if (!planOnly) { + expect( + system.includes( + `\n\n${gateAwarenessSection}\n\n# Openbuff Meta-information`, + ) + ? 'spaced' + : `${label}: gate section seam lost its blank-line gaps`, + ).toBe('spaced') + } + } + } + // Disclosed: every pointer sits under the block heading rather than + // trailing `# Repository state` bare. + const disclosed = createBase2('default').systemPrompt as string + const headingIndex = disclosed.indexOf(heading) + for (const { guide, pointer } of SYSTEM_GUIDE_POINTERS) { + expect( + disclosed.indexOf(pointer) > headingIndex + ? 'under heading' + : `${guide} pointer is emitted before ${heading}`, + ).toBe('under heading') + } + // Explicit off: the two adjacent system sections keep a blank-line gap. + const inline = createBase2('default', { + progressivePromptDisclosure: false, + }).systemPrompt as string + expect(inline).toContain( + `${qualitySection}\n\n${preReviewSelfCheckSection}`, + ) + }) + + test('explicit off emits no agents/guides pointer path in the system prompt', () => { + // The disclosure-off system surface is otherwise only checked for heading + // absence and section adjacency, so a `discloseGuide` change that emitted + // the pointer AND the section (instead of one or the other) would slip + // through. Assert the whole explicit-off system prompt carries no + // `agents/guides/*.md` path at all, in both plan and non-plan mode. + for (const planOnly of [false, true]) { + const system = createBase2('default', { + planOnly, + progressivePromptDisclosure: false, + }).systemPrompt as string + const pointerPaths = system.match(GUIDE_POINTER_PATH_PATTERN) ?? [] + // Labelled so a failure names the leaked pointer paths instead of + // printing a bare length mismatch. + expect( + pointerPaths.length === 0 + ? 'no pointer paths' + : `planOnly=${planOnly}: explicit-off system prompt still emits ${pointerPaths.join(', ')}`, + ).toBe('no pointer paths') + } }) test('flag on in plan mode also relocates the broad-audit section', () => { @@ -109,17 +398,67 @@ describe('base2 progressive prompt disclosure (M4)', () => { expect(instructions).toContain('agents/guides/broad-audit.md') }) - test('flag on preserves the standing obligation (no mandate silently dropped)', () => { + test('the broad-audit pointer tail is plan-mode only', () => { + // The pointer body is shared by every disclosed surface, but its plan-mode + // "do not implement" sentence is inert noise on the implementation and + // execute-plan surfaces, so `discloseBroadAudit` parameterizes the tail the + // same way `buildBroadAuditSection` parameterizes the section body. + const planTail = + 'In plan mode, do not implement — translate the findings into the durable plan packet instead.' + const planInstructions = createBase2('default', { planOnly: true }) + .instructionsPrompt as string + expect(planInstructions).toContain(planTail) + for (const options of [{}, { executePlan: true }]) { + const instructions = createBase2('default', options) + .instructionsPrompt as string + // Still the same disclosed pointer, just without the plan-mode tail. + expect(instructions).toContain('agents/guides/broad-audit.md') + expect(instructions).not.toContain(planTail) + } + }) + + test('flag off in plan mode keeps the PLAN-variant broad-audit section inline', () => { + // Plan mode's broad-audit body is the plan-variant finalize clause, not the + // implementation variant the GUIDE_POINTER_TABLE row pins. Without this + // case, swapping the plan builder to the implementation variant (or to the + // pointer) would still pass every other assertion here. const agent = createBase2('default', { - progressivePromptDisclosure: true, + planOnly: true, + progressivePromptDisclosure: false, }) - const system = agent.systemPrompt as string - // The obligation triggers must survive inline so the model still knows - // WHEN to act, even though the procedure moved to a guide. - expect(system.toLowerCase()).toContain('code craftsmanship') - expect(system.toLowerCase()).toContain('security-sensitive') - expect(system.toLowerCase()).toContain('before any git') - expect(system.toLowerCase()).toContain('specialist') + const instructions = agent.instructionsPrompt as string + expect(instructions).toContain(BROAD_AUDIT_PLAN) + expect(instructions).not.toContain(BROAD_AUDIT_IMPL) + expect(instructions).not.toContain('agents/guides/broad-audit.md') + }) + + test('flag off keeps the IMPL-variant broad-audit section inline on the default and execute-plan surfaces', () => { + // Positive counterpart to the disclosed-surface cases, which only assert + // the implementation body is ABSENT. Without this, replacing the + // implementation body with the plan variant — or with the pointer — on the + // explicit-off default/execute-plan surfaces would pass everything else. + for (const options of [{}, { executePlan: true }]) { + const instructions = createBase2('default', { + ...options, + progressivePromptDisclosure: false, + }).instructionsPrompt as string + expect(instructions).toContain(BROAD_AUDIT_IMPL) + expect(instructions).not.toContain(BROAD_AUDIT_PLAN) + expect(instructions).not.toContain('agents/guides/broad-audit.md') + } + }) + + test('flag on in execute-plan mode also relocates the broad-audit section', () => { + // EXECUTE_PLAN wraps buildImplementationInstructionsPrompt and forwards + // progressiveDisclosure into it, so the implementation-variant broad-audit + // body must be replaced by the pointer there too. + const agent = createBase2('default', { executePlan: true }) + const instructions = agent.instructionsPrompt as string + expect(instructions).not.toContain(BROAD_AUDIT_IMPL) + expect(instructions).toContain('agents/guides/broad-audit.md') + // The durable-plan block still rides along, so the pointer above is coming + // from the EXECUTE_PLAN surface rather than a plain implementation prompt. + expect(instructions).toContain('## Durable plan execution mode') }) // AC4 acceptance metric: progressive disclosure must shrink the authored @@ -141,37 +480,110 @@ describe('base2 progressive prompt disclosure (M4)', () => { expect(reduction).toBeGreaterThanOrEqual(0.25) }) - test('canary can only force-on; a `0` value falls through to the true default', () => { - // Contract: OPENBUFF_PROGRESSIVE_PROMPT_DISCLOSURE canary can only force - // ON, never OFF. `0` is not an off override; with no explicit option the - // flag resolves to DEFAULT_PROGRESSIVE_PROMPT_DISCLOSURE (true), so the - // surface stays disclosed. - const previous = process.env.OPENBUFF_PROGRESSIVE_PROMPT_DISCLOSURE - try { - process.env.OPENBUFF_PROGRESSIVE_PROMPT_DISCLOSURE = '0' - const agent = createBase2('default') - const system = agent.systemPrompt as string - expect(system).not.toContain(qualitySection) - expect(system).toContain('agents/guides/code-craftsmanship.md') - } finally { - if (previous === undefined) { - delete process.env.OPENBUFF_PROGRESSIVE_PROMPT_DISCLOSURE - } else { - process.env.OPENBUFF_PROGRESSIVE_PROMPT_DISCLOSURE = previous + test('the disclosed surfaces emit one guide-fallback placeholder per emitted pointer and the explicit-off surfaces emit none', () => { + // T1.4d: the placeholders are what recover the FULL relocated bodies for an + // embedder whose workspace has no `agents/guides/` (their providers live in + // packages/agent-runtime/src/templates/strings.ts and collapse to '' when + // the guide exists). They are appended AFTER the pointers, never in place of + // them, so the pointer-presence and >=25% cases above stay meaningful. With + // disclosure off the six bodies are already inline, so emitting them there + // would duplicate them. + // + // Recovery is per pointer: plan mode omits git-discipline's placeholder + // exactly as it omits the pointer, and substitutes the plan-clause + // broad-audit placeholder for the implementation-clause one. + const modes: ReadonlyArray<{ + label: string + options: Parameters[1] + planOnly: boolean + }> = [ + { label: 'default', options: {}, planOnly: false }, + { label: 'plan', options: { planOnly: true }, planOnly: true }, + { + label: 'execute-plan', + options: { executePlan: true }, + planOnly: false, + }, + ] + for (const { label, options, planOnly } of modes) { + const disclosed = createBase2('default', options).systemPrompt as string + for (const { guide, fallbackPlaceholder } of GUIDE_POINTERS) { + // Plan mode omits the git-discipline pointer entirely, and swaps the + // broad-audit recovery for the plan-clause placeholder below. + const expected = + guide === GIT_DISCIPLINE_GUIDE || guide === BROAD_AUDIT_GUIDE + ? !planOnly + : true + expect( + disclosed.includes(fallbackPlaceholder) === expected + ? 'expected' + : expected + ? `${label}: disclosed surface does not emit the ${guide} fallback placeholder` + : `${label}: disclosed surface emits the ${guide} fallback placeholder even though it omits that pointer`, + ).toBe('expected') } + // The plan-clause broad-audit recovery is plan-mode only: recovering the + // implementation-variant body there would contradict plan mode's own + // "do not implement" pointer tail. + expect( + disclosed.includes( + PLACEHOLDER.ON_DEMAND_GUIDE_FALLBACK_BROAD_AUDIT_PLAN, + ) === planOnly + ? 'expected' + : `${label}: plan-clause broad-audit recovery presence does not follow plan mode`, + ).toBe('expected') + + const inline = createBase2('default', { + ...options, + progressivePromptDisclosure: false, + }).systemPrompt as string + expect( + inline.includes('{CODEBUFF_ON_DEMAND_GUIDE_FALLBACK') + ? `${label}: explicit-off surface emits a guide-fallback placeholder, duplicating the inline bodies` + : 'omitted', + ).toBe('omitted') } }) - test('explicit false overrides env canary on', () => { + test('every relocated guide has a fallback body and vice versa', () => { + // Drift guard: adding a relocated guide without a recovery body (or a body + // without a pointer) silently reintroduces the lost-section defect for + // embedder workspaces. + expect(GUIDE_POINTERS.length).toBeGreaterThan(0) + // Widened to string[]: GUIDE_FALLBACK_SECTIONS is keyed by plain string + // (common/ cannot import the GuidePath union), so comparing the narrower + // pointer paths directly has no matching toEqual overload. + expect(GUIDE_POINTERS.map(({ guide }) => String(guide)).sort()).toEqual( + Object.keys(GUIDE_FALLBACK_SECTIONS).sort(), + ) + }) + + test('OPENBUFF_PROGRESSIVE_PROMPT_DISCLOSURE has no effect on resolution', () => { + // base2 reads no env var for prompt disclosure: the resolved surface is + // driven only by the option (omitted => DEFAULT, which is ON). Capture the + // whole authored surface for both option states under each env value and + // compare them byte-for-byte across values; which sections relocate is + // owned by the first test in this file, not re-derived here. const previous = process.env.OPENBUFF_PROGRESSIVE_PROMPT_DISCLOSURE try { - process.env.OPENBUFF_PROGRESSIVE_PROMPT_DISCLOSURE = '1' - const agent = createBase2('default', { - progressivePromptDisclosure: false, - }) - const system = agent.systemPrompt as string - expect(system).toContain(qualitySection) - expect(system).not.toContain('agents/guides/code-craftsmanship.md') + let baseline: { disclosed: string; inline: string } | undefined + for (const value of ['0', '1']) { + process.env.OPENBUFF_PROGRESSIVE_PROMPT_DISCLOSURE = value + // Omitted option => disclosed surface; explicit false => inline bodies. + const disclosed = authoredSurface(createBase2('default')) + const inline = authoredSurface( + createBase2('default', { progressivePromptDisclosure: false }), + ) + // Vacuity guard: the two option states must differ, otherwise the + // byte-equality assertions below would hold trivially. + expect(disclosed).not.toBe(inline) + if (!baseline) { + baseline = { disclosed, inline } + continue + } + expect(disclosed).toBe(baseline.disclosed) + expect(inline).toBe(baseline.inline) + } } finally { if (previous === undefined) { delete process.env.OPENBUFF_PROGRESSIVE_PROMPT_DISCLOSURE @@ -180,20 +592,4 @@ describe('base2 progressive prompt disclosure (M4)', () => { } } }) - - test('every relocated guide file exists and carries its moved content', () => { - const guidesDir = path.join(__dirname, '..', 'guides') - const checks: Array<[string, string]> = [ - ['broad-audit.md', 'scope'], - ['specialist-routing.md', 'specialist'], - ['git-discipline.md', 'commit'], - ['security-review.md', 'security'], - ['code-craftsmanship.md', 'craftsmanship'], - ] - for (const [file, keyword] of checks) { - const full = path.join(guidesDir, file) - expect(existsSync(full)).toBe(true) - expect(readFileSync(full, 'utf8').toLowerCase()).toContain(keyword) - } - }) }) diff --git a/agents/__tests__/base2.test.ts b/agents/__tests__/base2.test.ts index 76342701d0..fbb62cf08e 100644 --- a/agents/__tests__/base2.test.ts +++ b/agents/__tests__/base2.test.ts @@ -21,6 +21,7 @@ import { } from '../base2/base2' import { normalizeGateFilePath } from '../base2/gate-paths' import type { Base2ActiveWorkState } from '../base2/gate-state' +import { extractInlineFunctionSource } from './helpers/extract-inline-function-source' import type { AgentTemplate } from '@codebuff/agent-runtime/templates/types' @@ -57,6 +58,9 @@ function looksGoodWithParentOwnedRequirements( agentType: string, snapshotFingerprint: string, files: string[], + // Optional non-blocking advisories: the specialist parent-owned-only pass + // displays them alongside the ignored process requirements. + advisories: string[] = [], ) { return feedJson({ agentType, @@ -67,6 +71,7 @@ function looksGoodWithParentOwnedRequirements( snapshotFingerprint, reviewedFiles: files, findings: [], + ...(advisories.length > 0 ? { advisories } : {}), coverage: 'covered', dimensions: {}, requirementCoverage: [ @@ -152,6 +157,7 @@ function parseGateStateBlock(text: string): details: string repairRound?: number maxRepairRounds?: number + advisories?: string[] } | undefined { const match = text.match(/([\s\S]*?)<\/gate-state>/) @@ -168,6 +174,13 @@ function parseGateStateBlock(text: string): ...(typeof parsed.maxRepairRounds === 'number' ? { maxRepairRounds: parsed.maxRepairRounds } : {}), + // Reviewer advisories reach the user only through this block, so the + // bounded payload has to be assertable from a driven lifecycle. + ...(Array.isArray(parsed.advisories) + ? { + advisories: parsed.advisories.map((advisory) => String(advisory)), + } + : {}), } } catch { return undefined @@ -175,7 +188,7 @@ function parseGateStateBlock(text: string): } function buildFingerprint( - entries: Array<{ file: string; statusLine?: string; contentMarker: string }>, + entries: Array<{ file: string; contentMarker: string }>, validationSummary: string, ): string { // Mirror the runtime's content-only fingerprint (files-v4). The volatile @@ -186,9 +199,7 @@ function buildFingerprint( file: normalizeGateFilePath(entry.file), })) .sort((a, b) => a.file.localeCompare(b.file)) - const parts = sorted.map( - (entry) => `${entry.file}\t${entry.contentMarker}`, - ) + const parts = sorted.map((entry) => `${entry.file}\t${entry.contentMarker}`) const details = `files-v4\n${parts.join('\n')}\n--\n${validationSummary}` return `v3:${createHash('sha256').update(details).digest('hex')}` } @@ -196,8 +207,14 @@ function buildFingerprint( function attestedReviewerResult( reviewCall: any, verdict: 'LOOKS_GOOD' | 'NON_BLOCKING' | 'BLOCKING' = 'LOOKS_GOOD', - findings: string[] = [], + // Object findings (a reviewer-supplied stable `id` plus `summary`) are + // accepted alongside plain strings so a test can exercise the id half of the + // condone key, which only exists for a non-minted reviewer-supplied id. + findings: Array> = [], coverage: 'covered' | 'missing' | 'n/a' = 'covered', + // Optional non-blocking advisories: the gate persists and displays them, so a + // lifecycle test needs to emit them from a real reviewer receipt. + advisories: string[] = [], ) { const prompt = String(reviewCall?.input?.agents?.[0]?.prompt ?? '') const fingerprint = @@ -219,6 +236,7 @@ function attestedReviewerResult( snapshotFingerprint: fingerprint, reviewedFiles: files, findings, + ...(advisories.length > 0 ? { advisories } : {}), coverage, dimensions: { correctness: 'pass', @@ -324,33 +342,6 @@ function buildDurablePassAgentState(tmpFile: string, fingerprint: string) { type ParseGitStatusLine = (line: string) => string -function extractInlineFunctionSource( - source: string, - functionName: string, -): string { - const declarationStart = source.indexOf(`function ${functionName}(`) - if (declarationStart < 0) { - throw new Error(`Unable to find inline ${functionName} declaration`) - } - - const bodyStart = source.indexOf('{', declarationStart) - if (bodyStart < 0) { - throw new Error(`Unable to find inline ${functionName} body`) - } - - let depth = 0 - for (let index = bodyStart; index < source.length; index += 1) { - const character = source[index] - if (character === '{') depth += 1 - if (character === '}') depth -= 1 - if (depth === 0) { - return source.slice(declarationStart, index + 1) - } - } - - throw new Error(`Unable to find end of inline ${functionName} declaration`) -} - // parseGitStatusLine lives inside the serialized handleSteps generator, so it // cannot be exported as a module symbol. Extracting its source tests the actual // inline implementation reconstructed by the runtime. @@ -373,10 +364,56 @@ function loadInlineParseGitStatusLine(): ParseGitStatusLine { return buildHelper() } -type RepairEditorReadablePaths = ( - paths: string[], - texts?: string[], -) => string[] +type RepairEditorReadablePaths = (paths: string[], texts?: string[]) => string[] + +type InlineCondoneHelpers = { + condonedKeyMatches: ( + condonedKeys: Set, + verdictClass: string, + strippedText: string, + id?: string, + ) => boolean + legacyCondonedTextMatches: ( + condonedTexts: Set, + text: string, + ) => boolean + boundCondonedEntries: (values: string[]) => string[] + correlateReviewerFindingRecord: ( + blockerText: string, + records: Array<{ id: string; text: string }>, + ) => { id: string; text: string } | undefined +} + +// The condone helpers live inside the serialized handleSteps generator, so they +// cannot be imported. Reconstruct them together with the helpers they close over +// (stripReviewerVerdictPrefix / isMintedReviewerFindingId / +// condonedFindingKeysFor) so the class-matching, legacy-fallback, bounding, and +// record-correlation rules can be asserted directly instead of only through a +// full gate lifecycle. +function loadInlineCondoneHelpers(): InlineCondoneHelpers { + const base2Source = readFileSync( + new URL('../base2/base2.ts', import.meta.url), + 'utf8', + ) + // handleSteps helpers are TypeScript; transpile before new Function (plain JS). + const transpiler = new Bun.Transpiler({ loader: 'ts', target: 'bun' }) + const combinedTs = [ + extractInlineFunctionSource(base2Source, 'stripReviewerVerdictPrefix'), + extractInlineFunctionSource(base2Source, 'isMintedReviewerFindingId'), + extractInlineFunctionSource(base2Source, 'condonedFindingKeysFor'), + extractInlineFunctionSource(base2Source, 'condonedKeyMatches'), + extractInlineFunctionSource(base2Source, 'legacyCondonedTextMatches'), + extractInlineFunctionSource(base2Source, 'boundCondonedEntries'), + extractInlineFunctionSource(base2Source, 'correlateReviewerFindingRecord'), + 'return { condonedKeyMatches, legacyCondonedTextMatches, boundCondonedEntries, correlateReviewerFindingRecord }', + ].join('\n') + const combinedJs = transpiler.transformSync(combinedTs) + const buildHelpers = new Function( + `"use strict";\n${combinedJs}`, + ) as () => InlineCondoneHelpers + + return buildHelpers() +} // repairEditorReadablePaths lives inside the serialized handleSteps generator. // Reconstruct it with the normalizeGateFilePath + inferWorkspaceRootFromPath @@ -396,7 +433,9 @@ function loadInlineRepairEditorReadablePaths(): RepairEditorReadablePaths { 'return repairEditorReadablePaths', ].join('\n') const combinedJs = transpiler.transformSync(combinedTs) - const buildHelper = new Function(`"use strict";\n${combinedJs}`) as () => RepairEditorReadablePaths + const buildHelper = new Function( + `"use strict";\n${combinedJs}`, + ) as () => RepairEditorReadablePaths return buildHelper() } @@ -441,9 +480,12 @@ describe('base2 inline repairEditorReadablePaths', () => { }) test('skips URL-like tokens, node_modules, and .env paths from free-text extraction', () => { - const paths = repairEditorReadablePaths(['src/a.ts'], [ - 'See https://example.com/src/schema.ts and node_modules/pkg/index.ts and .env.local', - ]) + const paths = repairEditorReadablePaths( + ['src/a.ts'], + [ + 'See https://example.com/src/schema.ts and node_modules/pkg/index.ts and .env.local', + ], + ) expect(paths).toEqual(expect.arrayContaining(['src/a.ts', 'src/**/*'])) expect(paths.some((p) => p.includes('node_modules'))).toBe(false) expect(paths.some((p) => p.includes('.env'))).toBe(false) @@ -462,6 +504,164 @@ describe('base2 inline repairEditorReadablePaths', () => { }) }) +describe('base2 inline condone helpers', () => { + const { + condonedKeyMatches, + legacyCondonedTextMatches, + boundCondonedEntries, + correlateReviewerFindingRecord, + } = loadInlineCondoneHelpers() + const findingText = 'Minor style suggestion.' + const findingId = 'code-reviewer:correctness:minor-style' + + // RF-1: de-escalation is condonable, one-directionally. A stored BLOCKING key + // must condone a NON_BLOCKING re-raise of the SAME identity, or the blocker + // list stays non-empty, another repair-editor is spawned, and the + // already-applied repair trips the no-progress guard. + test('a stored BLOCKING key condones a NON_BLOCKING re-raise of the same identity', () => { + const keys = new Set([ + `BLOCKING::text:${findingText}`, + `BLOCKING::id:${findingId}`, + ]) + expect( + condonedKeyMatches(keys, 'NON_BLOCKING', findingText, findingId), + ).toBe(true) + // The text half alone is enough: a record-less finding has no stable id. + expect( + condonedKeyMatches( + new Set([`BLOCKING::text:${findingText}`]), + 'NON_BLOCKING', + findingText, + ), + ).toBe(true) + // The id half alone is enough too, for a differently-worded re-raise. + expect( + condonedKeyMatches( + new Set([`BLOCKING::id:${findingId}`]), + 'NON_BLOCKING', + 'Reworded nit text.', + findingId, + ), + ).toBe(true) + }) + + test('a stored NON_BLOCKING key never condones a BLOCKING re-raise', () => { + const keys = new Set([ + `NON_BLOCKING::text:${findingText}`, + `NON_BLOCKING::id:${findingId}`, + ]) + // Escalation is new information: the gate must reopen. + expect(condonedKeyMatches(keys, 'BLOCKING', findingText, findingId)).toBe( + false, + ) + // Same class still condones. + expect( + condonedKeyMatches(keys, 'NON_BLOCKING', findingText, findingId), + ).toBe(true) + }) + + test('a legacy prefix-less key stays class-exact in both directions', () => { + const legacyKeys = new Set([`*::text:${findingText}`]) + expect(condonedKeyMatches(legacyKeys, '*', findingText)).toBe(true) + expect(condonedKeyMatches(legacyKeys, 'NON_BLOCKING', findingText)).toBe( + false, + ) + expect(condonedKeyMatches(legacyKeys, 'BLOCKING', findingText)).toBe(false) + // A BLOCKING entry does not condone a prefix-less `*` re-raise either: the + // de-escalation allowance is NON_BLOCKING-only. + expect( + condonedKeyMatches( + new Set([`BLOCKING::text:${findingText}`]), + '*', + findingText, + ), + ).toBe(false) + }) + + // RF-6: the pre-T1.5 legacy text fallback is one helper, so all three call + // sites accept both the stripped text and the raw prefixed blocker. + test('the legacy text fallback matches both stripped and raw prefixed blockers', () => { + const strippedOnly = new Set([findingText]) + expect( + legacyCondonedTextMatches(strippedOnly, `NON_BLOCKING: ${findingText}`), + ).toBe(true) + expect(legacyCondonedTextMatches(strippedOnly, findingText)).toBe(true) + const rawOnly = new Set([`BLOCKING: ${findingText}`]) + expect(legacyCondonedTextMatches(rawOnly, `BLOCKING: ${findingText}`)).toBe( + true, + ) + expect(legacyCondonedTextMatches(rawOnly, 'A different nit.')).toBe(false) + }) + + // RF-4: the durable condone lists are bounded like reviewReceipts. + test('boundCondonedEntries dedupes and keeps only the most recent 200 entries', () => { + const bounded = boundCondonedEntries( + Array.from({ length: 250 }, (_unused, index) => `key-${index}`), + ) + expect(bounded).toHaveLength(200) + expect(bounded[0]).toBe('key-50') + expect(bounded).toContain('key-249') + expect(bounded).not.toContain('key-49') + // Duplicates collapse before the slice, so a repeatedly re-raised finding + // does not evict distinct entries. + expect(boundCondonedEntries(['a', 'b', 'a'])).toEqual(['a', 'b']) + }) + + // RF-3: a substring relationship between two finding texts must never attach + // the wrong record id, because the resulting `::id:` key would + // condone an unrelated finding. + test('correlateReviewerFindingRecord prefers [id], then exact text, and refuses ambiguous substrings', () => { + const shortRecord = { id: 'r-short', text: 'Null check missing.' } + const longRecord = { + id: 'r-long', + text: 'Null check missing. Also bounds check missing.', + } + const records = [shortRecord, longRecord] + // The explicit marker wins even when another record's text matches exactly. + expect( + correlateReviewerFindingRecord( + `NON_BLOCKING: [${longRecord.id}] ${shortRecord.text}`, + records, + ), + ).toBe(longRecord) + // Exact stripped-text match, not "first record whose text is contained". + expect( + correlateReviewerFindingRecord( + `NON_BLOCKING: ${longRecord.text}`, + records, + ), + ).toBe(longRecord) + expect( + correlateReviewerFindingRecord( + `NON_BLOCKING: ${shortRecord.text}`, + records, + ), + ).toBe(shortRecord) + // The `[id] text` blocker form is an exact match too. + expect( + correlateReviewerFindingRecord( + `NON_BLOCKING: [${shortRecord.id}] ${shortRecord.text}`, + [shortRecord], + ), + ).toBe(shortRecord) + // A synthesized blocker that merely CONTAINS both texts is ambiguous, so no + // id is attached; the text key still carries convergence. + expect( + correlateReviewerFindingRecord( + `BLOCKING: requirement missing: ${longRecord.text}`, + records, + ), + ).toBeUndefined() + // A single unambiguous containment still correlates. + expect( + correlateReviewerFindingRecord( + `BLOCKING: requirement missing: ${shortRecord.text}`, + [shortRecord], + ), + ).toBe(shortRecord) + }) +}) + describe('base2 inline parseGitStatusLine', () => { const parseGitStatusLine = loadInlineParseGitStatusLine() @@ -565,9 +765,7 @@ describe('base2 validation/reviewer coordination prompts', () => { // disclosure these lines live in agents/guides/specialist-routing.md; // assert the guide pointer here and the verbatim text on the // explicit-off surface (base2DisclosureOff). - expect(base2.systemPrompt).toContain( - 'agents/guides/specialist-routing.md', - ) + expect(base2.systemPrompt).toContain('agents/guides/specialist-routing.md') expect(base2DisclosureOff.systemPrompt).toContain( 'Do not manually re-spawn them after edits, after compaction', ) @@ -1092,9 +1290,9 @@ describe('base2 proactive index lookup', () => { expect(firstYield('fix it')).toMatchObject({ toolName: 'git_status' }) // Continuation prompts start at git_status. - expect( - firstYield('continue working on the previous task'), - ).toMatchObject({ toolName: 'git_status' }) + expect(firstYield('continue working on the previous task')).toMatchObject({ + toolName: 'git_status', + }) }) test('starts codebase-oriented Q&A prompts at git_status', () => { @@ -1173,9 +1371,9 @@ describe('base2 proactive index lookup', () => { expect(firstYield('show me the index')).toMatchObject({ toolName: 'git_status', }) - expect( - firstYield('refactor the authentication module code'), - ).toMatchObject({ toolName: 'git_status' }) + expect(firstYield('refactor the authentication module code')).toMatchObject( + { toolName: 'git_status' }, + ) expect( firstYield('How does the authentication module work in this codebase?'), ).toMatchObject({ toolName: 'git_status' }) @@ -1315,8 +1513,9 @@ describe('base2 verification and reviewer gates', () => { expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) expect( - gen.next({ toolResult: [{ type: 'json', value: { status: '' } }] } as any) - .value, + gen.next({ + toolResult: [{ type: 'json', value: { status: '' } }], + } as any).value, ).toMatchObject({ toolName: 'spawn_agent_inline', }) @@ -1459,8 +1658,9 @@ describe('base2 verification and reviewer gates', () => { expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) expect( - gen.next({ toolResult: [{ type: 'json', value: { status: '' } }] } as any) - .value, + gen.next({ + toolResult: [{ type: 'json', value: { status: '' } }], + } as any).value, ).toMatchObject({ toolName: 'spawn_agent_inline', }) @@ -1607,8 +1807,9 @@ describe('base2 verification and reviewer gates', () => { expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) expect( - gen.next({ toolResult: [{ type: 'json', value: { status: '' } }] } as any) - .value, + gen.next({ + toolResult: [{ type: 'json', value: { status: '' } }], + } as any).value, ).toMatchObject({ toolName: 'spawn_agent_inline', }) @@ -1732,12 +1933,10 @@ describe('base2 verification and reviewer gates', () => { [ { file: gateFileA, - statusLine: ` M ${fileA}`, contentMarker: buildContentMarker(fileA), }, { file: gateFileB, - statusLine: ` M ${fileB}`, contentMarker: buildContentMarker(fileB), }, ], @@ -1847,7 +2046,6 @@ describe('base2 verification and reviewer gates', () => { [ { file: gateFile, - statusLine: ` M ${tmpFile}`, contentMarker: buildContentMarker(tmpFile), }, ], @@ -1959,9 +2157,7 @@ describe('base2 verification and reviewer gates', () => { expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) expect( gen.next({ - toolResult: [ - { type: 'json', value: { status: ` M ${tmpFile}` } }, - ], + toolResult: [{ type: 'json', value: { status: ` M ${tmpFile}` } }], } as any).value, ).toMatchObject({ toolName: 'spawn_agent_inline', @@ -2211,7 +2407,8 @@ describe('base2 verification and reviewer gates', () => { currentPhase: 'final_response_allowed', latestWorkSummary: '', openReviewerBlockers: [], - lastValidationSummary: 'Configured file-change hooks passed: typecheck.', + lastValidationSummary: + 'Configured file-change hooks passed: typecheck.', nextRequiredAction: '', lastPinnedStateMessage: '', gatePassedFiles: [gateFile], @@ -2247,7 +2444,9 @@ describe('base2 verification and reviewer gates', () => { // Only the never-validated agent-touched dirty reviewable file B is // published; the gate-passed file A and the untouched file C are excluded. - expect((agentState as any).uncommittedUnvalidatedFiles).toEqual(['src/b.ts']) + expect((agentState as any).uncommittedUnvalidatedFiles).toEqual([ + 'src/b.ts', + ]) } finally { rmSync(tmpDir, { recursive: true, force: true }) } @@ -2408,9 +2607,9 @@ describe('base2 verification and reviewer gates', () => { expect.arrayContaining(['src/b.ts']), ) expect((agentState as any).canSuggestFollowups).toBe(false) - expect( - (agentState as any).base2ActiveWork.latestWorkSummary, - ).toMatch(/Unreviewed dirty reviewable files reopened the gate/) + expect((agentState as any).base2ActiveWork.latestWorkSummary).toMatch( + /Unreviewed dirty reviewable files reopened the gate/, + ) } finally { rmSync(tmpDir, { recursive: true, force: true }) } @@ -2730,7 +2929,6 @@ describe('base2 verification and reviewer gates', () => { agentState, prompt: 'Make the requested change now please', params: {}, - } as any) expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) @@ -2922,9 +3120,7 @@ describe('base2 verification and reviewer gates', () => { expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) expect( gen.next({ - toolResult: [ - { type: 'json', value: { status: ` M ${gateFile}` } }, - ], + toolResult: [{ type: 'json', value: { status: ` M ${gateFile}` } }], } as any).value, ).toMatchObject({ toolName: 'spawn_agent_inline', @@ -3846,7 +4042,8 @@ describe('base2 verification and reviewer gates', () => { lastValidationSummary: 'typecheck passed for src/a.ts', nextRequiredAction: '', lastPinnedStateMessage: '', - gateProgressLine: 'gate: validation passed; reviewer code-reviewer running', + gateProgressLine: + 'gate: validation passed; reviewer code-reviewer running', }, } const gen = base2.handleSteps!({ @@ -3917,8 +4114,12 @@ describe('base2 verification and reviewer gates', () => { expect(text).not.toContain('Gate progress:') }) - test('gate-state type round-trips gateProgressLine through JSON and is optional on older state', () => { - const state: Base2ActiveWorkState = { + test('legacy gate state without gateProgressLine renders the pinned block with no Gate progress line', () => { + // Older serialized state lacks the optional field entirely; the type usage + // here (no gateProgressLine key) is what asserts optionality, and the + // assertions below are behavioral: the pinned block must still render and + // must not carry a Gate progress line. + const legacyState: Base2ActiveWorkState = { pendingGateFiles: ['src/a.ts'], gatePassedFiles: [], gatePassedPendingFiles: [], @@ -3934,37 +4135,158 @@ describe('base2 verification and reviewer gates', () => { lastValidationSummary: '', nextRequiredAction: '', lastPinnedStateMessage: '', - gateProgressLine: 'gate: reviewer verdict LOOKS_GOOD; finalizing', } - const roundTripped = JSON.parse( - JSON.stringify(state), - ) as Base2ActiveWorkState - expect(roundTripped.gateProgressLine).toBe( - 'gate: reviewer verdict LOOKS_GOOD; finalizing', + const base2 = createBase2('default') + const gen = base2.handleSteps!({ + agentState: { agentId: 'base2', base2ActiveWork: legacyState }, + prompt: 'Finish the previous response.', + params: {}, + } as any) + + expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) + expect( + gen.next({ + toolResult: [{ type: 'json', value: { status: ' M src/a.ts' } }], + } as any).value, + ).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + const pinned = gen.next() + expect(pinned.value).toMatchObject({ + toolName: 'add_message', + input: { role: 'user' }, + }) + const text = (pinned.value as any).input.content as string + expect(text).toContain('Harness pinned active-work state') + expect(text).toContain('GATE: PENDING') + expect(text).not.toContain('Gate progress:') + }) + + // Win 4a delta-only pinned state: after the first full pinned block, a step + // whose only change is gate progress emits just the progress diff. + test('second pinned emission is delta-only when only gateProgressLine changed', () => { + const base2 = createBase2('default') + const agentState = { + agentId: 'base2', + base2ActiveWork: { + changedFiles: ['src/a.ts'], + touchedFiles: ['src/a.ts'], + pendingGateFiles: ['src/a.ts'], + currentPhase: 'awaiting_validation', + latestWorkSummary: '', + openReviewerBlockers: [], + lastValidationSummary: 'typecheck passed for src/a.ts', + nextRequiredAction: '', + lastPinnedStateMessage: '', + gateProgressLine: 'gate: validation hooks running for 1 file(s)', + }, + } + const gen = base2.handleSteps!({ + agentState, + prompt: 'Finish the previous response.', + params: {}, + } as any) + + expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) + expect( + gen.next({ + toolResult: [{ type: 'json', value: { status: ' M src/a.ts' } }], + } as any).value, + ).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + const firstPinned = gen.next() + const firstText = (firstPinned.value as any).input.content as string + expect(firstText).toContain('Harness pinned active-work state') + expect(firstText).toContain( + 'Gate progress: gate: validation hooks running for 1 file(s)', ) - // Older serialized state lacks the field entirely; it stays optional/absent. - const olderState: Base2ActiveWorkState = { - pendingGateFiles: ['src/a.ts'], - gatePassedFiles: [], - gatePassedPendingFiles: [], - gatePassedReviewerVerdict: '', - gatePassedValidationSummary: '', - gatePassedFingerprint: '', - lastReviewerGateSkipReason: '', - touchedFiles: ['src/a.ts'], - changedFiles: ['src/a.ts'], - currentPhase: 'awaiting_validation', - latestWorkSummary: '', - openReviewerBlockers: [], - lastValidationSummary: '', - nextRequiredAction: '', - lastPinnedStateMessage: '', + expect(gen.next().value).toBe('STEP') + // Simulate a mid-turn setGateProgress write: the progress line changes and + // lastPinnedStateMessage is reset to '' by markActiveWorkStateChanged. The + // delta path must key off the last EMITTED block, not that sentinel. + agentState.base2ActiveWork.gateProgressLine = + 'gate: validation passed; reviewer code-reviewer running' + agentState.base2ActiveWork.lastPinnedStateMessage = '' + + expect(gen.next({ stepsComplete: false } as any).value).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + const secondPinned = gen.next() + expect(secondPinned.value).toMatchObject({ + toolName: 'add_message', + input: { + role: 'user', + content: + 'Gate progress: gate: validation passed; reviewer code-reviewer running', + }, + }) + }) + + test('second pinned emission re-emits the full block when reviewer blockers change', () => { + const base2 = createBase2('default') + const agentState = { + agentId: 'base2', + base2ActiveWork: { + changedFiles: ['src/a.ts'], + touchedFiles: ['src/a.ts'], + pendingGateFiles: ['src/a.ts'], + currentPhase: 'awaiting_validation', + latestWorkSummary: '', + openReviewerBlockers: [] as string[], + lastValidationSummary: 'typecheck passed for src/a.ts', + nextRequiredAction: '', + lastPinnedStateMessage: '', + gateProgressLine: 'gate: validation hooks running for 1 file(s)', + }, } - const olderRoundTripped = JSON.parse( - JSON.stringify(olderState), - ) as Base2ActiveWorkState - expect(olderRoundTripped.gateProgressLine).toBeUndefined() + const gen = base2.handleSteps!({ + agentState, + prompt: 'Finish the previous response.', + params: {}, + } as any) + + expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) + expect( + gen.next({ + toolResult: [{ type: 'json', value: { status: ' M src/a.ts' } }], + } as any).value, + ).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + const firstText = (gen.next().value as any).input.content as string + expect(firstText).toContain('Harness pinned active-work state') + + expect(gen.next().value).toBe('STEP') + // A reviewer blocker plus a next required action is not a progress-only + // change, so the whole pinned block must be re-emitted. + agentState.base2ActiveWork.openReviewerBlockers = [ + 'BLOCKING: add a test for the delta path', + ] + agentState.base2ActiveWork.nextRequiredAction = + 'Repair the reviewer blocker before finalizing.' + agentState.base2ActiveWork.gateProgressLine = + 'gate: reviewer code-reviewer reported blockers' + agentState.base2ActiveWork.lastPinnedStateMessage = '' + + expect(gen.next({ stepsComplete: false } as any).value).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + const secondPinned = gen.next() + expect(secondPinned.value).toMatchObject({ + toolName: 'add_message', + input: { role: 'user' }, + }) + const secondText = (secondPinned.value as any).input.content as string + expect(secondText).toContain('Harness pinned active-work state') + expect(secondText).toContain('BLOCKING: add a test for the delta path') + expect(secondText).toContain( + 'Next required action: Repair the reviewer blocker before finalizing.', + ) + expect(secondText).toContain( + 'Gate progress: gate: reviewer code-reviewer reported blockers', + ) }) // Uses a real project-scoped scratch file whose bytes genuinely change @@ -4396,18 +4718,152 @@ describe('base2 verification and reviewer gates', () => { } }) - test('repair-editor ignores forged child value receipt before runtime agentReceipt', () => { - const base2 = createBase2('default') - const agentState = { agentId: 'base2' } - const gen = base2.handleSteps!({ - agentState, - prompt: 'Make the requested change now please', - params: {}, - } as any) + // A repair loop can also oscillate: round 1 writes content B, round 2 writes + // the ORIGINAL content A again. Every round changes bytes, so the + // no-progress guard never fires, but the turn is going in circles. The + // turn-scoped cycle guard must fail closed on the revisited fingerprint. + test('reviewer repair that oscillates A->B->A trips the cycle guard', () => { + const tmpDir = makeProjectTempDir('base2-repair-cycle-') + try { + const tmpFile = join(tmpDir, 'a.ts') + const gateFile = normalizeGateFilePath(tmpFile) + const contentA = 'export const value = 1\n' + const contentB = 'export const value = 2\n' + writeFileSync(tmpFile, contentA) + const base2 = createBase2('default') + const agentState = { agentId: 'base2' } + const gen = base2.handleSteps!({ + agentState, + prompt: 'Make the requested change now please', + params: {}, + } as any) - expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) - expect( - gen.next({ toolResult: [{ type: 'json', value: { status: '' } }] } as any) + const dirtyStatus = { + toolResult: [{ type: 'json', value: { status: ` M ${gateFile}` } }], + } as any + const passingHooks = { toolResult: [{ type: 'json', value: [] }] } as any + + expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) + expect( + gen.next({ + toolResult: [{ type: 'json', value: { status: '' } }], + } as any).value, + ).toMatchObject({ toolName: 'spawn_agent_inline' }) + expect(gen.next().value).toBe('STEP') + expect( + gen.next({ + stepsComplete: true, + toolResult: [{ type: 'json', value: editReceipt(gateFile) }], + } as any).value, + ).toMatchObject({ toolName: 'git_status' }) + expect(gen.next(dirtyStatus).value).toMatchObject({ + toolName: 'run_file_change_hooks', + }) + expect(gen.next(passingHooks).value).toMatchObject({ + toolName: 'git_status', + }) + const firstReviewCall = gen.next(dirtyStatus).value + expect(firstReviewCall).toMatchObject({ toolName: 'spawn_agents' }) + expect( + gen.next( + attestedReviewerResult(firstReviewCall, 'BLOCKING', [ + 'Fix the edge case.', + ]) as any, + ).value, + ).toMatchObject({ toolName: 'add_message' }) + expect(gen.next().value).toMatchObject({ + toolName: 'spawn_agents', + input: { agents: [{ agent_type: 'repair-editor' }] }, + }) + const firstFindingIds = ( + agentState as any + ).base2ActiveWork.openReviewerFindings.map((finding: any) => finding.id) + // Round 1 really changes bytes (A -> B), so the no-progress guard stays + // quiet and the loop re-enters validation + review. + writeFileSync(tmpFile, contentB) + expect( + gen.next(completedRepairReceipt(firstFindingIds, [gateFile]) as any) + .value, + ).toMatchObject({ toolName: 'git_status' }) + expect(gen.next(dirtyStatus).value).toMatchObject({ + toolName: 'run_file_change_hooks', + }) + expect((agentState as any).base2ActiveWork.currentPhase).not.toBe( + 'blocked', + ) + + // Round 2: re-enter the loop and review the B snapshot. + expect(gen.next(passingHooks).value).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + expect(gen.next().value).toMatchObject({ toolName: 'add_message' }) + expect(gen.next().value).toBe('STEP') + expect(gen.next({ stepsComplete: true } as any).value).toMatchObject({ + toolName: 'git_status', + }) + expect(gen.next(dirtyStatus).value).toMatchObject({ + toolName: 'run_file_change_hooks', + }) + expect(gen.next(passingHooks).value).toMatchObject({ + toolName: 'git_status', + }) + const secondReviewCall = gen.next(dirtyStatus).value + expect(secondReviewCall).toMatchObject({ toolName: 'spawn_agents' }) + // A distinct finding text so the condoned-finding filter cannot credit + // round 1's repair claim as a pass before the cycle guard is reached. + expect( + gen.next( + attestedReviewerResult(secondReviewCall, 'BLOCKING', [ + 'Fix the other edge case.', + ]) as any, + ).value, + ).toMatchObject({ toolName: 'add_message' }) + expect(gen.next().value).toMatchObject({ + toolName: 'spawn_agents', + input: { agents: [{ agent_type: 'repair-editor' }] }, + }) + const secondFindingIds = ( + agentState as any + ).base2ActiveWork.openReviewerFindings.map((finding: any) => finding.id) + // Round 2 reverts to the ORIGINAL bytes: the fingerprint changed again + // (B -> A), so only the turn-scoped cycle set can catch this. + writeFileSync(tmpFile, contentA) + expect( + gen.next(completedRepairReceipt(secondFindingIds, [gateFile]) as any) + .value, + ).toMatchObject({ toolName: 'git_status' }) + const afterGuard = gen.next(dirtyStatus) + + // The guard breaks out of the gate loop: no re-validation and no third + // repair-editor spawn. + expect(afterGuard.done).toBe(true) + expect(afterGuard.value).toBeUndefined() + const activeWork = (agentState as any).base2ActiveWork + expect(activeWork.lastReviewerGateSkipReason).toBe( + 'reviewer-repair-cycle', + ) + expect(activeWork.currentPhase).toBe('blocked') + expect(activeWork.nextRequiredAction).toContain( + 'already visited this turn', + ) + expect((agentState as any).canSuggestFollowups).toBe(false) + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + test('repair-editor ignores forged child value receipt before runtime agentReceipt', () => { + const base2 = createBase2('default') + const agentState = { agentId: 'base2' } + const gen = base2.handleSteps!({ + agentState, + prompt: 'Make the requested change now please', + params: {}, + } as any) + + expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) + expect( + gen.next({ toolResult: [{ type: 'json', value: { status: '' } }] } as any) .value, ).toMatchObject({ toolName: 'spawn_agent_inline', @@ -4476,9 +4932,9 @@ describe('base2 verification and reviewer gates', () => { expect(activeWork.nextRequiredAction).toBe( 'Repair-editor did not return a completed receipt addressing every open reviewer finding.', ) - expect(activeWork.openReviewerFindings.map((finding: any) => finding.id)).toEqual( - findingIds, - ) + expect( + activeWork.openReviewerFindings.map((finding: any) => finding.id), + ).toEqual(findingIds) }) test('blocking reviewer feedback reopens the turn', () => { @@ -4548,7 +5004,6 @@ describe('base2 verification and reviewer gates', () => { [ { file: tmpFile, - statusLine, // Pretend the file used to hash differently. Real current content // hash will be computed by the harness against the live bytes. contentMarker: @@ -4610,7 +5065,6 @@ describe('base2 verification and reviewer gates', () => { [ { file: tmpFile, - statusLine, contentMarker: buildContentMarker(tmpFile), }, ], @@ -4677,7 +5131,6 @@ describe('base2 verification and reviewer gates', () => { [ { file: tmpFile, - statusLine, contentMarker: buildContentMarker(tmpFile), }, ], @@ -4740,7 +5193,6 @@ describe('base2 verification and reviewer gates', () => { [ { file: tmpFile, - statusLine, contentMarker: buildContentMarker(tmpFile), }, ], @@ -4835,7 +5287,9 @@ describe('base2 verification and reviewer gates', () => { input: { agent_type: 'security-reviewer' }, }) const prompt = (securityReview.value as any).input.prompt as string - const snapshotFingerprint = prompt.split('Snapshot fingerprint: ')[1].split('\n')[0] + const snapshotFingerprint = prompt + .split('Snapshot fingerprint: ')[1] + .split('\n')[0] const blockerMessage = gen.next({ toolResult: [ { @@ -4885,6 +5339,115 @@ describe('base2 verification and reviewer gates', () => { }) }) + // RF-4-928ca2ea: gate-level coverage for the SECURITY-reviewer call site of + // collectParentOwnedRequirementBlockers. A LOOKS_GOOD security receipt whose + // only requirementCoverage gaps are parent-owned process duties (including + // one that is parent-owned only via evidence) must credit the security gate + // without spawning repair-editor. + test('credits a LOOKS_GOOD security review whose only requirement gaps are parent-owned', () => { + const base2 = createBase2('default') + const agentState = { agentId: 'base2' } + const gen = base2.handleSteps!({ + agentState, + prompt: 'Update sdk/src/policy/terminal-command-policy.ts.', + params: {}, + } as any) + + expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) + expect( + gen.next({ toolResult: [{ type: 'json', value: { status: '' } }] } as any) + .value, + ).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + expect(gen.next().value).toBe('STEP') + expect( + gen.next({ + stepsComplete: true, + toolResult: [ + { + type: 'json', + value: editReceipt('sdk/src/policy/terminal-command-policy.ts'), + }, + ], + } as any).value, + ).toMatchObject({ toolName: 'git_status' }) + const securityReview = gen.next({ + toolResult: [ + { + type: 'json', + value: { status: ' M sdk/src/policy/terminal-command-policy.ts' }, + }, + ], + } as any) + expect(securityReview.value).toMatchObject({ + toolName: 'spawn_agent_inline', + input: { agent_type: 'security-reviewer' }, + }) + const securityPrompt = (securityReview.value as any).input.prompt as string + const snapshotFingerprint = securityPrompt + .split('Snapshot fingerprint: ')[1] + .split('\n')[0] + const afterSecurityPass = gen.next({ + toolResult: [ + { + type: 'json', + value: { + schemaVersion: 1, + verdict: 'LOOKS_GOOD', + snapshotFingerprint, + reviewedFiles: ['sdk/src/policy/terminal-command-policy.ts'], + findings: [], + coverage: 'covered', + dimensions: { + inputBoundaries: 'pass', + authorization: 'pass', + secretHandling: 'pass', + resourceSafety: 'pass', + failureMode: 'pass', + }, + requirementCoverage: [ + { requirement: 'Commit and push', status: 'missing' }, + { requirement: 'Confirm CI/CD is green', status: 'uncertain' }, + // Parent-owned only via evidence; the requirement text alone is + // in-scope, so the security call site must consult the structured + // evidence exactly like finalization does. + { + requirement: 'Ship remaining workflow steps', + status: 'missing', + evidence: [ + 'parent must run full validation gate after this specialist', + ], + }, + ], + }, + }, + ], + } as any) + + // No repair round was opened for the parent-owned process gaps: the gate + // credited security review and re-entered the loop instead. + const afterValue = afterSecurityPass.value as any + expect( + afterValue?.toolName === 'spawn_agents' && + (afterValue?.input?.agents ?? []).some( + (agent: { agent_type?: string }) => + agent?.agent_type === 'repair-editor', + ), + ).toBe(false) + expect((agentState as any).base2ActiveWork).toMatchObject({ + securityReviewGateDone: true, + preEditSecurityReviewDone: true, + securityReviewGateFingerprint: snapshotFingerprint, + }) + expect((agentState as any).base2ActiveWork.openReviewerBlockers).toEqual([]) + expect((agentState as any).base2ActiveWork.openReviewerFindings).toEqual([]) + expect((agentState as any).base2ActiveWork.currentPhase).not.toBe( + 'repair_loop', + ) + expect((agentState as any).base2ActiveWork.currentPhase).not.toBe('blocked') + }) + test('security repair revalidates with security-reviewer before finalization', () => { const base2 = createBase2('default') const agentState = { agentId: 'base2' } @@ -5144,7 +5707,9 @@ describe('base2 verification and reviewer gates', () => { input: { agent_type: 'security-reviewer' }, }) - const blocked = gen.next({ toolResult: [{ type: 'json', value: {} }] } as any) + const blocked = gen.next({ + toolResult: [{ type: 'json', value: {} }], + } as any) expect(blocked.value).toMatchObject({ toolName: 'add_message', input: { role: 'user' }, @@ -5283,8 +5848,9 @@ describe('base2 verification and reviewer gates', () => { expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) expect( - gen.next({ toolResult: [{ type: 'json', value: { status: '' } }] } as any) - .value, + gen.next({ + toolResult: [{ type: 'json', value: { status: '' } }], + } as any).value, ).toMatchObject({ toolName: 'spawn_agent_inline', }) @@ -5658,7 +6224,9 @@ describe('base2 verification and reviewer gates', () => { findingsAddressed: [], requestedValidation: [], completionKind: 'changed', - evidence: ['agents/__tests__/base2.test.ts covers the gate behavior change.'], + evidence: [ + 'agents/__tests__/base2.test.ts covers the gate behavior change.', + ], } const basherValidation = gen.next({ toolResult: [ @@ -5693,9 +6261,7 @@ describe('base2 verification and reviewer gates', () => { } as any).value as any } else { const toolResult = - hookStep.toolName === 'git_status' - ? { status: dirtyStatus } - : {} + hookStep.toolName === 'git_status' ? { status: dirtyStatus } : {} hookStep = gen.next({ toolResult: [{ type: 'json', value: toolResult }], } as any).value as any @@ -5860,9 +6426,9 @@ describe('base2 verification and reviewer gates', () => { expect((agentState as any).base2ActiveWork.currentPhase).not.toBe( 'final_response_allowed', ) - expect((agentState as any).base2ActiveWork.gatePassedReviewerVerdict).not.toBe( - 'NON_BLOCKING', - ) + expect( + (agentState as any).base2ActiveWork.gatePassedReviewerVerdict, + ).not.toBe('NON_BLOCKING') const repairSpawn = gen.next().value as any expect(repairSpawn).toMatchObject({ toolName: 'spawn_agents', @@ -5921,13 +6487,14 @@ describe('base2 verification and reviewer gates', () => { return { gen, agentState, reviewCall, tmpDir, tmpFile, gateFile } } - // Feeds the first NON_BLOCKING reviewer result and the repair-editor - // completion receipt, landing on the second (re-review) spawn_agents call. - // Mirrors the yield sequence of the BLOCKING repair/re-review test above: - // after the repair receipt the generator yields git_status -> - // run_file_change_hooks -> spawn_agent_inline (the re-review code-reviewer) - // -> add_message (pinned active-work, phase awaiting_review) -> STEP, then - // the next loop iteration drives git_status -> list_jobs -> + // Feeds the first reviewer result (NON_BLOCKING by default, or BLOCKING when + // `firstVerdict` is passed so the de-escalation direction can be exercised) + // and the repair-editor completion receipt, landing on the second (re-review) + // spawn_agents call. Both verdict classes take the same repair path, so the + // yield sequence is identical: after the repair receipt the generator yields + // git_status -> run_file_change_hooks -> spawn_agent_inline (the re-review + // code-reviewer) -> add_message (pinned active-work, phase awaiting_review) -> + // STEP, then the next loop iteration drives git_status -> list_jobs -> // run_file_change_hooks -> git_status -> spawn_agents (the second review). function driveThroughRepairToSecondReview( gen: any, @@ -5935,11 +6502,17 @@ describe('base2 verification and reviewer gates', () => { reviewCall: any, tmpFile: string, gateFile: string, - findingSummary: string, + findingSummary: + | string + | Record + | Array>, + firstVerdict: 'NON_BLOCKING' | 'BLOCKING' = 'NON_BLOCKING', ) { - const firstReview = attestedReviewerResult(reviewCall, 'NON_BLOCKING', [ - findingSummary, - ]) + const firstReview = attestedReviewerResult( + reviewCall, + firstVerdict, + Array.isArray(findingSummary) ? findingSummary : [findingSummary], + ) const afterFirst = gen.next(firstReview as any) expect(afterFirst.value).toMatchObject({ toolName: 'add_message', @@ -5953,9 +6526,9 @@ describe('base2 verification and reviewer gates', () => { // The gate mints RF-- finding ids via buildReviewerFindingId; read // them from state (do NOT reuse the reviewer-output id) and make the // repair's byte change real so the no-progress fingerprint guard passes. - const findingIds = (agentState as any).base2ActiveWork.openReviewerFindings.map( - (finding: any) => finding.id, - ) + const findingIds = ( + agentState as any + ).base2ActiveWork.openReviewerFindings.map((finding: any) => finding.id) writeFileSync(tmpFile, 'export const value = 2 // repaired\n') expect( gen.next(completedRepairReceipt(findingIds, [gateFile]) as any).value, @@ -6051,77 +6624,716 @@ describe('base2 verification and reviewer gates', () => { expect((gatePassed.value as any).input.content).toMatch( /reviewer gate passed with LOOKS_GOOD/i, ) - expect( - (agentState as any).base2ActiveWork.currentPhase, - ).toBe('final_response_allowed') - expect( - (agentState as any).base2ActiveWork.openReviewerBlockers, - ).toEqual([]) + expect((agentState as any).base2ActiveWork.currentPhase).toBe( + 'final_response_allowed', + ) + expect((agentState as any).base2ActiveWork.openReviewerBlockers).toEqual( + [], + ) } finally { rmSync(tmpDir, { recursive: true, force: true }) } }) - test('genuinely NEW finding on re-review still blocks and spawns repair-editor', () => { - const findingText = 'Minor style suggestion.' - const newFindingText = 'Missing auth check.' - const { gen, agentState, reviewCall, tmpDir, tmpFile, gateFile } = + // RF-1-2ffc3f6b: behavioral coverage for the advisory wiring. A LOOKS_GOOD + // receipt carrying advisories must (a) persist them (plus advisoryCount) on + // the durable review receipt and (b) surface the SAME bounded list in the + // emitted payload, which is the only user-visible advisory + // surface. Every bound is exercised: a >240-char entry is truncated with + // '...', inner whitespace is collapsed, empty entries are dropped, and only + // the first 8 entries survive. + test('a LOOKS_GOOD receipt with advisories persists and surfaces bounded advisories', () => { + const { gen, agentState, reviewCall, tmpDir, gateFile } = driveToFirstReview() try { - const secondReviewCall = driveThroughRepairToSecondReview( - gen, - agentState, - reviewCall, - tmpFile, - gateFile, - findingText, - ) - // Second reviewer pass returns a DIFFERENT finding (not condoned). - const secondReview = attestedReviewerResult( - secondReviewCall, - 'NON_BLOCKING', - [newFindingText], - ) - const afterSecond = gen.next(secondReview as any) - expect(afterSecond.value).toMatchObject({ + const ansiAdvisory = 'advisory \u001b[31m8\u0000 visible\u007f' + const advisories = [ + 'x'.repeat(300), + ' spaced\n advisory ', + '', + 'advisory 3', + 'advisory 4', + 'advisory 5', + 'advisory 6', + 'advisory 7', + // Carries an ANSI escape plus NUL/DEL: the emitter must strip the + // control bytes while keeping the surrounding visible text. + ansiAdvisory, + 'advisory 9 dropped', + 'advisory 10 dropped', + ] + expect( + gen.next( + attestedReviewerResult( + reviewCall, + 'LOOKS_GOOD', + [], + 'covered', + advisories, + ) as any, + ).value, + ).toMatchObject({ toolName: 'git_status' }) + const gatePassed = gen.next({ + toolResult: [{ type: 'json', value: { status: ` M ${gateFile}` } }], + } as any) + expect(gatePassed.value).toMatchObject({ toolName: 'add_message', input: { role: 'user' }, }) - expect((afterSecond.value as any).input.content).toContain( - `NON_BLOCKING: ${newFindingText}`, - ) - const active = (agentState as any).base2ActiveWork - expect(active.openReviewerBlockers).toContain( - `NON_BLOCKING: ${newFindingText}`, + // The durable receipt keeps the reviewer's bytes (the persistence path is + // unchanged); only the emitted text strips control bytes. + const expectedReceiptAdvisories = [ + `${'x'.repeat(237)}...`, + 'spaced advisory', + 'advisory 3', + 'advisory 4', + 'advisory 5', + 'advisory 6', + 'advisory 7', + ansiAdvisory, + ] + const expectedGateStateAdvisories = [ + ...expectedReceiptAdvisories.slice(0, 7), + 'advisory [31m8 visible', + ] + const receipt = (agentState as any).base2ActiveWork.reviewReceipts[0] + expect(receipt.advisories).toEqual(expectedReceiptAdvisories) + expect(receipt.advisoryCount).toBe(expectedReceiptAdvisories.length) + const gateState = parseGateStateBlock( + (gatePassed.value as any).input.content as string, ) - const repairSpawn = gen.next().value as any - expect(repairSpawn).toMatchObject({ - toolName: 'spawn_agents', - input: { agents: [{ agent_type: 'repair-editor' }] }, + expect(gateState).toMatchObject({ + gate: 'validation/reviewer', + status: 'passed', }) + expect(gateState!.advisories).toEqual(expectedGateStateAdvisories) + for (const advisory of gateState!.advisories ?? []) { + expect(/[\u0000-\u001f\u007f]/.test(advisory)).toBe(false) + } } finally { rmSync(tmpDir, { recursive: true, force: true }) } }) - test('parent-owned requirementCoverage gap is still filtered alongside condoned texts', () => { - const { gen, agentState, reviewCall, tmpDir, gateFile } = - driveToFirstReview() + // Advisories must also reach the user on the NON-gate-pass paths. On the + // blocker/repair path no receipt exists yet (recordSuccessfulReviewReceipt + // only runs once a finalization verdict exists), so the displayed list comes + // from the shared collector and must apply the same bounds as . + test('a blocking reviewer round displays bounded advisories without turning them into blockers', () => { + const { gen, agentState, reviewCall, tmpDir } = driveToFirstReview() try { - // Reviewer returns only a parent-owned requirementCoverage gap (commit - // and push), which must be filtered out by isParentOwnedRequirementBlocker - // and must NOT produce a blocker or a repair spawn. - const prompt = String(reviewCall?.input?.agents?.[0]?.prompt ?? '') - const fingerprint = - prompt.match(/Snapshot fingerprint \(echo exactly\): ([^\n]+)/)?.[1] ?? - '' - const review = { - toolResult: [ - { - type: 'json', - value: [ - { - schemaVersion: 1, + const ansiAdvisory = 'advisory \u001b[31m7\u0000 visible\u007f' + const advisories = [ + 'y'.repeat(300), + ' spaced\n advisory ', + '', + 'advisory 3', + 'advisory 4', + 'advisory 5', + 'advisory 6', + ansiAdvisory, + 'advisory 8', + 'advisory 9 dropped', + 'advisory 10 dropped', + ] + const afterReview = gen.next( + attestedReviewerResult( + reviewCall, + 'BLOCKING', + ['Fix the edge case.'], + 'covered', + advisories, + ) as any, + ) + expect(afterReview.value).toMatchObject({ + toolName: 'add_message', + input: { role: 'user' }, + }) + const content = (afterReview.value as any).input.content as string + expect(content).toContain('BLOCKING: Fix the edge case.') + const lines = content.split('\n') + const headerIndex = lines.indexOf( + 'Advisories (non-blocking; no change required):', + ) + expect(headerIndex).toBeGreaterThan(-1) + const advisoryLines = lines + .slice(headerIndex + 1) + .filter((line) => line.startsWith('- ')) + // Exactly 8 bounded lines: the >240-char entry is truncated, inner + // whitespace is collapsed, the empty entry is dropped, control bytes are + // stripped, and everything past the 8th entry is discarded. + expect(advisoryLines).toEqual([ + `- ${'y'.repeat(237)}...`, + '- spaced advisory', + '- advisory 3', + '- advisory 4', + '- advisory 5', + '- advisory 6', + '- advisory [31m7 visible', + '- advisory 8', + ]) + for (const line of advisoryLines) { + expect(/[\u0000-\u001f\u007f]/.test(line)).toBe(false) + } + expect(content).not.toContain('advisory 9 dropped') + // Purely informational: advisories never enter the blocker/finding + // ledgers, so they cannot drive a repair round or a branch. + const active = (agentState as any).base2ActiveWork + const ledgerTexts = [ + ...((active.openReviewerBlockers ?? []) as string[]), + ...((active.openReviewerFindings ?? []) as Array<{ text: string }>).map( + (finding) => finding.text, + ), + ] + expect(active.openReviewerBlockers).toEqual([ + 'BLOCKING: Fix the edge case.', + ]) + for (const text of ledgerTexts) { + expect(text).not.toContain('advisory') + expect(text).not.toContain('y'.repeat(20)) + } + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + test('a blocking reviewer round with no advisories keeps the blocker message byte-identical', () => { + const { gen, reviewCall, tmpDir } = driveToFirstReview() + try { + const afterReview = gen.next( + attestedReviewerResult(reviewCall, 'BLOCKING', [ + 'Fix the edge case.', + ]) as any, + ) + expect(afterReview.value).toMatchObject({ + toolName: 'add_message', + input: { role: 'user' }, + }) + const content = (afterReview.value as any).input.content as string + // No header, no extra blank line: the empty advisory list adds nothing. + expect(content).toBe( + [ + 'Reviewer gate: code-reviewer returned blocking feedback. The harness will send these exact findings to repair-editor:', + '', + 'BLOCKING: Fix the edge case.', + '', + 'These findings remain open until targeted validation and a fresh matching reviewer pass clear them.', + ].join('\n'), + ) + expect(content).not.toContain('Advisories') + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + // T1.5: condoning is keyed on (verdict class, finding identity), not text + // alone. Under the old text-only key the stored condone entry stripped the + // NON_BLOCKING/BLOCKING prefix, so re-raising the SAME text at a HIGHER + // verdict class was swallowed by the condone filter and the gate finalized on + // an escalation it never repaired. + test('re-raising a condoned NON_BLOCKING text as BLOCKING is not condoned', () => { + const findingText = 'Minor style suggestion.' + const { gen, agentState, reviewCall, tmpDir, tmpFile, gateFile } = + driveToFirstReview() + try { + const secondReviewCall = driveThroughRepairToSecondReview( + gen, + agentState, + reviewCall, + tmpFile, + gateFile, + findingText, + ) + const active = (agentState as any).base2ActiveWork + // The repair round condoned the NON_BLOCKING class only. + expect(active.condonedFindingKeys).toContain( + `NON_BLOCKING::text:${findingText}`, + ) + expect(active.condonedFindingKeys).not.toContain( + `BLOCKING::text:${findingText}`, + ) + // Same text, escalated verdict class: new information, must stay open. + const secondReview = attestedReviewerResult( + secondReviewCall, + 'BLOCKING', + [findingText], + ) + const afterSecond = gen.next(secondReview as any) + expect(afterSecond.value).toMatchObject({ + toolName: 'add_message', + input: { role: 'user' }, + }) + expect((afterSecond.value as any).input.content).toContain( + `BLOCKING: ${findingText}`, + ) + expect(active.openReviewerBlockers).toContain(`BLOCKING: ${findingText}`) + expect(active.currentPhase).not.toBe('final_response_allowed') + expect(gen.next().value).toMatchObject({ + toolName: 'spawn_agents', + input: { agents: [{ agent_type: 'repair-editor' }] }, + }) + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + // Same-class re-raise: a NON_BLOCKING finding already reported as addressed + // and re-raised as NON_BLOCKING carries no new information, so the loop must + // converge. (The de-escalation direction is the separate test below.) + test('a condoned finding re-raised at the same class still converges', () => { + const findingText = 'Minor style suggestion.' + const { gen, agentState, reviewCall, tmpDir, tmpFile, gateFile } = + driveToFirstReview() + try { + const secondReviewCall = driveThroughRepairToSecondReview( + gen, + agentState, + reviewCall, + tmpFile, + gateFile, + findingText, + ) + const secondReview = attestedReviewerResult( + secondReviewCall, + 'NON_BLOCKING', + [findingText], + ) + const afterSecond = gen.next(secondReview as any) + const active = (agentState as any).base2ActiveWork + expect(active.currentPhase).not.toBe('repair_loop') + expect(active.currentPhase).not.toBe('blocked') + // Condoned pass: finalization, not another repair spawn. + expect(afterSecond.value).toMatchObject({ toolName: 'git_status' }) + const gatePassed = gen.next({ + toolResult: [{ type: 'json', value: { status: ` M ${gateFile}` } }], + } as any) + expect((gatePassed.value as any).input.content).toMatch( + /reviewer gate passed with LOOKS_GOOD/i, + ) + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + // RF-1/RF-2/RF-8: de-escalation is the mirror of the escalation case above and + // IS condonable, one-directionally. A finding condoned at BLOCKING and + // re-raised as a NON_BLOCKING nit carries no new information; if it were not + // condoned the blocker list would stay non-empty, another repair-editor would + // be spawned, and the already-applied repair would trip the no-progress guard, + // leaving the gate 'blocked' instead of converging. + test('a finding condoned at BLOCKING and re-raised as NON_BLOCKING still converges', () => { + const findingText = 'Minor style suggestion.' + const { gen, agentState, reviewCall, tmpDir, tmpFile, gateFile } = + driveToFirstReview() + try { + const secondReviewCall = driveThroughRepairToSecondReview( + gen, + agentState, + reviewCall, + tmpFile, + gateFile, + findingText, + 'BLOCKING', + ) + const active = (agentState as any).base2ActiveWork + // The repair round condoned the BLOCKING class only; the NON_BLOCKING key + // is never written, so convergence relies on the de-escalation allowance + // rather than on a same-class key. + expect(active.condonedFindingKeys).toContain( + `BLOCKING::text:${findingText}`, + ) + expect(active.condonedFindingKeys).not.toContain( + `NON_BLOCKING::text:${findingText}`, + ) + // Same identity, DE-ESCALATED verdict class: still condoned. + const secondReview = attestedReviewerResult( + secondReviewCall, + 'NON_BLOCKING', + [findingText], + ) + const afterSecond = gen.next(secondReview as any) + expect(active.currentPhase).not.toBe('repair_loop') + expect(active.currentPhase).not.toBe('blocked') + // Condoned pass: git_status -> gate-passed add_message, no repair spawn. + expect(afterSecond.value).toMatchObject({ toolName: 'git_status' }) + const gatePassed = gen.next({ + toolResult: [{ type: 'json', value: { status: ` M ${gateFile}` } }], + } as any) + expect(gatePassed.value).toMatchObject({ + toolName: 'add_message', + input: { role: 'user' }, + }) + expect((gatePassed.value as any).input.content).toMatch( + /reviewer gate passed with LOOKS_GOOD/i, + ) + expect((agentState as any).base2ActiveWork.currentPhase).toBe( + 'final_response_allowed', + ) + // The first round's BLOCKING blocker string is cleared by the + // condoned-pass cleanup rather than left looking open. + expect((agentState as any).base2ActiveWork.openReviewerBlockers).toEqual( + [], + ) + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + // RF-3: the id half of the condone key only exists for a reviewer-supplied + // (non-minted) finding id, so a suite that only feeds plain-string findings + // never executes that push branch. An object finding carrying a stable id + // must produce `NON_BLOCKING::id:`, a re-raise of that id at BLOCKING + // must stay open, and a co-reported record-less finding (minted `RF-...` id) + // must contribute no `::id:` key at all. + test('an object finding with a stable id records an id-keyed condone key and stays open when re-raised as BLOCKING', () => { + const findingId = 'code-reviewer:correctness:minor-style' + const findingText = 'Minor style suggestion.' + const blockerText = `[${findingId}] ${findingText}` + const plainFindingText = 'A plain-string nit with no id' + const objectFinding = { + id: findingId, + summary: findingText, + severity: 'low', + dimension: 'correctness', + evidence: ['a.ts uses the expected behavior.'], + correction: 'Optional naming cleanup.', + } + const { gen, agentState, reviewCall, tmpDir, tmpFile, gateFile } = + driveToFirstReview() + try { + const secondReviewCall = driveThroughRepairToSecondReview( + gen, + agentState, + reviewCall, + tmpFile, + gateFile, + [objectFinding, plainFindingText], + ) + const active = (agentState as any).base2ActiveWork + const condonedKeys = active.condonedFindingKeys as string[] + // (a) the reviewer-supplied id produced an id-keyed condone entry. + expect(condonedKeys).toContain(`NON_BLOCKING::id:${findingId}`) + expect(condonedKeys).toContain(`NON_BLOCKING::text:${blockerText}`) + // (c) the record-less finding got a minted RF-... id, which is positional + // and therefore never keyed on. + expect(condonedKeys).toContain(`NON_BLOCKING::text:${plainFindingText}`) + expect(condonedKeys.filter((key) => key.includes('::id:'))).toEqual([ + `NON_BLOCKING::id:${findingId}`, + ]) + expect( + condonedKeys.some((key) => /::id:RF-\d+-[0-9a-f]{8}$/.test(key)), + ).toBe(false) + // (b) the same identity re-raised at BLOCKING is new information. + const secondReview = attestedReviewerResult( + secondReviewCall, + 'BLOCKING', + [objectFinding, plainFindingText], + ) + const afterSecond = gen.next(secondReview as any) + expect(afterSecond.value).toMatchObject({ + toolName: 'add_message', + input: { role: 'user' }, + }) + expect((afterSecond.value as any).input.content).toContain( + `BLOCKING: ${blockerText}`, + ) + expect(active.openReviewerBlockers).toContain(`BLOCKING: ${blockerText}`) + expect( + ( + active.openReviewerFindings as Array<{ + id: string + status: string + }> + ).find((finding) => finding.id === findingId)?.status, + ).toBe('open') + expect(active.currentPhase).not.toBe('final_response_allowed') + expect(gen.next().value).toMatchObject({ + toolName: 'spawn_agents', + input: { agents: [{ agent_type: 'repair-editor' }] }, + }) + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + // RF-1: the condoned-pass cleanup used to filter openReviewerBlockers by + // STRIPPED TEXT only. mergeReviewerFindings retains other families' blockers, + // so a security-reviewer `BLOCKING: ` blocker that was never + // condoned was silently dropped when the code-reviewer round credited a + // condoned pass. The cleanup must key on (verdict class, identity) like every + // other condone decision. + test('condoned pass keeps another reviewer BLOCKING blocker that shares the condoned text', () => { + const findingText = 'Minor style suggestion.' + const securityBlocker = `BLOCKING: ${findingText}` + const tmpDir = makeProjectTempDir('base2-condoned-cleanup-') + const tmpFile = join(tmpDir, 'a.ts') + const gateFile = normalizeGateFilePath(tmpFile) + writeFileSync(tmpFile, 'export const value = 1\n') + try { + const base2 = createBase2('default') + const agentState = { + agentId: 'base2-custom', + base2ActiveWork: { + touchedFiles: [gateFile], + changedFiles: [gateFile], + pendingGateFiles: [gateFile], + currentPhase: 'awaiting_validation', + latestWorkSummary: '', + // Security-reviewer blocker whose text matches the condoned + // code-reviewer nit byte-for-byte, at the higher verdict class. + openReviewerBlockers: [securityBlocker], + openReviewerFindings: [ + { + id: 'security-reviewer:containment:same-text', + gateId: 'security-reviewer:prior-snapshot', + text: securityBlocker, + status: 'open', + files: [gateFile], + snapshotFingerprint: 'prior-snapshot', + reviewer: 'security-reviewer', + createdAt: '2025-01-01T00:00:00.000Z', + }, + ], + lastValidationSummary: '', + nextRequiredAction: '', + lastPinnedStateMessage: '', + gatePassedFiles: [], + gatePassedPendingFiles: [], + gatePassedReviewerVerdict: '', + gatePassedValidationSummary: '', + gatePassedFingerprint: '', + lastReviewerGateSkipReason: '', + reviewReceipts: [], + // A prior code-reviewer repair round condoned the NON_BLOCKING nit. + condonedFindingTexts: [findingText], + condonedFindingKeys: [`NON_BLOCKING::text:${findingText}`], + // Keep the aux gates out of this turn; only the final gate matters. + testWriterGateDone: true, + docWriterGateDone: true, + securityReviewGateDone: true, + preEditSecurityReviewDone: true, + specialistReviewGatesDone: [], + auxGatesLastPendingFiles: [gateFile], + }, + } + const gen = base2.handleSteps!({ + agentState, + prompt: 'Finish the pending review.', + params: {}, + } as any) + expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) + expect( + gen.next(feedJson({ status: ` M ${gateFile}` })).value, + ).toMatchObject({ toolName: 'spawn_agent_inline' }) + const maybePinned = gen.next().value + if (maybePinned !== 'STEP') { + expect(maybePinned).toMatchObject({ toolName: 'add_message' }) + expect(gen.next().value).toBe('STEP') + } + expect(gen.next(finishStepWithToolResult({})).value).toMatchObject({ + toolName: 'git_status', + }) + expect( + gen.next(feedJson({ status: ` M ${gateFile}` })).value, + ).toMatchObject({ toolName: 'run_file_change_hooks' }) + expect( + gen.next({ toolResult: [{ type: 'json', value: [] }] } as any).value, + ).toMatchObject({ toolName: 'git_status' }) + const reviewCall = gen.next(feedJson({ status: ` M ${gateFile}` })) + .value as any + expect(reviewCall).toMatchObject({ toolName: 'spawn_agents' }) + // Every collected blocker is condoned, so the round credits a condoned + // pass and runs the cleanup instead of spawning repair-editor. + const afterReview = gen.next( + attestedReviewerResult(reviewCall, 'NON_BLOCKING', [ + findingText, + ]) as any, + ) + expect(afterReview.value).toMatchObject({ toolName: 'git_status' }) + const active = (agentState as any).base2ActiveWork + // The never-condoned security blocker survives the cleanup. + expect(active.openReviewerBlockers).toContain(securityBlocker) + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + // RF-2: the security/specialist record shape used to store the UNPREFIXED + // record text, so reviewerVerdictClass returned the `*` wildcard and a repair + // round recorded `*::text:` / `*::id:` keys. With the old wildcard match those + // condoned ANY later class, so a security nit re-raised as BLOCKING was + // swallowed. The record now stores the prefixed blocker string and `*` matches + // only `*`, so the escalation stays open. + test('a security finding re-raised at BLOCKING is not condoned by a class-agnostic condone key', () => { + const findingId = 'security-reviewer:containment:fixture-path' + const findingText = 'Reject nested fixture paths.' + const securityFile = 'sdk/src/policy/terminal-command-policy.ts' + const blocker = `BLOCKING: [${findingId}] ${findingText}` + const base2 = createBase2('default') + const agentState = { + agentId: 'base2', + base2ActiveWork: { + touchedFiles: [], + changedFiles: [], + pendingGateFiles: [], + currentPhase: 'idle', + latestWorkSummary: '', + openReviewerBlockers: [], + openReviewerFindings: [], + lastValidationSummary: '', + nextRequiredAction: '', + lastPinnedStateMessage: '', + gatePassedFiles: [], + gatePassedPendingFiles: [], + gatePassedReviewerVerdict: '', + gatePassedValidationSummary: '', + gatePassedFingerprint: '', + lastReviewerGateSkipReason: '', + reviewReceipts: [], + // A prior repair round condoned this finding as a NON_BLOCKING nit. + // Both the class-keyed entries and the legacy class-agnostic `*` + // entries (what the pre-fix security path recorded from its unprefixed + // record text) are seeded; neither may condone a BLOCKING re-raise. + condonedFindingTexts: [findingText, `[${findingId}] ${findingText}`], + condonedFindingKeys: [ + `NON_BLOCKING::text:[${findingId}] ${findingText}`, + `NON_BLOCKING::id:${findingId}`, + `*::text:[${findingId}] ${findingText}`, + `*::text:${findingText}`, + `*::id:${findingId}`, + ], + }, + } + const gen = base2.handleSteps!({ + agentState, + prompt: `Update ${securityFile}.`, + params: {}, + } as any) + + expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) + expect( + gen.next({ toolResult: [{ type: 'json', value: { status: '' } }] } as any) + .value, + ).toMatchObject({ toolName: 'spawn_agent_inline' }) + expect(gen.next().value).toBe('STEP') + expect( + gen.next({ + stepsComplete: true, + toolResult: [{ type: 'json', value: editReceipt(securityFile) }], + } as any).value, + ).toMatchObject({ toolName: 'git_status' }) + const securityReview = gen.next({ + toolResult: [{ type: 'json', value: { status: ` M ${securityFile}` } }], + } as any) + expect(securityReview.value).toMatchObject({ + toolName: 'spawn_agent_inline', + input: { agent_type: 'security-reviewer' }, + }) + const snapshotFingerprint = ( + (securityReview.value as any).input.prompt as string + ) + .split('Snapshot fingerprint: ')[1] + .split('\n')[0] + const blockerMessage = gen.next({ + toolResult: [ + { + type: 'json', + value: { + schemaVersion: 1, + verdict: 'BLOCKING', + snapshotFingerprint, + reviewedFiles: [securityFile], + findings: [{ id: findingId, summary: findingText }], + coverage: 'covered', + dimensions: {}, + requirementCoverage: [], + }, + }, + ], + } as any) + + expect(blockerMessage.value).toMatchObject({ toolName: 'add_message' }) + const active = (agentState as any).base2ActiveWork + expect(active.openReviewerBlockers).toContain(blocker) + const escalated = ( + active.openReviewerFindings as Array<{ + id: string + text: string + status: string + }> + ).find((finding) => finding.id === findingId) + // The record carries the PREFIXED blocker string, so the derived class is + // BLOCKING rather than the class-agnostic `*`. + expect(escalated?.text).toBe(blocker) + expect(escalated?.status).toBe('open') + expect(active.currentPhase).toBe('repair_loop') + expect(gen.next().value).toMatchObject({ + toolName: 'spawn_agents', + input: { agents: [{ agent_type: 'repair-editor' }] }, + }) + }) + + test('genuinely NEW finding on re-review still blocks and spawns repair-editor', () => { + const findingText = 'Minor style suggestion.' + const newFindingText = 'Missing auth check.' + const { gen, agentState, reviewCall, tmpDir, tmpFile, gateFile } = + driveToFirstReview() + try { + const secondReviewCall = driveThroughRepairToSecondReview( + gen, + agentState, + reviewCall, + tmpFile, + gateFile, + findingText, + ) + // Second reviewer pass returns a DIFFERENT finding (not condoned). + const secondReview = attestedReviewerResult( + secondReviewCall, + 'NON_BLOCKING', + [newFindingText], + ) + const afterSecond = gen.next(secondReview as any) + expect(afterSecond.value).toMatchObject({ + toolName: 'add_message', + input: { role: 'user' }, + }) + expect((afterSecond.value as any).input.content).toContain( + `NON_BLOCKING: ${newFindingText}`, + ) + const active = (agentState as any).base2ActiveWork + expect(active.openReviewerBlockers).toContain( + `NON_BLOCKING: ${newFindingText}`, + ) + const repairSpawn = gen.next().value as any + expect(repairSpawn).toMatchObject({ + toolName: 'spawn_agents', + input: { agents: [{ agent_type: 'repair-editor' }] }, + }) + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + test('parent-owned requirementCoverage gap is still filtered alongside condoned texts', () => { + const { gen, agentState, reviewCall, tmpDir, gateFile } = + driveToFirstReview() + try { + // Reviewer returns only a parent-owned requirementCoverage gap (commit + // and push), which must be filtered out via + // collectParentOwnedRequirementBlockers and must NOT produce a blocker or + // a repair spawn. + const prompt = String(reviewCall?.input?.agents?.[0]?.prompt ?? '') + const fingerprint = + prompt.match(/Snapshot fingerprint \(echo exactly\): ([^\n]+)/)?.[1] ?? + '' + const review = { + toolResult: [ + { + type: 'json', + value: [ + { + schemaVersion: 1, verdict: 'NON_BLOCKING', snapshotFingerprint: fingerprint, reviewedFiles: [gateFile], @@ -6143,33 +7355,188 @@ describe('base2 verification and reviewer gates', () => { const afterReview = gen.next(review as any) const active = (agentState as any).base2ActiveWork // The parent-owned requirementCoverage gap (commit and push) is filtered - // out by isParentOwnedRequirementBlocker, so it is NOT elevated as a - // blocker. The only blocker present is the synthetic NON_BLOCKING - // empty-findings placeholder from collectReviewerBlockers, which is - // expected because the reviewer returned NON_BLOCKING with zero findings. + // out by collectParentOwnedRequirementBlockers, so it is never elevated as + // a blocker. Because a parent-owned row is not repair fuel, + // collectReviewerBlockers still emits the synthetic NON_BLOCKING + // empty-findings placeholder, and THAT survives the filter: NON_BLOCKING + // is not a finalization credit, so an empty surviving list would leave the + // gate with no repair target and a misdiagnosed no-verdict loop. const blockers = (active.openReviewerBlockers ?? []) as string[] - // The parent-owned requirementCoverage gap (commit and push) is filtered - // out by isParentOwnedRequirementBlocker, and because the reviewer - // returned NON_BLOCKING with zero findings, no synthetic placeholder is - // elevated either. The blockers list is empty. expect( - blockers.some((blocker: string) => - /BLOCKING:\s*requirement\s+missing:\s*commit and push/i.test(blocker), - ), - ).toBe(false) - expect(blockers).toHaveLength(0) - // The parent-owned filter removed the only gap, so no repair-editor - // spawn follows. The NON_BLOCKING verdict itself is not a finalization - // credit (LOOKS_GOOD only), so the gate continues the reviewer loop - // rather than finalizing; this test only asserts the parent-owned - // requirement gap never became a blocker or a repair spawn. - const nextYield = afterReview.value as any - const isRepairSpawn = - nextYield && - typeof nextYield === 'object' && - nextYield.toolName === 'spawn_agents' && - nextYield.input?.agents?.[0]?.agent_type === 'repair-editor' - expect(isRepairSpawn).toBe(false) + blockers.some((blocker: string) => + /BLOCKING:\s*requirement\s+missing:\s*commit and push/i.test(blocker), + ), + ).toBe(false) + expect(blockers).toEqual([ + 'NON_BLOCKING: reviewer returned non-blocking nits without findings; re-address and re-review until LOOKS_GOOD', + ]) + // The surviving placeholder is not a coverage finding, so the gate drives + // the ordinary repair-editor round instead of burning the no-verdict + // budget with zero repair targets. + expect(afterReview.value).toMatchObject({ + toolName: 'add_message', + input: { role: 'user' }, + }) + expect((afterReview.value as any).input.content).toContain( + 'NON_BLOCKING: reviewer returned non-blocking nits without findings', + ) + expect(gen.next().value).toMatchObject({ + toolName: 'spawn_agents', + input: { agents: [{ agent_type: 'repair-editor' }] }, + }) + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + // T0.2: condone credit is orchestrator-owned evidence, not reviewer/repair + // self-report. A receipt that lists finding ids but is not `completed` (or + // changed no files) must not condone anything, so the finding stays open and + // the next re-review re-elevates it. + test('blocked repair receipt does not condone the findings it claims to have addressed', () => { + const findingText = 'Minor style suggestion.' + const { gen, agentState, reviewCall, tmpDir, tmpFile, gateFile } = + driveToFirstReview() + try { + const firstReview = attestedReviewerResult(reviewCall, 'NON_BLOCKING', [ + findingText, + ]) + expect(gen.next(firstReview as any).value).toMatchObject({ + toolName: 'add_message', + input: { role: 'user' }, + }) + expect(gen.next().value).toMatchObject({ + toolName: 'spawn_agents', + input: { agents: [{ agent_type: 'repair-editor' }] }, + }) + const findingIds = ( + agentState as any + ).base2ActiveWork.openReviewerFindings.map((finding: any) => finding.id) + // Real byte change so the no-progress fingerprint guard does not fire for + // an unrelated reason; the receipt status is what must block condoning. + writeFileSync(tmpFile, 'export const value = 2 // partially repaired\n') + const blockedReceipt = repairSpawnReport({ + receiptId: 'repair-blocked-claiming-findings', + status: 'blocked', + changedFiles: [{ path: gateFile }], + findingsAddressed: findingIds, + value: { + status: 'blocked', + changedFiles: [{ path: gateFile }], + findingsAddressed: findingIds, + }, + }) + expect(gen.next(blockedReceipt as any).value).toMatchObject({ + toolName: 'git_status', + }) + // The claim was rejected: nothing is condoned, so the finding stays open. + expect( + (agentState as any).base2ActiveWork.condonedFindingTexts ?? [], + ).toEqual([]) + // Drive the re-review and confirm the same finding still blocks. + expect( + gen.next({ + toolResult: [{ type: 'json', value: { status: ` M ${gateFile}` } }], + } as any).value, + ).toMatchObject({ toolName: 'run_file_change_hooks' }) + expect( + gen.next({ + toolResult: [ + { + type: 'json', + value: [{ hookName: 'typecheck', exitCode: 0, stdout: 'ok' }], + }, + ], + } as any).value, + ).toMatchObject({ toolName: 'spawn_agent_inline' }) + expect(gen.next().value).toMatchObject({ toolName: 'add_message' }) + expect(gen.next().value).toBe('STEP') + expect( + gen.next({ stepsComplete: true, toolResult: [] } as any).value, + ).toMatchObject({ toolName: 'git_status' }) + expect( + gen.next({ + toolResult: [{ type: 'json', value: { status: ` M ${gateFile}` } }], + } as any).value, + ).toMatchObject({ toolName: 'run_file_change_hooks' }) + expect( + gen.next({ toolResult: [{ type: 'json', value: [] }] } as any).value, + ).toMatchObject({ toolName: 'git_status' }) + const secondReviewCall = gen.next({ + toolResult: [{ type: 'json', value: { status: ` M ${gateFile}` } }], + } as any).value as any + expect(secondReviewCall).toMatchObject({ toolName: 'spawn_agents' }) + const secondReview = attestedReviewerResult( + secondReviewCall, + 'NON_BLOCKING', + [findingText], + ) + const afterSecond = gen.next(secondReview as any) + expect(afterSecond.value).toMatchObject({ + toolName: 'add_message', + input: { role: 'user' }, + }) + expect((afterSecond.value as any).input.content).toContain( + `NON_BLOCKING: ${findingText}`, + ) + expect( + (agentState as any).base2ActiveWork.openReviewerBlockers, + ).toContain(`NON_BLOCKING: ${findingText}`) + expect(gen.next().value).toMatchObject({ + toolName: 'spawn_agents', + input: { agents: [{ agent_type: 'repair-editor' }] }, + }) + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + test('completed repair receipt with no changed files does not condone its findings', () => { + const findingText = 'Minor style suggestion.' + const { gen, agentState, reviewCall, tmpDir, gateFile } = + driveToFirstReview() + try { + const firstReview = attestedReviewerResult(reviewCall, 'NON_BLOCKING', [ + findingText, + ]) + expect(gen.next(firstReview as any).value).toMatchObject({ + toolName: 'add_message', + input: { role: 'user' }, + }) + expect(gen.next().value).toMatchObject({ + toolName: 'spawn_agents', + input: { agents: [{ agent_type: 'repair-editor' }] }, + }) + const findingIds = ( + agentState as any + ).base2ActiveWork.openReviewerFindings.map((finding: any) => finding.id) + // status: 'completed' and every id claimed, but zero changed files: the + // receipt has no positive evidence, so the claim is rejected. + const noChangeReceipt = repairSpawnReport({ + receiptId: 'repair-completed-no-changes', + status: 'completed', + changedFiles: [], + findingsAddressed: findingIds, + value: { + status: 'completed', + changedFiles: [], + findingsAddressed: findingIds, + }, + }) + expect(gen.next(noChangeReceipt as any).value).toMatchObject({ + toolName: 'git_status', + }) + expect( + (agentState as any).base2ActiveWork.condonedFindingTexts ?? [], + ).toEqual([]) + // The repair changed no bytes, so the no-progress guard blocks the gate + // instead of crediting a condoned pass. + gen.next({ + toolResult: [{ type: 'json', value: { status: ` M ${gateFile}` } }], + } as any) + const active = (agentState as any).base2ActiveWork + expect(active.condonedFindingTexts ?? []).toEqual([]) + expect(active.currentPhase).not.toBe('final_response_allowed') } finally { rmSync(tmpDir, { recursive: true, force: true }) } @@ -6431,9 +7798,7 @@ describe('base2 gate-passed credit ledger (Option A)', () => { ).toMatchObject({ toolName: 'git_status' }) expect( gen.next({ - toolResult: [ - { type: 'json', value: { status: ` M ${gateFile}` } }, - ], + toolResult: [{ type: 'json', value: { status: ` M ${gateFile}` } }], } as any).value, ).toMatchObject({ toolName: 'run_file_change_hooks', @@ -6444,18 +7809,14 @@ describe('base2 gate-passed credit ledger (Option A)', () => { } as any).value, ).toMatchObject({ toolName: 'git_status' }) const reviewCall = gen.next({ - toolResult: [ - { type: 'json', value: { status: ` M ${gateFile}` } }, - ], + toolResult: [{ type: 'json', value: { status: ` M ${gateFile}` } }], } as any).value expect(reviewCall).toMatchObject({ toolName: 'spawn_agents' }) expect( gen.next(attestedReviewerResult(reviewCall) as any).value, ).toMatchObject({ toolName: 'git_status' }) const gatePassed = gen.next({ - toolResult: [ - { type: 'json', value: { status: ` M ${gateFile}` } }, - ], + toolResult: [{ type: 'json', value: { status: ` M ${gateFile}` } }], } as any) expect(gatePassed.value).toMatchObject({ toolName: 'add_message' }) @@ -6839,9 +8200,7 @@ describe('base2 gate-passed credit ledger (Option A)', () => { // Current marker is not 'missing' (the file reads as unreadable/error), // so the stale stored-'missing' credit is evicted and republished. - expect( - (agentState as any).base2ActiveWork.gatePassedFiles, - ).toEqual([]) + expect((agentState as any).base2ActiveWork.gatePassedFiles).toEqual([]) expect((agentState as any).base2ActiveWork.currentPhase).toBe( 'awaiting_validation', ) @@ -6932,7 +8291,9 @@ describe('base2 validation-first reviewer snapshots', () => { toolResult: [{ type: 'json', value: { status: ' M src/a.ts' } }], } as any) const validation = validationJobs - expect(validation.value).toMatchObject({ toolName: 'run_file_change_hooks' }) + expect(validation.value).toMatchObject({ + toolName: 'run_file_change_hooks', + }) const postValidationStatus = gen.next({ toolResult: [{ type: 'json', value: [] }], @@ -6945,7 +8306,9 @@ describe('base2 validation-first reviewer snapshots', () => { toolName: 'spawn_agents', input: { agents: [{ agent_type: 'code-reviewer' }] }, }) - expect((review.value as any).input.agents[0]).not.toHaveProperty('background') + expect((review.value as any).input.agents[0]).not.toHaveProperty( + 'background', + ) }) }) @@ -7008,7 +8371,8 @@ describe('base2 repair-loop gate-state telemetry (M6.4)', () => { ).toEqual(expect.arrayContaining(['src/a.ts', 'src/**/*'])) expect( new Set( - repairSpawn.input.agents[0].handoff.permissions.readablePaths as string[], + repairSpawn.input.agents[0].handoff.permissions + .readablePaths as string[], ), ).toEqual(new Set(['src/a.ts', 'src/**/*'])) expect( @@ -7183,9 +8547,7 @@ describe('base2 test-writer aux-gate completion path', () => { const basherValidation = validReceipt.value as any expect(basherValidation).toMatchObject({ toolName: 'spawn_agents' }) gen.next({ toolResult: [{ type: 'json', value: [] }] } as any) - expect( - (agentState as any).base2ActiveWork.testWriterGateDone, - ).toBe(true) + expect((agentState as any).base2ActiveWork.testWriterGateDone).toBe(true) // After the test-writer gate completes, the generator continues the loop // and reaches run_file_change_hooks (the final validation/reviewer gate). // It may yield a spawn_agents (basher validation command from the test @@ -7196,7 +8558,10 @@ describe('base2 test-writer aux-gate completion path', () => { let guard = 0 while ( step && - !(step.toolName === 'run_file_change_hooks' || step.toolName === 'git_status') && + !( + step.toolName === 'run_file_change_hooks' || + step.toolName === 'git_status' + ) && guard++ < 10 ) { step = gen.next({ toolResult: [{ type: 'json', value: {} }] } as any) @@ -7261,12 +8626,10 @@ describe('base2 test-writer aux-gate completion path', () => { const afterInvalid = gen.next({ toolResult: [{ type: 'json', value: {} }], } as any) - expect( - (agentState as any).base2ActiveWork.testWriterGateDone, - ).toBe(true) - expect( - (agentState as any).base2ActiveWork.validationAssurance, - ).toBe('reduced') + expect((agentState as any).base2ActiveWork.testWriterGateDone).toBe(true) + expect((agentState as any).base2ActiveWork.validationAssurance).toBe( + 'reduced', + ) // The gate must not re-spawn the test-writer; it proceeds past the aux // gate. The next yield may be another aux gate (e.g. doc-writer) but must // not be a test-writer re-spawn. @@ -7468,71 +8831,468 @@ describe('resolveMaxSpecialistRepairRounds', () => { }) }) -describe('createBase2 maxReviewerRepairRounds option/env', () => { +describe('createBase2 maxReviewerRepairRounds option/env', () => { + test('option is stored on programmaticConfig', () => { + const base2 = createBase2('default', { maxReviewerRepairRounds: 10 }) + expect(base2.programmaticConfig).toMatchObject({ + maxReviewerRepairRounds: 10, + }) + }) + + test('env string is used when option is omitted', () => { + const previous = process.env.OPENBUFF_MAX_REVIEWER_REPAIR_ROUNDS + try { + process.env.OPENBUFF_MAX_REVIEWER_REPAIR_ROUNDS = '8' + const base2 = createBase2('default') + expect(base2.programmaticConfig).toMatchObject({ + maxReviewerRepairRounds: 8, + }) + } finally { + if (previous === undefined) { + delete process.env.OPENBUFF_MAX_REVIEWER_REPAIR_ROUNDS + } else { + process.env.OPENBUFF_MAX_REVIEWER_REPAIR_ROUNDS = previous + } + } + }) + + test('option wins over env', () => { + const previous = process.env.OPENBUFF_MAX_REVIEWER_REPAIR_ROUNDS + try { + process.env.OPENBUFF_MAX_REVIEWER_REPAIR_ROUNDS = '8' + const base2 = createBase2('default', { maxReviewerRepairRounds: 4 }) + expect(base2.programmaticConfig).toMatchObject({ + maxReviewerRepairRounds: 4, + }) + } finally { + if (previous === undefined) { + delete process.env.OPENBUFF_MAX_REVIEWER_REPAIR_ROUNDS + } else { + process.env.OPENBUFF_MAX_REVIEWER_REPAIR_ROUNDS = previous + } + } + }) + + test('createBase2({ maxReviewerRepairRounds: 2 }) exhausts when count is seeded at 2', () => { + const base2 = createBase2('default', { maxReviewerRepairRounds: 2 }) + expect(base2.programmaticConfig).toMatchObject({ + maxReviewerRepairRounds: 2, + }) + const agentState = { + agentId: 'base2', + base2ActiveWork: { reviewerRepairRoundCount: 2 }, + } + const gen = base2.handleSteps!({ + agentState, + prompt: 'Make the requested change now please', + params: {}, + config: base2.programmaticConfig, + } as any) + + expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) + expect( + gen.next({ toolResult: [{ type: 'json', value: { status: '' } }] } as any) + .value, + ).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + const maybePinned = gen.next().value + if (maybePinned !== 'STEP') { + expect(maybePinned).toMatchObject({ toolName: 'add_message' }) + expect(gen.next().value).toBe('STEP') + } + expect( + gen.next({ + stepsComplete: true, + toolResult: [{ type: 'json', value: editReceipt('src/a.ts') }], + } as any).value, + ).toMatchObject({ toolName: 'git_status' }) + expect( + gen.next({ + toolResult: [{ type: 'json', value: { status: ' M src/a.ts' } }], + } as any).value, + ).toMatchObject({ + toolName: 'run_file_change_hooks', + }) + const postValidationStatus = gen.next({ + toolResult: [{ type: 'json', value: [] }], + } as any).value + expect(postValidationStatus).toMatchObject({ toolName: 'git_status' }) + const reviewCall = gen.next({ + toolResult: [{ type: 'json', value: { status: ' M src/a.ts' } }], + } as any).value + expect(reviewCall).toMatchObject({ toolName: 'spawn_agents' }) + const exhausted = gen.next( + attestedReviewerResult(reviewCall, 'BLOCKING', [ + 'Fix the persistent edge case.', + ]) as any, + ) + + expect(exhausted.value).toMatchObject({ + toolName: 'add_message', + input: { role: 'user' }, + }) + expect((exhausted.value as any).input.content).toContain( + 'automated repair budget exhausted', + ) + expect((agentState as any).base2ActiveWork.currentPhase).toBe('blocked') + expect(gen.next().done).toBe(true) + }) +}) + +describe('createBase2 maxRepairRounds option/env', () => { + test('option is stored on programmaticConfig', () => { + const base2 = createBase2('default', { maxRepairRounds: 5 }) + expect(base2.programmaticConfig).toMatchObject({ + maxRepairRounds: 5, + }) + }) + + test('env string is used when option is omitted', () => { + const previous = process.env.OPENBUFF_MAX_REPAIR_ROUNDS + try { + process.env.OPENBUFF_MAX_REPAIR_ROUNDS = '7' + const base2 = createBase2('default') + expect(base2.programmaticConfig).toMatchObject({ + maxRepairRounds: 7, + }) + } finally { + if (previous === undefined) { + delete process.env.OPENBUFF_MAX_REPAIR_ROUNDS + } else { + process.env.OPENBUFF_MAX_REPAIR_ROUNDS = previous + } + } + }) + + test('option wins over env', () => { + const previous = process.env.OPENBUFF_MAX_REPAIR_ROUNDS + try { + process.env.OPENBUFF_MAX_REPAIR_ROUNDS = '7' + const base2 = createBase2('default', { maxRepairRounds: 2 }) + expect(base2.programmaticConfig).toMatchObject({ + maxRepairRounds: 2, + }) + } finally { + if (previous === undefined) { + delete process.env.OPENBUFF_MAX_REPAIR_ROUNDS + } else { + process.env.OPENBUFF_MAX_REPAIR_ROUNDS = previous + } + } + }) + + test('default createBase2 stores unlimited (null) repair budgets on programmaticConfig', () => { + const base2 = createBase2('default') + expect(base2.programmaticConfig).toMatchObject({ + maxRepairRounds: null, + maxReviewerRepairRounds: null, + maxSpecialistRepairRounds: null, + }) + }) +}) + +describe('createBase2 maxSpecialistRepairRounds option/env', () => { test('option is stored on programmaticConfig', () => { - const base2 = createBase2('default', { maxReviewerRepairRounds: 10 }) + const base2 = createBase2('default', { maxSpecialistRepairRounds: 5 }) expect(base2.programmaticConfig).toMatchObject({ - maxReviewerRepairRounds: 10, + maxSpecialistRepairRounds: 5, }) }) test('env string is used when option is omitted', () => { - const previous = process.env.OPENBUFF_MAX_REVIEWER_REPAIR_ROUNDS + const previous = process.env.OPENBUFF_MAX_SPECIALIST_REPAIR_ROUNDS try { - process.env.OPENBUFF_MAX_REVIEWER_REPAIR_ROUNDS = '8' + process.env.OPENBUFF_MAX_SPECIALIST_REPAIR_ROUNDS = '7' const base2 = createBase2('default') expect(base2.programmaticConfig).toMatchObject({ - maxReviewerRepairRounds: 8, + maxSpecialistRepairRounds: 7, }) } finally { if (previous === undefined) { - delete process.env.OPENBUFF_MAX_REVIEWER_REPAIR_ROUNDS + delete process.env.OPENBUFF_MAX_SPECIALIST_REPAIR_ROUNDS } else { - process.env.OPENBUFF_MAX_REVIEWER_REPAIR_ROUNDS = previous + process.env.OPENBUFF_MAX_SPECIALIST_REPAIR_ROUNDS = previous } } }) test('option wins over env', () => { - const previous = process.env.OPENBUFF_MAX_REVIEWER_REPAIR_ROUNDS + const previous = process.env.OPENBUFF_MAX_SPECIALIST_REPAIR_ROUNDS try { - process.env.OPENBUFF_MAX_REVIEWER_REPAIR_ROUNDS = '8' - const base2 = createBase2('default', { maxReviewerRepairRounds: 4 }) + process.env.OPENBUFF_MAX_SPECIALIST_REPAIR_ROUNDS = '7' + const base2 = createBase2('default', { maxSpecialistRepairRounds: 2 }) expect(base2.programmaticConfig).toMatchObject({ - maxReviewerRepairRounds: 4, + maxSpecialistRepairRounds: 2, }) } finally { if (previous === undefined) { - delete process.env.OPENBUFF_MAX_REVIEWER_REPAIR_ROUNDS + delete process.env.OPENBUFF_MAX_SPECIALIST_REPAIR_ROUNDS } else { - process.env.OPENBUFF_MAX_REVIEWER_REPAIR_ROUNDS = previous + process.env.OPENBUFF_MAX_SPECIALIST_REPAIR_ROUNDS = previous } } }) +}) - test('createBase2({ maxReviewerRepairRounds: 2 }) exhausts when count is seeded at 2', () => { - const base2 = createBase2('default', { maxReviewerRepairRounds: 2 }) - expect(base2.programmaticConfig).toMatchObject({ - maxReviewerRepairRounds: 2, +describe('base2 reviewer repair budget cap', () => { + test('default unlimited does not exhaust at seed count 6', () => { + // With unlimited default, seeding reviewerRepairRoundCount at the old + // default (6) must NOT exhaust; the loop continues into repair-editor. + const base2 = createBase2('default') + const agentState = { + agentId: 'base2', + base2ActiveWork: { + reviewerRepairRoundCount: 6, + }, + } + const gen = base2.handleSteps!({ + agentState, + prompt: 'Make the requested change now please', + params: {}, + config: base2.programmaticConfig, + } as any) + + expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) + expect( + gen.next({ toolResult: [{ type: 'json', value: { status: '' } }] } as any) + .value, + ).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + const maybePinned = gen.next().value + if (maybePinned !== 'STEP') { + expect(maybePinned).toMatchObject({ toolName: 'add_message' }) + expect(gen.next().value).toBe('STEP') + } + expect( + gen.next({ + stepsComplete: true, + toolResult: [{ type: 'json', value: editReceipt('src/a.ts') }], + } as any).value, + ).toMatchObject({ toolName: 'git_status' }) + expect( + gen.next({ + toolResult: [{ type: 'json', value: { status: ' M src/a.ts' } }], + } as any).value, + ).toMatchObject({ + toolName: 'run_file_change_hooks', + }) + const postValidationStatus = gen.next({ + toolResult: [{ type: 'json', value: [] }], + } as any).value + expect(postValidationStatus).toMatchObject({ toolName: 'git_status' }) + const reviewCall = gen.next({ + toolResult: [{ type: 'json', value: { status: ' M src/a.ts' } }], + } as any).value + expect(reviewCall).toMatchObject({ toolName: 'spawn_agents' }) + const afterReview = gen.next( + attestedReviewerResult(reviewCall, 'BLOCKING', [ + 'Fix the persistent edge case.', + ]) as any, + ) + + expect(afterReview.value).toMatchObject({ + toolName: 'add_message', + input: { role: 'user' }, + }) + expect((afterReview.value as any).input.content).not.toContain( + 'automated repair budget exhausted', + ) + expect(gen.next().value).toMatchObject({ + toolName: 'spawn_agents', + input: { agents: [{ agent_type: 'repair-editor' }] }, }) + }) + + test('explicit small maxReviewerRepairRounds still exhausts as opt-in cap', () => { + const base2 = createBase2('default', { maxReviewerRepairRounds: 1 }) const agentState = { agentId: 'base2', - base2ActiveWork: { reviewerRepairRoundCount: 2 }, + base2ActiveWork: { + reviewerRepairRoundCount: 1, + }, + } + const gen = base2.handleSteps!({ + agentState, + prompt: 'Make the requested change now please', + params: {}, + config: base2.programmaticConfig, + } as any) + + expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) + expect( + gen.next({ toolResult: [{ type: 'json', value: { status: '' } }] } as any) + .value, + ).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + const maybePinned = gen.next().value + if (maybePinned !== 'STEP') { + expect(maybePinned).toMatchObject({ toolName: 'add_message' }) + expect(gen.next().value).toBe('STEP') } + expect( + gen.next({ + stepsComplete: true, + toolResult: [{ type: 'json', value: editReceipt('src/a.ts') }], + } as any).value, + ).toMatchObject({ toolName: 'git_status' }) + expect( + gen.next({ + toolResult: [{ type: 'json', value: { status: ' M src/a.ts' } }], + } as any).value, + ).toMatchObject({ + toolName: 'run_file_change_hooks', + }) + const postValidationStatus = gen.next({ + toolResult: [{ type: 'json', value: [] }], + } as any).value + expect(postValidationStatus).toMatchObject({ toolName: 'git_status' }) + const reviewCall = gen.next({ + toolResult: [{ type: 'json', value: { status: ' M src/a.ts' } }], + } as any).value + expect(reviewCall).toMatchObject({ toolName: 'spawn_agents' }) + const exhausted = gen.next( + attestedReviewerResult(reviewCall, 'BLOCKING', [ + 'Fix the persistent edge case.', + ]) as any, + ) + + expect(exhausted.value).toMatchObject({ + toolName: 'add_message', + input: { role: 'user' }, + }) + expect((exhausted.value as any).input.content).toContain( + 'automated repair budget exhausted', + ) + expect((agentState as any).base2ActiveWork.currentPhase).toBe('blocked') + expect(gen.next().done).toBe(true) + }) +}) + +describe('base2 content-based reviewer finding correlation', () => { + test('security-reviewer findings correlate to their record by content, not positional index', () => { + // The security-reviewer blocking path builds openReviewerFindings from the + // synthesized blocker strings. collectReviewerBlockers emits a blocker for + // a plain string finding (which has NO finding record) alongside a blocker + // for an object finding (which does), so the two arrays no longer line up + // positionally. Positional records[index] correlation would attach the + // object finding's id/text to the plain-string blocker; content-based + // correlation must attach each record to the blocker whose text/id it + // actually matches, and the record-less blocker must fall back to an + // RF-... id with its own blocker text. + const base2 = createBase2('default') + const agentState = { agentId: 'base2' } + const gen = base2.handleSteps!({ + agentState, + prompt: 'Update sdk/src/policy/terminal-command-policy.ts.', + params: {}, + } as any) + + expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) + expect( + gen.next({ toolResult: [{ type: 'json', value: { status: '' } }] } as any) + .value, + ).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + expect(gen.next().value).toBe('STEP') + expect( + gen.next({ + stepsComplete: true, + toolResult: [ + { + type: 'json', + value: editReceipt('sdk/src/policy/terminal-command-policy.ts'), + }, + ], + } as any).value, + ).toMatchObject({ toolName: 'git_status' }) + const securityReviewJobs = gen.next({ + toolResult: [ + { + type: 'json', + value: { status: ' M sdk/src/policy/terminal-command-policy.ts' }, + }, + ], + } as any) + const securityReview = securityReviewJobs + expect(securityReview.value).toMatchObject({ + toolName: 'spawn_agent_inline', + input: { agent_type: 'security-reviewer' }, + }) + const securityPrompt = (securityReview.value as any).input.prompt as string + const snapshotFingerprint = securityPrompt + .split('Snapshot fingerprint: ')[1] + .split('\n')[0] + const blockerMessage = gen.next({ + toolResult: [ + { + type: 'json', + value: { + schemaVersion: 1, + verdict: 'BLOCKING', + snapshotFingerprint, + reviewedFiles: ['sdk/src/policy/terminal-command-policy.ts'], + // Order: a record-less string finding FIRST, then an object + // finding with a record. Positional records[index] would misalign + // the record onto the string blocker. + findings: [ + 'A synthesized-style finding with no id', + { + id: 'security-reviewer:containment:real', + summary: 'Reject nested fixture paths.', + }, + ], + coverage: 'covered', + dimensions: {}, + requirementCoverage: [], + }, + }, + ], + } as any) + + expect(blockerMessage.value).toMatchObject({ toolName: 'add_message' }) + const findings = (agentState as any).base2ActiveWork + .openReviewerFindings as Array<{ id: string; text: string }> + expect(findings).toHaveLength(2) + // Record-less blocker falls back to an RF-... id and keeps its own text. + expect(findings[0].id).toMatch(/^RF-/) + expect(findings[0].text).toBe( + 'BLOCKING: A synthesized-style finding with no id', + ) + // The object-finding blocker correlates by [id] to its real record, and the + // stored text is the PREFIXED blocker string so reviewerVerdictClass can + // derive the finding's verdict class for condone keying. + expect(findings[1].id).toBe('security-reviewer:containment:real') + expect(findings[1].text).toBe( + 'BLOCKING: [security-reviewer:containment:real] Reject nested fixture paths.', + ) + }) + + test('code-reviewer object findings keep their reviewer-supplied id', () => { + // T1.3: the final code-reviewer path used to mint a content-hash `RF-...` + // id for EVERY blocker, so a re-raised finding got a fresh id each round + // while the security and specialist paths already correlated by + // reviewer-supplied id. An object finding carrying `id` must keep it; a + // record-less string finding must still fall back to a minted id. The + // stored text must stay the prefixed blocker string (including the `[id] ` + // segment) because the condone filter and the carried/new derivation both + // key on it. + const base2 = createBase2('default') + const agentState = { agentId: 'base2' } const gen = base2.handleSteps!({ agentState, prompt: 'Make the requested change now please', params: {}, - config: base2.programmaticConfig, } as any) expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) expect( gen.next({ toolResult: [{ type: 'json', value: { status: '' } }] } as any) .value, - ).toMatchObject({ - toolName: 'spawn_agent_inline', - }) + ).toMatchObject({ toolName: 'spawn_agent_inline' }) const maybePinned = gen.next().value if (maybePinned !== 'STEP') { expect(maybePinned).toMatchObject({ toolName: 'add_message' }) @@ -7548,502 +9308,1304 @@ describe('createBase2 maxReviewerRepairRounds option/env', () => { gen.next({ toolResult: [{ type: 'json', value: { status: ' M src/a.ts' } }], } as any).value, - ).toMatchObject({ - toolName: 'run_file_change_hooks', - }) - const postValidationStatus = gen.next({ - toolResult: [{ type: 'json', value: [] }], - } as any).value - expect(postValidationStatus).toMatchObject({ toolName: 'git_status' }) + ).toMatchObject({ toolName: 'run_file_change_hooks' }) + expect( + gen.next({ toolResult: [{ type: 'json', value: [] }] } as any).value, + ).toMatchObject({ toolName: 'git_status' }) const reviewCall = gen.next({ toolResult: [{ type: 'json', value: { status: ' M src/a.ts' } }], } as any).value expect(reviewCall).toMatchObject({ toolName: 'spawn_agents' }) - const exhausted = gen.next( - attestedReviewerResult(reviewCall, 'BLOCKING', [ - 'Fix the persistent edge case.', - ]) as any, - ) - expect(exhausted.value).toMatchObject({ - toolName: 'add_message', - input: { role: 'user' }, - }) - expect((exhausted.value as any).input.content).toContain( - 'automated repair budget exhausted', + // Attest from the spawn prompt exactly like attestedReviewerResult does, + // but with an OBJECT finding, which that helper cannot express. + const reviewPrompt = String( + (reviewCall as any)?.input?.agents?.[0]?.prompt ?? '', + ) + const fingerprint = + reviewPrompt.match( + /Snapshot fingerprint \(echo exactly\): ([^\n]+)/, + )?.[1] ?? '' + const reviewedFiles = + reviewPrompt + .match(/(?:Gate-scope|Pending) changed files: ([^\n]+)/)?.[1] + ?.split(',') + .map((file: string) => file.trim()) + .filter((file: string) => file && file !== '(unknown)') ?? [] + const reviewerId = 'code-reviewer:src/a.ts:guard' + const afterReview = gen.next({ + toolResult: [ + { + type: 'json', + value: [ + { + schemaVersion: 1, + verdict: 'NON_BLOCKING', + snapshotFingerprint: fingerprint, + reviewedFiles, + findings: [ + { + id: reviewerId, + text: 'Tighten the early-return guard.', + severity: 'low', + dimension: 'correctness', + }, + 'A plain-string finding with no id', + ], + coverage: 'covered', + dimensions: { + correctness: 'pass', + security: 'pass', + tests: 'pass', + apiCompatibility: 'pass', + performance: 'pass', + }, + requirementCoverage: [], + }, + ], + }, + ], + } as any) + expect(afterReview.value).toMatchObject({ toolName: 'add_message' }) + + const codeReviewerFindings = (agentState as any).base2ActiveWork + .openReviewerFindings as Array<{ id: string; text: string }> + expect(codeReviewerFindings).toHaveLength(2) + const correlated = codeReviewerFindings.find((finding) => + finding.text.includes('Tighten the early-return guard.'), + ) + expect(correlated?.id).toBe(reviewerId) + expect(correlated?.text).toBe( + `NON_BLOCKING: [${reviewerId}] Tighten the early-return guard.`, + ) + const uncorrelated = codeReviewerFindings.find((finding) => + finding.text.includes('A plain-string finding with no id'), + ) + expect(uncorrelated?.id).toMatch(/^RF-/) + expect(uncorrelated?.text).toBe( + 'NON_BLOCKING: A plain-string finding with no id', ) - expect((agentState as any).base2ActiveWork.currentPhase).toBe('blocked') - expect(gen.next().done).toBe(true) }) }) -describe('createBase2 maxRepairRounds option/env', () => { - test('option is stored on programmaticConfig', () => { - const base2 = createBase2('default', { maxRepairRounds: 5 }) - expect(base2.programmaticConfig).toMatchObject({ - maxRepairRounds: 5, - }) +describe('base2 specialist parent-owned LOOKS_GOOD credit', () => { + test('LOOKS_GOOD reliability-reviewer with only parent-owned requirementCoverage does not spawn repair-editor', () => { + // Mirror of agents/e2e/gate-aux-ordering.e2e.test.ts parent-owned credit: + // a state/session path routes to reliability-reviewer; LOOKS_GOOD whose only + // requirementCoverage gaps are parent process duties must credit the + // specialist without spawning repair-editor. + const tmpDir = makeProjectTempDir('base2-parent-owned-specialist-') + try { + const stateDir = join(tmpDir, 'state') + mkdirSync(stateDir, { recursive: true }) + const absoluteFile = join(stateDir, 'session.ts') + writeFileSync(absoluteFile, 'export const session = "v1"\n') + // Prefer project-relative path under .base2-test-scratch when cwd is the + // openbuff root so the reliability router sees a `state` segment. + const gateFile = normalizeGateFilePath(absoluteFile) + const base2 = createBase2('default') + const agentState = { + agentId: 'base2-custom', + base2ActiveWork: { + changedFiles: [gateFile], + touchedFiles: [gateFile], + pendingGateFiles: [gateFile], + currentPhase: 'awaiting_validation', + openReviewerBlockers: [], + openReviewerFindings: [], + lastValidationSummary: '', + nextRequiredAction: '', + lastPinnedStateMessage: '', + gatePassedFiles: [], + gatePassedPendingFiles: [], + gatePassedReviewerVerdict: '', + gatePassedValidationSummary: '', + gatePassedFingerprint: '', + lastReviewerGateSkipReason: '', + reviewReceipts: [], + testWriterGateDone: true, + docWriterGateDone: true, + securityReviewGateDone: true, + preEditSecurityReviewDone: true, + specialistReviewGatesDone: [], + auxGatesLastPendingFiles: [gateFile], + }, + } + // Process tasks stay in the prompt for non-blocking parent context; keep + // a non-codebase-intent prompt so there is no query_index prelude. + const prompt = + 'Please finish the pending reliability finding. Parent will later commit and push then confirm CI/CD is green.' + const gen = base2.handleSteps!({ + agentState, + prompt, + params: {}, + } as any) + + expect(gen.next().value).toMatchObject({ + toolName: 'git_status', + input: {}, + }) + expect( + gen.next(feedJson({ status: ` M ${gateFile}` })).value, + ).toMatchObject({ + toolName: 'spawn_agent_inline', + input: { agent_type: 'context-pruner' }, + }) + expect(gen.next().value).toMatchObject({ toolName: 'add_message' }) + expect(gen.next().value).toBe('STEP') + expect(gen.next(finishStepWithToolResult({})).value).toMatchObject({ + toolName: 'git_status', + input: {}, + }) + const bundle = gen.next(feedJson({ status: ` M ${gateFile}` })) + expect(bundle.value).toMatchObject({ + toolName: 'get_change_review_bundle', + input: {}, + }) + const spawn = gen.next( + feedJson({ + snapshotId: 'unit-spec-snap-parent-owned', + files: [gateFile], + }), + ) + expect(spawn.value).toMatchObject({ + toolName: 'spawn_agents', + input: { agents: [{ agent_type: 'reliability-reviewer' }] }, + }) + const spawnPrompt = (spawn.value as any).input.agents[0].prompt as string + expect(typeof spawnPrompt).toBe('string') + expect( + spawnPrompt.includes('specialist-domain only') || + spawnPrompt.includes('Do NOT treat parent workflow'), + ).toBe(true) + expect(spawnPrompt).not.toMatch( + new RegExp( + `^Requirements:\\s*${prompt.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, + 'm', + ), + ) + const fingerprint = String( + (spawn.value as any).input.agents[0].params?.snapshot_id ?? '', + ) + expect(fingerprint).toMatch(/^v3:[a-f0-9]{64}$/) + + const after = gen.next( + looksGoodWithParentOwnedRequirements( + 'reliability-reviewer', + fingerprint, + [gateFile], + // The specialist harness supports advisories directly, so this case + // covers the specialist parent-owned-only pass path (the security + // pass path is the analogue and is covered by its own gate wiring). + ['z'.repeat(300), 'specialist advisory 1'], + ), + ) + const afterValue = after.value as any + const isRepairEditorSpawn = + afterValue?.toolName === 'spawn_agents' && + afterValue?.input?.agents?.[0]?.agent_type === 'repair-editor' + expect(isRepairEditorSpawn).toBe(false) + + expect(afterValue).toMatchObject({ + toolName: 'add_message', + input: { role: 'user' }, + }) + const parentOwnedContent = afterValue.input.content as string + expect(parentOwnedContent).toContain( + 'parent-owned process requirements were ignored', + ) + expect(parentOwnedContent).toContain( + 'Advisories (non-blocking; no change required):', + ) + expect(parentOwnedContent).toContain(`- ${'z'.repeat(237)}...`) + expect(parentOwnedContent).toContain('- specialist advisory 1') + + let creditYield = after + if ( + afterValue?.toolName === 'add_message' && + typeof afterValue?.input?.content === 'string' && + afterValue.input.content.includes( + 'parent-owned process requirements were ignored', + ) + ) { + creditYield = gen.next() + } + expect( + (creditYield.value as any)?.toolName === 'spawn_agents' && + ((creditYield.value as any)?.input?.agents ?? []).some( + (a: { agent_type?: string }) => a?.agent_type === 'repair-editor', + ), + ).toBe(false) + + expect( + (agentState as any).base2ActiveWork.specialistReviewGatesDone, + ).toContain('reliability-reviewer') + expect((agentState as any).base2ActiveWork.currentPhase).not.toBe( + 'repair_loop', + ) + expect((agentState as any).base2ActiveWork.currentPhase).not.toBe( + 'blocked', + ) + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } }) +}) + +describe('base2 specialist attestation tolerance', () => { + // RF-1: a pending path that is DELETED on disk carries a `missing` content + // marker and cannot be read by the specialist, so it must be + // attested-by-absence exactly like the final code-reviewer path. Without the + // deleted set at the specialist call sites the omitted deleted path is a + // coverage gap, which combined with a stale-snapshot finding escalates to the + // bundle-refresh retry and then the terminal `could not attest` block. + test('credits a routed specialist that omits a deleted pending file from reviewedFiles', () => { + const tmpDir = makeProjectTempDir('base2-specialist-deleted-') + try { + const stateDir = join(tmpDir, 'state') + mkdirSync(stateDir, { recursive: true }) + const presentAbsolute = join(stateDir, 'session.ts') + writeFileSync(presentAbsolute, 'export const session = "v1"\n') + const deletedAbsolute = join(stateDir, 'legacy-session.ts') + writeFileSync(deletedAbsolute, 'export const legacy = "gone"\n') + rmSync(deletedAbsolute, { force: true }) + const presentFile = normalizeGateFilePath(presentAbsolute) + const deletedFile = normalizeGateFilePath(deletedAbsolute) + const pendingFiles = [presentFile, deletedFile] + const status = ` M ${presentFile}\n D ${deletedFile}` + const base2 = createBase2('default') + const agentState = { + agentId: 'base2-custom', + base2ActiveWork: { + changedFiles: pendingFiles, + touchedFiles: pendingFiles, + pendingGateFiles: pendingFiles, + currentPhase: 'awaiting_validation', + openReviewerBlockers: [], + openReviewerFindings: [], + lastValidationSummary: '', + nextRequiredAction: '', + lastPinnedStateMessage: '', + gatePassedFiles: [], + gatePassedPendingFiles: [], + gatePassedReviewerVerdict: '', + gatePassedValidationSummary: '', + gatePassedFingerprint: '', + lastReviewerGateSkipReason: '', + reviewReceipts: [], + testWriterGateDone: true, + docWriterGateDone: true, + securityReviewGateDone: true, + preEditSecurityReviewDone: true, + specialistReviewGatesDone: [], + auxGatesLastPendingFiles: pendingFiles, + }, + } + const gen = base2.handleSteps!({ + agentState, + prompt: 'Please finish the pending reliability finding.', + params: {}, + } as any) + + expect(gen.next().value).toMatchObject({ + toolName: 'git_status', + input: {}, + }) + expect(gen.next(feedJson({ status })).value).toMatchObject({ + toolName: 'spawn_agent_inline', + input: { agent_type: 'context-pruner' }, + }) + expect(gen.next().value).toMatchObject({ toolName: 'add_message' }) + expect(gen.next().value).toBe('STEP') + expect(gen.next(finishStepWithToolResult({})).value).toMatchObject({ + toolName: 'git_status', + input: {}, + }) + const bundle = gen.next(feedJson({ status })) + expect(bundle.value).toMatchObject({ + toolName: 'get_change_review_bundle', + input: {}, + }) + const spawn = gen.next( + feedJson({ snapshotId: 'unit-spec-snap-deleted', files: pendingFiles }), + ) + expect(spawn.value).toMatchObject({ + toolName: 'spawn_agents', + input: { agents: [{ agent_type: 'reliability-reviewer' }] }, + }) + const fingerprint = String( + (spawn.value as any).input.agents[0].params?.snapshot_id ?? '', + ) + expect(fingerprint).toMatch(/^v3:[a-f0-9]{64}$/) - test('env string is used when option is omitted', () => { - const previous = process.env.OPENBUFF_MAX_REPAIR_ROUNDS - try { - process.env.OPENBUFF_MAX_REPAIR_ROUNDS = '7' - const base2 = createBase2('default') - expect(base2.programmaticConfig).toMatchObject({ - maxRepairRounds: 7, + // LOOKS_GOOD attesting ONLY the readable file, plus a stale-snapshot + // finding record. Pre-fix the omitted deleted path was a coverage gap and + // the stale record escalated to the bundle-refresh retry; now the + // deletion is attested-by-absence, so the gate credits the specialist and + // re-enters the loop at context pruning. + const afterSpecialist = gen.next( + feedJson({ + agentType: 'reliability-reviewer', + value: { + schemaVersion: 1, + family: 'reviewer', + verdict: 'LOOKS_GOOD', + snapshotFingerprint: fingerprint, + reviewedFiles: [presentFile], + findings: [ + { + id: 'reliability-reviewer:correctness:stale-snapshot', + summary: 'The supplied snapshot is stale and does not match.', + evidence: ['The current review bundle has a newer snapshot.'], + }, + ], + coverage: 'covered', + dimensions: {}, + requirementCoverage: [], + }, + }), + ) + expect(afterSpecialist.value).not.toMatchObject({ + toolName: 'get_change_review_bundle', }) + expect(afterSpecialist.value).toMatchObject({ + toolName: 'spawn_agent_inline', + input: { agent_type: 'context-pruner' }, + }) + const activeWork = (agentState as any).base2ActiveWork + expect(activeWork.specialistReviewGatesDone).toContain( + 'reliability-reviewer', + ) + expect(activeWork.currentPhase).not.toBe('blocked') + expect(activeWork.openReviewerBlockers).toEqual([]) } finally { - if (previous === undefined) { - delete process.env.OPENBUFF_MAX_REPAIR_ROUNDS - } else { - process.env.OPENBUFF_MAX_REPAIR_ROUNDS = previous - } + rmSync(tmpDir, { recursive: true, force: true }) } }) - test('option wins over env', () => { - const previous = process.env.OPENBUFF_MAX_REPAIR_ROUNDS - try { - process.env.OPENBUFF_MAX_REPAIR_ROUNDS = '7' - const base2 = createBase2('default', { maxRepairRounds: 2 }) - expect(base2.programmaticConfig).toMatchObject({ - maxRepairRounds: 2, - }) - } finally { - if (previous === undefined) { - delete process.env.OPENBUFF_MAX_REPAIR_ROUNDS - } else { - process.env.OPENBUFF_MAX_REPAIR_ROUNDS = previous + // RF-2: the specialist path tolerates a coverage-complete review whose + // well-formed v3 fingerprint does not match the expected snapshot; that + // tolerated drift must be RECORDED (gate telemetry with dedicated + // reported/expected keys) rather than accepted silently. + test('records fingerprint drift for a coverage-complete routed specialist review', () => { + const tmpDir = makeProjectTempDir('base2-specialist-drift-') + const telemetry: Array> = [] + const originalInfo = console.info + console.info = (...args: unknown[]) => { + const [first] = args + if (typeof first === 'string' && first.includes('"base2.gate"')) { + telemetry.push(JSON.parse(first) as Record) } } - }) + try { + const stateDir = join(tmpDir, 'state') + mkdirSync(stateDir, { recursive: true }) + const absoluteFile = join(stateDir, 'session.ts') + writeFileSync(absoluteFile, 'export const session = "v1"\n') + const gateFile = normalizeGateFilePath(absoluteFile) + const driftFingerprint = `v3:${'e'.repeat(64)}` + const base2 = createBase2('default') + const agentState = { + agentId: 'base2-custom', + base2ActiveWork: { + changedFiles: [gateFile], + touchedFiles: [gateFile], + pendingGateFiles: [gateFile], + currentPhase: 'awaiting_validation', + openReviewerBlockers: [], + openReviewerFindings: [], + lastValidationSummary: '', + nextRequiredAction: '', + lastPinnedStateMessage: '', + gatePassedFiles: [], + gatePassedPendingFiles: [], + gatePassedReviewerVerdict: '', + gatePassedValidationSummary: '', + gatePassedFingerprint: '', + lastReviewerGateSkipReason: '', + reviewReceipts: [], + testWriterGateDone: true, + docWriterGateDone: true, + securityReviewGateDone: true, + preEditSecurityReviewDone: true, + specialistReviewGatesDone: [], + auxGatesLastPendingFiles: [gateFile], + }, + } + const gen = base2.handleSteps!({ + agentState, + prompt: 'Please finish the pending reliability finding.', + params: {}, + } as any) - test('default createBase2 stores unlimited (null) repair budgets on programmaticConfig', () => { - const base2 = createBase2('default') - expect(base2.programmaticConfig).toMatchObject({ - maxRepairRounds: null, - maxReviewerRepairRounds: null, - maxSpecialistRepairRounds: null, - }) - }) -}) + expect(gen.next().value).toMatchObject({ + toolName: 'git_status', + input: {}, + }) + expect( + gen.next(feedJson({ status: ` M ${gateFile}` })).value, + ).toMatchObject({ + toolName: 'spawn_agent_inline', + input: { agent_type: 'context-pruner' }, + }) + expect(gen.next().value).toMatchObject({ toolName: 'add_message' }) + expect(gen.next().value).toBe('STEP') + expect(gen.next(finishStepWithToolResult({})).value).toMatchObject({ + toolName: 'git_status', + input: {}, + }) + const bundle = gen.next(feedJson({ status: ` M ${gateFile}` })) + expect(bundle.value).toMatchObject({ + toolName: 'get_change_review_bundle', + input: {}, + }) + const spawn = gen.next( + feedJson({ snapshotId: 'unit-spec-snap-drift', files: [gateFile] }), + ) + expect(spawn.value).toMatchObject({ + toolName: 'spawn_agents', + input: { agents: [{ agent_type: 'reliability-reviewer' }] }, + }) + const expectedFingerprint = String( + (spawn.value as any).input.agents[0].params?.snapshot_id ?? '', + ) + expect(expectedFingerprint).toMatch(/^v3:[a-f0-9]{64}$/) + expect(expectedFingerprint).not.toBe(driftFingerprint) -describe('createBase2 maxSpecialistRepairRounds option/env', () => { - test('option is stored on programmaticConfig', () => { - const base2 = createBase2('default', { maxSpecialistRepairRounds: 5 }) - expect(base2.programmaticConfig).toMatchObject({ - maxSpecialistRepairRounds: 5, - }) - }) + const afterSpecialist = gen.next( + feedJson({ + agentType: 'reliability-reviewer', + value: { + schemaVersion: 1, + family: 'reviewer', + verdict: 'LOOKS_GOOD', + snapshotFingerprint: driftFingerprint, + reviewedFiles: [gateFile], + findings: [], + coverage: 'covered', + dimensions: {}, + requirementCoverage: [], + }, + }), + ) + // Drift is tolerated: the specialist is credited and the loop re-enters. + expect(afterSpecialist.value).toMatchObject({ + toolName: 'spawn_agent_inline', + input: { agent_type: 'context-pruner' }, + }) + expect( + (agentState as any).base2ActiveWork.specialistReviewGatesDone, + ).toContain('reliability-reviewer') - test('env string is used when option is omitted', () => { - const previous = process.env.OPENBUFF_MAX_SPECIALIST_REPAIR_ROUNDS - try { - process.env.OPENBUFF_MAX_SPECIALIST_REPAIR_ROUNDS = '7' - const base2 = createBase2('default') - expect(base2.programmaticConfig).toMatchObject({ - maxSpecialistRepairRounds: 7, + const driftEvents = telemetry.filter( + (event) => event.reviewerStatus === 'attestation-fingerprint-drift', + ) + expect(driftEvents).toHaveLength(1) + expect(driftEvents[0]).toMatchObject({ + event: 'base2.gate', + reviewer: 'reliability-reviewer', + reportedFingerprint: driftFingerprint, + expectedFingerprint, + pendingFiles: [gateFile], }) + expect(driftEvents[0]!.reuseReason).toBeUndefined() } finally { - if (previous === undefined) { - delete process.env.OPENBUFF_MAX_SPECIALIST_REPAIR_ROUNDS - } else { - process.env.OPENBUFF_MAX_SPECIALIST_REPAIR_ROUNDS = previous - } + console.info = originalInfo + rmSync(tmpDir, { recursive: true, force: true }) } }) +}) - test('option wins over env', () => { - const previous = process.env.OPENBUFF_MAX_SPECIALIST_REPAIR_ROUNDS +describe('base2 reviewer round-findings telemetry', () => { + // One `round-findings` event per code-reviewer round. new-vs-carried is + // derived from the PERSISTED openReviewerFindings ledger (read before + // mergeReviewerFindings overwrites it), so a repeated finding text is + // reported as carried rather than new. The shadow-mode suppression fields + // are logged only: the gate still blocks and spawns repair. + test('emits one round-findings event per round and carries repeated finding text', () => { + const tmpDir = makeProjectTempDir('base2-round-findings-') + const tmpFile = join(tmpDir, 'a.ts') + const gateFile = normalizeGateFilePath(tmpFile) + writeFileSync(tmpFile, 'export const value = 1\n') + const findingText = 'Minor style suggestion.' + const telemetry: Array> = [] + const originalInfo = console.info + console.info = (...args: unknown[]) => { + const [first] = args + if (typeof first === 'string' && first.includes('"base2.gate"')) { + telemetry.push(JSON.parse(first) as Record) + } + } try { - process.env.OPENBUFF_MAX_SPECIALIST_REPAIR_ROUNDS = '7' - const base2 = createBase2('default', { maxSpecialistRepairRounds: 2 }) - expect(base2.programmaticConfig).toMatchObject({ - maxSpecialistRepairRounds: 2, + const base2 = createBase2('default') + const agentState = { agentId: 'base2-custom' } + const gen = base2.handleSteps!({ + agentState, + prompt: 'Make the requested change now please', + params: {}, + } as any) + + expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) + expect(gen.next(feedJson({ status: '' })).value).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + expect(gen.next().value).toBe('STEP') + expect( + gen.next(finishStepWithToolResult(editReceipt(gateFile))).value, + ).toMatchObject({ toolName: 'git_status' }) + expect( + gen.next(feedJson({ status: ` M ${gateFile}` })).value, + ).toMatchObject({ toolName: 'run_file_change_hooks' }) + expect(gen.next(feedJson([])).value).toMatchObject({ + toolName: 'git_status', + }) + const firstReviewCall = gen.next(feedJson({ status: ` M ${gateFile}` })) + .value as any + expect(firstReviewCall).toMatchObject({ toolName: 'spawn_agents' }) + + // Round 1: one NON_BLOCKING finding with nothing persisted yet. + expect( + gen.next( + attestedReviewerResult(firstReviewCall, 'NON_BLOCKING', [ + findingText, + ]) as any, + ).value, + ).toMatchObject({ toolName: 'add_message' }) + const firstRoundEvents = telemetry.filter( + (event) => event.reviewerStatus === 'round-findings', + ) + expect(firstRoundEvents).toHaveLength(1) + expect(firstRoundEvents[0]).toMatchObject({ + event: 'base2.gate', + reviewer: 'code-reviewer', + repairRound: 1, + findingCount: 1, + rawFindingCount: 1, + newFindingCount: 1, + carriedFindingCount: 0, + pendingFileCount: 1, + // Shadow mode: a threshold that dropped every suppressible finding + // would have passed this round. Logged only — see the blocker + // assertion below. + suppressibleFindingCount: 1, + wouldPassAtThisRound: true, + }) + expect( + Number(firstRoundEvents[0]!.newFindingCount) + + Number(firstRoundEvents[0]!.carriedFindingCount), + ).toBe(Number(firstRoundEvents[0]!.findingCount)) + // Observation only: the shadow fields did not unblock the gate. + expect( + (agentState as any).base2ActiveWork.openReviewerBlockers, + ).toContain(`NON_BLOCKING: ${findingText}`) + + expect(gen.next().value).toMatchObject({ + toolName: 'spawn_agents', + input: { agents: [{ agent_type: 'repair-editor' }] }, + }) + // Progress-only receipt: real bytes change (so the no-progress guard + // passes) but no finding id is claimed, so nothing is condoned and the + // same text legitimately returns as CARRIED next round. + writeFileSync(tmpFile, 'export const value = 2 // touched\n') + expect( + gen.next(progressOnlyRepairReceipt([gateFile]) as any).value, + ).toMatchObject({ toolName: 'git_status' }) + expect( + gen.next(feedJson({ status: ` M ${gateFile}` })).value, + ).toMatchObject({ toolName: 'run_file_change_hooks' }) + expect( + gen.next( + feedJson([{ hookName: 'typecheck', exitCode: 0, stdout: 'ok' }]), + ).value, + ).toMatchObject({ toolName: 'spawn_agent_inline' }) + expect(gen.next().value).toMatchObject({ toolName: 'add_message' }) + expect(gen.next().value).toBe('STEP') + expect( + gen.next({ stepsComplete: true, toolResult: [] } as any).value, + ).toMatchObject({ toolName: 'git_status' }) + expect( + gen.next(feedJson({ status: ` M ${gateFile}` })).value, + ).toMatchObject({ toolName: 'run_file_change_hooks' }) + expect(gen.next(feedJson([])).value).toMatchObject({ + toolName: 'git_status', + }) + const secondReviewCall = gen.next(feedJson({ status: ` M ${gateFile}` })) + .value as any + expect(secondReviewCall).toMatchObject({ toolName: 'spawn_agents' }) + + // Round 2 returns the SAME finding text: the comparison reads the + // persisted round-1 records, so it is CARRIED, not new. + expect( + gen.next( + attestedReviewerResult(secondReviewCall, 'NON_BLOCKING', [ + findingText, + ]) as any, + ).value, + ).toMatchObject({ toolName: 'add_message' }) + const roundEvents = telemetry.filter( + (event) => event.reviewerStatus === 'round-findings', + ) + expect(roundEvents).toHaveLength(2) + expect(roundEvents[1]).toMatchObject({ + reviewer: 'code-reviewer', + repairRound: 2, + findingCount: 1, + newFindingCount: 0, }) + expect( + Number(roundEvents[1]!.carriedFindingCount), + ).toBeGreaterThanOrEqual(1) + expect( + Number(roundEvents[1]!.newFindingCount) + + Number(roundEvents[1]!.carriedFindingCount), + ).toBe(Number(roundEvents[1]!.findingCount)) } finally { - if (previous === undefined) { - delete process.env.OPENBUFF_MAX_SPECIALIST_REPAIR_ROUNDS - } else { - process.env.OPENBUFF_MAX_SPECIALIST_REPAIR_ROUNDS = previous - } + console.info = originalInfo + rmSync(tmpDir, { recursive: true, force: true }) } }) }) -describe('base2 reviewer repair budget cap', () => { - test('default unlimited does not exhaust at seed count 6', () => { - // With unlimited default, seeding reviewerRepairRoundCount at the old - // default (6) must NOT exhaust; the loop continues into repair-editor. - const base2 = createBase2('default') - const agentState = { - agentId: 'base2', - base2ActiveWork: { - reviewerRepairRoundCount: 6, - }, +// T1.2(c): the reviewer is stateless across repair rounds, so a re-review +// packet carries a ledger of the findings a prior round already reported as +// addressed. Built only from already-persisted state +// (reviewerRepairRoundCount / openReviewerFindings). +describe('base2 reviewer re-review round ledger', () => { + /** Seeded gate state parked right before the final code-reviewer spawn. */ + function seededReviewState( + gateFile: string, + overrides: Partial>, + ) { + return { + touchedFiles: [gateFile], + changedFiles: [gateFile], + pendingGateFiles: [gateFile], + currentPhase: 'awaiting_validation', + latestWorkSummary: '', + openReviewerBlockers: [], + openReviewerFindings: [], + lastValidationSummary: '', + nextRequiredAction: '', + lastPinnedStateMessage: '', + gatePassedFiles: [], + gatePassedPendingFiles: [], + gatePassedReviewerVerdict: '', + gatePassedValidationSummary: '', + gatePassedFingerprint: '', + lastReviewerGateSkipReason: '', + reviewReceipts: [], + // Keep the aux gates out of the way: only the final gate matters here. + testWriterGateDone: true, + docWriterGateDone: true, + securityReviewGateDone: true, + preEditSecurityReviewDone: true, + specialistReviewGatesDone: [], + auxGatesLastPendingFiles: [gateFile], + ...overrides, } + } + + /** Drive a seeded turn to the final code-reviewer spawn and return its prompt. */ + function driveSeededStateToReviewPrompt( + gateFile: string, + activeWork: Record, + ): string { + const base2 = createBase2('default') const gen = base2.handleSteps!({ - agentState, - prompt: 'Make the requested change now please', + agentState: { agentId: 'base2-custom', base2ActiveWork: activeWork }, + prompt: 'Finish the pending review.', params: {}, config: base2.programmaticConfig, } as any) expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) expect( - gen.next({ toolResult: [{ type: 'json', value: { status: '' } }] } as any) - .value, - ).toMatchObject({ - toolName: 'spawn_agent_inline', - }) + gen.next(feedJson({ status: ` M ${gateFile}` })).value, + ).toMatchObject({ toolName: 'spawn_agent_inline' }) const maybePinned = gen.next().value if (maybePinned !== 'STEP') { expect(maybePinned).toMatchObject({ toolName: 'add_message' }) expect(gen.next().value).toBe('STEP') } - expect( - gen.next({ - stepsComplete: true, - toolResult: [{ type: 'json', value: editReceipt('src/a.ts') }], - } as any).value, - ).toMatchObject({ toolName: 'git_status' }) - expect( - gen.next({ - toolResult: [{ type: 'json', value: { status: ' M src/a.ts' } }], - } as any).value, - ).toMatchObject({ - toolName: 'run_file_change_hooks', + expect(gen.next(finishStepWithToolResult({})).value).toMatchObject({ + toolName: 'git_status', }) - const postValidationStatus = gen.next({ - toolResult: [{ type: 'json', value: [] }], - } as any).value - expect(postValidationStatus).toMatchObject({ toolName: 'git_status' }) - const reviewCall = gen.next({ - toolResult: [{ type: 'json', value: { status: ' M src/a.ts' } }], - } as any).value - expect(reviewCall).toMatchObject({ toolName: 'spawn_agents' }) - const afterReview = gen.next( - attestedReviewerResult(reviewCall, 'BLOCKING', [ - 'Fix the persistent edge case.', - ]) as any, - ) - - expect(afterReview.value).toMatchObject({ - toolName: 'add_message', - input: { role: 'user' }, + expect( + gen.next(feedJson({ status: ` M ${gateFile}` })).value, + ).toMatchObject({ toolName: 'run_file_change_hooks' }) + expect(gen.next(feedJson([])).value).toMatchObject({ + toolName: 'git_status', }) - expect((afterReview.value as any).input.content).not.toContain( - 'automated repair budget exhausted', - ) - expect(gen.next().value).toMatchObject({ + const reviewCall = gen.next(feedJson({ status: ` M ${gateFile}` })) + .value as any + expect(reviewCall).toMatchObject({ toolName: 'spawn_agents', - input: { agents: [{ agent_type: 'repair-editor' }] }, + input: { agents: [{ agent_type: 'code-reviewer' }] }, }) - }) + return String(reviewCall.input.agents[0].prompt) + } - test('explicit small maxReviewerRepairRounds still exhausts as opt-in cap', () => { - const base2 = createBase2('default', { maxReviewerRepairRounds: 1 }) - const agentState = { - agentId: 'base2', - base2ActiveWork: { - reviewerRepairRoundCount: 1, - }, + function codeReviewerFinding(text: string, index: number) { + return { + id: `RF-${index + 1}-0000000${index}`, + gateId: 'code-reviewer:prior-snapshot', + text, + status: 'open' as const, + files: ['src/a.ts'], + snapshotFingerprint: 'prior-snapshot', + reviewer: 'code-reviewer' as const, + createdAt: '2025-01-01T00:00:00.000Z', } + } + + test('round 0 (first review) adds no ledger at all', () => { + const base2 = createBase2('default') const gen = base2.handleSteps!({ - agentState, + agentState: { agentId: 'base2-custom' }, prompt: 'Make the requested change now please', params: {}, - config: base2.programmaticConfig, } as any) expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) - expect( - gen.next({ toolResult: [{ type: 'json', value: { status: '' } }] } as any) - .value, - ).toMatchObject({ + expect(gen.next(feedJson({ status: '' })).value).toMatchObject({ toolName: 'spawn_agent_inline', }) - const maybePinned = gen.next().value - if (maybePinned !== 'STEP') { - expect(maybePinned).toMatchObject({ toolName: 'add_message' }) - expect(gen.next().value).toBe('STEP') - } + expect(gen.next().value).toBe('STEP') expect( - gen.next({ - stepsComplete: true, - toolResult: [{ type: 'json', value: editReceipt('src/a.ts') }], - } as any).value, + gen.next(finishStepWithToolResult(editReceipt('src/a.ts'))).value, ).toMatchObject({ toolName: 'git_status' }) - expect( - gen.next({ - toolResult: [{ type: 'json', value: { status: ' M src/a.ts' } }], - } as any).value, - ).toMatchObject({ + expect(gen.next(feedJson({ status: ' M src/a.ts' })).value).toMatchObject({ toolName: 'run_file_change_hooks', }) - const postValidationStatus = gen.next({ - toolResult: [{ type: 'json', value: [] }], - } as any).value - expect(postValidationStatus).toMatchObject({ toolName: 'git_status' }) - const reviewCall = gen.next({ - toolResult: [{ type: 'json', value: { status: ' M src/a.ts' } }], - } as any).value - expect(reviewCall).toMatchObject({ toolName: 'spawn_agents' }) - const exhausted = gen.next( - attestedReviewerResult(reviewCall, 'BLOCKING', [ - 'Fix the persistent edge case.', - ]) as any, - ) - - expect(exhausted.value).toMatchObject({ - toolName: 'add_message', - input: { role: 'user' }, + expect(gen.next(feedJson([])).value).toMatchObject({ + toolName: 'git_status', }) - expect((exhausted.value as any).input.content).toContain( - 'automated repair budget exhausted', + const reviewCall = gen.next(feedJson({ status: ' M src/a.ts' })) + .value as any + const prompt = String(reviewCall.input.agents[0].prompt) + + expect(prompt).not.toContain('Repair round:') + expect(prompt).not.toContain('This is a re-review') + expect(prompt).not.toContain('repeated VERBATIM') + // No stray blank line either: the validation summary is immediately + // followed by the bounded-read instruction, exactly as before. + expect(prompt).toContain( + 'Validation gate summary: No configured file-change hooks ran.\nRead large files via read_files windows', ) - expect((agentState as any).base2ActiveWork.currentPhase).toBe('blocked') - expect(gen.next().done).toBe(true) }) -}) -describe('base2 content-based reviewer finding correlation', () => { - test('security-reviewer findings correlate to their record by content, not positional index', () => { - // The security-reviewer blocking path builds openReviewerFindings from the - // synthesized blocker strings. collectReviewerBlockers emits a blocker for - // a plain string finding (which has NO finding record) alongside a blocker - // for an object finding (which does), so the two arrays no longer line up - // positionally. Positional records[index] correlation would attach the - // object finding's id/text to the plain-string blocker; content-based - // correlation must attach each record to the blocker whose text/id it - // actually matches, and the record-less blocker must fall back to an - // RF-... id with its own blocker text. - const base2 = createBase2('default') - const agentState = { agentId: 'base2' } - const gen = base2.handleSteps!({ - agentState, - prompt: 'Update sdk/src/policy/terminal-command-policy.ts.', - params: {}, - } as any) + test('a re-review emits the round line, the verbatim re-raise rule, and its own findings verbatim', () => { + const tmpDir = makeProjectTempDir('base2-ledger-rereview-') + try { + const gateFile = normalizeGateFilePath(join(tmpDir, 'a.ts')) + writeFileSync(join(tmpDir, 'a.ts'), 'export const value = 1\n') + const codeFindings = [ + codeReviewerFinding('NON_BLOCKING: Tighten the early-return guard.', 0), + codeReviewerFinding( + 'BLOCKING: [code-reviewer:tests:missing-case] Add a case for the empty payload.', + 1, + ), + ] + const securityFinding = { + id: 'security-reviewer:containment:fixture-path', + gateId: 'security-reviewer:prior-snapshot', + text: 'BLOCKING: [security-reviewer:containment:fixture-path] Reject nested fixture paths.', + status: 'open' as const, + files: [gateFile], + snapshotFingerprint: 'prior-snapshot', + reviewer: 'security-reviewer' as const, + createdAt: '2025-01-01T00:00:00.000Z', + } + const prompt = driveSeededStateToReviewPrompt( + gateFile, + seededReviewState(gateFile, { + reviewerRepairRoundCount: 2, + // Code-reviewer records first so the rehydrated owed-set keeps the + // final block on the code-reviewer family. + openReviewerFindings: [...codeFindings, securityFinding], + }), + ) - expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) - expect( - gen.next({ toolResult: [{ type: 'json', value: { status: '' } }] } as any) - .value, - ).toMatchObject({ - toolName: 'spawn_agent_inline', - }) - expect(gen.next().value).toBe('STEP') - expect( - gen.next({ - stepsComplete: true, - toolResult: [ - { - type: 'json', - value: editReceipt('sdk/src/policy/terminal-command-policy.ts'), - }, - ], - } as any).value, - ).toMatchObject({ toolName: 'git_status' }) - const securityReviewJobs = gen.next({ - toolResult: [ - { - type: 'json', - value: { status: ' M sdk/src/policy/terminal-command-policy.ts' }, - }, - ], - } as any) - const securityReview = securityReviewJobs - expect(securityReview.value).toMatchObject({ - toolName: 'spawn_agent_inline', - input: { agent_type: 'security-reviewer' }, - }) - const securityPrompt = (securityReview.value as any).input.prompt as string - const snapshotFingerprint = securityPrompt - .split('Snapshot fingerprint: ')[1] - .split('\n')[0] - const blockerMessage = gen.next({ - toolResult: [ - { - type: 'json', - value: { - schemaVersion: 1, - verdict: 'BLOCKING', - snapshotFingerprint, - reviewedFiles: ['sdk/src/policy/terminal-command-policy.ts'], - // Order: a record-less string finding FIRST, then an object - // finding with a record. Positional records[index] would misalign - // the record onto the string blocker. - findings: [ - 'A synthesized-style finding with no id', - { - id: 'security-reviewer:containment:real', - summary: 'Reject nested fixture paths.', - }, - ], - coverage: 'covered', - dimensions: {}, - requirementCoverage: [], - }, - }, - ], - } as any) + expect(prompt).toContain('Repair round: 2. This is a re-review.') + expect(prompt).toContain( + 'Findings raised earlier and reported addressed are listed below. Verify each is genuinely fixed and cite the line that fixes it. If a fix is wrong or incomplete, re-raise the finding with its ORIGINAL text repeated VERBATIM and put your reason on a separate line: the gate matches re-raises by exact text (and by stable finding id when you supplied one), so a reworded re-raise is treated as a brand-new finding and the repair loop cannot converge.', + ) + // Verbatim rendering: the NON_BLOCKING:/BLOCKING: prefix and the `[id] ` + // segment are exactly what the condone matcher compares against. + expect(prompt).toContain( + ' - NON_BLOCKING: Tighten the early-return guard.', + ) + expect(prompt).toContain( + ' - BLOCKING: [code-reviewer:tests:missing-case] Add a case for the empty payload.', + ) + // Another reviewer family's finding must not leak into this packet. + expect(prompt).not.toContain('Reject nested fixture paths.') + expect(prompt).not.toContain('more earlier findings omitted') + // The ledger sits immediately after the validation summary line. + const lines = prompt.split('\n') + const summaryIndex = lines.findIndex((line) => + line.startsWith('Validation gate summary: '), + ) + expect(lines[summaryIndex + 1]).toBe( + 'Repair round: 2. This is a re-review.', + ) + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + test('caps the ledger at 12 entries and reports how many were omitted', () => { + const tmpDir = makeProjectTempDir('base2-ledger-cap-') + try { + const gateFile = normalizeGateFilePath(join(tmpDir, 'a.ts')) + writeFileSync(join(tmpDir, 'a.ts'), 'export const value = 1\n') + const openReviewerFindings = Array.from( + { length: 14 }, + (_unused, index) => + codeReviewerFinding(`NON_BLOCKING: Finding number ${index}.`, index), + ) + const prompt = driveSeededStateToReviewPrompt( + gateFile, + seededReviewState(gateFile, { + reviewerRepairRoundCount: 3, + openReviewerFindings, + }), + ) - expect(blockerMessage.value).toMatchObject({ toolName: 'add_message' }) - const findings = (agentState as any).base2ActiveWork - .openReviewerFindings as Array<{ id: string; text: string }> - expect(findings).toHaveLength(2) - // Record-less blocker falls back to an RF-... id and keeps its own text. - expect(findings[0].id).toMatch(/^RF-/) - expect(findings[0].text).toBe( - 'BLOCKING: A synthesized-style finding with no id', - ) - // The object-finding blocker correlates by [id] to its real record. - expect(findings[1].id).toBe('security-reviewer:containment:real') - expect(findings[1].text).toBe('Reject nested fixture paths.') + const bullets = prompt + .split('\n') + .filter((line) => line.startsWith(' - ')) + // 12 findings plus the single omitted-count bullet. + expect(bullets).toHaveLength(13) + expect(bullets[0]).toBe(' - NON_BLOCKING: Finding number 0.') + expect(bullets[11]).toBe(' - NON_BLOCKING: Finding number 11.') + expect(bullets[12]).toBe(' - (+2 more earlier findings omitted)') + expect(prompt).not.toContain('Finding number 12.') + expect(prompt).not.toContain('Finding number 13.') + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + test('emits only the round line when no finding belongs to this reviewer', () => { + const tmpDir = makeProjectTempDir('base2-ledger-empty-') + try { + const gateFile = normalizeGateFilePath(join(tmpDir, 'a.ts')) + writeFileSync(join(tmpDir, 'a.ts'), 'export const value = 1\n') + const prompt = driveSeededStateToReviewPrompt( + gateFile, + seededReviewState(gateFile, { + reviewerRepairRoundCount: 1, + openReviewerFindings: [ + { + id: 'security-reviewer:containment:fixture-path', + gateId: 'security-reviewer:prior-snapshot', + text: 'BLOCKING: Reject nested fixture paths.', + status: 'open' as const, + files: [gateFile], + snapshotFingerprint: 'prior-snapshot', + reviewer: 'security-reviewer' as const, + createdAt: '2025-01-01T00:00:00.000Z', + }, + ], + }), + ) + + expect(prompt).toContain('Repair round: 1. This is a re-review.') + expect(prompt).not.toContain('repeated VERBATIM') + expect(prompt.split('\n').some((line) => line.startsWith(' - '))).toBe( + false, + ) + expect(prompt).not.toContain('Reject nested fixture paths.') + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } }) }) -describe('base2 specialist parent-owned LOOKS_GOOD credit', () => { - test('LOOKS_GOOD reliability-reviewer with only parent-owned requirementCoverage does not spawn repair-editor', () => { - // Mirror of agents/e2e/gate-aux-ordering.e2e.test.ts parent-owned credit: - // a state/session path routes to reliability-reviewer; LOOKS_GOOD whose only - // requirementCoverage gaps are parent process duties must credit the - // specialist without spawning repair-editor. - const tmpDir = makeProjectTempDir('base2-parent-owned-specialist-') +describe('base2 emitGateTelemetry durable sink recorder', () => { + /** + * Drive a gate-disabled turn to its skip diagnostic (which emits telemetry). + * + * Mirrors the drive sequence and handleSteps argument shape of the passing + * test 'fast/no-validation mode skips file-change hooks and reviewer after + * edits', with the single difference that the caller-supplied `params` is + * passed instead of `params: {}`. In particular NO `config` key is passed: + * createBase2('fast').programmaticConfig carries `hasNoValidation: false`, + * and handleSteps prefers an explicit boolean `config.hasNoValidation` over + * the `base2-fast` agentId allowlist, so supplying the config would turn the + * validation/reviewer gate back ON and never reach the skip diagnostic. + */ + function driveDisabledGateTurn(params: Record) { + const base2 = createBase2('fast') + const agentState = { agentId: 'base2-fast' } + const gen = base2.handleSteps!({ + agentState, + prompt: 'Make the requested change now please', + params, + } as any) + + expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) + expect(gen.next(feedJson({ status: '' })).value).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + expect(gen.next().value).toBe('STEP') + expect( + gen.next(finishStepWithToolResult(editReceipt('src/a.ts'))).value, + ).toMatchObject({ toolName: 'git_status' }) + return gen.next(feedJson({ status: ' M src/a.ts' })) + } + + test('forwards the same undefined-stripped payload to the injected recorder and console.info', () => { + const recorded: Array> = [] + const logged: string[] = [] + const originalInfo = console.info + console.info = (...args: unknown[]) => { + const [first] = args + if (typeof first === 'string' && first.includes('"base2.gate"')) { + logged.push(first) + } + } try { - const stateDir = join(tmpDir, 'state') - mkdirSync(stateDir, { recursive: true }) - const absoluteFile = join(stateDir, 'session.ts') - writeFileSync(absoluteFile, 'export const session = "v1"\n') - // Prefer project-relative path under .base2-test-scratch when cwd is the - // openbuff root so the reliability router sees a `state` segment. - const gateFile = normalizeGateFilePath(absoluteFile) - const base2 = createBase2('default') - const agentState = { - agentId: 'base2-custom', - base2ActiveWork: { - changedFiles: [gateFile], - touchedFiles: [gateFile], - pendingGateFiles: [gateFile], - currentPhase: 'awaiting_validation', - openReviewerBlockers: [], - openReviewerFindings: [], - lastValidationSummary: '', - nextRequiredAction: '', - lastPinnedStateMessage: '', - gatePassedFiles: [], - gatePassedPendingFiles: [], - gatePassedReviewerVerdict: '', - gatePassedValidationSummary: '', - gatePassedFingerprint: '', - lastReviewerGateSkipReason: '', - reviewReceipts: [], - testWriterGateDone: true, - docWriterGateDone: true, - securityReviewGateDone: true, - preEditSecurityReviewDone: true, - specialistReviewGatesDone: [], - auxGatesLastPendingFiles: [gateFile], + const skipDiagnostic = driveDisabledGateTurn({ + orchestrationControlPlane: { + recordGateTelemetry: (payload: Record) => { + recorded.push(payload) + }, }, - } - // Process tasks stay in the prompt for non-blocking parent context; keep - // a non-codebase-intent prompt so there is no query_index prelude. - const prompt = - 'Please finish the pending reliability finding. Parent will later commit and push then confirm CI/CD is green.' - const gen = base2.handleSteps!({ - agentState, - prompt, - params: {}, - } as any) + }) - expect(gen.next().value).toMatchObject({ toolName: 'git_status', input: {} }) + expect(skipDiagnostic.value).toMatchObject({ toolName: 'add_message' }) + expect(recorded).toHaveLength(1) + // Keys/values verified against the gates-disabled skip site in + // agents/base2/base2.ts (currentPhase/pendingFileCount/pendingFiles/ + // reviewerStatus/validationStatus/skipReason) plus the `event: + // 'base2.gate'` key emitGateTelemetry prepends. + expect(recorded[0]).toMatchObject({ + event: 'base2.gate', + pendingFiles: ['src/a.ts'], + pendingFileCount: 1, + reviewerStatus: 'skipped', + validationStatus: 'skipped', + skipReason: 'validation-and-reviewer-gates-disabled', + }) + // Undefined-stripped: keys whose value was undefined never appear. expect( - gen.next(feedJson({ status: ` M ${gateFile}` })).value, - ).toMatchObject({ - toolName: 'spawn_agent_inline', - input: { agent_type: 'context-pruner' }, + Object.values(recorded[0]).every((value) => value !== undefined), + ).toBe(true) + // console.info still logs, and it logs the SAME object. + expect(logged).toHaveLength(1) + expect(JSON.parse(logged[0])).toEqual(recorded[0]) + } finally { + console.info = originalInfo + } + }) + + test('behaves exactly as before when no recorder is injected', () => { + const logged: string[] = [] + const originalInfo = console.info + console.info = (...args: unknown[]) => { + const [first] = args + if (typeof first === 'string' && first.includes('"base2.gate"')) { + logged.push(first) + } + } + try { + const skipDiagnostic = driveDisabledGateTurn({}) + expect(skipDiagnostic.value).toMatchObject({ toolName: 'add_message' }) + expect(logged).toHaveLength(1) + expect(JSON.parse(logged[0])).toMatchObject({ + event: 'base2.gate', + skipReason: 'validation-and-reviewer-gates-disabled', }) - expect(gen.next().value).toMatchObject({ toolName: 'add_message' }) - expect(gen.next().value).toBe('STEP') - expect(gen.next(finishStepWithToolResult({})).value).toMatchObject({ - toolName: 'git_status', - input: {}, + } finally { + console.info = originalInfo + } + }) + + test('a throwing recorder never throws out of the gate loop', () => { + const logged: string[] = [] + const originalInfo = console.info + console.info = (...args: unknown[]) => { + const [first] = args + if (typeof first === 'string' && first.includes('"base2.gate"')) { + logged.push(first) + } + } + try { + const skipDiagnostic = driveDisabledGateTurn({ + orchestrationControlPlane: { + recordGateTelemetry: () => { + throw new Error('sink exploded') + }, + }, }) - const bundle = gen.next(feedJson({ status: ` M ${gateFile}` })) - expect(bundle.value).toMatchObject({ - toolName: 'get_change_review_bundle', - input: {}, + // The gate continued to its skip diagnostic instead of surfacing the + // error. + expect(skipDiagnostic.value).toMatchObject({ + toolName: 'add_message', + input: { role: 'user' }, }) - const spawn = gen.next( - feedJson({ - snapshotId: 'unit-spec-snap-parent-owned', - files: [gateFile], - }), + expect((skipDiagnostic.value as any).input.content).toContain( + 'validation-and-reviewer-gates-disabled', ) - expect(spawn.value).toMatchObject({ - toolName: 'spawn_agents', - input: { agents: [{ agent_type: 'reliability-reviewer' }] }, + // The pre-existing console.info channel survives a throwing sink. + expect(logged).toHaveLength(1) + expect(JSON.parse(logged[0])).toMatchObject({ + event: 'base2.gate', + skipReason: 'validation-and-reviewer-gates-disabled', }) - const spawnPrompt = (spawn.value as any).input.agents[0].prompt as string - expect(typeof spawnPrompt).toBe('string') - expect( - spawnPrompt.includes('specialist-domain only') || - spawnPrompt.includes('Do NOT treat parent workflow'), - ).toBe(true) - expect(spawnPrompt).not.toMatch( - new RegExp( - `^Requirements:\\s*${prompt.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, - 'm', - ), - ) - const fingerprint = String( - (spawn.value as any).input.agents[0].params?.snapshot_id ?? '', - ) - expect(fingerprint).toMatch(/^v3:[a-f0-9]{64}$/) + } finally { + console.info = originalInfo + } + }) - const after = gen.next( - looksGoodWithParentOwnedRequirements( - 'reliability-reviewer', - fingerprint, - [gateFile], - ), - ) - const afterValue = after.value as any - const isRepairEditorSpawn = - afterValue?.toolName === 'spawn_agents' && - afterValue?.input?.agents?.[0]?.agent_type === 'repair-editor' - expect(isRepairEditorSpawn).toBe(false) + test('a failing console emit still reaches the durable sink', () => { + const recorded: Array> = [] + const originalInfo = console.info + // Stands in for every way the console leg can fail — a host console that + // throws, or a JSON.stringify that throws on a circular/BigInt payload. + // Narrowed to the gate line so unrelated console.info callers on the drive + // path are unaffected. + console.info = (...args: unknown[]) => { + const [first] = args + if (typeof first === 'string' && first.includes('"base2.gate"')) { + throw new Error('console exploded') + } + } + try { + const skipDiagnostic = driveDisabledGateTurn({ + orchestrationControlPlane: { + recordGateTelemetry: (payload: Record) => { + recorded.push(payload) + }, + }, + }) - let creditYield = after - if ( - afterValue?.toolName === 'add_message' && - typeof afterValue?.input?.content === 'string' && - afterValue.input.content.includes( - 'parent-owned process requirements were ignored', - ) - ) { - creditYield = gen.next() + expect(skipDiagnostic.value).toMatchObject({ toolName: 'add_message' }) + // The durable sink line is preserved even though the console leg failed. + expect(recorded).toHaveLength(1) + expect(recorded[0]).toMatchObject({ + event: 'base2.gate', + skipReason: 'validation-and-reviewer-gates-disabled', + }) + } finally { + console.info = originalInfo + } + }) +}) + +type InlineEmitGateTelemetry = (payload: Record) => void + +// emitGateTelemetry lives inside the serialized handleSteps generator and is +// only reachable through the gate loop, whose call sites never pass an `event` +// key. Reconstruct it with stub `params` / `mutableAgentState` bindings so the +// after-the-copy-loop discriminator guard can be asserted directly (the +// analogous `recordedAt` guard is covered by +// common/src/util/__tests__/gate-telemetry.test.ts). +function loadInlineEmitGateTelemetry( + recordGateTelemetry: (payload: Record) => void, + transitionBase2Gate?: (input: { current: unknown; phase: string }) => unknown, +): InlineEmitGateTelemetry { + const base2Source = readFileSync( + new URL('../base2/base2.ts', import.meta.url), + 'utf8', + ) + // handleSteps helpers are TypeScript; transpile before new Function (plain JS). + const transpiler = new Bun.Transpiler({ loader: 'ts', target: 'bun' }) + const combinedJs = transpiler.transformSync( + [ + extractInlineFunctionSource(base2Source, 'emitGateTelemetry'), + 'return emitGateTelemetry', + ].join('\n'), + ) + const buildHelper = new Function( + 'params', + 'mutableAgentState', + `"use strict";\n${combinedJs}`, + ) as ( + params: unknown, + mutableAgentState: Record, + ) => InlineEmitGateTelemetry + + return buildHelper( + { + orchestrationControlPlane: { + recordGateTelemetry, + ...(transitionBase2Gate ? { transitionBase2Gate } : {}), + }, + }, + {}, + ) +} + +describe('base2 inline emitGateTelemetry discriminator guard', () => { + test('a payload key named `event` cannot override the base2.gate discriminator', () => { + const recorded: Array> = [] + const logged: string[] = [] + const emitGateTelemetry = loadInlineEmitGateTelemetry((payload) => { + recorded.push(payload) + }) + const originalInfo = console.info + console.info = (...args: unknown[]) => { + const [first] = args + if (typeof first === 'string' && first.includes('"base2.gate"')) { + logged.push(first) } - expect( - (creditYield.value as any)?.toolName === 'spawn_agents' && - ((creditYield.value as any)?.input?.agents ?? []).some( - (a: { agent_type?: string }) => a?.agent_type === 'repair-editor', - ), - ).toBe(false) + } + try { + emitGateTelemetry({ + event: 'attacker-supplied', + skipReason: 'inline-discriminator-guard', + reviewerStatus: undefined, + }) + } finally { + console.info = originalInfo + } - expect( - (agentState as any).base2ActiveWork.specialistReviewGatesDone, - ).toContain('reliability-reviewer') - expect((agentState as any).base2ActiveWork.currentPhase).not.toBe( - 'repair_loop', - ) - expect((agentState as any).base2ActiveWork.currentPhase).not.toBe( - 'blocked', - ) + // `safePayload.event` is assigned AFTER the undefined-stripping copy loop, + // so the payload key is overridden instead of winning. + expect(recorded).toHaveLength(1) + expect(recorded[0].event).toBe('base2.gate') + expect(recorded[0].skipReason).toBe('inline-discriminator-guard') + // Undefined-stripping still applies to the rest of the payload. + expect('reviewerStatus' in recorded[0]).toBe(false) + // console.info logs the SAME overridden discriminator. + expect(logged).toHaveLength(1) + expect(JSON.parse(logged[0])).toEqual(recorded[0]) + }) +}) + +describe('base2 inline emitGateTelemetry transition isolation', () => { + test('a rejected phase transition still records the payload and logs it', () => { + const recorded: Array> = [] + const logged: string[] = [] + // base2GateWorkflowV1 throws on an illegal transition (e.g. 'repair_loop' + // from the default 'idle'). That throw must not cost us the telemetry for + // the very event that reported the illegal phase. + const emitGateTelemetry = loadInlineEmitGateTelemetry( + (payload) => { + recorded.push(payload) + }, + () => { + throw new Error('illegal transition: idle -> repair_loop') + }, + ) + const originalInfo = console.info + console.info = (...args: unknown[]) => { + const [first] = args + if (typeof first === 'string' && first.includes('"base2.gate"')) { + logged.push(first) + } + } + try { + emitGateTelemetry({ + currentPhase: 'repair_loop', + skipReason: 'inline-transition-throw', + }) } finally { - rmSync(tmpDir, { recursive: true, force: true }) + console.info = originalInfo } + + expect(recorded).toHaveLength(1) + expect(recorded[0]).toMatchObject({ + event: 'base2.gate', + currentPhase: 'repair_loop', + skipReason: 'inline-transition-throw', + }) + // The pre-existing console.info channel survives the throw too, and both + // channels still share one payload object. + expect(logged).toHaveLength(1) + expect(JSON.parse(logged[0])).toEqual(recorded[0]) + }) +}) + +type InlineGateStateBlockHelpers = { + formatGateStateBlock: ( + gate: 'validation' | 'reviewer' | 'validation/reviewer', + status: 'passed' | 'failed' | 'skipped', + details: string, + repairRound?: number, + advisories?: string[], + ) => string + extractGateStateBlocksFromMessage: (message: unknown) => Array<{ + gate: string + status: string + details: string + repairRound?: number + maxRepairRounds?: number + }> +} + +// Both helpers live inside the serialized handleSteps generator, so reconstruct +// them (plus collectMessageText, which the reader walks messages with) with a +// stub MAX_REPAIR_ROUNDS binding. That pairs the PRODUCER with base2's OWN +// conversation-gate-reuse READER, which is what a truncated block would break. +function loadInlineGateStateBlockHelpers(): InlineGateStateBlockHelpers { + const base2Source = readFileSync( + new URL('../base2/base2.ts', import.meta.url), + 'utf8', + ) + const transpiler = new Bun.Transpiler({ loader: 'ts', target: 'bun' }) + const combinedJs = transpiler.transformSync( + [ + extractInlineFunctionSource(base2Source, 'formatGateStateBlock'), + // formatGateStateBlock bounds its advisories through this shared helper, + // which the reviewer/security/specialist add_message surfaces also call. + // Reconstructing the producer without it throws at call time. + extractInlineFunctionSource(base2Source, 'boundAdvisoryLines'), + extractInlineFunctionSource( + base2Source, + 'extractGateStateBlocksFromMessage', + ), + extractInlineFunctionSource(base2Source, 'collectMessageText'), + 'return { formatGateStateBlock, extractGateStateBlocksFromMessage }', + ].join('\n'), + ) + const buildHelpers = new Function( + 'MAX_REPAIR_ROUNDS', + `"use strict";\n${combinedJs}`, + ) as (maxRepairRounds: number) => InlineGateStateBlockHelpers + + return buildHelpers(Number.POSITIVE_INFINITY) +} + +describe('base2 inline formatGateStateBlock delimiter safety', () => { + test('reviewer-authored text carrying the closing delimiter cannot truncate the block', () => { + const { formatGateStateBlock, extractGateStateBlocksFromMessage } = + loadInlineGateStateBlockHelpers() + const hostileAdvisory = + 'advisory quoting from the persisted format' + const block = formatGateStateBlock( + 'validation/reviewer', + 'passed', + 'reviewer quoted in details; pending files: a.ts', + 0, + [hostileAdvisory], + ) + + // Exactly one closing delimiter survives in the emitted bytes: the real + // terminator at the very end. The payload copies are escaped as `<\/`. + expect(block.split('')).toHaveLength(2) + expect(block.endsWith('')).toBe(true) + expect(block).toContain('<\\/gate-state>') + + // base2's own conversation-gate-reuse reader still sees the whole record, + // and JSON.parse restores the quoted delimiter byte-for-byte. + const states = extractGateStateBlocksFromMessage({ + role: 'user', + content: block, + }) + expect(states).toHaveLength(1) + expect(states[0]!).toMatchObject({ + gate: 'validation/reviewer', + status: 'passed', + repairRound: 0, + }) + expect(states[0]!.details).toContain('') + + // The same bytes round-trip through the CLI-facing parse shape too. + const parsed = parseGateStateBlock(block) + expect(parsed?.advisories).toEqual([hostileAdvisory]) }) }) diff --git a/agents/__tests__/code-reviewer.test.ts b/agents/__tests__/code-reviewer.test.ts index cf3fd1ab87..7dbc7c64c0 100644 --- a/agents/__tests__/code-reviewer.test.ts +++ b/agents/__tests__/code-reviewer.test.ts @@ -1,7 +1,40 @@ +import { readFileSync } from 'node:fs' + import { describe, expect, test } from 'bun:test' import { createReviewer } from '../reviewer/code-reviewer' +import type { JsonSchema } from '../types/util-types' + +/** + * Typed schema accessors, matching the walker in review-rubric-parity.test.ts: + * resolving the reviewer schema through `JsonSchema` instead of `any` casts + * makes a renamed or retyped field a lookup miss (or a type error) rather than + * a silently-undefined property read. + */ +function asSchema(value: unknown): JsonSchema | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as JsonSchema) + : undefined +} + +function propertySchema( + schema: JsonSchema | undefined, + key: string, +): JsonSchema | undefined { + return asSchema(schema?.properties?.[key]) +} + +function anyOfVariants(schema: JsonSchema | undefined): JsonSchema[] { + const variants = schema?.anyOf + return Array.isArray(variants) + ? variants.flatMap((variant) => { + const resolved = asSchema(variant) + return resolved ? [resolved] : [] + }) + : [] +} + describe('code-reviewer prompt isolation', () => { test('uses structured output when an output schema is declared', () => { const reviewer = createReviewer('anthropic/claude-opus-4.7') @@ -52,6 +85,113 @@ describe('code-reviewer prompt isolation', () => { ) }) + // T1.2(b): the finding generator must be a bounded, terminating enumeration. + test('bounds the finding generator with a finite completeness criterion', () => { + const reviewer = createReviewer('anthropic/claude-opus-4.7') + + expect(reviewer.instructionsPrompt).toContain( + 'that REQUIRES A CHANGE, in a single pass, and then stop', + ) + expect(reviewer.instructionsPrompt).toContain( + 'finite completeness criterion', + ) + // An empty finding set must be reachable and legitimate. + expect(reviewer.instructionsPrompt).toContain('Satisfiable empty set') + expect(reviewer.instructionsPrompt).toContain( + '"nothing requires a change" is a legitimate, reachable outcome', + ) + // Findings must be over properties a repair can clear. + expect(reviewer.instructionsPrompt).toContain('Monotonicity under repair') + expect(reviewer.instructionsPrompt).toContain( + 'taste-based or preference-based observations are not monotone', + ) + // Finding count tracks violations, not how much code is in view. + expect(reviewer.instructionsPrompt).toContain('Low churn sensitivity') + expect(reviewer.instructionsPrompt).toContain( + 're-reading the same unchanged code must not manufacture new findings', + ) + // The unbounded instruction must not come back. + expect(reviewer.instructionsPrompt).not.toContain('find ways to improve') + }) + + // T1.3: findings accept OPTIONAL id/severity/dimension metadata objects. + test('accepts optional finding metadata objects alongside plain strings', () => { + const reviewer = createReviewer('anthropic/claude-opus-4.7') + const schema: JsonSchema | undefined = reviewer.outputSchema + const findings = propertySchema(schema, 'findings') + + expect(findings?.type).toBe('array') + const variants = anyOfVariants(asSchema(findings?.items)) + expect(variants).toHaveLength(2) + // The plain-string form must stay valid: existing reviewers emit strings + // and must not be invalidated by the metadata addition. + expect(variants[0]).toEqual({ type: 'string' }) + + const objectForm = variants[1] + expect(objectForm?.type).toBe('object') + expect(Object.keys(objectForm?.properties ?? {}).sort()).toEqual([ + 'correction', + 'dimension', + 'evidence', + 'id', + 'severity', + 'text', + ]) + // RF-5: base2's recordSuccessfulReviewReceipt compacts finding.evidence and + // finding.correction into the durable receipt, so the schema must let a + // conforming reviewer actually supply them. + expect(propertySchema(objectForm, 'evidence')).toEqual({ + type: 'array', + items: { type: 'string' }, + }) + expect(propertySchema(objectForm, 'correction')).toEqual({ type: 'string' }) + // Only `text` is required — the rest is optional metadata, so requiring any + // of it here would reject a conforming reviewer. + expect(objectForm?.required).toEqual(['text']) + expect(propertySchema(objectForm, 'severity')?.enum).toEqual([ + 'critical', + 'high', + 'medium', + 'low', + ]) + // The dimension enum is derived-by-assertion from this schema's own + // dimensions object, so adding a review dimension without extending the + // finding label fails here instead of silently diverging. + expect(propertySchema(objectForm, 'dimension')?.enum).toEqual( + propertySchema(schema, 'dimensions')?.required, + ) + }) + + test('documents id-stable finding correlation across repair rounds', () => { + const reviewer = createReviewer('anthropic/claude-opus-4.7') + + expect(reviewer.instructionsPrompt).toContain( + 'the gate correlates findings across repair rounds by', + ) + expect(reviewer.instructionsPrompt).toContain( + 'Keep the id stable across rounds for the same underlying violation', + ) + // Severity/dimension are telemetry-only today; the prompt must not imply + // the gate thresholds on them (that is evidence-gated Tier 2 work). + expect(reviewer.instructionsPrompt).toContain( + 'the gate records as telemetry and does not currently act on', + ) + // RF-5: evidence/correction are schema-declared, so the prompt must tell + // the reviewer they exist and that the gate stores them. + expect(reviewer.instructionsPrompt).toContain( + 'the gate compacts both into its durable review receipt', + ) + // RF-2: gate-reviewer's `findingRecords` drops object findings without an + // `id`, and base2 builds receipt.findings only from those records, so the + // prompt must not promise persistence for an id-less finding. + expect(reviewer.instructionsPrompt).toContain( + 'only for findings that carry a stable `id`', + ) + expect(reviewer.instructionsPrompt).toContain( + "an id-less finding's evidence and correction are dropped", + ) + }) + test('requires set_output instead of an ambiguous textual verdict', () => { const reviewer = createReviewer('anthropic/claude-opus-4.7') @@ -76,6 +216,29 @@ describe('code-reviewer prompt isolation', () => { ) }) + // RF-1: the gate turns a `dimensions.*` value into a hard blocker only when + // its first word matches /^block(s|ing|er|ers)?\b/ (gate-reviewer's + // `collectReviewerBlockers` / `getReviewerFinalizationVerdict`), so the + // prompt must name that prefix instead of only showing the passing form. + test('names the block prefix a failing dimension must use', () => { + const reviewer = createReviewer('anthropic/claude-opus-4.7') + + expect(reviewer.instructionsPrompt).toContain( + 'A FAILING dimension must start with `block`, `blocks`, `blocking`, or `blocker(s)`', + ) + expect(reviewer.instructionsPrompt).toContain( + 'the gate only turns a dimension into a hard blocker when its first word matches that prefix', + ) + // The two spellings that are silently non-blocking must be named. + expect(reviewer.instructionsPrompt).toContain( + '`fail: ...` or `blocked: ...` is silently non-blocking', + ) + // The passing example must survive alongside the failing one. + expect(reviewer.instructionsPrompt).toContain( + '"pass: guards close cleanly"', + ) + }) + test('treats missing parallel validation output as unavailable', () => { const reviewer = createReviewer('anthropic/claude-opus-4.7') @@ -141,9 +304,7 @@ describe('code-reviewer prompt isolation', () => { const reviewer = createReviewer('anthropic/claude-opus-4.7') // Parent-owned process tasks are out of scope; only in-scope gaps force BLOCKING. - expect(reviewer.instructionsPrompt).toContain( - 'Parent-owned process tasks', - ) + expect(reviewer.instructionsPrompt).toContain('Parent-owned process tasks') expect(reviewer.instructionsPrompt).toContain( 'if ANY in-scope `requirementCoverage[].status` is `missing` or `uncertain`', ) @@ -162,10 +323,71 @@ describe('code-reviewer prompt isolation', () => { 'the parent gate finalizes only on `LOOKS_GOOD`', ) expect(reviewer.instructionsPrompt).toContain( - 'Use `NON_BLOCKING` when nits exist', + 'Use `NON_BLOCKING` when findings exist that require a change but do not block', ) expect(reviewer.instructionsPrompt).toContain( 'repair/re-review loop until a later review returns `LOOKS_GOOD`', ) }) + + // Advisory channel: cosmetic observations are recorded and displayed instead + // of blocking, so they never re-enter the repair loop as findings. + test('declares an optional advisories channel alongside findings', () => { + const reviewer = createReviewer('anthropic/claude-opus-4.7') + const schema: JsonSchema | undefined = reviewer.outputSchema + + expect(propertySchema(schema, 'advisories')).toEqual({ + type: 'array', + items: { type: 'string' }, + }) + // Vacuity guard: `required` must be the real non-empty list, and + // `advisories` must stay out of it so existing receipts stay conforming. + expect(schema?.required).toContain('findings') + expect(schema?.required).not.toContain('advisories') + }) + + // Reviewer-family symmetry (RF-6): only code-reviewer declares `advisories`, + // and that asymmetry is intentional rather than a pending migration. The + // rationale is pinned here so a later reviewer-family audit does not read the + // single declaration as an incomplete rollout. + test('documents advisories as additive and family-optional', () => { + const source = readFileSync( + new URL('../reviewer/code-reviewer.ts', import.meta.url), + 'utf8', + ) + + expect(source).toContain('Reviewer-family symmetry') + expect(source).toContain('ADDITIVE and stays out of') + expect(source).toContain( + 'collectReviewerAdvisories` reads a missing/unusable', + ) + }) + + test('routes cosmetic observations to advisories instead of blocking on them', () => { + const reviewer = createReviewer('anthropic/claude-opus-4.7') + + // The contradiction being removed: LOOKS_GOOD is no longer conditioned on + // having zero observations of any kind. + expect(reviewer.instructionsPrompt).not.toContain( + 'Do not emit `LOOKS_GOOD` while any findings remain', + ) + expect(reviewer.instructionsPrompt).toContain( + 'Use `LOOKS_GOOD` when nothing REQUIRES A CHANGE, even if you still have cosmetic observations', + ) + expect(reviewer.instructionsPrompt).toContain( + 'put those in `advisories` and leave `findings` empty', + ) + // The empty-findings invariant for LOOKS_GOOD must survive the rewrite. + expect(reviewer.instructionsPrompt).toContain( + 'a `LOOKS_GOOD` verdict must still carry an EMPTY `findings` array', + ) + // Advisories are not discarded, and the class of observation is named. + expect(reviewer.instructionsPrompt).toContain( + 'recorded in the durable review receipt and shown to the user, so nothing is lost', + ) + expect(reviewer.instructionsPrompt).toContain( + 'comment density, naming taste, optional refactors, speculative future-proofing', + ) + expect(reviewer.instructionsPrompt).toContain('at most 8 advisories') + }) }) diff --git a/agents/__tests__/gate-reviewer.test.ts b/agents/__tests__/gate-reviewer.test.ts index eb5a03dc23..927bf63029 100644 --- a/agents/__tests__/gate-reviewer.test.ts +++ b/agents/__tests__/gate-reviewer.test.ts @@ -4,13 +4,16 @@ import { describe, expect, test } from 'bun:test' import { classifyReviewerCrash, + collectParentOwnedRequirementBlockers, + collectReviewerAdvisories, collectReviewerBlockers, collectReviewerAttestationIssues, collectReviewerFindingRecords, + collectReviewerFingerprintDrift, + collectReviewerHardBlockers, detectReviewerCrash, getReviewerFinalizationVerdict, isParentOwnedOrOutOfScopeRequirement, - isParentOwnedRequirementBlocker, isTestCoverageReviewerFinding, isTransientReviewerCrash, stripReviewerPreamble, @@ -21,6 +24,41 @@ type ReviewerFinalizationVerdict = 'LOOKS_GOOD' | '' type GateReviewerHelpers = { stripReviewerPreamble: (text: string) => string collectReviewerBlockers: (toolResult: unknown) => string[] + // base2 keeps its own copy; the condone filter consults THAT one at runtime. + collectReviewerHardBlockers: (toolResult: unknown) => string[] + // base2's OWN copy is the gate's fail-closed attestation authority (every + // review call site runs it), so the mirror needs the same parity coverage as + // the hard-blocker one: an over-tolerant mirror would credit a review that + // never attested to the pending files. + collectReviewerAttestationIssues: ( + toolResult: unknown, + expectedFingerprint: string, + pendingFiles: string[], + deletedFiles?: string[], + ) => string[] + // The gate correlates structured findings with THIS copy, so the + // first-record-per-id de-dupe needs parity coverage too. + collectReviewerFindingRecords: (toolResult: unknown) => Array<{ + id: string + text: string + severity?: string + dimension?: string + evidence: string[] + correction?: string + }> + // The gate's durable review receipt is built from THIS collector (base2's + // generated copy), so the persisted advisory semantics need parity coverage. + collectReviewerAdvisories: (toolResult: unknown) => string[] + collectReviewerFingerprintDrift: ( + toolResult: unknown, + expectedFingerprint: string, + ) => string + // base2's gate call sites filter parent-owned requirement gaps with THIS + // copy, so it needs the same parity coverage as the hard-blocker mirror. + collectParentOwnedRequirementBlockers: ( + blockers: string[], + toolResult?: unknown, + ) => Set getReviewerFinalizationVerdict: ( toolResult: unknown, ) => ReviewerFinalizationVerdict @@ -33,24 +71,73 @@ type InlineHelperFactory = () => GateReviewerHelpers const INLINE_HELPER_NAMES: GateReviewerFunctionName[] = [ 'stripReviewerPreamble', 'collectReviewerBlockers', + 'collectReviewerHardBlockers', + 'collectReviewerAttestationIssues', + 'collectReviewerFindingRecords', + 'collectReviewerAdvisories', + 'collectReviewerFingerprintDrift', + 'collectParentOwnedRequirementBlockers', 'getReviewerFinalizationVerdict', 'detectReviewerCrash', ] +// Extraction constraint: every helper named here (and in INLINE_HELPER_NAMES) +// must have raw-text balanced braces, because extractInlineFunctionSource counts +// `{`/`}` without tokenizing — a brace inside a string, regex, or comment would +// mis-slice the helper. assertInlineFunctionSourceParses turns such a +// regression into a named error instead of a confusing parity mismatch. +// Why the count still lands correctly today: the only brace-bearing regexes in +// the mirrored set are QUANTIFIERS such as `[^.\n]{0,40}` in +// isParentOwnedOrOutOfScopeRequirement, and a quantifier's braces pair, so they +// cancel out. A regex or string literal carrying a LONE `{` or `}` is the hazard +// to watch for on future edits; assertInlineFunctionSourceParses only catches it +// when the resulting mis-slice happens not to parse. const INLINE_DEPENDENCY_NAMES = [ 'dedupeExactStringsPreserveOrder', 'collectStructuredReviewerOutputs', 'visitForStructuredVerdict', 'hasReviewerLineVerdict', 'collectStrings', - 'findReviewerCrash', // Crash taxonomy helpers are generated into base2; parity for // detectReviewerCrash only needs findReviewerCrash. Unit tests cover // isTransientReviewerCrash / classifyReviewerCrash against the export. + 'findReviewerCrash', + // collectReviewerFingerprintDrift routes the v3 shape check through the + // shared predicate, which delegates to the gate-fingerprint helper; both are + // generated into base2, so the reconstructed set needs them in scope. + 'isAttestableV3Fingerprint', + 'isAttestableSnapshotFingerprint', + // collectParentOwnedRequirementBlockers (exposed from the factory above) + // classifies each requirement row through this predicate. 'isParentOwnedOrOutOfScopeRequirement', - 'isParentOwnedRequirementBlocker', + // collectReviewerAttestationIssues normalizes reviewed/pending/deleted paths + // through the gate-path helper and resolves the receipt's attestation through + // the shared order-independent resolver; both are generated into base2. + 'normalizeGateFilePath', + 'resolveReviewerAttestation', ] as const +/** + * Fail fast when the raw-brace slice below cut a helper in the wrong place. + * + * The extracted declaration is parsed on its own (parse only, never called), so + * a helper that gains an unbalanced brace inside a string, regex, or comment + * reports the offending helper by name instead of surfacing as a confusing + * parity mismatch or a SyntaxError blaming the whole concatenated factory. + */ +function assertInlineFunctionSourceParses( + extracted: string, + functionName: string, +): void { + try { + new Function(`"use strict";\n${extracted}`) + } catch (error) { + throw new Error( + `Extracted inline ${functionName} source does not parse; extractInlineFunctionSource counts raw braces, so a brace inside a string/regex/comment mis-sliced it: ${String(error)}`, + ) + } +} + function extractInlineFunctionSource( source: string, functionName: string, @@ -71,7 +158,9 @@ function extractInlineFunctionSource( if (character === '{') depth += 1 if (character === '}') depth -= 1 if (depth === 0) { - return source.slice(declarationStart, index + 1) + const extracted = source.slice(declarationStart, index + 1) + assertInlineFunctionSourceParses(extracted, functionName) + return extracted } } @@ -91,12 +180,51 @@ function loadInlineGateReviewerHelpers(): GateReviewerHelpers { ) .join('\n\n') const buildHelpers = new Function( - `"use strict";\n${helperSource}\nreturn { stripReviewerPreamble, collectReviewerBlockers, getReviewerFinalizationVerdict, detectReviewerCrash }`, + // Derived from INLINE_HELPER_NAMES so adding a mirrored helper cannot + // silently skip parity coverage by being missing from a hand-written list. + `"use strict";\n${helperSource}\nreturn { ${INLINE_HELPER_NAMES.join(', ')} }`, ) as InlineHelperFactory return buildHelpers() } +/** + * Readable per-blocker reference for `collectParentOwnedRequirementBlockers`. + * + * RF-3 deleted the production `isParentOwnedRequirementBlocker` wrapper (it had + * no live gate call site yet was still emitted into base2's serialized + * `` payload), so the reference implementation the batch + * collector is compared against lives here and is expressed directly in terms + * of `isParentOwnedOrOutOfScopeRequirement`. + * + * Precedence matches the collector and `getReviewerFinalizationVerdict`: with no + * matching structured row the requirement text alone decides, and when several + * rows share the `${status}\n${requirement}` key a single in-scope row keeps the + * blocker open. + */ +function referenceParentOwnedBlocker( + blocker: string, + rows?: Array<{ requirement: string; status: string; evidence?: string[] }>, +): boolean { + const match = blocker.match( + /^BLOCKING:\s*requirement\s+(missing|uncertain):\s*([\s\S]+)$/i, + ) + if (!match) return false + const status = match[1]!.toLowerCase() + const requirementText = match[2]!.trim() + const matchingRows = (rows ?? []).filter( + (row) => + row.status.toLowerCase() === status && + row.requirement.trim() === requirementText, + ) + if (matchingRows.length === 0) { + return isParentOwnedOrOutOfScopeRequirement(requirementText) + } + return matchingRows.every((row) => + isParentOwnedOrOutOfScopeRequirement(row.requirement, row.evidence), + ) +} + describe('gate-reviewer helpers', () => { test('stripReviewerPreamble removes closed leading think blocks only', () => { expect( @@ -160,6 +288,202 @@ describe('gate-reviewer helpers', () => { correction: 'Regenerate the lockfile with the repository manager.', }, ]) + // A nested spawn + set_output can surface the same receipt twice; records + // are de-duped by id so correlateReviewerFindingRecord sees each once. + expect( + collectReviewerFindingRecords({ + type: 'json', + value: [result, { type: 'json', value: result }], + }), + ).toEqual(collectReviewerFindingRecords(result)) + }) + + // Advisory channel: recorded and displayed, never a repair target. The + // zero-blocker / still-LOOKS_GOOD assertions are the guard against an + // advisory reaching collectReviewerBlockers and re-entering the loop. + test('collectReviewerAdvisories extracts and de-dupes advisories without blocking', () => { + const looksGoodWithAdvisories = { + schemaVersion: 1, + verdict: 'LOOKS_GOOD' as const, + findings: [] as string[], + coverage: 'covered' as const, + dimensions: { correctness: 'pass: guards close cleanly' }, + advisories: [ + 'comment density is high in gate-reviewer.ts', + ' optional refactor: extract the walker ', + 'comment density is high in gate-reviewer.ts', + '', + 42, + ], + } + const advisories = collectReviewerAdvisories(looksGoodWithAdvisories) + // Vacuity guard: the extraction must actually produce entries. + expect(advisories).toEqual([ + 'comment density is high in gate-reviewer.ts', + 'optional refactor: extract the walker', + ]) + // A single bare string is accepted the same way `findings` accepts one. + expect( + collectReviewerAdvisories({ + ...looksGoodWithAdvisories, + advisories: 'naming taste only', + }), + ).toEqual(['naming taste only']) + // Absent / unusable advisories yield nothing rather than a placeholder. + expect( + collectReviewerAdvisories({ + ...looksGoodWithAdvisories, + advisories: undefined, + }), + ).toEqual([]) + expect( + collectReviewerAdvisories({ + ...looksGoodWithAdvisories, + advisories: [42], + }), + ).toEqual([]) + expect(collectReviewerAdvisories(null)).toEqual([]) + // The LAST structured entry is the receipt the gate records. + expect( + collectReviewerAdvisories({ + type: 'json', + value: [ + { ...looksGoodWithAdvisories, advisories: ['stale advisory'] }, + { ...looksGoodWithAdvisories, advisories: ['final advisory'] }, + ], + }), + ).toEqual(['final advisory']) + // CRITICAL: an advisory must never become a blocker or cost finalization. + expect(collectReviewerBlockers(looksGoodWithAdvisories)).toEqual([]) + expect(collectReviewerHardBlockers(looksGoodWithAdvisories)).toEqual([]) + expect(getReviewerFinalizationVerdict(looksGoodWithAdvisories)).toBe( + 'LOOKS_GOOD', + ) + }) + + // base2's recordSuccessfulReviewReceipt persists the receipt's `advisories` + // through its OWN generated copy of this collector, so the mirror must agree + // with the export over every advisory shape: a drifted mirror would persist + // (and surface) different advisories than the tested contract. + test('the inline base2 collectReviewerAdvisories mirror matches the export', () => { + const inlineHelpers = loadInlineGateReviewerHelpers() + const receipt = { + schemaVersion: 1, + verdict: 'LOOKS_GOOD' as const, + findings: [] as string[], + coverage: 'covered' as const, + advisories: ['dup advisory', ' spaced advisory ', 'dup advisory'], + } + const inputs: unknown[] = [ + receipt, + { ...receipt, advisories: 'single bare advisory' }, + { ...receipt, advisories: [] }, + { ...receipt, advisories: [42, ''] }, + { ...receipt, advisories: undefined }, + // The LAST structured entry wins, including through nested wrappers. + { + type: 'json', + value: [ + { ...receipt, advisories: ['stale advisory'] }, + { type: 'json', value: { ...receipt, advisories: ['final'] } }, + ], + }, + 'BLOCKING: plain text', + null, + ] + // Vacuity guard: the first input must actually produce advisories. + expect(inlineHelpers.collectReviewerAdvisories(receipt)).toEqual([ + 'dup advisory', + 'spaced advisory', + ]) + for (const input of inputs) { + expect(inlineHelpers.collectReviewerAdvisories(input)).toEqual( + collectReviewerAdvisories(input), + ) + } + }) + + // RF-2-0b9cbf03: advisory selection narrows to `schemaVersion`-SHAPED entries + // the same way resolveReviewerAttestation does, so a reviewer that QUOTES the + // documented example receipt AFTER its real one cannot have the example's + // advisories persisted and displayed as this review's. + test('collectReviewerAdvisories ignores a trailing quoted example receipt', () => { + const realReceipt = { + schemaVersion: 1, + verdict: 'LOOKS_GOOD' as const, + findings: [] as string[], + coverage: 'covered' as const, + advisories: ['real advisory'], + } + // Quote of the documented example receipt: it parses as a structured + // verdict but carries no schemaVersion, so it is not a shaped entry. + const quotedExample = { + verdict: 'LOOKS_GOOD' as const, + findings: [] as string[], + coverage: 'covered' as const, + advisories: ['example advisory'], + } + const withTrailingQuote = { + type: 'json', + value: [realReceipt, quotedExample], + } + const unshapedOnly = { type: 'json', value: [quotedExample] } + expect(collectReviewerAdvisories(withTrailingQuote)).toEqual([ + 'real advisory', + ]) + // With no shaped entry anywhere the last-entry fallback still applies. + expect(collectReviewerAdvisories(unshapedOnly)).toEqual([ + 'example advisory', + ]) + // The LAST shaped entry still wins over an earlier shaped one. + const twoShaped = { + type: 'json', + value: [ + { ...realReceipt, advisories: ['stale advisory'] }, + realReceipt, + quotedExample, + ], + } + expect(collectReviewerAdvisories(twoShaped)).toEqual(['real advisory']) + // base2's generated copy is the gate's runtime authority. + const inlineHelpers = loadInlineGateReviewerHelpers() + for (const input of [withTrailingQuote, unshapedOnly, twoShaped]) { + expect(inlineHelpers.collectReviewerAdvisories(input)).toEqual( + collectReviewerAdvisories(input), + ) + } + }) + + // Serialization guard for the gate-pass advisory lookup. reviewReceipts is + // durable and holds up to 24 entries across turns and reviewer families, so + // the pass path must select THIS gate's receipt by gateId instead of reading + // the last stored receipt (which could surface another family's or an earlier + // turn's advisories and count them in advisoryCount telemetry). Asserted at + // the source level because the pass block lives inside base2's serialized + // handleSteps generator and cannot be reconstructed standalone. + test('the base2 gate-pass advisory lookup is bound to this gate receipt', () => { + const base2Source = readFileSync( + new URL('../base2/base2.ts', import.meta.url), + 'utf8', + ) + expect(base2Source).toContain( + 'const passReceiptGateId = `${requiredReviewerAgentType}:${reviewSnapshotFingerprint}`', + ) + expect(base2Source).toContain( + '(receipt) => receipt.gateId === passReceiptGateId,', + ) + // The unbound "latest receipt" read must be gone. + expect(base2Source).not.toContain( + '(activeWorkState.reviewReceipts ?? []).slice(-1)[0]?.advisories', + ) + // The persisted advisories must come from the shared collector, not a + // second inline read of the structured entry with different semantics. + expect(base2Source).toContain( + 'const advisories = collectReviewerAdvisories(toolResult)', + ) + expect(base2Source).not.toContain( + 'const advisories = (result.advisories ??', + ) }) test('collectReviewerBlockers falls back to text line verdicts', () => { @@ -201,6 +525,107 @@ describe('gate-reviewer helpers', () => { expect(getReviewerFinalizationVerdict('BLOCKING: fix first')).toBe('') }) + // RF-2-0aa9f731: finalization credit and attestation must read the SAME + // entry set. An unshaped QUOTED LOOKS_GOOD example beside a real shaped + // BLOCKING receipt must not credit the gate; with no shaped entry at all the + // whole set is still read, matching resolveReviewerAttestation's fallback. + test('finalization credit ignores unshaped entries when the receipt carries shaped ones', () => { + const realBlocking = { + schemaVersion: 1, + verdict: 'BLOCKING' as const, + findings: ['real blocker'], + coverage: 'covered' as const, + } + const quotedLooksGood = { + verdict: 'LOOKS_GOOD' as const, + findings: [] as string[], + coverage: 'covered' as const, + } + const mixed = { type: 'json', value: [realBlocking, quotedLooksGood] } + expect(getReviewerFinalizationVerdict(mixed)).toBe('') + // The real receipt's blocker is still the surviving repair target. + expect(collectReviewerBlockers(mixed)).toEqual(['BLOCKING: real blocker']) + // A shaped LOOKS_GOOD receipt still finalizes. + expect( + getReviewerFinalizationVerdict({ + type: 'json', + value: [{ ...quotedLooksGood, schemaVersion: 1 }], + }), + ).toBe('LOOKS_GOOD') + // No shaped entry anywhere: the unnarrowed scan still credits LOOKS_GOOD. + const unshapedOnly = { type: 'json', value: [quotedLooksGood] } + expect(getReviewerFinalizationVerdict(unshapedOnly)).toBe('LOOKS_GOOD') + // base2's inline mirror is the gate's runtime authority. + const inlineHelpers = loadInlineGateReviewerHelpers() + expect(inlineHelpers.getReviewerFinalizationVerdict(mixed)).toBe('') + expect(inlineHelpers.getReviewerFinalizationVerdict(unshapedOnly)).toBe( + 'LOOKS_GOOD', + ) + }) + + // RF-1-17fea4a5: the documented loosening. The reviewedFiles UNION is not + // restricted to the entry that contributed the credited fingerprint, so a + // quoted example whose example path COLLIDES with a real pending path credits + // coverage the real entry never attested. Pinned so resolveReviewerAttestation's + // docblock caveat stays honest; a pending path NO entry reported still blocks. + test('the reviewedFiles union credits a colliding quoted example path', () => { + const expected = 'v3:' + 'd'.repeat(64) + const attesting = { + schemaVersion: 1, + verdict: 'NON_BLOCKING' as const, + findings: ['real nit'], + coverage: 'covered' as const, + snapshotFingerprint: expected, + reviewedFiles: ['src/b.ts'], + } + // Quote of the documented receipt example, whose example path happens to be + // a real pending file the attesting entry never reviewed. + const quotedExample = { + schemaVersion: 1, + verdict: 'NON_BLOCKING' as const, + findings: ['example: a nit'], + coverage: 'covered' as const, + reviewedFiles: ['src/a.ts'], + } + const collidingQuote = { type: 'json', value: [attesting, quotedExample] } + expect( + collectReviewerAttestationIssues(collidingQuote, expected, [ + 'src/a.ts', + 'src/b.ts', + ]), + ).toEqual([]) + // A pending path neither entry reported still fails closed. + expect( + collectReviewerAttestationIssues(collidingQuote, expected, [ + 'src/a.ts', + 'src/b.ts', + 'src/c.ts', + ]), + ).toEqual([ + 'BLOCKING: reviewer did not attest to every pending file: src/c.ts', + ]) + // base2's inline mirror is the gate's runtime authority. + const inlineHelpers = loadInlineGateReviewerHelpers() + for (const pendingFiles of [ + ['src/a.ts', 'src/b.ts'], + ['src/a.ts', 'src/b.ts', 'src/c.ts'], + ]) { + expect( + inlineHelpers.collectReviewerAttestationIssues( + collidingQuote, + expected, + pendingFiles, + ), + ).toEqual( + collectReviewerAttestationIssues( + collidingQuote, + expected, + pendingFiles, + ), + ) + } + }) + test('collectReviewerBlockers elevates NON_BLOCKING findings to repair strings', () => { expect( collectReviewerBlockers({ @@ -213,10 +638,7 @@ describe('gate-reviewer helpers', () => { }, ], }), - ).toEqual([ - 'NON_BLOCKING: minor naming nit', - 'NON_BLOCKING: style tweak', - ]) + ).toEqual(['NON_BLOCKING: minor naming nit', 'NON_BLOCKING: style tweak']) expect( collectReviewerBlockers({ type: 'json', @@ -321,6 +743,76 @@ describe('gate-reviewer helpers', () => { ]) }) + // RF-1-dd326e16: a parent-owned requirement gap is filtered away by every + // gate call site, so it is not repair fuel. An empty-findings NON_BLOCKING + // receipt whose ONLY gaps are parent-owned must still emit the synthetic + // placeholder; otherwise the call-site filter empties the blocker list, the + // condoned-pass branch cannot fire (it needs a collected blocker), and the + // gate misdiagnoses the receipt as "no structured output" with no target. + test('keeps the synthetic placeholder when the only requirement gap is parent-owned', () => { + const placeholder = + 'NON_BLOCKING: reviewer returned non-blocking nits without findings; re-address and re-review until LOOKS_GOOD' + const parentOwnedOnlyReceipt = { + schemaVersion: 1, + verdict: 'NON_BLOCKING' as const, + findings: [] as string[], + coverage: 'covered' as const, + requirementCoverage: [ + { + requirement: 'commit and push', + status: 'missing' as const, + evidence: [] as string[], + }, + ], + } + const rawBlockers = collectReviewerBlockers(parentOwnedOnlyReceipt) + expect(rawBlockers).toEqual([ + 'BLOCKING: requirement missing: commit and push', + placeholder, + ]) + const parentOwned = collectParentOwnedRequirementBlockers( + rawBlockers, + parentOwnedOnlyReceipt, + ) + // The call-site filter drops the process gap, and a repair/re-review target + // survives instead of an empty list. + expect(rawBlockers.filter((blocker) => !parentOwned.has(blocker))).toEqual([ + placeholder, + ]) + // Evidence-only parent ownership follows the same rule as the filter. + expect( + collectReviewerBlockers({ + ...parentOwnedOnlyReceipt, + requirementCoverage: [ + { + requirement: 'Ship remaining workflow steps', + status: 'missing' as const, + evidence: [ + 'parent must run full validation gate after this specialist', + ], + }, + ], + }), + ).toEqual([ + 'BLOCKING: requirement missing: Ship remaining workflow steps', + placeholder, + ]) + // An in-scope gap is repair fuel on its own, so no placeholder is mixed in + // (all-coverage / single-target routing is unchanged). + expect( + collectReviewerBlockers({ + ...parentOwnedOnlyReceipt, + requirementCoverage: [ + { + requirement: 'wire selfMutatedPaths', + status: 'missing' as const, + evidence: [] as string[], + }, + ], + }), + ).toEqual(['BLOCKING: requirement missing: wire selfMutatedPaths']) + }) + test('parent-owned process requirements stay in raw blockers but do not block LOOKS_GOOD finalization', () => { const parentOwnedReceipt = { verdict: 'LOOKS_GOOD' as const, @@ -348,7 +840,9 @@ describe('gate-reviewer helpers', () => { 'BLOCKING: requirement missing: Commit and push', 'BLOCKING: requirement uncertain: Confirm CI/CD is green', ]) - expect(getReviewerFinalizationVerdict(parentOwnedReceipt)).toBe('LOOKS_GOOD') + expect(getReviewerFinalizationVerdict(parentOwnedReceipt)).toBe( + 'LOOKS_GOOD', + ) expect( isParentOwnedOrOutOfScopeRequirement('Rewrite git commit messages'), ).toBe(true) @@ -356,14 +850,47 @@ describe('gate-reviewer helpers', () => { isParentOwnedOrOutOfScopeRequirement('Run full validation gate'), ).toBe(true) // Source-owned validation work must not be suppressed as parent process. + expect( + isParentOwnedOrOutOfScopeRequirement('run validation of the new API'), + ).toBe(false) + expect(isParentOwnedOrOutOfScopeRequirement('wire selfMutatedPaths')).toBe( + false, + ) + // RF-3: bare `parent must` / `push changes` also occur in ordinary domain + // text. Such a row would be filtered from blockers AND ignored by + // getReviewerFinalizationVerdict, so a genuine gap would finalize + // silently; only a process verb/object counts as parent-owned. + expect( + isParentOwnedOrOutOfScopeRequirement( + 'Reject the insert when the parent must be validated first', + ['the parent must be validated before insert'], + ), + ).toBe(false) expect( isParentOwnedOrOutOfScopeRequirement( - 'run validation of the new API', + 'the API should push changes to subscribers', ), ).toBe(false) expect( - isParentOwnedOrOutOfScopeRequirement('wire selfMutatedPaths'), + isParentOwnedOrOutOfScopeRequirement('push changes into the cache layer'), ).toBe(false) + // The process wordings stay parent-owned. + expect( + isParentOwnedOrOutOfScopeRequirement( + 'parent must run the full validation gate', + ), + ).toBe(true) + expect( + isParentOwnedOrOutOfScopeRequirement( + 'parent must confirm CI/CD is green', + ), + ).toBe(true) + expect( + isParentOwnedOrOutOfScopeRequirement('push the changes to origin'), + ).toBe(true) + expect(isParentOwnedOrOutOfScopeRequirement('push changes upstream')).toBe( + true, + ) // Real in-scope gaps still appear alongside parent-owned rows in raw blockers. expect( collectReviewerBlockers({ @@ -391,7 +918,7 @@ describe('gate-reviewer helpers', () => { ).toBe('') }) - test('isParentOwnedRequirementBlocker re-checks structured evidence at call sites', () => { + test('collectParentOwnedRequirementBlockers re-checks structured evidence at call sites', () => { // Requirement text alone is not parent-owned; evidence carries the process cue. // Finalization and call-site filters must both consult evidence so LOOKS_GOOD // does not finalize while still spawning repair-editor. @@ -414,19 +941,31 @@ describe('gate-reviewer helpers', () => { 'BLOCKING: requirement missing: Ship remaining workflow steps', ]) // Without toolResult, only the requirement text is visible → not parent-owned. - expect(isParentOwnedRequirementBlocker(rawBlockers[0]!)).toBe(false) + expect( + collectParentOwnedRequirementBlockers(rawBlockers).has(rawBlockers[0]!), + ).toBe(false) // With toolResult, structured evidence matches getReviewerFinalizationVerdict. expect( - isParentOwnedRequirementBlocker(rawBlockers[0]!, evidenceOnlyReceipt), + collectParentOwnedRequirementBlockers( + rawBlockers, + evidenceOnlyReceipt, + ).has(rawBlockers[0]!), + ).toBe(true) + expect( + referenceParentOwnedBlocker( + rawBlockers[0]!, + evidenceOnlyReceipt.requirementCoverage, + ), ).toBe(true) expect(getReviewerFinalizationVerdict(evidenceOnlyReceipt)).toBe( 'LOOKS_GOOD', ) // Call-site filter shape used by specialist/security/code-reviewer. - const filtered = rawBlockers.filter( - (blocker) => - !isParentOwnedRequirementBlocker(blocker, evidenceOnlyReceipt), + const parentOwned = collectParentOwnedRequirementBlockers( + rawBlockers, + evidenceOnlyReceipt, ) + const filtered = rawBlockers.filter((blocker) => !parentOwned.has(blocker)) expect(filtered).toEqual([]) // Evidence that does not establish parent ownership must keep the gap in-scope. @@ -442,15 +981,1202 @@ describe('gate-reviewer helpers', () => { } const inScopeBlockers = collectReviewerBlockers(inScopeEvidenceReceipt) expect( - isParentOwnedRequirementBlocker( - inScopeBlockers[0]!, + collectParentOwnedRequirementBlockers( + inScopeBlockers, inScopeEvidenceReceipt, - ), + ).has(inScopeBlockers[0]!), ).toBe(false) expect(getReviewerFinalizationVerdict(inScopeEvidenceReceipt)).toBe('') }) + // Hot-path shape used by the gate: both blocker lists are classified from ONE + // structured walk instead of one walk per blocker, so the batch helper must + // agree with the readable per-blocker reference on every blocker (including + // hard-rule strings and non-requirement blockers). + test('collectParentOwnedRequirementBlockers matches the per-blocker reference for a whole list', () => { + const receipt = { + schemaVersion: 1, + verdict: 'LOOKS_GOOD' as const, + findings: [] as string[], + coverage: 'covered' as const, + requirementCoverage: [ + { + requirement: 'Commit and push', + status: 'missing' as const, + evidence: [] as string[], + }, + { + requirement: 'Ship remaining workflow steps', + status: 'missing' as const, + evidence: [ + 'parent must run full validation gate after this specialist', + ], + }, + { + requirement: 'preserve CLI compatibility', + status: 'uncertain' as const, + evidence: ['flag parsing changed'], + }, + ], + } + const rawBlockers = collectReviewerBlockers(receipt) + const rawHardBlockers = collectReviewerHardBlockers(receipt) + const blockers = [ + ...rawBlockers, + ...rawHardBlockers, + // Non-requirement blockers must never be classified as parent-owned. + 'BLOCKING: test coverage missing for changed behavior (add a case to the relevant *.test.ts)', + 'NON_BLOCKING: tighten the docstring', + // Same requirement text, different status → no structured row matches, so + // the text-only fallback decides. + 'BLOCKING: requirement uncertain: Commit and push', + ] + const parentOwned = collectParentOwnedRequirementBlockers(blockers, receipt) + expect(Array.from(parentOwned).sort()).toEqual( + [ + 'BLOCKING: requirement missing: Commit and push', + 'BLOCKING: requirement missing: Ship remaining workflow steps', + 'BLOCKING: requirement uncertain: Commit and push', + ].sort(), + ) + for (const blocker of blockers) { + expect(parentOwned.has(blocker)).toBe( + referenceParentOwnedBlocker(blocker, receipt.requirementCoverage), + ) + } + // Without a toolResult the classification falls back to requirement text. + const textOnly = collectParentOwnedRequirementBlockers(blockers) + for (const blocker of blockers) { + expect(textOnly.has(blocker)).toBe(referenceParentOwnedBlocker(blocker)) + } + expect(textOnly.has('BLOCKING: requirement missing: Commit and push')).toBe( + true, + ) + expect( + textOnly.has( + 'BLOCKING: requirement missing: Ship remaining workflow steps', + ), + ).toBe(false) + }) + + // RF-2: the blocker string carries the RAW requirement text, so padded and + // multi-line requirements must still resolve to their structured, + // evidence-aware row instead of degrading to text-only classification. + test('classifies padded and multi-line requirement text from structured evidence', () => { + for (const requirement of [ + ' Ship remaining workflow steps ', + 'Ship remaining workflow steps\nacross both entrypoints', + ]) { + const receipt = { + schemaVersion: 1, + verdict: 'LOOKS_GOOD' as const, + findings: [] as string[], + coverage: 'covered' as const, + requirementCoverage: [ + { + requirement, + status: 'missing' as const, + evidence: [ + 'parent must run full validation gate after this specialist', + ], + }, + ], + } + const rawBlockers = collectReviewerBlockers(receipt) + expect(rawBlockers).toEqual([ + `BLOCKING: requirement missing: ${requirement}`, + ]) + expect( + collectParentOwnedRequirementBlockers(rawBlockers, receipt).has( + rawBlockers[0]!, + ), + ).toBe(true) + expect( + referenceParentOwnedBlocker( + rawBlockers[0]!, + receipt.requirementCoverage, + ), + ).toBe(true) + // Parent-owned only via evidence, so finalization still credits + // LOOKS_GOOD and the call-site filter must agree (no repair spawn). + expect(getReviewerFinalizationVerdict(receipt)).toBe('LOOKS_GOOD') + + // In-scope evidence keeps the same padded/multi-line row a repair target. + const inScopeReceipt = { + ...receipt, + requirementCoverage: [ + { + requirement, + status: 'missing' as const, + evidence: ['unit tests still fail on the new path'], + }, + ], + } + const inScopeBlockers = collectReviewerBlockers(inScopeReceipt) + expect( + collectParentOwnedRequirementBlockers( + inScopeBlockers, + inScopeReceipt, + ).has(inScopeBlockers[0]!), + ).toBe(false) + expect( + referenceParentOwnedBlocker( + inScopeBlockers[0]!, + inScopeReceipt.requirementCoverage, + ), + ).toBe(false) + expect(getReviewerFinalizationVerdict(inScopeReceipt)).toBe('') + } + }) + + // RF-1: structured rows are keyed by `${status}\n${requirement}`, so one + // receipt can carry two same-status/same-text rows whose evidence disagrees + // (one process, one in-scope). getReviewerFinalizationVerdict blocks when ANY + // matching row is in-scope, so the batch classifier must NOT filter the + // blocker out — otherwise the gate stays closed with an empty surviving + // blocker list and no repair target. + test('an in-scope duplicate row wins over a parent-owned row with the same status and text', () => { + const duplicateRowReceipt = { + schemaVersion: 1, + verdict: 'LOOKS_GOOD' as const, + findings: [] as string[], + coverage: 'covered' as const, + requirementCoverage: [ + { + requirement: 'Ship remaining workflow steps', + status: 'missing' as const, + evidence: [ + 'parent must run full validation gate after this specialist', + ], + }, + { + requirement: 'Ship remaining workflow steps', + status: 'missing' as const, + evidence: ['unit tests still fail on the new path'], + }, + ], + } + // Both rows emit the same blocker string, de-duped to one. + const rawBlockers = collectReviewerBlockers(duplicateRowReceipt) + expect(rawBlockers).toEqual([ + 'BLOCKING: requirement missing: Ship remaining workflow steps', + ]) + const parentOwned = collectParentOwnedRequirementBlockers( + rawBlockers, + duplicateRowReceipt, + ) + expect(parentOwned.has(rawBlockers[0]!)).toBe(false) + // The blocker survives the call-site filter, so the closed gate has a target. + expect(rawBlockers.filter((blocker) => !parentOwned.has(blocker))).toEqual([ + 'BLOCKING: requirement missing: Ship remaining workflow steps', + ]) + expect(getReviewerFinalizationVerdict(duplicateRowReceipt)).toBe('') + expect( + referenceParentOwnedBlocker( + rawBlockers[0]!, + duplicateRowReceipt.requirementCoverage, + ), + ).toBe(false) + // Row order must not change the outcome. + const reversedReceipt = { + ...duplicateRowReceipt, + requirementCoverage: [ + ...duplicateRowReceipt.requirementCoverage, + ].reverse(), + } + const reversedBlockers = collectReviewerBlockers(reversedReceipt) + expect( + collectParentOwnedRequirementBlockers( + reversedBlockers, + reversedReceipt, + ).has(reversedBlockers[0]!), + ).toBe(false) + expect(getReviewerFinalizationVerdict(reversedReceipt)).toBe('') + // Two parent-owned rows with the same key stay parent-owned (no regression + // in the credited path). + const bothParentOwnedReceipt = { + ...duplicateRowReceipt, + requirementCoverage: [ + duplicateRowReceipt.requirementCoverage[0]!, + { + requirement: 'Ship remaining workflow steps', + status: 'missing' as const, + evidence: ['parent/operator owns the push'], + }, + ], + } + const bothParentOwnedBlockers = collectReviewerBlockers( + bothParentOwnedReceipt, + ) + expect( + collectParentOwnedRequirementBlockers( + bothParentOwnedBlockers, + bothParentOwnedReceipt, + ).has(bothParentOwnedBlockers[0]!), + ).toBe(true) + expect(getReviewerFinalizationVerdict(bothParentOwnedReceipt)).toBe( + 'LOOKS_GOOD', + ) + }) + + // RF-2: evidence is consulted ONLY for explicit ownership assertions. A + // reviewer that merely QUOTES process prose as evidence ("the docs say commit + // and push") must not convert a genuine in-scope requirement gap into a + // credited LOOKS_GOOD with no surviving repair target. + test('quoted process prose in evidence keeps an in-scope requirement gap open', () => { + const quotedProseReceipt = { + schemaVersion: 1, + verdict: 'LOOKS_GOOD' as const, + findings: [] as string[], + coverage: 'covered' as const, + requirementCoverage: [ + { + requirement: 'preserve CLI compatibility', + status: 'missing' as const, + evidence: ['spec section: commit and push'], + }, + ], + } + expect( + isParentOwnedOrOutOfScopeRequirement('preserve CLI compatibility', [ + 'spec section: commit and push', + ]), + ).toBe(false) + const rawBlockers = collectReviewerBlockers(quotedProseReceipt) + expect(rawBlockers).toEqual([ + 'BLOCKING: requirement missing: preserve CLI compatibility', + ]) + const parentOwned = collectParentOwnedRequirementBlockers( + rawBlockers, + quotedProseReceipt, + ) + expect(parentOwned.has(rawBlockers[0]!)).toBe(false) + // The gap survives the call-site filter, so the closed gate has a target. + expect(rawBlockers.filter((blocker) => !parentOwned.has(blocker))).toEqual([ + 'BLOCKING: requirement missing: preserve CLI compatibility', + ]) + expect(getReviewerFinalizationVerdict(quotedProseReceipt)).toBe('') + expect( + referenceParentOwnedBlocker( + rawBlockers[0]!, + quotedProseReceipt.requirementCoverage, + ), + ).toBe(false) + // Explicit ownership assertions in evidence are still honored. + expect( + isParentOwnedOrOutOfScopeRequirement('preserve CLI compatibility', [ + 'parent/operator owns the release', + ]), + ).toBe(true) + expect( + isParentOwnedOrOutOfScopeRequirement('preserve CLI compatibility', [ + 'parent must push the release branch', + ]), + ).toBe(true) + }) + + // base2 carries its OWN collectParentOwnedRequirementBlockers copy and the + // gate call sites filter blockers with THAT copy at runtime, so the inline + // mirror must stay equivalent to the export (an over-filtering mirror would + // credit LOOKS_GOOD for a genuine in-scope gap). + test('the inline base2 collectParentOwnedRequirementBlockers mirror matches the export', () => { + const inlineHelpers = loadInlineGateReviewerHelpers() + const receipt = { + schemaVersion: 1, + verdict: 'LOOKS_GOOD' as const, + findings: [] as string[], + coverage: 'covered' as const, + requirementCoverage: [ + { + requirement: 'Commit and push', + status: 'missing' as const, + evidence: [] as string[], + }, + { + requirement: 'Ship remaining workflow steps', + status: 'missing' as const, + evidence: [ + 'parent must run full validation gate after this specialist', + ], + }, + { + requirement: 'Reject the insert when the parent must be validated', + status: 'missing' as const, + evidence: ['the parent must be validated before insert'], + }, + { + // RF-2: quoted process prose in evidence stays in-scope in both copies. + requirement: 'harden the parser', + status: 'missing' as const, + evidence: ['spec section: commit and push'], + }, + { + requirement: 'preserve CLI compatibility', + status: 'uncertain' as const, + evidence: ['flag parsing changed'], + }, + ], + } + const blockers = [ + ...collectReviewerBlockers(receipt), + 'BLOCKING: requirement missing: the API should push changes to subscribers', + 'BLOCKING: test coverage missing for changed behavior (add a case to the relevant *.test.ts)', + 'NON_BLOCKING: tighten the docstring', + ] + // The in-scope-precedence branch (`!parentOwnedRow || !structuredRows.has`) + // needs two same-status/same-text rows whose evidence disagrees, so pin the + // mirror against that receipt too — in both row orders, since order must not + // change either copy's classification. + const duplicateKeyReceipt = { + ...receipt, + requirementCoverage: [ + { + requirement: 'Ship remaining workflow steps', + status: 'missing' as const, + evidence: [ + 'parent must run full validation gate after this specialist', + ], + }, + { + requirement: 'Ship remaining workflow steps', + status: 'missing' as const, + evidence: ['unit tests still fail on the new path'], + }, + ], + } + const reversedDuplicateKeyReceipt = { + ...duplicateKeyReceipt, + requirementCoverage: [ + ...duplicateKeyReceipt.requirementCoverage, + ].reverse(), + } + for (const toolResult of [ + receipt, + { type: 'json', value: receipt }, + duplicateKeyReceipt, + { type: 'json', value: duplicateKeyReceipt }, + reversedDuplicateKeyReceipt, + { type: 'json', value: reversedDuplicateKeyReceipt }, + null, + ]) { + expect( + Array.from( + inlineHelpers.collectParentOwnedRequirementBlockers( + blockers, + toolResult, + ), + ), + ).toEqual( + Array.from(collectParentOwnedRequirementBlockers(blockers, toolResult)), + ) + } + // Same without a toolResult (text-only classification path). + expect( + Array.from(inlineHelpers.collectParentOwnedRequirementBlockers(blockers)), + ).toEqual(Array.from(collectParentOwnedRequirementBlockers(blockers))) + }) + + // Entry-selection asymmetry (fail closed): attestation is resolved from the + // schemaVersion-carrying entries while finalization/blocker collection scan + // EVERY entry, so a result whose entries disagree on the verdict must be + // rejected instead of letting an unattested LOOKS_GOOD entry take credit for + // another entry's correct fingerprint + reviewedFiles. + test('rejects a multi-entry receipt whose structured verdicts disagree', () => { + const expected = 'v3:' + 'a'.repeat(64) + const files = ['src/a.ts', 'src/b.ts'] + const mixedVerdictReceipt = { + type: 'json', + value: [ + // Finalization credit would come from this entry: attestation-shaped + // (it carries schemaVersion) but reporting neither a fingerprint nor + // reviewedFiles. + { + schemaVersion: 1, + verdict: 'LOOKS_GOOD', + findings: [], + coverage: 'covered', + }, + // Attestation would come from this entry, which carries the fingerprint. + { + schemaVersion: 1, + verdict: 'NON_BLOCKING', + findings: ['nit'], + coverage: 'covered', + snapshotFingerprint: expected, + reviewedFiles: files, + }, + ], + } + expect( + collectReviewerAttestationIssues(mixedVerdictReceipt, expected, files), + ).toEqual([ + 'BLOCKING: reviewer returned conflicting structured verdicts in one result', + ]) + // The asymmetry the blocker closes: finalization still reads LOOKS_GOOD from + // the first entry, so attestation is the only thing keeping the gate closed. + expect(getReviewerFinalizationVerdict(mixedVerdictReceipt)).toBe( + 'LOOKS_GOOD', + ) + // A duplicated receipt (nested spawn + set_output emitting the same object) + // agrees on the verdict and must still attest normally. + const duplicatedReceipt = { + type: 'json', + value: [ + { + schemaVersion: 1, + verdict: 'LOOKS_GOOD', + findings: [] as string[], + coverage: 'covered', + snapshotFingerprint: expected, + reviewedFiles: files, + }, + { + schemaVersion: 1, + verdict: 'LOOKS_GOOD', + findings: [] as string[], + coverage: 'covered', + snapshotFingerprint: expected, + reviewedFiles: files, + }, + ], + } + expect( + collectReviewerAttestationIssues(duplicatedReceipt, expected, files), + ).toEqual([]) + // The finding's literal shape: an earlier LOOKS_GOOD entry that is NOT + // attestation-shaped (no schemaVersion, so it is outside the narrowed + // conflict check) plus a fully-attesting entry. Verdict agreement makes the + // attesting entry authoritative for the same verdict the earlier entry would + // have finalized, so this stays creditable. + const unattestedFirstEntryReceipt = { + type: 'json', + value: [ + { verdict: 'LOOKS_GOOD', findings: [] as string[] }, + { + schemaVersion: 1, + verdict: 'LOOKS_GOOD', + findings: [] as string[], + coverage: 'covered', + snapshotFingerprint: expected, + reviewedFiles: files, + }, + ], + } + expect( + collectReviewerAttestationIssues( + unattestedFirstEntryReceipt, + expected, + files, + ), + ).toEqual([]) + expect(getReviewerFinalizationVerdict(unattestedFirstEntryReceipt)).toBe( + 'LOOKS_GOOD', + ) + // Same shape but both entries are attestation-shaped and the attesting one + // BLOCKS: finalization would still read LOOKS_GOOD from the first entry, so + // attestation must reject it. + const blockingAttestingEntryReceipt = { + type: 'json', + value: [ + { + schemaVersion: 1, + verdict: 'LOOKS_GOOD', + findings: [] as string[], + coverage: 'covered', + }, + { + schemaVersion: 1, + verdict: 'BLOCKING', + findings: ['real problem'], + coverage: 'covered', + snapshotFingerprint: expected, + reviewedFiles: files, + }, + ], + } + expect( + collectReviewerAttestationIssues( + blockingAttestingEntryReceipt, + expected, + files, + ), + ).toEqual([ + 'BLOCKING: reviewer returned conflicting structured verdicts in one result', + ]) + }) + + // RF-3-c7d841fd: `visitForStructuredVerdict` walks EVERY nested value, so a + // reviewer that QUOTES an example receipt object beside its own receipt used + // to trip the conflicting-verdict blocker and park the gate in `blocked` + // after one retry. The conflict check is narrowed to entries carrying + // `schemaVersion` (the attestation-shaped receipts). + test('ignores a quoted example receipt without schemaVersion in the conflict check', () => { + const expected = 'v3:' + 'a'.repeat(64) + const files = ['src/a.ts'] + const quotedExampleReceipt = { + type: 'json', + value: [ + // Illustrative receipt the reviewer quoted in its output: verdict-shaped + // but with no schemaVersion attestation of its own. + { verdict: 'LOOKS_GOOD', findings: ['example: no findings'] }, + { + schemaVersion: 1, + verdict: 'NON_BLOCKING', + findings: ['real nit'], + coverage: 'covered', + snapshotFingerprint: expected, + reviewedFiles: files, + }, + ], + } + expect( + collectReviewerAttestationIssues(quotedExampleReceipt, expected, files), + ).toEqual([]) + // The narrowing is not a silent pass: the real NON_BLOCKING finding is + // still repair fuel, so the gate opens a repair round instead of + // finalizing. + expect(collectReviewerBlockers(quotedExampleReceipt)).toContain( + 'NON_BLOCKING: real nit', + ) + // Two attestation-shaped receipts that disagree are still rejected. + expect( + collectReviewerAttestationIssues( + { + type: 'json', + value: [ + { + schemaVersion: 1, + verdict: 'LOOKS_GOOD', + findings: [] as string[], + coverage: 'covered', + }, + { + schemaVersion: 1, + verdict: 'NON_BLOCKING', + findings: ['nit'], + coverage: 'covered', + snapshotFingerprint: expected, + reviewedFiles: files, + }, + ], + }, + expected, + files, + ), + ).toEqual([ + 'BLOCKING: reviewer returned conflicting structured verdicts in one result', + ]) + }) + + // RF-1-f142a695 (PINNED INTENT): `collectReviewerAttestationIssues` narrows + // its conflict check to schemaVersion-carrying entries, but + // `collectReviewerBlockers` and `getReviewerFinalizationVerdict` deliberately + // keep scanning EVERY nested entry. The asymmetry is intentional and fails + // closed: a quoted BLOCKING/NON_BLOCKING example costs one extra repair + // round, whereas narrowing the two collectors would DROP a genuine blocker + // (and credit LOOKS_GOOD) whenever a reviewer's real findings arrive in an + // entry that omits schemaVersion. Pin both halves so the tradeoff cannot be + // flipped silently. + test('a quoted BLOCKING example without schemaVersion still elevates a fail-closed blocker', () => { + const expected = 'v3:' + 'a'.repeat(64) + const files = ['src/a.ts'] + const attesting = { + schemaVersion: 1, + verdict: 'LOOKS_GOOD' as const, + findings: [] as string[], + coverage: 'covered' as const, + snapshotFingerprint: expected, + reviewedFiles: files, + } + // Illustrative receipts the reviewer quoted from its contract: + // verdict-shaped, with no schemaVersion of their own. + const quotedBlockingExample = { + type: 'json', + value: [ + attesting, + { verdict: 'BLOCKING', findings: ['example: fix the thing'] }, + ], + } + const quotedNonBlockingExample = { + type: 'json', + value: [ + attesting, + { verdict: 'NON_BLOCKING', findings: ['example: a nit'] }, + ], + } + // Attestation is read from the schemaVersion-carrying entry and the quote + // stays outside the conflict check, so the receipt still attests. + for (const toolResult of [ + quotedBlockingExample, + quotedNonBlockingExample, + ]) { + expect( + collectReviewerAttestationIssues(toolResult, expected, files), + ).toEqual([]) + } + // The unnarrowed collectors still see the quote, so the surviving blocker — + // not the verdict — is what keeps the gate closed for one repair round. + expect(collectReviewerBlockers(quotedBlockingExample)).toEqual([ + 'BLOCKING: example: fix the thing', + ]) + expect(collectReviewerBlockers(quotedNonBlockingExample)).toEqual([ + 'NON_BLOCKING: example: a nit', + ]) + expect(getReviewerFinalizationVerdict(quotedBlockingExample)).toBe( + 'LOOKS_GOOD', + ) + // A quoted example carrying a hard-rule field blocks finalization outright + // rather than being ignored (the same fail-closed direction). + expect( + getReviewerFinalizationVerdict({ + type: 'json', + value: [ + attesting, + { verdict: 'BLOCKING', findings: ['example'], coverage: 'missing' }, + ], + }), + ).toBe('') + // base2 runs its OWN inline copies at every gate call site. + const inlineHelpers = loadInlineGateReviewerHelpers() + for (const toolResult of [ + quotedBlockingExample, + quotedNonBlockingExample, + ]) { + expect(inlineHelpers.collectReviewerBlockers(toolResult)).toEqual( + collectReviewerBlockers(toolResult), + ) + expect(inlineHelpers.getReviewerFinalizationVerdict(toolResult)).toBe( + getReviewerFinalizationVerdict(toolResult), + ) + expect( + inlineHelpers.collectReviewerAttestationIssues( + toolResult, + expected, + files, + ), + ).toEqual(collectReviewerAttestationIssues(toolResult, expected, files)) + } + }) + + // RF-2-edb346ed (PINNED INTENT): the receipt example reviewers are shown + // carries `schemaVersion: 1`, so a QUOTED example may copy it and is then + // indistinguishable from a second real receipt. Such an entry stays in the + // `verdicts` set on purpose: excluding entries that report neither + // snapshotFingerprint nor reviewedFiles would also excuse the mixed-verdict + // receipt pinned above, whose credited LOOKS_GOOD entry never attested at + // all. Disagreement therefore fails closed, and only verdict AGREEMENT is + // creditable. + test('a quoted example carrying schemaVersion stays in the conflict check', () => { + const expected = 'v3:' + 'a'.repeat(64) + const files = ['src/a.ts'] + const attesting = { + schemaVersion: 1, + verdict: 'NON_BLOCKING' as const, + findings: ['real nit'], + coverage: 'covered' as const, + snapshotFingerprint: expected, + reviewedFiles: files, + } + // Quoted example copying the documented shape (schemaVersion, but no + // fingerprint or reviewedFiles of its own) with a DIFFERENT verdict. + const quotedSchemaVersionExample = { + type: 'json', + value: [ + { + schemaVersion: 1, + verdict: 'LOOKS_GOOD', + findings: [] as string[], + coverage: 'covered', + }, + attesting, + ], + } + expect( + collectReviewerAttestationIssues( + quotedSchemaVersionExample, + expected, + files, + ), + ).toEqual([ + 'BLOCKING: reviewer returned conflicting structured verdicts in one result', + ]) + // Agreement is unaffected: the same quoted shape beside a receipt with the + // SAME verdict still attests from the real (fingerprint-reporting) entry. + const agreeingQuote = { + type: 'json', + value: [ + { + schemaVersion: 1, + verdict: 'NON_BLOCKING', + findings: ['example: a nit'], + coverage: 'covered', + }, + attesting, + ], + } + expect( + collectReviewerAttestationIssues(agreeingQuote, expected, files), + ).toEqual([]) + // Two genuinely attesting receipts that disagree stay fail-closed. + const twoAttestingReceipts = { + type: 'json', + value: [ + { ...attesting, verdict: 'LOOKS_GOOD', findings: [] as string[] }, + { ...attesting, verdict: 'BLOCKING', findings: ['real problem'] }, + ], + } + expect( + collectReviewerAttestationIssues(twoAttestingReceipts, expected, files), + ).toEqual([ + 'BLOCKING: reviewer returned conflicting structured verdicts in one result', + ]) + // base2's inline attestation copy is the gate's runtime authority. + const inlineHelpers = loadInlineGateReviewerHelpers() + for (const toolResult of [ + quotedSchemaVersionExample, + agreeingQuote, + twoAttestingReceipts, + ]) { + expect( + inlineHelpers.collectReviewerAttestationIssues( + toolResult, + expected, + files, + ), + ).toEqual(collectReviewerAttestationIssues(toolResult, expected, files)) + } + }) + + // RF-1-919b6ae7 / RF-2-cdbf4706: the documented example receipt carries + // `schemaVersion: 1`, so a reviewer may quote it AFTER its own receipt with + // the SAME verdict. That passes the narrowed conflict check, so the + // attestation and the drift must be read from the entry that actually + // reported one — otherwise a well-behaved reviewer collects two spurious + // BLOCKING attestation issues and, after base2's single + // `reviewer-protocol-attestation-failed` retry, a terminal gate failure. + test('reads the attestation from the attesting entry when a schemaVersion-carrying quote trails it', () => { + const drifted = 'v3:' + 'e'.repeat(64) + const expected = 'v3:' + 'd'.repeat(64) + const files = ['src/a.ts'] + const attesting = { + schemaVersion: 1, + verdict: 'NON_BLOCKING' as const, + findings: ['real nit'], + coverage: 'covered' as const, + snapshotFingerprint: drifted, + reviewedFiles: files, + } + // Quote of the documented shape (schemaVersion: 1, SAME verdict, neither + // snapshotFingerprint nor reviewedFiles) AFTER the real receipt. + const trailingSchemaVersionQuote = { + type: 'json', + value: [ + attesting, + { + schemaVersion: 1, + verdict: 'NON_BLOCKING', + findings: ['example: a nit'], + coverage: 'covered', + }, + ], + } + expect( + collectReviewerAttestationIssues( + trailingSchemaVersionQuote, + expected, + files, + ), + ).toEqual([]) + // The tolerated drift is still recorded from the attesting entry. + expect( + collectReviewerFingerprintDrift(trailingSchemaVersionQuote, expected), + ).toBe(drifted) + // Fail closed still: when NO schemaVersion-carrying entry reported a + // fingerprint or reviewedFiles, the last one is read and rejected. + const quoteOnlyReceipt = { + type: 'json', + value: [ + { + schemaVersion: 1, + verdict: 'NON_BLOCKING', + findings: ['example: a nit'], + coverage: 'covered', + }, + ], + } + expect( + collectReviewerAttestationIssues(quoteOnlyReceipt, expected, files), + ).toEqual([ + 'BLOCKING: reviewer did not report an attestable snapshot fingerprint', + 'BLOCKING: reviewer did not attest to every pending file: src/a.ts', + ]) + expect(collectReviewerFingerprintDrift(quoteOnlyReceipt, expected)).toBe('') + // base2's inline mirrors are the gate's runtime authority. + const inlineHelpers = loadInlineGateReviewerHelpers() + for (const toolResult of [trailingSchemaVersionQuote, quoteOnlyReceipt]) { + expect( + inlineHelpers.collectReviewerAttestationIssues( + toolResult, + expected, + files, + ), + ).toEqual(collectReviewerAttestationIssues(toolResult, expected, files)) + expect( + inlineHelpers.collectReviewerFingerprintDrift(toolResult, expected), + ).toBe(collectReviewerFingerprintDrift(toolResult, expected)) + } + }) + + // RF-1-7e02aac4 / RF-2-fb778b7d: the documented receipt example literally + // shows `reviewedFiles: ["src/a.ts"]`, so a SAME-verdict quote of it trailing + // the real receipt reports attestation payload of its own. A + // last-payload-wins selector would read the attestation (and the drift) from + // that quote and raise a spurious 'did not attest to every pending file' + // blocker, so selection prefers the entry reporting the EXPECTED fingerprint + // and otherwise the FIRST payload-carrying entry. + test('keeps the attestation on the real receipt when a trailing quote also reports payload', () => { + const drifted = 'v3:' + 'e'.repeat(64) + const quoted = 'v3:' + 'c'.repeat(64) + const expected = 'v3:' + 'd'.repeat(64) + const files = ['src/a.ts'] + const attesting = { + schemaVersion: 1, + verdict: 'NON_BLOCKING' as const, + findings: ['real nit'], + coverage: 'covered' as const, + snapshotFingerprint: drifted, + reviewedFiles: files, + } + // Quote of the documented shape, copied WITH its reviewedFiles example. + const quotedExample = { + schemaVersion: 1, + verdict: 'NON_BLOCKING' as const, + findings: ['example: a nit'], + coverage: 'covered' as const, + reviewedFiles: ['src/example.ts'], + } + const trailingQuoteWithFiles = { + type: 'json', + value: [attesting, quotedExample], + } + // The same quote, also copying a v3 fingerprint of its own. + const trailingQuoteWithFingerprint = { + type: 'json', + value: [attesting, { ...quotedExample, snapshotFingerprint: quoted }], + } + // A LEADING quote cannot steal it either: the entry reporting the expected + // fingerprint wins outright. + const leadingQuoteWithFingerprint = { + type: 'json', + value: [ + { ...quotedExample, snapshotFingerprint: quoted }, + { ...attesting, snapshotFingerprint: expected }, + ], + } + const toolResults = [ + trailingQuoteWithFiles, + trailingQuoteWithFingerprint, + leadingQuoteWithFingerprint, + ] + for (const toolResult of toolResults) { + expect( + collectReviewerAttestationIssues(toolResult, expected, files), + ).toEqual([]) + } + // Drift telemetry follows the real receipt, not the quote. + expect( + collectReviewerFingerprintDrift(trailingQuoteWithFiles, expected), + ).toBe(drifted) + expect( + collectReviewerFingerprintDrift(trailingQuoteWithFingerprint, expected), + ).toBe(drifted) + // Nothing to record when the preferred entry matched exactly, even though + // the quote beside it reported a drifted fingerprint. + expect( + collectReviewerFingerprintDrift(leadingQuoteWithFingerprint, expected), + ).toBe('') + // base2's inline mirrors are the gate's runtime authority. + const inlineHelpers = loadInlineGateReviewerHelpers() + for (const toolResult of toolResults) { + expect( + inlineHelpers.collectReviewerAttestationIssues( + toolResult, + expected, + files, + ), + ).toEqual(collectReviewerAttestationIssues(toolResult, expected, files)) + expect( + inlineHelpers.collectReviewerFingerprintDrift(toolResult, expected), + ).toBe(collectReviewerFingerprintDrift(toolResult, expected)) + } + }) + + // RF-1-e38fb9e1 / RF-2-410a3e1a: the mirror image of the trailing-quote + // hazard. A LEADING quote of the documented example (schemaVersion: 1, + // `reviewedFiles: ["src/example.ts"]`, no fingerprint of its own) beside a + // real receipt whose fingerprint DRIFTED matches no entry, so a + // first-payload-wins selector would read the attestation from the quote and + // emit spurious 'fingerprint did not match' + 'did not attest to every + // pending file' blockers while the drift recorder reported '' — a terminal + // gate failure after base2's single reviewer-protocol retry, with the real + // drift unrecorded. Selection is order-independent instead: reviewedFiles are + // unioned across the schemaVersion-carrying entries and the fingerprint comes + // from the entry matching the expected one, else the first attestable one. + test('keeps the attestation on the real receipt when a leading quote precedes a drifted fingerprint', () => { + const drifted = 'v3:' + 'e'.repeat(64) + const expected = 'v3:' + 'd'.repeat(64) + const files = ['src/a.ts'] + // Quote of the documented shape, copied WITH its reviewedFiles example and + // no fingerprint of its own, LEADING the real receipt. + const quotedExample = { + schemaVersion: 1, + verdict: 'NON_BLOCKING' as const, + findings: ['example: a nit'], + coverage: 'covered' as const, + reviewedFiles: ['src/example.ts'], + } + const attesting = { + schemaVersion: 1, + verdict: 'NON_BLOCKING' as const, + findings: ['real nit'], + coverage: 'covered' as const, + snapshotFingerprint: drifted, + reviewedFiles: files, + } + const leadingQuoteWithDriftedReal = { + type: 'json', + value: [quotedExample, attesting], + } + expect( + collectReviewerAttestationIssues( + leadingQuoteWithDriftedReal, + expected, + files, + ), + ).toEqual([]) + // Drift telemetry reports the REAL receipt's fingerprint rather than '' ... + expect( + collectReviewerFingerprintDrift(leadingQuoteWithDriftedReal, expected), + ).toBe(drifted) + // ... and the union is not a blanket pass: when NEITHER entry reported an + // attestable fingerprint the receipt still fails closed. + const leadingQuoteWithoutFingerprints = { + type: 'json', + value: [quotedExample, { ...attesting, snapshotFingerprint: undefined }], + } + expect( + collectReviewerAttestationIssues( + leadingQuoteWithoutFingerprints, + expected, + files, + ), + ).toEqual([ + 'BLOCKING: reviewer did not report an attestable snapshot fingerprint', + ]) + expect( + collectReviewerFingerprintDrift( + leadingQuoteWithoutFingerprints, + expected, + ), + ).toBe('') + // A pending file no entry attested still blocks, drifted fingerprint and + // all, so the union cannot manufacture coverage. + expect( + collectReviewerAttestationIssues(leadingQuoteWithDriftedReal, expected, [ + ...files, + 'src/b.ts', + ]), + ).toEqual([ + 'BLOCKING: reviewer snapshot fingerprint did not match the reviewed working tree', + 'BLOCKING: reviewer did not attest to every pending file: src/b.ts', + ]) + // base2's inline mirrors are the gate's runtime authority. + const inlineHelpers = loadInlineGateReviewerHelpers() + for (const toolResult of [ + leadingQuoteWithDriftedReal, + leadingQuoteWithoutFingerprints, + ]) { + for (const pendingFiles of [files, [...files, 'src/b.ts']]) { + expect( + inlineHelpers.collectReviewerAttestationIssues( + toolResult, + expected, + pendingFiles, + ), + ).toEqual( + collectReviewerAttestationIssues(toolResult, expected, pendingFiles), + ) + } + expect( + inlineHelpers.collectReviewerFingerprintDrift(toolResult, expected), + ).toBe(collectReviewerFingerprintDrift(toolResult, expected)) + } + }) + + // RF-1-add6f07e (PINNED INTENT): `resolveReviewerAttestation` resolves PER + // FIELD, so the credited attestation is a COMPOSITE — the fingerprint may come + // from one shaped entry while the coverage union comes from all of them. A + // receipt whose real entry reported no fingerprint is therefore creditable + // from a quoted example entry's attestable v3 value. That looseness is + // accepted deliberately: requiring the fingerprint-contributing entry to also + // contribute a reviewed file would not close it (the documented example + // carries `reviewedFiles`) and would reject the deletions-only receipt, which + // legitimately attests with an empty `reviewedFiles`. What the gate relies on + // is pinned below: the union still cannot manufacture coverage for a pending + // file no entry reported, and a receipt where NO entry reported an attestable + // fingerprint still fails closed. + test('credits a spliced attestation: coverage from one entry, fingerprint from another', () => { + const quotedFingerprint = 'v3:' + 'e'.repeat(64) + const expected = 'v3:' + 'd'.repeat(64) + const pendingFiles = ['src/a.ts'] + const splicedReceipt = { + type: 'json', + value: [ + // The real receipt: full coverage, but no fingerprint of its own. + { + schemaVersion: 1, + verdict: 'NON_BLOCKING' as const, + findings: ['real nit'], + coverage: 'covered' as const, + reviewedFiles: pendingFiles, + snapshotFingerprint: undefined, + }, + // A quoted example receipt carrying the documented attestable shape. + { + schemaVersion: 1, + verdict: 'NON_BLOCKING' as const, + findings: ['example: a nit'], + coverage: 'covered' as const, + reviewedFiles: ['src/example.ts'], + snapshotFingerprint: quotedFingerprint, + }, + ], + } + expect( + collectReviewerAttestationIssues(splicedReceipt, expected, pendingFiles), + ).toEqual([]) + // The credited fingerprint is the quoted one, so the drift is recorded + // rather than accepted silently. + expect(collectReviewerFingerprintDrift(splicedReceipt, expected)).toBe( + quotedFingerprint, + ) + // The union cannot manufacture coverage: a pending file no entry reported + // still blocks (and the non-matching fingerprint is then a mismatch too). + expect( + collectReviewerAttestationIssues(splicedReceipt, expected, [ + ...pendingFiles, + 'src/b.ts', + ]), + ).toEqual([ + 'BLOCKING: reviewer snapshot fingerprint did not match the reviewed working tree', + 'BLOCKING: reviewer did not attest to every pending file: src/b.ts', + ]) + // Strip the quoted entry's fingerprint and the same receipt fails closed: + // the splice never invents an attestable value. + const noFingerprintReceipt = { + type: 'json', + value: [ + splicedReceipt.value[0]!, + { ...splicedReceipt.value[1]!, snapshotFingerprint: undefined }, + ], + } + expect( + collectReviewerAttestationIssues( + noFingerprintReceipt, + expected, + pendingFiles, + ), + ).toEqual([ + 'BLOCKING: reviewer did not report an attestable snapshot fingerprint', + ]) + expect( + collectReviewerFingerprintDrift(noFingerprintReceipt, expected), + ).toBe('') + // base2's inline mirrors are the gate's runtime authority. + const inlineHelpers = loadInlineGateReviewerHelpers() + for (const toolResult of [splicedReceipt, noFingerprintReceipt]) { + for (const files of [pendingFiles, [...pendingFiles, 'src/b.ts']]) { + expect( + inlineHelpers.collectReviewerAttestationIssues( + toolResult, + expected, + files, + ), + ).toEqual(collectReviewerAttestationIssues(toolResult, expected, files)) + } + expect( + inlineHelpers.collectReviewerFingerprintDrift(toolResult, expected), + ).toBe(collectReviewerFingerprintDrift(toolResult, expected)) + } + }) + + // RF-2-1ce51577: schemaVersion conformance is checked on EVERY shaped entry, + // not just the resolved one. A sibling entry claiming schemaVersion 2 must + // reject the whole receipt even when the attesting entry reports 1 (and + // regardless of which side the non-conforming entry arrives on). + test('rejects a receipt whose sibling entry claims a non-1 schemaVersion', () => { + const expected = 'v3:' + 'd'.repeat(64) + const pendingFiles = ['src/a.ts'] + const attesting = { + schemaVersion: 1, + verdict: 'NON_BLOCKING' as const, + findings: ['real nit'], + coverage: 'covered' as const, + snapshotFingerprint: expected, + reviewedFiles: pendingFiles, + } + // Same verdict, so the conflict check passes and schemaVersion is what + // decides. Non-conforming siblings are NOT ignored. + const futureSchemaSibling = { + schemaVersion: 2, + verdict: 'NON_BLOCKING' as const, + findings: ['from a newer schema'], + coverage: 'covered' as const, + reviewedFiles: ['src/example.ts'], + } + const trailingSibling = { + type: 'json', + value: [attesting, futureSchemaSibling], + } + const leadingSibling = { + type: 'json', + value: [futureSchemaSibling, attesting], + } + const inlineHelpers = loadInlineGateReviewerHelpers() + for (const toolResult of [trailingSibling, leadingSibling]) { + expect( + collectReviewerAttestationIssues(toolResult, expected, pendingFiles), + ).toEqual([ + 'BLOCKING: reviewer returned an invalid attestation schemaVersion', + ]) + expect( + inlineHelpers.collectReviewerAttestationIssues( + toolResult, + expected, + pendingFiles, + ), + ).toEqual( + collectReviewerAttestationIssues(toolResult, expected, pendingFiles), + ) + } + // A quoted example WITHOUT schemaVersion is still outside the conformance + // check, so it does not reject the receipt (unchanged behavior). + const unshapedQuote = { + type: 'json', + value: [ + attesting, + { verdict: 'NON_BLOCKING' as const, findings: ['example: a nit'] }, + ], + } + expect( + collectReviewerAttestationIssues(unshapedQuote, expected, pendingFiles), + ).toEqual([]) + }) + test('structured v1 reviews require file coverage; fingerprint mismatch only blocks when coverage is incomplete', () => { + // RF-3: 'stale' is not an attestable v3 fingerprint, so the operator + // message must name the MISSING fingerprint rather than mislabelling it as + // a mismatch (still fail-closed either way). expect( collectReviewerAttestationIssues( { @@ -463,7 +2189,7 @@ describe('gate-reviewer helpers', () => { ['src/a.ts', 'src/b.ts'], ), ).toEqual([ - 'BLOCKING: reviewer snapshot fingerprint did not match the reviewed working tree', + 'BLOCKING: reviewer did not report an attestable snapshot fingerprint', 'BLOCKING: reviewer did not attest to every pending file: src/b.ts', ]) expect( @@ -547,6 +2273,178 @@ describe('gate-reviewer helpers', () => { ).toEqual([]) }) + // The tolerance above is deliberate but must not be silent: the drift is + // reported separately so callers can record it (base2 emits gate telemetry). + test('collectReviewerFingerprintDrift reports the tolerated non-matching v3 fingerprint', () => { + const drifted = 'v3:' + 'e'.repeat(64) + const expected = 'v3:' + 'd'.repeat(64) + const coverageComplete = { + schemaVersion: 1, + verdict: 'LOOKS_GOOD', + snapshotFingerprint: drifted, + reviewedFiles: ['src/a.ts'], + } + // Attestation still tolerates the drift (no blocker) ... + expect( + collectReviewerAttestationIssues(coverageComplete, expected, [ + 'src/a.ts', + ]), + ).toEqual([]) + // ... and the drift is surfaced for recording. + expect(collectReviewerFingerprintDrift(coverageComplete, expected)).toBe( + drifted, + ) + // Nothing to record for an exact match or an already-blocking fingerprint. + expect( + collectReviewerFingerprintDrift( + { ...coverageComplete, snapshotFingerprint: expected }, + expected, + ), + ).toBe('') + expect( + collectReviewerFingerprintDrift( + { ...coverageComplete, snapshotFingerprint: 'unreadable:no-crypto' }, + expected, + ), + ).toBe('') + expect( + collectReviewerFingerprintDrift( + { + schemaVersion: 1, + verdict: 'LOOKS_GOOD', + reviewedFiles: ['src/a.ts'], + }, + expected, + ), + ).toBe('') + expect(collectReviewerFingerprintDrift(null, expected)).toBe('') + // base2 consults its own inline copy at runtime; keep it equivalent. + const inlineHelpers = loadInlineGateReviewerHelpers() + for (const input of [ + coverageComplete, + { ...coverageComplete, snapshotFingerprint: expected }, + { type: 'json', value: [coverageComplete] }, + 'BLOCKING: plain prose', + null, + ]) { + expect( + inlineHelpers.collectReviewerFingerprintDrift(input, expected), + ).toBe(collectReviewerFingerprintDrift(input, expected)) + } + }) + + // RF-1-a0c548bf / RF-2-19213abe: `visitForStructuredVerdict` walks every + // nested value, so a reviewer that QUOTES an example receipt AFTER its own one + // must still be attested — and have its tolerated drift recorded — from the + // attestation-shaped entry rather than from the trailing quote. + test('reads the attestation and the drift from the schemaVersion-carrying entry when a quoted example trails it', () => { + const drifted = 'v3:' + 'e'.repeat(64) + const expected = 'v3:' + 'd'.repeat(64) + const attestingReceiptWithDrift = { + schemaVersion: 1, + verdict: 'LOOKS_GOOD' as const, + findings: [] as string[], + coverage: 'covered' as const, + snapshotFingerprint: drifted, + reviewedFiles: ['src/a.ts'], + } + const quotedExampleWithoutFingerprint = { + verdict: 'LOOKS_GOOD' as const, + findings: [] as string[], + reviewedFiles: ['src/example.ts'], + } + const toolResult = [ + attestingReceiptWithDrift, + quotedExampleWithoutFingerprint, + ] + // The trailing quote no longer parks the gate in `blocked` on a bogus + // invalid-schemaVersion blocker ... + expect( + collectReviewerAttestationIssues(toolResult, expected, ['src/a.ts']), + ).toEqual([]) + // ... and the tolerated drift is still recorded from the attesting receipt. + expect(collectReviewerFingerprintDrift(toolResult, expected)).toBe(drifted) + // Fail-closed fallback: a receipt whose only entry carries no schemaVersion + // is still rejected. + expect( + collectReviewerAttestationIssues( + [quotedExampleWithoutFingerprint], + expected, + ['src/example.ts'], + ), + ).toEqual([ + 'BLOCKING: reviewer returned an invalid attestation schemaVersion', + ]) + // base2 runs its own inline copies of both helpers at the gate call sites. + const inlineHelpers = loadInlineGateReviewerHelpers() + expect( + inlineHelpers.collectReviewerAttestationIssues(toolResult, expected, [ + 'src/a.ts', + ]), + ).toEqual([]) + expect( + inlineHelpers.collectReviewerFingerprintDrift(toolResult, expected), + ).toBe(drifted) + expect( + inlineHelpers.collectReviewerAttestationIssues( + [quotedExampleWithoutFingerprint], + expected, + ['src/example.ts'], + ), + ).toEqual([ + 'BLOCKING: reviewer returned an invalid attestation schemaVersion', + ]) + }) + + // RF-3-3ed5cbee: the gate's fail-closed attestation authority runs from + // base2's inline copy, so it needs the same broad parity coverage as the + // hard-blocker and finding-record mirrors. + test('the inline base2 collectReviewerAttestationIssues mirror matches the export', () => { + const inlineHelpers = loadInlineGateReviewerHelpers() + const expected = 'v3:' + 'd'.repeat(64) + const attesting = { + schemaVersion: 1, + verdict: 'LOOKS_GOOD' as const, + findings: [] as string[], + coverage: 'covered' as const, + snapshotFingerprint: expected, + reviewedFiles: ['src/a.ts', './src/b.ts'], + } + const inputs: unknown[] = [ + attesting, + { type: 'json', value: attesting }, + { ...attesting, snapshotFingerprint: 'v3:' + 'e'.repeat(64) }, + { ...attesting, snapshotFingerprint: 'unreadable:no-crypto' }, + { ...attesting, snapshotFingerprint: undefined }, + { ...attesting, schemaVersion: 2 }, + { ...attesting, reviewedFiles: ['src/a.ts'] }, + { ...attesting, reviewedFiles: ['../outside/a.ts'] }, + // Quoted example receipt trailing the real one (RF-1) and a genuine + // multi-receipt verdict conflict. + [attesting, { verdict: 'LOOKS_GOOD', findings: [] as string[] }], + [attesting, { schemaVersion: 1, verdict: 'BLOCKING', findings: ['x'] }], + 'BLOCKING: plain prose', + null, + ] + const pendingFileSets = [['src/a.ts', 'src/b.ts'], ['src/a.ts'], []] + for (const input of inputs) { + for (const pendingFiles of pendingFileSets) { + expect( + inlineHelpers.collectReviewerAttestationIssues( + input, + expected, + pendingFiles, + ['src/b.ts'], + ), + ).toEqual( + collectReviewerAttestationIssues(input, expected, pendingFiles, [ + 'src/b.ts', + ]), + ) + } + } + }) + test('fails closed when a coverage-complete review reports no fingerprint', () => { expect( collectReviewerAttestationIssues( @@ -586,7 +2484,10 @@ describe('gate-reviewer helpers', () => { { schemaVersion: 1, verdict: 'LOOKS_GOOD', - snapshotFingerprint: 'v3:' + 'g'.repeat(64), + // Attestable (well-formed lowercase hex) but NOT the expected value, + // so the mismatch branch is what fires rather than the + // non-attestable branch above. + snapshotFingerprint: 'v3:' + 'e'.repeat(64), reviewedFiles: ['src/a.ts'], }, 'v3:' + 'f'.repeat(64), @@ -619,7 +2520,11 @@ describe('gate-reviewer helpers', () => { test('empty reviewable subset yields no attestation issues even with missing reviewer output', () => { expect(collectReviewerAttestationIssues(null, 'current', [])).toEqual([]) expect( - collectReviewerAttestationIssues({ type: 'json', value: [] }, 'current', []), + collectReviewerAttestationIssues( + { type: 'json', value: [] }, + 'current', + [], + ), ).toEqual([]) }) @@ -857,14 +2762,48 @@ describe('gate-reviewer helpers', () => { ) expect(classifyReviewerCrash('HTTP 429')).toBe('transient') expect( - classifyReviewerCrash( - 'snapshot attestation failed for bare fingerprint', - ), + classifyReviewerCrash('snapshot attestation failed for bare fingerprint'), ).toBe('protocol') expect(classifyReviewerCrash('non-attestable fingerprint')).toBe('protocol') expect(classifyReviewerCrash('ordinary spawn failed')).toBe('fatal') }) + // RF-2-c710dee9: the hasBareHex branch. A bare 64-hex run means the reviewer + // echoed a raw digest instead of the canonical `v3:<64hex>` token, which is a + // protocol failure rather than a content crash. + test('classifyReviewerCrash classifies a bare 64-hex run as protocol', () => { + expect(classifyReviewerCrash('a'.repeat(64))).toBe('protocol') + expect( + classifyReviewerCrash('reviewer emitted ' + 'c'.repeat(64) + ' verbatim'), + ).toBe('protocol') + }) + + // PINNED INTENT (RF-2-c710dee9): a message carrying BOTH a well-formed + // `v3:<64hex>` token AND a separate bare 64-hex run classifies as 'protocol'. + // The bare run is the protocol failure; an unrelated valid token in the same + // message must not suppress it. + test('classifyReviewerCrash classifies a mixed v3 + bare-hex message as protocol', () => { + expect( + classifyReviewerCrash( + 'reviewer reported v3:' + + 'a'.repeat(64) + + ' but the receipt carried ' + + 'b'.repeat(64), + ), + ).toBe('protocol') + }) + + // The `(?:^|[^:])` prefix excludes the v3 token's OWN hex: a message whose + // only 64-hex run is the `v3:` token (and which carries no other protocol + // cue) must fall through to 'fatal'. + test('classifyReviewerCrash does not treat a v3 token as a bare hex run', () => { + expect( + classifyReviewerCrash( + 'reviewer process exited while writing v3:' + 'a'.repeat(64), + ), + ).toBe('fatal') + }) + test('detectReviewerCrash respects depth cap to avoid pathological recursion', () => { // Build a chain deeper than the cap (8). At depth >8, nested crash is ignored. let deep: any = { errorMessage: 'unreachable' } @@ -872,6 +2811,58 @@ describe('gate-reviewer helpers', () => { expect(detectReviewerCrash(deep)).toBeNull() }) + // RF-1-f9a386af: the structured walker carries the same depth cap (8) as + // findReviewerCrash, so an envelope nested past the cap fails closed (empty + // result / no finalization credit) instead of blowing the stack. + test('visitForStructuredVerdict respects the depth cap for over-nested structured verdicts', () => { + let deepStructured: any = { + schemaVersion: 1, + verdict: 'LOOKS_GOOD', + findings: [] as string[], + coverage: 'covered', + } + for (let i = 0; i < 12; i += 1) deepStructured = { nested: deepStructured } + expect(getReviewerFinalizationVerdict(deepStructured)).toBe('') + expect(collectReviewerBlockers(deepStructured)).toEqual([]) + expect(collectReviewerHardBlockers(deepStructured)).toEqual([]) + expect(collectReviewerFindingRecords(deepStructured)).toEqual([]) + }) + + // Same cap on the text walker: a blocker string nested past the cap is + // dropped rather than overflowing the stack. + test('collectStrings respects the depth cap for over-nested reviewer text', () => { + let deepText: any = 'BLOCKING: unreachable text verdict' + for (let i = 0; i < 30; i += 1) deepText = { nested: [deepText] } + expect(collectReviewerBlockers(deepText)).toEqual([]) + }) + + // A self-referential envelope (a child pointing back at its parent) must + // terminate in BOTH walkers instead of throwing RangeError. + test('both reviewer walkers terminate on a self-referential envelope', () => { + const cyclicText: any = { note: 'BLOCKING: cyclic path' } + cyclicText.self = cyclicText + // Structured walk finds nothing, so the text walk decides; repeated visits + // are collapsed by the exact-string de-dupe. + expect(collectReviewerBlockers(cyclicText)).toEqual([ + 'BLOCKING: cyclic path', + ]) + expect(getReviewerFinalizationVerdict(cyclicText)).toBe('') + + const cyclicStructured: any = { + wrapper: { + schemaVersion: 1, + verdict: 'BLOCKING', + findings: ['cyclic finding'], + coverage: 'covered', + }, + } + cyclicStructured.wrapper.parent = cyclicStructured + expect(collectReviewerBlockers(cyclicStructured)).toEqual([ + 'BLOCKING: cyclic finding', + ]) + expect(getReviewerFinalizationVerdict(cyclicStructured)).toBe('') + }) + test('exported helpers match inline base2 mirror behavior', () => { const inlineHelpers = loadInlineGateReviewerHelpers() @@ -967,6 +2958,21 @@ describe('gate-reviewer helpers', () => { }, ], }, + // RF-1-dd326e16: an empty-findings NON_BLOCKING receipt whose only gap is + // parent-owned must keep the synthetic placeholder in BOTH copies. + { + type: 'json', + value: [ + { + verdict: 'NON_BLOCKING', + findings: [], + coverage: 'covered', + requirementCoverage: [ + { requirement: 'Commit and push', status: 'missing' }, + ], + }, + ], + }, null, ] @@ -1125,9 +3131,9 @@ describe('gate-reviewer helpers', () => { 'BLOCKING: test coverage missing for changed behavior (add a case to the relevant *.test.ts)', ), ).toBe(true) - expect( - isTestCoverageReviewerFinding('test coverage is insufficient'), - ).toBe(true) + expect(isTestCoverageReviewerFinding('test coverage is insufficient')).toBe( + true, + ) expect(isTestCoverageReviewerFinding('TEST COVERAGE missing')).toBe(true) expect( isTestCoverageReviewerFinding( @@ -1167,13 +3173,335 @@ describe('gate-reviewer helpers', () => { }) test('isTestCoverageReviewerFinding rejects non-string inputs', () => { - expect( - isTestCoverageReviewerFinding(undefined as unknown as string), - ).toBe(false) - expect(isTestCoverageReviewerFinding(null as unknown as string)).toBe( + expect(isTestCoverageReviewerFinding(undefined as unknown as string)).toBe( false, ) + expect(isTestCoverageReviewerFinding(null as unknown as string)).toBe(false) expect(isTestCoverageReviewerFinding(42 as unknown as string)).toBe(false) expect(isTestCoverageReviewerFinding({} as unknown as string)).toBe(false) }) }) + +// T0.1: gate-derived hard rules are the non-condonable subset of the blocker +// strings. base2's condone filter exempts them via exact Set.has membership +// against collectReviewerBlockers output, so byte identity is load-bearing. +describe('collectReviewerHardBlockers', () => { + const HARD_RULE_INPUTS: unknown[] = [ + { verdict: 'NON_BLOCKING', findings: ['nit'], coverage: 'missing' }, + { + verdict: 'LOOKS_GOOD', + findings: [], + coverage: 'covered', + dimensions: { correctness: 'pass', security: 'block', tests: 'BLOCK' }, + }, + { + verdict: 'NON_BLOCKING', + findings: ['unrelated prose'], + coverage: 'covered', + requirementCoverage: [ + { requirement: 'add the export', status: 'missing' }, + { requirement: 'preserve CLI compatibility', status: 'uncertain' }, + { requirement: 'add tests', status: 'satisfied' }, + ], + }, + { + verdict: 'BLOCKING', + findings: ['Fix A'], + coverage: 'missing', + dimensions: { security: 'block' }, + requirementCoverage: [ + { requirement: 'wire selfMutatedPaths', status: 'missing' }, + ], + }, + { verdict: 'LOOKS_GOOD', findings: [], coverage: 'covered' }, + { verdict: 'NON_BLOCKING', findings: ['only prose'], coverage: 'covered' }, + { verdict: 'NON_BLOCKING', findings: [], coverage: 'covered' }, + ] + + test('returns the coverage-missing hard rule for coverage: missing', () => { + expect( + collectReviewerHardBlockers({ + verdict: 'NON_BLOCKING', + findings: ['nit'], + coverage: 'missing', + }), + ).toEqual([ + 'BLOCKING: test coverage missing for changed behavior (add a case to the relevant *.test.ts)', + ]) + }) + + test('returns one hard rule per blocked review dimension', () => { + expect( + collectReviewerHardBlockers({ + verdict: 'LOOKS_GOOD', + findings: [], + coverage: 'covered', + dimensions: { + correctness: 'pass', + security: 'block', + tests: 'BLOCK', + }, + }), + ).toEqual([ + 'BLOCKING: security review dimension failed', + 'BLOCKING: tests review dimension failed', + ]) + }) + + test('returns one hard rule per missing/uncertain requirement', () => { + expect( + collectReviewerHardBlockers({ + verdict: 'NON_BLOCKING', + findings: ['unrelated prose'], + coverage: 'covered', + requirementCoverage: [ + { requirement: 'add the export', status: 'missing' }, + { requirement: 'preserve CLI compatibility', status: 'uncertain' }, + { requirement: 'add tests', status: 'satisfied' }, + ], + }), + ).toEqual([ + 'BLOCKING: requirement missing: add the export', + 'BLOCKING: requirement uncertain: preserve CLI compatibility', + ]) + }) + + test('returns nothing for a clean LOOKS_GOOD result or prose-only findings', () => { + expect( + collectReviewerHardBlockers({ + verdict: 'LOOKS_GOOD', + findings: [], + coverage: 'covered', + dimensions: { correctness: 'pass' }, + requirementCoverage: [ + { requirement: 'add tests', status: 'satisfied' }, + ], + }), + ).toEqual([]) + // NON_BLOCKING prose findings and the synthetic empty-findings placeholder + // are condonable reviewer prose, never hard rules. + expect( + collectReviewerHardBlockers({ + verdict: 'NON_BLOCKING', + findings: ['minor naming nit', 'style tweak'], + coverage: 'covered', + }), + ).toEqual([]) + expect( + collectReviewerHardBlockers({ + verdict: 'NON_BLOCKING', + findings: [], + coverage: 'covered', + }), + ).toEqual([]) + // BLOCKING prose findings are also excluded. + expect( + collectReviewerHardBlockers({ + verdict: 'BLOCKING', + findings: ['Fix A'], + coverage: 'covered', + }), + ).toEqual([]) + expect(collectReviewerHardBlockers(null)).toEqual([]) + expect(collectReviewerHardBlockers('BLOCKING: plain text')).toEqual([]) + }) + + // RF-1: the reviewer contract's prevailing style is ": ", so a + // blocking dimension arrives as `block: `. Both collectors must emit + // the byte-identical hard rule for it, and finalization must refuse credit. + test('treats a "block: " dimension as failing in both collectors', () => { + const clauseStyleReceipt = { + schemaVersion: 1, + verdict: 'LOOKS_GOOD' as const, + findings: [] as string[], + coverage: 'covered' as const, + dimensions: { security: 'block: fails closed?' }, + } + expect(collectReviewerHardBlockers(clauseStyleReceipt)).toEqual([ + 'BLOCKING: security review dimension failed', + ]) + expect(collectReviewerBlockers(clauseStyleReceipt)).toEqual([ + 'BLOCKING: security review dimension failed', + ]) + // A failing dimension must not ride along with a LOOKS_GOOD receipt. + expect(getReviewerFinalizationVerdict(clauseStyleReceipt)).toBe('') + }) + + test('keeps "blocked" and "pass: " dimension values non-failing', () => { + const passingReceipt = { + schemaVersion: 1, + verdict: 'LOOKS_GOOD' as const, + findings: [] as string[], + coverage: 'covered' as const, + dimensions: { + security: 'pass: no auth gaps', + // `blocked` is a different word: the prefix test is word-bounded. + tests: 'blocked', + docs: 'minor: tighten wording', + }, + } + expect(collectReviewerHardBlockers(passingReceipt)).toEqual([]) + expect(collectReviewerBlockers(passingReceipt)).toEqual([]) + expect(getReviewerFinalizationVerdict(passingReceipt)).toBe('LOOKS_GOOD') + // Padded / upper-case `BLOCK` still fails: trim + lowercase first. + const paddedReceipt = { + ...passingReceipt, + dimensions: { security: ' BLOCK: unauthenticated endpoint ' }, + } + expect(collectReviewerHardBlockers(paddedReceipt)).toEqual([ + 'BLOCKING: security review dimension failed', + ]) + expect(collectReviewerBlockers(paddedReceipt)).toEqual([ + 'BLOCKING: security review dimension failed', + ]) + expect(getReviewerFinalizationVerdict(paddedReceipt)).toBe('') + // RF-1-ac6c3ba2: `\b` never matched between `block` and `i`/`e`, so these + // plausible reviewer phrasings were silently treated as passing dimensions + // and could finalize LOOKS_GOOD. The plural verb form `blocks` is covered + // for the same reason: it is a verdict, unlike the state word `blocked`. + for (const status of [ + 'blocking: fails open on parse error', + 'blocker: fails open on parse error', + 'blockers: two unguarded paths', + 'blocks: fails open on parse error', + 'blocks finalization: missing auth', + ' BLOCKING: unauthenticated endpoint ', + 'blocking', + 'blocks', + ]) { + const failingReceipt = { + ...passingReceipt, + dimensions: { security: status }, + } + expect(collectReviewerHardBlockers(failingReceipt)).toEqual([ + 'BLOCKING: security review dimension failed', + ]) + expect(collectReviewerBlockers(failingReceipt)).toEqual([ + 'BLOCKING: security review dimension failed', + ]) + expect(getReviewerFinalizationVerdict(failingReceipt)).toBe('') + } + // `blocked` stays a different word even when it carries a clause. + const blockedClauseReceipt = { + ...passingReceipt, + dimensions: { tests: 'blocked: waiting on an upstream fixture' }, + } + expect(collectReviewerHardBlockers(blockedClauseReceipt)).toEqual([]) + expect(collectReviewerBlockers(blockedClauseReceipt)).toEqual([]) + expect(getReviewerFinalizationVerdict(blockedClauseReceipt)).toBe( + 'LOOKS_GOOD', + ) + }) + + // Byte-identity contract: base2 exempts hard rules from condoning with + // `hardBlockers.has(blocker)` over strings produced by collectReviewerBlockers. + // If either template literal drifts by a single character the exemption + // silently stops working, so this parity assertion is required. + test('every hard-rule string is byte-identical to a collectReviewerBlockers string', () => { + for (const input of HARD_RULE_INPUTS) { + const blockers = collectReviewerBlockers(input) + for (const hardBlocker of collectReviewerHardBlockers(input)) { + expect(blockers).toContain(hardBlocker) + } + } + // Nested spawn/set_output wrappers walk the same way and stay de-duped. + const nested = { + type: 'json', + value: [HARD_RULE_INPUTS[3], HARD_RULE_INPUTS[3]], + } + const nestedHardBlockers = collectReviewerHardBlockers(nested) + expect(nestedHardBlockers).toEqual([ + 'BLOCKING: test coverage missing for changed behavior (add a case to the relevant *.test.ts)', + 'BLOCKING: security review dimension failed', + 'BLOCKING: requirement missing: wire selfMutatedPaths', + ]) + for (const hardBlocker of nestedHardBlockers) { + expect(collectReviewerBlockers(nested)).toContain(hardBlocker) + } + }) + + // base2 carries its OWN collectReviewerHardBlockers copy and the condone + // filter consults THAT copy at runtime, so the inline mirror must stay + // equivalent to the export over every hard-rule shape. + test('the inline base2 collectReviewerHardBlockers mirror matches the export', () => { + const inlineHelpers = loadInlineGateReviewerHelpers() + const inputs: unknown[] = [ + ...HARD_RULE_INPUTS, + // Nested spawn/set_output wrappers and non-structured results. + { type: 'json', value: [HARD_RULE_INPUTS[3], HARD_RULE_INPUTS[3]] }, + { nested: { type: 'json', value: HARD_RULE_INPUTS[2] } }, + 'BLOCKING: plain text', + null, + ] + for (const input of inputs) { + expect(inlineHelpers.collectReviewerHardBlockers(input)).toEqual( + collectReviewerHardBlockers(input), + ) + } + }) + + // RF-3-98832529: the gate correlates findings through base2's OWN + // collectReviewerFindingRecords copy, so the first-record-per-id de-dupe must + // hold there too (a mirror that kept the LAST record would correlate the + // wrong text back to the reviewer's finding). + test('the inline base2 collectReviewerFindingRecords mirror matches the export', () => { + const inlineHelpers = loadInlineGateReviewerHelpers() + const firstReceipt = { + schemaVersion: 1, + verdict: 'BLOCKING' as const, + coverage: 'covered' as const, + findings: [ + { + id: 'code-reviewer:correctness:dup', + severity: 'high', + dimension: 'correctness', + summary: 'first occurrence text', + evidence: ['first evidence'], + correction: 'fix it once', + }, + ], + } + // A set_output-style duplicate of the same finding id carrying different text. + const duplicateReceipt = { + ...firstReceipt, + findings: [ + { + ...firstReceipt.findings[0]!, + summary: 'second occurrence text', + evidence: ['second evidence'], + }, + ], + } + const duplicateIdReceipt = { + type: 'json', + value: [ + // Nested spawn wrapper around the first occurrence. + { type: 'json', value: firstReceipt }, + duplicateReceipt, + ], + } + const records = collectReviewerFindingRecords(duplicateIdReceipt) + // Exactly one record for the duplicated id, keeping the FIRST text. + expect(records).toEqual([ + { + id: 'code-reviewer:correctness:dup', + text: 'first occurrence text', + severity: 'high', + dimension: 'correctness', + evidence: ['first evidence'], + correction: 'fix it once', + }, + ]) + for (const input of [ + duplicateIdReceipt, + firstReceipt, + { nested: duplicateIdReceipt }, + 'BLOCKING: plain text', + null, + ]) { + expect(inlineHelpers.collectReviewerFindingRecords(input)).toEqual( + collectReviewerFindingRecords(input), + ) + } + }) +}) diff --git a/agents/__tests__/guide-test-utils.ts b/agents/__tests__/guide-test-utils.ts new file mode 100644 index 0000000000..e2d149af5e --- /dev/null +++ b/agents/__tests__/guide-test-utils.ts @@ -0,0 +1,35 @@ +import { existsSync } from 'fs' +import path from 'path' + +/** + * Shared repo-root/guide-existence helpers for the guide-pointer tests. + * + * `agents/guides/*.md` paths appear in prompts and in `GUIDE_POINTERS` as + * workspace-relative POSIX paths, so several tests need the same + * `agents/__tests__ -> repo root` derivation plus the same labelled existence + * probe. Both live here so a future guides-dir (or test-dir) move only changes + * one place. + */ + +/** agents/__tests__ -> repo root. */ +export const REPO_ROOT = path.join(__dirname, '..', '..') + +/** Absolute path for a workspace-relative repo path such as a guide file. */ +export function resolveRepoPath(relativePath: string): string { + return path.join(REPO_ROOT, relativePath) +} + +/** + * Labelled existence probe: returns `'exists'` or a message naming the missing + * path, so `expect(describeRepoFileExistence(x)).toBe('exists')` reports the + * offending file instead of `expected false to be true`. Pass `label` when the + * caller wants to name the reference (e.g. a pointer) rather than the raw path. + */ +export function describeRepoFileExistence( + relativePath: string, + label: string = relativePath, +): string { + return existsSync(resolveRepoPath(relativePath)) + ? 'exists' + : `${label} is missing` +} diff --git a/agents/__tests__/quality-prompt-snapshot.test.ts b/agents/__tests__/quality-prompt-snapshot.test.ts index e951b708a8..eaad56b4e2 100644 --- a/agents/__tests__/quality-prompt-snapshot.test.ts +++ b/agents/__tests__/quality-prompt-snapshot.test.ts @@ -1,11 +1,10 @@ import { describe, expect, test } from 'bun:test' import { PLACEHOLDER } from '@codebuff/agent-runtime/templates/types' +import { frontendSection } from '@codebuff/common/constants/prompt-sections' import { createBaseDeep } from '../base2/base-deep' import { createBase2 } from '../base2/base2' -import { frontendSection } from '@codebuff/common/constants/prompt-sections' - import { createCodeEditor } from '../editor/editor' import { buildBroadAuditSection, @@ -61,11 +60,20 @@ describe('shared craftsmanship prompt sections', () => { // with the reviewer rubric; assert topic coverage only so future wording // changes cannot silently drop a required self-check topic. Unlike // qualitySection, this section is intentionally NOT byte-frozen. + // + // The reviewer-parity topics ('Security pass', 'Test coverage + // (blocking)', 'Requirement coverage', 'File attestation') are + // deliberately NOT re-asserted here: they are owned by + // review-rubric-parity.test.ts, which pins them as bullet labels tied to + // the reviewer schema fields they mirror. Duplicating the weaker bare-word + // form would give the rubric two owners with different strictness. expect(preReviewSelfCheckSection).toContain('# Pre-Review Self-Check') - expect(preReviewSelfCheckSection).toContain('Security pass') - expect(preReviewSelfCheckSection).toContain('Test coverage') expect(preReviewSelfCheckSection).toContain('Test quality') - expect(preReviewSelfCheckSection).toContain('Compatibility') + expect(preReviewSelfCheckSection).toContain('Advisory vs blocking') + // Pinned to the bullet label: the intro sentence already contains + // "apiCompatibility", so a bare 'Compatibility' would still pass with the + // bullet deleted. + expect(preReviewSelfCheckSection).toContain('**Compatibility:**') expect(preReviewSelfCheckSection).toContain('Architecture') expect(preReviewSelfCheckSection).toContain('Resource safety') expect(preReviewSelfCheckSection).toContain('Hygiene') @@ -113,18 +121,6 @@ describe('shared craftsmanship prompt sections', () => { ) }) - test('buildBroadAuditSection throws on empty or whitespace finalizeClause', () => { - expect(() => - buildBroadAuditSection('' as unknown as never), - ).toThrow('finalizeClause must be a non-empty string') - expect(() => - buildBroadAuditSection(' ' as unknown as never), - ).toThrow('finalizeClause must be a non-empty string') - expect(() => - buildBroadAuditSection('\n\t' as unknown as never), - ).toThrow('finalizeClause must be a non-empty string') - }) - test('gitDisciplineSection contains the required git-discipline topics (not byte-frozen)', () => { // gitDisciplineSection is advisory guidance that may evolve; only assert // topic coverage so future tightening does not silently drop a rule. @@ -183,7 +179,9 @@ describe('shared craftsmanship prompt sections', () => { // affirmative-state-first topic coverage so future tightening does not // silently drop GATE PENDING/PASSED, pending-set authority, or local-check // separation. - expect(gateAwarenessSection).toContain('# Automated Validation & Review Gate') + expect(gateAwarenessSection).toContain( + '# Automated Validation & Review Gate', + ) expect(gateAwarenessSection).toContain('GATE: PENDING') expect(gateAwarenessSection).toContain('GATE: PASSED') expect(gateAwarenessSection).toContain('final_response_allowed') @@ -285,6 +283,61 @@ describe('shared craftsmanship prompt sections', () => { expect(editor.instructionsPrompt).toContain(preReviewSelfCheckSection) }) + test('base2 inline Code Editing Mandates keeps topic parity with qualitySection', () => { + // base2 keeps an always-inline '# Code Editing Mandates' block that + // restates the canonical craftsmanship rules in its own wording, so the + // default disclosure-ON surface (where qualitySection becomes a guide + // pointer) still carries them inline. That duplication is intentional and + // documented at the qualitySectionPointer declaration in base2.ts; this + // guard pins the shared topic labels on both copies so dropping a rule from + // either side fails here instead of letting the two silently diverge. + const system = createBase2('default').systemPrompt as string + const heading = '# Code Editing Mandates' + const start = system.indexOf(heading) + expect( + start >= 0 ? 'present' : 'base2 system prompt lost its inline mandates', + ).toBe('present') + const afterHeading = system.slice(start + heading.length) + const end = afterHeading.indexOf('\n# ') + expect( + end > 0 + ? 'bounded' + : 'inline Code Editing Mandates block is no longer followed by a heading', + ).toBe('bounded') + const inlineMandates = afterHeading.slice(0, end) + + // Bullet labels only (no leading dash): the inline copy uses slightly + // different indentation for some bullets, but a missing topic must fail. + const sharedTopicLabels = [ + '**Conventions:**', + '**Libraries/Frameworks:**', + '**Style & Structure:**', + '**Idiomatic Changes:**', + '**Simplicity & Minimalism:**', + '**Code Reuse:**', + '**Refactoring Awareness:**', + '**Testing:**', + '**Package Management:**', + '**Code Hygiene:**', + ] + for (const label of sharedTopicLabels) { + expect( + inlineMandates.includes(label) + ? 'inline' + : `inline Code Editing Mandates lost ${label}`, + ).toBe('inline') + expect( + qualitySection.includes(label) + ? 'canonical' + : `qualitySection lost ${label}`, + ).toBe('canonical') + } + // The no-any rule is labelled slightly differently on each side, so pin the + // shared prose instead of the bullet label. + expect(inlineMandates).toContain('Don\'t type cast as "any"') + expect(qualitySection).toContain('Don\'t type cast as "any"') + }) + test('base2 system prompt prefers direct code_search and multi-query code-searcher', () => { // Root content-search tools are granted; the prompt must prefer direct // code_search for single-pattern search and code-searcher for multi-query diff --git a/agents/__tests__/review-rubric-parity.test.ts b/agents/__tests__/review-rubric-parity.test.ts new file mode 100644 index 0000000000..3f38f8c516 --- /dev/null +++ b/agents/__tests__/review-rubric-parity.test.ts @@ -0,0 +1,376 @@ +import { describe, expect, test } from 'bun:test' +import { readdirSync, readFileSync } from 'fs' +import path from 'path' + +import { GUIDE_POINTERS, type GuidePath } from '../base2/base2' +import { preReviewSelfCheckSection } from '../base2/quality-prompt-section' +import reviewerDefinition from '../reviewer/code-reviewer' +import { describeRepoFileExistence, resolveRepoPath } from './guide-test-utils' + +import type { JsonSchema } from '../types/util-types' + +/** + * Parity guard: every mechanically-blocking rule the automated reviewer gate + * enforces must also be taught to implementation agents through + * `preReviewSelfCheckSection` (and its `agents/guides/pre-review-self-check.md` + * mirror). Adding a blocking rule to the reviewer without teaching implementers + * fails here. + * + * The reviewer side is asserted against the imported reviewer definition's + * `outputSchema` rather than a text read of `agents/reviewer/code-reviewer.ts`: + * a renamed or removed schema field is then a lookup miss (and a renamed + * export a type error), and reformatting the reviewer source — a printWidth + * change, a property broken across lines, a quote-style flip — cannot fail the + * guard without real semantic drift. + * + * The reviewer uses camelCase schema field names while the rubric is human + * prose, so each schema path maps to a verbatim rubric excerpt and matching is + * case-insensitive. The excerpts are bullet labels rather than bare topic + * words: a bare word like `security` is a substring of almost any rubric prose, + * so deleting the bullet that teaches the rule would not fail the check. + */ +const REVIEWER_SOURCE = 'agents/reviewer/code-reviewer.ts' + +/** + * Resolve a node inside the reviewer output schema by a dotted path. Each + * segment is a `properties` key, except the literal `items` segment, which + * steps into an array's item schema. Returns undefined when any hop is + * missing or is not an object schema, so a renamed field fails the lookup + * instead of silently making the parity check vacuous. + */ +function resolveSchemaNode( + root: JsonSchema | undefined, + path: string, +): JsonSchema | undefined { + let current = root + for (const segment of path.split('.')) { + const next: unknown = + segment === 'items' ? current?.items : current?.properties?.[segment] + if (typeof next !== 'object' || next === null || Array.isArray(next)) { + return undefined + } + current = next as JsonSchema + } + return current +} + +// reviewer schema path -> verbatim rubric excerpt that teaches it, plus the +// exact enum the field must still declare when the blocking behaviour lives in +// the enum rather than in the field's presence. This table covers every +// mechanically-blocking field of the reviewer receipt: `requirementCoverage` +// (and its blocking `missing`/`uncertain` statuses), `reviewedFiles`, the five +// `dimensions`, `coverage` (where `missing` blocks), and `verdict` (whose +// `BLOCKING` value is the gate's own signal). `coverage` and `dimensions.tests` +// share one rubric bullet on purpose — the merged `Test coverage (blocking)` +// bullet is the single owner of the coverage-naming rule. Every rubric excerpt +// is a bullet label except `correctness`, which the rubric teaches through its +// dimension enumeration rather than a dedicated bullet, and the `uncertain` +// status, whose rule lives in a clause of the requirement-coverage bullet. +const REVIEWER_SCHEMA_RULES: Array< + [schemaPath: string, rubricExcerpt: string, enumValues?: readonly string[]] +> = [ + ['requirementCoverage', '**Requirement coverage (blocking):**'], + [ + 'requirementCoverage.items.status', + '`uncertain` blocks exactly like `missing`', + ['satisfied', 'missing', 'uncertain'], + ], + ['reviewedFiles', '**File attestation:**'], + [ + 'dimensions.correctness', + 'scores correctness, security, tests, apiCompatibility, and performance', + ], + ['dimensions.security', '**Security pass:**'], + ['dimensions.tests', '**Test coverage (blocking):**'], + ['dimensions.apiCompatibility', '**Compatibility:**'], + ['dimensions.performance', '**Resource safety:**'], + ['coverage', '**Test coverage (blocking):**', ['covered', 'missing', 'n/a']], + [ + 'verdict', + '**Advisory vs blocking:**', + ['LOOKS_GOOD', 'NON_BLOCKING', 'BLOCKING'], + ], +] + +describe('review rubric parity — reviewer gate rules reach implementers', () => { + test('every blocking reviewer schema field has a corresponding rubric concept', () => { + const { outputSchema } = reviewerDefinition + const rubric = preReviewSelfCheckSection.toLowerCase() + + for (const [ + schemaPath, + rubricExcerpt, + enumValues, + ] of REVIEWER_SCHEMA_RULES) { + // Assert the reviewer still declares the field instead of skipping: a + // renamed schema field (requirementCoverage -> requirements) must fail + // here rather than silently making the parity check vacuous. Labelled so + // the failure names the missing path instead of printing `undefined`. + const node = resolveSchemaNode(outputSchema, schemaPath) + expect( + node + ? 'declared' + : `${REVIEWER_SOURCE} outputSchema no longer declares ${schemaPath}`, + ).toBe('declared') + if (enumValues) { + // Exact-set comparison rather than containment: a NEW enum value (a + // fourth requirement status, a fourth coverage marker) changes what + // blocks, so it must be taught to implementers too. + const declared = node?.enum ?? [] + const enumMatches = + declared.length === enumValues.length && + enumValues.every((value) => declared.includes(value)) + expect( + enumMatches + ? 'declared' + : `${REVIEWER_SOURCE} ${schemaPath}.enum is [${declared.join(', ')}], expected exactly [${enumValues.join(', ')}]`, + ).toBe('declared') + } + expect(rubric).toContain(rubricExcerpt.toLowerCase()) + } + }) + + test('the requirement-coverage and test-coverage rules stay marked blocking', () => { + // Pinned to the two bullet labels rather than a bare `(blocking)` count: a + // count would still pass if both markers migrated onto unrelated bullets. + expect(preReviewSelfCheckSection).toContain( + '**Requirement coverage (blocking):**', + ) + expect(preReviewSelfCheckSection).toContain('**Test coverage (blocking):**') + // The merged `Test coverage (blocking)` bullet is now the single owner of + // the coverage-naming rule, so the superseded duplicate bullet must not + // come back alongside it. + expect(preReviewSelfCheckSection).not.toContain('**Coverage naming') + }) +}) + +/** + * Guide/constant drift guard: every guide with a `GUIDE_POINTERS` row + * duplicates the exported prompt section that progressive prompt disclosure + * relocates into it. Editing a section without updating its guide (or the + * reverse) would silently serve stale guidance to any agent that follows the + * pointer. This check owns both existence and content parity for every guide + * with a `GUIDE_POINTERS` row, replacing the weaker keyword-per-file table that + * used to live in `base2-progressive-disclosure.test.ts`. + * + * Normalization: drop the leading markdown H1 heading from both sides (each + * guide repeats its section's heading), collapse every whitespace run to a + * single space, then trim — which absorbs the guides' trailing newline and any + * markdown re-wrapping. Escaped backticks in the TypeScript template literals + * are plain backticks in the exported string value, so the two sides compare + * directly. The two sides are normalized independently, so a section that keeps + * an H2 heading (the broad-audit prompt subsection) still compares correctly: + * its drift check runs against an excerpt that starts after the heading. + */ +function normalizeMarkdown(text: string): string { + return text + .replace(/^#\s[^\n]*\n/, '') + .replace(/\s+/g, ' ') + .trim() +} + +/** + * Guide-specific drift metadata, keyed by the workspace-relative guide path in + * `GUIDE_POINTERS`. The guide/section/pointer wiring itself is owned by that + * exported table in base2.ts, so this test derives the file list instead of + * duplicating it. + */ +type GuideDriftOverride = { + /** + * Set only when the guide is intentionally not a verbatim copy of the whole + * section; the drift check then covers the contiguous excerpt running from + * `from` through the end of `to` instead of the full section body. + */ + excerpt?: { from: string; to: string } + /** + * Guide-only tail content that must survive. Excerpt comparison stops at + * `excerpt.to`, so anything after it is otherwise unguarded and could be + * deleted without failing this check. + */ + tailContains?: string[] +} + +/** + * Keyed by `GuidePath` so a renamed or unknown guide path is a compile error + * instead of a runtime "override matches no GUIDE_POINTERS entry" assertion — + * the same compile-time-over-runtime argument the `GUIDE_POINTER_TABLE` + * comment in base2.ts makes. + */ +const GUIDE_DRIFT_OVERRIDES: Partial> = { + 'agents/guides/specialist-routing.md': { + // specialist-routing.md is a reordered superset rather than a copy: the + // section leads with its "Gate vs Specialists — ownership matrix" table + // before the routing list, while the guide leads with the routing list and + // carries its own expanded ownership/params tables plus deterministic + // router-trigger sections the section does not contain. Full containment + // therefore cannot hold. The excerpt below still pins the routing intro, + // all seven routing bullets, and the evidence paragraph verbatim. + excerpt: { + from: 'Use specialists when repository evidence', + to: 'the final code-reviewer gate.', + }, + // Anchors for content the excerpt window does not cover, so a deleted + // section fails here instead of passing on the excerpt alone. These are + // NOT all guide-only headings: several (`## Gate vs Specialists`, + // `## Params Contract`, `## Compaction recovery`, + // `## Sequential vs parallel`) also exist in `specialistRoutingSection` + // but sit outside the excerpt bounds, while others (the deterministic + // router-trigger sections) are genuinely guide-only. Keep both kinds: one + // anchor per uncovered H2/H3 heading plus a distinctive line from the + // sections whose body carries the substance (the deterministic router + // table, the ownership matrix, and the params contract). + tailContains: [ + '## Deterministic routing triggers', + 'computed by `selectSpecialistReviewers`', + '### Why a specialist may not spawn', + '## Gate vs Specialists', + 'Ownership and timing — Final Gate always runs last', + '## Params Contract', + '`params.snapshot_id` = `v3:<64-hex>` (opaque gate token)', + '## Example spawns', + '## Compaction recovery', + '## Sequential vs parallel', + ], + }, + 'agents/guides/broad-audit.md': { + // The builder is parameterized on the finalize clause, and the guide + // documents the implementation variant plus a trailing plan-mode note the + // section does not contain, so full containment cannot hold. The excerpt + // still pins the scope-then-shard intro paragraph, all four steps, and the + // closing sharding mandate verbatim against the implementation variant. + excerpt: { + from: 'For broad, open-ended', + to: 'not a single codesearch.', + }, + // The plan-mode note trails the excerpt window; pin it so it cannot be + // dropped silently. + tailContains: [ + 'In plan mode, do not implement — translate the findings into the durable plan packet instead.', + ], + }, +} + +/** + * Guides under `agents/guides/` that intentionally have no `GUIDE_POINTERS` + * row, and therefore no exported prompt section to mirror. Listed explicitly so + * a NEW guide file cannot land without a drift owner: the reverse-direction + * sweep below fails until it is either pointed at from base2 or added here. + */ +type DriftExemptGuide = + | 'agents/guides/editor-writers-and-repair.md' + | 'agents/guides/knowledge-files.md' + +const DRIFT_EXEMPT_GUIDES = new Set([ + // Spawn-contract documentation referenced from specialist-routing.md; it + // documents agent relationships rather than duplicating a prompt section. + 'agents/guides/editor-writers-and-repair.md', + // Knowledge-file conventions; the runtime injects knowledge files directly, + // so no exported prompt-section constant mirrors this guide. + 'agents/guides/knowledge-files.md', +]) + +/** + * Membership check for the directory-read guide names, which are plain strings. + * The set itself stays keyed by the `DriftExemptGuide` literal union — the same + * compile-time-over-runtime argument `GUIDE_DRIFT_OVERRIDES` makes with + * `Partial>` — so a typo'd exemption is a compile error + * instead of only a runtime "exempt but missing" message. + */ +function isDriftExemptGuide(guide: string): guide is DriftExemptGuide { + return (DRIFT_EXEMPT_GUIDES as ReadonlySet).has(guide) +} + +describe('guide/constant drift — relocated guides mirror their sections', () => { + test('every agents/guides markdown file has a drift owner', () => { + // Reverse direction of the mirror check below: without this, a new guide + // (or an existing unreferenced one) could sit under agents/guides/ with + // nothing asserting it stays in sync with anything. + // Recursive so a guide added under `agents/guides//` still needs a + // drift owner — a non-recursive read would leave exactly the hole this + // sweep exists to close. Separators are normalized to `/` because the + // GUIDE_POINTERS paths are workspace-relative POSIX paths. + const guidesDir = resolveRepoPath('agents/guides') + const guideFiles = readdirSync(guidesDir, { + recursive: true, + encoding: 'utf8', + }) + .filter((name) => name.endsWith('.md')) + .map((name) => `agents/guides/${name.split(path.sep).join('/')}`) + // Guard against a bad directory read making the sweep vacuous. + expect(guideFiles.length).toBeGreaterThanOrEqual(GUIDE_POINTERS.length) + const pointedGuides = new Set( + GUIDE_POINTERS.map((entry) => entry.guide), + ) + for (const guide of guideFiles) { + expect( + pointedGuides.has(guide) || isDriftExemptGuide(guide) + ? 'owned' + : `${guide} has no GUIDE_POINTERS row and is not in DRIFT_EXEMPT_GUIDES`, + ).toBe('owned') + } + // A stale exemption (guide deleted, or later given a pointer) must fail too, + // so the exempt list cannot silently accumulate dead entries. + for (const exempt of DRIFT_EXEMPT_GUIDES) { + expect( + guideFiles.includes(exempt) && !pointedGuides.has(exempt) + ? 'exempt' + : `${exempt} is exempt but is missing or now has a GUIDE_POINTERS row`, + ).toBe('exempt') + } + }) + + test('every relocated guide carries its exported section content', () => { + // Numeric vacuity guard only: an emptied table must not make the whole + // drift check pass by iterating nothing. The exact guide count is owned by + // `GUIDE_PATHS`/`GUIDE_POINTERS` in base2.ts, so it is not restated here. + expect(GUIDE_POINTERS.length).toBeGreaterThan(0) + for (const { guide, sectionName, section } of GUIDE_POINTERS) { + const { excerpt, tailContains } = GUIDE_DRIFT_OVERRIDES[guide] ?? {} + const guidePath = resolveRepoPath(guide) + // Labelled so a missing guide names the file instead of failing with + // `expected false to be true`. + expect(describeRepoFileExistence(guide)).toBe('exists') + const guideBody = normalizeMarkdown(readFileSync(guidePath, 'utf8')) + const sectionBody = normalizeMarkdown(section) + + let expected = sectionBody + if (excerpt) { + const from = normalizeMarkdown(excerpt.from) + const to = normalizeMarkdown(excerpt.to) + const start = sectionBody.indexOf(from) + // Labelled like the neighbouring assertions so a stale excerpt bound + // names the guide/constant pair instead of printing a bare number. + expect( + start >= 0 + ? 'found' + : `${sectionName} (${guide}) no longer contains excerpt start "${excerpt.from}"`, + ).toBe('found') + const end = sectionBody.indexOf(to, start) + expect( + end > start + ? 'found' + : `${sectionName} (${guide}) no longer contains excerpt end "${excerpt.to}" after its start`, + ).toBe('found') + expected = sectionBody.slice(start, end + to.length) + } + + // Guard against the comparison degrading into a keyword check. + expect(expected.length).toBeGreaterThan(200) + // Labelled so a drift failure names the guide/constant pair instead of + // dumping two multi-kilobyte strings. + expect( + guideBody.includes(expected) + ? 'mirrored' + : `${guide} drifted from ${sectionName}`, + ).toBe('mirrored') + + for (const tail of tailContains ?? []) { + expect( + guideBody.includes(normalizeMarkdown(tail)) + ? 'present' + : `${guide} no longer contains its tail section "${tail}"`, + ).toBe('present') + } + } + }) +}) diff --git a/agents/base2/base2.ts b/agents/base2/base2.ts index 09cfafa3f0..a7231d3ec7 100644 --- a/agents/base2/base2.ts +++ b/agents/base2/base2.ts @@ -5,6 +5,7 @@ import { resolveMaxReviewerRepairRounds, resolveMaxSpecialistRepairRounds, } from '@codebuff/common/util/gate-repair-budgets' +import { FALLBACK_GUIDES } from '@codebuff/common/util/guides' import type { Base2ActiveWorkPhase, @@ -14,6 +15,7 @@ import type { Base2ReviewReceipt, } from './gate-state' import { + type BroadAuditFinalizeClause, buildBroadAuditSection, gateAwarenessSection, gitDisciplineSection, @@ -26,17 +28,193 @@ import { resolveModelToolNames, type UnlockedToolTier } from './tool-tiers' import { publisher } from '../constants' import { PLACEHOLDER, + type PlaceholderValue, type SecretAgentDefinition, } from '../types/secret-agent-definition' /** - * Default for progressive prompt disclosure when the caller omits the - * `progressivePromptDisclosure` option. Post-flip (M2): ON by default. There is - * no env canary; an explicit `progressivePromptDisclosure: false` is the only - * way to opt out. + * Default when the option is omitted: ON (post-M2 flip). No env canary — an + * explicit `progressivePromptDisclosure: false` is the only opt-out. */ const DEFAULT_PROGRESSIVE_PROMPT_DISCLOSURE: boolean = true +/** + * Pointers emitted in place of the relocated sections while disclosure is on. + * Every pointer must keep its "If that guide is unavailable" clause: the guides + * are plain repo files, so an embedder whose workspace lacks `agents/guides/` + * degrades to the inline summary instead of losing the section to a failed + * read. The full bodies are additionally recovered at prompt-format time by the + * `ON_DEMAND_GUIDE_FALLBACK` placeholder below, whose provider detects the + * missing guides against the caller's workspace root (T1.4d; table and + * detection live in `common/src/util/guides.ts`). + */ +const broadAuditPointer = + 'Broad audit / many-file / coverage-sweep request → read_files `agents/guides/broad-audit.md` before sharding. If that guide is unavailable, still scope first: measure breadth, dispatch one file-picker/code-searcher pair per subsystem in bounded waves, and machine-check coverage before synthesizing — never a single codesearch.' +/** Finalize clause whose section body `GUIDE_POINTER_TABLE` pins. */ +const BROAD_AUDIT_ROW_CLAUSE: BroadAuditFinalizeClause = + 'proceed to implementation or the answer' +/** + * Per-clause tail appended to `broadAuditPointer`: only plan mode carries the + * "do not implement" sentence. Keyed by `BroadAuditFinalizeClause` so a new + * clause is a compile error rather than a silently missing tail. + */ +const BROAD_AUDIT_POINTER_TAILS: Record = { + 'proceed to implementation or the answer': '', + 'translate the findings into the durable plan packet below': + ' In plan mode, do not implement — translate the findings into the durable plan packet instead.', +} +const specialistRoutingPointer = + 'Choosing a specialist agent → read_files `agents/guides/specialist-routing.md`. If that guide is unavailable, route only on a crossed risk boundary (architecture, requirements, performance, reliability, migration, compatibility, accessibility, dependencies), pass the gate-assigned `params.snapshot_id`, and never substitute a specialist for the runtime-owned final gate.' +const gitDisciplinePointer = + 'Before any git commit/branch/push → read_files `agents/guides/git-discipline.md`. If that guide is unavailable, apply the standard git rules: delegate to `git-committer` with `params.owned_paths`, commit only after GATE: PASSED, never push or alter git config unless explicitly asked, and never commit secrets.' +// The named guide is advisory routing only (when to ask for a pre-edit +// security review), so the degraded clause mirrors that routing rule rather +// than restating the reviewer rubric's input-validation/fail-closed bullet, +// which `preReviewSelfCheckPointer` already owns. +const securityReviewPointer = + 'Editing security-sensitive files (auth/crypto/secrets/payment/permissions) → read_files `agents/guides/security-review.md` before editing. If that guide is unavailable, apply the standard routing rule: consider an advisory (non-blocking) `security-reviewer` pre-edit review before the editor runs, skip it for trivial edits such as typos or comments, and remember the automated post-edit validation/reviewer gate still runs regardless.' +// The always-inline `# Code Editing Mandates` block below intentionally +// restates these rules in base2's own wording: it is the minimum always-on +// editing contract, so it must survive both this pointer and a workspace with +// no `agents/guides/`. quality-prompt-snapshot.test.ts pins the shared topic +// labels on BOTH copies so the duplication cannot silently drift. +const qualitySectionPointer = + 'Code craftsmanship standards (conventions, minimal-change, reuse, no-any, hygiene) → read_files `agents/guides/code-craftsmanship.md` before editing code. If that guide is unavailable, apply the standard craftsmanship rules: follow existing project conventions, verify a library is already used before adopting it, make the minimal change, reuse existing helpers, avoid `any` casts, and leave no dead code or missing imports.' +const preReviewSelfCheckPointer = + 'Before finishing implementation work → read_files `agents/guides/pre-review-self-check.md` (security, requirement coverage, test coverage, compatibility, resource safety, hygiene). If that guide is unavailable, apply the standard self-check rules: name the exact test file and case covering every behavior change, map each requirement to satisfied/missing/uncertain (uncertain blocks like missing), account for every changed file, and re-verify security, compatibility, resource safety, and hygiene before returning.' + +/** + * Workspace-relative guide paths shared by the table and every call site. + * + * Aliased from the canonical table in `@codebuff/common/util/guides`, which + * also keys the recovery bodies the fallback placeholders inline, so the + * pointers here and those bodies cannot drift apart. + */ +const GUIDE_PATHS = FALLBACK_GUIDES +/** Exported so callers/tests key guide-scoped lookups by the same union. */ +export type GuidePath = (typeof GUIDE_PATHS)[keyof typeof GUIDE_PATHS] + +/** One relocated section's wiring, minus the guide path that keys it. */ +type GuidePointerRow = { + /** Exported-constant name, used in test failure messages. */ + sectionName: string + /** Verbose section body the pointer replaces while disclosure is on. */ + section: string + /** Compact pointer emitted in the section's place. */ + pointer: string + /** + * Placeholder that recovers THIS section's body when the guide is missing + * under the embedder's workspace root. One placeholder per pointer, so a mode + * that deliberately omits a pointer omits its recovery too (plan mode is + * read-only: it emits neither the git-discipline pointer nor its body, so it + * must not get commit/push guidance back through recovery either). + */ + fallbackPlaceholder: PlaceholderValue + /** Authored surface the pointer is emitted into. */ + surface: 'system' | 'instructions' +} + +/** + * Single source of truth for the relocated-section → guide → pointer wiring. + * Prompt assembly resolves both halves through `discloseGuide` (or its + * `discloseBroadAudit` wrapper), so a relocated section cannot exist without a + * row here. Keying by `GuidePath` makes "exactly one row per guide" a + * compile-time property. + */ +const GUIDE_POINTER_TABLE: Record = { + [GUIDE_PATHS.codeCraftsmanship]: { + sectionName: 'qualitySection', + section: qualitySection, + pointer: qualitySectionPointer, + fallbackPlaceholder: + PLACEHOLDER.ON_DEMAND_GUIDE_FALLBACK_CODE_CRAFTSMANSHIP, + surface: 'system', + }, + [GUIDE_PATHS.preReviewSelfCheck]: { + sectionName: 'preReviewSelfCheckSection', + section: preReviewSelfCheckSection, + pointer: preReviewSelfCheckPointer, + fallbackPlaceholder: + PLACEHOLDER.ON_DEMAND_GUIDE_FALLBACK_PRE_REVIEW_SELF_CHECK, + surface: 'system', + }, + [GUIDE_PATHS.gitDiscipline]: { + sectionName: 'gitDisciplineSection', + section: gitDisciplineSection, + pointer: gitDisciplinePointer, + fallbackPlaceholder: PLACEHOLDER.ON_DEMAND_GUIDE_FALLBACK_GIT_DISCIPLINE, + surface: 'system', + }, + [GUIDE_PATHS.securityReview]: { + sectionName: 'securityReviewSection', + section: securityReviewSection, + pointer: securityReviewPointer, + fallbackPlaceholder: PLACEHOLDER.ON_DEMAND_GUIDE_FALLBACK_SECURITY_REVIEW, + surface: 'system', + }, + [GUIDE_PATHS.specialistRouting]: { + sectionName: 'specialistRoutingSection', + section: specialistRoutingSection, + pointer: specialistRoutingPointer, + fallbackPlaceholder: + PLACEHOLDER.ON_DEMAND_GUIDE_FALLBACK_SPECIALIST_ROUTING, + surface: 'system', + }, + [GUIDE_PATHS.broadAudit]: { + sectionName: `buildBroadAuditSection('${BROAD_AUDIT_ROW_CLAUSE}')`, + // The body is clause-parameterized; the guide documents the implementation + // variant, so this row pins that one clause's body. `discloseBroadAudit` + // rebuilds the body for the clause it is asked for, which for + // `BROAD_AUDIT_ROW_CLAUSE` is byte-identical to this string. + section: buildBroadAuditSection(BROAD_AUDIT_ROW_CLAUSE), + pointer: broadAuditPointer, + // Clause-parameterized recovery: this row pins the IMPLEMENTATION clause, + // matching the body the guide documents. Plan mode substitutes + // `ON_DEMAND_GUIDE_FALLBACK_BROAD_AUDIT_PLAN` so a recovered body can never + // contradict the finalize clause that surface's pointer emitted. + fallbackPlaceholder: PLACEHOLDER.ON_DEMAND_GUIDE_FALLBACK_BROAD_AUDIT, + surface: 'instructions', + }, +} + +/** Flattened view of `GUIDE_POINTER_TABLE` in `GUIDE_PATHS` declaration order. */ +export const GUIDE_POINTERS: ReadonlyArray< + GuidePointerRow & { + /** Workspace-relative guide path, exactly as the pointer text emits it. */ + guide: GuidePath + } +> = Object.values(GUIDE_PATHS).map((guide) => ({ + guide, + ...GUIDE_POINTER_TABLE[guide], +})) + +/** Pointer while disclosure is on, the verbose section body when it is off. */ +function discloseGuide( + guide: GuidePath, + progressiveDisclosure: boolean, +): string { + const { section, pointer } = GUIDE_POINTER_TABLE[guide] + return progressiveDisclosure ? pointer : section +} + +/** + * Broad-audit disclosure: the one relocated section whose body is + * clause-parameterized. The pointer half resolves through the row and appends + * the clause's tail; the explicit-off half rebuilds the body for the requested + * clause, which for `BROAD_AUDIT_ROW_CLAUSE` is byte-identical to the section + * the row pins. + */ +function discloseBroadAudit( + finalizeClause: BroadAuditFinalizeClause, + progressiveDisclosure: boolean, +): string { + if (progressiveDisclosure) { + const { pointer } = GUIDE_POINTER_TABLE[GUIDE_PATHS.broadAudit] + return `${pointer}${BROAD_AUDIT_POINTER_TAILS[finalizeClause]}` + } + return buildBroadAuditSection(finalizeClause) +} + export { DEFAULT_MAX_REPAIR_ROUNDS, DEFAULT_MAX_SPECIALIST_REPAIR_ROUNDS, @@ -135,24 +313,72 @@ export function createBase2( // agents[agentId] -> defaultModel -> explicit model -> hard error; see // docs/configuration.md and docs/local-mode.md). - const progressiveDisclosure = progressivePromptDisclosure - // M4 progressive prompt disclosure (default ON; opt out with an explicit - // `progressivePromptDisclosure: false`): when enabled, relocate verbose - // advisory sections out of the always-on prompt and replace each with a - // compact pointer to an on-demand guide file. When disabled, disclose() - // returns the full section verbatim so the assembled prompt is byte-identical. - const disclose = (fullSection: string, pointer: string): string => - progressiveDisclosure ? pointer : fullSection - const specialistRoutingPointer = - 'Choosing a specialist agent → read_files `agents/guides/specialist-routing.md`.' - const gitDisciplinePointer = - 'Before any git commit/branch/push → read_files `agents/guides/git-discipline.md`.' - const securityReviewPointer = - 'Editing security-sensitive files (auth/crypto/secrets/payment/permissions) → read_files `agents/guides/security-review.md` before editing.' - const qualitySectionPointer = - 'Code craftsmanship standards (conventions, minimal-change, reuse, no-any, hygiene) → read_files `agents/guides/code-craftsmanship.md` before editing code.' - const preReviewSelfCheckPointer = - 'Before finishing implementation work → apply the pre-review self-check rubric from `agents/base2/quality-prompt-section.ts` (preReviewSelfCheckSection): security pass, test coverage, compatibility, resource safety, hygiene.' + // Disclosure ON relocates each verbose advisory section to its pointer; OFF + // keeps the section body verbatim so the prompt matches the pre-M4 surface. + const disclose = (guide: GuidePath): string => + discloseGuide(guide, progressivePromptDisclosure) + // Recovery placeholder for one disclosed pointer. Only reached from the + // disclosure-gated branch below: with disclosure off the bodies are inline, so + // no recovery is emitted at all. + const recover = (guide: GuidePath): PlaceholderValue => + GUIDE_POINTER_TABLE[guide].fallbackPlaceholder + + // Assembled as a list so every gap stays exactly one blank line and + // `buildArray` drops plan mode's git-discipline entry without leaving a + // double gap. The heading is disclosure-only: bare pointer sentences would + // otherwise read as a continuation of `# Repository state`. + const guideSections = buildArray( + progressivePromptDisclosure && '# On-demand guides', + disclose(GUIDE_PATHS.codeCraftsmanship), + disclose(GUIDE_PATHS.preReviewSelfCheck), + !planOnly && disclose(GUIDE_PATHS.gitDiscipline), + disclose(GUIDE_PATHS.securityReview), + disclose(GUIDE_PATHS.specialistRouting), + // T1.4d guide fallback, ADDITIVE: it FOLLOWS the pointers above instead of + // replacing them, so the pointer-presence assertions and the >=25% + // authored-reduction metric in + // agents/__tests__/base2-progressive-disclosure.test.ts stay meaningful + // rather than vacuous. Disclosure-off must not emit it: the six bodies are + // already inline there, so a fallback copy would duplicate them. The + // pointers keep their "If that guide is unavailable" clause regardless — it + // is unverified that every embedder entry point runs injectPlaceholders, so + // that clause remains the last line of defense. + // + // ONE placeholder per pointer actually emitted, so recovery mirrors the + // mode's exclusions instead of regrowing all six bodies: plan mode omits + // git-discipline here exactly as it omits the pointer above, and takes the + // plan-clause broad-audit body. broadAudit's pointer lives in the + // instructions prompt while its body arrives through this system-prompt + // block, because a provider cannot tell which surface it is injected into. + // + // Concatenated into a SINGLE entry with no separator: each provider emits + // its own trailing blank line, so the recovered blocks stay one blank line + // apart and the in-repo surface (every provider collapsing to '') keeps no + // stray blank lines at all. + progressivePromptDisclosure && + buildArray( + recover(GUIDE_PATHS.codeCraftsmanship), + recover(GUIDE_PATHS.preReviewSelfCheck), + !planOnly && recover(GUIDE_PATHS.gitDiscipline), + recover(GUIDE_PATHS.securityReview), + recover(GUIDE_PATHS.specialistRouting), + planOnly + ? PLACEHOLDER.ON_DEMAND_GUIDE_FALLBACK_BROAD_AUDIT_PLAN + : recover(GUIDE_PATHS.broadAudit), + ).join(''), + ).join('\n\n') + + // Tail of `# Spawning agents guidelines`, assembled as a list for the same + // reason as `guideSections`: the gate contract is conditional, so + // interpolating it bare would run `# Automated Validation & Review Gate` onto + // the preceding bullet and its last bullet onto + // `# Openbuff Meta-information`. `buildArray` also drops the entry without + // leaving a double blank line on the surfaces that omit it (plan-only / + // `fast`). + const spawnGuidelinesTail = buildArray( + "- **Never spawn the context-pruner agent:** This agent is spawned automatically for you and you don't need to spawn it yourself.", + isDefault && !planOnly && gateAwarenessSection, + ).join('\n\n') // Model-visible surface, narrowed when the caller passes `unlockedTiers`. const modelToolNames = resolveModelToolNames({ @@ -420,8 +646,8 @@ ${ ? '- **Do not omit context for isolated agents:** Many agents inherit conversation history and can be brief. Thinker has includeMessageHistory:false and cannot see the parent conversation, so pass a self-contained decision packet plus optional params.depth / params.outputSchemaHint.' : '- **Do not omit context for isolated agents:** Many agents inherit conversation history and can be brief. Isolated agents that do not inherit conversation history need a self-contained handoff.' } -- **Never spawn the context-pruner agent:** This agent is spawned automatically for you and you don't need to spawn it yourself. -${isDefault && !planOnly ? gateAwarenessSection : ''} +${spawnGuidelinesTail} + # Openbuff Meta-information ${modelOverride !== undefined ? `You are running on the ${modelOverride} model.` : 'You are running on the model configured via openbuff.json (defaultModel / modes / agents — see docs/local-mode.md) — the `model` field is not a fallback.'} @@ -512,20 +738,15 @@ ${PLACEHOLDER.SYSTEM_INFO_PROMPT} The runtime injects a fresh, compact Git-status observation before coding work and after model steps. Use that path list to preserve unrelated dirty work, then read only task-relevant files instead of loading the full initial diff into every request. -${disclose(qualitySection, qualitySectionPointer)} -${disclose(preReviewSelfCheckSection, preReviewSelfCheckPointer)} - ${PLACEHOLDER.FRONTEND_SECTION} -${!planOnly ? disclose(gitDisciplineSection, gitDisciplinePointer) : ''} - -${disclose(securityReviewSection, securityReviewPointer)} - -${disclose(specialistRoutingSection, specialistRoutingPointer)} +${guideSections} `, instructionsPrompt: planOnly - ? buildPlanOnlyInstructionsPrompt({ progressiveDisclosure }) + ? buildPlanOnlyInstructionsPrompt({ + progressiveDisclosure: progressivePromptDisclosure, + }) : executePlan ? buildExecutePlanInstructionsPrompt({ isFast, @@ -533,7 +754,7 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} hasNoValidation, noAskUser, - progressiveDisclosure, + progressiveDisclosure: progressivePromptDisclosure, }) : buildImplementationInstructionsPrompt({ isFast, @@ -541,7 +762,7 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} hasNoValidation, noAskUser, - progressiveDisclosure, + progressiveDisclosure: progressivePromptDisclosure, }), stepPrompt: planOnly ? buildPlanOnlyStepPrompt({}) @@ -935,6 +1156,10 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} // converges instead of looping forever on the same NON_BLOCKING // architectural commentary. Reset when the gate passes. activeWorkState.condonedFindingTexts ??= [] + // T1.5 companion: (verdict class, finding identity) condone keys. Legacy + // serialized state lacks this field, which is what makes the + // condonedFindingTexts fallback below conditional on it being empty. + activeWorkState.condonedFindingKeys ??= [] if (activeWorkState.openReviewerFindings.length > 0) { // Rehydrate the owed set from EVERY open finding, not just findings[0]: // serialized state can carry open findings from several reviewers and @@ -1210,6 +1435,28 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} rearmGateForUnreviewedDirty(unreviewedAtTurnStart) } } + // Turn-scoped CYCLE detection for the two repair loops below. These are + // deliberately turn-scoped LOCALS and NOT persisted gate state (they must + // not be added to gate-state.ts): a fingerprint cycle is only meaningful + // within one turn's repair loop, and a later turn must stay free to + // revisit an earlier workspace state. The existing no-progress guards + // only compare against the IMMEDIATELY PRECEDING fingerprint, so an + // A→B→A oscillation changes the fingerprint every round and never trips + // them; these sets catch a fingerprint that CHANGED but was already + // visited this turn. Two separate sets on purpose: the reviewer loop + // hashes buildGateSnapshotDetails(pending, validationSummary) while the + // specialist loop hashes buildGateSnapshotDetails(pending, ''), so one + // shared set would conflate two different fingerprint spaces. + const seenReviewerRepairFingerprints = new Set() + const seenSpecialistRepairFingerprints = new Set() + // Win 4a delta-only baseline: the last pinned block actually EMITTED + // this turn. Deliberately separate from + // activeWorkState.lastPinnedStateMessage, which doubles as the '' + // cache-invalidation sentinel (markActiveWorkStateChanged clears it on + // every gate-state write, including every setGateProgress call) and so + // can never report what the model last saw. Turn-scoped local, not + // persisted state: a resumed turn re-emits the full block once. + let lastEmittedPinnedStateMessage = '' while (true) { yield { toolName: 'spawn_agent_inline', @@ -1287,44 +1534,46 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} pinnedStateMessage && pinnedStateMessage !== activeWorkState.lastPinnedStateMessage ) { - const previousPinned = activeWorkState.lastPinnedStateMessage ?? '' - // Win 4a delta-only: emit full Harness pinned block once; subsequent STEPS when - // openReviewerBlockers/nextRequiredAction unchanged only emit Gate progress diff. - // Stripping Gate progress line lets us detect when only progress changed. + // Win 4a delta-only: the full Harness pinned block is emitted once + // per turn; a later step whose ONLY change is the gate-progress line + // emits just that line. The baseline is the last EMITTED block, never + // activeWorkState.lastPinnedStateMessage — markActiveWorkStateChanged + // resets that field to '' on every gate-state write (including every + // setGateProgress call), so using it here would make this branch + // unreachable and re-emit the whole block every step. + const previousPinned = lastEmittedPinnedStateMessage + const gateProgressLine = activeWorkState.gateProgressLine ?? '' + // Sections are joined with '\n\n', so removing the progress line + // leaves a longer blank-line run behind. Collapsing over-long runs + // keeps the stripped forms comparable across the line's first + // appearance and its disappearance, not only across value changes. const stripGateProgress = (msg: string): string => - msg.replace(/\nGate progress:[^\n]*/g, '').trim() - const prevStripped = stripGateProgress(previousPinned) - const nextStripped = stripGateProgress(pinnedStateMessage) + msg + .replace(/\nGate progress:[^\n]*/g, '') + .replace(/\n{3,}/g, '\n\n') + .trim() + // An empty gateProgressLine has no delta to send: the pinned block + // renders `Gate progress: ` only for a non-empty line. The + // explicit emptiness check keeps a stray "Gate progress: " substring + // inside reviewer blocker text from producing an empty delta. const isOnlyGateProgressChange = previousPinned !== '' && - prevStripped === nextStripped && - pinnedStateMessage.includes('Gate progress:') - if (isOnlyGateProgressChange) { - const gateProgressLine = activeWorkState.gateProgressLine ?? '' - if (gateProgressLine) { - activeWorkState.lastPinnedStateMessage = pinnedStateMessage - yield { - toolName: 'add_message', - input: { - role: 'user', - content: `Gate progress: ${gateProgressLine}`, - }, - includeToolCall: false, - } as any - } else { - activeWorkState.lastPinnedStateMessage = pinnedStateMessage - } - } else { - activeWorkState.lastPinnedStateMessage = pinnedStateMessage - yield { - toolName: 'add_message', - input: { - role: 'user', - content: pinnedStateMessage, - }, - includeToolCall: false, - } as any - } + gateProgressLine !== '' && + stripGateProgress(previousPinned) === + stripGateProgress(pinnedStateMessage) && + pinnedStateMessage.includes(`Gate progress: ${gateProgressLine}`) + activeWorkState.lastPinnedStateMessage = pinnedStateMessage + lastEmittedPinnedStateMessage = pinnedStateMessage + yield { + toolName: 'add_message', + input: { + role: 'user', + content: isOnlyGateProgressChange + ? `Gate progress: ${gateProgressLine}` + : pinnedStateMessage, + }, + includeToolCall: false, + } as any } // No per-step tier bookkeeping: progressiveToolDisclosure is pinned @@ -1410,13 +1659,16 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} const gitStatusFiles = extractGitStatusFiles( (currentGitStatus as any)?.toolResult, ) + // A real git_status payload (it carries a `status` field) gates both the + // mid-turn dirty-snapshot refresh below and the committed-file pruning + // branch further down, so compute it once. + const isRealGitStatusResult = + (currentGitStatus as any)?.toolResult?.[0]?.value?.status !== + undefined // Refresh the mid-turn dirty snapshot whenever git_status returns a // real status payload so top-of-loop P0 re-arm and P3 publication see // live dirtiness rather than only the turn-start snapshot. - const isRealGitStatusForDirtySnapshot = - (currentGitStatus as any)?.toolResult?.[0]?.value?.status !== - undefined - if (isRealGitStatusForDirtySnapshot) { + if (isRealGitStatusResult) { latestDirtyFiles = gitStatusFiles } // Prune pending gate files that were previously observed as dirty in @@ -1427,9 +1679,6 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} // Only prune when the git_status result is a real response (has a // `status` field) and we have previously confirmed files dirty, to // avoid false pruning from mock/empty results in tests. - const isRealGitStatusResult = - (currentGitStatus as any)?.toolResult?.[0]?.value?.status !== - undefined if ( isRealGitStatusResult && gitStatusObservedDirty && @@ -2025,11 +2274,17 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} const securityCrash = detectReviewerCrash(securityToolResult) // Parent-owned process RF strings are not repair targets for security. // Pass toolResult so evidence-only parent ownership matches finalization. - const securityBlockers = collectReviewerBlockers( - securityToolResult, - ).filter( - (blocker: string) => - !isParentOwnedRequirementBlocker(blocker, securityToolResult), + const rawSecurityBlockers = + collectReviewerBlockers(securityToolResult) + // One structured walk for the whole list: the per-blocker helper + // re-collects the structured reviewer outputs on every call. + const parentOwnedSecurityBlockers = + collectParentOwnedRequirementBlockers( + rawSecurityBlockers, + securityToolResult, + ) + const securityBlockers = rawSecurityBlockers.filter( + (blocker: string) => !parentOwnedSecurityBlockers.has(blocker), ) const securityAttestationIssues = collectReviewerAttestationIssues( securityToolResult, @@ -2044,14 +2299,20 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} securityAttestationIssues.length > 0 || !securityVerdict if (securityBlockers.length > 0) { - const records = collectReviewerFindingRecordsInline(securityToolResult) + const records = collectReviewerFindingRecords(securityToolResult) const securityFindingRecords = securityBlockers.map( (text: string, index: number) => { const record = correlateReviewerFindingRecord(text, records) return { id: record?.id ?? buildReviewerFindingId(text, index), gateId: `security-reviewer:${securitySnapshotFingerprint}`, - text: record?.text ?? text, + // The PREFIXED blocker string, like the code-reviewer path: + // `reviewerVerdictClass` derives the condone key's verdict + // class from this text, so storing the record's unprefixed + // text made every security finding class-agnostic (`*`) and a + // nit condoned as NON_BLOCKING silently swallowed its own + // BLOCKING re-raise. Only the id is adopted from the record. + text, status: 'open' as const, files: securityChangedFiles, snapshotFingerprint: securitySnapshotFingerprint, @@ -2279,6 +2540,24 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} validationStatus: 'passed', reuseReason: 'aux-gate:security-reviewer', }) + // Non-empty only: a passing security gate stays silent exactly as + // before whenever the receipt carries no advisories. + const securityAdvisories = boundAdvisoryLines( + collectReviewerAdvisories(securityToolResult), + ) + if (securityAdvisories.length > 0) { + yield { + toolName: 'add_message', + input: { + role: 'user', + content: [ + 'Advisories (non-blocking; no change required):', + ...securityAdvisories.map((advisory) => `- ${advisory}`), + ].join('\n'), + }, + includeToolCall: false, + } as any + } } activeWorkState.securityReviewGateDone = true activeWorkState.preEditSecurityReviewDone = true @@ -2355,6 +2634,16 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} '', ), ) + // Deleted pending files (a `missing` content marker in the files-v4 + // snapshot details) are attested-by-absence: a specialist cannot read + // them, so they are not required in its reviewedFiles. Computed once + // from the specialist pending snapshot and reused by both specialist + // attestation call sites, matching the final code-reviewer path — a + // deleted pending path must never produce a coverage gap that can + // escalate to the terminal `could not attest` branch. + const specialistDeletedFiles = collectDeletedFilesFromSnapshotDetails( + buildGateSnapshotDetails(specialistPendingFiles, ''), + ) const baseRoutedSpecialists = selectSpecialistReviewersInline({ files: specialistPendingFiles, requirements: prompt ?? '', @@ -2494,6 +2783,7 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} result, specialistCreditFingerprint, specialistPendingFiles, + specialistDeletedFiles, ) return ( attestationIssues.length > 0 && @@ -2587,6 +2877,7 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} specialistToolResult, expectedSnapshotId, specialistPendingFiles, + specialistDeletedFiles, ) // Fingerprint-only drift on a fully-attesting review is NOT a // terminal protocol failure: only a FILE-COVERAGE gap or a @@ -2622,19 +2913,47 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} specialistTerminalFailure = true break } + // Attestation tolerates a coverage-complete specialist review + // whose well-formed v3 fingerprint drifted from the expected + // snapshot; record that drift instead of accepting it + // silently, so a specialist review of possibly-stale file + // content that passed the gate stays auditable (same contract + // as the final code-reviewer gate). + if (attestsEverything) { + const specialistFingerprintDrift = + collectReviewerFingerprintDrift( + specialistToolResult, + expectedSnapshotId, + ) + if (specialistFingerprintDrift) { + emitGateTelemetry({ + currentPhase: activeWorkState.currentPhase, + pendingFileCount: specialistPendingFiles.length, + pendingFiles: specialistPendingFiles, + reviewerStatus: 'attestation-fingerprint-drift', + reviewer: agentType, + reportedFingerprint: specialistFingerprintDrift, + expectedFingerprint: expectedSnapshotId, + }) + } + } const crash = detectReviewerCrash(specialistToolResult) const rawBlockers = collectReviewerBlockers(specialistToolResult) // Defense in depth: parent-owned process RF strings must not - // alone force a specialist repair-editor spawn. Pass - // toolResult so evidence-only parent ownership matches + // alone force a specialist repair-editor spawn. Classify the + // whole list in ONE structured walk (the per-blocker helper + // re-collects the structured outputs on every call) so + // evidence-only parent ownership still matches // getReviewerFinalizationVerdict. + const parentOwnedSpecialistBlockers = + collectParentOwnedRequirementBlockers( + rawBlockers, + specialistToolResult, + ) const blockers = rawBlockers.filter( (blocker: string) => - !isParentOwnedRequirementBlocker( - blocker, - specialistToolResult, - ), + !parentOwnedSpecialistBlockers.has(blocker), ) const parentOwnedOnlyBlockers = rawBlockers.length > 0 && blockers.length === 0 @@ -2670,6 +2989,9 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} } clearOwedReviewer(agentType) markActiveWorkStateChanged() + const parentOwnedPassAdvisories = boundAdvisoryLines( + collectReviewerAdvisories(specialistToolResult), + ) yield { toolName: 'add_message', input: { @@ -2678,6 +3000,15 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} `${agentType} returned LOOKS_GOOD; parent-owned process requirements were ignored for the specialist gate (not repair targets):`, '', ...rawBlockers, + ...(parentOwnedPassAdvisories.length > 0 + ? [ + '', + 'Advisories (non-blocking; no change required):', + ...parentOwnedPassAdvisories.map( + (advisory) => `- ${advisory}`, + ), + ] + : []), ].join('\n'), }, includeToolCall: false, @@ -2686,7 +3017,7 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} } if (blockers.length > 0) { const records = - collectReviewerFindingRecordsInline(specialistToolResult) + collectReviewerFindingRecords(specialistToolResult) const specialistFindingRecords = blockers.map( (text: string, index: number) => { const record = correlateReviewerFindingRecord( @@ -2697,7 +3028,10 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} id: record?.id ?? buildReviewerFindingId(text, index), gateId: `${agentType}:${expectedSnapshotId}`, - text: record?.text ?? text, + // Prefixed blocker string for the same reason as the + // security path above: the condone key's verdict class + // is derived from this text. + text, status: 'open' as const, files: specialistPendingFiles, snapshotFingerprint: expectedSnapshotId, @@ -2801,6 +3135,10 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} const preRepairFingerprint = hashGateSnapshotDetails( buildGateSnapshotDetails(Array.from(pendingGateFiles), ''), ) + // Recording the BASELINE (not just post-repair states) is + // what makes A→B→A trip: round 1 records A, post B is new; + // round 2 records B, post A is already in the set. + seenSpecialistRepairFingerprints.add(preRepairFingerprint) const specialistOpenFindings = ( activeWorkState.openReviewerFindings ?? [] ).filter((finding) => finding.reviewer === agentType) @@ -2993,6 +3331,42 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} specialistRepairExit = true break } + // Turn-scoped CYCLE detection. The equality guard above + // already handled an UNCHANGED fingerprint, so reaching + // here means the bytes changed — but this exact workspace + // state was already visited earlier in this turn's repair + // loop, i.e. the repairs are oscillating (A→B→A). Re-firing + // the specialist could only repeat an earlier verdict, so + // fail closed on demonstrated non-progress instead of + // waiting for a guessed repair budget. + if ( + seenSpecialistRepairFingerprints.has( + postRepairFingerprint, + ) + ) { + if (!activeWorkState.lastReviewerGateSkipReason) { + activeWorkState.lastReviewerGateSkipReason = + 'specialist-repair-cycle' + } + activeWorkState.currentPhase = 'blocked' + activeWorkState.nextRequiredAction = `The ${agentType} repair loop returned the workspace to a state it already visited this turn; retrying will not converge. Stop retrying and inspect the finding or handoff.` + activeWorkState.latestWorkSummary = `${agentType} repair loop revisited an earlier workspace fingerprint (repair cycle).` + mutableAgentState.canSuggestFollowups = false + finalResponseGateOpen = false + markActiveWorkStateChanged() + emitGateTelemetry({ + currentPhase: 'blocked', + pendingFileCount: currentPendingGateFiles.length, + pendingFiles: currentPendingGateFiles, + reviewerStatus: 'failed', + validationStatus: 'passed', + repairRound: specialistRepairRound, + skipReason: 'specialist-repair-cycle', + }) + specialistRepairExit = true + break + } + seenSpecialistRepairFingerprints.add(postRepairFingerprint) // Leave agentType in the owed set: it must re-attest // against the post-repair bytes before finalization. activeWorkState.currentPhase = 'awaiting_validation' @@ -3084,6 +3458,26 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} agentType, expectedSnapshotId, ) + // Non-empty only: a passing specialist gate stays silent + // exactly as before when the receipt carries no advisories. + const specialistAdvisories = boundAdvisoryLines( + collectReviewerAdvisories(specialistToolResult), + ) + if (specialistAdvisories.length > 0) { + yield { + toolName: 'add_message', + input: { + role: 'user', + content: [ + 'Advisories (non-blocking; no change required):', + ...specialistAdvisories.map( + (advisory) => `- ${advisory}`, + ), + ].join('\n'), + }, + includeToolCall: false, + } as any + } } activeWorkState.specialistReviewGatesDone = Array.from( new Set([ @@ -3977,6 +4371,11 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} 'Snapshot details (read for file membership; do not echo):', reviewSnapshotDetails, `Validation gate summary: ${validationSummary}`, + // Re-review ledger; empty on round 0 so no stray heading or + // blank line appears in the first review's prompt. + ...buildReviewerRoundLedgerLines( + requiredReviewerAgentType, + ), 'Read large files via read_files windows (bounded block reads) instead of whole-file reads so your accumulated read context stays bounded; still attest to every pending file in reviewedFiles.', '', 'Return the required structured review object. Echo snapshotFingerprint exactly, list every pending changed file in reviewedFiles (including tests), evaluate all review dimensions, and map every user requirement to evidence. Changed tests are first-class review targets and may also be cited as coverage evidence. Use coverage: missing only when no covering test exists in the changed files or elsewhere in the repo.', @@ -4074,12 +4473,45 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} break } activeWorkState.reviewerProtocolRetryCount = 0 + // Attestation tolerates a coverage-complete review whose well-formed + // v3 fingerprint drifted from the expected snapshot; record that drift + // instead of accepting it silently, so a review of possibly-stale file + // content that passed the gate stays auditable. + const reviewerFingerprintDrift = collectReviewerFingerprintDrift( + reviewerToolResult, + reviewSnapshotFingerprint, + ) + if (reviewerFingerprintDrift) { + emitGateTelemetry({ + currentPhase: activeWorkState.currentPhase, + pendingFileCount: reviewableGateScopeFiles.length, + pendingFiles: reviewableGateScopeFiles, + reviewerStatus: 'attestation-fingerprint-drift', + reviewer: requiredReviewerAgentType, + reportedFingerprint: reviewerFingerprintDrift, + expectedFingerprint: reviewSnapshotFingerprint, + }) + } // Parent-owned process RF strings are not repair targets; filter at // the consumer so raw collectReviewerBlockers can still surface them. - // Pass toolResult so evidence-only parent ownership matches finalization. - const collectedBlockers = collectReviewerBlockers(reviewerToolResult).filter( - (blocker: string) => - !isParentOwnedRequirementBlocker(blocker, reviewerToolResult), + // ONE structured walk of the tool-result tree classifies BOTH blocker + // lists (the per-blocker helper re-walks it on every call): every + // hard-rule string is byte-identical to an entry in + // rawCollectedBlockers (asserted by the gate-reviewer parity test), so + // classifying rawCollectedBlockers alone covers rawHardBlockers too. + // Passing the toolResult keeps evidence-only parent ownership in step + // with finalization. + const rawCollectedBlockers = + collectReviewerBlockers(reviewerToolResult) + const rawHardBlockers = + collectReviewerHardBlockers(reviewerToolResult) + const parentOwnedRequirementBlockers = + collectParentOwnedRequirementBlockers( + rawCollectedBlockers, + reviewerToolResult, + ) + const collectedBlockers = rawCollectedBlockers.filter( + (blocker: string) => !parentOwnedRequirementBlockers.has(blocker), ) // Stale-finding suppression: filter out any blocker whose text // matches a previously-condoned finding text (a finding the @@ -4087,29 +4519,87 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} // The reviewer re-derives findings from scratch and may return the // same NON_BLOCKING architectural commentary; without this filter // the loop never converges. Condoned texts are cleared on gate pass. + // T1.3/T1.5: reviewer-supplied finding records, collected ONCE for + // the whole round. The condone filter below needs them to key on the + // reviewer's stable finding id, and the record builds further down + // reuse the same list so a re-raised finding keeps its reviewer id + // instead of being re-minted as a content-hash `RF-...` id. + const reviewerFindingRecords = + collectReviewerFindingRecords(reviewerToolResult) + // T1.5: (verdict class, identity) keys are authoritative. The legacy + // text list is consulted ONLY while no keys exist (state serialized + // before this field), because a legacy entry carries no class and + // trusting it alongside keys would restore the escalation swallow. + const condonedKeys: Set = new Set( + activeWorkState.condonedFindingKeys ?? [], + ) const condonedTexts: Set = new Set( activeWorkState.condonedFindingTexts ?? [], ) + // Gate-derived hard rules (coverage missing, failed dimension, in-scope + // requirement missing/uncertain) are NOT condonable: they are derived by the + // gate from the reviewer's structured fields, not reviewer prose a repair + // round can "address". Letting the condone filter suppress them empties the + // blocker list and reaches the condoned-pass branch below, which sets the + // verdict directly and so bypasses getReviewerFinalizationVerdict — the only + // enforcement of coverage: 'missing' and in-scope requirement gaps. + // Parent-owned requirement gaps are filtered out the same way they are for + // collectedBlockers, so a process-only gap never becomes permanent. + const hardBlockers: Set = new Set( + rawHardBlockers.filter( + (blocker: string) => !parentOwnedRequirementBlockers.has(blocker), + ), + ) const blockers: string[] = collectedBlockers.filter( (blocker: string) => { + // Hard rules are exempt from condoning. The strings are + // byte-identical across the two collectors, so exact membership + // works with no prefix parsing. + if (hardBlockers.has(blocker)) return true + const verdictClass = reviewerVerdictClass(blocker) // Strip the NON_BLOCKING/BLOCKING prefix for text comparison since // the condoned text is the raw finding text without the prefix. - const rawText = blocker.replace( - /^(?:NON_BLOCKING|BLOCKING):\s*/, - '', - ) - return !condonedTexts.has(rawText) && !condonedTexts.has(blocker) + const rawText = stripReviewerVerdictPrefix(blocker) + if (condonedKeys.size > 0) { + return !condonedKeyMatches( + condonedKeys, + verdictClass, + rawText, + correlateReviewerFindingRecord( + blocker, + reviewerFindingRecords, + )?.id, + ) + } + return !legacyCondonedTextMatches(condonedTexts, blocker) }, ) // Record any newly-condoned texts (collected but filtered out) so // they persist across rounds and in the pinned state display. if (blockers.length < collectedBlockers.length) { - const newlyCondoned: string[] = collectedBlockers - .filter((b: string) => !blockers.includes(b)) - .map((b: string) => b.replace(/^(?:NON_BLOCKING|BLOCKING):\s*/, '')) - activeWorkState.condonedFindingTexts = Array.from( - new Set([...(activeWorkState.condonedFindingTexts ?? []), ...newlyCondoned]), + const suppressed: string[] = collectedBlockers.filter( + (b: string) => !blockers.includes(b), + ) + const newlyCondoned: string[] = suppressed.map((b: string) => + stripReviewerVerdictPrefix(b), ) + activeWorkState.condonedFindingTexts = boundCondonedEntries([ + ...(activeWorkState.condonedFindingTexts ?? []), + ...newlyCondoned, + ]) + // Mirror the same suppressions into the class-keyed list so a + // suppression recorded on this path cannot later be re-read as + // class-agnostic. + activeWorkState.condonedFindingKeys = boundCondonedEntries([ + ...(activeWorkState.condonedFindingKeys ?? []), + ...suppressed.flatMap((b: string) => + condonedFindingKeysFor( + reviewerVerdictClass(b), + stripReviewerVerdictPrefix(b), + correlateReviewerFindingRecord(b, reviewerFindingRecords)?.id, + ), + ), + ]) markActiveWorkStateChanged() } // Condoned pass: the condoned filter suppressed every collected @@ -4119,31 +4609,61 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} // reviewer -> repair -> re-review loop converges. The existing // finalization branch below still fires on this verdict. if (collectedBlockers.length > 0 && blockers.length === 0) { - reviewerFinalizationVerdict = 'LOOKS_GOOD' - recordSuccessfulReviewReceipt( - reviewerToolResult, - requiredReviewerAgentType, - reviewSnapshotFingerprint, - ) - // Clear the now-condoned blocker strings so the pinned state and - // finalization no longer surface them as open. mergeReviewerFindings - // is not invoked on this path (no surviving blockers), so without - // this the first review's blocker strings would persist and the - // gate would look like it still has open feedback even though the - // findings were condoned. Only blockers whose stripped text is in - // condonedFindingTexts are removed; any unrelated blocker is kept. - const condonedSet: Set = new Set( - activeWorkState.condonedFindingTexts ?? [], - ) - activeWorkState.openReviewerBlockers = ( - activeWorkState.openReviewerBlockers ?? [] - ).filter( - (blocker: string) => - !condonedSet.has( - blocker.replace(/^(?:NON_BLOCKING|BLOCKING):\s*/, ''), - ), - ) - markActiveWorkStateChanged() + // The filter above exempts hard rules, so an empty surviving set means no + // hard rule fired. Re-assert it here from the receipt's own hard-rule set + // so a future filter change cannot silently restore the bypass: the + // condone path may suppress blockers but must never be verdict authority + // over coverage/requirement rules. When the receipt produced a hard rule + // it stays in `blockers` and drives the normal repair path, so nothing is + // credited and openReviewerBlockers is not cleared. + const receiptHasHardRule = hardBlockers.size > 0 + if (!receiptHasHardRule) { + reviewerFinalizationVerdict = 'LOOKS_GOOD' + recordSuccessfulReviewReceipt( + reviewerToolResult, + requiredReviewerAgentType, + reviewSnapshotFingerprint, + ) + // Clear the now-condoned blocker strings so the pinned state and + // finalization no longer surface them as open. mergeReviewerFindings + // is not invoked on this path (no surviving blockers), so without + // this the first review's blocker strings would persist and the + // gate would look like it still has open feedback even though the + // findings were condoned. Keyed on (verdict class, identity) like + // the condone filter above — NOT stripped text alone: this list + // retains other families' blockers (mergeReviewerFindings keeps + // them), so a text-only match could drop a security-reviewer + // `BLOCKING: ` blocker that was never condoned. The + // sets are rebuilt here because the suppression recording above + // just appended to both lists. Legacy text fallback only while no + // keys exist, exactly as above. + const cleanupCondonedKeys: Set = new Set( + activeWorkState.condonedFindingKeys ?? [], + ) + const cleanupCondonedTexts: Set = new Set( + activeWorkState.condonedFindingTexts ?? [], + ) + activeWorkState.openReviewerBlockers = ( + activeWorkState.openReviewerBlockers ?? [] + ).filter((blocker: string) => { + if (cleanupCondonedKeys.size > 0) { + return !condonedKeyMatches( + cleanupCondonedKeys, + reviewerVerdictClass(blocker), + stripReviewerVerdictPrefix(blocker), + correlateReviewerFindingRecord( + blocker, + reviewerFindingRecords, + )?.id, + ) + } + return !legacyCondonedTextMatches( + cleanupCondonedTexts, + blocker, + ) + }) + markActiveWorkStateChanged() + } } if (blockers.length > 0) { // Coverage-style findings (a missing/uncertain test-coverage gap) @@ -4158,9 +4678,8 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} const repairAgentLabel = allCoverageFindings ? 'Test-writer' : 'Repair-editor' - activeWorkState.reviewerRepairRoundCount = Number( - activeWorkState.reviewerRepairRoundCount ?? 0, - ) + 1 + activeWorkState.reviewerRepairRoundCount = + Number(activeWorkState.reviewerRepairRoundCount ?? 0) + 1 // Optional hard round cap for the reviewer -> repair -> re-review // loop when createBase2/env set a finite maxReviewerRepairRounds. // Default is unlimited; NON_BLOCKING findings still burn the counter @@ -4176,7 +4695,9 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} // records are built below on the non-exhausted path). const budgetExhaustedRecords = blockers.map( (text: string, index: number) => ({ - id: buildReviewerFindingId(text, index), + id: + correlateReviewerFindingRecord(text, reviewerFindingRecords) + ?.id ?? buildReviewerFindingId(text, index), gateId: `${requiredReviewerAgentType}:${reviewSnapshotFingerprint}`, text, status: 'open' as const, @@ -4215,10 +4736,84 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} } as any break } + // New-vs-carried is derived from ALREADY-PERSISTED gate state: + // openReviewerFindings has rehydration wired, and reviewer round + // counts reset only on gate pass, so a round is expected to span a + // turn boundary. A locals-based comparison would report every + // finding as new exactly when the metric matters. Read BEFORE + // mergeReviewerFindings overwrites the ledger, filtered to this + // reviewer so another family's open findings cannot inflate carried, + // and with the condone filter's exact prefix regex so carried/new + // cannot disagree with condoning. + const priorOwnFindingTexts = new Set( + (activeWorkState.openReviewerFindings ?? []) + .filter( + (finding) => finding.reviewer === requiredReviewerAgentType, + ) + .map((finding) => stripReviewerVerdictPrefix(finding.text)), + ) + const carriedFindingCount = blockers.filter((blocker: string) => + priorOwnFindingTexts.has(stripReviewerVerdictPrefix(blocker)), + ).length + // SHADOW MODE — observation only: log what a severity threshold + // WOULD decide without acting on it. Nothing here may branch or + // touch blockers, the verdict, the repair spawn, or the phase + // (thresholding is evidence-gated Tier 2 work). Severity metadata + // does not exist yet, so every finding sits in one `unlabeled` + // bucket; when severity lands, replace this single predicate with + // per-severity buckets over the same list. Gate-derived hard rules + // are never suppressible, so reuse the hardBlockers set above. + const suppressibleFindings = blockers.filter( + (blocker: string) => + blocker.startsWith('NON_BLOCKING:') && + !hardBlockers.has(blocker), + ) + // T1.5 evidence: blockers whose identity WAS condoned, but under + // the other verdict class. These are exactly the escalations the + // old text-only key swallowed; probing only the other class (never + // the `*` wildcard) keeps class-agnostic legacy entries out. + const escalatedFindings = blockers.filter((blocker: string) => { + const verdictClass = reviewerVerdictClass(blocker) + if (verdictClass === '*') return false + const otherClass = + verdictClass === 'BLOCKING' ? 'NON_BLOCKING' : 'BLOCKING' + return condonedFindingKeysFor( + otherClass, + stripReviewerVerdictPrefix(blocker), + correlateReviewerFindingRecord(blocker, reviewerFindingRecords) + ?.id, + ).some((key) => condonedKeys.has(key)) + }) + // Emitted only on this non-exhausted path; an exhausted round is + // already covered by its own skipReason telemetry. + emitGateTelemetry({ + currentPhase: activeWorkState.currentPhase, + reviewerStatus: 'round-findings', + reviewer: requiredReviewerAgentType, + repairRound: Number(activeWorkState.reviewerRepairRoundCount ?? 0), + findingCount: blockers.length, + rawFindingCount: rawCollectedBlockers.length, + newFindingCount: blockers.length - carriedFindingCount, + carriedFindingCount, + pendingFileCount: reviewableGateScopeFiles.length, + suppressibleFindingCount: suppressibleFindings.length, + escalatedFindingCount: escalatedFindings.length, + wouldPassAtThisRound: + suppressibleFindings.length === blockers.length && + blockers.length > 0, + }) const codeReviewerFindingRecords = blockers.map( (text: string, index: number) => ({ - id: buildReviewerFindingId(text, index), + id: + correlateReviewerFindingRecord(text, reviewerFindingRecords) + ?.id ?? buildReviewerFindingId(text, index), gateId: `${requiredReviewerAgentType}:${reviewSnapshotFingerprint}`, + // Deliberately the blocker string, NOT `record?.text` (which the + // security path uses): the condone filter and the carried/new + // derivation above both key on this text minus its + // NON_BLOCKING/BLOCKING prefix, and `record.text` omits the + // `[id] ` segment the blocker carries. Swapping it would make + // condoning and carried-count disagree. Only the id is adopted. text, status: 'open' as const, files: Array.from(pendingGateFiles), @@ -4242,6 +4837,12 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} activeWorkState.currentPhase = 'blocked' activeWorkState.latestWorkSummary = `Reviewer feedback is open for pending files: ${Array.from(pendingGateFiles).join(', ') || '(unknown files)'}` markActiveWorkStateChanged() + // Read through the shared collector, NOT a receipt: + // recordSuccessfulReviewReceipt only runs once a finalization + // verdict exists, so this round has written none yet. + const roundAdvisories = boundAdvisoryLines( + collectReviewerAdvisories(reviewerToolResult), + ) yield { toolName: 'add_message', input: { @@ -4250,6 +4851,13 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} `Reviewer gate: ${reviewerAgentType} returned blocking feedback. The harness will send these exact findings to ${allCoverageFindings ? 'test-writer' : 'repair-editor'}:`, '', ...blockers, + ...(roundAdvisories.length > 0 + ? [ + '', + 'Advisories (non-blocking; no change required):', + ...roundAdvisories.map((advisory) => `- ${advisory}`), + ] + : []), '', 'These findings remain open until targeted validation and a fresh matching reviewer pass clear them.', ].join('\n'), @@ -4269,6 +4877,10 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} validationSummary, ), ) + // Recording the BASELINE (not just post-repair states) is what + // makes A→B→A trip: round 1 records A, post B is new; round 2 + // records B, post A is already in the set. + seenReviewerRepairFingerprints.add(preReviewerRepairFingerprint) const reviewerRepairSessionId = activeWorkState.repairSessionId ?? `review-repair-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` @@ -4522,21 +5134,57 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} // repair-editor reported as addressed. If the fresh re-review // returns identical text, the blocker-elevation filter above // will suppress it as condoned, breaking the infinite loop. + // + // Condone credit is ORCHESTRATOR-OWNED evidence, not reviewer/repair + // self-report: a listed finding id is only condoned when the receipt + // claims completion AND actually changed at least one file. A rejected + // claim leaves the finding open, so the next re-review re-elevates it + // instead of silently converging on an unrepaired workspace. + // reviewerRepairHasProgress is the same non-empty changedFiles[].path + // predicate the loop-continuation guard above uses (that guard's break + // condition is deliberately unchanged — it governs whether the loop + // proceeds, which is a separate concern). const addressedFindings = (activeWorkState.openReviewerFindings ?? []) .filter((finding) => reviewerRepairReceipt!.findingsAddressed.includes(finding.id), ) - if (addressedFindings.length > 0) { - const addressedTexts = addressedFindings.map( - (finding) => finding.text.replace(/^(?:NON_BLOCKING|BLOCKING):\s*/, ''), - ) - activeWorkState.condonedFindingTexts = Array.from( - new Set([ - ...(activeWorkState.condonedFindingTexts ?? []), - ...addressedTexts, - ]), + const condoneEvidenceIsSufficient = + reviewerRepairReceipt!.status === 'completed' && + reviewerRepairHasProgress + if (addressedFindings.length > 0 && condoneEvidenceIsSufficient) { + const addressedTexts = addressedFindings.map((finding) => + stripReviewerVerdictPrefix(finding.text), ) + activeWorkState.condonedFindingTexts = boundCondonedEntries([ + ...(activeWorkState.condonedFindingTexts ?? []), + ...addressedTexts, + ]) + // T1.5: record the (verdict class, identity) key alongside the + // legacy text so a later review that RE-RAISES the same text at a + // higher verdict class is not condoned by it. The class comes from + // the stored finding text, which the code-reviewer path keeps + // prefixed for exactly this reason. + activeWorkState.condonedFindingKeys = boundCondonedEntries([ + ...(activeWorkState.condonedFindingKeys ?? []), + ...addressedFindings.flatMap((finding) => + condonedFindingKeysFor( + reviewerVerdictClass(finding.text), + stripReviewerVerdictPrefix(finding.text), + finding.id, + ), + ), + ]) markActiveWorkStateChanged() + } else if (addressedFindings.length > 0) { + emitGateTelemetry({ + currentPhase: activeWorkState.currentPhase, + reviewerStatus: 'repair', + condoneClaimsRejected: addressedFindings.length, + condoneRejectReason: + reviewerRepairReceipt!.status !== 'completed' + ? 'receipt-not-completed' + : 'no-changed-files', + }) } const reviewerRepairStatus = yield { toolName: 'git_status', @@ -4573,6 +5221,42 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} markActiveWorkStateChanged() break } + // Turn-scoped CYCLE detection. The equality guard above already + // handled an UNCHANGED fingerprint, so reaching here means the + // bytes changed — but this exact workspace state was already + // visited earlier in this turn's repair loop, i.e. the repairs are + // oscillating (A→B→A). A fresh reviewer pass could only walk the + // same ring again, so fail closed on demonstrated non-progress + // instead of waiting for a guessed repair budget. NOTE: this + // fingerprint folds in validationSummary, so it is not a pure file + // content hash; a summary that reverts along with the bytes is + // still a genuine cycle and blocking is correct. + if ( + seenReviewerRepairFingerprints.has(repairedSnapshotFingerprint) + ) { + activeWorkState.lastReviewerGateSkipReason = + 'reviewer-repair-cycle' + activeWorkState.currentPhase = 'blocked' + activeWorkState.nextRequiredAction = `${repairAgentLabel} returned the workspace to a state it already visited this turn while addressing the reviewer findings; retrying will not converge. Stop retrying and inspect the finding or handoff.` + activeWorkState.latestWorkSummary = + 'Reviewer repair loop revisited an earlier workspace fingerprint (repair cycle).' + mutableAgentState.canSuggestFollowups = false + finalResponseGateOpen = false + markActiveWorkStateChanged() + emitGateTelemetry({ + currentPhase: 'blocked', + pendingFileCount: pendingGateFiles.size, + pendingFiles: Array.from(pendingGateFiles), + reviewerStatus: 'failed', + validationStatus: 'passed', + repairRound: Number( + activeWorkState.reviewerRepairRoundCount ?? 0, + ), + skipReason: 'reviewer-repair-cycle', + }) + break + } + seenReviewerRepairFingerprints.add(repairedSnapshotFingerprint) const reVerify = yield { toolName: 'run_file_change_hooks', input: { files: Array.from(pendingGateFiles) }, @@ -4815,17 +5499,20 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} } let activeWorkStateChanged = false if (passedPendingFiles.length > 0 && reviewerFinalizationVerdict) { - // Record the transient pass line, then immediately reset the - // durable progress line so a stale 'passed'/mid-gate line cannot - // persist into the next edit cycle. - setGateProgress('gate: passed') + // No pinned emission happens between here and the end of the gate, + // so a transient 'gate: passed' line could never be rendered. + // Reset the durable progress line directly (a stale mid-gate line + // must not persist into the next edit cycle) and invalidate the + // pinned cache once. activeWorkState.gateProgressLine = '' + markActiveWorkStateChanged() activeWorkState.openReviewerBlockers = [] activeWorkState.openReviewerFindings = [] // Clear condoned finding texts as well so they cannot leak into // the next edit cycle; they only suppress re-elevation within the // reviewer repair loop that produced them. activeWorkState.condonedFindingTexts = [] + activeWorkState.condonedFindingKeys = [] // Clear the owed SET, not just the legacy scalar: a leftover entry // would survive the pass and force a phantom re-attestation (or // resurrect the scalar via addOwedReviewer/clearOwedReviewer) on the @@ -4897,6 +5584,24 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} passedPendingFiles.length > 0 ? `reviewer verdict ${passVerdict}; ${validationHooksSkipped ? validationSummary : 'validation hooks ran'}; pending files: ${passedPendingFiles.join(', ')}` : `no edited files were detected; reviewer verdict ${passVerdict || 'n/a'}; hooks ran=${!validationHooksSkipped}` + // Advisories recorded on the receipt for this gate. They never + // entered openReviewerBlockers (no collector reads them), so the + // pass block is the only surface that shows them to the user. Read + // from the durable receipt just written rather than adding state. + // + // BOUND to THIS gate's receipt id (`${reviewer}:${fingerprint}`, the + // gateId recordSuccessfulReviewReceipt writes). reviewReceipts is + // DURABLE and keeps up to 24 entries across turns and reviewer + // families, so reading the LAST receipt could surface another + // family's or an earlier turn's advisories as this gate's (and count + // them in advisoryCount telemetry). A gate that finalized without a + // fresh receipt for this snapshot (durable/conversation pass reuse or + // an authorized bypass) matches none and shows no advisories. + const passReceiptGateId = `${requiredReviewerAgentType}:${reviewSnapshotFingerprint}` + const passAdvisories = + (activeWorkState.reviewReceipts ?? []).find( + (receipt) => receipt.gateId === passReceiptGateId, + )?.advisories ?? [] emitGateTelemetry({ currentPhase: 'final_response_allowed', pendingFileCount: passedPendingFiles.length, @@ -4906,6 +5611,7 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} validationStatus: validationHooksSkipped ? 'skipped' : 'passed', reviewerVerdict: passVerdict, hooksRan: !validationHooksSkipped, + advisoryCount: passAdvisories.length, }) yield { toolName: 'add_message', @@ -4925,6 +5631,8 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} 'validation/reviewer', 'passed', passDetails, + undefined, + passAdvisories, ), ].join('\n'), }, @@ -4994,17 +5702,62 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} // Durable one-line mid-turn gate-progress note. Rendered by // buildPinnedActiveWorkMessage as a "Gate progress:" line inside the - // existing pinned active-work message — no new yield/add_message is - // introduced. Dedupes so repeated identical updates do not churn the - // pinned state. Self-contained inline helper (handleSteps is serialized - // via .toString() + new Function(...), so it must not reference - // module-scope imports). + // pinned active-work message. When that line is the only change since + // the last emitted pinned block, the top-of-loop emitter yields a + // delta-only add_message carrying just `Gate progress: ` instead + // of the full block. Most writes go through this helper (it dedupes so + // repeated identical updates do not churn the pinned state); the + // gate-pass path resets the field directly because that reset is never + // rendered. Self-contained inline helper (handleSteps is serialized via + // .toString() + new Function(...), so it must not reference module-scope + // imports). function setGateProgress(line: string): void { if (activeWorkState.gateProgressLine === line) return activeWorkState.gateProgressLine = line markActiveWorkStateChanged() } + // T1.2(c) re-review ledger for the reviewer spawn packet. The reviewer is + // stateless across repair rounds, so without this it re-derives every + // finding from scratch instead of verifying the ones a repair round + // already reported as addressed. Returns [] on round 0 so the first + // review's prompt is byte-identical to the pre-ledger surface, and reads + // ONLY already-persisted state (openReviewerFindings / + // reviewerRepairRoundCount) — no new gate state. + // + // Findings are filtered to the spawned reviewer's own family: + // openReviewerFindings can hold security-reviewer and specialist records, + // and asking the code-reviewer to verify those would take it outside its + // scope. `finding.text` is rendered VERBATIM (keeping the + // NON_BLOCKING:/BLOCKING: prefix and any `[id] ` segment) because that is + // exactly the string the condone matcher compares a re-raise against. + // Inline because handleSteps is serialized via .toString() + + // new Function(...), so it must not reference module-scope imports. + function buildReviewerRoundLedgerLines(reviewer: string): string[] { + const repairRound = Number( + activeWorkState.reviewerRepairRoundCount ?? 0, + ) + if (!(repairRound > 0)) return [] + const lines = [`Repair round: ${repairRound}. This is a re-review.`] + const ownFindings = (activeWorkState.openReviewerFindings ?? []).filter( + (finding) => finding.reviewer === reviewer, + ) + if (ownFindings.length === 0) return lines + lines.push( + 'Findings raised earlier and reported addressed are listed below. Verify each is genuinely fixed and cite the line that fixes it. If a fix is wrong or incomplete, re-raise the finding with its ORIGINAL text repeated VERBATIM and put your reason on a separate line: the gate matches re-raises by exact text (and by stable finding id when you supplied one), so a reworded re-raise is treated as a brand-new finding and the repair loop cannot converge.', + ) + const shown = ownFindings.slice(0, 12) + for (const finding of shown) { + lines.push(` - ${finding.text}`) + } + if (ownFindings.length > shown.length) { + lines.push( + ` - (+${ownFindings.length - shown.length} more earlier findings omitted)`, + ) + } + return lines + } + // Inline helpers for gate-state telemetry/diagnostics. Kept inside // handleSteps because handleSteps is serialized via .toString() and // reconstructed with new Function(...), so module-scope closures are @@ -5015,9 +5768,15 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} status: 'passed' | 'failed' | 'skipped', details: string, repairRound?: number, + advisories?: string[], ): string { + // Order matters: collapse whitespace FIRST so tabs/newlines/CRs become + // spaces, then strip the remaining C0/DEL control bytes (ESC, NUL, + // \x7f, ...). Reviewer-authored text renders verbatim in the CLI's + // renderer, where a surviving ESC could corrupt or spoof output. const normalizedDetails = String(details ?? '') .replace(/\s+/g, ' ') + .replace(/[\x00-\x1f\x7f]/g, '') .trim() const payload: { gate: string @@ -5025,6 +5784,7 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} details: string repairRound?: number maxRepairRounds?: number + advisories?: string[] } = { gate, status, details: normalizedDetails } if ( typeof repairRound === 'number' && @@ -5038,36 +5798,113 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} payload.maxRepairRounds = MAX_REPAIR_ROUNDS } } - return `${JSON.stringify(payload)}` + // Reviewer advisories: non-blocking observations that no blocker + // collector reads. Bounded through the shared helper below so this + // block and the reviewer/security/specialist add_message surfaces stay + // byte-identical. + const boundedAdvisories = boundAdvisoryLines(advisories) + if (boundedAdvisories.length > 0) { + payload.advisories = boundedAdvisories + } + // Delimiter safety: this payload carries reviewer-authored text + // (`details`, `advisories`), so a literal `` inside it + // would terminate this tag-delimited block early and every non-greedy + // downstream reader (the CLI's parseGateStateBlock and this file's own + // extractGateStateBlocksFromMessage) would silently drop the record. + // `\/` is a legal JSON string escape, so escaping every `${JSON.stringify(payload).replace(/<\//g, '<\\/')}` + } + + // Shared by the emitter and the reviewer/security/specialist + // add_message paths so every advisory surface applies identical bounds and + // control-byte stripping. Collapse whitespace FIRST (so tabs/newlines become + // spaces rather than vanishing), then strip C0/DEL, then cap length, so the + // 240-char bound describes the text actually shown. + // + // `advisories` is an optional additive reviewer output field that only + // `code-reviewer` declares today, so the security/specialist families read + // back as an empty list and stay silent until one of them opts in. + function boundAdvisoryLines(advisories?: string[]): string[] { + return (advisories ?? []) + .map((advisory) => + String(advisory ?? '') + .replace(/\s+/g, ' ') + .replace(/[\x00-\x1f\x7f]/g, '') + .trim(), + ) + .filter((advisory) => advisory.length > 0) + .slice(0, 8) + .map((advisory) => + advisory.length > 240 + ? `${advisory.slice(0, 237).trimEnd()}...` + : advisory, + ) } function emitGateTelemetry(payload: Record): void { try { const phase = payload.currentPhase - const transition = (params as any)?.orchestrationControlPlane - ?.transitionBase2Gate + // Hoisted once: this is serialized handleSteps code, so repeating the + // cast per control-plane member is pure duplication. + const controlPlane = (params as any)?.orchestrationControlPlane + const transition = controlPlane?.transitionBase2Gate if (typeof phase === 'string' && typeof transition === 'function') { - mutableAgentState.workflowStates ??= {} - mutableAgentState.workflowStates['base2-gate-v1'] = transition({ - current: mutableAgentState.workflowStates['base2-gate-v1'], - phase, - }) - } - if ( - typeof console !== 'object' || - console === null || - typeof (console as { info?: unknown }).info !== 'function' - ) { - return + // Own try/catch: base2GateWorkflowV1 THROWS on an illegal + // transition (e.g. 'repair_loop' from the default 'idle'). Sharing + // the outer catch would drop both the durable sink line and the + // console.info line for exactly the event most worth recording, so + // a rejected phase transition must not suppress telemetry. + try { + mutableAgentState.workflowStates ??= {} + mutableAgentState.workflowStates['base2-gate-v1'] = transition({ + current: mutableAgentState.workflowStates['base2-gate-v1'], + phase, + }) + } catch { + // Leave the previous workflow state in place and still emit below. + } } - const safePayload: Record = { event: 'base2.gate' } + // Built BEFORE the console emit on purpose: both channels must share + // one payload object, and a runtime without console.info must still + // reach the sink. + const safePayload: Record = {} for (const [key, value] of Object.entries(payload)) { - if (value === undefined) continue + // `event` is skipped EXPLICITLY, so the discard of a + // payload-supplied one is visible where it happens rather than + // implied by the assignment below. Unlike the sink's + // `droppedPayloadKeys`, this discard is intentionally unreported: + // every call site here is in-process and supplies no `event`. + if (key === 'event' || value === undefined) continue safePayload[key] = value } - ;(console as { info: (...args: unknown[]) => void }).info( - JSON.stringify(safePayload), - ) + // The sink's discriminator, owned by this emitter the way + // gate-telemetry.ts owns its authoritative `recordedAt`. + safePayload.event = 'base2.gate' + // console.info FIRST, then the durable sink: the sink is injected by + // the runtime (base2's handleSteps is serialized and cannot import + // it), so it is absent on older runtimes and in tests that pass no + // control plane. Emitting the pre-existing log channel first means a + // throwing recorder falls into the outer catch with the console line + // already written, so no separate try/catch around `record` is needed + // to preserve both channels. + // + // Its OWN try/catch so the converse holds as well: a payload that is + // not JSON-serializable (circular reference, BigInt) or a host console + // that throws costs ONLY the console line, and the durable sink below + // still receives `safePayload`. + try { + if (typeof console?.info === 'function') { + console.info(JSON.stringify(safePayload)) + } + } catch { + // Console channel only; the durable sink emit below still runs. + } + const record = controlPlane?.recordGateTelemetry + if (typeof record === 'function') { + record(safePayload) + } } catch { // Telemetry must never throw or block the loop. } @@ -5176,6 +6013,117 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} return stored === fingerprint } + // T1.5: condoning is keyed on (verdict class, finding identity) rather + // than finding text alone. Text-only keying strips the + // NON_BLOCKING/BLOCKING prefix before comparing, so a nit condoned as + // NON_BLOCKING was silently swallowed when a later review re-raised the + // SAME text as BLOCKING — an escalation is new information and must + // reopen the gate. Inline because handleSteps is serialized via + // .toString() + new Function(...). + // + // `*` is the class for a text that carries no verdict prefix. Every gate + // path now stores the PREFIXED blocker string on its finding records + // (security/specialist included), so `*` only arises for legacy + // serialized state; `condonedKeyMatches` deliberately matches `*` against + // `*` ONLY, because a class-agnostic entry that matched any later class is + // exactly the escalation swallow this keying exists to close. The single + // cross-class allowance is DE-ESCALATION: a stored BLOCKING key also + // condones a NON_BLOCKING re-raise of the same identity (see + // `condonedKeyMatches`), never the reverse. + function reviewerVerdictClass(text: string): string { + if (text.startsWith('BLOCKING:')) return 'BLOCKING' + if (text.startsWith('NON_BLOCKING:')) return 'NON_BLOCKING' + return '*' + } + + // Single strip site for the NON_BLOCKING/BLOCKING verdict prefix. Every + // condone/telemetry decision keys on (verdict class, stripped text), so + // the strip must stay in lockstep with `reviewerVerdictClass`; the regex + // was previously duplicated at ~6 call sites and could drift from it. + function stripReviewerVerdictPrefix(text: string): string { + return text.replace(/^(?:NON_BLOCKING|BLOCKING):\s*/, '') + } + + // Minted ids (buildReviewerFindingId) embed the blocker's POSITION in the + // round's list, so identical text at a different index yields a different + // id: they are not a durable identity and must not be keyed on. Only a + // reviewer-supplied id (T1.3 object findings, contractually stable across + // rounds) is. The text key carries convergence for the rest. + function isMintedReviewerFindingId(id: string): boolean { + return /^RF-\d+-[0-9a-f]{8}$/.test(id) + } + + function condonedFindingKeysFor( + verdictClass: string, + strippedText: string, + id?: string, + ): string[] { + const keys = [`${verdictClass}::text:${strippedText}`] + if (id && !isMintedReviewerFindingId(id)) { + keys.push(`${verdictClass}::id:${id}`) + } + return keys + } + + function condonedKeyMatches( + condonedKeys: Set, + verdictClass: string, + strippedText: string, + id?: string, + ): boolean { + // Same-class match first: a `*` (prefix-less, legacy) entry condones + // only another `*` finding, and a NON_BLOCKING entry never condones a + // BLOCKING re-raise of the same identity — an escalation is new + // information and must reopen the gate. + if ( + condonedFindingKeysFor(verdictClass, strippedText, id).some((key) => + condonedKeys.has(key), + ) + ) { + return true + } + // DE-ESCALATION, accepted ONE-DIRECTIONALLY: a finding already reported + // as addressed at BLOCKING and re-raised as a NON_BLOCKING nit carries + // no new information. Without this, blockers stay non-empty, another + // repair-editor is spawned, and the already-applied repair trips the + // no-progress guard, parking the gate in 'blocked' instead of + // converging. The reverse direction is NOT accepted: only the same-class + // check above can condone a BLOCKING re-raise. + if (verdictClass === 'NON_BLOCKING') { + return condonedFindingKeysFor('BLOCKING', strippedText, id).some( + (key) => condonedKeys.has(key), + ) + } + return false + } + + // Single owner of the pre-T1.5 legacy text fallback, consulted only while + // no (verdict class, identity) keys exist. The gate blocker filter, the + // condoned-pass cleanup, and mergeReviewerFindings.isCondoned all route + // through this helper so state serialized before `condonedFindingKeys` + // existed behaves IDENTICALLY at all three sites: a legacy entry may have + // been recorded either stripped or as the raw prefixed blocker, so both + // shapes match. + function legacyCondonedTextMatches( + condonedTexts: Set, + text: string, + ): boolean { + return ( + condonedTexts.has(stripReviewerVerdictPrefix(text)) || + condonedTexts.has(text) + ) + } + + // Bound the durable condone lists the way `reviewReceipts` is bounded. + // Each round can append up to 2 keys per suppressed finding plus one entry + // per repair-addressed finding, and both lists reset only on gate pass, so + // with default-unlimited repair rounds they would otherwise grow without + // bound inside serialized base2ActiveWork. Keeping the MOST RECENT entries + // preserves convergence for the findings the current rounds re-raise. + function boundCondonedEntries(values: string[]): string[] { + return Array.from(new Set(values)).slice(-200) + } + // Merge one reviewer's blocking output into the open finding ledger // WITHOUT clobbering another reviewer's still-open findings. Blocker // strings carry no reviewer field, so the retained blocker set is the @@ -5189,18 +6137,34 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} records: NonNullable, blockers: string[], ): void { - // Condoned-status override: an incoming record whose stripped finding - // text was already reported as addressed by a prior repair round is - // recorded as 'condoned' instead of 'open' so it neither blocks - // finalization nor re-triggers a repair spawn. Applies uniformly to - // every caller since it reads the path-agnostic condonedFindingTexts. + // Condoned-status override: an incoming record whose finding identity + // was already reported as addressed by a prior repair round is recorded + // as 'condoned' instead of 'open' so it neither blocks finalization nor + // re-triggers a repair spawn. Applies uniformly to every caller. Keyed + // on (verdict class, identity) like the gate's own condone filter, so a + // re-raised-at-HIGHER-class finding stays 'open' while a de-escalated + // re-raise stays 'condoned'; the legacy text list is consulted only + // while no keys exist (pre-T1.5 serialized state), through the shared + // `legacyCondonedTextMatches` helper so all three call sites agree. + const condonedKeys: Set = new Set( + activeWorkState.condonedFindingKeys ?? [], + ) const condonedTexts: Set = new Set( activeWorkState.condonedFindingTexts ?? [], ) + const isCondoned = (record: { text: string; id: string }): boolean => { + if (condonedKeys.size > 0) { + return condonedKeyMatches( + condonedKeys, + reviewerVerdictClass(record.text), + stripReviewerVerdictPrefix(record.text), + record.id, + ) + } + return legacyCondonedTextMatches(condonedTexts, record.text) + } const mergedRecords = records.map((record) => - condonedTexts.has( - record.text.replace(/^(?:NON_BLOCKING|BLOCKING):\s*/, ''), - ) + isCondoned(record) ? { ...record, status: 'condoned' as const } : record, ) @@ -5366,6 +6330,7 @@ type ReviewerCoverage = 'covered' | 'missing' | 'n/a'; type StructuredReviewerOutput = { verdict: ReviewerStructuredVerdict; findings: string[]; + advisories?: string[]; coverage?: ReviewerCoverage; dimensions?: Record; requirementCoverage?: Array<{ @@ -5389,7 +6354,144 @@ type ReviewerFindingRecord = { }; function collectReviewerFindingRecords(toolResult: unknown): ReviewerFindingRecord[] { - return collectStructuredReviewerOutputs(toolResult).flatMap((entry) => entry.findingRecords ?? []); + // Nested spawn/set_output wrappers can surface the same receipt twice; keep + // the FIRST record per id so `correlateReviewerFindingRecord` never sees + // duplicates (`collectReviewerBlockers` de-dupes its strings the same way). + const seen = new Set(); + const records: ReviewerFindingRecord[] = []; + for (const entry of collectStructuredReviewerOutputs(toolResult)) { + for (const record of entry.findingRecords ?? []) { + if (seen.has(record.id)) + continue; + seen.add(record.id); + records.push(record); + } + } + return records; +} + +/** + * Advisory observations from the LAST `schemaVersion`-shaped structured reviewer + * entry (the receipt the gate records). Recorded and displayed only: no blocker + * collector reads them, so an advisory never blocks and never re-enters the + * repair loop. + * + * Entry selection matches `resolveReviewerAttestation`'s shaped narrowing for + * the same reason: a reviewer that QUOTES the documented example receipt AFTER + * its real one must not have the example's advisories persisted and displayed as + * this review's. With no shaped entry at all the LAST entry is read verbatim, + * matching that helper's fallback. + * + * Non-test consumer: base2's `recordSuccessfulReviewReceipt` builds the durable + * receipt's `advisories` with this collector at runtime through the + * `` copy emitted from this module, so the PERSISTED + * advisory semantics (last shaped entry, trimmed, exact-duplicate-free) are the + * tested ones instead of a second inline read of `result.advisories`. + * + * Reviewer-family symmetry: `advisories` is an OPTIONAL additive output field. + * Only code-reviewer declares it today; a family that omits it (security + * reviewer, routed specialists) reads back here as no advisories, so no other + * reviewer schema has to migrate and an older receipt keeps round-tripping. + * + * Advisory text is returned verbatim (trimmed only). Delimiter safety lives at + * the emitter: base2's `formatGateStateBlock` escapes `` block, so + * an advisory quoting the literal `` cannot truncate that block for + * the CLI renderer or base2's own conversation-gate-reuse reader. + */ +function collectReviewerAdvisories(toolResult: unknown): string[] { + const structured = collectStructuredReviewerOutputs(toolResult); + const shaped = structured.filter((entry) => entry.schemaVersion !== undefined); + const candidates = shaped.length > 0 ? shaped : structured; + const last = candidates[candidates.length - 1]; + return dedupeExactStringsPreserveOrder(last?.advisories ?? []); +} + +/** True when `value` is a canonical attestable `v3:<64 hex>` snapshot fingerprint. */ +function isAttestableV3Fingerprint(value: unknown): value is string { + return typeof value === 'string' && isAttestableSnapshotFingerprint(value); +} + +/** The attestation fields `collectReviewerAttestationIssues` reads. */ +type ResolvedReviewerAttestation = { + schemaVersion?: number; + snapshotFingerprint?: string; + reviewedFiles: string[]; +}; + +/** + * The attestation a receipt is read from, resolved ORDER-INDEPENDENTLY across + * its `schemaVersion`-carrying (`shaped`) entries, so a quoted example on + * EITHER side of the real receipt cannot steal the attestation and turn a + * well-behaved review into spurious fingerprint/coverage blockers (a terminal + * gate failure after base2's single `reviewer-protocol-attestation-failed` + * retry). `collectReviewerAttestationIssues` already requires the shaped + * verdicts to agree, so they describe ONE review. + * + * CANONICAL WHY for the entry selection; call sites carry pointers only. + * + * Resolution is PER FIELD, so the result is a COMPOSITE rather than one entry: + * `reviewedFiles` is the UNION of the shaped entries, `snapshotFingerprint` is + * the entry reporting `expectedFingerprint` else the first reporting an + * attestable v3 fingerprint else undefined, and `schemaVersion` is 1 only when + * EVERY shaped entry reports 1 (otherwise the first non-conforming version, so + * the caller's `!== 1` check rejects the whole receipt). + * + * ACCEPTED LOOSENING (pinned in agents/__tests__/gate-reviewer.test.ts): the + * spliced fields let a quoted example entry supply the fingerprint for a real + * entry that reported none, and — because the union is NOT restricted to the + * entry that contributed the credited fingerprint — a quoted example whose + * `reviewedFiles` path COLLIDES with a real pending path (the documented + * example literally shows `reviewedFiles: ["src/a.ts"]`) credits coverage the + * real entry never attested. Narrowing the union would not close the + * fingerprint half and WOULD reject the deletions-only receipt, which + * legitimately attests with an empty `reviewedFiles`. So the guarantee is the + * weaker one: a pending file NO entry reported at all still blocks. + * + * With no shaped entry at all the LAST entry is read verbatim, so a receipt that + * never attested still fails closed on the caller's schemaVersion check. + * + * CALLER PRECONDITION: `structured` is non-empty. + */ +function resolveReviewerAttestation(structured: StructuredReviewerOutput[], expectedFingerprint: string): ResolvedReviewerAttestation { + const shaped = structured.filter((entry) => entry.schemaVersion !== undefined); + if (shaped.length === 0) { + const last = structured[structured.length - 1]; + return { + schemaVersion: last.schemaVersion, + snapshotFingerprint: last.snapshotFingerprint, + reviewedFiles: last.reviewedFiles ?? [], + }; + } + let matching: StructuredReviewerOutput | undefined; + let attestable: StructuredReviewerOutput | undefined; + for (const entry of shaped) { + const fingerprint = entry.snapshotFingerprint ?? ''; + if (fingerprint.length === 0) + continue; + if (fingerprint === expectedFingerprint) { + matching = entry; + break; + } + if (attestable === undefined && isAttestableV3Fingerprint(fingerprint)) { + attestable = entry; + } + } + const attesting = matching ?? attestable; + const reviewedFiles: string[] = []; + for (const entry of shaped) { + for (const file of entry.reviewedFiles ?? []) + reviewedFiles.push(file); + } + // Surfacing the FIRST non-conforming version (instead of the attesting + // entry's) is what makes the caller's `!== 1` check reject a receipt whose + // sibling entry claims another schema version. + const nonConforming = shaped.find((entry) => entry.schemaVersion !== 1); + return { + schemaVersion: nonConforming?.schemaVersion ?? 1, + snapshotFingerprint: attesting?.snapshotFingerprint, + reviewedFiles, + }; } function collectReviewerAttestationIssues(toolResult: unknown, expectedFingerprint: string, pendingFiles: string[], deletedFiles?: string[]): string[] { @@ -5404,11 +6506,27 @@ function collectReviewerAttestationIssues(toolResult: unknown, expectedFingerpri 'BLOCKING: reviewer did not return the required structured snapshot attestation', ]; } - const result = structured[structured.length - 1]; + // CONFLICT CHECK (fail closed): a result carrying several receipts (e.g. a + // nested spawn plus set_output) could otherwise be attested from one entry + // while finalization credit came from another, so shaped entries that + // disagree on the verdict are rejected outright. Unshaped entries stay out: + // a QUOTED verdict-shaped example would otherwise park the gate in `blocked` + // (the UNNARROWED blocker collectors still elevate one into a repair round). + // Entry selection: see `resolveReviewerAttestation`. + const verdicts = new Set(structured + .filter((entry) => entry.schemaVersion !== undefined) + .map((entry) => entry.verdict)); + if (verdicts.size > 1) { + return [ + 'BLOCKING: reviewer returned conflicting structured verdicts in one result', + ]; + } + const result = resolveReviewerAttestation(structured, expectedFingerprint); + // 1 only when EVERY shaped entry conforms (see `resolveReviewerAttestation`). if (result.schemaVersion !== 1) { return ['BLOCKING: reviewer returned an invalid attestation schemaVersion']; } - const reviewed = new Set((result.reviewedFiles ?? []) + const reviewed = new Set(result.reviewedFiles .map((file) => normalizeGateFilePath(file)) .filter((file) => file.length > 0)); // Files deleted in the changeset carry a `missing` content marker and cannot @@ -5423,33 +6541,70 @@ function collectReviewerAttestationIssues(toolResult: unknown, expectedFingerpri .map((file) => normalizeGateFilePath(file)) .filter((file) => file.length > 0 && !reviewed.has(file) && !deleted.has(file)); const issues: string[] = []; - // Fingerprint tolerance: a reviewer that attested to EVERY pending source - // file with a well-formed snapshot fingerprint is trusted even when the - // exact snapshot id advanced between its spawn and attestation (e.g. an - // unrelated plan-session .jsonl/.md or a git-status bundle bump). Only a - // FILE-COVERAGE gap, a missing/empty fingerprint, or a non-attestable - // sentinel fingerprint remains a hard blocker. This decouples transient - // snapshot drift from terminal reviewer failure while keeping genuine - // coverage gaps and malformed attestations fail-closed. + // Fingerprint tolerance: a coverage-complete review reporting a well-formed + // v3 fingerprint is trusted even when the exact snapshot id advanced between + // its spawn and attestation; only a FILE-COVERAGE gap or a missing / + // non-attestable fingerprint stays a hard blocker. The tolerance is not + // silent — every base2 caller records the drift via + // `collectReviewerFingerprintDrift`, whose docblock carries the rationale. const reportedFingerprint = result.snapshotFingerprint; - const fingerprintIsAttestable = typeof reportedFingerprint === 'string' && - /^v3:[a-f0-9]{64}$/.test(reportedFingerprint); - const fingerprintMatches = fingerprintIsAttestable && reportedFingerprint === expectedFingerprint; - if (!fingerprintMatches && missing.length > 0) { - issues.push('BLOCKING: reviewer snapshot fingerprint did not match the reviewed working tree'); - } - if (!fingerprintIsAttestable && missing.length === 0) { - // A review that covers every pending file but reports no attestable - // snapshot fingerprint cannot be safely credited; fail closed without a - // fingerprint at all. + const fingerprintIsAttestable = isAttestableV3Fingerprint(reportedFingerprint); + if (!fingerprintIsAttestable) { + // A missing / non-attestable fingerprint is never creditable, so report + // THAT instead of mislabelling an absent fingerprint as a mismatch. Both + // branches stay fail-closed; only the operator message differs. issues.push('BLOCKING: reviewer did not report an attestable snapshot fingerprint'); } + else if (reportedFingerprint !== expectedFingerprint && + missing.length > 0) { + issues.push('BLOCKING: reviewer snapshot fingerprint did not match the reviewed working tree'); + } if (missing.length > 0) { issues.push(`BLOCKING: reviewer did not attest to every pending file: ${missing.join(', ')}`); } return issues; } +/** + * The reported v3 snapshot fingerprint when a review echoed a well-formed + * fingerprint that does NOT match the expected snapshot, else ''. + * + * CALLER PRECONDITION: only call this for a review whose + * `collectReviewerAttestationIssues` came back clean. That check — not this + * function — is what establishes coverage-completeness (every pending file + * attested) and verdict agreement across the structured entries. This function + * inspects only the resolved attestation (the same one + * `collectReviewerAttestationIssues` reads, via `resolveReviewerAttestation`) + * and reports any well-formed non-matching v3 value, including one from a + * receipt with a file-coverage gap, so a caller that skips the attestation + * guard would report drift for a review that is already hard-blocked. Both + * base2 gate families guard correctly for reviews that reached attestation: + * the final code-reviewer gate and the routed specialist gates. + * + * CRASH-PATH EXEMPTION: a caller that forces `attestationIssues` to `[]` + * because `detectReviewerCrash` fired never ran attestation at all, and is + * exempt from the precondition. A crashed result normally carries no structured + * entry, so this function returns ''; if one does carry a drifted fingerprint, + * the resulting record is telemetry-only and credits the review with nothing — + * the gate still treats it as a crash. + * + * `collectReviewerAttestationIssues` deliberately tolerates that drift so an + * unrelated bundle bump cannot fail the gate; callers use this to RECORD the + * drift instead of accepting it silently, because a review of stale file + * content would otherwise pass the gate with no trace. '' means there is + * nothing to record: an exact match, or a missing/non-attestable fingerprint + * (both already hard blockers in the attestation issues). + */ +function collectReviewerFingerprintDrift(toolResult: unknown, expectedFingerprint: string): string { + const structured = collectStructuredReviewerOutputs(toolResult); + if (structured.length === 0) + return ''; + const reported = resolveReviewerAttestation(structured, expectedFingerprint).snapshotFingerprint; + if (!isAttestableV3Fingerprint(reported)) + return ''; + return reported === expectedFingerprint ? '' : reported; +} + function stripReviewerPreamble(text: string): string { let remaining = text.trim(); // Tolerate reviewers that still emit a closed leading ... @@ -5479,83 +6634,122 @@ function isTestCoverageReviewerFinding(text: string): boolean { * Process/orchestrator work a source specialist or code reviewer cannot satisfy * from diff/source evidence. Keep patterns specific so real source requirements * that merely mention "commit" or "validation" are not suppressed. + * + * Evidence is consulted ONLY for explicit ownership assertions (`parent must + * `, `parent/operator`, ...). Every process cue must appear in + * the REQUIREMENT text: a reviewer that merely QUOTES process prose as + * evidence (e.g. `evidence: ['spec section: commit and push']` for "preserve + * CLI compatibility") would otherwise convert a genuine in-scope requirement + * gap into a credited LOOKS_GOOD with no surviving repair target. */ function isParentOwnedOrOutOfScopeRequirement(requirement: string, evidence?: string[]): boolean { if (typeof requirement !== 'string') return false; - const text = [requirement, ...(evidence ?? [])] + // Process cues are read from the requirement text only (see above). + const requirementText = requirement.toLowerCase(); + // Ownership assertions are honored from the requirement text or evidence. + const ownershipText = [requirement, ...(evidence ?? [])] .filter((part): part is string => typeof part === 'string') .join('\n') .toLowerCase(); - if (!text.trim()) + if (!ownershipText.trim()) return false; - if (/\brewrite\b[^.\n]{0,40}\bgit\b[^.\n]{0,40}\bcommit(?:\s+messages?)?\b/.test(text) || - /\bamend\b[^.\n]{0,40}\bgit\b[^.\n]{0,40}\bcommit(?:\s+messages?|\s+history)?\b/.test(text) || - /\brewrite\b[^.\n]{0,40}\bcommit\s+messages?\b/.test(text) || - /\bamend\b[^.\n]{0,40}\bcommit\s+(?:messages?|history)\b/.test(text)) { + if (/\brewrite\b[^.\n]{0,40}\bgit\b[^.\n]{0,40}\bcommit(?:\s+messages?)?\b/.test(requirementText) || + /\bamend\b[^.\n]{0,40}\bgit\b[^.\n]{0,40}\bcommit(?:\s+messages?|\s+history)?\b/.test(requirementText) || + /\brewrite\b[^.\n]{0,40}\bcommit\s+messages?\b/.test(requirementText) || + /\bamend\b[^.\n]{0,40}\bcommit\s+(?:messages?|history)\b/.test(requirementText)) { return true; } // Only the full validation gate / CI process step is parent-owned. // Source requirements like "run validation of the new API" stay in-scope. - if (/\brun\b[^.\n]{0,24}\bfull\s+validation(?:\s+gate)?\b/.test(text)) { + if (/\brun\b[^.\n]{0,24}\bfull\s+validation(?:\s+gate)?\b/.test(requirementText)) { return true; } - if (/\bcommit\s+and\s+push\b/.test(text) || - /\bpush\s+(?:the\s+)?changes\b/.test(text)) { + // Repository push only: domain text like "push changes to subscribers" is + // in-scope work, so a process push must name a repository target. + if (/\bcommit\s+and\s+push\b/.test(requirementText) || + /\bpush\s+(?:the\s+)?changes\s+(?:upstream|to\s+(?:origin|remote|the\s+remote|the\s+upstream|the\s+branch))\b/.test(requirementText)) { return true; } - if (/\bconfirm\b[^.\n]{0,24}\bci\/?cd\b[^.\n]{0,24}\bgreen\b/.test(text) || - /\bcheck\b[^.\n]{0,24}\bci(?:\/?cd)?\b[^.\n]{0,24}\bgreen\b/.test(text)) { + if (/\bconfirm\b[^.\n]{0,24}\bci\/?cd\b[^.\n]{0,24}\bgreen\b/.test(requirementText) || + /\bcheck\b[^.\n]{0,24}\bci(?:\/?cd)?\b[^.\n]{0,24}\bgreen\b/.test(requirementText)) { return true; } - if (/\bparent\s+must\b/.test(text) || - /\bparent\/?operator\b/.test(text) || - /\bnot\s+performed\s+by\s+this\s+specialist\b/.test(text) || - /\bspecialist\s+contract\s+forbids\s+basher\b/.test(text)) { + // `parent must ` only: "the parent must be validated before + // insert" is domain text, not a handoff of process work. These are ownership + // assertions, so reviewer evidence may establish them. + if (/\bparent\s+must\s+(?:also\s+|then\s+)?(?:run|commit|push|amend|rewrite|confirm|merge|deploy|release|revalidate)\b/.test(ownershipText) || + /\bparent\/?operator\b/.test(ownershipText) || + /\bnot\s+performed\s+by\s+this\s+specialist\b/.test(ownershipText) || + /\bspecialist\s+contract\s+forbids\s+basher\b/.test(ownershipText)) { return true; } return false; } /** - * True when a blocker string is only a parent-owned requirementCoverage gap. + * The subset of `blockers` that are only parent-owned requirementCoverage gaps. * - * When `toolResult` is provided, re-check structured `requirementCoverage` - * (requirement text + evidence) the same way `getReviewerFinalizationVerdict` - * does. Without that, a LOOKS_GOOD receipt that is parent-owned only via - * evidence can finalize yet still spawn repair-editor at call sites that only - * see `BLOCKING: requirement missing|uncertain: `. + * Classification: the structured `requirementCoverage` row whose + * `${status}\n${requirement}` matches the blocker decides via + * `isParentOwnedOrOutOfScopeRequirement` (requirement text + evidence), with + * the requirement text alone as the fallback when no row matches. The + * structured reviewer outputs are collected once per CALL, covering the whole + * blocker list; that is a readability convenience rather than a material + * saving, because every other gate collector (blockers, hard blockers, + * finalization verdict, finding records, attestation issues, fingerprint drift) + * re-walks the same reviewer result, and `visitForStructuredVerdict`'s depth-8 + * cap is what bounds the cost. */ -function isParentOwnedRequirementBlocker(blocker: string, toolResult?: unknown): boolean { - if (typeof blocker !== 'string') - return false; - const match = blocker.match(/^BLOCKING:\s*requirement\s+(missing|uncertain):\s*(.+)$/i); - if (!match) - return false; - const status = match[1].toLowerCase(); - const requirementText = match[2].trim(); +function collectParentOwnedRequirementBlockers(blockers: string[], toolResult?: unknown): Set { + // Structured requirement rows keyed by `${status}\n${requirement.trim()}`; + // the value is true only when EVERY row with that key is parent-owned once + // its evidence is taken into account. The key is trimmed because the blocker + // string carries the RAW requirement text and the lookup below trims it. + const structuredRows = new Map(); if (toolResult !== undefined) { - const structured = collectStructuredReviewerOutputs(toolResult); - let sawStructuredRow = false; - for (const entry of structured) { + for (const entry of collectStructuredReviewerOutputs(toolResult)) { for (const requirement of entry.requirementCoverage ?? []) { - if (requirement.requirement !== requirementText || - (requirement.status !== 'missing' && - requirement.status !== 'uncertain') || - requirement.status !== status) { - continue; - } - sawStructuredRow = true; - if (isParentOwnedOrOutOfScopeRequirement(requirement.requirement, requirement.evidence)) { - return true; + const key = `${requirement.status}\n${requirement.requirement.trim()}`; + const parentOwnedRow = isParentOwnedOrOutOfScopeRequirement(requirement.requirement, requirement.evidence); + // In-scope precedence: getReviewerFinalizationVerdict blocks when ANY + // matching row is in-scope, so an in-scope row must overwrite a + // parent-owned row with the same status+text key. Otherwise the blocker + // would be filtered out while the verdict stayed '', closing the gate + // with no surviving repair target. + if (!parentOwnedRow || !structuredRows.has(key)) { + structuredRows.set(key, parentOwnedRow); } } } + } + const parentOwnedBlockers = new Set(); + for (const blocker of blockers) { + if (typeof blocker !== 'string') + continue; + // `[\s\S]` (not `.`) so a multi-line requirement text is still parsed + // instead of skipped into text-only classification. + const match = blocker.match(/^BLOCKING:\s*requirement\s+(missing|uncertain):\s*([\s\S]+)$/i); + if (!match) + continue; + // `status` comes from the regex above, so it is already 'missing' or + // 'uncertain'; the row status is part of the key, so a row for the same + // requirement with a different status never matches. + const status = match[1].toLowerCase(); + const requirementText = match[2].trim(); + const structuredRow = structuredRows.get(`${status}\n${requirementText}`); + if (structuredRow === undefined) { + // No structured row matched: classify from the requirement text alone. + if (isParentOwnedOrOutOfScopeRequirement(requirementText)) { + parentOwnedBlockers.add(blocker); + } + continue; + } // Structured row(s) matched: trust evidence-aware classification only. - if (sawStructuredRow) - return false; + if (structuredRow) + parentOwnedBlockers.add(blocker); } - return isParentOwnedOrOutOfScopeRequirement(requirementText); + return parentOwnedBlockers; } function dedupeExactStringsPreserveOrder(values: string[]): string[] { @@ -5570,6 +6764,19 @@ function dedupeExactStringsPreserveOrder(values: string[]): string[] { return out; } +/** + * Every blocker string a reviewer result implies: BLOCKING prose findings, + * gate-derived hard rules (coverage missing, failed dimension, in-scope + * requirement missing/uncertain), NON_BLOCKING prose findings, and the + * synthetic empty-findings NON_BLOCKING placeholder. + * + * SYNC CONTRACT: the gate-derived hard-rule strings emitted here must stay + * byte-identical to the ones `collectReviewerHardBlockers` emits — base2's + * condone filter exempts hard rules via exact `Set.has` membership across the + * two collectors. The two functions are deliberately independent (so this + * function's byte output cannot shift); `agents/__tests__/gate-reviewer.test.ts` + * asserts the parity. + */ function collectReviewerBlockers(toolResult: unknown): string[] { // First check for structured reviewer outputs (e.g. JSON with a // verdict field). BLOCKING and NON_BLOCKING both surface repair targets; @@ -5585,30 +6792,45 @@ function collectReviewerBlockers(toolResult: unknown): string[] { } // Coverage-adequacy / dimension / requirement hard blockers first so we // know whether an empty NON_BLOCKING receipt already has repair fuel. - const hardBlockersBefore = structuredBlockers.length; + // Parent-owned requirement rows are deliberately NOT counted as repair + // fuel: every gate call site filters them away again, so counting them + // would suppress the synthetic placeholder below and leave the consumer + // with an empty blocker list — no repair target, no condoned pass, and a + // misdiagnosed "reviewer ran but returned no structured output" loop. + let entryHasHardBlocker = false; // Coverage-adequacy contract (M6.3): missing test coverage for a // behavior-changing edit is BLOCKING regardless of the text verdict. if (entry.coverage === 'missing') { structuredBlockers.push('BLOCKING: test coverage missing for changed behavior (add a case to the relevant *.test.ts)'); + entryHasHardBlocker = true; } + // Reviewer dimensions follow the contract's ": " style, so a + // blocking dimension arrives as `block: ` (or `blocks:` / + // `blocking:` / `blocker(s):`). Match the leading word only: `blocked` is a + // different word (a state, not a verdict) and must NOT count as failing. for (const [dimension, status] of Object.entries(entry.dimensions ?? {})) { - if (status.toLowerCase() === 'block') { + if (/^block(?:s|ing|er|ers)?\b/.test(status.trim().toLowerCase())) { structuredBlockers.push(`BLOCKING: ${dimension} review dimension failed`); + entryHasHardBlocker = true; } } // Keep parent-owned process requirement gaps in the raw blocker list so // consumers can credit LOOKS_GOOD via parentOwnedOnlyBlockers (filter at - // the call site; do not elevating-filter here). + // the call site; do not filter or elevate them here). for (const requirement of entry.requirementCoverage ?? []) { if (requirement.status === 'missing' || requirement.status === 'uncertain') { // Requirement text only in the string; call-site parent-owned filters // re-check structured requirementCoverage (+ evidence) via - // isParentOwnedRequirementBlocker(blocker, toolResult). + // collectParentOwnedRequirementBlockers(blockers, toolResult). structuredBlockers.push(`BLOCKING: requirement ${requirement.status}: ${requirement.requirement}`); + // Only an IN-SCOPE gap is repair fuel, decided with the same predicate + // the call-site filter uses (requirement text + evidence). + if (!isParentOwnedOrOutOfScopeRequirement(requirement.requirement, requirement.evidence)) { + entryHasHardBlocker = true; + } } } - const entryHasHardBlocker = structuredBlockers.length > hardBlockersBefore; // NON_BLOCKING is repair fuel, not a pass: elevate findings into the // same repair path used for BLOCKING until the reviewer returns LOOKS_GOOD. // Empty-findings synthetic is only needed when no hard blocker already @@ -5638,6 +6860,49 @@ function collectReviewerBlockers(toolResult: unknown): string[] { .filter((text) => hasReviewerLineVerdict(text, 'BLOCKING'))); } +/** + * ONLY the gate-derived hard rules the gate itself derives from the reviewer's + * structured fields: the coverage-missing string, one string per `block` + * dimension, and one string per in-scope requirement whose status is + * `missing`/`uncertain`. Reviewer prose findings (BLOCKING or NON_BLOCKING) and + * the synthetic empty-findings placeholder are deliberately excluded: they are + * the only blockers a repair round can legitimately "address", so only they are + * condonable. + * + * SYNC CONTRACT: these strings must stay byte-identical to the corresponding + * ones produced by `collectReviewerBlockers` — base2's condone filter compares + * them with exact `Set.has` membership, so a single-character divergence would + * silently stop exempting hard rules. The two functions are kept independent so + * `collectReviewerBlockers`' byte output cannot shift; the parity is asserted by + * `agents/__tests__/gate-reviewer.test.ts`. + */ +function collectReviewerHardBlockers(toolResult: unknown): string[] { + const structured = collectStructuredReviewerOutputs(toolResult); + const hardBlockers: string[] = []; + for (const entry of structured) { + if (entry.coverage === 'missing') { + hardBlockers.push('BLOCKING: test coverage missing for changed behavior (add a case to the relevant *.test.ts)'); + } + // Same prefix rule as collectReviewerBlockers (kept independently): the + // trimmed, lowercased value starting with the word `block` (or + // `blocks`/`blocking`/`blocker`/`blockers`) fails, so `block: `, + // `blocks: ` and `blocking: ` count while `blocked` does + // not. + for (const [dimension, status] of Object.entries(entry.dimensions ?? {})) { + if (/^block(?:s|ing|er|ers)?\b/.test(status.trim().toLowerCase())) { + hardBlockers.push(`BLOCKING: ${dimension} review dimension failed`); + } + } + for (const requirement of entry.requirementCoverage ?? []) { + if (requirement.status === 'missing' || + requirement.status === 'uncertain') { + hardBlockers.push(`BLOCKING: requirement ${requirement.status}: ${requirement.requirement}`); + } + } + } + return dedupeExactStringsPreserveOrder(hardBlockers); +} + /** * Detects whether the reviewer agent itself crashed (returned an `errorMessage` * field, threw, or otherwise produced no usable output) as opposed to running @@ -5686,12 +6951,17 @@ function findReviewerCrash(value: unknown, depth: number = 0): string | null { if (record.type === 'error' && typeof record.message === 'string') { return (record.message.trim() || 'reviewer agent reported an unspecified error'); } - if (record.type === 'json' && 'value' in record) { + const jsonNode = record.type === 'json' && 'value' in record; + if (jsonNode) { const nested = findReviewerCrash(record.value, depth + 1); if (nested) return nested; } - for (const nested of Object.values(record)) { + for (const [key, nested] of Object.entries(record)) { + // The json recursion above already walked `value`; walking it again would + // double the work at every nesting level up to the depth cap. + if (jsonNode && key === 'value') + continue; const found = findReviewerCrash(nested, depth + 1); if (found) return found; @@ -5730,17 +7000,24 @@ function isTransientReviewerCrash(message: string): boolean { * Coarse crash taxonomy for specialist/reviewer failures. * null/empty → none; rate-limit patterns → transient; optional protocol-ish * bare-hex / non-attestable / snapshot-attestation wording → protocol; else fatal. + * + * Non-test consumer: base2's specialist review gate branches on + * `classifyReviewerCrash(crash) === 'protocol'` / `'transient'` at runtime via + * the `` copy emitted from this module, so this is the + * canonical source of that taxonomy rather than a dead public helper. */ function classifyReviewerCrash(message: string | null): 'none' | 'transient' | 'protocol' | 'fatal' { - if (message == null) - return 'none'; if (typeof message !== 'string' || !message.trim()) return 'none'; if (isTransientReviewerCrash(message)) return 'transient'; const lower = message.toLowerCase(); - const hasBareHex = /(?:^|[^:])\b[a-f0-9]{64}\b/i.test(message) && - !/\bv3:[a-f0-9]{64}\b/i.test(message); + // The `(?:^|[^:])` prefix already excludes a `v3:<64hex>` token's own hex (the + // character before the run may not be ':'), so a message that ALSO carries a + // well-formed v3 token still classifies as 'protocol' when it contains a + // separate bare 64-hex run. No extra v3 guard: it only suppressed genuine + // bare-hex detection. + const hasBareHex = /(?:^|[^:])\b[a-f0-9]{64}\b/i.test(message); if (hasBareHex || lower.includes('non-attestable') || lower.includes('snapshot attestation') || @@ -5769,9 +7046,24 @@ function getReviewerFinalizationVerdict(toolResult: unknown): ReviewerFinalizati !isParentOwnedOrOutOfScopeRequirement(requirement.requirement, requirement.evidence)))) { return ''; } + // A failing review dimension (`block` / `block: ` / `blocks:` / + // `blocking:` / `blocker(s): `) is a gate-derived hard blocker as + // well, so it blocks finalization alongside coverage-missing and in-scope + // requirement gaps instead of riding along with LOOKS_GOOD. + if (structured.some((entry) => Object.values(entry.dimensions ?? {}).some((status) => /^block(?:s|ing|er|ers)?\b/.test(status.trim().toLowerCase())))) { + return ''; + } // Finalization credit is LOOKS_GOOD only. NON_BLOCKING findings are // elevated by collectReviewerBlockers into the repair loop. - for (const entry of structured) { + // The scan is restricted to the `schemaVersion`-carrying entries whenever the + // receipt carries any, so credit and collectReviewerAttestationIssues read + // the SAME entry set and an unshaped quoted LOOKS_GOOD example cannot credit + // a receipt whose real entry is BLOCKING. With no shaped entry the whole set + // is read, matching `resolveReviewerAttestation`'s verbatim fallback. + const creditable = structured.some((entry) => entry.schemaVersion !== undefined) + ? structured.filter((entry) => entry.schemaVersion !== undefined) + : structured; + for (const entry of creditable) { if (entry.verdict === 'LOOKS_GOOD') return 'LOOKS_GOOD'; } @@ -5791,19 +7083,24 @@ function collectStructuredReviewerOutputs(value: unknown): StructuredReviewerOut return out; } -function visitForStructuredVerdict(value: unknown, out: StructuredReviewerOutput[]): void { +function visitForStructuredVerdict(value: unknown, out: StructuredReviewerOutput[], depth: number = 0): void { + // Depth cap (same value findReviewerCrash uses on the same envelopes): 8 is + // well past any realistic agent-result envelope but stops pathological or + // self-referential recursion from blowing the stack. + if (depth > 8) + return; if (!value) return; if (Array.isArray(value)) { for (const item of value) - visitForStructuredVerdict(item, out); + visitForStructuredVerdict(item, out, depth + 1); return; } if (typeof value !== 'object') return; const record = value as Record; if (record.type === 'json' && 'value' in record) { - visitForStructuredVerdict(record.value, out); + visitForStructuredVerdict(record.value, out, depth + 1); return; } const rawVerdict = record.verdict; @@ -5812,6 +7109,18 @@ function visitForStructuredVerdict(value: unknown, out: StructuredReviewerOutput if (upper === 'LOOKS_GOOD' || upper === 'NON_BLOCKING' || upper === 'BLOCKING') { + // ONE normalizer for object findings so the human-readable `findings` + // strings and the structured `findingRecords` below cannot drift. + const normalizeObjectFinding = (finding: object) => { + const item = finding as Record; + const id = typeof item.id === 'string' ? item.id.trim() : ''; + const text = typeof item.summary === 'string' + ? item.summary.trim() + : typeof item.text === 'string' + ? item.text.trim() + : ''; + return { id, text }; + }; const findings: string[] = []; const rawFindings = record.findings; if (typeof rawFindings === 'string') { @@ -5825,17 +7134,23 @@ function visitForStructuredVerdict(value: unknown, out: StructuredReviewerOutput findings.push(finding.trim()); } else if (finding && typeof finding === 'object') { - const findingRecord = finding as Record; - const id = typeof findingRecord.id === 'string' - ? findingRecord.id.trim() - : ''; - const summary = typeof findingRecord.summary === 'string' - ? findingRecord.summary.trim() - : typeof findingRecord.text === 'string' - ? findingRecord.text.trim() - : ''; - if (summary) - findings.push(id ? `[${id}] ${summary}` : summary); + const { id, text } = normalizeObjectFinding(finding); + if (text) + findings.push(id ? `[${id}] ${text}` : text); + } + } + } + const advisories: string[] = []; + const rawAdvisories = record.advisories; + if (typeof rawAdvisories === 'string') { + const trimmed = rawAdvisories.trim(); + if (trimmed) + advisories.push(trimmed); + } + else if (Array.isArray(rawAdvisories)) { + for (const advisory of rawAdvisories) { + if (typeof advisory === 'string' && advisory.trim()) { + advisories.push(advisory.trim()); } } } @@ -5850,6 +7165,7 @@ function visitForStructuredVerdict(value: unknown, out: StructuredReviewerOutput out.push({ verdict: upper as ReviewerStructuredVerdict, findings, + ...(advisories.length > 0 ? { advisories } : {}), coverage, dimensions: record.dimensions && typeof record.dimensions === 'object' ? Object.fromEntries(Object.entries(record.dimensions as Record).filter((entry): entry is [ @@ -5892,12 +7208,7 @@ function visitForStructuredVerdict(value: unknown, out: StructuredReviewerOutput if (!finding || typeof finding !== 'object') return []; const item = finding as Record; - const id = typeof item.id === 'string' ? item.id.trim() : ''; - const text = typeof item.summary === 'string' - ? item.summary.trim() - : typeof item.text === 'string' - ? item.text.trim() - : ''; + const { id, text } = normalizeObjectFinding(finding); if (!id || !text) return []; return [ @@ -5925,17 +7236,23 @@ function visitForStructuredVerdict(value: unknown, out: StructuredReviewerOutput } } for (const nested of Object.values(record)) { - visitForStructuredVerdict(nested, out); + visitForStructuredVerdict(nested, out, depth + 1); } } function hasReviewerLineVerdict(text: string, verdict: ReviewerStructuredVerdict): boolean { - return text - .split(/\r?\n/) - .some((line) => new RegExp(`^${verdict}\\b`, 'i').test(line.trim())); + // Compiled once per call: built inside the per-line `.some` callback it was + // recompiled for every line of every string collected from the tool result. + const linePattern = new RegExp(`^${verdict}\\b`, 'i'); + return text.split(/\r?\n/).some((line) => linePattern.test(line.trim())); } -function collectStrings(value: unknown, out: string[]): void { +function collectStrings(value: unknown, out: string[], depth: number = 0): void { + // Depth cap (same value findReviewerCrash uses on the same envelopes): 8 is + // well past any realistic agent-result envelope but stops pathological or + // self-referential recursion from blowing the stack. + if (depth > 8) + return; if (typeof value === 'string') { out.push(value); return; @@ -5944,13 +7261,13 @@ function collectStrings(value: unknown, out: string[]): void { return; if (Array.isArray(value)) { for (const item of value) - collectStrings(item, out); + collectStrings(item, out, depth + 1); return; } if (typeof value !== 'object') return; for (const nested of Object.values(value as Record)) { - collectStrings(nested, out); + collectStrings(nested, out, depth + 1); } } @@ -6862,24 +8179,24 @@ function hashGateSnapshotDetails(details: string): string { return { snapshotId: '', errorMessage: '', files: [] } } - function collectReviewerFindingRecordsInline( - toolResult: unknown, - ): Array<{ id: string; text: string }> { - return collectStructuredReviewerOutputs(toolResult).flatMap( - (entry) => entry.findingRecords ?? [], - ) - } - // Correlate a synthesized blocker string to the reviewer-supplied // finding record it came from by CONTENT, never by positional index. // collectReviewerBlockers can emit synthesized blockers (coverage // missing, dimension-block, requirement missing/uncertain) that have no // corresponding finding record and appear in a different order/count - // than collectReviewerFindingRecordsInline returns, so records[index] - // could attach the wrong id/text. Prefer an explicit `[id]` match, then - // fall back to an exact finding-text substring match. Self-contained - // inline helper (handleSteps is serialized via .toString() + - // new Function(...), so it must not reference module-scope imports). + // than collectReviewerFindingRecords returns, so records[index] + // could attach the wrong id/text. Self-contained inline helper + // (handleSteps is serialized via .toString() + new Function(...), so it + // must not reference module-scope imports). + // + // Matching is strongest-first: the explicit `[id]` marker, then an EXACT + // match against the stripped blocker text (or its `[id] text` form), and + // only then a substring match — and that last one solely when exactly one + // record matches. A bare `blockerText.includes(record.text)` attached the + // wrong record whenever one finding's text was a substring of another's, + // and the resulting `::id:` condone key then condoned an + // UNRELATED finding. Attaching no id is safe (the text key still carries + // convergence); attaching the wrong one is not. function correlateReviewerFindingRecord( blockerText: string, records: Array<{ id: string; text: string }>, @@ -6889,12 +8206,20 @@ function hashGateSnapshotDetails(details: string): string { return record } } + const strippedText = stripReviewerVerdictPrefix(blockerText) for (const record of records) { - if (record.text && blockerText.includes(record.text)) { + if (!record.text) continue + if ( + strippedText === record.text || + (record.id && strippedText === `[${record.id}] ${record.text}`) + ) { return record } } - return undefined + const substringMatches = records.filter( + (record) => record.text && strippedText.includes(record.text), + ) + return substringMatches.length === 1 ? substringMatches[0] : undefined } function isStaleSnapshotReviewerResult(toolResult: unknown): boolean { @@ -7009,6 +8334,13 @@ function hashGateSnapshotDetails(details: string): string { evidenceTruncated: coverage.evidenceTruncated || coverage.evidence.length > 1, })), + ...(receipt.advisories && receipt.advisories.length > 0 + ? { + advisories: receipt.advisories + .slice(0, 2) + .map((advisory) => compactReceiptString(advisory, 180)), + } + : {}), receiptTruncated: true, } if ( @@ -7022,11 +8354,30 @@ function hashGateSnapshotDetails(details: string): string { findings: [], requirementCoverage: [], dimensions: {}, + // advisoryCount survives so a consumer can still tell advisories + // existed even though the texts did not fit the storage bound. + advisories: undefined, } } const gateId = `${reviewer}:${expectedFingerprint}` const reviewedFiles = normalizeGateFileList(result.reviewedFiles ?? []) + // Advisories are the reviewer's non-blocking observations. They are + // recorded (and surfaced) but never become repair targets, which is + // what lets a LOOKS_GOOD verdict carry cosmetic notes instead of + // holding the turn open with findings that require no change. + const MAX_RECEIPT_ADVISORIES = 8 + // Read through the shared collector (generated into this file's + // region from agents/base2/gate-reviewer.ts) + // so the PERSISTED advisories are exactly the tested contract — last + // structured entry, trimmed, exact-duplicate-free — rather than a + // second inline read of `result.advisories` with different semantics. + const advisories = collectReviewerAdvisories(toolResult) + .map((advisory) => + compactReceiptString(advisory, MAX_RECEIPT_TEXT_LENGTH), + ) + .filter((advisory) => advisory.length > 0) + .slice(0, MAX_RECEIPT_ADVISORIES) const receipt: Base2ReviewReceipt = { gateId, reviewer, @@ -7067,6 +8418,9 @@ function hashGateSnapshotDetails(details: string): string { } }), findingCount: (result.findingRecords ?? []).length, + ...(advisories.length > 0 + ? { advisories, advisoryCount: advisories.length } + : {}), requirementCoverage: (result.requirementCoverage ?? []).map( (coverage) => ({ requirement: compactReceiptString( @@ -7152,6 +8506,10 @@ function hashGateSnapshotDetails(details: string): string { maxRepairRounds?: number }> = [] for (const text of texts) { + // Non-greedy on purpose: formatGateStateBlock escapes `([\s\S]*?)<\/gate-state>/g) for (const match of matches) { try { @@ -8610,11 +9968,7 @@ function buildImplementationInstructionsPrompt({ const gateActive = !isFast && !hasNoValidation return `Act as a helpful assistant and freely respond to the user's request however would be most helpful to the user. Use your judgement to orchestrate the completion of the user's request using your specialized sub-agents and tools as needed. Take your time and be comprehensive. Don't surprise the user. For example, don't modify files if the user has not asked you to do so at least implicitly. -${ - progressiveDisclosure - ? 'Broad audit / many-file / coverage-sweep request → read_files `agents/guides/broad-audit.md` before sharding.' - : buildBroadAuditSection('proceed to implementation or the answer') - } +${discloseBroadAudit('proceed to implementation or the answer', progressiveDisclosure)} ## Example response @@ -8735,11 +10089,10 @@ function buildPlanOnlyInstructionsPrompt({ You are in plan mode. Preserve short-answer behavior: if the user is asking a question, requesting an explanation, or asking for a small clarification, answer directly and do not create a plan packet. -${ - progressiveDisclosure - ? 'Broad audit / many-file / coverage-sweep request → read_files `agents/guides/broad-audit.md` before sharding.' - : buildBroadAuditSection('translate the findings into the durable plan packet below') -} +${discloseBroadAudit( + 'translate the findings into the durable plan packet below', + progressiveDisclosure, +)} For larger implementation, migration, debugging, or multi-step work, gather enough context to create a comprehensive, resumable plan packet. For non-trivial plans, create all four durable artifacts by default (SPEC.md, PLAN.md, STATUS.md, LESSONS.md); these are not optional or only "as needed". Normal users should not need to explicitly ask for STATUS or LESSONS artifacts. You may ask targeted clarifying questions with ask_user when the answer materially changes the plan. Avoid obvious questions and questions about details that can be adjusted later. diff --git a/agents/base2/gate-reviewer.ts b/agents/base2/gate-reviewer.ts index 8af22fc5b3..c907652708 100644 --- a/agents/base2/gate-reviewer.ts +++ b/agents/base2/gate-reviewer.ts @@ -1,14 +1,20 @@ /** * Pure reviewer gate parsing helpers extracted from `base2.ts`. * - * NOTE: equivalent inline copies of these helpers still exist inside - * `createBase2`'s `handleSteps` generator because that function is + * NOTE: `base2.ts` carries inline copies of these helpers inside its + * `` region. `createBase2`'s `handleSteps` generator is * serialized via `handleSteps.toString()` and reconstructed with - * `new Function(...)`. Reconstructed functions lose their module - * closure, so they cannot reference imports from this file. Keep the - * two implementations in sync. + * `new Function(...)`; a reconstructed function loses its module closure, so it + * cannot reference imports from this file — every helper it uses has to be + * inlined into that region. + * + * That region is generator-owned, so do NOT hand-patch it. After changing this + * file, regenerate it with + * bun run scripts/generate-gate-helpers.ts --write agents/base2/base2.ts + * and verify freshness with the same script's `--check` mode. */ +import { isAttestableSnapshotFingerprint } from './gate-fingerprint' import { normalizeGateFilePath } from './gate-paths' type ReviewerStructuredVerdict = 'LOOKS_GOOD' | 'NON_BLOCKING' | 'BLOCKING' @@ -20,6 +26,7 @@ type ReviewerCoverage = 'covered' | 'missing' | 'n/a' type StructuredReviewerOutput = { verdict: ReviewerStructuredVerdict findings: string[] + advisories?: string[] coverage?: ReviewerCoverage dimensions?: Record requirementCoverage?: Array<{ @@ -45,9 +52,144 @@ export type ReviewerFindingRecord = { export function collectReviewerFindingRecords( toolResult: unknown, ): ReviewerFindingRecord[] { - return collectStructuredReviewerOutputs(toolResult).flatMap( - (entry) => entry.findingRecords ?? [], - ) + // Nested spawn/set_output wrappers can surface the same receipt twice; keep + // the FIRST record per id so `correlateReviewerFindingRecord` never sees + // duplicates (`collectReviewerBlockers` de-dupes its strings the same way). + const seen = new Set() + const records: ReviewerFindingRecord[] = [] + for (const entry of collectStructuredReviewerOutputs(toolResult)) { + for (const record of entry.findingRecords ?? []) { + if (seen.has(record.id)) continue + seen.add(record.id) + records.push(record) + } + } + return records +} + +/** + * Advisory observations from the LAST `schemaVersion`-shaped structured reviewer + * entry (the receipt the gate records). Recorded and displayed only: no blocker + * collector reads them, so an advisory never blocks and never re-enters the + * repair loop. + * + * Entry selection matches `resolveReviewerAttestation`'s shaped narrowing for + * the same reason: a reviewer that QUOTES the documented example receipt AFTER + * its real one must not have the example's advisories persisted and displayed as + * this review's. With no shaped entry at all the LAST entry is read verbatim, + * matching that helper's fallback. + * + * Non-test consumer: base2's `recordSuccessfulReviewReceipt` builds the durable + * receipt's `advisories` with this collector at runtime through the + * `` copy emitted from this module, so the PERSISTED + * advisory semantics (last shaped entry, trimmed, exact-duplicate-free) are the + * tested ones instead of a second inline read of `result.advisories`. + * + * Reviewer-family symmetry: `advisories` is an OPTIONAL additive output field. + * Only code-reviewer declares it today; a family that omits it (security + * reviewer, routed specialists) reads back here as no advisories, so no other + * reviewer schema has to migrate and an older receipt keeps round-tripping. + * + * Advisory text is returned verbatim (trimmed only). Delimiter safety lives at + * the emitter: base2's `formatGateStateBlock` escapes `` block, so + * an advisory quoting the literal `` cannot truncate that block for + * the CLI renderer or base2's own conversation-gate-reuse reader. + */ +export function collectReviewerAdvisories(toolResult: unknown): string[] { + const structured = collectStructuredReviewerOutputs(toolResult) + const shaped = structured.filter((entry) => entry.schemaVersion !== undefined) + const candidates = shaped.length > 0 ? shaped : structured + const last = candidates[candidates.length - 1] + return dedupeExactStringsPreserveOrder(last?.advisories ?? []) +} + +/** True when `value` is a canonical attestable `v3:<64 hex>` snapshot fingerprint. */ +function isAttestableV3Fingerprint(value: unknown): value is string { + return typeof value === 'string' && isAttestableSnapshotFingerprint(value) +} + +/** The attestation fields `collectReviewerAttestationIssues` reads. */ +type ResolvedReviewerAttestation = { + schemaVersion?: number + snapshotFingerprint?: string + reviewedFiles: string[] +} + +/** + * The attestation a receipt is read from, resolved ORDER-INDEPENDENTLY across + * its `schemaVersion`-carrying (`shaped`) entries, so a quoted example on + * EITHER side of the real receipt cannot steal the attestation and turn a + * well-behaved review into spurious fingerprint/coverage blockers (a terminal + * gate failure after base2's single `reviewer-protocol-attestation-failed` + * retry). `collectReviewerAttestationIssues` already requires the shaped + * verdicts to agree, so they describe ONE review. + * + * CANONICAL WHY for the entry selection; call sites carry pointers only. + * + * Resolution is PER FIELD, so the result is a COMPOSITE rather than one entry: + * `reviewedFiles` is the UNION of the shaped entries, `snapshotFingerprint` is + * the entry reporting `expectedFingerprint` else the first reporting an + * attestable v3 fingerprint else undefined, and `schemaVersion` is 1 only when + * EVERY shaped entry reports 1 (otherwise the first non-conforming version, so + * the caller's `!== 1` check rejects the whole receipt). + * + * ACCEPTED LOOSENING (pinned in agents/__tests__/gate-reviewer.test.ts): the + * spliced fields let a quoted example entry supply the fingerprint for a real + * entry that reported none, and — because the union is NOT restricted to the + * entry that contributed the credited fingerprint — a quoted example whose + * `reviewedFiles` path COLLIDES with a real pending path (the documented + * example literally shows `reviewedFiles: ["src/a.ts"]`) credits coverage the + * real entry never attested. Narrowing the union would not close the + * fingerprint half and WOULD reject the deletions-only receipt, which + * legitimately attests with an empty `reviewedFiles`. So the guarantee is the + * weaker one: a pending file NO entry reported at all still blocks. + * + * With no shaped entry at all the LAST entry is read verbatim, so a receipt that + * never attested still fails closed on the caller's schemaVersion check. + * + * CALLER PRECONDITION: `structured` is non-empty. + */ +function resolveReviewerAttestation( + structured: StructuredReviewerOutput[], + expectedFingerprint: string, +): ResolvedReviewerAttestation { + const shaped = structured.filter((entry) => entry.schemaVersion !== undefined) + if (shaped.length === 0) { + const last = structured[structured.length - 1] + return { + schemaVersion: last.schemaVersion, + snapshotFingerprint: last.snapshotFingerprint, + reviewedFiles: last.reviewedFiles ?? [], + } + } + let matching: StructuredReviewerOutput | undefined + let attestable: StructuredReviewerOutput | undefined + for (const entry of shaped) { + const fingerprint = entry.snapshotFingerprint ?? '' + if (fingerprint.length === 0) continue + if (fingerprint === expectedFingerprint) { + matching = entry + break + } + if (attestable === undefined && isAttestableV3Fingerprint(fingerprint)) { + attestable = entry + } + } + const attesting = matching ?? attestable + const reviewedFiles: string[] = [] + for (const entry of shaped) { + for (const file of entry.reviewedFiles ?? []) reviewedFiles.push(file) + } + // Surfacing the FIRST non-conforming version (instead of the attesting + // entry's) is what makes the caller's `!== 1` check reject a receipt whose + // sibling entry claims another schema version. + const nonConforming = shaped.find((entry) => entry.schemaVersion !== 1) + return { + schemaVersion: nonConforming?.schemaVersion ?? 1, + snapshotFingerprint: attesting?.snapshotFingerprint, + reviewedFiles, + } } export function collectReviewerAttestationIssues( @@ -67,12 +209,30 @@ export function collectReviewerAttestationIssues( 'BLOCKING: reviewer did not return the required structured snapshot attestation', ] } - const result = structured[structured.length - 1] + // CONFLICT CHECK (fail closed): a result carrying several receipts (e.g. a + // nested spawn plus set_output) could otherwise be attested from one entry + // while finalization credit came from another, so shaped entries that + // disagree on the verdict are rejected outright. Unshaped entries stay out: + // a QUOTED verdict-shaped example would otherwise park the gate in `blocked` + // (the UNNARROWED blocker collectors still elevate one into a repair round). + // Entry selection: see `resolveReviewerAttestation`. + const verdicts = new Set( + structured + .filter((entry) => entry.schemaVersion !== undefined) + .map((entry) => entry.verdict), + ) + if (verdicts.size > 1) { + return [ + 'BLOCKING: reviewer returned conflicting structured verdicts in one result', + ] + } + const result = resolveReviewerAttestation(structured, expectedFingerprint) + // 1 only when EVERY shaped entry conforms (see `resolveReviewerAttestation`). if (result.schemaVersion !== 1) { return ['BLOCKING: reviewer returned an invalid attestation schemaVersion'] } const reviewed = new Set( - (result.reviewedFiles ?? []) + result.reviewedFiles .map((file) => normalizeGateFilePath(file)) .filter((file) => file.length > 0), ) @@ -92,31 +252,27 @@ export function collectReviewerAttestationIssues( (file) => file.length > 0 && !reviewed.has(file) && !deleted.has(file), ) const issues: string[] = [] - // Fingerprint tolerance: a reviewer that attested to EVERY pending source - // file with a well-formed snapshot fingerprint is trusted even when the - // exact snapshot id advanced between its spawn and attestation (e.g. an - // unrelated plan-session .jsonl/.md or a git-status bundle bump). Only a - // FILE-COVERAGE gap, a missing/empty fingerprint, or a non-attestable - // sentinel fingerprint remains a hard blocker. This decouples transient - // snapshot drift from terminal reviewer failure while keeping genuine - // coverage gaps and malformed attestations fail-closed. + // Fingerprint tolerance: a coverage-complete review reporting a well-formed + // v3 fingerprint is trusted even when the exact snapshot id advanced between + // its spawn and attestation; only a FILE-COVERAGE gap or a missing / + // non-attestable fingerprint stays a hard blocker. The tolerance is not + // silent — every base2 caller records the drift via + // `collectReviewerFingerprintDrift`, whose docblock carries the rationale. const reportedFingerprint = result.snapshotFingerprint - const fingerprintIsAttestable = - typeof reportedFingerprint === 'string' && - /^v3:[a-f0-9]{64}$/.test(reportedFingerprint) - const fingerprintMatches = - fingerprintIsAttestable && reportedFingerprint === expectedFingerprint - if (!fingerprintMatches && missing.length > 0) { + const fingerprintIsAttestable = isAttestableV3Fingerprint(reportedFingerprint) + if (!fingerprintIsAttestable) { + // A missing / non-attestable fingerprint is never creditable, so report + // THAT instead of mislabelling an absent fingerprint as a mismatch. Both + // branches stay fail-closed; only the operator message differs. issues.push( - 'BLOCKING: reviewer snapshot fingerprint did not match the reviewed working tree', + 'BLOCKING: reviewer did not report an attestable snapshot fingerprint', ) - } - if (!fingerprintIsAttestable && missing.length === 0) { - // A review that covers every pending file but reports no attestable - // snapshot fingerprint cannot be safely credited; fail closed without a - // fingerprint at all. + } else if ( + reportedFingerprint !== expectedFingerprint && + missing.length > 0 + ) { issues.push( - 'BLOCKING: reviewer did not report an attestable snapshot fingerprint', + 'BLOCKING: reviewer snapshot fingerprint did not match the reviewed working tree', ) } if (missing.length > 0) { @@ -127,6 +283,50 @@ export function collectReviewerAttestationIssues( return issues } +/** + * The reported v3 snapshot fingerprint when a review echoed a well-formed + * fingerprint that does NOT match the expected snapshot, else ''. + * + * CALLER PRECONDITION: only call this for a review whose + * `collectReviewerAttestationIssues` came back clean. That check — not this + * function — is what establishes coverage-completeness (every pending file + * attested) and verdict agreement across the structured entries. This function + * inspects only the resolved attestation (the same one + * `collectReviewerAttestationIssues` reads, via `resolveReviewerAttestation`) + * and reports any well-formed non-matching v3 value, including one from a + * receipt with a file-coverage gap, so a caller that skips the attestation + * guard would report drift for a review that is already hard-blocked. Both + * base2 gate families guard correctly for reviews that reached attestation: + * the final code-reviewer gate and the routed specialist gates. + * + * CRASH-PATH EXEMPTION: a caller that forces `attestationIssues` to `[]` + * because `detectReviewerCrash` fired never ran attestation at all, and is + * exempt from the precondition. A crashed result normally carries no structured + * entry, so this function returns ''; if one does carry a drifted fingerprint, + * the resulting record is telemetry-only and credits the review with nothing — + * the gate still treats it as a crash. + * + * `collectReviewerAttestationIssues` deliberately tolerates that drift so an + * unrelated bundle bump cannot fail the gate; callers use this to RECORD the + * drift instead of accepting it silently, because a review of stale file + * content would otherwise pass the gate with no trace. '' means there is + * nothing to record: an exact match, or a missing/non-attestable fingerprint + * (both already hard blockers in the attestation issues). + */ +export function collectReviewerFingerprintDrift( + toolResult: unknown, + expectedFingerprint: string, +): string { + const structured = collectStructuredReviewerOutputs(toolResult) + if (structured.length === 0) return '' + const reported = resolveReviewerAttestation( + structured, + expectedFingerprint, + ).snapshotFingerprint + if (!isAttestableV3Fingerprint(reported)) return '' + return reported === expectedFingerprint ? '' : reported +} + export function stripReviewerPreamble(text: string): string { let remaining = text.trim() // Tolerate reviewers that still emit a closed leading ... @@ -152,51 +352,78 @@ export function isTestCoverageReviewerFinding(text: string): boolean { * Process/orchestrator work a source specialist or code reviewer cannot satisfy * from diff/source evidence. Keep patterns specific so real source requirements * that merely mention "commit" or "validation" are not suppressed. + * + * Evidence is consulted ONLY for explicit ownership assertions (`parent must + * `, `parent/operator`, ...). Every process cue must appear in + * the REQUIREMENT text: a reviewer that merely QUOTES process prose as + * evidence (e.g. `evidence: ['spec section: commit and push']` for "preserve + * CLI compatibility") would otherwise convert a genuine in-scope requirement + * gap into a credited LOOKS_GOOD with no surviving repair target. */ export function isParentOwnedOrOutOfScopeRequirement( requirement: string, evidence?: string[], ): boolean { if (typeof requirement !== 'string') return false - const text = [requirement, ...(evidence ?? [])] + // Process cues are read from the requirement text only (see above). + const requirementText = requirement.toLowerCase() + // Ownership assertions are honored from the requirement text or evidence. + const ownershipText = [requirement, ...(evidence ?? [])] .filter((part): part is string => typeof part === 'string') .join('\n') .toLowerCase() - if (!text.trim()) return false + if (!ownershipText.trim()) return false if ( /\brewrite\b[^.\n]{0,40}\bgit\b[^.\n]{0,40}\bcommit(?:\s+messages?)?\b/.test( - text, + requirementText, ) || /\bamend\b[^.\n]{0,40}\bgit\b[^.\n]{0,40}\bcommit(?:\s+messages?|\s+history)?\b/.test( - text, + requirementText, ) || - /\brewrite\b[^.\n]{0,40}\bcommit\s+messages?\b/.test(text) || - /\bamend\b[^.\n]{0,40}\bcommit\s+(?:messages?|history)\b/.test(text) + /\brewrite\b[^.\n]{0,40}\bcommit\s+messages?\b/.test(requirementText) || + /\bamend\b[^.\n]{0,40}\bcommit\s+(?:messages?|history)\b/.test( + requirementText, + ) ) { return true } // Only the full validation gate / CI process step is parent-owned. // Source requirements like "run validation of the new API" stay in-scope. - if (/\brun\b[^.\n]{0,24}\bfull\s+validation(?:\s+gate)?\b/.test(text)) { + if ( + /\brun\b[^.\n]{0,24}\bfull\s+validation(?:\s+gate)?\b/.test(requirementText) + ) { return true } + // Repository push only: domain text like "push changes to subscribers" is + // in-scope work, so a process push must name a repository target. if ( - /\bcommit\s+and\s+push\b/.test(text) || - /\bpush\s+(?:the\s+)?changes\b/.test(text) + /\bcommit\s+and\s+push\b/.test(requirementText) || + /\bpush\s+(?:the\s+)?changes\s+(?:upstream|to\s+(?:origin|remote|the\s+remote|the\s+upstream|the\s+branch))\b/.test( + requirementText, + ) ) { return true } if ( - /\bconfirm\b[^.\n]{0,24}\bci\/?cd\b[^.\n]{0,24}\bgreen\b/.test(text) || - /\bcheck\b[^.\n]{0,24}\bci(?:\/?cd)?\b[^.\n]{0,24}\bgreen\b/.test(text) + /\bconfirm\b[^.\n]{0,24}\bci\/?cd\b[^.\n]{0,24}\bgreen\b/.test( + requirementText, + ) || + /\bcheck\b[^.\n]{0,24}\bci(?:\/?cd)?\b[^.\n]{0,24}\bgreen\b/.test( + requirementText, + ) ) { return true } + // `parent must ` only: "the parent must be validated before + // insert" is domain text, not a handoff of process work. These are ownership + // assertions, so reviewer evidence may establish them. if ( - /\bparent\s+must\b/.test(text) || - /\bparent\/?operator\b/.test(text) || - /\bnot\s+performed\s+by\s+this\s+specialist\b/.test(text) || - /\bspecialist\s+contract\s+forbids\s+basher\b/.test(text) + /\bparent\s+must\s+(?:also\s+|then\s+)?(?:run|commit|push|amend|rewrite|confirm|merge|deploy|release|revalidate)\b/.test( + ownershipText, + ) || + /\bparent\/?operator\b/.test(ownershipText) || + /\bnot\s+performed\s+by\s+this\s+specialist\b/.test(ownershipText) || + /\bspecialist\s+contract\s+forbids\s+basher\b/.test(ownershipText) ) { return true } @@ -204,55 +431,73 @@ export function isParentOwnedOrOutOfScopeRequirement( } /** - * True when a blocker string is only a parent-owned requirementCoverage gap. + * The subset of `blockers` that are only parent-owned requirementCoverage gaps. * - * When `toolResult` is provided, re-check structured `requirementCoverage` - * (requirement text + evidence) the same way `getReviewerFinalizationVerdict` - * does. Without that, a LOOKS_GOOD receipt that is parent-owned only via - * evidence can finalize yet still spawn repair-editor at call sites that only - * see `BLOCKING: requirement missing|uncertain: `. + * Classification: the structured `requirementCoverage` row whose + * `${status}\n${requirement}` matches the blocker decides via + * `isParentOwnedOrOutOfScopeRequirement` (requirement text + evidence), with + * the requirement text alone as the fallback when no row matches. The + * structured reviewer outputs are collected once per CALL, covering the whole + * blocker list; that is a readability convenience rather than a material + * saving, because every other gate collector (blockers, hard blockers, + * finalization verdict, finding records, attestation issues, fingerprint drift) + * re-walks the same reviewer result, and `visitForStructuredVerdict`'s depth-8 + * cap is what bounds the cost. */ -export function isParentOwnedRequirementBlocker( - blocker: string, +export function collectParentOwnedRequirementBlockers( + blockers: string[], toolResult?: unknown, -): boolean { - if (typeof blocker !== 'string') return false - const match = blocker.match( - /^BLOCKING:\s*requirement\s+(missing|uncertain):\s*(.+)$/i, - ) - if (!match) return false - const status = match[1].toLowerCase() - const requirementText = match[2].trim() - +): Set { + // Structured requirement rows keyed by `${status}\n${requirement.trim()}`; + // the value is true only when EVERY row with that key is parent-owned once + // its evidence is taken into account. The key is trimmed because the blocker + // string carries the RAW requirement text and the lookup below trims it. + const structuredRows = new Map() if (toolResult !== undefined) { - const structured = collectStructuredReviewerOutputs(toolResult) - let sawStructuredRow = false - for (const entry of structured) { + for (const entry of collectStructuredReviewerOutputs(toolResult)) { for (const requirement of entry.requirementCoverage ?? []) { - if ( - requirement.requirement !== requirementText || - (requirement.status !== 'missing' && - requirement.status !== 'uncertain') || - requirement.status !== status - ) { - continue - } - sawStructuredRow = true - if ( - isParentOwnedOrOutOfScopeRequirement( - requirement.requirement, - requirement.evidence, - ) - ) { - return true + const key = `${requirement.status}\n${requirement.requirement.trim()}` + const parentOwnedRow = isParentOwnedOrOutOfScopeRequirement( + requirement.requirement, + requirement.evidence, + ) + // In-scope precedence: getReviewerFinalizationVerdict blocks when ANY + // matching row is in-scope, so an in-scope row must overwrite a + // parent-owned row with the same status+text key. Otherwise the blocker + // would be filtered out while the verdict stayed '', closing the gate + // with no surviving repair target. + if (!parentOwnedRow || !structuredRows.has(key)) { + structuredRows.set(key, parentOwnedRow) } } } + } + const parentOwnedBlockers = new Set() + for (const blocker of blockers) { + if (typeof blocker !== 'string') continue + // `[\s\S]` (not `.`) so a multi-line requirement text is still parsed + // instead of skipped into text-only classification. + const match = blocker.match( + /^BLOCKING:\s*requirement\s+(missing|uncertain):\s*([\s\S]+)$/i, + ) + if (!match) continue + // `status` comes from the regex above, so it is already 'missing' or + // 'uncertain'; the row status is part of the key, so a row for the same + // requirement with a different status never matches. + const status = match[1].toLowerCase() + const requirementText = match[2].trim() + const structuredRow = structuredRows.get(`${status}\n${requirementText}`) + if (structuredRow === undefined) { + // No structured row matched: classify from the requirement text alone. + if (isParentOwnedOrOutOfScopeRequirement(requirementText)) { + parentOwnedBlockers.add(blocker) + } + continue + } // Structured row(s) matched: trust evidence-aware classification only. - if (sawStructuredRow) return false + if (structuredRow) parentOwnedBlockers.add(blocker) } - - return isParentOwnedOrOutOfScopeRequirement(requirementText) + return parentOwnedBlockers } function dedupeExactStringsPreserveOrder(values: string[]): string[] { @@ -266,6 +511,19 @@ function dedupeExactStringsPreserveOrder(values: string[]): string[] { return out } +/** + * Every blocker string a reviewer result implies: BLOCKING prose findings, + * gate-derived hard rules (coverage missing, failed dimension, in-scope + * requirement missing/uncertain), NON_BLOCKING prose findings, and the + * synthetic empty-findings NON_BLOCKING placeholder. + * + * SYNC CONTRACT: the gate-derived hard-rule strings emitted here must stay + * byte-identical to the ones `collectReviewerHardBlockers` emits — base2's + * condone filter exempts hard rules via exact `Set.has` membership across the + * two collectors. The two functions are deliberately independent (so this + * function's byte output cannot shift); `agents/__tests__/gate-reviewer.test.ts` + * asserts the parity. + */ export function collectReviewerBlockers(toolResult: unknown): string[] { // First check for structured reviewer outputs (e.g. JSON with a // verdict field). BLOCKING and NON_BLOCKING both surface repair targets; @@ -282,24 +540,35 @@ export function collectReviewerBlockers(toolResult: unknown): string[] { } // Coverage-adequacy / dimension / requirement hard blockers first so we // know whether an empty NON_BLOCKING receipt already has repair fuel. - const hardBlockersBefore = structuredBlockers.length + // Parent-owned requirement rows are deliberately NOT counted as repair + // fuel: every gate call site filters them away again, so counting them + // would suppress the synthetic placeholder below and leave the consumer + // with an empty blocker list — no repair target, no condoned pass, and a + // misdiagnosed "reviewer ran but returned no structured output" loop. + let entryHasHardBlocker = false // Coverage-adequacy contract (M6.3): missing test coverage for a // behavior-changing edit is BLOCKING regardless of the text verdict. if (entry.coverage === 'missing') { structuredBlockers.push( 'BLOCKING: test coverage missing for changed behavior (add a case to the relevant *.test.ts)', ) + entryHasHardBlocker = true } + // Reviewer dimensions follow the contract's ": " style, so a + // blocking dimension arrives as `block: ` (or `blocks:` / + // `blocking:` / `blocker(s):`). Match the leading word only: `blocked` is a + // different word (a state, not a verdict) and must NOT count as failing. for (const [dimension, status] of Object.entries(entry.dimensions ?? {})) { - if (status.toLowerCase() === 'block') { + if (/^block(?:s|ing|er|ers)?\b/.test(status.trim().toLowerCase())) { structuredBlockers.push( `BLOCKING: ${dimension} review dimension failed`, ) + entryHasHardBlocker = true } } // Keep parent-owned process requirement gaps in the raw blocker list so // consumers can credit LOOKS_GOOD via parentOwnedOnlyBlockers (filter at - // the call site; do not elevating-filter here). + // the call site; do not filter or elevate them here). for (const requirement of entry.requirementCoverage ?? []) { if ( requirement.status === 'missing' || @@ -307,14 +576,22 @@ export function collectReviewerBlockers(toolResult: unknown): string[] { ) { // Requirement text only in the string; call-site parent-owned filters // re-check structured requirementCoverage (+ evidence) via - // isParentOwnedRequirementBlocker(blocker, toolResult). + // collectParentOwnedRequirementBlockers(blockers, toolResult). structuredBlockers.push( `BLOCKING: requirement ${requirement.status}: ${requirement.requirement}`, ) + // Only an IN-SCOPE gap is repair fuel, decided with the same predicate + // the call-site filter uses (requirement text + evidence). + if ( + !isParentOwnedOrOutOfScopeRequirement( + requirement.requirement, + requirement.evidence, + ) + ) { + entryHasHardBlocker = true + } } } - const entryHasHardBlocker = - structuredBlockers.length > hardBlockersBefore // NON_BLOCKING is repair fuel, not a pass: elevate findings into the // same repair path used for BLOCKING until the reviewer returns LOOKS_GOOD. @@ -349,6 +626,55 @@ export function collectReviewerBlockers(toolResult: unknown): string[] { ) } +/** + * ONLY the gate-derived hard rules the gate itself derives from the reviewer's + * structured fields: the coverage-missing string, one string per `block` + * dimension, and one string per in-scope requirement whose status is + * `missing`/`uncertain`. Reviewer prose findings (BLOCKING or NON_BLOCKING) and + * the synthetic empty-findings placeholder are deliberately excluded: they are + * the only blockers a repair round can legitimately "address", so only they are + * condonable. + * + * SYNC CONTRACT: these strings must stay byte-identical to the corresponding + * ones produced by `collectReviewerBlockers` — base2's condone filter compares + * them with exact `Set.has` membership, so a single-character divergence would + * silently stop exempting hard rules. The two functions are kept independent so + * `collectReviewerBlockers`' byte output cannot shift; the parity is asserted by + * `agents/__tests__/gate-reviewer.test.ts`. + */ +export function collectReviewerHardBlockers(toolResult: unknown): string[] { + const structured = collectStructuredReviewerOutputs(toolResult) + const hardBlockers: string[] = [] + for (const entry of structured) { + if (entry.coverage === 'missing') { + hardBlockers.push( + 'BLOCKING: test coverage missing for changed behavior (add a case to the relevant *.test.ts)', + ) + } + // Same prefix rule as collectReviewerBlockers (kept independently): the + // trimmed, lowercased value starting with the word `block` (or + // `blocks`/`blocking`/`blocker`/`blockers`) fails, so `block: `, + // `blocks: ` and `blocking: ` count while `blocked` does + // not. + for (const [dimension, status] of Object.entries(entry.dimensions ?? {})) { + if (/^block(?:s|ing|er|ers)?\b/.test(status.trim().toLowerCase())) { + hardBlockers.push(`BLOCKING: ${dimension} review dimension failed`) + } + } + for (const requirement of entry.requirementCoverage ?? []) { + if ( + requirement.status === 'missing' || + requirement.status === 'uncertain' + ) { + hardBlockers.push( + `BLOCKING: requirement ${requirement.status}: ${requirement.requirement}`, + ) + } + } + } + return dedupeExactStringsPreserveOrder(hardBlockers) +} + /** * Detects whether the reviewer agent itself crashed (returned an `errorMessage` * field, threw, or otherwise produced no usable output) as opposed to running @@ -395,11 +721,15 @@ function findReviewerCrash(value: unknown, depth: number = 0): string | null { record.message.trim() || 'reviewer agent reported an unspecified error' ) } - if (record.type === 'json' && 'value' in record) { + const jsonNode = record.type === 'json' && 'value' in record + if (jsonNode) { const nested = findReviewerCrash(record.value, depth + 1) if (nested) return nested } - for (const nested of Object.values(record)) { + for (const [key, nested] of Object.entries(record)) { + // The json recursion above already walked `value`; walking it again would + // double the work at every nesting level up to the depth cap. + if (jsonNode && key === 'value') continue const found = findReviewerCrash(nested, depth + 1) if (found) return found } @@ -436,17 +766,24 @@ export function isTransientReviewerCrash(message: string): boolean { * Coarse crash taxonomy for specialist/reviewer failures. * null/empty → none; rate-limit patterns → transient; optional protocol-ish * bare-hex / non-attestable / snapshot-attestation wording → protocol; else fatal. + * + * Non-test consumer: base2's specialist review gate branches on + * `classifyReviewerCrash(crash) === 'protocol'` / `'transient'` at runtime via + * the `` copy emitted from this module, so this is the + * canonical source of that taxonomy rather than a dead public helper. */ export function classifyReviewerCrash( message: string | null, ): 'none' | 'transient' | 'protocol' | 'fatal' { - if (message == null) return 'none' if (typeof message !== 'string' || !message.trim()) return 'none' if (isTransientReviewerCrash(message)) return 'transient' const lower = message.toLowerCase() - const hasBareHex = - /(?:^|[^:])\b[a-f0-9]{64}\b/i.test(message) && - !/\bv3:[a-f0-9]{64}\b/i.test(message) + // The `(?:^|[^:])` prefix already excludes a `v3:<64hex>` token's own hex (the + // character before the run may not be ':'), so a message that ALSO carries a + // well-formed v3 token still classifies as 'protocol' when it contains a + // separate bare 64-hex run. No extra v3 guard: it only suppressed genuine + // bare-hex detection. + const hasBareHex = /(?:^|[^:])\b[a-f0-9]{64}\b/i.test(message) if ( hasBareHex || lower.includes('non-attestable') || @@ -489,9 +826,32 @@ export function getReviewerFinalizationVerdict( ) { return '' } + // A failing review dimension (`block` / `block: ` / `blocks:` / + // `blocking:` / `blocker(s): `) is a gate-derived hard blocker as + // well, so it blocks finalization alongside coverage-missing and in-scope + // requirement gaps instead of riding along with LOOKS_GOOD. + if ( + structured.some((entry) => + Object.values(entry.dimensions ?? {}).some((status) => + /^block(?:s|ing|er|ers)?\b/.test(status.trim().toLowerCase()), + ), + ) + ) { + return '' + } // Finalization credit is LOOKS_GOOD only. NON_BLOCKING findings are // elevated by collectReviewerBlockers into the repair loop. - for (const entry of structured) { + // The scan is restricted to the `schemaVersion`-carrying entries whenever the + // receipt carries any, so credit and collectReviewerAttestationIssues read + // the SAME entry set and an unshaped quoted LOOKS_GOOD example cannot credit + // a receipt whose real entry is BLOCKING. With no shaped entry the whole set + // is read, matching `resolveReviewerAttestation`'s verbatim fallback. + const creditable = structured.some( + (entry) => entry.schemaVersion !== undefined, + ) + ? structured.filter((entry) => entry.schemaVersion !== undefined) + : structured + for (const entry of creditable) { if (entry.verdict === 'LOOKS_GOOD') return 'LOOKS_GOOD' } @@ -516,16 +876,21 @@ function collectStructuredReviewerOutputs( function visitForStructuredVerdict( value: unknown, out: StructuredReviewerOutput[], + depth: number = 0, ): void { + // Depth cap (same value findReviewerCrash uses on the same envelopes): 8 is + // well past any realistic agent-result envelope but stops pathological or + // self-referential recursion from blowing the stack. + if (depth > 8) return if (!value) return if (Array.isArray(value)) { - for (const item of value) visitForStructuredVerdict(item, out) + for (const item of value) visitForStructuredVerdict(item, out, depth + 1) return } if (typeof value !== 'object') return const record = value as Record if (record.type === 'json' && 'value' in record) { - visitForStructuredVerdict(record.value, out) + visitForStructuredVerdict(record.value, out, depth + 1) return } const rawVerdict = record.verdict @@ -536,6 +901,19 @@ function visitForStructuredVerdict( upper === 'NON_BLOCKING' || upper === 'BLOCKING' ) { + // ONE normalizer for object findings so the human-readable `findings` + // strings and the structured `findingRecords` below cannot drift. + const normalizeObjectFinding = (finding: object) => { + const item = finding as Record + const id = typeof item.id === 'string' ? item.id.trim() : '' + const text = + typeof item.summary === 'string' + ? item.summary.trim() + : typeof item.text === 'string' + ? item.text.trim() + : '' + return { id, text } + } const findings: string[] = [] const rawFindings = record.findings if (typeof rawFindings === 'string') { @@ -546,18 +924,20 @@ function visitForStructuredVerdict( if (typeof finding === 'string' && finding.trim()) { findings.push(finding.trim()) } else if (finding && typeof finding === 'object') { - const findingRecord = finding as Record - const id = - typeof findingRecord.id === 'string' - ? findingRecord.id.trim() - : '' - const summary = - typeof findingRecord.summary === 'string' - ? findingRecord.summary.trim() - : typeof findingRecord.text === 'string' - ? findingRecord.text.trim() - : '' - if (summary) findings.push(id ? `[${id}] ${summary}` : summary) + const { id, text } = normalizeObjectFinding(finding) + if (text) findings.push(id ? `[${id}] ${text}` : text) + } + } + } + const advisories: string[] = [] + const rawAdvisories = record.advisories + if (typeof rawAdvisories === 'string') { + const trimmed = rawAdvisories.trim() + if (trimmed) advisories.push(trimmed) + } else if (Array.isArray(rawAdvisories)) { + for (const advisory of rawAdvisories) { + if (typeof advisory === 'string' && advisory.trim()) { + advisories.push(advisory.trim()) } } } @@ -572,6 +952,7 @@ function visitForStructuredVerdict( out.push({ verdict: upper as ReviewerStructuredVerdict, findings, + ...(advisories.length > 0 ? { advisories } : {}), coverage, dimensions: record.dimensions && typeof record.dimensions === 'object' @@ -624,13 +1005,7 @@ function visitForStructuredVerdict( ? rawFindings.flatMap((finding) => { if (!finding || typeof finding !== 'object') return [] const item = finding as Record - const id = typeof item.id === 'string' ? item.id.trim() : '' - const text = - typeof item.summary === 'string' - ? item.summary.trim() - : typeof item.text === 'string' - ? item.text.trim() - : '' + const { id, text } = normalizeObjectFinding(finding) if (!id || !text) return [] return [ { @@ -659,7 +1034,7 @@ function visitForStructuredVerdict( } } for (const nested of Object.values(record)) { - visitForStructuredVerdict(nested, out) + visitForStructuredVerdict(nested, out, depth + 1) } } @@ -667,23 +1042,32 @@ function hasReviewerLineVerdict( text: string, verdict: ReviewerStructuredVerdict, ): boolean { - return text - .split(/\r?\n/) - .some((line) => new RegExp(`^${verdict}\\b`, 'i').test(line.trim())) + // Compiled once per call: built inside the per-line `.some` callback it was + // recompiled for every line of every string collected from the tool result. + const linePattern = new RegExp(`^${verdict}\\b`, 'i') + return text.split(/\r?\n/).some((line) => linePattern.test(line.trim())) } -function collectStrings(value: unknown, out: string[]): void { +function collectStrings( + value: unknown, + out: string[], + depth: number = 0, +): void { + // Depth cap (same value findReviewerCrash uses on the same envelopes): 8 is + // well past any realistic agent-result envelope but stops pathological or + // self-referential recursion from blowing the stack. + if (depth > 8) return if (typeof value === 'string') { out.push(value) return } if (!value) return if (Array.isArray(value)) { - for (const item of value) collectStrings(item, out) + for (const item of value) collectStrings(item, out, depth + 1) return } if (typeof value !== 'object') return for (const nested of Object.values(value as Record)) { - collectStrings(nested, out) + collectStrings(nested, out, depth + 1) } } diff --git a/agents/base2/gate-state.ts b/agents/base2/gate-state.ts index 6780e694ba..1772dbe7a1 100644 --- a/agents/base2/gate-state.ts +++ b/agents/base2/gate-state.ts @@ -42,6 +42,14 @@ export type Base2ReviewReceipt = { correctionTruncated?: boolean }> findingCount?: number + /** + * Non-blocking observations: recorded and displayed, never repair targets. + * Stored verbatim; the `` emitter escapes `::text:` and + * `::id:`, where verdictClass is + * `NON_BLOCKING`, `BLOCKING`, or `*` for a text that carries no verdict + * prefix (legacy serialized state only — every reviewer path now stores the + * prefixed blocker string). Matching is per class with ONE-DIRECTIONAL + * de-escalation: a `*` entry condones only another `*` finding, a `BLOCKING` + * entry ALSO condones a `NON_BLOCKING` re-raise of the same identity (a + * de-escalated re-raise carries no new information, so the reviewer → repair + * → re-review loop still converges instead of spawning a repair round whose + * already-applied repair trips the no-progress guard), and a `NON_BLOCKING` + * entry NEVER condones a `BLOCKING` re-raise. `condonedFindingTexts` alone + * strips the prefix before comparing, so a nit condoned as NON_BLOCKING was + * silently swallowed when a later review re-raised the SAME text as BLOCKING; + * an escalation is new information and must reopen the gate. Reset when the + * gate passes. Backward-compatible: older serialized state lacks this field, + * and the legacy text path is consulted only while this list is empty (fail + * closed). Bounded to the most recent 200 entries at every write site (like + * `reviewReceipts`). MUST stay a plain JSON-serializable array. + */ + condonedFindingKeys?: string[] /** * Reviewer family that must re-attest after a runtime-attested repair changes * the workspace and validation passes. Missing legacy provenance fails closed diff --git a/agents/base2/quality-prompt-section.ts b/agents/base2/quality-prompt-section.ts index a0501bb57c..3303fa8889 100644 --- a/agents/base2/quality-prompt-section.ts +++ b/agents/base2/quality-prompt-section.ts @@ -5,9 +5,21 @@ * and the editor agent so that implementation agents receive the same * craftsmanship guidance the orchestrator already encodes inline. * - * `qualitySection` is byte-frozen: a snapshot test - * (`agents/__tests__/quality-prompt-snapshot.test.ts`) asserts byte-equality - * so accidental drift across the three consumers is caught at test time. + * `qualitySection` is byte-frozen: a snapshot test in + * `agents/__tests__/quality-prompt-snapshot.test.ts` pins this constant's + * bytes, so editing the text fails until the snapshot is deliberately updated. + * That same test file separately asserts, by containment, that each of the + * three consumers interpolates the constant — which is what keeps the three + * consumers on identical text. + * + * Every relocatable section body now lives in + * `@codebuff/common/constants/prompt-sections` and is RE-EXPORTED here: the + * T1.4d guide fallback in `common/src/util/guides.ts` needs those bodies, and + * `packages/agent-runtime` must not import from `agents/`. Re-exporting keeps + * every existing consumer's import path unchanged. + * + * `gateAwarenessSection` stays authored here: it is deliberately NOT + * relocatable to a guide, so it needs no fallback body. * * The frontend guidance lives in the canonical * `@codebuff/common/constants/prompt-sections` module and reaches prompts via @@ -15,75 +27,15 @@ * exported here. */ -/** - * General code-craftsmanship section: DRY/SOLID/clean-code/hygiene/conventions. - * - * This text is deliberately a standalone block (no surrounding context) so it - * can be interpolated into any system or instructions prompt. - */ -export const qualitySection = `# Code Craftsmanship - -- **Conventions:** Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code, tests, and configuration first. -- **Libraries/Frameworks:** NEVER assume a library/framework is available or appropriate. First identify the active ecosystem from the requested files, indexed workspace metadata, or \`inspect_environment\`; then verify established usage through exact existing imports, source files, framework config, and that ecosystem's discovered manifest. Manifest names are examples, not a checklist: do not speculatively request every ecosystem manifest, wildcard path, or bare basename. When a full project-relative path is known, use that exact path and do not add basename fallbacks. -- **Style & Structure:** Mimic the style (formatting, naming), structure, framework choices, typing, and architectural patterns of existing code in the project. -- **Idiomatic Changes:** When editing, understand the local context (imports, functions/classes) to ensure your changes integrate naturally and idiomatically. -- **Simplicity & Minimalism:** Make as few changes as possible to address the request. Only do what has been asked for and no more. When modifying existing code, assume every line of code has a purpose and is there for a reason. Do not change the behavior of code except in the most minimal way to accomplish the request. -- **Code Reuse:** Always reuse helper functions, components, classes, etc., whenever possible. Don't reimplement what already exists elsewhere in the codebase. -- **Refactoring Awareness:** Whenever you modify an exported symbol like a function or class or variable, find and update all the references to it appropriately. -- **Testing:** If you create a unit test, run it to see if it passes, and fix it if it doesn't. -- **Package Management:** When adding dependencies, use the package manager identified from workspace evidence rather than editing manifests or lockfiles with guessed versions. Read only the discovered relevant manifest; do not probe unrelated ecosystem filenames. Do not install packages globally unless explicitly asked. -- **Code Hygiene:** Leave things in a good state: - - Don't forget to add any imports that might be needed - - Remove unused variables, functions, and files that result from your changes - - If you added files or functions meant to replace existing code, remove the previous code -- **Don't type cast as "any":** Don't cast variables as "any" (or similar for other languages). This is a bad practice that leads to bugs. Exception: when the value can truly be any type.` - -/** - * Build the "Broad audit / exploration requests — scope first, then shard" - * prompt section. - * - * Extracted here (was duplicated inline in base2.ts for the implementation - * and plan-only prompts). Interpolated by both orchestrator prompt paths so - * the scope-then-shard guidance stays consistent. - * - * M2.1 makes this section *adaptive* — instead of a static "3–6 / 8–12 - * subagents" heuristic, the breadth rubric is keyed to the number of distinct - * subsystems / domains the request spans, using the same vocabulary the M10 - * breadth classifier (`classifyPrompt` in `evals/buffbench/plan-sharding-signals.ts`) - * uses to detect audit-style prompts. The model estimates breadth, then the - * rubric picks the shard count. - * - * `finalizeClause` is interpolated after step 3 so the implementation path - * can say "proceed to implementation or the answer" and the plan path can say - * "translate the findings into the durable plan packet below". - */ -export type BroadAuditFinalizeClause = - | 'proceed to implementation or the answer' - | 'translate the findings into the durable plan packet below'; - -export function buildBroadAuditSection( - finalizeClause: BroadAuditFinalizeClause, -): string { - if (typeof finalizeClause !== 'string' || !finalizeClause.trim()) { - throw new Error( - 'buildBroadAuditSection: finalizeClause must be a non-empty string', - ); - } - return `## Broad audit / exploration requests — scope first, then shard - -For broad, open-ended, or audit-style requests (for example: "check this codebase for any feature improvements", "audit the codebase for security/correctness/perf issues", "assess this codebase for how production ready it is on a feature, security and code level", "find all the places X is handled", "what can be improved in the agents/sdk/cli", or anything where the relevant surface is not already obvious), do NOT default to a single surface-level codesearch or one or two file reads. Instead, run a deliberate scope-then-shard flow: - -1. **Assess scope and measure breadth.** The runtime starts cross-subsystem requests with \`inspect_codebase_structure\`; treat its snapshot-bound subsystem, entrypoint, route, command, public-API, test, generated-source, and language/framework capability inventory as authoritative for shard allocation. Supplement it with query_index only for semantic discovery. Count the distinct subsystems / packages / concerns the request spans. Pick the shard count from this adaptive rubric (breadth = number of distinct subsystems the request touches): - - **breadth 1–2 (focused):** one shard pair per subsystem (one file-picker + one code-searcher), plus a docs researcher if a major external library is involved. - - **breadth 3–5 (multi-subsystem audit):** at least one complete file-picker/code-searcher pair per subsystem. Dispatch the pairs in bounded waves when they exceed the per-call limit. - - **breadth 6+ (whole-codebase audit):** at least one complete file-picker/code-searcher pair per subsystem, plus one researcher-docs per major external library involved, dispatched in bounded waves. - The \`file-picker\` and \`code-searcher\` shards named above are DISCOVERY-ONLY: they return prose and file paths, not receipts, and cannot emit a \`structuralReceipt\`. Their output feeds the reasoning/audit shards (step 3), it is not passed to \`evaluate_audit_coverage\` directly. The wider the surface, the more shards. Each call must respect the advertised batch limit, but there is no fixed total-agent limit: join a wave, evaluate coverage, and launch another until the inventory is covered. Never default to a single codesearch for an audit-style request. -2. **Check frontend presence and coverage.** If top-level dirs, routes, pages, app/, src/, components/, or framework config indicate a frontend exists, the audit must cover UI page wiring, routes, navigation, API integration, auth/error/loading states, accessibility, and responsiveness. If no frontend is present, explicitly mark frontend/UI coverage out-of-scope rather than silently omitting it. -3. **Shard by feature slices and structure.** Make vertical feature slices (entrypoint or UI/command → orchestrator/runtime → service/storage/provider → tests/docs/failure states) the primary reasoning shards. Add structural package shards and cross-cutting domain shards for security, compatibility, performance, accessibility, migration, and reliability. Attach the inventory's language/framework capability packet instead of selecting a language-specific agent. These reasoning/audit shards are \`general-agent\` shards invoked with the \`write_audit_findings\` tool (passing the \`sessionSlug\`, \`shardId\`, and \`snapshotId\`) — that tool is what emits each shard's \`structuralReceipt\`, and these are the receipts that feed \`evaluate_audit_coverage\`. The \`file-picker\`/\`code-searcher\` discovery shards from steps 1–2 are inputs to these audit shards: they hand over prose and paths, they do not produce receipts. Each shard must return the subsystem IDs and feature IDs it actually covered. -4. **Machine-check completeness before synthesis.** Run \`inspect_feature_completeness\` for every claimed or discovered user-visible feature, then \`evaluate_audit_coverage\` with the exact inventory snapshot, each audit shard's returned \`structuralReceipt\` (these come only from the \`general-agent\` + \`write_audit_findings\` audit shards of step 3, never from the discovery-only \`file-picker\`/\`code-searcher\` shards), each feature inspection's returned \`coverageReceipt\`, and explicit out-of-scope reasons. Never reconstruct receipts from prose or count-only summaries. Feature receipts start as \`heuristic\`; verify their cited files with exact reads before changing \`evidence_kind\` to \`verified\`. Uncovered subsystems, unreachable implementations, documented-but-unimplemented behavior, tests without runtime wiring, or runtime paths without failure-state coverage block a complete audit. Only after the coverage result is complete should you synthesize and ${finalizeClause}. - -Never make the user ask explicitly for "use multiple agents" — the scope assessment and breadth measurement above are your job, and the default for audit-style requests is parallel sharding, not a single codesearch.` -} +export { + buildBroadAuditSection, + gitDisciplineSection, + preReviewSelfCheckSection, + qualitySection, + securityReviewSection, + specialistRoutingSection, +} from '@codebuff/common/constants/prompt-sections' +export type { BroadAuditFinalizeClause } from '@codebuff/common/constants/prompt-sections' /** * Gate-awareness section: tells the orchestrator the runtime-owned @@ -137,123 +89,4 @@ Dirty working-tree files are not the same as pending: only task-related **review - Write the final user-visible completion summary first - Spawn optional \`git-committer\` (with \`params.owned_paths\` for task-owned paths) before followups if committing this turn - Call \`suggest_followups\` only as the absolute last tool after summary/commit; never mid-turn and never before remaining work -- Any new edit re-arms on every new edit (back to GATE: PENDING); one more clear cycle is required. Treat early withhold as normal ordering; do not tight-loop committer spawns — wait for GATE: PASSED, then spawn once.` - -/** - * Security-review section: advisory pre-edit review for security-sensitive - * file patterns. - * - * NOT byte-frozen — advisory guidance that may evolve as the - * security-reviewer agent and threat models mature. - * - * Interpolated by both orchestrators (base2 + base-deep) so the model - * gives consistent security-review guidance. NOT interpolated into the - * editor — the orchestrator decides when to spawn security-reviewer; - * the editor implements the (already-reviewed) change. - */ -export const securityReviewSection = `# Security-Sensitive File Patterns (Advisory Pre-Edit Review) - -Some files carry elevated security risk — credentials, auth flows, crypto, payment, secrets management. Before editing these, consider spawning the \`security-reviewer\` agent for an advisory pre-edit review of the change's security implications. - -**Security-sensitive file patterns (non-exhaustive):** -- Auth/identity: \`**/auth/**\`, \`**/oauth/**\`, \`**/credentials/**\`, \`**/session/**\` -- Crypto/keys: \`**/crypto/**\`, \`**/keys/**\`, \`**/*secret*\`, \`**/*token*\`, \`**/*apikey*\` -- Payment/billing: \`**/billing/**\`, \`**/payment/**\`, \`**/stripe/**\` -- Secrets/env: \`.env*\`, \`**/.env*\`, \`**/secrets/**\`, \`**/vault/**\` -- Permissions/policy: \`**/permissions/**\`, \`**/rbac/**\`, \`**/policy/**\` - -**Guidance:** -- This is **advisory, not blocking** — the security-reviewer's findings inform your approach but do not gate the edit. -- Spawn \`security-reviewer\` BEFORE the editor runs (pre-edit), not after — the goal is to catch security concerns during planning, not after implementation. -- For trivial changes (typo, comment) in sensitive files, skip the review. -- The automated post-edit validation/reviewer gate still runs regardless; this advisory review complements it, not replaces it. -- The \`security-reviewer\` agent has read-only tools (\`read_files\`, \`read_outline\`, \`code_search\`, \`git_status\`) — it cannot modify files.` - -export const specialistRoutingSection = `# Specialist Routing - -## Gate vs Specialists — ownership matrix - -| Dimension | Final Gate (runtime-owned) | Specialist Gates (domain-scoped aux) | -|---|---|---| -| Ownership | Runtime-owned: hooks + \`code-reviewer\` | Caller-spawned aux specialists (reviewer-family + \`security-reviewer\` when routed) | -| When | After every turn that leaves reviewable \`pendingGateFiles\` — runs on turn end | Only when the scoped risk boundary is crossed (see routing list) — pre-edit advisory or explicit user request | -| Verdict | Global gate: PASS unlocks \`final_response_allowed\` / \`git-committer\`; FAIL reopens with blockers | Scoped gate: blocks only its risk dimension; complements, does not replace, Final Gate | -| Attestation | Gate-owned opaque token; re-arms on every new edit | Reviewer-family attests via gate token; \`security-reviewer\` attests via fingerprint (see Params Contract) | - -Use specialists when repository evidence or the requested outcome crosses one of these risk boundaries. This applies in DEFAULT, PLAN, and EXECUTE_PLAN modes; planning and resumed execution need the same expert access as implementation. - -- Architecture or public boundary decisions → \`architect\`; requirement/acceptance ambiguity or end-to-end reachability → \`product-reviewer\`. -- Independent branches, patches, worktrees, or conflicting implementations → \`integration-agent\`. -- Benchmarks, hot paths, latency, throughput, or allocations → \`performance-specialist\`; races, retries, cancellation, idempotency, or state machines → \`reliability-reviewer\`. -- Schema/data changes or backfills → \`migration-reviewer\`; exported APIs, serialization, CLI/config/env contracts, or persisted formats → \`compatibility-reviewer\`. -- UI keyboard/focus/semantic/assistive behavior → \`accessibility-reviewer\`; visual hierarchy, responsive layout, screenshots, or design-system behavior → \`ux-visual-reviewer\`. -- Manifest/lockfile/provenance/license/vulnerability concerns → \`dependency-reviewer\`; multi-component failures and competing hypotheses → \`incident-coordinator\`. -- Explicit release/version/tag/package/CI work → \`release-manager\`; documentation architecture/coverage → \`docs-architect\`; independent requirement scoring → \`evaluator\`. - -Gather the exact source and snapshot evidence before spawning. Advisory specialists inform the plan; reviewer specialists can block their scoped risk dimension. They complement rather than replace targeted validation and the final code-reviewer gate. - -## Params Contract - -| Agent family | Required \`params\` | Rejected | Notes | -|---|---|---|---| -| Reviewer-family (\`product-reviewer\`, \`performance-specialist\`, \`reliability-reviewer\`, \`migration-reviewer\`, \`compatibility-reviewer\`, \`accessibility-reviewer\`, \`ux-visual-reviewer\`, \`dependency-reviewer\`, \`evaluator\`) | \`params.snapshot_id = v3:<64-hex>\` gate-assigned opaque v3:<64-hex> gate-owned opaque token from the parent gate | bare hex or missing token | Spawning with the wrong or missing snapshot key fails the spawn | -| \`security-reviewer\` (exception) | \`params.changed_files\` + \`params.snapshot_fingerprint\` | \`params.snapshot_id\` | Rejects \`snapshot_id\`; requires file list + fingerprint only | - -Bare hex \`snapshotId\` from \`get_change_review_bundle\` is evidence-only — do not use it as \`params.snapshot_id\`. - -## Compaction recovery - -If compaction drops GATE state, re-derive from the runtime's pinned GATE line / \`pendingGateFiles\` and do not manually re-spawn reviewer-family specialists — wait for the runtime-owned Final Gate result. - -## Sequential vs parallel - -Final Gate is sequential (hooks → \`code-reviewer\` → GATE decision). Specialist (aux) gates may run in parallel with each other and with advisory work, but never substitute for or race the Final Gate. - -Post-edit reviewer-family specialists are routed automatically by the orchestrator's gate. Do not manually re-spawn them after edits, after compaction, or merely because set_output is unavailable; wait for the runtime-owned gate result. Manual specialist calls are for pre-edit advisory work or an explicit user request.` - -/** - * Git-discipline section: orchestrator-level guidance for git workflows. - * - * NOT byte-frozen — advisory guidance that may evolve as the git-committer - * agent and git_branch/git_status SDK helpers mature. - * - * Interpolated by both orchestrators (base2 + base-deep) so the model gives - * consistent git-discipline guidance. NOT interpolated into the editor — - * the editor is for code editing, not git work, and the git-committer agent - * owns the detailed commit workflow (see gitCommitGuidePrompt in - * common/src/constants/git-discipline.ts). - */ -export const gitDisciplineSection = `# Git Discipline - -When the user asks to commit, stage, branch, or push changes, delegate the full git workflow to the \`git-committer\` agent rather than running raw \`git\` commands yourself. Pass exact task-owned paths whenever known. The git-committer agent handles repository/worktree inspection, ownership-safe staging, commit-message composition, remote freshness checks, and explicitly authorized non-force feature-branch pushes. - -- **Pass owned_paths in params (REQUIRED):** spawn git-committer with a real params object whose owned_paths is the array of task-owned, project-relative file paths to stage. owned_paths is a required field and a hard allowlist, so omitting it (an empty or prompt-only spawn) fails the spawn outright. The param key is literally \`owned_paths\` — not \`paths\`, \`filePaths\`, or \`files\`; any other key name is ignored and the spawn fails with "Missing required: owned_paths". Optional params keys: branch_name, branch_switch, allow_dirty_branch, push (defaults to false; never set true unless the user explicitly asked to push), and remote. Put these in params, not only in the prose prompt. -- **Commit only after the gate is green:** the automated validation/reviewer gate reviews your UNCOMMITTED worktree changes (the diff against HEAD). Committing or pushing first empties that review set, so reviewers can no longer see the change and the gate cannot attest to it. Only commit/push (via git-committer) when the pinned line shows **GATE: PASSED** / phase \`final_response_allowed\`. If the user asks to commit before the gate has run, end the turn first so the gate can clear, then commit. The runtime enforces this ordering: spawning git-committer while GATE: PENDING fails with "git-committer withheld", so wait for GATE: PASSED for the pending files, then spawn the committer. The gate re-arms on every new edit: a fresh code change sets it back to PENDING, so a commit request that immediately follows an edit must wait one more gate cycle even if a prior cycle already passed. Treat this as normal ordering, not an error — the gate runs and clears automatically, so do not retry the committer spawn in a tight loop; wait for GATE: PASSED, then spawn it once. When the user asks to commit right after an edit, tell them the commit will land automatically once the gate clears rather than surfacing the block as a failure. Never spawn git-committer on a mere prediction that the gate will pass. -- **Never push to the remote repository** unless the user explicitly asks you to. Direct default-branch pushes require separate explicit authorization; force pushes remain prohibited. -- **Never alter git config** (no \`git config user.name/email\`, no \`--global\` flags). -- **Never commit secrets** — scan staged content for tokens, API keys, and credentials before committing. The git-committer agent does this automatically. -- **Dirty-tree awareness:** the runtime injects Git status before work begins and after model steps. Use that observation before switching branches or starting a new task. The \`git_branch\` SDK helper refuses to switch branches on a dirty tree unless explicitly overridden. -- **Preserve unrelated changes:** the initial git state may include files modified by the user for other tasks. Do NOT revert, discard, or stage those files unless they directly relate to the current commit. -- **Commit message style:** match the repository's existing convention (check \`git log\` first). Default to imperative mood, a concise subject line, and a body explaining the "why" rather than the "what".` - -/** - * Pre-review self-check rubric: prompts the implementer to verify their own - * diff against the same rubric the automated reviewers apply before returning. - * - * NOT byte-frozen — advisory guidance that may evolve with the reviewer rubric. - * - * Interpolated by all three consumers (base2, base-deep, and the editor) - * alongside qualitySection so implementation agents self-check their diff - * before handing it to review. - */ -export const preReviewSelfCheckSection = `# Pre-Review Self-Check - -Before finishing, verify your own diff against the same rubric the automated reviewers apply. Fix violations before returning; do not leave them for review. - -- **Security pass:** user-controlled input is validated and bounded before it reaches file paths, shell commands, queries, or credentials; secrets are never logged, interpolated into errors, or persisted unencrypted; failures deny by default (no swallowed auth/permission errors, no skipped async cleanup). -- **Test coverage:** every behavior-changing edit has a covering test — name the exact test file and case to add; state concretely why coverage is n/a for pure refactors, formatting, or comments. -- **Test quality:** tests exercise the changed branch and assert externally visible state or output; no assertion-free tests or snapshot-only coverage of behavioral logic. -- **Compatibility:** exported symbols, CLI flags, config/environment variables, schemas, persisted formats, and event/error payloads keep backward compatibility; migrations keep rollback paths. -- **Architecture:** dependency directions hold; no deep imports into package internals; no duplicated canonical helpers. -- **Resource safety:** no unbounded reads, collections, retries, or output accumulation; I/O and processes have timeouts; cleanup runs on early return. -- **Hygiene:** no dead code, no missing imports, no unintended deletions, style matches surrounding code, no unnecessary try/catch, no unjustified \`any\` casts.` +- The gate re-arms on every new edit (back to GATE: PENDING); one more clear cycle is required. Treat early withhold as normal ordering; do not tight-loop committer spawns — wait for GATE: PASSED, then spawn once.` diff --git a/agents/e2e/gate-lifecycle.e2e.test.ts b/agents/e2e/gate-lifecycle.e2e.test.ts index b5c5b76cef..1788a4b5d1 100644 --- a/agents/e2e/gate-lifecycle.e2e.test.ts +++ b/agents/e2e/gate-lifecycle.e2e.test.ts @@ -1,4 +1,10 @@ -import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { + existsSync, + mkdirSync, + readdirSync, + rmSync, + writeFileSync, +} from 'node:fs' import path from 'node:path' import { afterEach, describe, expect, test } from 'bun:test' @@ -64,15 +70,25 @@ function editReceipt(path: string) { } } -const SCRATCH_ROOT = '.e2e-scratch/base2-gate-lifecycle' +const SCRATCH_PARENT = '.e2e-scratch' +const SCRATCH_ROOT = `${SCRATCH_PARENT}/base2-gate-lifecycle` const LIFECYCLE_FILE = `${SCRATCH_ROOT}/lifecycle.ts` const MULTI_BATCH_FILE_A = `${SCRATCH_ROOT}/multi-batch-a.ts` const MULTI_BATCH_FILE_B = `${SCRATCH_ROOT}/multi-batch-b.ts` const HAPPY_PATH_FILE = `${SCRATCH_ROOT}/happy-path.ts` const DELETED_FILE = `${SCRATCH_ROOT}/deleted.ts` +const CONDONE_COVERAGE_FILE = `${SCRATCH_ROOT}/condone-coverage.ts` +const FINGERPRINT_DRIFT_FILE = `${SCRATCH_ROOT}/fingerprint-drift.ts` +const PARENT_OWNED_FILE = `${SCRATCH_ROOT}/parent-owned.ts` +const IN_SCOPE_REQUIREMENT_FILE = `${SCRATCH_ROOT}/in-scope-requirement.ts` afterEach(() => { rmSync(SCRATCH_ROOT, { recursive: true, force: true }) + // Leave no residue: the shared '.e2e-scratch' parent is removed too, but only + // once it is empty, because sibling e2e suites own their own roots under it. + if (existsSync(SCRATCH_PARENT) && readdirSync(SCRATCH_PARENT).length === 0) { + rmSync(SCRATCH_PARENT, { recursive: true, force: true }) + } }) function reviewerFingerprintFromSpawn(value: any): string { @@ -88,6 +104,14 @@ function reviewerResult(params: { reviewedFiles: string[] verdict: 'LOOKS_GOOD' | 'NON_BLOCKING' | 'BLOCKING' findings?: string[] + /** Defaults to `covered`; pass `missing` to fire the coverage hard rule. */ + coverage?: 'covered' | 'missing' | 'n/a' + /** Defaults to an empty list. */ + requirementCoverage?: Array<{ + requirement: string + status: string + evidence?: string[] + }> }) { return feedJson({ schemaVersion: 1, @@ -95,7 +119,7 @@ function reviewerResult(params: { snapshotFingerprint: params.snapshotFingerprint, reviewedFiles: params.reviewedFiles, findings: params.findings ?? [], - coverage: 'covered', + coverage: params.coverage ?? 'covered', dimensions: { correctness: 'pass', security: 'pass', @@ -103,7 +127,7 @@ function reviewerResult(params: { apiCompatibility: 'pass', performance: 'pass', }, - requirementCoverage: [], + requirementCoverage: params.requirementCoverage ?? [], }) } @@ -336,11 +360,12 @@ describe('base2 deterministic gate lifecycle e2e', () => { toolName: 'spawn_agent_inline', input: { agent_type: 'context-pruner' }, }) - const maybePinnedState = gen.next().value - if (maybePinnedState !== 'STEP') { - expect(maybePinnedState).toMatchObject({ toolName: 'add_message' }) - expect(gen.next().value).toBe('STEP') - } + // Deterministic: the reviewer-blocker repair round left the gate armed + // (pending file still open, phase advanced), so the pinned active-work + // message is re-emitted exactly once before the model step. Pinning the + // sequence makes a dropped or extra pinned add_message fail here. + expect(gen.next().value).toMatchObject({ toolName: 'add_message' }) + expect(gen.next().value).toBe('STEP') // The repair-editor already applied the fix, so the parent model finishes // without claiming another edit; the gate then snapshots and reviews. @@ -350,9 +375,7 @@ describe('base2 deterministic gate lifecycle e2e', () => { // Invariant 10: the re-entered loop runs validation hooks before the // fresh reviewer (the specialist terminal-failure continue changed the // flow so validation re-runs on re-entry). - const reValidation = gen.next( - feedJson({ status: ` M ${LIFECYCLE_FILE}` }), - ) + const reValidation = gen.next(feedJson({ status: ` M ${LIFECYCLE_FILE}` })) expect(reValidation.value).toMatchObject({ toolName: 'run_file_change_hooks', input: { files: [LIFECYCLE_FILE] }, @@ -374,9 +397,9 @@ describe('base2 deterministic gate lifecycle e2e', () => { }) // The repair loop persists the BLOCKING reviewer's family and explicitly // re-dispatches it after validation; this is not dependent on aux-gate order. - expect((agentState as any).base2ActiveWork.requiredReviewerRevalidation).toBe( - 'code-reviewer', - ) + expect( + (agentState as any).base2ActiveWork.requiredReviewerRevalidation, + ).toBe('code-reviewer') // Invariant 11: only LOOKS_GOOD permits finalization. const finalPreCreditStatus = gen.next( @@ -418,8 +441,177 @@ describe('base2 deterministic gate lifecycle e2e', () => { expect((agentState as any).canSuggestFollowups).toBe(true) }) + // T0.1: a condoned nit must never carry a gate-derived hard rule with it. The + // re-review returns the SAME (condoned) NON_BLOCKING nit plus + // coverage: 'missing'; the coverage rule is not condonable, so the condoned + // pass must not fire and the gate must stay closed. + test('condoned re-review findings cannot bypass the coverage-missing hard rule', () => { + mkdirSync(path.dirname(CONDONE_COVERAGE_FILE), { recursive: true }) + writeFileSync(CONDONE_COVERAGE_FILE, 'export const condone = "before"\n') + const nit = 'Consider renaming the local for clarity.' + const base2 = createBase2('default') + const agentState = { agentId: 'base2-custom' } + const gen = base2.handleSteps!({ + agentState, + prompt: 'Implement the condone-coverage change.', + params: {}, + } as any) + + expect(gen.next().value).toMatchObject({ toolName: 'git_status', input: {} }) + expect(gen.next(feedJson({ status: '' })).value).toMatchObject({ + toolName: 'spawn_agent_inline', + input: { agent_type: 'context-pruner' }, + }) + expect(gen.next().value).toBe('STEP') + expect( + gen.next(finishStepWithToolResult(editReceipt(CONDONE_COVERAGE_FILE))) + .value, + ).toMatchObject({ toolName: 'git_status', input: {} }) + expect( + gen.next(feedJson({ status: ` M ${CONDONE_COVERAGE_FILE}` })).value, + ).toMatchObject({ + toolName: 'run_file_change_hooks', + input: { files: [CONDONE_COVERAGE_FILE] }, + }) + expect( + gen.next(feedJson([{ hookName: 'typecheck', exitCode: 0, stdout: 'ok' }])) + .value, + ).toMatchObject({ toolName: 'git_status', input: {} }) + const firstReviewerSpawn = gen.next( + feedJson({ status: ` M ${CONDONE_COVERAGE_FILE}` }), + ) + expect(firstReviewerSpawn.value).toMatchObject({ + toolName: 'spawn_agents', + input: { agents: [{ agent_type: 'code-reviewer' }] }, + }) + + // Round 1: a NON_BLOCKING prose nit with coverage covered. This is the + // condonable class, so the repair round records it as condoned. + const firstBlocked = gen.next( + reviewerResult({ + snapshotFingerprint: reviewerFingerprintFromSpawn( + firstReviewerSpawn.value, + ), + reviewedFiles: [CONDONE_COVERAGE_FILE], + verdict: 'NON_BLOCKING', + findings: [nit], + }), + ) + expect(firstBlocked.value).toMatchObject({ + toolName: 'add_message', + input: { role: 'user' }, + }) + expect((firstBlocked.value as any).input.content).toContain( + `NON_BLOCKING: ${nit}`, + ) + const repairEditorSpawn = gen.next() + expect(repairEditorSpawn.value).toMatchObject({ + toolName: 'spawn_agents', + input: { agents: [{ agent_type: 'repair-editor' }] }, + }) + const findingIds = ( + repairEditorSpawn.value as any + ).input.agents[0].handoff.findings.map( + (finding: { id: string }) => finding.id, + ) + // Real byte change: the no-progress fingerprint guard compares pre/post + // repair snapshots derived from on-disk bytes. + writeFileSync(CONDONE_COVERAGE_FILE, 'export const condone = "after"\n') + expect( + gen.next( + feedJson({ + agentId: 'repair-editor-1', + agentName: 'Repair Editor', + agentType: 'repair-editor', + value: {}, + agentReceipt: { + schemaVersion: 1, + receiptId: 'condone-repair-receipt', + status: 'completed', + changedFiles: [{ path: CONDONE_COVERAGE_FILE }], + findingsAddressed: findingIds, + requestedValidation: [], + }, + }), + ).value, + ).toMatchObject({ toolName: 'git_status', input: {} }) + expect( + gen.next(feedJson({ status: ` M ${CONDONE_COVERAGE_FILE}` })).value, + ).toMatchObject({ + toolName: 'run_file_change_hooks', + input: { files: [CONDONE_COVERAGE_FILE] }, + }) + expect( + gen.next(feedJson([{ hookName: 'typecheck', exitCode: 0, stdout: 'ok' }])) + .value, + ).toMatchObject({ + toolName: 'spawn_agent_inline', + input: { agent_type: 'context-pruner' }, + }) + // Deterministic: the condone repair round leaves the gate armed, so the + // pinned active-work message is emitted exactly once before the model step. + expect(gen.next().value).toMatchObject({ toolName: 'add_message' }) + expect(gen.next().value).toBe('STEP') + // The repair round condoned the nit text. + expect((agentState as any).base2ActiveWork.condonedFindingTexts).toContain( + nit, + ) + + expect(gen.next(finishStepWithToolResult({})).value).toMatchObject({ + toolName: 'git_status', + }) + expect( + gen.next(feedJson({ status: ` M ${CONDONE_COVERAGE_FILE}` })).value, + ).toMatchObject({ + toolName: 'run_file_change_hooks', + input: { files: [CONDONE_COVERAGE_FILE] }, + }) + expect( + gen.next(feedJson([{ hookName: 'typecheck', exitCode: 0, stdout: 'ok' }])) + .value, + ).toMatchObject({ toolName: 'git_status', input: {} }) + const secondReviewerSpawn = gen.next( + feedJson({ status: ` M ${CONDONE_COVERAGE_FILE}` }), + ) + expect(secondReviewerSpawn.value).toMatchObject({ + toolName: 'spawn_agents', + input: { agents: [{ agent_type: 'code-reviewer' }] }, + }) + + // Round 2: the SAME condoned nit, but now coverage is missing. The condone + // filter suppresses the nit and MUST NOT suppress the coverage hard rule. + const afterSecond = gen.next( + reviewerResult({ + snapshotFingerprint: reviewerFingerprintFromSpawn( + secondReviewerSpawn.value, + ), + reviewedFiles: [CONDONE_COVERAGE_FILE], + verdict: 'NON_BLOCKING', + findings: [nit], + coverage: 'missing', + }), + ) + expect(afterSecond.value).toMatchObject({ + toolName: 'add_message', + input: { role: 'user' }, + }) + const secondText = (afterSecond.value as any).input.content as string + // The gate never credits a pass: no LOOKS_GOOD gate-pass message. + expect(secondText).not.toMatch(/reviewer gate passed with LOOKS_GOOD/i) + expect(secondText).toContain( + 'BLOCKING: test coverage missing for changed behavior (add a case to the relevant *.test.ts)', + ) + const activeWork = (agentState as any).base2ActiveWork + expect(activeWork.openReviewerBlockers).toContain( + 'BLOCKING: test coverage missing for changed behavior (add a case to the relevant *.test.ts)', + ) + expect(activeWork.currentPhase).not.toBe('final_response_allowed') + expect(activeWork.pendingGateFiles).toEqual([CONDONE_COVERAGE_FILE]) + }) + test('validates and credits the cumulative dirty scope for a multi-batch task', () => { - mkdirSync(SCRATCH_ROOT, { recursive: true }) + mkdirSync(path.dirname(MULTI_BATCH_FILE_A), { recursive: true }) + mkdirSync(path.dirname(MULTI_BATCH_FILE_B), { recursive: true }) writeFileSync(MULTI_BATCH_FILE_A, 'export const batchA = "dirty"\n') writeFileSync(MULTI_BATCH_FILE_B, 'export const batchB = "dirty"\n') @@ -559,13 +751,9 @@ describe('base2 deterministic gate lifecycle e2e', () => { toolName: 'spawn_agent_inline', input: { agent_type: 'context-pruner' }, }) - // The pinned active-work message is conditional (empty when there is no - // unresolved gate work), so tolerate its absence. - const maybePinnedState = drive() - if (maybePinnedState !== 'STEP') { - expect(maybePinnedState).toMatchObject({ toolName: 'add_message' }) - expect(drive()).toBe('STEP') - } + // Deterministic: a fresh turn carries no unresolved gate work, so the pinned + // active-work message is empty and the loop steps straight to the model. + expect(drive()).toBe('STEP') expect( drive(finishStepWithToolResult(editReceipt(HAPPY_PATH_FILE))), @@ -577,9 +765,7 @@ describe('base2 deterministic gate lifecycle e2e', () => { expect( drive(feedJson([{ hookName: 'typecheck', exitCode: 0, stdout: 'ok' }])), ).toMatchObject({ toolName: 'git_status', input: {} }) - const reviewerSpawn = drive( - feedJson({ status: ` M ${HAPPY_PATH_FILE}` }), - ) + const reviewerSpawn = drive(feedJson({ status: ` M ${HAPPY_PATH_FILE}` })) expect(reviewerSpawn).toMatchObject({ toolName: 'spawn_agents', input: { agents: [{ agent_type: 'code-reviewer' }] }, @@ -656,17 +842,17 @@ describe('base2 deterministic gate lifecycle e2e', () => { // Continuity prompt starts from an explicit working-tree snapshot. expect(gen.next().value).toMatchObject({ toolName: 'git_status', input: {} }) - expect(gen.next(feedJson({ status: ` D ${DELETED_FILE}` })).value).toMatchObject({ + expect( + gen.next(feedJson({ status: ` D ${DELETED_FILE}` })).value, + ).toMatchObject({ toolName: 'spawn_agent_inline', input: { agent_type: 'context-pruner' }, }) - // The pinned active-work message is conditional (pendingGateFiles is empty), - // so tolerate its absence before the first model step. - const maybePinnedState = gen.next().value - if (maybePinnedState !== 'STEP') { - expect(maybePinnedState).toMatchObject({ toolName: 'add_message' }) - expect(gen.next().value).toBe('STEP') - } + // Deterministic: the carried-over state is phase awaiting_validation, which + // is unresolved gate work, so the pinned active-work message is emitted + // exactly once before the first model step. + expect(gen.next().value).toMatchObject({ toolName: 'add_message' }) + expect(gen.next().value).toBe('STEP') // The model turns the on-disk deletion into a tracked pending change. expect( @@ -736,16 +922,16 @@ describe('base2 deterministic gate lifecycle e2e', () => { toolName: 'spawn_agent_inline', input: { agent_type: 'context-pruner' }, }) - const followupPinnedState = followupGen.next().value - if (followupPinnedState !== 'STEP') { - expect(followupPinnedState).toMatchObject({ toolName: 'add_message' }) - expect(followupGen.next().value).toBe('STEP') - } + // Deterministic: the prior turn's gate pass left no unresolved gate work + // (the deletion keeps its credited 'missing' marker, so nothing re-arms), + // hence no pinned add_message on this turn. + expect(followupGen.next().value).toBe('STEP') // No new edits: the step completes with an empty tool result and the gate // stays closed — uncommittedUnvalidatedFiles never re-adds the deleted path. - expect( - followupGen.next(finishStepWithToolResult({})).value, - ).toMatchObject({ toolName: 'git_status', input: {} }) + expect(followupGen.next(finishStepWithToolResult({})).value).toMatchObject({ + toolName: 'git_status', + input: {}, + }) const followupActiveWork = (agentState as any).base2ActiveWork expect(followupActiveWork.gatePassedFiles).toEqual([DELETED_FILE]) @@ -768,4 +954,288 @@ describe('base2 deterministic gate lifecycle e2e', () => { } expect(drain.done).toBe(true) }) + + // RF-4: a coverage-complete review whose fingerprint is well-formed but does + // NOT match the expected snapshot is tolerated (the gate still credits + // LOOKS_GOOD) and the tolerated drift is recorded exactly once as gate + // telemetry with dedicated reported/expected fingerprint keys. + test('tolerates a well-formed non-matching reviewer fingerprint and records the drift once', () => { + mkdirSync(path.dirname(FINGERPRINT_DRIFT_FILE), { recursive: true }) + writeFileSync(FINGERPRINT_DRIFT_FILE, 'export const drift = "before"\n') + const driftFingerprint = `v3:${'e'.repeat(64)}` + const telemetry: Array> = [] + const originalInfo = console.info + console.info = (...args: unknown[]) => { + const [first] = args + if (typeof first === 'string' && first.includes('"base2.gate"')) { + telemetry.push(JSON.parse(first) as Record) + } + } + try { + const base2 = createBase2('default') + const agentState = { agentId: 'base2-custom' } + const gen = base2.handleSteps!({ + agentState, + prompt: 'Implement the drift change.', + params: {}, + } as any) + + expect(gen.next().value).toMatchObject({ + toolName: 'git_status', + input: {}, + }) + expect(gen.next(feedJson({ status: '' })).value).toMatchObject({ + toolName: 'spawn_agent_inline', + input: { agent_type: 'context-pruner' }, + }) + expect(gen.next().value).toBe('STEP') + expect( + gen.next(finishStepWithToolResult(editReceipt(FINGERPRINT_DRIFT_FILE))) + .value, + ).toMatchObject({ toolName: 'git_status', input: {} }) + expect( + gen.next(feedJson({ status: ` M ${FINGERPRINT_DRIFT_FILE}` })).value, + ).toMatchObject({ + toolName: 'run_file_change_hooks', + input: { files: [FINGERPRINT_DRIFT_FILE] }, + }) + expect( + gen.next( + feedJson([{ hookName: 'typecheck', exitCode: 0, stdout: 'ok' }]), + ).value, + ).toMatchObject({ toolName: 'git_status', input: {} }) + const reviewerSpawn = gen.next( + feedJson({ status: ` M ${FINGERPRINT_DRIFT_FILE}` }), + ) + expect(reviewerSpawn.value).toMatchObject({ + toolName: 'spawn_agents', + input: { agents: [{ agent_type: 'code-reviewer' }] }, + }) + const expectedFingerprint = reviewerFingerprintFromSpawn( + reviewerSpawn.value, + ) + expect(expectedFingerprint).not.toBe(driftFingerprint) + + // Coverage-complete review (every pending file attested) that echoes a + // well-formed but different v3 fingerprint. + const preCreditStatus = gen.next( + reviewerResult({ + snapshotFingerprint: driftFingerprint, + reviewedFiles: [FINGERPRINT_DRIFT_FILE], + verdict: 'LOOKS_GOOD', + }), + ) + expect(preCreditStatus.value).toMatchObject({ + toolName: 'git_status', + input: {}, + }) + const gatePassed = gen.next( + feedJson({ status: ` M ${FINGERPRINT_DRIFT_FILE}` }), + ) + expect(gatePassed.value).toMatchObject({ + toolName: 'add_message', + input: { role: 'user' }, + }) + const passText = (gatePassed.value as any).input.content as string + expect(passText).toContain( + 'Automated validation and reviewer gate passed with LOOKS_GOOD', + ) + expect((agentState as any).base2ActiveWork).toMatchObject({ + currentPhase: 'final_response_allowed', + pendingGateFiles: [], + openReviewerBlockers: [], + gatePassedReviewerVerdict: 'LOOKS_GOOD', + }) + + // The tolerated drift is recorded exactly once, with dedicated payload + // keys instead of a concatenated reuseReason string. + const driftEvents = telemetry.filter( + (event) => event.reviewerStatus === 'attestation-fingerprint-drift', + ) + expect(driftEvents).toHaveLength(1) + expect(driftEvents[0]).toMatchObject({ + event: 'base2.gate', + reviewer: 'code-reviewer', + reportedFingerprint: driftFingerprint, + expectedFingerprint, + pendingFiles: [FINGERPRINT_DRIFT_FILE], + }) + expect(driftEvents[0]!.reuseReason).toBeUndefined() + } finally { + console.info = originalInfo + } + }) + + // RF-4: gate-level coverage for the final code-reviewer's parent-owned filter + // (base2 classifies `rawCollectedBlockers` in a single structured walk and + // reuses that one set for the `hardBlockers` filter too). A LOOKS_GOOD review + // whose only requirementCoverage gaps are parent-owned process duties — + // including one that is parent-owned only via evidence — must credit the gate + // with no repair spawn. + test('credits LOOKS_GOOD when every requirement gap is parent-owned process work', () => { + mkdirSync(path.dirname(PARENT_OWNED_FILE), { recursive: true }) + writeFileSync(PARENT_OWNED_FILE, 'export const parentOwned = "before"\n') + const base2 = createBase2('default') + const agentState = { agentId: 'base2-custom' } + const gen = base2.handleSteps!({ + agentState, + prompt: 'Implement the parent-owned requirement change.', + params: {}, + } as any) + + expect(gen.next().value).toMatchObject({ toolName: 'git_status', input: {} }) + expect(gen.next(feedJson({ status: '' })).value).toMatchObject({ + toolName: 'spawn_agent_inline', + input: { agent_type: 'context-pruner' }, + }) + expect(gen.next().value).toBe('STEP') + expect( + gen.next(finishStepWithToolResult(editReceipt(PARENT_OWNED_FILE))).value, + ).toMatchObject({ toolName: 'git_status', input: {} }) + expect( + gen.next(feedJson({ status: ` M ${PARENT_OWNED_FILE}` })).value, + ).toMatchObject({ + toolName: 'run_file_change_hooks', + input: { files: [PARENT_OWNED_FILE] }, + }) + expect( + gen.next(feedJson([{ hookName: 'typecheck', exitCode: 0, stdout: 'ok' }])) + .value, + ).toMatchObject({ toolName: 'git_status', input: {} }) + const reviewerSpawn = gen.next( + feedJson({ status: ` M ${PARENT_OWNED_FILE}` }), + ) + expect(reviewerSpawn.value).toMatchObject({ + toolName: 'spawn_agents', + input: { agents: [{ agent_type: 'code-reviewer' }] }, + }) + + const preCreditStatus = gen.next( + reviewerResult({ + snapshotFingerprint: reviewerFingerprintFromSpawn(reviewerSpawn.value), + reviewedFiles: [PARENT_OWNED_FILE], + verdict: 'LOOKS_GOOD', + requirementCoverage: [ + { requirement: 'Commit and push', status: 'missing' }, + { requirement: 'Confirm CI/CD is green', status: 'uncertain' }, + // Parent-owned only via evidence; the requirement text alone is + // in-scope, so the gate must consult the structured evidence. + { + requirement: 'Ship remaining workflow steps', + status: 'missing', + evidence: [ + 'parent must run full validation gate after this specialist', + ], + }, + ], + }), + ) + expect(preCreditStatus.value).toMatchObject({ + toolName: 'git_status', + input: {}, + }) + const gatePassed = gen.next(feedJson({ status: ` M ${PARENT_OWNED_FILE}` })) + expect(gatePassed.value).toMatchObject({ + toolName: 'add_message', + input: { role: 'user' }, + }) + const passText = (gatePassed.value as any).input.content as string + expect(passText).toContain( + 'Automated validation and reviewer gate passed with LOOKS_GOOD', + ) + // No repair round was opened for the parent-owned process gaps. + expect(passText).not.toContain('BLOCKING: requirement missing:') + expect((agentState as any).base2ActiveWork).toMatchObject({ + currentPhase: 'final_response_allowed', + pendingGateFiles: [], + openReviewerBlockers: [], + gatePassedReviewerVerdict: 'LOOKS_GOOD', + }) + }) + + // RF-4 (negative): one in-scope requirement row alongside the parent-owned + // ones must survive the same filter and keep the gate closed with a repair + // target instead of finalizing silently. + test('keeps the gate closed when one requirement gap is in-scope work', () => { + mkdirSync(path.dirname(IN_SCOPE_REQUIREMENT_FILE), { recursive: true }) + writeFileSync(IN_SCOPE_REQUIREMENT_FILE, 'export const inScope = "before"\n') + const base2 = createBase2('default') + const agentState = { agentId: 'base2-custom' } + const gen = base2.handleSteps!({ + agentState, + prompt: 'Implement the in-scope requirement change.', + params: {}, + } as any) + + expect(gen.next().value).toMatchObject({ toolName: 'git_status', input: {} }) + expect(gen.next(feedJson({ status: '' })).value).toMatchObject({ + toolName: 'spawn_agent_inline', + input: { agent_type: 'context-pruner' }, + }) + expect(gen.next().value).toBe('STEP') + expect( + gen.next(finishStepWithToolResult(editReceipt(IN_SCOPE_REQUIREMENT_FILE))) + .value, + ).toMatchObject({ toolName: 'git_status', input: {} }) + expect( + gen.next(feedJson({ status: ` M ${IN_SCOPE_REQUIREMENT_FILE}` })).value, + ).toMatchObject({ + toolName: 'run_file_change_hooks', + input: { files: [IN_SCOPE_REQUIREMENT_FILE] }, + }) + expect( + gen.next(feedJson([{ hookName: 'typecheck', exitCode: 0, stdout: 'ok' }])) + .value, + ).toMatchObject({ toolName: 'git_status', input: {} }) + const reviewerSpawn = gen.next( + feedJson({ status: ` M ${IN_SCOPE_REQUIREMENT_FILE}` }), + ) + expect(reviewerSpawn.value).toMatchObject({ + toolName: 'spawn_agents', + input: { agents: [{ agent_type: 'code-reviewer' }] }, + }) + + const reviewerBlocked = gen.next( + reviewerResult({ + snapshotFingerprint: reviewerFingerprintFromSpawn(reviewerSpawn.value), + reviewedFiles: [IN_SCOPE_REQUIREMENT_FILE], + verdict: 'LOOKS_GOOD', + requirementCoverage: [ + { requirement: 'Commit and push', status: 'missing' }, + { + requirement: 'preserve CLI compatibility', + status: 'missing', + evidence: ['flag parsing changed'], + }, + ], + }), + ) + expect(reviewerBlocked.value).toMatchObject({ + toolName: 'add_message', + input: { role: 'user' }, + }) + const blockedText = (reviewerBlocked.value as any).input.content as string + expect(blockedText).toContain( + 'BLOCKING: requirement missing: preserve CLI compatibility', + ) + // The parent-owned gap is still filtered out of the repair target set. + expect(blockedText).not.toContain( + 'BLOCKING: requirement missing: Commit and push', + ) + expect(blockedText).not.toMatch(/reviewer gate passed with LOOKS_GOOD/i) + const activeWork = (agentState as any).base2ActiveWork + expect(activeWork.openReviewerBlockers).toContain( + 'BLOCKING: requirement missing: preserve CLI compatibility', + ) + expect(activeWork.openReviewerBlockers).not.toContain( + 'BLOCKING: requirement missing: Commit and push', + ) + expect(activeWork.currentPhase).not.toBe('final_response_allowed') + expect(activeWork.pendingGateFiles).toEqual([IN_SCOPE_REQUIREMENT_FILE]) + // The surviving blocker drives a repair round instead of finalizing. + expect(gen.next().value).toMatchObject({ + toolName: 'spawn_agents', + input: { agents: [{ agent_type: 'repair-editor' }] }, + }) + }) }) diff --git a/agents/guides/broad-audit.md b/agents/guides/broad-audit.md index 55f8be80f3..526fa21381 100644 --- a/agents/guides/broad-audit.md +++ b/agents/guides/broad-audit.md @@ -1,4 +1,4 @@ -## Broad audit / exploration requests — scope first, then shard +# Broad audit / exploration requests — scope first, then shard For broad, open-ended, or audit-style requests (for example: "check this codebase for any feature improvements", "audit the codebase for security/correctness/perf issues", "assess this codebase for how production ready it is on a feature, security and code level", "find all the places X is handled", "what can be improved in the agents/sdk/cli", or anything where the relevant surface is not already obvious), do NOT default to a single surface-level codesearch or one or two file reads. Instead, run a deliberate scope-then-shard flow: diff --git a/agents/guides/pre-review-self-check.md b/agents/guides/pre-review-self-check.md new file mode 100644 index 0000000000..7dedfce706 --- /dev/null +++ b/agents/guides/pre-review-self-check.md @@ -0,0 +1,14 @@ +# Pre-Review Self-Check + +Before finishing, verify your own diff against the same rubric the automated reviewers apply. Fix violations before returning; do not leave them for review. The reviewer scores correctness, security, tests, apiCompatibility, and performance as separate dimensions, and it must attest to every changed file. + +- **Security pass:** user-controlled input is validated and bounded before it reaches file paths, shell commands, queries, or credentials; secrets are never logged, interpolated into errors, or persisted unencrypted; failures deny by default (no swallowed auth/permission errors, no skipped async cleanup). +- **Test coverage (blocking):** every behavior-changing edit has a covering test — name the exact test file and case that covers the new branch. Missing coverage for changed behavior blocks finalization; state concretely why coverage is not applicable for pure refactors, formatting, or comments. +- **Test quality:** tests exercise the changed branch and assert externally visible state or output; no assertion-free tests or snapshot-only coverage of behavioral logic. +- **Requirement coverage (blocking):** the reviewer maps every user requirement and acceptance criterion to `satisfied` / `missing` / `uncertain`, and `uncertain` blocks exactly like `missing`. Before returning, enumerate the requirements and name the file and symbol that satisfies each one. Ambiguity is a block, not a hedge — resolve it by reading rather than leaving it for review. +- **File attestation:** every changed file is reviewed and accounted for, and changed tests are first-class review targets rather than incidental. Do not leave a changed file whose purpose you cannot state. +- **Advisory vs blocking:** cosmetic and stylistic observations are advisory and must not hold the turn open. Do not pre-emptively refactor surrounding code for style, and do not leave a material correctness, security, compatibility, or coverage issue unfixed on the grounds that it is small. +- **Compatibility:** exported symbols, CLI flags, config/environment variables, schemas, persisted formats, and event/error payloads keep backward compatibility; migrations keep rollback paths. +- **Architecture:** dependency directions hold; no deep imports into package internals; no duplicated canonical helpers. +- **Resource safety:** no unbounded reads, collections, retries, or output accumulation; I/O and processes have timeouts; cleanup runs on early return. +- **Hygiene:** no dead code, no missing imports, no unintended deletions, style matches surrounding code, no unnecessary try/catch, no unjustified `any` casts. diff --git a/agents/reviewer/code-reviewer.ts b/agents/reviewer/code-reviewer.ts index 1e81a96ad4..4e10046364 100644 --- a/agents/reviewer/code-reviewer.ts +++ b/agents/reviewer/code-reviewer.ts @@ -47,7 +47,69 @@ export const createReviewer = ( }, snapshotFingerprint: { type: 'string' }, reviewedFiles: { type: 'array', items: { type: 'string' } }, - findings: { type: 'array', items: { type: 'string' } }, + // T1.3: a finding is either a plain string or an OPTIONAL metadata + // object. The object form lets the reviewer supply a stable `id`, which + // the gate correlates across repair rounds instead of re-minting a + // content-hash `RF-...` id every round, plus optional `severity` / + // `dimension` labels the gate records as telemetry only. `evidence` and + // `correction` are declared because the gate COMPACTS both into the + // durable review receipt (base2's `recordSuccessfulReviewReceipt`); + // without them a schema-conforming reviewer could never populate that + // handling. That persistence is id-gated: gate-reviewer's + // `findingRecords` drops an object finding without an `id`, so + // evidence/correction only reach the receipt alongside a stable `id`. Plain strings stay valid so nothing about an existing reviewer + // breaks; `text` is the only required object field. Enums mirror + // security-reviewer's severity scale and this schema's own `dimensions` + // keys. + findings: { + type: 'array', + items: { + anyOf: [ + { type: 'string' }, + { + type: 'object', + properties: { + id: { type: 'string' }, + text: { type: 'string' }, + severity: { + type: 'string', + enum: ['critical', 'high', 'medium', 'low'], + }, + dimension: { + type: 'string', + enum: [ + 'correctness', + 'security', + 'tests', + 'apiCompatibility', + 'performance', + ], + }, + evidence: { type: 'array', items: { type: 'string' } }, + correction: { type: 'string' }, + }, + required: ['text'], + }, + ], + }, + }, + // `findings` are repair targets that re-enter the loop; `advisories` + // never block and never re-enter it. + // + // Reviewer-family symmetry: this field is ADDITIVE and stays out of + // `required`, so the asymmetry with the other reviewer families is + // intentional rather than a pending migration. A family that does not + // declare it (security-reviewer, routed specialists) simply reports none: + // gate-reviewer's `collectReviewerAdvisories` reads a missing/unusable + // field as zero advisories, and the durable receipt then omits both + // `advisories` and `advisoryCount`. + // + // Advisory text is stored and displayed verbatim: base2's + // `formatGateStateBlock` escapes `` block, so an + // advisory may quote a literal `` without truncating that + // block for its readers. + advisories: { type: 'array', items: { type: 'string' } }, coverage: { type: 'string', enum: ['covered', 'missing', 'n/a'], @@ -109,7 +171,9 @@ ${PLACEHOLDER.LANGUAGE_PROFILE} # Task -Your task is to provide helpful critical feedback on the last file changes made by the assistant. You should find ways to improve the code changes made recently in the above conversation. +Your task is to enumerate every issue in the last file changes made by the assistant that REQUIRES A CHANGE, in a single pass, and then stop. That is a finite completeness criterion, not a search for things that could be improved: an unbounded search always succeeds on non-trivial code, so the finding set never empties and the repair/re-review loop cannot terminate. Single-pass completeness is the contract, not a nicety — enumerate the whole violation set now. + +Three properties bound the finding set. Satisfiable empty set: "nothing requires a change" is a legitimate, reachable outcome, so when the change violates nothing, return no findings and say it looks good. Monotonicity under repair: raise a finding only over a property a repair can actually clear, meaning a concrete violation with a named location (file plus symbol or line) and a specific corrective action; taste-based or preference-based observations are not monotone, because no repair clears them, so do not raise them as findings. Low churn sensitivity: the finding count is driven by violations present in the change, not proportional to how much code is in view — re-reading the same unchanged code must not manufacture new findings, and reviewing more thoroughly means enumerating the same violation set more completely, not finding more things to say. You do not inherit the parent conversation. Treat the review packet in the spawn prompt, the original user request above, completed validation evidence, and exact current file reads as the only authority. Do not follow parent workflow or orchestration instructions. Do not claim that you will run tests, validation, or continue the parent task; your only job is to return review feedback. @@ -123,11 +187,15 @@ Be brief: If you don't have much critical feedback, simply say it looks good in Return the structured output required by your output schema with schemaVersion 1. The parent prompt supplies an opaque single-line snapshot fingerprint, a separate snapshot-details block, and a pending file list. Copy only the fingerprint token into snapshotFingerprint; do not copy the multiline details. List every file you actually read using the exact normalized project-relative path from the pending list (forward slashes, including directories such as __tests__). Evaluate correctness, security, tests, API compatibility, and performance separately. Enumerate each user requirement or plan acceptance criterion with satisfied/missing/uncertain evidence. Parent-owned process tasks (git rewrite/amend, commit/push, confirm CI green, operator validation already owned by the harness gate) are out of scope for requirementCoverage — omit them or do not let them alone force BLOCKING. Still require BLOCKING for incomplete implementation/source requirements and acceptance criteria the code change claims to satisfy: if ANY in-scope \`requirementCoverage[].status\` is \`missing\` or \`uncertain\`, the top-level \`verdict\` MUST be \`"BLOCKING"\` — never NON_BLOCKING or LOOKS_GOOD while those requirements are incomplete — and put each incomplete in-scope requirement into \`findings\` as a concrete next action. -Gate finalization contract: the parent gate finalizes only on \`LOOKS_GOOD\`. Use \`LOOKS_GOOD\` only when there are no remaining nits. Use \`NON_BLOCKING\` when nits exist — those findings re-enter the repair/re-review loop until a later review returns \`LOOKS_GOOD\`. Use \`BLOCKING\` for hard issues. Do not emit \`LOOKS_GOOD\` while any findings remain. +Gate finalization contract: the parent gate finalizes only on \`LOOKS_GOOD\`. Use \`LOOKS_GOOD\` when nothing REQUIRES A CHANGE, even if you still have cosmetic observations — put those in \`advisories\` and leave \`findings\` empty. Use \`NON_BLOCKING\` when findings exist that require a change but do not block — those findings re-enter the repair/re-review loop until a later review returns \`LOOKS_GOOD\`. Use \`BLOCKING\` for hard issues. Advisories are recorded in the durable review receipt and shown to the user, so nothing is lost by keeping them out of \`findings\`; a \`LOOKS_GOOD\` verdict must still carry an EMPTY \`findings\` array. + +\`advisories\` is the channel for observations that do not require a change: comment density, naming taste, optional refactors, speculative future-proofing — the same class the "Monotonicity under repair" paragraph says must not be raised as findings. Use at most 8 advisories, one short line each. You must call \`set_output\` with one object that satisfies the declared output schema. Do not finish with prose, a Markdown JSON block, or a textual verdict label: those do not populate structured agent output and the parent will receive \`null\`. Put the verdict in the schema's \`verdict\` field. Missing test coverage for a behavior-changing edit requires \`verdict: "BLOCKING"\` and \`coverage: "missing"\`. For blocking or non-blocking feedback, put the exact next actions in \`findings\`; prefer one comprehensive list over drip-feeding issues across review cycles. -Pass structured fields as native object values. Never call \`JSON.stringify\`, never put serialized JSON text inside \`data\`, and never wrap the result in a Markdown fence. Keep the receipt compact: deduplicate findings, use at most 12 findings, and use at most 2 concise evidence strings per requirement. Each \`dimensions.*\` value must be at most ~40 words / one short sentence — prefer a status word plus one clause (e.g. \`"pass: guards close cleanly"\`), never multi-paragraph essays. Long dimension prose blows the tool-call payload and can truncate the receipt mid-object. +A \`findings\` entry may be a plain string or an object \`{ id, text, severity, dimension }\`. Prefer the object form whenever you can supply a stable \`id\`: the gate correlates findings across repair rounds by \`id\`, so a stable id lets a re-raised finding be recognized as the SAME finding rather than a new one, which is what allows the repair loop to converge. Keep the id stable across rounds for the same underlying violation — derive it from the location and the rule it violates (e.g. \`code-reviewer:src/a.ts:unchecked-null\`), never from your wording, which changes between rounds. \`text\` is the only required object field; \`severity\` (\`critical\`/\`high\`/\`medium\`/\`low\`) and \`dimension\` (one of the five review dimensions) are optional labels the gate records as telemetry and does not currently act on, so omit them rather than guessing. \`evidence\` (at most 3 short quoted observations) and \`correction\` (one concise line naming the fix) are optional too, and the gate compacts both into its durable review receipt only for findings that carry a stable \`id\` (an id-less finding's evidence and correction are dropped), so pair them with an \`id\` when you already have them and omit them otherwise. + +Pass structured fields as native object values. Never call \`JSON.stringify\`, never put serialized JSON text inside \`data\`, and never wrap the result in a Markdown fence. Keep the receipt compact: deduplicate findings, use at most 12 findings, and use at most 2 concise evidence strings per requirement. Each \`dimensions.*\` value must be at most ~40 words / one short sentence — prefer a status word plus one clause (e.g. \`"pass: guards close cleanly"\`), never multi-paragraph essays. A FAILING dimension must start with \`block\`, \`blocks\`, \`blocking\`, or \`blocker(s)\` (e.g. \`"block: unbounded read in loadAll"\`): the gate only turns a dimension into a hard blocker when its first word matches that prefix, so \`fail: ...\` or \`blocked: ...\` is silently non-blocking. Long dimension prose blows the tool-call payload and can truncate the receipt mid-object. Type check before calling \`set_output\`: \`schemaVersion\` is the number \`1\`; \`reviewedFiles\`, \`findings\`, and \`requirementCoverage\` are arrays; \`dimensions\` is an object. For example, use \`reviewedFiles: ["src/a.ts"]\`, never \`reviewedFiles: "[\\\"src/a.ts\\\"]"\`. A successful file read plus prose or stringified fields is not a completed review receipt. diff --git a/cli/knowledge.md b/cli/knowledge.md index 32f0aa7849..7b40a72b5d 100644 --- a/cli/knowledge.md +++ b/cli/knowledge.md @@ -13,6 +13,7 @@ - Durable planning is entered through `mode:plan`; the standalone `/plan` command is intentionally absent from `COMMAND_REGISTRY` and `SLASH_COMMANDS` so there is one plan-entry path. - Keep the durable-plan quartet registered: `/resume-plan` (`rp`), `/update-plan` (`up`), `/plan-status` (`ps`), and `/lessons` (`lesson`). These commands operate on `.agents/sessions//` artifacts and fall back to the plan-session picker when no target is provided. +- `/plans` (`plan-ls`) lists the artifact-bearing sessions returned by `listPlanSessions()` and never prompts the agent, while `/plan-use` (`plan-active`, `use-plan`) writes the `ACTIVE_SESSION` pointer file under `.agents/` and only accepts a bare slug or `.agents/sessions/` — the resolved directory must be exactly one segment under `.agents/sessions/` because the pointer stores bare slugs, so nested paths, non-session paths, missing directories, and artifact-less directories are all rejected before any write. Both share `formatPlanSessionListRow` / `PLAN_SESSIONS_DIR_PREFIX` from `cli/src/commands/plan-artifacts.ts` so the rendered box and the text report cannot drift. - `/memory` (alias `/mem`, `cli/src/commands/memory-command.ts`, registered in `command-registry.ts` and listed in `data/slash-commands.ts`) inspects the persisted cross-session task memory for the current project (`.openbuff/memory/task-memory.json`). `/memory status` (the default) reports the record's revision and age, its goal and per-list counts, and how much of its evidence still verifies against disk, listing up to five stale paths; `/memory prune` drops evidence that no longer verifies. Both subcommands are move-aware via `collectWorkspaceMoves` + `WorkspaceJournalService` so a renamed file's evidence rebinds rather than being reported stale and deleted. - Slash-command descriptions should stay model-agnostic under BYOK/local mode. Use wording such as "configured reviewer" rather than naming hosted models. @@ -865,7 +866,7 @@ Streaming markdown renders as plain text until the message or agent finishes. Th - Release validation runs `bun --cwd=scripts run guard:memory-drift`; if CLI `src/` or tmux interaction behavior changes, refresh `cli/knowledge.md` and/or `cli/tmux.knowledge.md` in the same commit so the staleness guard stays green. - Gate vs Specialist routing canonical matrix lives in `agents/base2/quality-prompt-section.ts` (`specialistRoutingSection`) and `agents/guides/specialist-routing.md`; `docs/agents-and-tools.md` links there for the Params Contract (`snapshot_id` vs `snapshot_fingerprint`). - Index workspace watching now classifies file changes before notifying the index manager: ignored top-level build/cache directories and the configured cache dir are skipped, file updates and deletes are batched as path-specific deltas, ambiguous directory/watch errors mark the index stale, and at most four project roots keep active recursive watchers. -- `cli/src/components/renderers/gate-state-box.tsx` renders `` blocks as a bordered box supporting exactly the four `GateStateStatus` values from `cli/src/types/chat.ts` — `pending` (`…`, warning), `passed` (`✓`, success), `failed` (`✗`, error), and `skipped` (`–`, warning) — with the heading format ` · · `; keep `STATUS_LABEL`, `STATUS_ICON`, and `statusColor` exhaustive when adding a status. +- `cli/src/components/renderers/gate-state-box.tsx` renders `` blocks as a bordered box supporting exactly the four `GateStateStatus` values from `cli/src/types/chat.ts` — `pending` (`…`, warning), `passed` (`✓`, success), `failed` (`✗`, error), and `skipped` (`–`, warning) — with the heading format ` · · `; keep `STATUS_LABEL`, `STATUS_ICON`, and `statusColor` exhaustive when adding a status. The block schema is `gate`/`status` (required) plus optional `details`, `origin`, and `advisories`: `advisories` is an additive array of non-empty reviewer observations that never changes the gate status, is dropped entirely by `parseGateStateBlock` unless every entry is a non-empty string, and renders under an "Advisory (non-blocking):" bulleted list after `details`. base2 bounds it to 8 entries of ≤240 chars per block, so downstream consumers must treat it as optional and already-truncated. - `cli/src/commands/git-command-args.ts` parses user-supplied `/diff` and `/status` arguments with `parseSafeGitArgs`, which rejects shell operators and expansions (newline, `;`, `$`, backtick, `|`, `&`, `<`, `>`, `\`) and unclosed quotes while intentionally allowing `()[]{}` so git pathspec magic such as `:(exclude)` still works; `quoteShellArgument` single-quotes each argument (escaping embedded single quotes) and `buildSafeGitCommand` assembles the final command with a fallback argument list, so route any new git-backed slash command through these helpers instead of interpolating raw input. - `cli/src/utils/sdk-event-handlers.ts` consumes the additive `job_update` print-mode event through a catch-all branch, so unknown future event variants stay no-ops rather than throwing. - `cli/src/data/initial-agent-type-sources.generated.ts` is regenerated by the repository-root `bun scripts/generate-tool-definitions.ts`, so any new public tool schema (e.g. the `occurrence` selector on `replace_range`, or the `windows`/`around`/`symbol` selectors on `read_files`) must land with a regenerated, committed copy of that file — CI verifies it is current. The same freshness check gates the three `tools.ts` type sources (`agents/`, `.agents/`, `common/src/templates/`), so a `list_jobs` description/schema change also requires regenerating and committing those. diff --git a/cli/src/commands/__tests__/command-args.test.ts b/cli/src/commands/__tests__/command-args.test.ts index 0911b09522..ca4c3d0814 100644 --- a/cli/src/commands/__tests__/command-args.test.ts +++ b/cli/src/commands/__tests__/command-args.test.ts @@ -10,10 +10,21 @@ import { COMMAND_REGISTRY, defineCommand, defineCommandWithArgs, + formatPlanListReport, + planListActiveState, } from '../command-registry' -import { listPlanSessions } from '../plan-artifacts' +import { + ACTIVE_SESSION_FILE_NAME, + listPlanSessions, + PLAN_ARTIFACT_NAMES, +} from '../plan-artifacts' import type { RouterParams } from '../command-registry' +import type { + ContentBlock, + PlanStatusContentBlock, + TextContentBlock, +} from '../../types/chat' /** * Tests for the command factory pattern. @@ -164,7 +175,7 @@ describe('command factory pattern', () => { }) test('expected commands ignore args', () => { - const expectedNoArgs = ['exit', 'help', 'init'] + const expectedNoArgs = ['exit', 'help', 'init', 'plans'] for (const name of expectedNoArgs) { const cmd = COMMAND_REGISTRY.find((c) => c.name === name) expect(cmd, `Command ${name} should exist`).toBeDefined() @@ -185,6 +196,7 @@ describe('command factory pattern', () => { 'resume-plan', 'update-plan', 'plan-status', + 'plan-use', 'lessons', 'mode:default', 'mode:plan', @@ -230,6 +242,19 @@ describe('command factory pattern', () => { ).toBeDefined() } }) + + test('registers /plans and /plan-use with their aliases', () => { + const plansCmd = COMMAND_REGISTRY.find((c) => c.name === 'plans') + expect(plansCmd, 'Command plans should remain registered').toBeDefined() + expect(plansCmd?.aliases).toEqual(['plan-ls']) + + const planUseCmd = COMMAND_REGISTRY.find((c) => c.name === 'plan-use') + expect( + planUseCmd, + 'Command plan-use should remain registered', + ).toBeDefined() + expect(planUseCmd?.aliases).toEqual(['plan-active', 'use-plan']) + }) }) describe('new command arg handling', () => { @@ -302,6 +327,75 @@ describe('command factory pattern', () => { fs.writeFileSync(path.join(dir, name), body, 'utf8') } + /** + * Write a STATE.json with fixed timestamps so listPlanSessions() returns + * deterministic summaries (the synthesized fallback stamps `new Date()`). + */ + const writeSessionState = ( + slug: string, + status: string, + currentTask: string | null = null, + ) => { + writeArtifact( + slug, + 'STATE.json', + JSON.stringify({ + schemaVersion: 2, + slug, + status, + currentTask, + revision: 1, + checkpoint: null, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }), + ) + } + + const activeSessionPointerPath = () => + path.join(tmpRoot, '.agents', ACTIVE_SESSION_FILE_NAME) + + /** The stale-active-session note `/plans` emits for a pointer slug. */ + const staleNoteFor = (slug: string) => + `Stale active session: ${slug} (no listed plan session matches .agents/${ACTIVE_SESSION_FILE_NAME}). Use /plan-use to point at an existing session.` + + type MessagesUpdater = ( + prev: unknown[], + ) => Array<{ content: string; blocks?: ContentBlock[] }> + + /** + * The system message appended by a local (non-sending) command handler. + * Local handlers append exactly once, so the single-call expectation keeps an + * extra setMessages call from being silently ignored while the message is + * still read from the last call. + */ + const lastSystemMessage = ( + setMessagesCalls: unknown, + ): { content: string; blocks?: ContentBlock[] } => { + const calls = setMessagesCalls as Array<[MessagesUpdater]> + expect(calls.length).toBe(1) + const next = calls[calls.length - 1][0]([]) + return next[next.length - 1] + } + + /** The `/plans` list block, narrowed off the emitted block list. */ + const planListBlock = ( + blocks: ContentBlock[] | undefined, + ): PlanStatusContentBlock => { + const block = blocks?.[0] + expect(block?.type).toBe('plan-status-list') + if (!block || block.type !== 'plan-status-list') { + throw new Error('expected a plan-status-list block') + } + return block + } + + /** Text blocks carried alongside the `/plans` list block. */ + const textBlockContents = (blocks: ContentBlock[] | undefined): string[] => + (blocks ?? []) + .filter((block): block is TextContentBlock => block.type === 'text') + .map((block) => block.content) + beforeEach(() => { tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-cmd-')) setProjectRoot(tmpRoot) @@ -443,24 +537,39 @@ describe('command factory pattern', () => { statusCmd!.handler(params, 'foo') expect(sendMessage).not.toHaveBeenCalled() - expect(setMessages).toHaveBeenCalled() - const setMessagesCalls = setMessages.mock.calls as unknown as Array< - [(prev: unknown[]) => Array<{ content: string }>] - > - const updater = setMessagesCalls[0][0] - const next = updater([]) - const systemMessage = next[next.length - 1] + const systemMessage = lastSystemMessage(setMessages.mock.calls) expect(systemMessage.content).toContain('currently: ready for review') expect(systemMessage.content).toContain('.agents/sessions/foo/PLAN.md') expect(systemMessage.content).toContain('Missing: SPEC.md, LESSONS.md') }) + test('plan-status reports a session directory that does not exist', () => { + const statusCmd = COMMAND_REGISTRY.find((c) => c.name === 'plan-status') + const sendMessage = mock(async () => {}) + const setMessages = mock(() => {}) + const params = createMockParams({ + inputValue: '/plan-status ghost', + sendMessage, + setMessages, + }) + + statusCmd!.handler(params, 'ghost') + + // readPlanArtifacts returns null for a missing directory, which is + // formatPlanStatusReport's `!artifacts` branch. + expect(sendMessage).not.toHaveBeenCalled() + expect(lastSystemMessage(setMessages.mock.calls).content).toBe( + '/plan-status: session directory .agents/sessions/ghost not found.', + ) + }) + test('durable plan commands with missing args open plan session picker', () => { const commandNames = [ 'resume-plan', 'update-plan', 'plan-status', 'lessons', + 'plan-use', ] for (const name of commandNames) { @@ -494,6 +603,444 @@ describe('command factory pattern', () => { } }) + test('plans emits a plan-status-list block without sending to the agent', () => { + writeArtifact( + 'alpha', + 'PLAN.md', + '- [x] done one\n- [ ] task two\n', + ) + writeSessionState('alpha', 'active', 'task two') + writeArtifact('beta', 'SPEC.md', 'spec body') + writeSessionState('beta', 'paused') + + const plansCmd = COMMAND_REGISTRY.find((c) => c.name === 'plans') + expect(plansCmd).toBeDefined() + + const sendMessage = mock(async () => {}) + const setMessages = mock(() => {}) + const params = createMockParams({ + inputValue: '/plans', + sendMessage, + setMessages, + }) + + plansCmd!.handler(params, '') + + // /plans is a local inspector: it must never reach the agent. + expect(sendMessage).not.toHaveBeenCalled() + + const message = lastSystemMessage(setMessages.mock.calls) + const block = planListBlock(message.blocks) + expect(block.mode).toBe('list') + expect(block.isStatusReport).toBe(false) + // No pointer file exists, so no stale-pointer note block is carried. + expect(textBlockContents(message.blocks)).toEqual([]) + // Explicit expectations on the carried rows: the slug set plus each row's + // scan-derived fields. Asserting equality against a second + // listPlanSessions() call would only add a redundant filesystem scan and a + // dependency on directory-mtime ordering. + const carried = block.sessions ?? [] + expect(carried.map((session) => session.slug).sort()).toEqual([ + 'alpha', + 'beta', + ]) + const bySlug = new Map(carried.map((session) => [session.slug, session])) + expect(bySlug.get('alpha')).toEqual( + expect.objectContaining({ + status: 'active', + isActive: false, + currentTask: 'task two', + progress: { done: 1, total: 2 }, + }), + ) + expect(bySlug.get('beta')).toEqual( + expect.objectContaining({ + status: 'paused', + isActive: false, + currentTask: null, + progress: { done: 0, total: 0 }, + }), + ) + // The text fallback is formatted from the very rows the block carries. + expect(block.reportText).toBe( + formatPlanListReport(carried, planListActiveState(carried)), + ) + expect(block.reportText).toBe(message.content) + // `kind` duplicated `mode` and is no longer emitted. + expect('kind' in block).toBe(false) + }) + + test('plans carries the stale-pointer note as a rendered text block', () => { + writeArtifact('alpha', 'PLAN.md', 'plan body') + writeSessionState('alpha', 'active') + // 'ghost' has no known artifacts, so listPlanSessions filters it out and + // no scanned row is marked active: the pointer is stale. + fs.mkdirSync(path.join(tmpRoot, '.agents', 'sessions', 'ghost'), { + recursive: true, + }) + fs.writeFileSync(activeSessionPointerPath(), 'ghost\n', 'utf8') + + const plansCmd = COMMAND_REGISTRY.find((c) => c.name === 'plans') + const setMessages = mock(() => {}) + const params = createMockParams({ inputValue: '/plans', setMessages }) + + plansCmd!.handler(params, '') + + const staleNote = staleNoteFor('ghost') + const message = lastSystemMessage(setMessages.mock.calls) + const block = planListBlock(message.blocks) + // PlanStatusBox renders the rows and ignores reportText whenever any + // session exists, so the note has to travel on its own block to stay + // visible in the rendered UI. + expect(block.sessions?.map((session) => session.slug)).toEqual(['alpha']) + expect(textBlockContents(message.blocks)).toEqual([staleNote]) + // The text fallback still carries the same note exactly once, from the + // same single pointer read. + expect(block.reportText.split('\n')).toContain(staleNote) + expect(message.content).toBe(block.reportText) + }) + + test('plans carries the stale-pointer note only in reportText when no sessions are listed', () => { + // Only the pointer file exists, so listPlanSessions finds no rows and + // PlanStatusBox falls back to rendering reportText — which + // formatPlanListReport already ends with the note. A second text block + // here would show the same note twice. + fs.mkdirSync(path.join(tmpRoot, '.agents'), { recursive: true }) + fs.writeFileSync(activeSessionPointerPath(), 'ghost\n', 'utf8') + + const plansCmd = COMMAND_REGISTRY.find((c) => c.name === 'plans') + const setMessages = mock(() => {}) + const params = createMockParams({ inputValue: '/plans', setMessages }) + + plansCmd!.handler(params, '') + + const staleNote = staleNoteFor('ghost') + const message = lastSystemMessage(setMessages.mock.calls) + const block = planListBlock(message.blocks) + expect(block.sessions).toEqual([]) + expect(textBlockContents(message.blocks)).toEqual([]) + // Exactly one occurrence in the rendered fallback text. + expect( + block.reportText.split('\n').filter((line) => line === staleNote), + ).toEqual([staleNote]) + expect(message.content).toBe(block.reportText) + }) + + test('formatPlanListReport flags an active-session pointer with no listed session', () => { + writeArtifact('alpha', 'PLAN.md', 'plan body') + writeSessionState('alpha', 'active') + // 'ghost' exists on disk but has zero known artifacts, so + // listPlanSessions filters it out and no scanned row is marked active. + fs.mkdirSync(path.join(tmpRoot, '.agents', 'sessions', 'ghost'), { + recursive: true, + }) + fs.writeFileSync(activeSessionPointerPath(), 'ghost\n', 'utf8') + + const sessions = listPlanSessions() + expect(sessions.map((session) => session.slug)).toEqual(['alpha']) + expect(sessions.some((session) => session.isActive)).toBe(false) + + const report = formatPlanListReport( + sessions, + planListActiveState(sessions), + ) + const lines = report.split('\n') + + expect(lines).toContain(staleNoteFor('ghost')) + // The rows themselves never claimed an active session, so the normal + // 'Active session: ' line must stay absent. + expect(lines.some((line) => line.startsWith('Active session:'))).toBe( + false, + ) + }) + + test('formatPlanListReport flags a stale pointer when no sessions exist at all', () => { + fs.mkdirSync(path.join(tmpRoot, '.agents'), { recursive: true }) + fs.writeFileSync(activeSessionPointerPath(), 'ghost\n', 'utf8') + + const sessions = listPlanSessions() + const report = formatPlanListReport( + sessions, + planListActiveState(sessions), + ) + const lines = report.split('\n') + + expect(lines[0]).toBe('No plan sessions found under .agents/sessions/.') + expect(lines).toContain(staleNoteFor('ghost')) + }) + + test('formatPlanListReport renders the empty-sessions text', () => { + // No pointer file exists, so the empty-sessions text stands alone. + expect( + formatPlanListReport([], { activeSlug: null, staleNote: null }), + ).toBe( + [ + 'No plan sessions found under .agents/sessions/.', + 'Use /mode:plan to start one.', + ].join('\n'), + ) + }) + + test('formatPlanListReport formats badge, progress, current task and active marker', () => { + writeArtifact( + 'alpha', + 'PLAN.md', + '- [x] done one\n- [ ] task two\n', + ) + writeSessionState('alpha', 'active', 'task two') + writeArtifact('beta', 'SPEC.md', 'spec body') + writeSessionState('beta', 'paused') + fs.writeFileSync(activeSessionPointerPath(), 'alpha\n', 'utf8') + + const sessions = listPlanSessions() + const report = formatPlanListReport( + sessions, + planListActiveState(sessions), + ) + const lines = report.split('\n') + + expect(lines[0]).toBe(`Plan sessions (${sessions.length}):`) + // ' * ' active marker + padded badge + progress + current task. + expect(lines).toContain( + ' * [active] alpha 1/2 done current: "task two"', + ) + // Non-active row: blank marker, no progress (total 0), no current task. + expect(lines).toContain(' [paused] beta') + expect(lines[lines.length - 1]).toBe('Active session: alpha') + }) + + test('plan-use without a slug opens the picker and writes no pointer', () => { + const planUseCmd = COMMAND_REGISTRY.find((c) => c.name === 'plan-use') + expect(planUseCmd).toBeDefined() + + const setMessages = mock(() => {}) + const params = createMockParams({ + inputValue: '/plan-use', + setMessages, + }) + + const result = planUseCmd!.handler(params, '') + + // Consistent with every other plan command: a missing target routes + // through the shared session picker instead of printing usage text. + expect(result).toEqual({ openPlanSessionPicker: 'plan-use' }) + expect(setMessages).not.toHaveBeenCalled() + expect(fs.existsSync(activeSessionPointerPath())).toBe(false) + }) + + test('plan-use rejects an invalid slug', () => { + const planUseCmd = COMMAND_REGISTRY.find((c) => c.name === 'plan-use') + const setMessages = mock(() => {}) + const params = createMockParams({ + inputValue: '/plan-use bad slug!', + setMessages, + }) + + planUseCmd!.handler(params, 'bad slug!') + + expect(lastSystemMessage(setMessages.mock.calls).content).toBe( + '/plan-use: invalid slug "bad slug!". Slugs may contain letters, digits, dots, underscores, and dashes.', + ) + expect(fs.existsSync(activeSessionPointerPath())).toBe(false) + }) + + test('plan-use accepts the .agents/sessions/ path form', () => { + writeArtifact('alpha', 'PLAN.md', 'plan body') + + const planUseCmd = COMMAND_REGISTRY.find((c) => c.name === 'plan-use') + const setMessages = mock(() => {}) + const params = createMockParams({ + inputValue: '/plan-use .agents/sessions/alpha', + setMessages, + }) + + planUseCmd!.handler(params, '.agents/sessions/alpha') + + // The path form every other plan command accepts resolves to the same + // session, and the pointer file stores the resolved bare slug. + expect(lastSystemMessage(setMessages.mock.calls).content).toBe( + 'Active session set to alpha (.agents/sessions/alpha).', + ) + expect(fs.readFileSync(activeSessionPointerPath(), 'utf8')).toBe( + 'alpha\n', + ) + }) + + test('plan-use fails closed when the session directory does not exist', () => { + const planUseCmd = COMMAND_REGISTRY.find((c) => c.name === 'plan-use') + const setMessages = mock(() => {}) + const params = createMockParams({ + inputValue: '/plan-use ghost', + setMessages, + }) + + planUseCmd!.handler(params, 'ghost') + + expect(lastSystemMessage(setMessages.mock.calls).content).toBe( + '/plan-use: no plan session found at .agents/sessions/ghost. Use /plans to list existing sessions, or /mode:plan to start one.', + ) + expect(fs.existsSync(activeSessionPointerPath())).toBe(false) + }) + + test('plan-use fails closed for a session directory without artifacts', () => { + fs.mkdirSync(path.join(tmpRoot, '.agents', 'sessions', 'empty'), { + recursive: true, + }) + + const planUseCmd = COMMAND_REGISTRY.find((c) => c.name === 'plan-use') + const setMessages = mock(() => {}) + const params = createMockParams({ + inputValue: '/plan-use empty', + setMessages, + }) + + planUseCmd!.handler(params, 'empty') + + // /plans would not list this session, so the pointer must not name it. + expect(lastSystemMessage(setMessages.mock.calls).content).toBe( + `/plan-use: no plan artifacts found under .agents/sessions/empty. Expected one of: ${PLAN_ARTIFACT_NAMES.join(', ')}. Use /plans to list existing sessions, or /mode:plan to start one.`, + ) + expect(fs.existsSync(activeSessionPointerPath())).toBe(false) + }) + + test('plan-use rejects a path that escapes the project root', () => { + const planUseCmd = COMMAND_REGISTRY.find((c) => c.name === 'plan-use') + const setMessages = mock(() => {}) + const params = createMockParams({ + inputValue: '/plan-use ../outside', + setMessages, + }) + + planUseCmd!.handler(params, '../outside') + + // resolvePlanSessionDir fails before any slug or containment check runs, + // so the pointer file is never created. + expect(lastSystemMessage(setMessages.mock.calls).content).toBe( + '/plan-use: Resolved session path escapes the project root.', + ) + expect(fs.existsSync(activeSessionPointerPath())).toBe(false) + }) + + test('plan-use rejects a path outside .agents/sessions', () => { + // A real directory with a real plan artifact, so the existsSync and + // artifact checks would both pass: only the sessions-dir check can reject + // it. Without that check the pointer would be written as bare 'foo', + // resolving to the nonexistent .agents/sessions/foo. + const outsideDir = path.join(tmpRoot, 'src', 'foo') + fs.mkdirSync(outsideDir, { recursive: true }) + fs.writeFileSync(path.join(outsideDir, 'PLAN.md'), 'plan body', 'utf8') + + const planUseCmd = COMMAND_REGISTRY.find((c) => c.name === 'plan-use') + const setMessages = mock(() => {}) + const params = createMockParams({ + inputValue: '/plan-use src/foo', + setMessages, + }) + + planUseCmd!.handler(params, 'src/foo') + + expect(lastSystemMessage(setMessages.mock.calls).content).toBe( + '/plan-use: src/foo is not a plan session directory. Use a bare slug or .agents/sessions/.', + ) + expect(fs.existsSync(activeSessionPointerPath())).toBe(false) + }) + + test('plan-use rejects traversal out of .agents/sessions', () => { + const outsideDir = path.join(tmpRoot, 'src', 'foo') + fs.mkdirSync(outsideDir, { recursive: true }) + fs.writeFileSync(path.join(outsideDir, 'PLAN.md'), 'plan body', 'utf8') + + const planUseCmd = COMMAND_REGISTRY.find((c) => c.name === 'plan-use') + const setMessages = mock(() => {}) + const params = createMockParams({ + inputValue: '/plan-use .agents/sessions/../src/foo', + setMessages, + }) + + planUseCmd!.handler(params, '.agents/sessions/../src/foo') + + // Traversal that stays inside the project root still resolves outside + // .agents/sessions/ (here `.agents/src/foo`), so it is rejected on the + // same branch. + expect(lastSystemMessage(setMessages.mock.calls).content).toBe( + '/plan-use: .agents/src/foo is not a plan session directory. Use a bare slug or .agents/sessions/.', + ) + expect(fs.existsSync(activeSessionPointerPath())).toBe(false) + }) + + test('plan-use rejects a nested path under a session directory', () => { + writeArtifact('alpha', 'PLAN.md', 'plan body') + const nested = path.join(tmpRoot, '.agents', 'sessions', 'alpha', 'sub') + fs.mkdirSync(nested, { recursive: true }) + fs.writeFileSync(path.join(nested, 'PLAN.md'), 'plan body', 'utf8') + + const planUseCmd = COMMAND_REGISTRY.find((c) => c.name === 'plan-use') + const setMessages = mock(() => {}) + const params = createMockParams({ + inputValue: '/plan-use .agents/sessions/alpha/sub', + setMessages, + }) + + planUseCmd!.handler(params, '.agents/sessions/alpha/sub') + + // The pointer stores bare slugs, so a multi-segment session path could + // never be represented by it. + expect(lastSystemMessage(setMessages.mock.calls).content).toBe( + '/plan-use: .agents/sessions/alpha/sub is not a plan session directory. Use a bare slug or .agents/sessions/.', + ) + expect(fs.existsSync(activeSessionPointerPath())).toBe(false) + }) + + test('plan-use writes .agents/ACTIVE_SESSION for an existing session', () => { + writeArtifact('alpha', 'PLAN.md', 'plan body') + + const planUseCmd = COMMAND_REGISTRY.find((c) => c.name === 'plan-use') + const sendMessage = mock(async () => {}) + const setMessages = mock(() => {}) + const params = createMockParams({ + inputValue: '/plan-use alpha', + sendMessage, + setMessages, + }) + + planUseCmd!.handler(params, 'alpha') + + expect(sendMessage).not.toHaveBeenCalled() + expect(lastSystemMessage(setMessages.mock.calls).content).toBe( + 'Active session set to alpha (.agents/sessions/alpha).', + ) + expect(fs.readFileSync(activeSessionPointerPath(), 'utf8')).toBe( + 'alpha\n', + ) + }) + + test('plan-use reports a failed pointer write and leaves no pointer file', () => { + writeArtifact('alpha', 'PLAN.md', 'plan body') + + // Both the validation steps and the pointer write resolve the project root + // through the CLI resolver, so the write is made to fail on its own terms: + // a directory sitting at the pointer path makes writeFileSync throw. + fs.mkdirSync(activeSessionPointerPath(), { recursive: true }) + + const planUseCmd = COMMAND_REGISTRY.find((c) => c.name === 'plan-use') + const sendMessage = mock(async () => {}) + const setMessages = mock(() => {}) + const params = createMockParams({ + inputValue: '/plan-use alpha', + sendMessage, + setMessages, + }) + + planUseCmd!.handler(params, 'alpha') + + expect(sendMessage).not.toHaveBeenCalled() + expect(lastSystemMessage(setMessages.mock.calls).content).toBe( + '/plan-use: failed to write .agents/ACTIVE_SESSION (project root not set?).', + ) + // Nothing was written: the path is still the blocking directory. + expect(fs.statSync(activeSessionPointerPath()).isDirectory()).toBe(true) + }) + test('listPlanSessions returns sessions with plan artifacts only', () => { writeArtifact('with-plan', 'PLAN.md', 'plan body') writeArtifact('with-status', 'STATUS.md', 'status body') diff --git a/cli/src/commands/__tests__/index-command.test.ts b/cli/src/commands/__tests__/index-command.test.ts index 4dc7e1ddd7..9f07580f83 100644 --- a/cli/src/commands/__tests__/index-command.test.ts +++ b/cli/src/commands/__tests__/index-command.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from 'bun:test' -import { handleIndexCommand } from '../index-command' +import { + buildIndexStatusContentBlock, + handleIndexCommand, + handleIndexCommandBlocks, +} from '../index-command' const createDeps = (overrides: Record = {}) => { const manager = { @@ -161,3 +165,163 @@ describe('/index command', () => { expect(result).toContain('read_subtree, glob, or code_search') }) }) + +describe('buildIndexStatusContentBlock prefix parsing', () => { + const baseResult = () => ({ + results: [] as Array<{ path: string; score: number }>, + ready: true, + totalIndexed: 99, + indexAge: 61_000, + status: { + state: 'ready' as const, + ready: true, + stale: false, + refreshing: false, + semantic: 'ready' as const, + totalIndexed: 99, + indexAge: 61_000, + diagnostics: [] as Array<{ filePath: string; stage: string; message: string }>, + message: 'Index ready.', + }, + }) + + test('strips Corpus:/Age:/Vector: prefixes correctly', () => { + const result = baseResult() + const block = buildIndexStatusContentBlock(result, true, true) + + expect(block.statusLine).toContain('Index status: ready') + expect(block.corpusLine).toBe('99 indexed files.') + expect(block.corpusLine).not.toContain('Corpus:') + expect(block.ageLine).toBe('1m') + expect(block.ageLine).not.toContain('Age:') + expect(block.vectorLine).toBe('ready') + expect(block.vectorLine).not.toContain('Vector embeddings:') + expect(block.hintLine).toContain('Use /index explain') + expect(block.lines.length).toBeGreaterThan(3) + }) + + test('parses coverage and diagnostics when present', () => { + const result = { + ...baseResult(), + status: { + ...baseResult().status, + state: 'degraded' as const, + refreshing: true, + coverage: { + truncated: true, + maxFiles: 100, + skippedFiles: 5, + skippedPrefixes: ['vendor'], + }, + diagnostics: [ + { filePath: 'src/bad.ts', stage: 'parse', message: 'oops' }, + { filePath: 'src/bad2.ts', stage: 'index', message: 'fail' }, + ], + }, + } + const block = buildIndexStatusContentBlock(result, false, true) + + expect(block.coverageLine).toContain('Coverage: partial') + expect(block.diagnosticsLines).toBeDefined() + expect(block.diagnosticsLines?.[0]).toContain('Diagnostics:') + expect(block.diagnosticsLines?.join('\n')).toContain('src/bad.ts') + }) + + test('handles empty age and vector disabled', () => { + const result = { + ...baseResult(), + indexAge: 0, + status: { ...baseResult().status, indexAge: 0 }, + } + const block = buildIndexStatusContentBlock(result, false, false) + + expect(block.ageLine).toBe('not available') + expect(block.vectorLine).toBe('disabled') + }) + + test('handles not-ready hint and zero corpus edge', () => { + const result = { + results: [], + ready: false, + totalIndexed: 0, + indexAge: 0, + status: { + state: 'building' as const, + ready: false, + stale: false, + refreshing: true, + semantic: 'building' as const, + totalIndexed: 0, + indexAge: 0, + diagnostics: [], + message: 'Building index...', + }, + } + const block = buildIndexStatusContentBlock(result, false, true) + + expect(block.corpusLine).toBe('0 indexed files.') + expect(block.hintLine).toContain('Retry shortly') + }) + + test('corpus singular vs plural boundary', () => { + const one = { + ...baseResult(), + status: { ...baseResult().status, totalIndexed: 1 }, + } + const block = buildIndexStatusContentBlock(one, true, true) + expect(block.corpusLine).toBe('1 indexed file.') + }) +}) + +describe('handleIndexCommandBlocks disabled vs status/rebuild', () => { + test('returns disabled block without corpus/age/vector', async () => { + const block = (await handleIndexCommandBlocks('status', { + getManager: () => ({ + enabled: false, + semanticEnabled: false, + manager: null, + }), + })) as import('../../types/chat').IndexStatusContentBlock + + expect(block.type).toBe('index-status') + expect(block.statusLine).toContain('disabled in openbuff.json') + expect(block.corpusLine).toBe('') + expect(block.ageLine).toBe('') + expect(block.vectorLine).toBe('') + expect(block.lines[0]).toContain('disabled') + }) + + test('status parses same as buildIndexStatusContentBlock', async () => { + const { deps, manager } = createDeps() + const expected = buildIndexStatusContentBlock(manager.query(), manager.isSemanticReady(), true) + const block = (await handleIndexCommandBlocks('status', deps)) as import('../../types/chat').IndexStatusContentBlock + + expect(block.statusLine).toBe(expected.statusLine) + expect(block.corpusLine).toBe(expected.corpusLine) + expect(block.ageLine).toBe(expected.ageLine) + expect(block.vectorLine).toBe(expected.vectorLine) + }) + + test('rebuild prefixes message and lines', async () => { + const { deps } = createDeps() + const block = (await handleIndexCommandBlocks('rebuild', deps)) as import('../../types/chat').IndexStatusContentBlock + + expect(block.messageLine).toContain('Index refresh requested') + expect(block.messageLine).toContain('\n') + expect(block.lines[0]).toContain('Index refresh requested') + expect(block.lines[1]).toContain('Index status:') + expect(block.corpusLine).not.toContain('Corpus:') + expect(block.corpusLine).toContain('indexed files') + }) + + test('disabled rebuild path also returns disabled block', async () => { + const block = (await handleIndexCommandBlocks('rebuild', { + getManager: () => ({ + enabled: false, + semanticEnabled: false, + manager: null, + }), + })) as import('../../types/chat').IndexStatusContentBlock + expect(block.statusLine).toContain('disabled') + }) +}) diff --git a/cli/src/commands/__tests__/memory-command.test.ts b/cli/src/commands/__tests__/memory-command.test.ts index 9eabaec192..804a5f2218 100644 --- a/cli/src/commands/__tests__/memory-command.test.ts +++ b/cli/src/commands/__tests__/memory-command.test.ts @@ -1,6 +1,14 @@ import { describe, expect, test } from 'bun:test' -import { handleMemoryCommand } from '../memory-command' +import { + buildMemoryContentBlock, + formatAge, + GOAL_PREVIEW_CHARS, + handleMemoryCommand, + handleMemoryCommandBlocks, + PRUNE_FAILURE_CAUSES, + STALE_PATHS_SHOWN, +} from '../memory-command' import type { MemoryCommandDeps } from '../memory-command' import type { @@ -359,3 +367,361 @@ describe('/memory command', () => { } }) }) + +describe('/memory blocks', () => { + test('STALE_PATHS_SHOWN is 5 and PRUNE_FAILURE_CAUSES covers all reasons', () => { + expect(STALE_PATHS_SHOWN).toBe(5) + expect(PRUNE_FAILURE_CAUSES['invalid-record']).toContain('schema validation') + expect(PRUNE_FAILURE_CAUSES['concurrent-write']).toContain('changed while pruning') + expect(PRUNE_FAILURE_CAUSES['write-failed']).toContain('atomic renames') + expect(Object.keys(PRUNE_FAILURE_CAUSES).sort()).toEqual([ + 'concurrent-write', + 'invalid-record', + 'write-failed', + ]) + }) + + test('formatAge covers boundary buckets directly', () => { + expect(formatAge(500)).toBe('<1s') + expect(formatAge(30 * 1_000)).toBe('30s') + expect(formatAge(5 * 60 * 1_000)).toBe('5m') + expect(formatAge(3 * 60 * 60 * 1_000 + 7 * 60 * 1_000)).toBe('3h 7m') + expect(formatAge(2 * 24 * 60 * 60 * 1_000 + 5 * 60 * 60 * 1_000)).toBe('2d 5h') + }) + + test('empty state block when no record has been persisted yet', async () => { + const { deps } = createDeps({ memory: undefined }) + + const block = await handleMemoryCommandBlocks('', deps) + + expect(block).toEqual({ type: 'memory', state: 'empty' }) + }) + + test('empty state via buildMemoryContentBlock alias', async () => { + const { deps } = createDeps({ memory: undefined }) + + const block = await buildMemoryContentBlock('status', deps) + + expect(block.type).toBe('memory') + expect(block.state).toBe('empty') + }) + + test('status block reports stale/fresh split and counts', async () => { + const memory = makeMemory({ + goal: 'Ship cross-session memory', + decisions: ['d1', 'd2'], + requirements: ['r1'], + editsMade: ['e1', 'e2', 'e3'], + validationResults: ['v1'], + blockers: ['b1', 'b2'], + nextActions: ['n1'], + revision: 7, + updatedAt: Date.now() - 1_000, + evidence: [ + makeEvidence({ id: 'ev-fresh-1', path: 'fresh-1.ts', stale: false }), + makeEvidence({ id: 'ev-fresh-2', path: 'fresh-2.ts', stale: false }), + makeEvidence({ id: 'ev-stale-1', path: 'gone-1.ts', stale: true }), + makeEvidence({ id: 'ev-stale-2', path: 'gone-2.ts', stale: true }), + ], + }) + const { deps } = createDeps({ memory, reconciled: memory }) + + const block = await handleMemoryCommandBlocks('status', deps) + + expect(block.type).toBe('memory') + if (block.state !== 'status') throw new Error('expected status block') + expect(block.revision).toBe(7) + expect(block.goal).toBe('Ship cross-session memory') + expect(block.isGoalTruncated).toBe(false) + expect(block.counts).toEqual({ + decisions: 2, + requirements: 1, + editsMade: 3, + validationResults: 1, + blockers: 2, + nextActions: 1, + }) + expect(block.evidence).toEqual({ fresh: 2, stale: 2, total: 4 }) + expect(block.stalePaths).toEqual(['gone-1.ts', 'gone-2.ts']) + expect(block.totalStaleCount).toBe(2) + }) + + test('status block caps stalePaths at STALE_PATHS_SHOWN but preserves totalStaleCount', async () => { + const memory = makeMemory({ + evidence: Array.from({ length: 8 }, (_, index) => + makeEvidence({ + id: `ev-${index}`, + path: `stale-${index}.ts`, + stale: true, + }), + ), + }) + const { deps } = createDeps({ memory, reconciled: memory }) + + const block = await handleMemoryCommandBlocks('status', deps) + + if (block.state !== 'status') throw new Error('expected status block') + expect(block.stalePaths).toHaveLength(STALE_PATHS_SHOWN) + expect(block.stalePaths).toEqual([ + 'stale-0.ts', + 'stale-1.ts', + 'stale-2.ts', + 'stale-3.ts', + 'stale-4.ts', + ]) + expect(block.evidence.stale).toBe(8) + expect(block.totalStaleCount).toBe(8) + expect(block.stalePaths).not.toContain('stale-5.ts') + }) + + test('status block omits stalePaths when every entry is fresh', async () => { + const memory = makeMemory({ + goal: '', + evidence: [makeEvidence({ id: 'ev-ok', path: 'ok.ts', stale: false })], + }) + const { deps } = createDeps({ memory, reconciled: memory }) + + const block = await handleMemoryCommandBlocks('status', deps) + + if (block.state !== 'status') throw new Error('expected status block') + expect(block.evidence).toEqual({ fresh: 1, stale: 0, total: 1 }) + expect(block.stalePaths).toEqual([]) + expect(block.totalStaleCount).toBe(0) + }) + + test('goal truncation sets isGoalTruncated and caps goalPreview at GOAL_PREVIEW_CHARS', async () => { + const longGoal = 'g'.repeat(GOAL_PREVIEW_CHARS + 40) + const memory = makeMemory({ goal: longGoal }) + const { deps } = createDeps({ memory, reconciled: memory }) + + const block = await handleMemoryCommandBlocks('status', deps) + + if (block.state !== 'status') throw new Error('expected status block') + expect(block.goal).toBe(longGoal) + expect(block.isGoalTruncated).toBe(true) + expect(block.goalPreview).toBe(longGoal.slice(0, GOAL_PREVIEW_CHARS)) + expect(block.goalPreview).toHaveLength(GOAL_PREVIEW_CHARS) + }) + + test('short goal is not truncated', async () => { + const memory = makeMemory({ goal: 'short goal' }) + const { deps } = createDeps({ memory, reconciled: memory }) + + const block = await handleMemoryCommandBlocks('status', deps) + + if (block.state !== 'status') throw new Error('expected status block') + expect(block.goal).toBe('short goal') + expect(block.goalPreview).toBe('short goal') + expect(block.isGoalTruncated).toBe(false) + }) + + test('empty goal yields null goal and placeholder preview', async () => { + const memory = makeMemory({ goal: '' }) + const { deps } = createDeps({ memory, reconciled: memory }) + + const block = await handleMemoryCommandBlocks('status', deps) + + if (block.state !== 'status') throw new Error('expected status block') + expect(block.goal).toBeNull() + expect(block.goalPreview).toBe('(none recorded)') + expect(block.isGoalTruncated).toBe(false) + }) + + test('goal exactly at GOAL_PREVIEW_CHARS is not truncated', async () => { + const exactGoal = 'x'.repeat(GOAL_PREVIEW_CHARS) + const memory = makeMemory({ goal: exactGoal }) + const { deps } = createDeps({ memory, reconciled: memory }) + + const block = await handleMemoryCommandBlocks('status', deps) + + if (block.state !== 'status') throw new Error('expected status block') + expect(block.isGoalTruncated).toBe(false) + expect(block.goalPreview).toBe(exactGoal) + }) + + test('pruned block carries removed and remaining counts', async () => { + const { deps } = createDeps({ + pruneOutcome: { status: 'pruned', removed: 2, remaining: 5 }, + }) + + const block = await handleMemoryCommandBlocks('prune', deps) + + expect(block).toEqual({ type: 'memory', state: 'pruned', removed: 2, remaining: 5 }) + }) + + test('pruned block via buildMemoryContentBlock alias', async () => { + const { deps } = createDeps({ + pruneOutcome: { status: 'pruned', removed: 1, remaining: 0 }, + }) + + const block = await buildMemoryContentBlock('prune', deps) + + expect(block).toEqual({ type: 'memory', state: 'pruned', removed: 1, remaining: 0 }) + }) + + test('nothing-to-prune block when removed is zero', async () => { + const { deps } = createDeps({ + pruneOutcome: { status: 'pruned', removed: 0, remaining: 4 }, + }) + + const block = await handleMemoryCommandBlocks('prune', deps) + + expect(block).toEqual({ type: 'memory', state: 'nothing-to-prune', remaining: 4 }) + }) + + test('no-record block when store has no persisted memory', async () => { + const { deps } = createDeps({ pruneOutcome: { status: 'no-record' } }) + + const block = await handleMemoryCommandBlocks('prune', deps) + + expect(block).toEqual({ type: 'memory', state: 'no-record' }) + }) + + test('failed block for invalid-record carries cause and unchanged counts', async () => { + const { deps } = createDeps({ + pruneOutcome: { status: 'failed', reason: 'invalid-record', removed: 3, remaining: 2 }, + }) + + const block = await handleMemoryCommandBlocks('prune', deps) + + expect(block.type).toBe('memory') + if (block.state !== 'failed') throw new Error('expected failed block') + expect(block.reason).toBe('invalid-record') + expect(block.cause).toBe(PRUNE_FAILURE_CAUSES['invalid-record']) + expect(block.cause).toContain('schema validation') + expect(block.removed).toBe(3) + expect(block.remaining).toBe(2) + }) + + test('failed block for concurrent-write carries cause and unchanged counts', async () => { + const { deps } = createDeps({ + pruneOutcome: { status: 'failed', reason: 'concurrent-write', removed: 1, remaining: 0 }, + }) + + const block = await handleMemoryCommandBlocks('prune', deps) + + if (block.state !== 'failed') throw new Error('expected failed block') + expect(block.reason).toBe('concurrent-write') + expect(block.cause).toBe(PRUNE_FAILURE_CAUSES['concurrent-write']) + expect(block.cause).toContain('changed while pruning') + expect(block.removed).toBe(1) + expect(block.remaining).toBe(0) + }) + + test('failed block for write-failed carries cause and unchanged counts', async () => { + const { deps } = createDeps({ + pruneOutcome: { status: 'failed', reason: 'write-failed', removed: 5, remaining: 1 }, + }) + + const block = await handleMemoryCommandBlocks('prune', deps) + + if (block.state !== 'failed') throw new Error('expected failed block') + expect(block.reason).toBe('write-failed') + expect(block.cause).toBe(PRUNE_FAILURE_CAUSES['write-failed']) + expect(block.cause).toContain('atomic renames') + expect(block.removed).toBe(5) + expect(block.remaining).toBe(1) + }) + + test('failed blocks never collapse to no-record or nothing-to-prune', async () => { + const reasons: Array<'invalid-record' | 'concurrent-write' | 'write-failed'> = [ + 'invalid-record', + 'concurrent-write', + 'write-failed', + ] + for (const reason of reasons) { + const { deps } = createDeps({ + pruneOutcome: { status: 'failed', reason, removed: 2, remaining: 1 }, + }) + const block = await handleMemoryCommandBlocks('prune', deps) + expect(block.state).toBe('failed') + expect(block.state).not.toBe('no-record') + expect(block.state).not.toBe('nothing-to-prune') + } + }) + + test('error state block for status when load throws', async () => { + const { deps } = createDeps({ loadThrows: true }) + + const block = await handleMemoryCommandBlocks('status', deps) + + expect(block.type).toBe('memory') + expect(block.state).toBe('error') + if (block.state !== 'error') throw new Error('expected error block') + expect(block.message).toContain('Memory status failed') + expect(block.message).toContain('load exploded') + }) + + test('error state block for status when journal moves throw', async () => { + const memory = makeMemory() + const { deps } = createDeps({ memory, reconciled: memory, movesThrow: true }) + + const block = await handleMemoryCommandBlocks('status', deps) + + if (block.state !== 'error') throw new Error('expected error block') + expect(block.message).toContain('Memory status failed') + expect(block.message).toContain('journal exploded') + }) + + test('error state block for prune when prune throws', async () => { + const { deps } = createDeps({ pruneThrows: true }) + + const block = await handleMemoryCommandBlocks('prune', deps) + + if (block.state !== 'error') throw new Error('expected error block') + expect(block.message).toContain('Memory prune failed') + expect(block.message).toContain('prune exploded') + }) + + test('error state block for prune when journal moves throw before prune', async () => { + const { deps, calls } = createDeps({ + pruneOutcome: { status: 'pruned', removed: 3, remaining: 0 }, + movesThrow: true, + }) + + const block = await handleMemoryCommandBlocks('prune', deps) + + if (block.state !== 'error') throw new Error('expected error block') + expect(block.message).toContain('Memory prune failed') + expect(block.message).toContain('journal exploded') + expect(calls.prune).toBe(0) + }) + + test('unknown subcommand returns error block with usage', async () => { + const { deps, calls } = createDeps({ memory: makeMemory() }) + + const block = await handleMemoryCommandBlocks('wat', deps) + + expect(block).toEqual({ + type: 'memory', + state: 'error', + message: 'Usage: /memory [status|prune]', + }) + expect(calls.prune).toBe(0) + expect(calls.moves).toBe(0) + }) + + test('status forwards journal moves so evidence rebinds', async () => { + const memory = makeMemory({ + evidence: [makeEvidence({ id: 'ev-moved', path: 'old.ts' })], + }) + const moves = [{ from: 'old.ts', to: 'nested/new.ts' }] as any + const { deps, calls } = createDeps({ memory, reconciled: memory, workspaceMoves: moves }) + + await handleMemoryCommandBlocks('status', deps) + + expect(calls.reconcileMoves).toEqual([moves]) + }) + + test('prune forwards journal moves', async () => { + const moves = [{ from: 'old.ts', to: 'nested/new.ts' }] as any + const { deps, calls } = createDeps({ + pruneOutcome: { status: 'pruned', removed: 0, remaining: 1 }, + workspaceMoves: moves, + }) + + await handleMemoryCommandBlocks('prune', deps) + + expect(calls.pruneMoves).toEqual([moves]) + }) +}) diff --git a/cli/src/commands/command-registry.ts b/cli/src/commands/command-registry.ts index 13288ed315..90313067d4 100644 --- a/cli/src/commands/command-registry.ts +++ b/cli/src/commands/command-registry.ts @@ -1,28 +1,31 @@ import { existsSync } from 'node:fs' import { CHATGPT_OAUTH_ENABLED } from '@codebuff/common/constants/chatgpt-oauth' -import { - isValidPlanSlug, - writeActiveSessionPointer, -} from '@codebuff/common/util/plan-artifacts' +import { isValidPlanSlug } from '@codebuff/common/util/plan-artifacts' import { registerPlanTimelineCommand } from './plan-timeline' import { handleContextCommand } from './context' -import { handleIndexCommand } from './index-command' +import { handleIndexCommandBlocks } from './index-command' import { handleHelpCommand } from './help' import { handleImageCommand } from './image' import { handleInfoCommand } from './info' -import { handleMemoryCommand } from './memory-command' +import { handleMemoryCommandBlocks } from './memory-command' import { handleInitializationFlowLocally } from './init' import { buildSafeGitCommand } from './git-command-args' import { + ACTIVE_SESSION_FILE_NAME, formatArtifactsForPrompt, + formatPlanSessionListRowText, getActivePlanSessionSlug, hasAnyArtifact, + hasPlanArtifactInDir, listPlanSessions, PLAN_ARTIFACT_NAMES, + PLAN_SESSIONS_DIR_PREFIX, readPlanArtifacts, resolvePlanSessionDir, + writeActivePlanSessionPointer, + type PlanSessionSummary, } from './plan-artifacts' import { buildInterviewPrompt, @@ -61,7 +64,7 @@ import { import type { MultilineInputHandle } from '../components/multiline-input' import type { InputValue, PendingAttachment } from '../types/store' -import type { ChatMessage } from '../types/chat' +import type { ChatMessage, ContentBlock } from '../types/chat' import type { SendMessageFn } from '../types/contracts/send-message' import type { AgentMode } from '../utils/constants' @@ -204,6 +207,22 @@ const appendLocalMessage = (params: RouterParams, body: string) => { ]) } +const appendLocalBlocks = ( + params: RouterParams, + blocksLike: ContentBlock[], + userInput?: string, + legacyContent?: string, +) => { + const input = userInput ?? params.inputValue.trim() + params.setMessages((prev) => [ + ...prev, + getUserMessage(input), + legacyContent !== undefined + ? getSystemMessage(blocksLike, legacyContent) + : getSystemMessage(blocksLike), + ]) +} + const showMissingArtifactsMessage = ( params: RouterParams, command: string, @@ -215,6 +234,17 @@ const showMissingArtifactsMessage = ( ) } +/** + * Agent state directory holding the active-session pointer file. The plan + * session prefix the `/plan-use` containment check uses is imported from + * plan-artifacts.ts (PLAN_SESSIONS_DIR_PREFIX) so the resolver and this check + * cannot drift. + */ +const AGENTS_DIR = '.agents' + +/** The active-session pointer path, as shown to the user. */ +const ACTIVE_SESSION_POINTER_PATH = `${AGENTS_DIR}/${ACTIVE_SESSION_FILE_NAME}` + const openPlanSessionPicker = ( params: RouterParams, command: string, @@ -223,7 +253,7 @@ const openPlanSessionPicker = ( return { openPlanSessionPicker: command } } -const formatPlanStatusReport = ( +export const formatPlanStatusReport = ( sessionDir: string, artifacts: ReturnType, ): string => { @@ -247,69 +277,110 @@ const formatPlanStatusReport = ( } const status = artifacts.files['STATUS.md'] if (status) { - lines.push('', `STATUS.md:`, status.trimEnd()) + lines.push('', 'STATUS.md:', status.trimEnd()) } return lines.join('\n') } -const STATUS_BADGE: Record = { - active: '[active] ', - paused: '[paused] ', - completed: '[completed]', - archived: '[archived] ', +/** The active row's slug plus the stale-pointer note for one `/plans` scan. */ +export type PlanListActiveState = { + activeSlug: string | null + staleNote: string | null +} + +/** + * The active slug from the already-scanned rows, plus a note when the pointer + * names a session `/plans` does not list. The pointer stores bare slugs and is + * read only when no scanned row claims to be active. + */ +export const planListActiveState = ( + sessions: PlanSessionSummary[], +): PlanListActiveState => { + const activeSlug = sessions.find((session) => session.isActive)?.slug ?? null + const pointerSlug = activeSlug ? null : getActivePlanSessionSlug() + return { + activeSlug, + staleNote: pointerSlug + ? `Stale active session: ${pointerSlug} (no listed plan session matches ${ACTIVE_SESSION_POINTER_PATH}). Use /plan-use to point at an existing session.` + : null, + } } -const formatPlanListReport = (): string => { - const sessions = listPlanSessions() +/** + * Text fallback for the `/plans` block, formatted from the already-scanned rows + * and the active state the handler derived once. `/plans` also carries + * `staleNote` as its own block when rows exist, because the rendered box shows + * the rows instead of this text then. + */ +export const formatPlanListReport = ( + sessions: PlanSessionSummary[], + active: PlanListActiveState, +): string => { + const { activeSlug, staleNote } = active + if (sessions.length === 0) { - return [ + const emptyLines = [ 'No plan sessions found under .agents/sessions/.', - 'Use /plan to start one, or /plans for this list.', - ].join('\n') + 'Use /mode:plan to start one.', + ] + if (staleNote) { + emptyLines.push('', staleNote) + } + return emptyLines.join('\n') } - const active = getActivePlanSessionSlug() + const lines: string[] = [`Plan sessions (${sessions.length}):`] for (const session of sessions) { - const badge = STATUS_BADGE[session.status] ?? `[${session.status}]` - const activeMarker = session.isActive ? ' * ' : ' ' - const progress = - session.progress.total > 0 - ? ` ${session.progress.done}/${session.progress.total} done` - : '' - const current = session.currentTask - ? ` current: "${session.currentTask}"` - : '' - lines.push(`${activeMarker}${badge} ${session.slug}${progress}${current}`) + lines.push(formatPlanSessionListRowText(session)) } - if (active) { - lines.push('', `Active session: ${active}`) + if (activeSlug) { + lines.push('', `Active session: ${activeSlug}`) + } else if (staleNote) { + lines.push('', staleNote) } return lines.join('\n') } -const setPlanUse = (slug: string): string => { - const trimmed = slug.trim() - if (!trimmed) { - return '/plan-use: missing session slug. Usage: /plan-use .' - } - if (!isValidPlanSlug(trimmed)) { - return `/plan-use: invalid slug "${trimmed}". Slugs may contain letters, digits, dots, underscores, and dashes.` - } - const resolved = resolvePlanSessionDir(trimmed) +/** + * Point `.agents/ACTIVE_SESSION` at `input` — a bare slug or the + * `.agents/sessions/` path form — and return the message to show. The + * pointer stores bare slugs, so the resolved directory must be exactly + * `.agents/sessions/`. + */ +const setPlanUse = (input: string): string => { + const resolved = resolvePlanSessionDir(input) if (!resolved.ok) { return `/plan-use: ${resolved.error}` } + // resolvePlanSessionDir only guarantees the path stays inside the project + // root, so `src/foo` (or `.agents/sessions/../src/foo`) would otherwise be + // validated at src/foo while the pointer named `foo` — pointing at + // .agents/sessions/foo, the exact stale pointer the checks below prevent. + const slug = resolved.sessionDir.startsWith(PLAN_SESSIONS_DIR_PREFIX) + ? resolved.sessionDir.slice(PLAN_SESSIONS_DIR_PREFIX.length) + : null + if (slug === null || slug.includes('/')) { + return `/plan-use: ${resolved.sessionDir} is not a plan session directory. Use a bare slug or ${PLAN_SESSIONS_DIR_PREFIX}.` + } + if (!isValidPlanSlug(slug)) { + return `/plan-use: invalid slug "${slug}". Slugs may contain letters, digits, dots, underscores, and dashes.` + } // Reject slugs whose session directory does not exist on disk; otherwise the // active-session pointer becomes stale and the next agent run silently "uses" - // a session that has no artifacts. + // a session that does not exist. if (!existsSync(resolved.absSessionDir)) { - return `/plan-use: no plan session found at ${resolved.sessionDir}. Use /plans to list existing sessions, or /plan to start one.` + return `/plan-use: no plan session found at ${resolved.sessionDir}. Use /plans to list existing sessions, or /mode:plan to start one.` + } + // An existing but artifact-less directory is not listed by /plans, so pointing + // at it would leave a pointer naming a session the user cannot see. + if (!hasPlanArtifactInDir(resolved.absSessionDir)) { + return `/plan-use: no plan artifacts found under ${resolved.sessionDir}. Expected one of: ${PLAN_ARTIFACT_NAMES.join(', ')}. Use /plans to list existing sessions, or /mode:plan to start one.` } - const written = writeActiveSessionPointer(trimmed) + const written = writeActivePlanSessionPointer(slug) if (!written) { return '/plan-use: failed to write .agents/ACTIVE_SESSION (project root not set?).' } - return `Active session set to ${trimmed} (${resolved.sessionDir}).` + return `Active session set to ${slug} (${resolved.sessionDir}).` } const ALL_COMMANDS: CommandDefinition[] = [ @@ -637,29 +708,21 @@ const ALL_COMMANDS: CommandDefinition[] = [ error instanceof Error ? error.message : String(error) }` } - const message = [ - 'Openbuff doctor', - '', - `Project: ${getProjectRoot()}`, - `Project agents: ${getProjectAgentTrustStatus() ? 'trusted and enabled' : 'disabled (use --trust-project-agents to enable)'}`, - `Project skills: ${getProjectSkillTrustStatus() ? 'trusted and enabled' : 'disabled with project-agent trust policy'}`, - `Loaded skills: ${getSkillCount()}`, - `Loaded MCP servers: ${Object.keys(getLoadedMCPServers()).length}`, - `Agent diagnostics: ${diagnostics.length}`, - ...diagnostics - .slice(0, 10) - .map( - (diagnostic) => - `- ${diagnostic.filePath || diagnostic.agentId}: ${diagnostic.message}`, - ), - '', + const block: ContentBlock = { + type: 'doctor', + projectRoot: getProjectRoot(), + agentsTrusted: getProjectAgentTrustStatus(), + skillsTrusted: getProjectSkillTrustStatus(), + skillCount: getSkillCount(), + mcpCount: Object.keys(getLoadedMCPServers()).length, + diagnostics: diagnostics.slice(0, 10).map((diagnostic) => ({ + filePath: diagnostic.filePath, + agentId: diagnostic.agentId, + message: diagnostic.message, + })), providerStatus, - ].join('\n') - params.setMessages((prev) => [ - ...prev, - getUserMessage(params.inputValue.trim()), - getSystemMessage(message), - ]) + } + appendLocalBlocks(params, [block]) params.saveToHistory(params.inputValue.trim()) clearInput(params) }, @@ -668,17 +731,25 @@ const ALL_COMMANDS: CommandDefinition[] = [ name: 'index', handler: async (params, args) => { const input = params.inputValue.trim() || '/index' - let message: string try { - message = await handleIndexCommand(args) + const result = await handleIndexCommandBlocks(args) + if (typeof result === 'string') { + params.setMessages((prev) => [ + ...prev, + getUserMessage(input), + getSystemMessage(result), + ]) + } else { + appendLocalBlocks(params, [result], input) + } } catch (error) { - message = `Index command failed: ${error instanceof Error ? error.message : String(error)}` + const message = `Index command failed: ${error instanceof Error ? error.message : String(error)}` + params.setMessages((prev) => [ + ...prev, + getUserMessage(input), + getSystemMessage(message), + ]) } - params.setMessages((prev) => [ - ...prev, - getUserMessage(input), - getSystemMessage(message), - ]) params.saveToHistory(input) clearInput(params) }, @@ -688,17 +759,21 @@ const ALL_COMMANDS: CommandDefinition[] = [ aliases: ['mem'], handler: async (params, args) => { const input = params.inputValue.trim() || '/memory' - let message: string try { - message = await handleMemoryCommand(args) + const block = await handleMemoryCommandBlocks(args) + params.setMessages((prev) => [ + ...prev, + getUserMessage(input), + getSystemMessage([block]), + ]) } catch (error) { - message = `Memory command failed: ${error instanceof Error ? error.message : String(error)}` + const message = `Memory command failed: ${error instanceof Error ? error.message : String(error)}` + params.setMessages((prev) => [ + ...prev, + getUserMessage(input), + getSystemMessage(message), + ]) } - params.setMessages((prev) => [ - ...prev, - getUserMessage(input), - getSystemMessage(message), - ]) params.saveToHistory(input) clearInput(params) }, @@ -923,10 +998,14 @@ const ALL_COMMANDS: CommandDefinition[] = [ return } const artifacts = readPlanArtifacts(parsed.target) - appendLocalMessage( - params, - formatPlanStatusReport(resolved.sessionDir, artifacts), - ) + const reportText = formatPlanStatusReport(resolved.sessionDir, artifacts) + const block: ContentBlock = { + type: 'plan-status', + mode: 'status', + reportText, + isStatusReport: true, + } + appendLocalBlocks(params, [block], undefined, reportText) clearInput(params) }, }), @@ -935,7 +1014,32 @@ const ALL_COMMANDS: CommandDefinition[] = [ aliases: ['plan-ls'], handler: (params) => { params.saveToHistory(params.inputValue.trim()) - appendLocalMessage(params, formatPlanListReport()) + const sessions = listPlanSessions() + // One scan-derived active slug and one pointer read feed both the text + // report and the rendered note. + const active = planListActiveState(sessions) + const reportText = formatPlanListReport(sessions, active) + const blocks: ContentBlock[] = [ + { + type: 'plan-status-list', + mode: 'list', + reportText, + sessions, + isStatusReport: false, + }, + ] + // PlanStatusBox renders the block's `sessions` rows and ignores + // reportText whenever any session exists, so the stale-pointer note is + // carried as its own text block to stay visible in the rendered UI + // instead of only in the message content. + // + // With zero listed sessions the box falls back to rendering reportText, + // which formatPlanListReport already ends with the note, so an extra + // block would show the same note twice. + if (active.staleNote && sessions.length > 0) { + blocks.push({ type: 'text', content: active.staleNote }) + } + appendLocalBlocks(params, blocks, undefined, reportText) clearInput(params) }, }), @@ -945,13 +1049,12 @@ const ALL_COMMANDS: CommandDefinition[] = [ handler: (params, args) => { params.saveToHistory(params.inputValue.trim()) const trimmed = args.trim() + // A missing target opens the shared plan session picker, like every other + // plan command. The picker submits the `.agents/sessions/` path + // form, which setPlanUse accepts, and it only lists sessions that have + // plan artifacts — exactly the sessions setPlanUse allows. if (!trimmed) { - appendLocalMessage( - params, - '/plan-use: missing session slug. Usage: /plan-use .', - ) - clearInput(params) - return + return openPlanSessionPicker(params, 'plan-use') } appendLocalMessage(params, setPlanUse(trimmed)) clearInput(params) diff --git a/cli/src/commands/context.ts b/cli/src/commands/context.ts index aea96a711e..d2cc477bce 100644 --- a/cli/src/commands/context.ts +++ b/cli/src/commands/context.ts @@ -4,8 +4,24 @@ import { formatGateRepairBudgetsForCli } from '@codebuff/common/util/gate-repair import { useChatStore } from '../state/chat-store' import { getSystemMessage } from '../utils/message-history' +import type { ContextContentBlock } from '../types/chat' import type { PostUserMessageFn } from '../types/contracts/send-message' +export function buildContextContentBlock(): ContextContentBlock { + const ledger = + useChatStore.getState().runState?.sessionState?.mainAgentState + .contextBudgetLedger + + const gateBudgetsText = formatGateRepairBudgetsForCli() + const ledgerText = ledger ? formatLedgerForCli(ledger) : null + + return { + type: 'context', + ledgerText, + gateBudgetsText, + } +} + /** * Handles the /context command — displays the per-turn context token budget * breakdown recorded while assembling the current system prompt, then the @@ -19,19 +35,16 @@ import type { PostUserMessageFn } from '../types/contracts/send-message' export function handleContextCommand(): { postUserMessage: PostUserMessageFn } { - const ledger = - useChatStore.getState().runState?.sessionState?.mainAgentState - .contextBudgetLedger - - const gateBudgets = formatGateRepairBudgetsForCli() - const content = ledger - ? `${formatLedgerForCli(ledger)}\n\n${gateBudgets}` + const block = buildContextContentBlock() + const gateBudgets = block.gateBudgetsText + const content = block.ledgerText + ? `${block.ledgerText}\n\n${gateBudgets}` : gateBudgets - const postUserMessage: PostUserMessageFn = (prev) => [ - ...prev, - getSystemMessage(content), - ] + const postUserMessage: PostUserMessageFn = (prev) => { + const msg = getSystemMessage([block], content) + return [...prev, msg] + } return { postUserMessage } } diff --git a/cli/src/commands/index-command.ts b/cli/src/commands/index-command.ts index 0e08271d27..4c923bb32b 100644 --- a/cli/src/commands/index-command.ts +++ b/cli/src/commands/index-command.ts @@ -2,6 +2,7 @@ import { IndexManager } from '@codebuff/indexer' import { createConfiguredEmbedder, loadProviderConfigSync } from '@openbuff/sdk' import { getProjectRoot } from '../project-files' +import { formatAge } from '../utils/format-helpers' type IndexQueryResult = { results: Array<{ @@ -79,11 +80,121 @@ const defaultDeps: IndexCommandDeps = { }, } +export function buildIndexStatusContentBlock( + result: IndexQueryResult, + semanticReady: boolean, + semanticEnabled: boolean, +): import('../types/chat').IndexStatusContentBlock { + const status = result.status + const statusLine = `Index status: ${status.state}${status.refreshing ? ' · refreshing' : ''}.` + const messageLine = status.message + const corpusLine = `${status.totalIndexed} indexed file${status.totalIndexed === 1 ? '' : 's'}.` + const ageLine = status.indexAge > 0 ? formatAge(status.indexAge) : 'not available' + const semantic = !semanticEnabled + ? 'disabled' + : semanticReady + ? 'ready' + : `${status.semantic} (metadata-only fallback)` + const vectorLine = semantic + const hintLine = status.ready + ? 'Use /index explain to inspect ranking provenance.' + : 'Retry shortly, run /index rebuild, or use read_subtree/glob/code_search.' + const coverageLine = status.coverage?.truncated + ? `Coverage: partial at ${status.coverage.maxFiles} files; skipped ${status.coverage.skippedFiles} under ${status.coverage.skippedPrefixes.join(', ') || 'unknown prefixes'}.` + : undefined + const diagnosticsLines = + status.diagnostics.length > 0 + ? [ + `Diagnostics: ${status.diagnostics.length} parser issue${status.diagnostics.length === 1 ? '' : 's'}.`, + ...status.diagnostics + .slice(0, 5) + .map((diagnostic) => `- ${diagnostic.filePath} (${diagnostic.stage}): ${diagnostic.message}`), + ] + : undefined + const lines = [ + statusLine, + messageLine, + `Corpus: ${corpusLine}`, + `Age: ${ageLine}`, + `Vector embeddings: ${vectorLine}`, + hintLine, + ...(coverageLine ? [coverageLine] : []), + ...(diagnosticsLines ? diagnosticsLines : []), + ] + return { + type: 'index-status', + statusLine, + messageLine, + corpusLine, + ageLine, + vectorLine, + hintLine, + coverageLine, + diagnosticsLines, + lines, + } +} + +export async function handleIndexCommandBlocks( + rawArgs: string, + deps: IndexCommandDeps = defaultDeps, +): Promise { + const [subcommand = 'status', ...rest] = rawArgs.trim().split(/\s+/).filter(Boolean) + const normalized = subcommand.toLowerCase() + const setup = deps.getManager() + + if (!setup.enabled || !setup.manager) { + const lines = [ + 'Index status: disabled in openbuff.json.', + 'Use read_subtree, glob, or code_search for live discovery.', + ] + return { + type: 'index-status', + statusLine: lines[0], + messageLine: lines[1], + corpusLine: '', + ageLine: '', + vectorLine: '', + hintLine: '', + lines, + } + } + + if (normalized === 'status') { + return buildIndexStatusContentBlock( + setup.manager.query('', { limit: 1 }), + setup.manager.isSemanticReady(), + setup.semanticEnabled, + ) + } + + if (normalized === 'rebuild') { + setup.manager.markStale() + setup.manager.ensureBuilt() + await setup.manager.waitUntilReady(30_000) + const status = setup.manager.query('', { limit: 1 }) + const block = buildIndexStatusContentBlock( + status, + setup.manager.isSemanticReady(), + setup.semanticEnabled, + ) + const prefix = 'Index refresh requested. Compatible caches are reconciled incrementally; incompatible caches rebuild.' + return { + ...block, + messageLine: `${prefix}\n${block.messageLine}`, + lines: [prefix, ...block.lines], + } + } + + // For non-status subcommands (explain, etc.), fall back to string. + return handleIndexCommand(rawArgs, deps) +} + export async function handleIndexCommand( rawArgs: string, deps: IndexCommandDeps = defaultDeps, ): Promise { - const [subcommand = 'status', ...rest] = rawArgs.trim().split(/\s+/) + const [subcommand = 'status', ...rest] = rawArgs.trim().split(/\s+/).filter(Boolean) const normalized = subcommand.toLowerCase() const setup = deps.getManager() @@ -151,7 +262,7 @@ export async function handleIndexCommand( return 'Usage: /index [status|rebuild|explain ]' } -function formatIndexStatus( +export function formatIndexStatus( result: IndexQueryResult, semanticReady: boolean, semanticEnabled: boolean, @@ -191,16 +302,8 @@ function formatIndexStatus( return lines.join('\n') } -function formatAge(milliseconds: number): string { - if (milliseconds < 1_000) return '<1s' - const seconds = Math.floor(milliseconds / 1_000) - if (seconds < 60) return `${seconds}s` - const minutes = Math.floor(seconds / 60) - if (minutes < 60) return `${minutes}m` - const hours = Math.floor(minutes / 60) - return `${hours}h ${minutes % 60}m` -} +export { formatAge } from '../utils/format-helpers' -function round(value: number): string { +export function round(value: number): string { return (Math.round(value * 100) / 100).toString() } diff --git a/cli/src/commands/info.ts b/cli/src/commands/info.ts index d75bba6253..fae48f9268 100644 --- a/cli/src/commands/info.ts +++ b/cli/src/commands/info.ts @@ -2,6 +2,7 @@ import { getProjectRoot } from '../project-files' import { getCliEnv } from '../utils/env' import { getSystemMessage } from '../utils/message-history' +import type { InfoContentBlock } from '../types/chat' import type { PostUserMessageFn } from '../types/contracts/send-message' function getWorkspaceRoot(): string { @@ -19,6 +20,14 @@ function getCliVersion(): string { return getCliEnv().CODEBUFF_CLI_VERSION ?? '1.0.0' } +export function buildInfoContentBlock(): InfoContentBlock { + return { + type: 'info', + version: getCliVersion(), + workspace: getWorkspaceRoot(), + } +} + /** * Handles the /info command — displays diagnostic information. * Also accessible via the /status alias. @@ -26,22 +35,19 @@ function getCliVersion(): string { export function handleInfoCommand(): { postUserMessage: PostUserMessageFn } { - const projectRoot = getWorkspaceRoot() - const version = getCliVersion() - - // Format the diagnostic info as a system message + const block = buildInfoContentBlock() const infoContent = [ '🔍 CLI Diagnostic Info', '', - `Version: ${version}`, - `Workspace: ${projectRoot}`, + `Version: ${block.version}`, + `Workspace: ${block.workspace}`, 'Auth: Local/BYOK Mode', ].join('\n') - const postUserMessage: PostUserMessageFn = (prev) => [ - ...prev, - getSystemMessage(infoContent), - ] + const postUserMessage: PostUserMessageFn = (prev) => { + const msg = getSystemMessage([block], infoContent) + return [...prev, msg] + } return { postUserMessage } } diff --git a/cli/src/commands/memory-command.ts b/cli/src/commands/memory-command.ts index e5517d4a43..55e4085cde 100644 --- a/cli/src/commands/memory-command.ts +++ b/cli/src/commands/memory-command.ts @@ -12,6 +12,7 @@ import { } from '@openbuff/sdk' import { getProjectRoot } from '../project-files' +import { formatAge, pluralizeEntries } from '../utils/format-helpers' import type { TaskMemoryPruneOutcome, @@ -61,15 +62,15 @@ const defaultDeps: MemoryCommandDeps = { getWorkspaceMoves: loadWorkspaceMoves, } -const STALE_PATHS_SHOWN = 5 -const GOAL_PREVIEW_CHARS = 120 +export const STALE_PATHS_SHOWN = 5 +export const GOAL_PREVIEW_CHARS = 120 /** * User-facing cause for each prune failure the store reports. A failed prune * must never be phrased as an absent record or as "nothing to prune": the * record still holds its stale entries and the user needs to know why. */ -const PRUNE_FAILURE_CAUSES: Record< +export const PRUNE_FAILURE_CAUSES: Record< Extract['reason'], string > = { @@ -80,109 +81,218 @@ const PRUNE_FAILURE_CAUSES: Record< 'the record could not be written (check file permissions and whether the filesystem supports atomic renames)', } -export async function handleMemoryCommand( +// --------------------------------------------------------------------------- +// Shared helpers — journal logic is centralized here so string and block +// paths do not duplicate WorkspaceJournalService.create → collectWorkspaceMoves. +// --------------------------------------------------------------------------- + +async function getStatusContext(deps: MemoryCommandDeps) { + const rootDir = deps.getRootDir() + const memory = await deps.loadPersistedTaskMemory({ rootDir }) + if (!memory) return null + const reconciled = await deps.reconcileTaskMemoryEvidence({ + memory, + rootDir, + // Same move contract as hydration: a renamed file's evidence is + // rebound to its destination rather than reported stale (and then + // offered up for pruning). + workspaceMoves: await deps.getWorkspaceMoves(rootDir), + }) + const live = reconciled.evidence.filter((item) => !item.stale).length + const stale = reconciled.evidence.length - live + const stalePaths = reconciled.evidence + .filter((item) => item.stale && item.path) + .map((item) => item.path as string) + .slice(0, STALE_PATHS_SHOWN) + const rawGoal = typeof memory.goal === 'string' ? memory.goal : null + const hasGoal = !!rawGoal && rawGoal.length > 0 + const goal = hasGoal ? rawGoal : null + const isGoalTruncated = hasGoal ? rawGoal!.length > GOAL_PREVIEW_CHARS : false + const goalPreview = hasGoal + ? rawGoal!.slice(0, GOAL_PREVIEW_CHARS) + : '(none recorded)' + return { + memory, + reconciled, + live, + stale, + stalePaths, + totalStaleCount: stale, + goal, + goalPreview, + isGoalTruncated, + } +} + +async function getPruneOutcome(deps: MemoryCommandDeps) { + const rootDir = deps.getRootDir() + return deps.pruneStaleTaskMemoryEvidence({ + rootDir, + // Prune DELETES what reconciles stale, so the known moves must be + // supplied here too or a rename permanently loses valid evidence. + workspaceMoves: await deps.getWorkspaceMoves(rootDir), + }) +} + +function memoryBlockToString(block: import('../types/chat').MemoryContentBlock): string { + switch (block.state) { + case 'empty': + return [ + 'No persisted task memory for this project yet.', + 'It is written after your first successful run completes.', + ].join('\n') + case 'status': { + const lines = [ + `Task memory: revision ${block.revision}, updated ${formatAge(Math.max(0, Date.now() - block.updatedAt))} ago.`, + `Goal: ${block.goalPreview}`, + `Decisions: ${block.counts.decisions} · Requirements: ${block.counts.requirements} · Edits: ${block.counts.editsMade}`, + `Validations: ${block.counts.validationResults} · Blockers: ${block.counts.blockers} · Next actions: ${block.counts.nextActions}`, + `Evidence: ${block.evidence.fresh} fresh, ${block.evidence.stale} stale (of ${block.evidence.total}).`, + ] + if (block.stalePaths.length > 0) { + lines.push('Stale evidence paths:') + for (const p of block.stalePaths) lines.push(`- ${p}`) + } + if (block.evidence.stale > 0) { + lines.push('Run /memory prune to drop stale evidence entries.') + } + return lines.join('\n') + } + case 'pruned': + return `Pruned ${block.removed} stale evidence ${pluralizeEntries(block.removed)}; ${block.remaining} remain.` + case 'nothing-to-prune': + return `Nothing to prune: all ${block.remaining} evidence entries are fresh.` + case 'no-record': + return 'No persisted task memory to prune for this project.' + case 'failed': + return [ + `Memory prune failed: ${block.cause}.`, + `The record is unchanged: ${block.removed} stale evidence ${pluralizeEntries(block.removed)} still present (${block.remaining} fresh).`, + ].join('\n') + case 'error': + return block.message + default: { + const unknownState = (block as { state?: string }).state ?? 'unknown' + return `Memory status: unknown state "${unknownState}"` + } + } +} + +export async function handleMemoryCommandBlocks( rawArgs: string, deps: MemoryCommandDeps = defaultDeps, -): Promise { +): Promise { const trimmed = rawArgs.trim() const normalized = (trimmed.split(/\s+/)[0] || 'status').toLowerCase() - if (normalized === 'prune') { - return runPrune(deps) + return runPruneBlock(deps) } if (normalized !== 'status') { - return 'Usage: /memory [status|prune]' + return { + type: 'memory', + state: 'error', + message: 'Usage: /memory [status|prune]', + } } - return runStatus(deps) + return runStatusBlock(deps) } -async function runStatus(deps: MemoryCommandDeps): Promise { - try { - const rootDir = deps.getRootDir() - const memory = await deps.loadPersistedTaskMemory({ rootDir }) - if (!memory) { - return [ - 'No persisted task memory for this project yet.', - 'It is written after your first successful run completes.', - ].join('\n') - } +export async function buildMemoryContentBlock( + rawArgs: string, + deps: MemoryCommandDeps = defaultDeps, +): Promise { + return handleMemoryCommandBlocks(rawArgs, deps) +} - const reconciled = await deps.reconcileTaskMemoryEvidence({ - memory, - rootDir, - // Same move contract as hydration: a renamed file's evidence is - // rebound to its destination rather than reported stale (and then - // offered up for pruning). - workspaceMoves: await deps.getWorkspaceMoves(rootDir), - }) - const live = reconciled.evidence.filter((item) => !item.stale).length - const stale = reconciled.evidence.length - live - const stalePaths = reconciled.evidence - .filter((item) => item.stale && item.path) - .slice(0, STALE_PATHS_SHOWN) - .map((item) => `- ${item.path}`) - const goal = memory.goal - ? memory.goal.slice(0, GOAL_PREVIEW_CHARS) - : '(none recorded)' - - const lines = [ - `Task memory: revision ${memory.revision}, updated ${formatAge(Date.now() - memory.updatedAt)} ago.`, - `Goal: ${goal}`, - `Decisions: ${memory.decisions.length} · Requirements: ${memory.requirements.length} · Edits: ${memory.editsMade.length}`, - `Validations: ${memory.validationResults.length} · Blockers: ${memory.blockers.length} · Next actions: ${memory.nextActions.length}`, - `Evidence: ${live} fresh, ${stale} stale (of ${reconciled.evidence.length}).`, - ] - if (stalePaths.length > 0) { - lines.push('Stale evidence paths:') - lines.push(...stalePaths) +async function runStatusBlock( + deps: MemoryCommandDeps, +): Promise { + try { + const context = await getStatusContext(deps) + if (!context) { + return { type: 'memory', state: 'empty' } } - if (stale > 0) { - lines.push('Run /memory prune to drop stale evidence entries.') + return { + type: 'memory', + state: 'status', + revision: context.memory.revision, + updatedAt: context.memory.updatedAt, + goal: context.goal, + goalPreview: context.goalPreview, + isGoalTruncated: context.isGoalTruncated, + counts: { + decisions: context.memory.decisions.length, + requirements: context.memory.requirements.length, + editsMade: context.memory.editsMade.length, + validationResults: context.memory.validationResults.length, + blockers: context.memory.blockers.length, + nextActions: context.memory.nextActions.length, + }, + evidence: { + fresh: context.live, + stale: context.stale, + total: context.reconciled.evidence.length, + }, + stalePaths: context.stalePaths, + totalStaleCount: context.totalStaleCount, } - return lines.join('\n') } catch (error) { - return `Memory status failed: ${error instanceof Error ? error.message : String(error)}` + return { + type: 'memory', + state: 'error', + message: `Memory status failed: ${error instanceof Error ? error.message : String(error)}`, + } } } -async function runPrune(deps: MemoryCommandDeps): Promise { +async function runPruneBlock( + deps: MemoryCommandDeps, +): Promise { try { - const rootDir = deps.getRootDir() - const outcome = await deps.pruneStaleTaskMemoryEvidence({ - rootDir, - // Prune DELETES what reconciles stale, so the known moves must be - // supplied here too or a rename permanently loses valid evidence. - workspaceMoves: await deps.getWorkspaceMoves(rootDir), - }) + const outcome = await getPruneOutcome(deps) if (outcome.status === 'no-record') { - return 'No persisted task memory to prune for this project.' + return { type: 'memory', state: 'no-record' } } if (outcome.status === 'failed') { - return [ - `Memory prune failed: ${PRUNE_FAILURE_CAUSES[outcome.reason]}.`, - `The record is unchanged: ${outcome.removed} stale evidence ${pluralizeEntries(outcome.removed)} still present (${outcome.remaining} fresh).`, - ].join('\n') + return { + type: 'memory', + state: 'failed', + reason: outcome.reason, + cause: PRUNE_FAILURE_CAUSES[outcome.reason], + removed: outcome.removed, + remaining: outcome.remaining, + } } if (outcome.removed === 0) { - return `Nothing to prune: all ${outcome.remaining} evidence entries are fresh.` + return { + type: 'memory', + state: 'nothing-to-prune', + remaining: outcome.remaining, + } + } + return { + type: 'memory', + state: 'pruned', + removed: outcome.removed, + remaining: outcome.remaining, } - return `Pruned ${outcome.removed} stale evidence ${pluralizeEntries(outcome.removed)}; ${outcome.remaining} remain.` } catch (error) { - return `Memory prune failed: ${error instanceof Error ? error.message : String(error)}` + return { + type: 'memory', + state: 'error', + message: `Memory prune failed: ${error instanceof Error ? error.message : String(error)}`, + } } } -function pluralizeEntries(count: number): string { - return count === 1 ? 'entry' : 'entries' +export async function handleMemoryCommand( + rawArgs: string, + deps: MemoryCommandDeps = defaultDeps, +): Promise { + // Preserve string API for tests by delegating to the block API and + // converting back to the legacy plain-text format — no duplicated journal logic. + const block = await handleMemoryCommandBlocks(rawArgs, deps) + return memoryBlockToString(block) } -function formatAge(milliseconds: number): string { - if (milliseconds < 1_000) return '<1s' - const seconds = Math.floor(milliseconds / 1_000) - if (seconds < 60) return `${seconds}s` - const minutes = Math.floor(seconds / 60) - if (minutes < 60) return `${minutes}m` - const hours = Math.floor(minutes / 60) - if (hours < 24) return `${hours}h ${minutes % 60}m` - return `${Math.floor(hours / 24)}d ${hours % 24}h` -} +export { formatAge, pluralizeEntries } diff --git a/cli/src/commands/plan-artifacts.ts b/cli/src/commands/plan-artifacts.ts index 98415636b4..db9961c154 100644 --- a/cli/src/commands/plan-artifacts.ts +++ b/cli/src/commands/plan-artifacts.ts @@ -126,6 +126,65 @@ export type PlanSessionSummary = { isActive: boolean } +/** + * Width of the fixed-width `[status]` badge column, sized so the four common + * lifecycle badges line up. Longer statuses simply overflow the column. + */ +const PLAN_STATUS_BADGE_WIDTH = '[completed]'.length + +/** Fixed-width `[status]` badge for one session row. */ +function formatPlanStatusBadge(status: string): string { + return `[${status}]`.padEnd(PLAN_STATUS_BADGE_WIDTH) +} + +/** The segments of one `/plans` list row, in render order. */ +export type PlanSessionListRow = { + /** `' * '` for the project-wide active session, blank padding otherwise. */ + activeMarker: string + /** Fixed-width `[status]` badge. */ + badge: string + /** Slug plus the optional `N/M done` progress suffix. */ + label: string + /** ` current: "..."` suffix, or `''` when no current task is recorded. */ + current: string +} + +/** + * Canonical `/plans` row formatter. PlanStatusBox colors these segments + * individually while formatPlanListReport joins them for the text fallback, so + * the rendered and text forms of a session row cannot drift. + */ +export function formatPlanSessionListRow( + session: PlanSessionSummary, +): PlanSessionListRow { + const progress = + session.progress.total > 0 + ? ` ${session.progress.done}/${session.progress.total} done` + : '' + return { + activeMarker: session.isActive ? ' * ' : ' ', + badge: formatPlanStatusBadge(session.status), + label: `${session.slug}${progress}`, + current: session.currentTask ? ` current: "${session.currentTask}"` : '', + } +} + +/** Single-line text form of a `/plans` row. */ +export function formatPlanSessionListRowText( + session: PlanSessionSummary, +): string { + const { activeMarker, badge, label, current } = + formatPlanSessionListRow(session) + return `${activeMarker}${badge} ${label}${current}` +} + +/** + * Canonical project-relative prefix every plan session directory lives under. + * The resolver below and the `/plan-use` containment check in + * command-registry.ts both derive from this constant so they cannot drift. + */ +export const PLAN_SESSIONS_DIR_PREFIX = '.agents/sessions/' + type ResolveResult = | { ok: true; sessionDir: string; absSessionDir: string } | { ok: false; error: string } @@ -140,7 +199,7 @@ function normalizeSessionInput(input: string): string { while (rel.startsWith('./')) rel = rel.slice(2) if (rel.endsWith('.md')) rel = path.posix.dirname(rel) if (!rel.includes('/') && rel.length > 0) { - rel = `.agents/sessions/${rel}` + rel = `${PLAN_SESSIONS_DIR_PREFIX}${rel}` } return rel } @@ -176,6 +235,38 @@ export function resolvePlanSessionDir(input: string): ResolveResult { return { ok: true, sessionDir, absSessionDir: abs } } +/** + * Canonical artifact-presence rule: an artifact counts as present only when it + * exists as a regular file. Every caller (readPlanArtifacts, listPlanSessions + * and the `/plan-use` validation path) goes through this helper so the rule + * lives in exactly one place. + */ +function planArtifactExists( + absSessionDir: string, + name: PlanArtifactName, +): boolean { + const artifactPath = path.join(absSessionDir, name) + return fs.existsSync(artifactPath) && fs.statSync(artifactPath).isFile() +} + +/** Known plan artifacts present in an already-resolved session directory. */ +function listPresentPlanArtifacts(absSessionDir: string): PlanArtifactName[] { + return PLAN_ARTIFACT_NAMES.filter((name) => + planArtifactExists(absSessionDir, name), + ) +} + +/** + * True when an already-resolved session directory holds at least one known plan + * artifact. Used on the `/plan-use` validation path so the slug is resolved + * exactly once and artifact bodies are not read just to test presence. + */ +export function hasPlanArtifactInDir(absSessionDir: string): boolean { + return PLAN_ARTIFACT_NAMES.some((name) => + planArtifactExists(absSessionDir, name), + ) +} + /** * Read whichever plan artifacts exist under the given session directory. * Returns `null` if the session directory does not exist on disk. Artifacts @@ -192,22 +283,22 @@ export function readPlanArtifacts(input: string): PlanArtifacts | null { const truncated: PlanArtifactName[] = [] for (const name of PLAN_ARTIFACT_NAMES) { + if (!planArtifactExists(resolved.absSessionDir, name)) { + missing.push(name) + continue + } const abs = path.join(resolved.absSessionDir, name) - if (fs.existsSync(abs) && fs.statSync(abs).isFile()) { - const raw = fs.readFileSync(abs, 'utf8') - if (raw.length > MAX_ARTIFACT_BYTES) { - const head = raw.slice(0, MAX_ARTIFACT_BYTES) - const dropped = raw.length - head.length - files[name] = - `${head}\n\n[...truncated ${dropped} chars to keep prompt bounded; read the file directly for full contents...]` - truncated.push(name) - } else { - files[name] = raw - } - presentPaths[name] = `${resolved.sessionDir}/${name}` + const raw = fs.readFileSync(abs, 'utf8') + if (raw.length > MAX_ARTIFACT_BYTES) { + const head = raw.slice(0, MAX_ARTIFACT_BYTES) + const dropped = raw.length - head.length + files[name] = + `${head}\n\n[...truncated ${dropped} chars to keep prompt bounded; read the file directly for full contents...]` + truncated.push(name) } else { - missing.push(name) + files[name] = raw } + presentPaths[name] = `${resolved.sessionDir}/${name}` } return { @@ -286,10 +377,7 @@ export function listPlanSessions(): PlanSessionSummary[] { if (!absSessionDir.startsWith(rootWithSep)) return null if (!isValidPlanSlug(entry.name)) return null - const artifacts = PLAN_ARTIFACT_NAMES.filter((name) => { - const artifactPath = path.join(absSessionDir, name) - return fs.existsSync(artifactPath) && fs.statSync(artifactPath).isFile() - }) + const artifacts = listPresentPlanArtifacts(absSessionDir) if (artifacts.length === 0) return null @@ -346,5 +434,36 @@ export function getActivePlanSessionSlug(): string | null { return readActiveSessionPointer() } +/** + * Point `.agents/ACTIVE_SESSION` at `slug`, resolving the project root through + * the same CLI resolver callers validate the session directory against. + * + * The shared `writeActiveSessionPointer` resolves the root again through its own + * module-level resolver, so a validated session directory and the written + * pointer could target different roots; writing here keeps the check and the + * write on one resolver. Returns false (never throws) when the project root is + * unavailable or the pointer cannot be written. + */ +export function writeActivePlanSessionPointer(slug: string): boolean { + if (!isValidPlanSlug(slug)) return false + try { + const pointerDir = path.join(getProjectRoot(), '.agents') + fs.mkdirSync(pointerDir, { recursive: true }) + fs.writeFileSync( + path.join(pointerDir, ACTIVE_SESSION_POINTER_FILENAME), + `${slug}\n`, + 'utf8', + ) + return true + } catch (err) { + console.debug( + `[plan-artifacts] writeActivePlanSessionPointer failed for ${slug}: ${ + err instanceof Error ? err.message : String(err) + }`, + ) + return false + } +} + /** Re-export the active-session pointer filename for callers (e.g. CLI banners). */ export const ACTIVE_SESSION_FILE_NAME = ACTIVE_SESSION_POINTER_FILENAME diff --git a/cli/src/components/__tests__/completion-summary-box.test.tsx b/cli/src/components/__tests__/completion-summary-box.test.tsx new file mode 100644 index 0000000000..68f9aea7b1 --- /dev/null +++ b/cli/src/components/__tests__/completion-summary-box.test.tsx @@ -0,0 +1,221 @@ +import { describe, expect, test } from 'bun:test' +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' + +import { initializeThemeStore } from '../../hooks/use-theme' +import { chatThemes } from '../../utils/theme-system' +import { CompletionSummaryBox } from '../renderers/completion-summary-box' + +import type { CompletionSummary } from '../../utils/completion-summary' +import type { CompletionSummaryContentBlock } from '../../types/chat' + +initializeThemeStore() + +const theme = chatThemes.dark + +function makeSummary(overrides: Partial): CompletionSummary { + return { + filesEdited: 0, + filesFailed: 0, + filesUnconfirmed: 0, + filesRolledBack: 0, + rollbackIncomplete: 0, + reviewVerdict: null, + testPassed: 0, + testFailed: 0, + hooksPassed: 0, + hooksFailed: 0, + hooksSkipped: 0, + auxiliaryCompleted: 0, + auxiliaryFailed: 0, + errors: 0, + ...overrides, + } +} + +function renderSummary(summary: CompletionSummary): string { + const block: CompletionSummaryContentBlock = { + type: 'completion-summary', + summary, + } + return renderToStaticMarkup() +} + +describe('CompletionSummaryBox deriveTone', () => { + test('(1) filesFailed>0 => error tone', () => { + const markup = renderSummary(makeSummary({ filesFailed: 1 })) + expect(markup).toContain(theme.error) + }) + + test('(2) hasUnconfirmed => error tone', () => { + const markup = renderSummary(makeSummary({ filesUnconfirmed: 1 })) + expect(markup).toContain(theme.error) + }) + + test('(3) rollbackIncomplete => error tone', () => { + const markup = renderSummary(makeSummary({ rollbackIncomplete: 1 })) + expect(markup).toContain(theme.error) + }) + + test('(4) BLOCKING verdict => error tone', () => { + const markup = renderSummary(makeSummary({ reviewVerdict: 'BLOCKING' })) + expect(markup).toContain(theme.error) + }) + + test('(4b) NEEDS_WORK verdict => error tone', () => { + const markup = renderSummary(makeSummary({ reviewVerdict: 'NEEDS_WORK' })) + expect(markup).toContain(theme.error) + }) + + test('(4c) errors>0 => error tone', () => { + const markup = renderSummary(makeSummary({ errors: 1 })) + expect(markup).toContain(theme.error) + }) + + test('(5) hasRolledBack alone => warning tone', () => { + const markup = renderSummary(makeSummary({ filesRolledBack: 1 })) + expect(markup).toContain(theme.warning) + expect(markup).not.toContain(theme.error) + }) + + test('(6) NON_BLOCKING alone => warning tone', () => { + const markup = renderSummary(makeSummary({ reviewVerdict: 'NON_BLOCKING' })) + expect(markup).toContain(theme.warning) + expect(markup).not.toContain(theme.error) + }) + + test('(7) success case => success tone', () => { + const markup = renderSummary(makeSummary({ filesEdited: 1 })) + expect(markup).toContain(theme.success) + expect(markup).not.toContain(theme.error) + expect(markup).not.toContain(theme.warning) + }) + + test('(7b) LOOKS_GOOD verdict alone => success tone', () => { + const markup = renderSummary(makeSummary({ reviewVerdict: 'LOOKS_GOOD' })) + expect(markup).toContain(theme.success) + }) + + test('(8) empty => secondary tone', () => { + const markup = renderSummary(makeSummary({})) + expect(markup).toContain(theme.secondary) + expect(markup).not.toContain(theme.error) + expect(markup).not.toContain(theme.warning) + expect(markup).not.toContain(theme.success) + }) + + test('error takes precedence over warning (failed + rolled back)', () => { + const markup = renderSummary( + makeSummary({ filesFailed: 1, filesRolledBack: 1 }), + ) + expect(markup).toContain(theme.error) + }) + + test('error takes precedence over success (failed + edited)', () => { + const markup = renderSummary( + makeSummary({ filesFailed: 1, filesEdited: 2 }), + ) + expect(markup).toContain(theme.error) + }) + + test('testFailed => error tone', () => { + const markup = renderSummary(makeSummary({ testFailed: 1 })) + expect(markup).toContain(theme.error) + }) + + test('hooksFailed => error tone', () => { + const markup = renderSummary(makeSummary({ hooksFailed: 1 })) + expect(markup).toContain(theme.error) + }) + + test('auxiliaryFailed => error tone', () => { + const markup = renderSummary(makeSummary({ auxiliaryFailed: 1 })) + expect(markup).toContain(theme.error) + }) + + test('errors >0 with no other activity => error tone', () => { + const markup = renderSummary(makeSummary({ errors: 3 })) + expect(markup).toContain(theme.error) + }) + + test('testPassed alone => success tone', () => { + const markup = renderSummary(makeSummary({ testPassed: 5 })) + expect(markup).toContain(theme.success) + expect(markup).not.toContain(theme.error) + expect(markup).not.toContain(theme.warning) + }) + + test('hooksPassed alone => success tone', () => { + const markup = renderSummary(makeSummary({ hooksPassed: 2 })) + expect(markup).toContain(theme.success) + }) + + test('auxiliaryCompleted alone => success tone', () => { + const markup = renderSummary(makeSummary({ auxiliaryCompleted: 1 })) + expect(markup).toContain(theme.success) + }) + + test('APPROVED verdict alone => success tone', () => { + const markup = renderSummary(makeSummary({ reviewVerdict: 'APPROVED' })) + expect(markup).toContain(theme.success) + }) + + test('warning overrides success (rolledBack + edited)', () => { + const markup = renderSummary( + makeSummary({ filesRolledBack: 1, filesEdited: 2 }), + ) + expect(markup).toContain(theme.warning) + expect(markup).not.toContain(theme.error) + }) + + test('renders files section with warning tone when filesRolledBack', () => { + const markup = renderSummary(makeSummary({ filesRolledBack: 1 })) + expect(markup).toContain('rolled back') + expect(markup).toContain(theme.warning) + }) + + test('renders hooks section', () => { + const markup = renderSummary(makeSummary({ hooksPassed: 1, hooksFailed: 1 })) + expect(markup).toContain('Hooks') + expect(markup).toContain('passed') + expect(markup).toContain('failed') + }) + + test('renders tests section', () => { + const markup = renderSummary(makeSummary({ testPassed: 5, testFailed: 1 })) + expect(markup).toContain('Tests') + expect(markup).toContain('5 passed') + expect(markup).toContain('1 failed') + }) + + test('renders auxiliary section', () => { + const markup = renderSummary( + makeSummary({ auxiliaryCompleted: 2, auxiliaryFailed: 1 }), + ) + expect(markup).toContain('auxiliary') + }) + + test('renders errors section with error color', () => { + const markup = renderSummary(makeSummary({ errors: 2 })) + expect(markup).toContain('2 errors') + expect(markup).toContain(theme.error) + }) + + test('renders BLOCKING with red icon', () => { + const markup = renderSummary(makeSummary({ reviewVerdict: 'BLOCKING' })) + expect(markup).toContain('BLOCKING') + expect(markup).toContain('🔴') + }) + + test('renders NON_BLOCKING with yellow icon', () => { + const markup = renderSummary(makeSummary({ reviewVerdict: 'NON_BLOCKING' })) + expect(markup).toContain('NON_BLOCKING') + expect(markup).toContain('🟡') + }) + + test('renders LOOKS_GOOD with green icon', () => { + const markup = renderSummary(makeSummary({ reviewVerdict: 'LOOKS_GOOD' })) + expect(markup).toContain('LOOKS_GOOD') + expect(markup).toContain('🟢') + }) +}) diff --git a/cli/src/components/__tests__/context-box.test.tsx b/cli/src/components/__tests__/context-box.test.tsx new file mode 100644 index 0000000000..2b6693e406 --- /dev/null +++ b/cli/src/components/__tests__/context-box.test.tsx @@ -0,0 +1,57 @@ +import { describe, expect, test } from 'bun:test' +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' + +import { initializeThemeStore } from '../../hooks/use-theme' +import { chatThemes } from '../../utils/theme-system' +import { ContextBox } from '../renderers/context-box' + +import type { ContextContentBlock } from '../../types/chat' + +initializeThemeStore() + +const theme = chatThemes.dark + +const makeBlock = (overrides: Partial = {}): ContextContentBlock => ({ + type: 'context', + ledgerText: 'Context ledger header\nSecond header line\nToken usage: 123/456', + gateBudgetsText: 'Gate repair budgets\nbudget: 10\nGate budgets: 5', + ...overrides, +}) + +describe('ContextBox', () => { + test('smoke renders title Context and key lines with theme tokens', () => { + const markup = renderToStaticMarkup() + + expect(markup).toContain('Context') + expect(markup).toContain('Context ledger header') + expect(markup).toContain('Gate repair budgets') + expect(markup).toContain(theme.secondary) + }) + + test('renders ledger header with secondary and body with muted', () => { + const markup = renderToStaticMarkup() + expect(markup).toContain(theme.secondary) + expect(markup).toContain(theme.muted) + }) + + test('handles null ledgerText still renders gate', () => { + const markup = renderToStaticMarkup() + expect(markup).toContain('Context') + expect(markup).toContain('Gate repair budgets') + expect(markup).not.toContain('Token usage') + }) + + test('handles empty gateBudgetsText', () => { + const markup = renderToStaticMarkup() + expect(markup).toContain('Context') + expect(markup).toContain('Context ledger header') + }) + + test('handles empty ledger lines boundary', () => { + const markup = renderToStaticMarkup( + , + ) + expect(markup).toContain('Context') + }) +}) diff --git a/cli/src/components/__tests__/doctor-box.test.tsx b/cli/src/components/__tests__/doctor-box.test.tsx new file mode 100644 index 0000000000..3ee1756767 --- /dev/null +++ b/cli/src/components/__tests__/doctor-box.test.tsx @@ -0,0 +1,94 @@ +import { describe, expect, test } from 'bun:test' +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' + +import { initializeThemeStore } from '../../hooks/use-theme' +import { chatThemes } from '../../utils/theme-system' +import { DoctorBox } from '../renderers/doctor-box' + +import type { DoctorContentBlock } from '../../types/chat' + +initializeThemeStore() + +const theme = chatThemes.dark + +const makeBlock = (overrides: Partial = {}): DoctorContentBlock => ({ + type: 'doctor', + projectRoot: '/repo/project', + agentsTrusted: true, + skillsTrusted: false, + skillCount: 3, + mcpCount: 2, + diagnostics: [ + { filePath: 'src/a.ts', message: 'missing export' }, + { agentId: 'agent-1', message: 'agent failed' }, + ], + providerStatus: 'Provider: openai\nStatus: ok', + ...overrides, +}) + +describe('DoctorBox', () => { + test('smoke renders title Doctor and key lines with theme tokens', () => { + const markup = renderToStaticMarkup() + + expect(markup).toContain('Doctor') + expect(markup).toContain('Project root:') + expect(markup).toContain('/repo/project') + expect(markup).toContain('Project agents:') + expect(markup).toContain('trusted and enabled') + expect(markup).toContain('Project skills:') + expect(markup).toContain('Loaded skills:') + expect(markup).toContain('3') + expect(markup).toContain('Loaded MCP servers:') + expect(markup).toContain('2') + expect(markup).toContain('Agent diagnostics:') + expect(markup).toContain(theme.secondary) + }) + + test('shows disabled badge when agents not trusted with warning color', () => { + const markup = renderToStaticMarkup() + expect(markup).toContain('disabled (use --trust-project-agents to enable)') + expect(markup).toContain(theme.warning) + }) + + test('shows skills disabled badge with warning when not trusted', () => { + const markup = renderToStaticMarkup() + expect(markup).toContain('disabled with project-agent trust policy') + expect(markup).toContain(theme.warning) + }) + + test('shows success color when trusted', () => { + const markup = renderToStaticMarkup( + , + ) + expect(markup).toContain(theme.success) + }) + + test('renders diagnostics capped at 10 and provider lines', () => { + const manyDiagnostics = Array.from({ length: 15 }, (_, i) => ({ + filePath: `src/file-${i}.ts`, + message: `err ${i}`, + })) + const markup = renderToStaticMarkup() + expect(markup).toContain('src/file-0.ts: err 0') + expect(markup).toContain('src/file-9.ts: err 9') + expect(markup).not.toContain('src/file-10.ts: err 10') + expect(markup).toContain('Provider: openai') + expect(markup).toContain(theme.muted) + }) + + test('handles zero diagnostics and empty providerStatus', () => { + const markup = renderToStaticMarkup( + , + ) + expect(markup).toContain('Doctor') + expect(markup).toContain('Agent diagnostics:') + expect(markup).toContain('0') + }) + + test('handles boundary counts zero', () => { + const markup = renderToStaticMarkup() + expect(markup).toContain('Loaded skills:') + expect(markup).toContain('Loaded MCP servers:') + }) +}) diff --git a/cli/src/components/__tests__/gate-state-box.test.tsx b/cli/src/components/__tests__/gate-state-box.test.tsx index 8a84ed8dfa..1f91134166 100644 --- a/cli/src/components/__tests__/gate-state-box.test.tsx +++ b/cli/src/components/__tests__/gate-state-box.test.tsx @@ -99,6 +99,83 @@ describe('GateStateBox', () => { expect(markup).not.toContain('undefined') }) + test('renders advisories below the details when provided', () => { + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Advisory (non-blocking)') + expect(markup).toContain('consider a regression test') + expect(markup).toContain('naming nit in helper') + expect(markup.indexOf('no blockers')).toBeLessThan( + markup.indexOf('Advisory (non-blocking)'), + ) + expect(markup).toContain(theme.secondary) + }) + + // Delimiter safety: after the parser unescapes the payload, advisory text may + // legitimately contain the literal `` closing delimiter. + test('renders advisory text containing the gate-state closing delimiter', () => { + const markup = renderToStaticMarkup( + from the persisted format', + ], + })} + />, + ) + + expect(markup).toContain('Advisory (non-blocking)') + expect(markup).toContain('advisory quoting') + // The delimiter text reaches the rendered output (React escapes `<`/`>` in + // text children, so accept either form rather than pinning the escaping). + expect(markup).toMatch(/(<|<)\/gate-state(>|>)/) + }) + + // Render contract: the parser only admits advisory lists within the + // producer's bound (8 entries), and every admitted entry is rendered. + test('renders every advisory of a list at the producer cap', () => { + const advisories = Array.from( + { length: 8 }, + (_, index) => `advisory ${index}`, + ) + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Advisory (non-blocking)') + for (const advisory of advisories) { + expect(markup).toContain(advisory) + } + }) + + test('renders no advisory text when the field is absent', () => { + const block = makeBlock({ gateStatus: 'passed', details: 'no blockers' }) + delete block.advisories + const markup = renderToStaticMarkup() + + expect(markup).toContain('no blockers') + expect(markup).not.toContain('Advisory') + }) + + test('renders no advisory text for an empty advisories array', () => { + const markup = renderToStaticMarkup( + , + ) + + expect(markup).not.toContain('Advisory') + }) + test('uses error color for failed status', () => { const failedMarkup = renderToStaticMarkup( , @@ -115,4 +192,80 @@ describe('GateStateBox', () => { expect(passedMarkup).toContain(theme.success) }) + + test('uses warning color for pending status', () => { + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain(theme.warning) + }) + + test('uses secondary color for skipped status', () => { + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain(theme.secondary) + }) + + test('skipped uses secondary not warning', () => { + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain(theme.secondary) + expect(markup).not.toContain(theme.warning) + }) + + test('pending uses warning not secondary', () => { + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain(theme.warning) + expect(markup).not.toContain(theme.secondary) + }) + + test('skipped hint text is rendered', () => { + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('SKIPPED — gate intentionally not run') + }) + + test('maps all gate statuses to correct tones via border color', () => { + const cases: Array<{ + status: GateStateContentBlock['gateStatus'] + expectedColor: string + }> = [ + { status: 'passed', expectedColor: theme.success }, + { status: 'failed', expectedColor: theme.error }, + { status: 'pending', expectedColor: theme.warning }, + { status: 'skipped', expectedColor: theme.secondary }, + ] + for (const { status, expectedColor } of cases) { + const markup = renderToStaticMarkup( + , + ) + expect(markup).toContain(expectedColor) + } + }) + + test('passed does not use error', () => { + const markup = renderToStaticMarkup( + , + ) + expect(markup).toContain(theme.success) + expect(markup).not.toContain(theme.error) + }) + + test('failed does not use success', () => { + const markup = renderToStaticMarkup( + , + ) + expect(markup).toContain(theme.error) + expect(markup).not.toContain(theme.success) + }) }) diff --git a/cli/src/components/__tests__/index-status-box.test.tsx b/cli/src/components/__tests__/index-status-box.test.tsx new file mode 100644 index 0000000000..5e83f03894 --- /dev/null +++ b/cli/src/components/__tests__/index-status-box.test.tsx @@ -0,0 +1,111 @@ +import { describe, expect, test } from 'bun:test' +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' + +import { initializeThemeStore } from '../../hooks/use-theme' +import { chatThemes } from '../../utils/theme-system' +import { IndexStatusBox } from '../renderers/index-status-box' + +import type { IndexStatusContentBlock } from '../../types/chat' + +initializeThemeStore() + +const theme = chatThemes.dark + +const makeBlock = (overrides: Partial = {}): IndexStatusContentBlock => ({ + type: 'index-status', + statusLine: 'Index status: ready.', + messageLine: 'Index ready.', + corpusLine: '42 indexed files.', + ageLine: '1m', + vectorLine: 'ready', + hintLine: 'Use /index explain to inspect ranking provenance.', + coverageLine: undefined, + diagnosticsLines: undefined, + lines: [ + 'Index status: ready.', + 'Index ready.', + 'Corpus: 42 indexed files.', + 'Age: 1m.', + 'Vector embeddings: ready.', + 'Use /index explain to inspect ranking provenance.', + ], + ...overrides, +}) + +describe('IndexStatusBox', () => { + test('smoke renders derived title and key lines with theme tokens', () => { + const markup = renderToStaticMarkup() + expect(markup).toContain('Index status: ready') + expect(markup).toContain('Corpus:') + expect(markup).toContain('42 indexed files.') + expect(markup).toContain('Age:') + expect(markup).toContain('1m') + expect(markup).toContain('Vector:') + expect(markup).toContain('ready') + expect(markup).toContain(theme.secondary) + }) + + test('renders hintLine with muted and foreground tokens', () => { + const markup = renderToStaticMarkup() + expect(markup).toContain('Use /index explain') + expect(markup).toContain(theme.muted) + expect(markup).toContain(theme.foreground) + }) + + test('renders coverageLine with warning color', () => { + const markup = renderToStaticMarkup( + , + ) + expect(markup).toContain('Coverage: partial') + expect(markup).toContain(theme.warning) + }) + + test('renders diagnosticsLines with header and muted lines', () => { + const markup = renderToStaticMarkup( + , + ) + expect(markup).toContain('Diagnostics (2)') + expect(markup).toContain('src/bad.ts') + expect(markup).toContain(theme.secondary) + expect(markup).toContain(theme.muted) + }) + + test('handles disabled statusLine and empty optional lines', () => { + const markup = renderToStaticMarkup( + , + ) + expect(markup).toContain('Index status: disabled in openbuff.json') + expect(markup).toContain('Use read_subtree') + expect(markup).not.toContain('Corpus:') + expect(markup).not.toContain('Age:') + expect(markup).not.toContain('Vector:') + }) + + test('deriveTitle handles empty statusLine fallback', () => { + const markup = renderToStaticMarkup() + expect(markup).toContain('Index status') + }) + + test('omits hint/coverage/diagnostics when absent', () => { + const markup = renderToStaticMarkup( + , + ) + expect(markup).toContain('Index status: ready') + expect(markup).not.toContain('Coverage: partial') + expect(markup).not.toContain('Diagnostics (') + }) +}) diff --git a/cli/src/components/__tests__/info-box.test.tsx b/cli/src/components/__tests__/info-box.test.tsx new file mode 100644 index 0000000000..0387c4cb85 --- /dev/null +++ b/cli/src/components/__tests__/info-box.test.tsx @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'bun:test' +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' + +import { initializeThemeStore } from '../../hooks/use-theme' +import { chatThemes } from '../../utils/theme-system' +import { InfoBox } from '../renderers/info-box' + +import type { InfoContentBlock } from '../../types/chat' + +initializeThemeStore() + +const theme = chatThemes.dark + +const makeBlock = (overrides: Partial = {}): InfoContentBlock => ({ + type: 'info', + version: '1.2.3', + workspace: '/tmp/workspace', + ...overrides, +}) + +describe('InfoBox', () => { + test('smoke renders title CLI Diagnostic Info and key lines with theme tokens', () => { + const markup = renderToStaticMarkup() + + expect(markup).toContain('CLI Diagnostic Info') + expect(markup).toContain('Version:') + expect(markup).toContain('1.2.3') + expect(markup).toContain('Workspace:') + expect(markup).toContain('/tmp/workspace') + expect(markup).toContain('Auth:') + expect(markup).toContain('Local/BYOK Mode') + expect(markup).toContain(theme.secondary) + expect(markup).toContain(theme.foreground) + }) + + test('renders version and workspace empty values still shows labels', () => { + const markup = renderToStaticMarkup() + expect(markup).toContain('CLI Diagnostic Info') + expect(markup).toContain('Version:') + expect(markup).toContain('Workspace:') + }) + + test('uses secondary for labels and foreground for values', () => { + const markup = renderToStaticMarkup() + expect(markup).toContain(theme.secondary) + expect(markup).toContain(theme.foreground) + }) +}) diff --git a/cli/src/components/__tests__/memory-box.test.tsx b/cli/src/components/__tests__/memory-box.test.tsx new file mode 100644 index 0000000000..a75de837d7 --- /dev/null +++ b/cli/src/components/__tests__/memory-box.test.tsx @@ -0,0 +1,223 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test' +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' + +import { initializeThemeStore } from '../../hooks/use-theme' +import { MemoryBox } from '../renderers/memory-box' +import { formatAge } from '../../commands/memory-command' + +import type { MemoryContentBlock } from '../../types/chat' + +initializeThemeStore() + +const FIXED_NOW = 1_700_000_000_000 +let originalNow: () => number + +beforeEach(() => { + originalNow = Date.now + Date.now = () => FIXED_NOW +}) + +afterEach(() => { + Date.now = originalNow +}) + +const makeStatusBlock = ( + overrides: Partial> = {}, +): Extract => ({ + type: 'memory', + state: 'status', + revision: 7, + updatedAt: FIXED_NOW - 45_000, // 45s ago + goal: 'Build a cool feature for users', + goalPreview: 'Build a cool feature for users', + isGoalTruncated: false, + counts: { + decisions: 1, + requirements: 2, + editsMade: 3, + validationResults: 4, + blockers: 5, + nextActions: 6, + }, + evidence: { + fresh: 3, + stale: 1, + total: 4, + }, + stalePaths: ['src/old.ts', 'src/old2.ts'], + totalStaleCount: 2, + ...overrides, +}) + +describe('MemoryBox', () => { + test('empty renders without throwing and contains empty messages', () => { + const block: MemoryContentBlock = { type: 'memory', state: 'empty' } + const markup = renderToStaticMarkup() + expect(markup).toContain('No persisted task memory for this project yet.') + expect(markup).toContain('It is written after your first successful run completes.') + }) + + test('status renders revision·age header', () => { + const block = makeStatusBlock({ revision: 12, updatedAt: FIXED_NOW - 5_000 }) + const expectedAge = formatAge(FIXED_NOW - block.updatedAt) // should be "5s" + const markup = renderToStaticMarkup() + expect(markup).toContain(`${block.revision} \u00B7 ${expectedAge}`) + expect(markup).toContain('12 \u00B7') + }) + + test('status renders goalPreview', () => { + const block = makeStatusBlock({ goalPreview: 'Ship the feature', goal: 'Ship the feature' }) + const markup = renderToStaticMarkup() + expect(markup).toContain('Ship the feature') + expect(markup).toContain('Goal') + }) + + test('status renders counts', () => { + const block = makeStatusBlock() + const markup = renderToStaticMarkup() + expect(markup).toContain('Decisions: 1') + expect(markup).toContain('Requirements: 2') + expect(markup).toContain('Edits: 3') + expect(markup).toContain('Validations: 4') + expect(markup).toContain('Blockers: 5') + expect(markup).toContain('Next actions: 6') + }) + + test('status renders evidence badge with fresh/stale/total', () => { + const block = makeStatusBlock({ evidence: { fresh: 5, stale: 2, total: 7 } }) + const markup = renderToStaticMarkup() + expect(markup).toContain('Evidence:') + expect(markup).toContain('5 fresh') + expect(markup).toContain('2 stale') + expect(markup).toContain('(of 7)') + }) + + test('status shows stale header button with total count', () => { + const block = makeStatusBlock({ stalePaths: ['a.ts'], totalStaleCount: 1, evidence: { fresh: 1, stale: 1, total: 2 } }) + const markup = renderToStaticMarkup() + expect(markup).toContain('Stale paths (1)') + expect(markup).toContain('\u25BE Stale paths (1)') + }) + + test('status stale overflow header reflects totalStaleCount not slice length', () => { + const block = makeStatusBlock({ + stalePaths: ['a.ts', 'b.ts'], + totalStaleCount: 7, + evidence: { fresh: 0, stale: 7, total: 7 }, + }) + const markup = renderToStaticMarkup() + // collapsed button shows total count 7, not slice length 2 + expect(markup).toContain('Stale paths (7)') + // overflow "+ N more" is only visible when expanded, so collapsed should NOT contain it yet but header proves overflow logic + expect(markup).not.toContain('+ 5 more') + // ensure stalePaths themselves are hidden when collapsed + expect(markup).not.toContain('a.ts') + }) + + test('status shows prune button when stale > 0', () => { + const block = makeStatusBlock({ evidence: { fresh: 1, stale: 3, total: 4 } }) + const markup = renderToStaticMarkup() + expect(markup).toContain('Run /memory prune to drop stale evidence entries.') + }) + + test('status hides prune button when stale === 0', () => { + const block = makeStatusBlock({ evidence: { fresh: 4, stale: 0, total: 4 }, totalStaleCount: 0, stalePaths: [] }) + const markup = renderToStaticMarkup() + expect(markup).not.toContain('Run /memory prune to drop stale evidence entries.') + expect(markup).not.toContain('Stale paths') + }) + + test('status shows Expand button when goal is truncated', () => { + const block = makeStatusBlock({ + goal: 'a'.repeat(200), + goalPreview: 'a'.repeat(120), + isGoalTruncated: true, + }) + const markup = renderToStaticMarkup() + expect(markup).toContain('\u25BE Expand') + expect(markup).toContain('a'.repeat(120)) + expect(markup).not.toContain('a'.repeat(200)) + }) + + test('status hides Expand button when goal is not truncated', () => { + const block = makeStatusBlock({ isGoalTruncated: false }) + const markup = renderToStaticMarkup() + expect(markup).not.toContain('\u25BE Expand') + }) + + test('pruned renders with plural entries', () => { + const block: MemoryContentBlock = { type: 'memory', state: 'pruned', removed: 2, remaining: 5 } + const markup = renderToStaticMarkup() + expect(markup).toContain('Pruned 2 stale evidence entries; 5 remain.') + }) + + test('pruned renders singular entry', () => { + const block: MemoryContentBlock = { type: 'memory', state: 'pruned', removed: 1, remaining: 9 } + const markup = renderToStaticMarkup() + expect(markup).toContain('Pruned 1 stale evidence entry; 9 remain.') + }) + + test('nothing-to-prune renders correctly', () => { + const block: MemoryContentBlock = { type: 'memory', state: 'nothing-to-prune', remaining: 4 } + const markup = renderToStaticMarkup() + expect(markup).toContain('Nothing to prune: all 4 evidence entries are fresh.') + }) + + test('no-record renders correctly', () => { + const block: MemoryContentBlock = { type: 'memory', state: 'no-record' } + const markup = renderToStaticMarkup() + expect(markup).toContain('No persisted task memory to prune for this project.') + }) + + test('failed renders cause and unchanged record line', () => { + const block: MemoryContentBlock = { + type: 'memory', + state: 'failed', + reason: 'concurrent-write', + cause: 'the record changed while pruning (a run saved task memory); re-run /memory prune', + removed: 3, + remaining: 2, + } + const markup = renderToStaticMarkup() + expect(markup).toContain('Memory prune failed: the record changed while pruning') + expect(markup).toContain('The record is unchanged: 3 stale evidence entries still present (2 fresh).') + }) + + test('failed singular entry', () => { + const block: MemoryContentBlock = { + type: 'memory', + state: 'failed', + reason: 'invalid-record', + cause: 'the pruned record failed schema validation', + removed: 1, + remaining: 0, + } + const markup = renderToStaticMarkup() + expect(markup).toContain('1 stale evidence entry still present (0 fresh).') + }) + + test('error renders message', () => { + const block: MemoryContentBlock = { type: 'memory', state: 'error', message: 'Memory status failed: boom' } + const markup = renderToStaticMarkup() + expect(markup).toContain('Memory status failed: boom') + }) + + test('status renders zero counts without throwing', () => { + const block = makeStatusBlock({ + counts: { decisions: 0, requirements: 0, editsMade: 0, validationResults: 0, blockers: 0, nextActions: 0 }, + evidence: { fresh: 0, stale: 0, total: 0 }, + stalePaths: [], + totalStaleCount: 0, + }) + const markup = renderToStaticMarkup() + expect(markup).toContain('Decisions: 0') + expect(markup).toContain('0 fresh') + }) + + test('status with null goal still shows placeholder preview', () => { + const block = makeStatusBlock({ goal: null, goalPreview: '(none recorded)', isGoalTruncated: false }) + const markup = renderToStaticMarkup() + expect(markup).toContain('(none recorded)') + }) +}) diff --git a/cli/src/components/__tests__/plan-box.test.tsx b/cli/src/components/__tests__/plan-box.test.tsx index 82fa0c89a9..4f73768479 100644 --- a/cli/src/components/__tests__/plan-box.test.tsx +++ b/cli/src/components/__tests__/plan-box.test.tsx @@ -1,28 +1,78 @@ -import { describe, expect, mock, test } from 'bun:test' +import { beforeEach, describe, expect, mock, test } from 'bun:test' import React from 'react' import { renderToStaticMarkup } from 'react-dom/server' -import { initializeThemeStore } from '../../hooks/use-theme' import { computeTerminalLayout } from '../../hooks/use-terminal-layout' +import { renderMarkdown } from '../../utils/markdown-renderer' import { chatThemes, createMarkdownPalette } from '../../utils/theme-system' +type CapturedButton = { + text: string + onClick?: (event?: unknown) => void | Promise +} + +const capturedButtons: CapturedButton[] = [] + +const textFromReactNode = (node: React.ReactNode): string => { + if (typeof node === 'string' || typeof node === 'number') { + return String(node) + } + + if (Array.isArray(node)) { + return node.map(textFromReactNode).join('') + } + + if (React.isValidElement<{ children?: React.ReactNode }>(node)) { + return textFromReactNode(node.props.children) + } + + return '' +} + +mock.module('../button', () => ({ + Button: ({ + children, + onClick, + ...rest + }: { + children?: React.ReactNode + onClick?: (event?: unknown) => void | Promise + [key: string]: unknown + }) => { + capturedButtons.push({ text: textFromReactNode(children), onClick }) + + return React.createElement('box', rest, children) + }, +})) + mock.module('../../hooks/use-terminal-layout', () => ({ computeTerminalLayout, useTerminalLayout: () => computeTerminalLayout(80, 24), })) -const { PlanBox } = await import('../renderers/plan-box') +mock.module('../../hooks/use-theme', () => ({ + useTheme: () => chatThemes.dark, + initializeThemeStore: () => {}, +})) -initializeThemeStore() +const { PlanBox } = await import('../renderers/plan-box') const theme = chatThemes.dark const markdownPalette = createMarkdownPalette(theme) describe('PlanBox', () => { + beforeEach(() => { + capturedButtons.length = 0 + }) + test('renders markdown plan content and execute action', () => { const markup = renderToStaticMarkup( {}} @@ -32,6 +82,9 @@ describe('PlanBox', () => { expect(markup).toContain('Build Plan') expect(markup).toContain('Ship it') expect(markup).toContain('Execute Plan') + // The heading really went through the markdown renderer. + expect(markup).toContain(markdownPalette.headingFg[1]) + expect(markup).not.toContain('# Build Plan') }) test('renders artifact metadata and commands when present', () => { @@ -90,22 +143,21 @@ describe('PlanBox', () => { }) test('renders custom artifact commands as clickable buttons', () => { - let insertedCommand: string | undefined + const onInsertCommand = mock(() => {}) + const commands = [ + '/review-design .agents/sessions/demo', + '/validate-tests .agents/sessions/demo', + ] const markup = renderToStaticMarkup( {}} - onInsertCommand={(command) => { - insertedCommand = command - }} + onInsertCommand={onInsertCommand} />, ) @@ -113,8 +165,24 @@ describe('PlanBox', () => { expect(markup).toContain('/review-design .agents/sessions/demo') expect(markup).toContain('/validate-tests .agents/sessions/demo') - // The onInsertCommand callback is callable (the prop was passed through) - expect(insertedCommand).toBeUndefined() + // The mocked Button captures every rendered button, including the build-mode + // actions, so select the command buttons by their text. + const commandButtons = capturedButtons.filter((button) => + commands.includes(button.text), + ) + expect(commandButtons.length).toBe(2) + + for (const button of commandButtons) { + button.onClick?.() + } + + expect(onInsertCommand).toHaveBeenCalledWith( + '/review-design .agents/sessions/demo', + ) + expect(onInsertCommand).toHaveBeenCalledWith( + '/validate-tests .agents/sessions/demo', + ) + expect(onInsertCommand).toHaveBeenCalledTimes(2) }) test('renders known artifact paths and commands together with custom artifacts', () => { @@ -215,18 +283,244 @@ describe('PlanBox', () => { expect(markup).toContain('Execute Plan') }) - test('uses minimum markdown code block width for narrow layouts', () => { + test('clamps the markdown code block width to the 10-column minimum for narrow layouts', () => { + // The clamp under test: PlanBox passes Math.max(10, availableWidth - 8). + const MIN_CODE_BLOCK_WIDTH = 10 + const NARROW_AVAILABLE_WIDTH = 0 + const UNCLAMPED_WIDTH = NARROW_AVAILABLE_WIDTH - 8 + // Template literal so the fence reaches the markdown renderer as a real + // code block instead of a single raw text line. + const codeSource = `\`\`\`ts +const ok = true +\`\`\`` + // Every rendered code segment carries the code background, so counting them + // measures how many wrapped rows the chosen width produced without + // depending on where markdown-renderer breaks the line. + const countCodeSegments = (html: string): number => + html.split(markdownPalette.codeBackground).length - 1 + const renderAtWidth = (codeBlockWidth: number): string => + renderToStaticMarkup( + + {renderMarkdown(codeSource, { + codeBlockWidth, + palette: markdownPalette, + })} + , + ) + const markup = renderToStaticMarkup( {}} />, ) - expect(markup).toContain('const') - expect(markup).toContain('ok') - expect(markup).toContain('true') + // A code block was rendered: language header + code background styling. + expect(markup).toContain('// ts') + expect(markup).toContain(markdownPalette.codeBackground) + expect(markup).not.toContain('```') + + // Without the clamp the width would be -8, which the wrapper floors to a + // single column and fragments into one segment per character. PlanBox must + // instead render exactly what an explicit 10-column render produces. + const clampedSegments = countCodeSegments( + renderAtWidth(MIN_CODE_BLOCK_WIDTH), + ) + const unclampedSegments = countCodeSegments(renderAtWidth(UNCLAMPED_WIDTH)) + + expect(countCodeSegments(markup)).toBe(clampedSegments) + expect(clampedSegments).toBeLessThan(unclampedSegments) + }) + + test('filters out customArtifacts with empty label', () => { + const markup = renderToStaticMarkup( + {}} + />, + ) + + expect(markup).toContain('VALID.md: .agents/sessions/demo/VALID.md') + expect(markup).not.toContain('EMPTY.md') + expect(markup).toContain('Session: .agents/sessions/demo') + }) + + test('filters out customArtifacts with empty path', () => { + const markup = renderToStaticMarkup( + {}} + />, + ) + + expect(markup).toContain('KEEP.md: .agents/sessions/demo/KEEP.md') + expect(markup).not.toContain('ORPHAN.md') + }) + + test('filters out customArtifacts with whitespace-only label or path', () => { + const markup = renderToStaticMarkup( + {}} + />, + ) + + expect(markup).toContain('REAL.md: .agents/sessions/demo/REAL.md') + expect(markup).not.toContain('WS1.md') + expect(markup).not.toContain('WS2.md') + }) + + test('omits artifact section when all customArtifacts are empty', () => { + const markup = renderToStaticMarkup( + {}} + />, + ) + + expect(markup).not.toContain('Artifacts') + }) + + test('filters empty strings from customArtifactCommands', () => { + const markup = renderToStaticMarkup( + {}} + />, + ) + + expect(markup).toContain('/valid-command .agents/sessions/demo') + expect(markup).toContain('Artifacts') + // Only the single non-empty command became a button. Without the filter the + // two '' entries would render two extra empty-text command buttons, so this + // count is what pins the filtering behaviour. + const commandButtons = capturedButtons.filter((button) => + button.text.startsWith('/'), + ) + expect(commandButtons.length).toBe(1) + expect( + capturedButtons.some((button) => button.text.trim().length === 0), + ).toBe(false) + }) + + test('renders every repeated command and artifact path (keys stay unique via index suffix)', () => { + // Repeats are intentionally not collapsed: rows and command buttons are + // keyed by `${value}-${index}`, so duplicates stay uniquely keyed and every + // supplied entry remains visible. Key uniqueness itself is not observable + // through renderToStaticMarkup (colliding React keys only warn), so this + // asserts the visible consequence. + const markup = renderToStaticMarkup( + {}} + />, + ) + + const countIn = (needle: string): number => markup.split(needle).length - 1 + + expect(countIn('DUP.md: .agents/sessions/demo/DUP.md')).toBe(2) + // The known executeCommand plus the identical custom command entry. + expect(countIn('/mode:execute_plan Go!')).toBe(2) + expect(countIn('/review-design .agents/sessions/demo')).toBe(2) + // Both duplicate commands are real, independently clickable buttons. + expect( + capturedButtons.filter( + (button) => button.text === '/review-design .agents/sessions/demo', + ).length, + ).toBe(2) + }) + + test('drops empty artifact paths and commands while keeping the Artifacts section', () => { + const markup = renderToStaticMarkup( + {}} + />, + ) + + expect(markup).toContain('Session: .agents/sessions/demo') + expect(markup).not.toContain('SPEC.md') + expect(markup).toContain('Artifacts') + // Every supplied command ('' executeCommand, ['', ' '] custom commands) + // was dropped, so no command button was rendered. + expect( + capturedButtons.filter((button) => button.text.startsWith('/')).length, + ).toBe(0) + expect( + capturedButtons.some((button) => button.text.trim().length === 0), + ).toBe(false) + // The build-mode 'Execute Plan' button is still captured, so the zero count + // above is a real absence rather than a broken capture. + expect( + capturedButtons.some((button) => button.text === 'Execute Plan'), + ).toBe(true) }) }) diff --git a/cli/src/components/__tests__/plan-status-box.test.tsx b/cli/src/components/__tests__/plan-status-box.test.tsx new file mode 100644 index 0000000000..5fcecf7f6c --- /dev/null +++ b/cli/src/components/__tests__/plan-status-box.test.tsx @@ -0,0 +1,386 @@ +import { describe, expect, test } from 'bun:test' +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' + +import { formatPlanSessionListRow } from '../../commands/plan-artifacts' +import { initializeThemeStore } from '../../hooks/use-theme' +import { chatThemes } from '../../utils/theme-system' +import { PlanStatusBox } from '../renderers/plan-status-box' + +import type { PlanSessionSummary } from '../../commands/plan-artifacts' +import type { PlanStatusContentBlock } from '../../types/chat' + +initializeThemeStore() + +const theme = chatThemes.dark + +const escapeForRegExp = (value: string): string => + value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +/** + * A whole rendered cell: one text element whose entire content is `content`. + * Matching the whole cell keeps assertions specific to the segment under test + * instead of any occurrence of its text anywhere in the markup. + */ +const cellRegExp = (content: string): RegExp => + new RegExp(`]*>${escapeForRegExp(content)}`) + +/** + * One whole rendered session row: the marker, badge and label cells emitted + * back-to-back, derived from the canonical row formatter so row spacing stays + * owned by plan-artifacts instead of being duplicated as literals here. + * + * Matching the row's own cell sequence is what keeps row assertions specific + * without first locating a rows container: an inactive row's marker cell is + * whitespace-only, so a bare marker match would depend on HarnessBox never + * emitting a whitespace-only text element of its own. Anchoring on the row + * instead of on HarnessBox's DOM shape means these assertions survive any + * change to the surrounding box structure. + */ +const sessionRow = (session: PlanSessionSummary): RegExp => { + const row = formatPlanSessionListRow(session) + return new RegExp( + [row.activeMarker, `${row.badge} `, row.label] + .map((cell) => `]*>${escapeForRegExp(cell)}`) + .join(''), + ) +} + +/** + * A blank report line is rendered as a whole placeholder row: a text element + * whose entire content is one space. Pinning the surrounding element shape (not + * just the loose '> ' fragment) keeps the negative assertion from going + * vacuous if the row markup changes. + */ +const BLANK_LINE_ROW = cellRegExp(' ') + +const countMatches = (markup: string, pattern: RegExp): number => + markup.match(new RegExp(pattern.source, 'g'))?.length ?? 0 + +/** + * Opening tag of the first nested text element whose content starts with + * `content`. Asserting the color of a specific segment (rather than anywhere in + * the markup) is what makes the color assertions non-vacuous: theme.muted is + * also applied to every row's active marker. + */ +const styleOfSegment = (markup: string, content: string): string => { + const match = markup.match( + new RegExp(`]*>\\s*${escapeForRegExp(content)}`), + ) + expect( + match, + `expected a text segment starting with "${content}"`, + ).not.toBeNull() + return match![0] +} + +const makeStatusBlock = ( + overrides: Partial = {}, +): PlanStatusContentBlock => ({ + type: 'plan-status', + mode: 'status', + reportText: + 'Plan status header\n[active] my-plan 1/3 done\n current: "task one"', + isStatusReport: true, + ...overrides, +}) + +/** + * Single session-row fixture. Session directories are derived from the resolved + * slug so an overridden slug cannot drift from its paths. + */ +const makeSession = ( + overrides: Partial = {}, +): PlanSessionSummary => { + const slug = overrides.slug ?? 'alpha' + return { + slug, + sessionDir: `.agents/sessions/${slug}`, + absSessionDir: `/tmp/.agents/sessions/${slug}`, + artifacts: ['PLAN.md'], + status: 'active', + currentTask: 'task one', + // Fixed timestamp so the fixture stays deterministic if an assertion ever + // reads `updatedAt`. + updatedAt: '2024-01-01T00:00:00.000Z', + progress: { done: 1, total: 3 }, + isActive: true, + ...overrides, + } +} + +/** + * The two fixture rows used by makeListBlock: one project-wide active session + * and one inactive session. + */ +const FIXTURE_ACTIVE_SESSION = makeSession() +const FIXTURE_INACTIVE_SESSION = makeSession({ + slug: 'beta', + artifacts: ['SPEC.md'], + status: 'paused', + currentTask: null, + progress: { done: 0, total: 0 }, + isActive: false, +}) + +const makeListBlock = ( + overrides: Partial = {}, +): PlanStatusContentBlock => ({ + type: 'plan-status-list', + mode: 'list', + reportText: '', + isStatusReport: false, + sessions: [FIXTURE_ACTIVE_SESSION, FIXTURE_INACTIVE_SESSION], + ...overrides, +}) + +describe('PlanStatusBox', () => { + test('smoke renders status mode title Plan status and report lines', () => { + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Plan status') + expect(markup).toContain('Plan status header') + }) + + test('status mode colors the current-task line muted and other lines foreground', () => { + const markup = renderToStaticMarkup( + , + ) + expect(markup).toContain('current:') + expect(styleOfSegment(markup, 'current:')).toContain(theme.muted) + expect(styleOfSegment(markup, 'Plan status header')).toContain( + theme.foreground, + ) + expect(styleOfSegment(markup, 'Plan status header')).not.toContain( + theme.muted, + ) + }) + + test('status mode colors a badge that follows leading text', () => { + const markup = renderToStaticMarkup( + , + ) + + // The badge is not at index 0, so this takes the `before`-non-empty branch: + // leading text, badge, and trailing text render as separate segments. + expect(styleOfSegment(markup, '[paused]')).toContain(theme.warning) + expect(markup).toContain('>session foo ') + expect(styleOfSegment(markup, 'session foo')).toContain(theme.foreground) + expect(markup).toContain('> 0/2 done') + }) + + /** + * The badge heuristic matches the first `[status]` token anywhere on a report + * line, so STATUS.md prose that happens to mention one is colorized too. The + * renderer cannot distinguish a badge from body text, so this case pins the + * accepted consequence deliberately instead of leaving it incidental. + */ + test('status mode colors a status token that appears in STATUS.md body text', () => { + const markup = renderToStaticMarkup( + , + ) + + expect(styleOfSegment(markup, '[paused]')).toContain(theme.warning) + expect(markup).toContain('>waiting until the ') + }) + + test('smoke renders list mode title Plan sessions and sessions', () => { + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Plan sessions') + expect(markup).toContain('alpha') + expect(markup).toContain('1/3 done') + expect(markup).toContain('current: "task one"') + expect(markup).toContain('beta') + }) + + test('list mode colors each badge by status and the current task muted', () => { + const markup = renderToStaticMarkup( + , + ) + expect(styleOfSegment(markup, '[active]')).toContain(theme.success) + expect(styleOfSegment(markup, '[paused]')).toContain(theme.warning) + // The slug/progress segment stays foreground, so a muted match here would + // not be coming from the current-task segment. + expect(styleOfSegment(markup, 'alpha')).toContain(theme.foreground) + expect(styleOfSegment(markup, 'current:')).toContain(theme.muted) + }) + + test('list mode active marker is emitted for the active row only', () => { + const activeSession = FIXTURE_ACTIVE_SESSION + const inactiveSession = FIXTURE_INACTIVE_SESSION + + const activeOnly = renderToStaticMarkup( + , + ) + const inactiveOnly = renderToStaticMarkup( + , + ) + const both = renderToStaticMarkup() + + expect(activeOnly).toMatch(sessionRow(activeSession)) + expect(activeOnly).toContain('alpha') + // The formatter distinguishes the two markers, so the negative assertion + // below cannot pass vacuously. + expect(sessionRow(activeSession).source).not.toBe( + sessionRow(inactiveSession).source, + ) + // A non-active row gets the blank marker cell, never the active marker. + expect(inactiveOnly).not.toMatch(sessionRow(activeSession)) + expect(inactiveOnly).toMatch(sessionRow(inactiveSession)) + expect(inactiveOnly).toContain('beta') + // Exactly one marked row when both sessions are listed. + expect(countMatches(both, sessionRow(activeSession))).toBe(1) + }) + + /** + * formatPlanListReport's stale-active-session note never reaches this box: + * with sessions present the rows come from `sessions` and `reportText` is + * ignored. `/plans` therefore emits the note as its own text block (pinned by + * command-args.test.ts) so it stays visible in the rendered UI. Pinned here so + * the omission stays a deliberate contract. + */ + test('list mode with sessions ignores reportText, including a stale-pointer note', () => { + const staleNote = + 'Stale active session: ghost (no listed plan session matches .agents/ACTIVE_SESSION). Use /plan-use to point at an existing session.' + const markup = renderToStaticMarkup( + , + ) + + // The session row still renders from `sessions`... + expect(markup).toMatch(sessionRow(FIXTURE_INACTIVE_SESSION)) + expect(markup).toContain('beta') + // ...and nothing from reportText — the note included — is rendered. + expect(markup).not.toContain('Stale active session') + expect(markup).not.toContain('Plan sessions (1):') + }) + + test('handles empty sessions list by rendering the empty-state report text', () => { + // The real `/plans`-empty path: with no sessions the component falls through + // to the reportText branch, which carries formatPlanListReport's + // empty-state message. + const markup = renderToStaticMarkup( + , + ) + expect(markup).toContain('Plan sessions') + expect(markup).toContain('No plan sessions found under .agents/sessions/.') + expect(markup).toContain('Use /mode:plan to start one.') + // No session rows rendered: whole rows only exist in the list branch. + expect(markup).not.toMatch(sessionRow(FIXTURE_ACTIVE_SESSION)) + expect(markup).not.toMatch(sessionRow(FIXTURE_INACTIVE_SESSION)) + }) + + /** + * The zero-sessions `/plans` path with a stale pointer: formatPlanListReport + * appends the note to reportText and this branch renders it, so `/plans` must + * not also carry the note as its own text block (pinned by + * command-args.test.ts) or the user would see it twice. + */ + test('empty sessions list renders a stale-pointer note from reportText exactly once', () => { + // The note's `` suffix is HTML-escaped in the rendered markup, so the + // countable anchor is its bracket-free head. + const noteHead = + 'Stale active session: ghost (no listed plan session matches .agents/ACTIVE_SESSION).' + const markup = renderToStaticMarkup( + to point at an existing session.`, + ].join('\n'), + })} + />, + ) + + expect(markup).toContain('No plan sessions found under .agents/sessions/.') + expect(countMatches(markup, new RegExp(escapeForRegExp(noteHead)))).toBe(1) + }) + + test('handles empty reportText boundary without emitting placeholder rows', () => { + const markup = renderToStaticMarkup( + , + ) + expect(markup).toContain('Plan status') + // reportText '' takes the `lines === []` path: no rows at all. A + // `''.split('\n')` implementation would emit one `' '` placeholder row, so + // this row-specific marker is the assertion that pins the branch. Status + // mode renders no whitespace-only cells of its own (the list branch's blank + // markers are not in play), so the match is specific to the placeholder row. + expect(markup).not.toMatch(BLANK_LINE_ROW) + + // Control: a report that does contain a blank line renders the placeholder + // row between the two non-empty rows. + const withBlankLine = renderToStaticMarkup( + , + ) + expect(withBlankLine).toMatch(BLANK_LINE_ROW) + expect(withBlankLine).toMatch( + new RegExp( + `>first[\\s\\S]*${BLANK_LINE_ROW.source}[\\s\\S]*>last`, + ), + ) + }) + + test('list mode completed and archived badges use secondary/muted', () => { + const markup = renderToStaticMarkup( + , + ) + expect(markup).toContain('[completed]') + expect(markup).toContain('[archived]') + // Assert the badge segments themselves: theme.muted is also applied to + // every row's active marker, so a bare toContain would pass without the + // archived badge being colored at all. + expect(styleOfSegment(markup, '[completed]')).toContain(theme.secondary) + expect(styleOfSegment(markup, '[archived]')).toContain(theme.muted) + expect(styleOfSegment(markup, '[archived]')).not.toContain(theme.secondary) + }) +}) diff --git a/cli/src/components/__tests__/prompt-history-search-screen.test.ts b/cli/src/components/__tests__/prompt-history-search-screen.test.ts index 53a31a42d1..ab27330b58 100644 --- a/cli/src/components/__tests__/prompt-history-search-screen.test.ts +++ b/cli/src/components/__tests__/prompt-history-search-screen.test.ts @@ -1,6 +1,54 @@ -import { describe, test, expect } from 'bun:test' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' -import { filterAndScorePrompts } from '../prompt-history-search-screen' +import { afterEach, beforeEach, describe, test, expect } from 'bun:test' + +import { + HISTORY_UNAVAILABLE_MESSAGE, + deriveLoadedPrompts, + filterAndScorePrompts, + getEmptyResultsMessage, + reanchorFocusForReload, + reanchorFocusedPrompt, + readPromptHistoryForDisplay, + retryUnavailablePromptHistory, +} from '../prompt-history-search-screen' +import { fuzzyMatch } from '../../utils/fuzzy-match' +import { + MESSAGE_HISTORY_MAX_RETRY_ATTEMPTS, + MESSAGE_HISTORY_RETRY_COOLDOWN_MS, + getMessageHistoryPath, + getSessionHistoryRetry, + getUnpersistedMessageHistory, + setSessionHistoryRetry, + setUnpersistedMessageHistory, +} from '../../utils/message-history' + +import type { MessageHistoryLoadOutcome } from '../../utils/message-history' + +/** The budget a session starts from: nothing spent, never attempted. */ +const IDLE_SESSION_RETRY = { + attempts: 0, + lastAttemptMs: Number.NEGATIVE_INFINITY, +} + +/** + * Reset the two process-wide session stores message-history keeps (memory-only + * prompts and the retry budget), which retryUnavailablePromptHistory and + * readPromptHistoryForDisplay read and spend. + * + * Registered as hooks rather than called from test bodies: a test that throws + * before its own cleanup would otherwise leak memory-only prompts and a spent + * retry budget into every later test sharing this module instance. + */ +const resetSessionHistoryState = (): void => { + setUnpersistedMessageHistory([]) + setSessionHistoryRetry(IDLE_SESSION_RETRY) +} + +beforeEach(resetSessionHistoryState) +afterEach(resetSessionHistoryState) describe('filterAndScorePrompts', () => { test('empty query returns all items capped at the limit', () => { @@ -44,7 +92,6 @@ describe('filterAndScorePrompts', () => { expect(result).toContain('fix the broken auth tests') // Verify ordering: the first result has a better (lower) fuzzyMatch score. - const { fuzzyMatch } = require('../../utils/fuzzy-match') const firstScore = fuzzyMatch(result[0], 'fix auth')!.score const secondScore = fuzzyMatch(result[1], 'fix auth')!.score expect(firstScore).toBeLessThanOrEqual(secondScore) @@ -76,3 +123,530 @@ describe('filterAndScorePrompts', () => { expect(result).toEqual(['!git status']) }) }) + +describe('deriveLoadedPrompts', () => { + test('reverses the persisted order so the newest prompt is listed first', () => { + // loadMessageHistorySafe returns most-recent-last; the list renders + // most-recent-first, and filterAndScorePrompts caps an empty query by + // relying on that order. + const { prompts } = deriveLoadedPrompts( + ['oldest', 'middle', 'newest'], + false, + ) + expect(prompts).toEqual(['newest', 'middle', 'oldest']) + }) + + test('leaves the loaded array untouched', () => { + // The array belongs to the caller, so reversing it in place would corrupt + // the history it read. + const history = ['a', 'b'] + deriveLoadedPrompts(history, false) + expect(history).toEqual(['a', 'b']) + }) + + test('selects the load state from the unavailability report', () => { + // The mount effect leaves 'loading' behind for exactly one of these two, and + // a degraded/busy read must not be collapsed into a genuinely empty load. + expect(deriveLoadedPrompts([], false).loadState).toBe('loaded') + expect(deriveLoadedPrompts([], true).loadState).toBe('unavailable') + // A partial read still contributes its entries. + expect(deriveLoadedPrompts(['partial'], true)).toEqual({ + prompts: ['partial'], + loadState: 'unavailable', + }) + }) + + test('an unavailable load still reports an unavailable empty list', () => { + expect( + getEmptyResultsMessage('', deriveLoadedPrompts([], true).loadState), + ).toBe(HISTORY_UNAVAILABLE_MESSAGE) + expect( + getEmptyResultsMessage('', deriveLoadedPrompts([], false).loadState), + ).toBe('No prompt history yet') + }) +}) + +describe('getEmptyResultsMessage', () => { + test('reports a not-yet-loaded history as loading, never as empty', () => { + // The history is read from an effect, so the list genuinely renders before + // the load resolves; reporting "no prompt history yet" there would claim a + // state that has not been observed. + expect(getEmptyResultsMessage('', 'loading')).toBe( + 'Loading prompt history...', + ) + // Loading outranks the no-matches message too: nothing was searched yet. + expect(getEmptyResultsMessage('zzz', 'loading')).toBe( + 'Loading prompt history...', + ) + }) + + test('reports genuinely empty history when the load succeeded', () => { + expect(getEmptyResultsMessage('', 'loaded')).toBe('No prompt history yet') + }) + + test('reports no matches for a non-empty query when the load succeeded', () => { + expect(getEmptyResultsMessage(' zzz ', 'loaded')).toBe( + 'No prompts matching "zzz"', + ) + }) + + test('reports an unavailable history when the load failed or degraded', () => { + // A busy lock or an unreadable history must not be misreported as empty + // history, and it outranks the no-matches message because nothing was ever + // searched. Asserting the exported constant keeps this from passing against + // a stale copy of the string. + expect(getEmptyResultsMessage('', 'unavailable')).toBe( + HISTORY_UNAVAILABLE_MESSAGE, + ) + expect(getEmptyResultsMessage('zzz', 'unavailable')).toBe( + HISTORY_UNAVAILABLE_MESSAGE, + ) + // The wording itself is pinned once, so a silent reword stays visible. + expect(HISTORY_UNAVAILABLE_MESSAGE).toBe( + 'History unavailable — locked by another Openbuff process or unreadable', + ) + }) +}) + +describe('retryUnavailablePromptHistory', () => { + const loadOk = (history: string[]) => (): MessageHistoryLoadOutcome => ({ + history, + unavailable: false, + }) + + test('recovers the screen when a retry finally reads the history', () => { + // The busy lock another Openbuff process held is gone: the overlay must + // leave HISTORY_UNAVAILABLE_MESSAGE without being closed and reopened. + const retried = retryUnavailablePromptHistory({ + loadState: 'unavailable', + nowMs: 1_000, + load: loadOk(['oldest', 'newest']), + }) + expect(retried).toBeDefined() + expect(retried!.loadState).toBe('loaded') + // Still most-recent-first for display. + expect(retried!.prompts).toEqual(['newest', 'oldest']) + // A trustworthy read clears the shared retry budget, for every consumer. + expect(getSessionHistoryRetry()).toEqual({ + attempts: 0, + lastAttemptMs: 1_000, + }) + expect(getEmptyResultsMessage('', retried!.loadState)).toBe( + 'No prompt history yet', + ) + }) + + test('does not read at all for a load that was not unavailable', () => { + let calls = 0 + const load = (): MessageHistoryLoadOutcome => { + calls++ + return { history: [], unavailable: false } + } + for (const loadState of ['loading', 'loaded'] as const) { + expect( + retryUnavailablePromptHistory({ + loadState, + nowMs: 1_000, + load, + }), + ).toBeUndefined() + } + expect(calls).toBe(0) + expect(getSessionHistoryRetry()).toEqual(IDLE_SESSION_RETRY) + }) + + test('the process-wide retry budget bounds how often an unreadable history is re-read', () => { + let calls = 0 + const load = (): MessageHistoryLoadOutcome => { + calls++ + return { history: [], unavailable: true } + } + // Inside the cooldown nothing is read, so a poll tick cannot pay a blocking + // lock acquisition per tick. + setSessionHistoryRetry({ attempts: 1, lastAttemptMs: 1_000 }) + expect( + retryUnavailablePromptHistory({ + loadState: 'unavailable', + nowMs: 1_000 + MESSAGE_HISTORY_RETRY_COOLDOWN_MS - 1, + load, + }), + ).toBeUndefined() + // The attempt cap stops retrying entirely once the budget is spent — and + // because the budget is process-wide, attempts another consumer (the + // navigation hook, an earlier open of this overlay) already spent count + // against this mount instead of giving it a fresh allowance. + setSessionHistoryRetry({ + attempts: MESSAGE_HISTORY_MAX_RETRY_ATTEMPTS, + lastAttemptMs: 1_000, + }) + expect( + retryUnavailablePromptHistory({ + loadState: 'unavailable', + nowMs: 1_000 + MESSAGE_HISTORY_RETRY_COOLDOWN_MS, + load, + }), + ).toBeUndefined() + expect(calls).toBe(0) + + // A due retry that stays unavailable spends one attempt of the shared + // budget and keeps the screen reporting unavailability. + setSessionHistoryRetry({ attempts: 1, lastAttemptMs: 1_000 }) + const retried = retryUnavailablePromptHistory({ + loadState: 'unavailable', + nowMs: 1_000 + MESSAGE_HISTORY_RETRY_COOLDOWN_MS, + load, + }) + expect(calls).toBe(1) + expect(retried!.loadState).toBe('unavailable') + expect(getSessionHistoryRetry()).toEqual({ + attempts: 2, + lastAttemptMs: 1_000 + MESSAGE_HISTORY_RETRY_COOLDOWN_MS, + }) + }) + + test('a still-degraded retry grows the list but never shrinks it', () => { + // The shared session read owns the never-shrink rule, so the screen adopts + // whatever it resolves to: a partial read that saw more entries... + const grown = retryUnavailablePromptHistory({ + loadState: 'unavailable', + nowMs: 1_000, + load: () => ({ history: ['partial', 'more'], unavailable: true }), + }) + expect(grown!.prompts).toEqual(['more', 'partial']) + expect(grown!.loadState).toBe('unavailable') + + // ...and one that resolved back to the retained view keeps every prompt. + setSessionHistoryRetry(IDLE_SESSION_RETRY) + const retained = retryUnavailablePromptHistory({ + loadState: 'unavailable', + nowMs: 1_000, + load: () => ({ history: ['oldest', 'newest'], unavailable: true }), + }) + expect(retained!.prompts).toEqual(['newest', 'oldest']) + }) +}) + +describe('reanchorFocusedPrompt', () => { + test('follows the focused prompt when a background reload grows the list', () => { + // The overlay listed two prompts (most-recent-first) with the older one + // focused; the retry read the full history, which prepends a newer prompt. + // A stationary index would leave the cursor on 'b' and Enter would select + // the wrong prompt. + expect( + reanchorFocusedPrompt({ + focusedIndex: 1, + displayedPrompts: ['b', 'a'], + reloadedPrompts: ['c', 'b', 'a'], + }), + ).toBe(2) + }) + + test('leaves the cursor alone when the reload changed nothing', () => { + // Every reload allocates a fresh array, so an unchanged list must not move + // the cursor — including onto another verbatim duplicate. + expect( + reanchorFocusedPrompt({ + focusedIndex: 1, + displayedPrompts: ['b', 'a'], + reloadedPrompts: ['b', 'a'], + }), + ).toBe(1) + expect( + reanchorFocusedPrompt({ + focusedIndex: 2, + displayedPrompts: ['dup', 'x', 'dup'], + reloadedPrompts: ['dup', 'x', 'dup'], + }), + ).toBe(2) + }) + + test('anchors a verbatim repeat to the newest matching occurrence', () => { + // Display order is most-recent-first, so the newest 'dup' is the one the + // user was focused on and the one the cursor must follow. + expect( + reanchorFocusedPrompt({ + focusedIndex: 0, + displayedPrompts: ['dup', 'x', 'dup'], + reloadedPrompts: ['new', 'dup', 'x', 'dup'], + }), + ).toBe(1) + }) + + test('clamps into range when the focused prompt is gone', () => { + expect( + reanchorFocusedPrompt({ + focusedIndex: 1, + displayedPrompts: ['b', 'a'], + reloadedPrompts: ['b'], + }), + ).toBe(0) + }) + + test('re-anchors from the cursor the list actually rendered', () => { + // The raw focus state can sit past the end (the query shrank the list), so + // the rendered/clamped cursor — 'a' — is what gets re-anchored. + expect( + reanchorFocusedPrompt({ + focusedIndex: 9, + displayedPrompts: ['b', 'a'], + reloadedPrompts: ['c', 'b', 'a'], + }), + ).toBe(2) + }) + + test('focuses the newest prompt when there is nothing to anchor to', () => { + // No draft to fall back to, unlike up/down navigation: an empty reload or a + // first load must leave a usable cursor rather than -1. + expect( + reanchorFocusedPrompt({ + focusedIndex: 3, + displayedPrompts: ['b', 'a'], + reloadedPrompts: [], + }), + ).toBe(0) + expect( + reanchorFocusedPrompt({ + focusedIndex: 0, + displayedPrompts: [], + reloadedPrompts: ['c', 'b', 'a'], + }), + ).toBe(0) + }) + + test('a retry that grows the list still selects the prompt the user focused', () => { + // End-to-end shape of the finding: the overlay opened on a degraded read + // that saw two prompts, the user focused the older one, and the retry read + // the full history. + const displayedPrompts = deriveLoadedPrompts(['older', 'newer'], true) + .prompts + const focusedPrompt = displayedPrompts[1] + + const retried = retryUnavailablePromptHistory({ + loadState: 'unavailable', + nowMs: 1_000, + load: () => ({ + history: ['older', 'newer', 'newest'], + unavailable: false, + }), + })! + const reloadedPrompts = filterAndScorePrompts(retried.prompts, '', 200) + const reanchored = reanchorFocusedPrompt({ + focusedIndex: 1, + displayedPrompts, + reloadedPrompts, + }) + + expect(reloadedPrompts[reanchored]).toBe(focusedPrompt) + // Clamping alone kept index 1, which now points at a different prompt. + expect(reloadedPrompts[1]).not.toBe(focusedPrompt) + }) +}) + +describe('reanchorFocusForReload', () => { + const LIMIT = 200 + + test('derives both filtered lists from the query that is live now', () => { + // The cursor addresses the *filtered* list, so a reload has to be + // re-anchored between two lists filtered by the same query. + const displayedAllPrompts = ['fix b', 'other', 'fix a'] + const reloadedAllPrompts = ['newest', 'fix b', 'other', 'fix a'] + const focusedPrompt = filterAndScorePrompts( + displayedAllPrompts, + 'fix', + LIMIT, + )[1] + + const reanchored = reanchorFocusForReload({ + focusedIndex: 1, + query: 'fix', + displayedAllPrompts, + reloadedAllPrompts, + limit: LIMIT, + }) + + expect( + filterAndScorePrompts(reloadedAllPrompts, 'fix', LIMIT)[reanchored], + ).toBe(focusedPrompt) + }) + + test('a query the render has not observed yet still anchors correctly', () => { + // The exact race: the retry tick lands between the keystroke that set the + // query and the next render. Re-anchoring against the query published then + // (rather than a lagging post-render mirror) keeps the highlight on the + // focused prompt; the previous query resolves somewhere else entirely. + const displayedAllPrompts = ['fix b', 'other', 'fix a'] + const reloadedAllPrompts = ['newest', 'fix b', 'other', 'fix a'] + const filteredReload = filterAndScorePrompts( + reloadedAllPrompts, + 'fix', + LIMIT, + ) + + const withLiveQuery = reanchorFocusForReload({ + focusedIndex: 1, + query: 'fix', + displayedAllPrompts, + reloadedAllPrompts, + limit: LIMIT, + }) + expect(filteredReload[withLiveQuery]).toBe('fix a') + + const withStaleQuery = reanchorFocusForReload({ + focusedIndex: 1, + query: '', + displayedAllPrompts, + reloadedAllPrompts, + limit: LIMIT, + }) + expect(withStaleQuery).not.toBe(withLiveQuery) + expect(filteredReload[withStaleQuery]).not.toBe('fix a') + }) + + test('an empty query re-anchors over the raw lists', () => { + expect( + reanchorFocusForReload({ + focusedIndex: 1, + query: '', + displayedAllPrompts: ['b', 'a'], + reloadedAllPrompts: ['c', 'b', 'a'], + limit: LIMIT, + }), + ).toBe(2) + }) + + test('applies the render cap to both lists', () => { + // The rendered list is capped, so the reloaded list has to be capped the + // same way or the cursor would anchor onto a row that is never shown. + expect( + reanchorFocusForReload({ + focusedIndex: 0, + query: '', + displayedAllPrompts: ['b', 'a'], + reloadedAllPrompts: ['c', 'b', 'a'], + limit: 2, + }), + ).toBe(1) + }) + + test('re-anchoring is not idempotent, so a reload must apply it once', () => { + // Pins why the screen mirrors the focus index in a ref and computes the + // next cursor outside the setFocusedIndex updater: the helper follows the + // focused prompt, so applying it to its own result (a state updater React + // invoked twice under StrictMode) moves the highlight onto the neighbour. + const displayedAllPrompts = ['dup', 'x', 'dup'] + const reloadedAllPrompts = ['new', 'dup', 'x', 'dup'] + const once = reanchorFocusForReload({ + focusedIndex: 0, + query: '', + displayedAllPrompts, + reloadedAllPrompts, + limit: LIMIT, + }) + expect(once).toBe(1) + expect(reloadedAllPrompts[once]).toBe(displayedAllPrompts[0]) + + const twice = reanchorFocusForReload({ + focusedIndex: once, + query: '', + displayedAllPrompts, + reloadedAllPrompts, + limit: LIMIT, + }) + expect(twice).toBe(2) + expect(reloadedAllPrompts[twice]).not.toBe(displayedAllPrompts[0]) + + // Re-anchoring between two identical lists is stable, which is what makes + // re-running the screen's load step (rather than the helper) harmless. + expect( + reanchorFocusForReload({ + focusedIndex: once, + query: '', + displayedAllPrompts: reloadedAllPrompts, + reloadedAllPrompts, + limit: LIMIT, + }), + ).toBe(once) + }) +}) + +describe('readPromptHistoryForDisplay', () => { + let tempDir: string + let originalConfigDir: string | undefined + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openbuff-prompt-search-')) + originalConfigDir = process.env.OPENBUFF_CONFIG_DIR + process.env.OPENBUFF_CONFIG_DIR = tempDir + }) + + afterEach(() => { + if (originalConfigDir === undefined) delete process.env.OPENBUFF_CONFIG_DIR + else process.env.OPENBUFF_CONFIG_DIR = originalConfigDir + fs.rmSync(tempDir, { recursive: true, force: true }) + }) + + const displayed = (loaded: MessageHistoryLoadOutcome): string[] => + deriveLoadedPrompts(loaded.history, loaded.unavailable).prompts + + test("lists this session's memory-only prompts, every time the history is read", () => { + // A prompt whose append to disk failed is navigable with up/down, so it must + // be searchable in the overlay too: the two consumers share one load. + fs.writeFileSync(getMessageHistoryPath(), JSON.stringify(['on-disk'])) + setUnpersistedMessageHistory(['append-failed']) + + const loaded = readPromptHistoryForDisplay([]) + expect(loaded.unavailable).toBe(false) + // Memory-only entries are this session's newest, so they list first. + expect(displayed(loaded)).toEqual(['append-failed', 'on-disk']) + + // Re-folded on every read: a second load (mount of a reopened overlay, or a + // retry) must not lose it. + expect(displayed(readPromptHistoryForDisplay(['append-failed', 'on-disk']))) + .toEqual(['append-failed', 'on-disk']) + expect(getUnpersistedMessageHistory()).toEqual(['append-failed']) + }) + + test('retires a memory-only prompt once disk proves it landed', () => { + fs.writeFileSync( + getMessageHistoryPath(), + JSON.stringify(['on-disk', 'append-failed']), + ) + setUnpersistedMessageHistory(['append-failed']) + + // Not duplicated, and no longer carried by the session. + expect(displayed(readPromptHistoryForDisplay([]))).toEqual([ + 'append-failed', + 'on-disk', + ]) + expect(getUnpersistedMessageHistory()).toEqual([]) + }) + + test('a degraded read keeps the displayed prompts and the memory-only ones', () => { + // A fresh lock file makes the read report unavailability (busy lock held by + // another Openbuff process) after the short interactive budget. + fs.writeFileSync(path.join(tempDir, 'message-history.lock'), '') + setUnpersistedMessageHistory(['append-failed']) + + const loaded = readPromptHistoryForDisplay(['newest', 'oldest']) + expect(loaded.unavailable).toBe(true) + expect(displayed(loaded)).toEqual(['append-failed', 'newest', 'oldest']) + // A partial read proves nothing about disk, so the entry stays pending. + expect(getUnpersistedMessageHistory()).toEqual(['append-failed']) + }) + + test('a successful retry surfaces the memory-only prompt in the overlay', () => { + // The exact recovery path the finding calls out: the overlay opened while + // the history was unavailable, and the retry that reads it must include the + // prompt whose append failed this session. + fs.writeFileSync(getMessageHistoryPath(), JSON.stringify(['on-disk'])) + setUnpersistedMessageHistory(['append-failed']) + + const retried = retryUnavailablePromptHistory({ + loadState: 'unavailable', + nowMs: 1_000, + load: () => readPromptHistoryForDisplay([]), + }) + expect(retried!.loadState).toBe('loaded') + expect(retried!.prompts).toEqual(['append-failed', 'on-disk']) + }) +}) diff --git a/cli/src/components/__tests__/sweep-boxes.test.tsx b/cli/src/components/__tests__/sweep-boxes.test.tsx new file mode 100644 index 0000000000..bfef0a2f28 --- /dev/null +++ b/cli/src/components/__tests__/sweep-boxes.test.tsx @@ -0,0 +1,241 @@ +import { describe, test, expect } from 'bun:test' +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' + +import { initializeThemeStore } from '../../hooks/use-theme' +import { ContextBox } from '../renderers/context-box' +import { InfoBox } from '../renderers/info-box' +import { DoctorBox } from '../renderers/doctor-box' +import { IndexStatusBox } from '../renderers/index-status-box' +import { PlanStatusBox } from '../renderers/plan-status-box' + +import type { + ContextContentBlock, + InfoContentBlock, + DoctorContentBlock, + IndexStatusContentBlock, + PlanStatusContentBlock, +} from '../../types/chat' + +initializeThemeStore() + +describe('sweep renderers', () => { + test('ContextBox renders without throwing and contains Context title', () => { + const block: ContextContentBlock = { + type: 'context', + ledgerText: 'Ledger header\nLedger detail\nmore ledger', + gateBudgetsText: 'Gate budgets line 1\nGate repair budgets detail\nbudget row', + } + const markup = renderToStaticMarkup() + expect(markup).toContain('Context') + expect(markup).toContain('Ledger header') + expect(markup).toContain('Gate budgets') + }) + + test('ContextBox handles null ledgerText', () => { + const block: ContextContentBlock = { + type: 'context', + ledgerText: null, + gateBudgetsText: 'Gate budgets: ok', + } + const markup = renderToStaticMarkup() + expect(markup).toContain('Context') + expect(markup).toContain('Gate budgets: ok') + }) + + test('InfoBox renders version, workspace and Auth', () => { + const block: InfoContentBlock = { + type: 'info', + version: '1.2.3', + workspace: '/tmp/ws', + } + const markup = renderToStaticMarkup() + expect(markup).toContain('CLI Diagnostic Info') + expect(markup).toContain('Version:') + expect(markup).toContain('1.2.3') + expect(markup).toContain('Workspace:') + expect(markup).toContain('/tmp/ws') + expect(markup).toContain('Auth:') + expect(markup).toContain('Local/BYOK Mode') + }) + + test('DoctorBox renders project root, agents/skills badges and counts', () => { + const block: DoctorContentBlock = { + type: 'doctor', + projectRoot: '/home/user/project', + agentsTrusted: true, + skillsTrusted: false, + skillCount: 3, + mcpCount: 2, + diagnostics: [{ filePath: '/a/b.ts', message: 'oops' }], + providerStatus: 'provider ok\nline2', + } + const markup = renderToStaticMarkup() + expect(markup).toContain('Doctor') + expect(markup).toContain('Project root:') + expect(markup).toContain('/home/user/project') + expect(markup).toContain('Project agents:') + expect(markup).toContain('trusted and enabled') + expect(markup).toContain('Project skills:') + expect(markup).toContain('disabled with project-agent trust policy') + expect(markup).toContain('Loaded skills:') + expect(markup).toContain('3') + expect(markup).toContain('Loaded MCP servers:') + expect(markup).toContain('Agent diagnostics:') + expect(markup).toContain('/a/b.ts: oops') + expect(markup).toContain('provider ok') + }) + + test('DoctorBox disabled agents badge', () => { + const block: DoctorContentBlock = { + type: 'doctor', + projectRoot: '/root', + agentsTrusted: false, + skillsTrusted: true, + skillCount: 0, + mcpCount: 0, + diagnostics: [], + providerStatus: '', + } + const markup = renderToStaticMarkup() + expect(markup).toContain('disabled (use --trust-project-agents to enable)') + expect(markup).toContain('trusted and enabled') // skills trusted + }) + + test('IndexStatusBox renders title, corpus, age, vector and hint/coverage', () => { + const block: IndexStatusContentBlock = { + type: 'index-status', + statusLine: 'Index ready.', + messageLine: 'all good', + corpusLine: 'corpus xyz', + ageLine: '2h ago', + vectorLine: 'vectors ok', + hintLine: 'hint: run reindex', + coverageLine: 'coverage low', + diagnosticsLines: ['diag 1', 'diag 2'], + lines: [], + } + const markup = renderToStaticMarkup() + expect(markup).toContain('Index ready') + expect(markup).toContain('all good') + expect(markup).toContain('Corpus:') + expect(markup).toContain('corpus xyz') + expect(markup).toContain('Age:') + expect(markup).toContain('2h ago') + expect(markup).toContain('Vector:') + expect(markup).toContain('vectors ok') + expect(markup).toContain('hint: run reindex') + expect(markup).toContain('coverage low') + expect(markup).toContain('Diagnostics (2)') + expect(markup).toContain('diag 1') + }) + + test('IndexStatusBox derives default title when empty statusLine', () => { + const block: IndexStatusContentBlock = { + type: 'index-status', + statusLine: ' ', + messageLine: '', + corpusLine: 'c', + ageLine: 'a', + vectorLine: 'v', + hintLine: '', + lines: [], + } + const markup = renderToStaticMarkup() + expect(markup).toContain('Index status') + }) + + test('PlanStatusBox status mode renders reportText', () => { + const block: PlanStatusContentBlock = { + type: 'plan-status', + mode: 'status', + reportText: 'Plan status report\ncurrent: do thing\n[active] session-a', + isStatusReport: true, + } + const markup = renderToStaticMarkup() + expect(markup).toContain('Plan status') + expect(markup).toContain('Plan status report') + expect(markup).toContain('current: do thing') + }) + + test('PlanStatusBox list mode renders sessions with badges', () => { + const block: PlanStatusContentBlock = { + type: 'plan-status-list', + mode: 'list', + reportText: 'fallback report', + sessions: [ + { + slug: 'my-session', + status: 'active', + isActive: true, + progress: { done: 2, total: 5 }, + currentTask: 'implement feature', + } as any, + { + slug: 'old-session', + status: 'completed', + isActive: false, + progress: { done: 0, total: 0 }, + currentTask: undefined, + } as any, + ], + isStatusReport: false, + } + const markup = renderToStaticMarkup() + expect(markup).toContain('Plan sessions') + expect(markup).toContain('my-session') + expect(markup).toContain('[active]') + expect(markup).toContain('2/5 done') + expect(markup).toContain('current: "implement feature"') + expect(markup).toContain('[completed]') + expect(markup).toContain('old-session') + }) + + test('each sweep box renders without throwing on minimal input', () => { + expect(() => + renderToStaticMarkup( + , + ), + ).not.toThrow() + expect(() => + renderToStaticMarkup(), + ).not.toThrow() + expect(() => + renderToStaticMarkup( + , + ), + ).not.toThrow() + expect(() => + renderToStaticMarkup( + , + ), + ).not.toThrow() + expect(() => + renderToStaticMarkup( + , + ), + ).not.toThrow() + }) +}) diff --git a/cli/src/components/blocks/single-block.tsx b/cli/src/components/blocks/single-block.tsx index a986fc5f38..517b98aa3f 100644 --- a/cli/src/components/blocks/single-block.tsx +++ b/cli/src/components/blocks/single-block.tsx @@ -9,14 +9,17 @@ import { ContentWithMarkdown } from './content-with-markdown' import { ImageBlock } from './image-block' import { UserBlockTextWithInlineCopy } from './user-content-copy' import { useTheme } from '../../hooks/use-theme' +import { CompletionSummaryBox } from '../renderers/completion-summary-box' +import { ContextBox } from '../renderers/context-box' +import { DoctorBox } from '../renderers/doctor-box' import { GateStateBox } from '../renderers/gate-state-box' +import { IndexStatusBox } from '../renderers/index-status-box' +import { InfoBox } from '../renderers/info-box' +import { MemoryBox } from '../renderers/memory-box' import { PlanBox } from '../renderers/plan-box' +import { PlanStatusBox } from '../renderers/plan-status-box' -import type { - ContentBlock, - TextContentBlock, - ImageContentBlock, -} from '../../types/chat' +import type { ContentBlock, TextContentBlock } from '../../types/chat' import type { MarkdownPalette } from '../../utils/markdown-renderer' interface SingleBlockProps { @@ -133,6 +136,63 @@ export const SingleBlock = memo( ) } + case 'completion-summary': { + return ( + + + + ) + } + + case 'memory': { + return ( + + + + ) + } + + case 'context': { + return ( + + + + ) + } + + case 'info': { + return ( + + + + ) + } + + case 'doctor': { + return ( + + + + ) + } + + case 'index-status': { + return ( + + + + ) + } + + case 'plan-status': + case 'plan-status-list': { + return ( + + + + ) + } + case 'html': { return ( ) diff --git a/cli/src/components/prompt-history-search-screen.tsx b/cli/src/components/prompt-history-search-screen.tsx index 9a93e63f95..a6943c5220 100644 --- a/cli/src/components/prompt-history-search-screen.tsx +++ b/cli/src/components/prompt-history-search-screen.tsx @@ -2,32 +2,47 @@ * PromptHistorySearchScreen - Full-screen fuzzy search over past user prompts * (Ctrl+R or `/prompts`). * - * Features: - * - Loads ALL prompts via `loadMessageHistory()` (most-recent-last on disk), - * reverses to most-recent-first for display, and filters in-memory. - * - Fuzzy subsequence matching with scoring (reuses `fuzzyMatch`). - * - Enter selects the focused prompt and hands it back to the input bar. - * - Escape / Ctrl-C closes without selecting. - * - Empty query shows the most recent ~200 prompts; typing filters them. + * - History is read from a mount effect via `loadSessionMessageHistory()`, so + * rendering stays pure. `PromptHistoryLoadState` keeps a not-yet-read or + * unreadable history from being reported as a genuinely empty one. + * - An 'unavailable' read is retried while the overlay stays open, on the + * process-wide retry budget in message-history: a transient busy lock + * recovers without a reopen, and a persistently unreadable history costs a + * bounded number of blocking lock acquisitions per process. + * - A reload can grow or shift the list, so the focus cursor follows the prompt + * the user focused (`reanchorFocusForReload`) instead of being clamped onto a + * different prompt that Enter would then select. + * - Empty query shows the most recent ~200 prompts; typing fuzzy-filters them + * (`fuzzyMatch`). Enter selects, Escape / Ctrl-C closes. * - * Note: Ctrl+R is the standard reverse-i-search binding in shells. Because - * Openbuff runs in its own TUI (not a raw shell), this binding is safe to - * repurpose here for global prompt history search. + * Note: Ctrl+R is the standard reverse-i-search binding in shells. Openbuff runs + * in its own TUI, so the binding is safe to repurpose here. */ import { TextAttributes } from '@opentui/core' -import React, { useCallback, useMemo, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { MultilineInput } from './multiline-input' import { SelectableList } from './selectable-list' import { useTerminalLayout } from '../hooks/use-terminal-layout' import { useTheme } from '../hooks/use-theme' -import { loadMessageHistory } from '../utils/message-history' +import { + MESSAGE_HISTORY_RETRY_COOLDOWN_MS, + getSessionHistoryRetry, + historyRetryNowMs, + loadSessionMessageHistory, + reconcileHistoryIndex, + recordSessionHistoryRetryAttempt, + shouldRetryUnavailableHistory, +} from '../utils/message-history' import { createTextPasteHandler } from '../utils/strings' import { isPlainEnterKey } from '../utils/terminal-enter-detection' +import { hashString } from '../utils/hash' import { fuzzyMatch } from '../utils/fuzzy-match' import type { SelectableListItem } from './selectable-list' +import type { InputValue } from '../types/store' +import type { MessageHistoryLoadOutcome } from '../utils/message-history' const LAYOUT = { CONTENT_PADDING: 4, @@ -80,6 +95,227 @@ export function filterAndScorePrompts( return scored.slice(0, limit).map((s) => s.prompt) } +/** + * Load lifecycle of the prompt history this screen searches. The history is read + * from an effect, so 'loading' is a state the results list genuinely renders in + * and must be modelled explicitly: collapsing it into 'loaded' reported a + * not-yet-read history as a genuinely empty one. + */ +export type PromptHistoryLoadState = 'loading' | 'loaded' | 'unavailable' + +/** + * Empty-results message for a history that could not be read. Exported so the + * tests assert against this exact string instead of a copy that can go stale. + */ +export const HISTORY_UNAVAILABLE_MESSAGE = + 'History unavailable — locked by another Openbuff process or unreadable' + +/** + * Pure helper for the results list's empty message. Neither a history that has + * not been read yet nor an unavailable one — a "busy" lock held by another + * Openbuff process, or persisted history that could not be read — may be + * reported as genuinely empty history, and both outrank the no-matches message + * because nothing was ever searched. + * + * Exported so tests can exercise the message choice without rendering React. + */ +export function getEmptyResultsMessage( + query: string, + loadState: PromptHistoryLoadState, +): string { + if (loadState === 'loading') return 'Loading prompt history...' + if (loadState === 'unavailable') return HISTORY_UNAVAILABLE_MESSAGE + const trimmed = query.trim() + return trimmed ? `No prompts matching "${trimmed}"` : 'No prompt history yet' +} + +/** + * The screen state one history read — a mount load or a retry of an unavailable + * one — resolves to. The retry budget spent is not part of it: that lives in the + * process-wide store every consumer shares, instead of being handed back to one + * component instance. + */ +export type RetriedPromptHistory = { + prompts: string[] + loadState: PromptHistoryLoadState +} + +/** + * Pure mapping of one history read onto the state this screen renders: + * most-recent-first prompts plus the load state the results list reports. + * + * `loadSessionMessageHistory()` returns most-recent-last, so the list is + * reversed here rather than at render time. A copy is reversed because the + * loaded array is the caller's, and an unavailable read is reported as such + * instead of being collapsed into a genuinely-empty 'loaded'. + * + * Exported so tests can exercise the mount effect's mapping without rendering + * React (renderHook/render is unreliable under React 19 + Bun here). + */ +export function deriveLoadedPrompts( + history: string[], + unavailable: boolean, +): RetriedPromptHistory { + return { + prompts: [...history].reverse(), + loadState: unavailable ? 'unavailable' : 'loaded', + } +} + +/** + * Read the history this overlay searches, through the shared session load. + * + * `displayedPrompts` is the list currently on screen (most-recent-first); + * loadSessionMessageHistory works in persisted (most-recent-last) order, so it + * is reversed on the way in. Going through the shared load is what keeps this + * screen consistent with up/down navigation: a still-degraded read may only + * grow the view it is given, and this session's memory-only prompts (appends + * that never reached disk) are folded in for both consumers instead of only for + * the navigation hook. + * + * Exported so tests exercise the real read without rendering React + * (render/renderHook is unreliable under React 19 + Bun here). + */ +export function readPromptHistoryForDisplay( + displayedPrompts: string[], +): MessageHistoryLoadOutcome { + return loadSessionMessageHistory([...displayedPrompts].reverse()) +} + +/** + * Retry a previously unavailable history read while the overlay is open. + * + * Without this the mount-only read left the screen pinned to + * HISTORY_UNAVAILABLE_MESSAGE for as long as the overlay stayed open, even + * though the busy lock that caused it is typically held for milliseconds by + * another Openbuff process. The rules are the ones use-input-history already + * applies, so both consumers of the same load recover identically: + * + * - `load` is not called at all unless the last read was unavailable *and* the + * process-wide retry budget allows another attempt (cooldown, attempt cap, + * idle refund), so a persistently unreadable history pays a bounded number of + * blocking lock acquisitions rather than one per poll. + * - That budget is read from and spent in message-history's session store, not + * passed in and returned: a budget owned by this component bounded only one + * mount, so navigation, each reopen of the overlay and every remount could + * each pay MESSAGE_HISTORY_MAX_RETRY_ATTEMPTS blocking acquisitions of their + * own. Sharing one budget is what makes the documented bound hold, and a + * trustworthy read here clears it for the navigation hook too. + * - `load` is the shared session read (readPromptHistoryForDisplay), which + * already resolves a still-degraded read against the view the screen holds — + * so a partial read can never drop prompts already on screen — and folds in + * this session's memory-only prompts. + * + * `nowMs` must come from historyRetryNowMs. Returns undefined when no retry is + * due: nothing was read and no state changes. + * + * Exported so tests exercise the real recovery step without rendering React + * (render/renderHook is unreliable under React 19 + Bun here). + */ +export function retryUnavailablePromptHistory(params: { + loadState: PromptHistoryLoadState + nowMs: number + load: () => MessageHistoryLoadOutcome +}): RetriedPromptHistory | undefined { + const { loadState, nowMs, load } = params + if (loadState !== 'unavailable') return undefined + if (!shouldRetryUnavailableHistory(getSessionHistoryRetry(), nowMs)) + return undefined + const loaded = load() + recordSessionHistoryRetryAttempt(nowMs, loaded.unavailable) + return deriveLoadedPrompts(loaded.history, loaded.unavailable) +} + +/** + * Re-anchor the results list's focus cursor onto a list a background reload + * replaced. + * + * The retry above can grow or shift the prompt list while the user is looking + * at it (a busy lock cleared and the full history came back, or another + * terminal appended prompts). A stationary numeric cursor then points at a + * different prompt than the one the user focused, and Enter selects that other + * prompt — clamping only keeps the index in range, it does not keep it on the + * same entry. So the entry itself wins when it still exists, exactly as + * up/down navigation does. + * + * `reconcileHistoryIndex` is the shared implementation of that rule, but it + * works in persisted (most-recent-last) order — its lastIndexOf tie-break + * picks the *newest* occurrence of a verbatim repeat — while this screen lists + * most-recent-first. Both lists and the cursor are therefore mapped into that + * order and back instead of duplicating the rule here. + * + * Unlike navigation there is no draft to return to: an empty reload or a + * vanished prompt focuses the newest entry / clamps into range rather than + * leaving the cursor unusable at -1. + * + * Exported so tests can exercise the re-anchoring without rendering React + * (render/renderHook is unreliable under React 19 + Bun here). + */ +export function reanchorFocusedPrompt(params: { + focusedIndex: number + displayedPrompts: string[] + reloadedPrompts: string[] +}): number { + const { focusedIndex, displayedPrompts, reloadedPrompts } = params + const toPersisted = (index: number, length: number): number => + length - 1 - index + // Re-anchor from the cursor the list actually rendered: the raw state can sit + // past the end after the query shrank the list, and mapping that into + // persisted order would produce a negative index and drop the anchor. + const rendered = Math.min( + Math.max(focusedIndex, 0), + Math.max(0, displayedPrompts.length - 1), + ) + // The empty lists need no guards of their own: an empty displayed list maps to + // toPersisted(0, 0) === -1, and an empty reload makes reconcileHistoryIndex + // return -1 by itself, so both fall through the trailing branch and focus the + // newest entry. + const reconciled = reconcileHistoryIndex( + toPersisted(rendered, displayedPrompts.length), + [...displayedPrompts].reverse(), + [...reloadedPrompts].reverse(), + ) + return reconciled === -1 ? 0 : toPersisted(reconciled, reloadedPrompts.length) +} + +/** + * Focus cursor a reload must adopt, derived from the raw prompt lists and the + * query that is live *now*. + * + * The overlay renders `filterAndScorePrompts(allPrompts, searchQuery)` and the + * focus cursor addresses that filtered list, so re-anchoring needs the exact + * filtered list the cursor was pointing into. Mirroring the rendered list into + * a ref from a post-render effect made that mirror lag: a retry tick that fired + * before the effect flushed re-anchored against a stale query/result list and + * could move the highlight onto an unrelated prompt. Deriving both lists here, + * from the raw prompts and the synchronously published query, removes the + * second copy entirely — the same inputs the render uses produce the same + * filtered lists whenever the retry lands. + * + * Exported so tests can exercise the derivation without rendering React + * (render/renderHook is unreliable under React 19 + Bun here). + */ +export function reanchorFocusForReload(params: { + focusedIndex: number + query: string + displayedAllPrompts: string[] + reloadedAllPrompts: string[] + limit: number +}): number { + const { + focusedIndex, + query, + displayedAllPrompts, + reloadedAllPrompts, + limit, + } = params + return reanchorFocusedPrompt({ + focusedIndex, + displayedPrompts: filterAndScorePrompts(displayedAllPrompts, query, limit), + reloadedPrompts: filterAndScorePrompts(reloadedAllPrompts, query, limit), + }) +} + export const PromptHistorySearchScreen: React.FC< PromptHistorySearchScreenProps > = ({ onClose, onSelectPrompt }) => { @@ -89,29 +325,117 @@ export const PromptHistorySearchScreen: React.FC< const [searchQuery, setSearchQuery] = useState('') const [searchCursor, setSearchCursor] = useState(0) const [focusedIndex, setFocusedIndex] = useState(0) + const [allPrompts, setAllPrompts] = useState([]) + const [loadState, setLoadState] = useState('loading') + // Mirrors allPrompts for the retry poll below, which runs from a timer and + // must not re-subscribe (and restart its cooldown) on every state change. + const promptsRef = useRef([]) + // Mirrors searchQuery, written in the query change handler together with + // setSearchQuery so a retry tick landing between a keystroke and the next + // render re-anchors against the query the user actually typed. The rendered + // result list is derived from these two, never mirrored separately. + const searchQueryRef = useRef('') + // Mirrors focusedIndex so the next cursor is computed exactly once per reload: + // reanchorFocusForReload is not idempotent (it follows the focused prompt to + // its new position), and React may invoke a state updater twice. + const focusedIndexRef = useRef(0) - // Load the full prompt history once on mount. `loadMessageHistory()` returns - // most-recent-last; reverse so the most recent prompt is first for display. - const allPrompts = useMemo(() => { - const history = loadMessageHistory() - return [...history].reverse() + // Every focus change goes through here, so the ref stays the authoritative + // pre-reload cursor the re-anchoring in applyLoad reads. + const publishFocusedIndex = useCallback((next: number) => { + focusedIndexRef.current = next + setFocusedIndex(next) }, []) + const applyLoad = useCallback( + (next: RetriedPromptHistory) => { + // Keep the cursor on the prompt the user focused: this load may have + // grown or shifted the list, and Enter selects whatever the cursor points + // at. Both filtered lists are derived from the live query here, because + // the focus index addresses the filtered list, not the raw history. + // Computed exactly once, before promptsRef is advanced, so a re-run of + // this callback (or a double-invoked state updater) re-anchors between two + // identical lists and leaves the highlight where it is. + publishFocusedIndex( + reanchorFocusForReload({ + focusedIndex: focusedIndexRef.current, + query: searchQueryRef.current, + displayedAllPrompts: promptsRef.current, + reloadedAllPrompts: next.prompts, + limit: LAYOUT.MAX_RENDERED_ITEMS, + }), + ) + promptsRef.current = next.prompts + setAllPrompts(next.prompts) + setLoadState(next.loadState) + }, + [publishFocusedIndex], + ) + + // The shared session read never throws, uses the short interactive lock budget + // and degrades to whatever could be read, so neither the mount pass nor a + // retry tick can throw or stall the event loop; every form of unavailability + // (busy lock, unreadable snapshot, failed journal read) is reported back. + const readPromptHistory = useCallback( + (): MessageHistoryLoadOutcome => + readPromptHistoryForDisplay(promptsRef.current), + [], + ) + + // Load the full prompt history on mount, from an effect instead of the render + // body so rendering stays pure (no synchronous disk I/O mid-render). Until + // this effect runs the state stays 'loading', so the first render does not + // claim the history is empty. The mapping itself lives in deriveLoadedPrompts + // so it is testable without rendering. + useEffect(() => { + const loaded = readPromptHistory() + recordSessionHistoryRetryAttempt(historyRetryNowMs(), loaded.unavailable) + applyLoad(deriveLoadedPrompts(loaded.history, loaded.unavailable)) + }, [readPromptHistory, applyLoad]) + + // Recover from an unavailable load while the overlay stays open: a busy lock + // held by another Openbuff process is usually transient. + // retryUnavailablePromptHistory owns the rule set, so this stays a state + // assignment, and the timer exists only while the history is unavailable. + // Ticks past the spent attempt cap are cheap (one predicate, no I/O) and are + // what pick the retry back up once the idle refund applies. + useEffect(() => { + if (loadState !== 'unavailable') return + const timer = setInterval(() => { + const retried = retryUnavailablePromptHistory({ + // The live load state from this effect's closure, not a hardcoded + // 'unavailable': the guard inside the retry stays the one authority on + // whether a read is due, in production as well as in tests. + loadState, + nowMs: historyRetryNowMs(), + load: readPromptHistory, + }) + if (!retried) return + applyLoad(retried) + }, MESSAGE_HISTORY_RETRY_COOLDOWN_MS) + return () => clearInterval(timer) + }, [loadState, readPromptHistory, applyLoad]) + const filteredPrompts = useMemo( () => filterAndScorePrompts(allPrompts, searchQuery, LAYOUT.MAX_RENDERED_ITEMS), [allPrompts, searchQuery], ) - const items = useMemo( - () => - filteredPrompts.map((prompt, index) => ({ - id: `prompt:${index}:${prompt}`, - label: prompt.replace(/\n/g, ' '), - icon: '▸', - })), - [filteredPrompts], - ) + const items = useMemo(() => { + const seen = new Map() + return filteredPrompts.map((prompt) => { + const h = hashString(prompt) + const n = seen.get(h) ?? 0 + seen.set(h, n + 1) + // Content hash, not positional index: filterAndScorePrompts reorders by + // score, so a `prompt:${index}` key + findIndex lookup was fragile and + // O(n). The hash keeps React keys stable across reorder while the dedup + // suffix keeps duplicate prompts unique without embedding unbounded text. + const id = n === 0 ? `prompt:${h}` : `prompt:${h}-${n}` + return { id, label: prompt.replace(/\n/g, ' '), icon: '▸' } + }) + }, [filteredPrompts]) // Clamp focused index when the filtered list shrinks const clampedFocusedIndex = Math.min( @@ -119,18 +443,28 @@ export const PromptHistorySearchScreen: React.FC< Math.max(0, items.length - 1), ) + // Publish the query synchronously with its state update so the retry poll's + // re-anchoring can never see a query the user has already replaced. + const handleQueryChange = useCallback( + ({ text, cursorPosition }: InputValue) => { + searchQueryRef.current = text + setSearchQuery(text) + setSearchCursor(cursorPosition) + publishFocusedIndex(0) + }, + [publishFocusedIndex], + ) + const handleSelect = useCallback( - (item: SelectableListItem) => { - const idx = items.findIndex((i) => i.id === item.id) - if (idx === -1) return - const prompt = filteredPrompts[idx] + (item: SelectableListItem, index: number) => { + const prompt = filteredPrompts[index] if (!prompt) return // Close the overlay BEFORE updating the input so the keyboard hook does // not re-interpret the keypress while the overlay is unmounting. onClose() onSelectPrompt(prompt) }, - [items, filteredPrompts, onClose, onSelectPrompt], + [filteredPrompts, onClose, onSelectPrompt], ) const handleKeyIntercept = useCallback( @@ -147,17 +481,21 @@ export const PromptHistorySearchScreen: React.FC< return true } if (key.name === 'up') { - setFocusedIndex((prev) => Math.max(0, prev - 1)) + publishFocusedIndex(Math.max(0, focusedIndexRef.current - 1)) return true } if (key.name === 'down') { - setFocusedIndex((prev) => Math.min(items.length - 1, prev + 1)) + // Math.max like the up branch: an empty results list (unavailable or + // no-match) would otherwise publish an invalid -1 cursor. + publishFocusedIndex( + Math.max(0, Math.min(items.length - 1, focusedIndexRef.current + 1)), + ) return true } if (isPlainEnterKey(key)) { const focused = items[clampedFocusedIndex] if (focused) { - handleSelect(focused) + handleSelect(focused, clampedFocusedIndex) } return true } @@ -168,7 +506,7 @@ export const PromptHistorySearchScreen: React.FC< // Let printable keys through to the input return false }, - [items, clampedFocusedIndex, handleSelect, onClose], + [items, clampedFocusedIndex, handleSelect, onClose, publishFocusedIndex], ) const contentMaxWidth = Math.min( @@ -228,20 +566,12 @@ export const PromptHistorySearchScreen: React.FC< { - setSearchQuery(text) - setSearchCursor(cursorPosition) - setFocusedIndex(0) - }} + onChange={handleQueryChange} onSubmit={() => {}} onPaste={createTextPasteHandler( searchQuery, Math.min(searchCursor, searchQuery.length), - ({ text, cursorPosition }) => { - setSearchQuery(text) - setSearchCursor(cursorPosition) - setFocusedIndex(0) - }, + handleQueryChange, )} onKeyIntercept={handleKeyIntercept} placeholder="Search past prompts..." @@ -268,14 +598,10 @@ export const PromptHistorySearchScreen: React.FC< diff --git a/cli/src/components/renderers/completion-summary-box.tsx b/cli/src/components/renderers/completion-summary-box.tsx new file mode 100644 index 0000000000..bd27557356 --- /dev/null +++ b/cli/src/components/renderers/completion-summary-box.tsx @@ -0,0 +1,174 @@ +import { memo } from 'react' + +import { HarnessBox } from './harness-box' +import { useTheme } from '../../hooks/use-theme' + +import type { CompletionSummaryContentBlock } from '../../types/chat' +import type { ChatTheme } from '../../types/theme-system' +import type { CompletionSummary } from '../../utils/completion-summary' + +type Tone = 'secondary' | 'success' | 'error' | 'warning' | 'info' + +const deriveTone = (summary: CompletionSummary): Tone => { + const isBlockingVerdict = + summary.reviewVerdict === 'BLOCKING' || summary.reviewVerdict === 'NEEDS_WORK' + const isWarningVerdict = summary.reviewVerdict === 'NON_BLOCKING' + const hasFailed = + summary.filesFailed > 0 || + summary.testFailed > 0 || + summary.hooksFailed > 0 || + summary.auxiliaryFailed > 0 + const hasUnconfirmed = summary.filesUnconfirmed > 0 + const hasRollbackIncomplete = summary.rollbackIncomplete > 0 + const hasRolledBack = summary.filesRolledBack > 0 + + if ( + hasFailed || + hasUnconfirmed || + hasRollbackIncomplete || + isBlockingVerdict || + summary.errors > 0 + ) { + return 'error' + } + if (hasRolledBack || isWarningVerdict) { + return 'warning' + } + const hasSuccess = + summary.filesEdited > 0 || + summary.testPassed > 0 || + summary.hooksPassed > 0 || + summary.auxiliaryCompleted > 0 || + summary.reviewVerdict === 'LOOKS_GOOD' || + summary.reviewVerdict === 'APPROVED' + if (hasSuccess) return 'success' + return 'secondary' +} + +const statusColorForTone = (tone: Tone, theme: ChatTheme): string => { + switch (tone) { + case 'success': + return theme.success + case 'error': + return theme.error + case 'warning': + return theme.warning + case 'info': + return theme.info + default: + return theme.secondary + } +} + +const reviewIcon = (verdict: string | null): string => { + if (verdict === 'BLOCKING' || verdict === 'NEEDS_WORK') return '🔴' + if (verdict === 'NON_BLOCKING') return '🟡' + if (verdict === 'LOOKS_GOOD' || verdict === 'APPROVED') return '🟢' + return '🟢' +} + +interface CompletionSummaryBoxProps { + block: CompletionSummaryContentBlock +} + +export const CompletionSummaryBox = memo(({ block }: CompletionSummaryBoxProps) => { + const theme = useTheme() + const summary = block.summary + const tone = deriveTone(summary) + + const hasFiles = + summary.filesEdited > 0 || + summary.filesFailed > 0 || + summary.filesUnconfirmed > 0 || + summary.filesRolledBack > 0 || + summary.rollbackIncomplete > 0 + const hasHooks = summary.hooksPassed > 0 || summary.hooksFailed > 0 || summary.hooksSkipped > 0 + const hasTests = summary.testPassed > 0 || summary.testFailed > 0 + const hasAux = summary.auxiliaryCompleted > 0 || summary.auxiliaryFailed > 0 + + const filesTone: Tone = + summary.filesFailed > 0 || summary.rollbackIncomplete > 0 || summary.filesUnconfirmed > 0 + ? 'error' + : summary.filesRolledBack > 0 + ? 'warning' + : 'success' + const hooksTone: Tone = summary.hooksFailed > 0 ? 'error' : 'success' + const testsTone: Tone = summary.testFailed > 0 ? 'error' : 'success' + const auxTone: Tone = summary.auxiliaryFailed > 0 ? 'error' : 'success' + + const filesText = (() => { + const parts: string[] = [] + if (summary.filesEdited > 0) parts.push(`${summary.filesEdited} file${summary.filesEdited !== 1 ? 's' : ''} edited`) + if (summary.filesFailed > 0) parts.push(`${summary.filesFailed} failed`) + if (summary.filesUnconfirmed > 0) parts.push(`${summary.filesUnconfirmed} unconfirmed`) + if (summary.filesRolledBack > 0) parts.push(`${summary.filesRolledBack} rolled back`) + if (summary.rollbackIncomplete > 0) parts.push(`${summary.rollbackIncomplete} rollback incomplete`) + return parts.join(', ') + })() + const hooksText = (() => { + const parts: string[] = [] + if (summary.hooksPassed > 0) parts.push(`${summary.hooksPassed} passed`) + if (summary.hooksFailed > 0) parts.push(`${summary.hooksFailed} failed`) + if (summary.hooksSkipped > 0) parts.push(`${summary.hooksSkipped} skipped`) + return `Hooks: ${parts.join(', ')}` + })() + const testsText = (() => { + let part = 'Tests: ' + if (summary.testPassed > 0) part += `${summary.testPassed} passed` + if (summary.testFailed > 0) { + if (summary.testPassed > 0) part += ', ' + part += `${summary.testFailed} failed` + } + return part + })() + const auxText = `${summary.auxiliaryCompleted} auxiliary agent${summary.auxiliaryCompleted === 1 ? '' : 's'} completed${summary.auxiliaryFailed > 0 ? `, ${summary.auxiliaryFailed} failed` : ''}` + const errorsText = `${summary.errors} error${summary.errors !== 1 ? 's' : ''}` + const reviewTone: Tone = + summary.reviewVerdict === 'BLOCKING' || summary.reviewVerdict === 'NEEDS_WORK' + ? 'error' + : summary.reviewVerdict === 'NON_BLOCKING' + ? 'warning' + : 'success' + + return ( + + {hasFiles ? ( + + {filesTone === 'error' ? '❌' : filesTone === 'warning' ? '⚠️' : '✅'} + {` ${filesText}`} + + ) : null} + {hasHooks ? ( + + {hooksTone === 'error' ? '❌' : '✅'} + {` ${hooksText}`} + + ) : null} + {summary.reviewVerdict ? ( + + Reviewed: + {` ${reviewIcon(summary.reviewVerdict)}`} + {` ${summary.reviewVerdict}`} + + ) : null} + {hasTests ? ( + + {testsTone === 'error' ? '❌' : '✅'} + {` ${testsText}`} + + ) : null} + {hasAux ? ( + + {auxTone === 'error' ? '⚠️' : '✅'} + {` ${auxText}`} + + ) : null} + {summary.errors > 0 ? ( + + + {` ${errorsText}`} + + ) : null} + + ) +}) diff --git a/cli/src/components/renderers/context-box.tsx b/cli/src/components/renderers/context-box.tsx new file mode 100644 index 0000000000..8d3ff80890 --- /dev/null +++ b/cli/src/components/renderers/context-box.tsx @@ -0,0 +1,53 @@ +import { memo } from 'react' + +import { HarnessBox } from './harness-box' +import { useTheme } from '../../hooks/use-theme' + +import type { ContextContentBlock } from '../../types/chat' + +interface ContextBoxProps { + block: ContextContentBlock +} + +export const ContextBox = memo(({ block }: ContextBoxProps) => { + const theme = useTheme() + const ledgerLines = + block.ledgerText && block.ledgerText.trim().length > 0 ? block.ledgerText.split('\n') : [] + const gateLines = + block.gateBudgetsText && block.gateBudgetsText.trim().length > 0 ? block.gateBudgetsText.split('\n') : [] + + return ( + + {ledgerLines.length > 0 ? ( + + {ledgerLines.map((line, idx) => { + const isHeader = idx < 2 + return ( + + {line.length > 0 ? line : ' '} + + ) + })} + + ) : null} + {gateLines.length > 0 ? ( + + {gateLines.map((line, idx) => { + const isTitle = line.startsWith('Gate repair budgets') || line.startsWith('Gate budgets') + return ( + + {line.length > 0 ? line : ' '} + + ) + })} + + ) : null} + + ) +}) diff --git a/cli/src/components/renderers/doctor-box.tsx b/cli/src/components/renderers/doctor-box.tsx new file mode 100644 index 0000000000..e163bfdd49 --- /dev/null +++ b/cli/src/components/renderers/doctor-box.tsx @@ -0,0 +1,71 @@ +import { memo } from 'react' + +import { HarnessBox } from './harness-box' +import { useTheme } from '../../hooks/use-theme' + +import type { DoctorContentBlock } from '../../types/chat' + +interface DoctorBoxProps { + block: DoctorContentBlock +} + +export const DoctorBox = memo(({ block }: DoctorBoxProps) => { + const theme = useTheme() + const providerLines = block.providerStatus ? block.providerStatus.split('\n') : [] + const diagnostics = block.diagnostics.slice(0, 10) + const agentsBadge = block.agentsTrusted ? 'trusted and enabled' : 'disabled (use --trust-project-agents to enable)' + const skillsBadge = block.skillsTrusted ? 'trusted and enabled' : 'disabled with project-agent trust policy' + + return ( + + + Project root: + {` ${block.projectRoot}`} + + + Project agents: + {` ${agentsBadge}`} + + + Project skills: + {` ${skillsBadge}`} + + + Loaded skills: + {` ${block.skillCount}`} + + + Loaded MCP servers: + {` ${block.mcpCount}`} + + + Agent diagnostics: + {` ${block.diagnostics.length}`} + + {diagnostics.length > 0 ? ( + + {diagnostics.map((diagnostic, idx) => ( + + {`- ${diagnostic.filePath || diagnostic.agentId}: ${diagnostic.message}`} + + ))} + + ) : null} + {providerLines.length > 0 ? ( + + {providerLines.map((line, idx) => ( + + {line.length > 0 ? line : ' '} + + ))} + + ) : null} + + ) +}) diff --git a/cli/src/components/renderers/gate-state-box.tsx b/cli/src/components/renderers/gate-state-box.tsx index 41d8c13ba4..e87daf6db2 100644 --- a/cli/src/components/renderers/gate-state-box.tsx +++ b/cli/src/components/renderers/gate-state-box.tsx @@ -1,10 +1,9 @@ import { memo } from 'react' import { useTheme } from '../../hooks/use-theme' -import { BORDER_CHARS } from '../../utils/ui-constants' +import { HarnessBox } from './harness-box' import type { GateStateContentBlock, GateStateStatus } from '../../types/chat' -import type { ChatTheme } from '../../types/theme-system' interface GateStateBoxProps { block: GateStateContentBlock @@ -24,40 +23,28 @@ const STATUS_ICON: Record = { skipped: '–', } -const statusColor = (status: GateStateStatus, theme: ChatTheme): string => { - switch (status) { - case 'passed': - return theme.success - case 'failed': - return theme.error - case 'pending': - return theme.warning - case 'skipped': - return theme.warning - } +const STATUS_TONE: Record< + GateStateStatus, + 'success' | 'error' | 'warning' | 'secondary' +> = { + pending: 'warning', + passed: 'success', + failed: 'error', + skipped: 'secondary', } export const GateStateBox = memo(({ block }: GateStateBoxProps) => { const theme = useTheme() - const color = statusColor(block.gateStatus, theme) - const heading = `${STATUS_ICON[block.gateStatus]} ${block.origin ?? 'Gate'} · ${block.gate} · ${STATUS_LABEL[block.gateStatus]}` + const color = theme[STATUS_TONE[block.gateStatus]] + const heading = `${STATUS_ICON[block.gateStatus]} ${block.origin?.trim() || 'Gate'} · ${block.gate} · ${STATUS_LABEL[block.gateStatus]}` return ( - - {heading} {block.gateStatus === 'skipped' ? ( { {block.details} ) : null} - + {block.advisories && block.advisories.length > 0 ? ( + <> + + Advisory (non-blocking): + + {block.advisories.map((advisory, index) => ( + + {`• ${advisory}`} + + ))} + + ) : null} + ) }) diff --git a/cli/src/components/renderers/harness-box.tsx b/cli/src/components/renderers/harness-box.tsx new file mode 100644 index 0000000000..e31465a0b3 --- /dev/null +++ b/cli/src/components/renderers/harness-box.tsx @@ -0,0 +1,53 @@ +import { memo, type ReactNode } from 'react' + +import { useTheme } from '../../hooks/use-theme' +import { BORDER_CHARS } from '../../utils/ui-constants' + +import type { ChatTheme } from '../../types/theme-system' + +type HarnessTone = 'secondary' | 'success' | 'error' | 'warning' | 'info' + +interface HarnessBoxProps { + tone?: HarnessTone + title?: string + gap?: 0 | 1 + paddingBottom?: 0 | 1 + children: ReactNode +} + +const getBorderColor = (theme: ChatTheme, tone: HarnessTone): string => { + const colors: Record = { + secondary: theme.secondary, + success: theme.success, + error: theme.error, + warning: theme.warning, + info: theme.info, + } + return colors[tone] +} + +export const HarnessBox = memo( + ({ tone = 'secondary', title, gap = 1, paddingBottom = 1, children }: HarnessBoxProps) => { + const theme = useTheme() + const borderColor = getBorderColor(theme, tone) + return ( + + {title ? {title} : null} + {children} + + ) + }, +) diff --git a/cli/src/components/renderers/index-status-box.tsx b/cli/src/components/renderers/index-status-box.tsx new file mode 100644 index 0000000000..5d706be723 --- /dev/null +++ b/cli/src/components/renderers/index-status-box.tsx @@ -0,0 +1,69 @@ +import { memo } from 'react' + +import { HarnessBox } from './harness-box' +import { useTheme } from '../../hooks/use-theme' +import { deriveTitle } from '../../utils/format-helpers' +import { hashString } from '../../utils/hash' + +import type { IndexStatusContentBlock } from '../../types/chat' + +interface IndexStatusBoxProps { + block: IndexStatusContentBlock +} + +export const IndexStatusBox = memo(({ block }: IndexStatusBoxProps) => { + const theme = useTheme() + const title = deriveTitle(block.statusLine) || 'Index status' + const diagnostics = block.diagnosticsLines ?? [] + + return ( + + {block.messageLine ? ( + {block.messageLine} + ) : null} + {block.corpusLine?.trim() ? ( + + Corpus: + {` ${block.corpusLine}`} + + ) : null} + {block.ageLine?.trim() ? ( + + Age: + {` ${block.ageLine}`} + + ) : null} + {block.vectorLine?.trim() ? ( + + Vector: + {` ${block.vectorLine}`} + + ) : null} + {block.hintLine ? ( + {block.hintLine} + ) : null} + {block.coverageLine ? ( + {block.coverageLine} + ) : null} + {diagnostics.length > 0 ? ( + + {`Diagnostics (${diagnostics.length})`} + {(() => { + const seen = new Map() + return diagnostics.map((line) => { + const h = hashString(line) + const n = seen.get(h) ?? 0 + seen.set(h, n + 1) + const key = n === 0 ? `diag-${h}` : `diag-${h}-${n}` + return ( + + {line} + + ) + }) + })()} + + ) : null} + + ) +}) diff --git a/cli/src/components/renderers/info-box.tsx b/cli/src/components/renderers/info-box.tsx new file mode 100644 index 0000000000..d72331c337 --- /dev/null +++ b/cli/src/components/renderers/info-box.tsx @@ -0,0 +1,31 @@ +import { memo } from 'react' + +import { HarnessBox } from './harness-box' +import { useTheme } from '../../hooks/use-theme' + +import type { InfoContentBlock } from '../../types/chat' + +interface InfoBoxProps { + block: InfoContentBlock +} + +export const InfoBox = memo(({ block }: InfoBoxProps) => { + const theme = useTheme() + + return ( + + + Version: + {` ${block.version}`} + + + Workspace: + {` ${block.workspace}`} + + + Auth: + Local/BYOK Mode + + + ) +}) diff --git a/cli/src/components/renderers/memory-box.tsx b/cli/src/components/renderers/memory-box.tsx new file mode 100644 index 0000000000..0c9df9d20a --- /dev/null +++ b/cli/src/components/renderers/memory-box.tsx @@ -0,0 +1,207 @@ +import { memo, useState } from 'react' + +import { HarnessBox } from './harness-box' +import { useTheme } from '../../hooks/use-theme' +import { BORDER_CHARS } from '../../utils/ui-constants' +import { Button } from '../button' +import { CollapseButton } from '../collapse-button' +import { formatAge, pluralizeEntries } from '../../utils/format-helpers' + +import type { MemoryContentBlock } from '../../types/chat' + +interface MemoryBoxProps { + block: MemoryContentBlock + onInsertCommand?: (command: string) => void +} + +const statusColorForTone = (tone: 'success' | 'error' | 'warning' | 'secondary', theme: ReturnType) => { + switch (tone) { + case 'success': + return theme.success + case 'error': + return theme.error + case 'warning': + return theme.warning + case 'secondary': + return theme.secondary + } +} + +export const MemoryBox = memo(({ block, onInsertCommand = () => {} }: MemoryBoxProps) => { + const theme = useTheme() + const [hoveredPrune, setHoveredPrune] = useState(false) + const [goalExpanded, setGoalExpanded] = useState(false) + const [staleExpanded, setStaleExpanded] = useState(false) + + if (block.state === 'empty') { + return ( + + + No persisted task memory for this project yet. + + + It is written after your first successful run completes. + + + ) + } + + if (block.state === 'status') { + const ageText = formatAge(Math.max(0, Date.now() - block.updatedAt)) + const header = `${block.revision} · ${ageText}` + const staleTone: 'success' | 'warning' | 'error' = + block.evidence.stale > 0 + ? block.evidence.fresh === 0 + ? 'error' + : 'warning' + : 'success' + const tone: 'success' | 'warning' | 'error' | 'secondary' = + block.evidence.stale > 0 ? staleTone : 'success' + const goalText = block.isGoalTruncated && goalExpanded && block.goal ? block.goal : block.goalPreview + const staleHeader = `Stale paths (${block.totalStaleCount})` // N via totalStaleCount, slice capped at STALE_PATHS_SHOWN in command + + return ( + + + Goal + {goalText} + {block.isGoalTruncated ? ( + goalExpanded ? ( + setGoalExpanded(false)} /> + ) : ( + + ) + ) : null} + + + + {`Decisions: ${block.counts.decisions}`} + {' \u00B7 '} + {`Requirements: ${block.counts.requirements}`} + {' \u00B7 '} + {`Edits: ${block.counts.editsMade}`} + + + {`Validations: ${block.counts.validationResults}`} + {' \u00B7 '} + {`Blockers: ${block.counts.blockers}`} + {' \u00B7 '} + {`Next actions: ${block.counts.nextActions}`} + + + + + Evidence: + {` ${block.evidence.fresh} fresh`} + {', '} + {`${block.evidence.stale} stale`} + {` (of ${block.evidence.total})`} + + + {block.totalStaleCount > 0 ? ( + staleExpanded ? ( + + {staleHeader} + {block.stalePaths.map((p, idx) => ( + + {p} + + ))} + {block.totalStaleCount > block.stalePaths.length ? ( + {`+ ${block.totalStaleCount - block.stalePaths.length} more`} + ) : null} + setStaleExpanded(false)} /> + + ) : ( + + ) + ) : null} + {block.evidence.stale > 0 ? ( + + ) : null} + + ) + } + + if (block.state === 'pruned') { + return ( + + + {`Pruned ${block.removed} stale evidence ${pluralizeEntries(block.removed)}; ${block.remaining} remain.`} + + + ) + } + + if (block.state === 'nothing-to-prune') { + return ( + + + {`Nothing to prune: all ${block.remaining} evidence entries are fresh.`} + + + ) + } + + if (block.state === 'no-record') { + return ( + + No persisted task memory to prune for this project. + + ) + } + + if (block.state === 'failed') { + return ( + + {`Memory prune failed: ${block.cause}.`} + + {`The record is unchanged: ${block.removed} stale evidence ${pluralizeEntries(block.removed)} still present (${block.remaining} fresh).`} + + + ) + } + + // error state + return ( + + {block.message} + + ) +}) diff --git a/cli/src/components/renderers/plan-box.tsx b/cli/src/components/renderers/plan-box.tsx index 84019ce760..df542fee0d 100644 --- a/cli/src/components/renderers/plan-box.tsx +++ b/cli/src/components/renderers/plan-box.tsx @@ -8,6 +8,7 @@ import { import { BORDER_CHARS } from '../../utils/ui-constants' import { Button } from '../button' import { BuildModeButtons } from '../build-mode-buttons' +import { HarnessBox } from './harness-box' import type { PlanArtifactMetadata } from '../../types/chat' @@ -30,12 +31,12 @@ const formatArtifactRows = (metadata: PlanArtifactMetadata): string[] => { ['STATUS.md', metadata.statusPath], ['LESSONS.md', metadata.lessonsPath], ] - .filter((row): row is [string, string] => Boolean(row[1])) + .filter((row): row is [string, string] => Boolean(row[1]?.trim())) .map(([label, value]) => `${label}: ${value}`) - const customArtifactRows = (metadata.customArtifacts ?? []).map( - ({ label, path }) => `${label}: ${path}`, - ) + const customArtifactRows = (metadata.customArtifacts ?? []) + .filter(({ label, path }) => Boolean(label?.trim()) && Boolean(path?.trim())) + .map(({ label, path }) => `${label}: ${path}`) return [...artifactRows, ...customArtifactRows] } @@ -49,7 +50,7 @@ const formatCommandRows = (metadata: PlanArtifactMetadata): string[] => metadata.statusCommand, metadata.lessonsCommand, ...(metadata.customArtifactCommands ?? []), - ].filter((command): command is string => Boolean(command)) + ].filter((command): command is string => Boolean(command?.trim())) export const PlanBox = memo( ({ @@ -65,23 +66,10 @@ export const PlanBox = memo( const commandRows = metadata ? formatCommandRows(metadata) : [] const hasMetadata = artifactRows.length > 0 || commandRows.length > 0 // Track which command is hovered so each button highlights independently. - const [hoveredCommand, setHoveredCommand] = useState(null) + const [hoveredIndex, setHoveredIndex] = useState(null) return ( - + {renderMarkdown(planContent, { codeBlockWidth: Math.max(10, availableWidth - 8), @@ -91,30 +79,30 @@ export const PlanBox = memo( {hasMetadata && ( Artifacts - {artifactRows.map((row) => ( - + {artifactRows.map((row, index) => ( + {row} ))} - {commandRows.map((command) => ( + {commandRows.map((command, index) => (