diff --git a/.claude/skills/issue-ingest/SKILL.md b/.claude/skills/issue-ingest/SKILL.md index cada39b8f..fcf3cf18f 100644 --- a/.claude/skills/issue-ingest/SKILL.md +++ b/.claude/skills/issue-ingest/SKILL.md @@ -81,6 +81,11 @@ its receipt gates ARE the mechanical implementation for the parts it owns): - **Reproduction before localization→PLAN** — `record_issue_reproduction` evidence or a typed `--no-repro` waiver (else the engine emits a one-shot reproduction-required directive). +- **Branch freshness before PLAN (issue-tracer v3 Phase 0, issue #2564)** — the trace records + the fetch outcome with `record_branch_freshness` (`synced`, `behind:`, or + `fetch-failed:` plus the verbatim user override when the user accepted a stale base). + `behind` and a bare `fetch-failed` fail closed (else the engine emits a one-shot + freshness-required directive). - **Plan-critic gate before EXECUTE** — the reducer will not advance to EXECUTE until the plan-critic approval is observed. - **Authoritative plan state** — read through the ledger-aware loader, never the projection. @@ -93,9 +98,22 @@ its receipt gates ARE the mechanical implementation for the parts it owns): - **Recurrence sweep before commit-pr handoff** — the defect class must be characterized, searched with explicit predicates, every hit dispositioned, and a guardrail installed with proof it catches the original defect (or the "no defect class" fast path recorded); - record it with `record_recurrence_sweep` (else the engine emits a one-shot sweep directive). + record it with `record_recurrence_sweep`, including `relatedProblems` — the Phase 1 + related-problems sweep results (at least one entry) on BOTH paths (else the engine emits + a one-shot sweep directive). +- **Per-phase validator receipts before commit-pr handoff (issue-tracer v3, issue #2564)** — + run the phase validator (`trace-check.sh phase `) for every completed phase and record + each outcome with `record_trace_validation` (phase, pass/fail, the reviewedCommit and + treeId it reported); any fail entry fails closed until re-recorded as a pass (else the + engine emits a one-shot validator directive). - **Honest completion** — `publication_handoff` is NOT "resolved"; terminal `published` needs an issue-bound publication receipt. +- **Merge approval recorded, never certified (issue-tracer v3 Phase 5.1, issue #2564)** — + after publication, the human merge approval is captured with `record_merge_approval` + (prHeadSha equal to finalCriticReviewedCommit, userApprovalVerbatim quoted verbatim); + the trace reaches its true terminal `merge_approval_recorded` status. The merge decision + stays human-enforced — the plugin records it for audit and never certifies, drives, or + green-lights the merge itself. - **Durable delivery** — a transition persists only after its directive is delivered. With these receipts the Full-Resolution Contract is mechanically composed into this trace diff --git a/.opencode/skills/issue-ingest/SKILL.md b/.opencode/skills/issue-ingest/SKILL.md index cada39b8f..fcf3cf18f 100644 --- a/.opencode/skills/issue-ingest/SKILL.md +++ b/.opencode/skills/issue-ingest/SKILL.md @@ -81,6 +81,11 @@ its receipt gates ARE the mechanical implementation for the parts it owns): - **Reproduction before localization→PLAN** — `record_issue_reproduction` evidence or a typed `--no-repro` waiver (else the engine emits a one-shot reproduction-required directive). +- **Branch freshness before PLAN (issue-tracer v3 Phase 0, issue #2564)** — the trace records + the fetch outcome with `record_branch_freshness` (`synced`, `behind:`, or + `fetch-failed:` plus the verbatim user override when the user accepted a stale base). + `behind` and a bare `fetch-failed` fail closed (else the engine emits a one-shot + freshness-required directive). - **Plan-critic gate before EXECUTE** — the reducer will not advance to EXECUTE until the plan-critic approval is observed. - **Authoritative plan state** — read through the ledger-aware loader, never the projection. @@ -93,9 +98,22 @@ its receipt gates ARE the mechanical implementation for the parts it owns): - **Recurrence sweep before commit-pr handoff** — the defect class must be characterized, searched with explicit predicates, every hit dispositioned, and a guardrail installed with proof it catches the original defect (or the "no defect class" fast path recorded); - record it with `record_recurrence_sweep` (else the engine emits a one-shot sweep directive). + record it with `record_recurrence_sweep`, including `relatedProblems` — the Phase 1 + related-problems sweep results (at least one entry) on BOTH paths (else the engine emits + a one-shot sweep directive). +- **Per-phase validator receipts before commit-pr handoff (issue-tracer v3, issue #2564)** — + run the phase validator (`trace-check.sh phase `) for every completed phase and record + each outcome with `record_trace_validation` (phase, pass/fail, the reviewedCommit and + treeId it reported); any fail entry fails closed until re-recorded as a pass (else the + engine emits a one-shot validator directive). - **Honest completion** — `publication_handoff` is NOT "resolved"; terminal `published` needs an issue-bound publication receipt. +- **Merge approval recorded, never certified (issue-tracer v3 Phase 5.1, issue #2564)** — + after publication, the human merge approval is captured with `record_merge_approval` + (prHeadSha equal to finalCriticReviewedCommit, userApprovalVerbatim quoted verbatim); + the trace reaches its true terminal `merge_approval_recorded` status. The merge decision + stays human-enforced — the plugin records it for audit and never certifies, drives, or + green-lights the merge itself. - **Durable delivery** — a transition persists only after its directive is delivered. With these receipts the Full-Resolution Contract is mechanically composed into this trace diff --git a/docs/observability-retention-registry.md b/docs/observability-retention-registry.md index 3f8395dab..3d2507550 100644 --- a/docs/observability-retention-registry.md +++ b/docs/observability-retention-registry.md @@ -367,11 +367,8 @@ per row. | `council-criteria` | .swarm/council/{safeId(taskId)}.json | governed-content | one criteria file per task; council/ dir close-scoped (per-key) | indexed: single JSON per task | cleaned — council/ dir lifecycle | not a defect — this-gate | | `council-evidence-files` | .swarm/evidence/{phase}/phase-council.json + .swarm/evidence/final-co… | governed-content | per-phase/per-final single artifacts; evidence/ close-scoped (session-scoped) | indexed: single JSON | cleaned — evidence/ dir lifecycle | not a defect — this-gate | | `record-receipt-artifacts` | .swarm/{implementation-review,issue-publication,reproduction,recurren… | governed-content | single rewritten receipt files; bounded fields (global) | indexed: single small JSONs | untouched (cross-run receipts by design — issue-tracer… | not a defect — this-gate | -<<<<<<< HEAD | `pr-feedback-loop-state` | .swarm/pr-feedback-loop-state.json + pr-feedback-evidence/{seq}.json + pr-feedback-loop-cleanups/ | operational | one rewritten state (200-correlation FIFO) + one evidence JSON per oversight dispatch + one receipt per cancellation (per-trigger) | full-file: Zod-validated state read | retained — cross-run idempotency basis (digests/budgets); close: neither | not a defect — this-gate; direct-file exemption (#2502) | -======= | `speckit-checkoff-ledger` | .swarm/speckit-checkoff-ledger.json | derived-rebuildable | single rewritten JSON, one entry per projected feature (MAX_SPEC_FILES 100) (none) | full-file: 512 KiB bounded read (speckit-checkoff.ts readBounded) | retained — derived state, rebuilt on next /swarm sdd project; close: neither | not a defect — this-gate; direct-file exemption (#2501) | ->>>>>>> origin/main | `spec-drift-artifacts` | .swarm/spec.md + .swarm/spec-staleness.json + .swarm/spec-snapshot.md… | authoritative | single-session drift state; spec-archive/ + spec.md + staleness + snapshot all in close c… (session-scoped) | full-file: bounded spec reads (effective-spec.ts:11-14) | archived+cleaned — unconditional removal so next sessi… | not a defect — this-gate; direct-file exemption (#2036) | | `workflow-wal-dirs` | .swarm/coder-settlements/{taskId}.json + .swarm/task-repairs/{taskId}… | authoritative | per-task WAL files; all four dirs in ACTIVE_STATE_DIRS_TO_CLEAN (session-scoped) | indexed: single JSON per task | cleaned — all four dirs archived+cleaned | not a defect — this-gate; direct-file exemption (#2036) | | `summaries` | .swarm/summaries/{S*}.json | governed-content | summaries.retention_days (default 7) enforced by the retention sweep via cleanupSummaries; listing capped MAX_SUMMARIES_LISTED 500 (global) | indexed: per-file reads; listing newest-first capped 500 | untouched — the sweep owns the retention_days horizon | not a defect — #2483 | diff --git a/docs/releases/pending/issue-2564-tracer-v3-receipts.md b/docs/releases/pending/issue-2564-tracer-v3-receipts.md new file mode 100644 index 000000000..9e2cc49ac --- /dev/null +++ b/docs/releases/pending/issue-2564-tracer-v3-receipts.md @@ -0,0 +1,60 @@ +# Wire issue-tracer v3 receipts plugin-side and prove issue ingestion reaches a durable plan (issue #2564) + +## What + +- **Branch-freshness receipt (v3 Phase 0)**: new `record_branch_freshness` tool writes + `.swarm/branch-freshness.json` (`synced` / `behind:` / `fetch-failed:` plus an + optional verbatim user override). The reader mirrors `trace-check.sh phase0` exactly — + `behind` and a bare `fetch-failed` fail closed; only a recorded override rescues a failed + fetch — and the reducer parks the trace with a one-shot `FRESHNESS_GATE` directive before + the PLAN transition until the receipt permits. +- **Per-phase validator receipts**: new `record_trace_validation` tool records each + `trace-check.sh phase ` outcome (v3 phase enum 0..5 including 2.5/4.2/4.5/4.6, pass/fail, + reviewedCommit + treeId, both 40-hex) into `.swarm/trace-validation.json`, upserting per phase. + The reducer's new `TRACE_VALIDATION_GATE` blocks the commit-pr handoff while any recorded phase + is failing or none is recorded. +- **Widened recurrence-sweep receipt**: `record_recurrence_sweep` now requires + `relatedProblems` — the Phase 1 related-problems sweep results, at least one + `{ref, note?}` entry — on both the real-defect-class and "no defect class" paths, and the + reader enforces the widened shape non-vacuously (missing key / empty array / blank refs no + longer satisfy the gate). +- **Merge-approval receipt (v3 Phase 5.1)**: new `record_merge_approval` tool records the + human merge approval bound to the exact PR head (`prHeadSha === finalCriticReviewedCommit`, + both 40-hex; mismatch rejected at write time) with the approval quoted verbatim. The trace + gains a true terminal `merge_approval_recorded` status after `published` — RECORDED, NEVER + CERTIFIED: `nextMode` stays null and the directive names the human as the merge authority, + inheriting trace-check.sh's "human-enforced gate; presence and binding only" posture. +- **Journey proof**: new end-to-end tests drive a real `/swarm issue --trace` input + through `handleIssueCommand`, the real receipt tool executors, the real ledger + (`savePlan`/`loadPlan` — plan tasks carry acceptance criteria, files, and requirement + references), the real `approve_plan_critic` path, an interruption/resume step (module + cache resets), the gates ladder, publication, and the merge-approval recording — no + `_internals` behavioral overrides. + +## Why + +Issue #2564: the issue-tracer v3 protocol's gates were enforced only by the skill text and +`scripts/trace-check.sh` — the plugin-side reducer, state adapter, and receipt tools knew only +the v2-era artifacts, so the runtime could neither observe nor enforce the v3 receipts, and no +test wired the ingestion command, the real receipt tools, and the reducer together. + +## Migration notes + +- A recurrence-sweep receipt recorded before this change (v2 shape, without `relatedProblems`) + no longer satisfies the recurrence gate. An in-flight trace that already fired its recurrence + one-shot will wait quietly; re-running `/swarm issue --trace` resets the trace state and + re-arms every gate, and the widened receipt is then recorded with `relatedProblems`. A + load-time receipt migration is deliberately out of scope here (#2566 owns validator, + portability, and migration follow-ups). +- `TraceStatus` grows `merge_approval_recorded`; state readers normalize unknown legacy + statuses to `in_progress` exactly as before, and `published` alone remains a valid resting + state until a merge-approval receipt is observed. + +## Verification + +- Frozen acceptance checks C1-C9 (arm's-length authored, red-checkpointed at base + `b865ba262`): all RED/ERROR at base, all GREEN post-fix; PRESERVING checks C6/C8 stayed + GREEN throughout (v2-shaped reducer literals stay transparent — the new gates fire only on + explicit `false`). +- The reducer remains a pure, model-agnostic module (no imports, no runner/model/host routing + anywhere in the trace engine). diff --git a/scripts/retention-registry.data.ts b/scripts/retention-registry.data.ts index b36e7556e..e87a07193 100644 --- a/scripts/retention-registry.data.ts +++ b/scripts/retention-registry.data.ts @@ -2206,26 +2206,30 @@ export const RETENTION_REGISTRY: readonly RetentionRow[] = [ { id: 'record-receipt-artifacts', category: 5, - pathGrammar: '.swarm/{implementation-review,issue-publication,reproduction,recurrence-sweep}.json + .swarm/{issue-trace-state,issue-reference}.json', + pathGrammar: '.swarm/{implementation-review,issue-publication,reproduction,recurrence-sweep,branch-freshness,trace-validation,merge-approval}.json + .swarm/{issue-trace-state,issue-reference}.json', canonicalRoot: 'project-swarm', writerModules: [ 'src/tools/record-implementation-review.ts', 'src/tools/record-issue-publication.ts', 'src/tools/record-issue-reproduction.ts', 'src/tools/record-recurrence-sweep.ts', + 'src/tools/record-branch-freshness.ts', + 'src/tools/record-trace-validation.ts', + 'src/tools/record-merge-approval.ts', 'src/hooks/issue-trace-state.ts', 'src/commands/issue.ts', ], writerCitations: [ 'record-implementation-review.ts:87 / record-issue-publication.ts:74 / record-issue-reproduction.ts:85 / record-recurrence-sweep.ts:125 — atomic single JSONs with Zod-bounded fields', - 'issue-trace-state.ts:308 writeTraceState + issue.ts:216-264 — transactional two-artifact write with rollback (:243-288)', + 'record-branch-freshness.ts:92 / record-trace-validation.ts:136 / record-merge-approval.ts:89 — issue #2564 v3 receipts: atomic single JSONs, unique per-process temp names, trace-validation upserts one bounded entry per phase', + 'issue-trace-state.ts:435 writeTraceState + issue.ts:216-264 — transactional two-artifact write with rollback (:243-288)', ], - readerCitations: ['issue-trace-state.ts:269,287 — full-file reads with legacy completed→status normalization (:242-252)'], - schemaVersion: 'per-artifact Zod schemas (commands ≤200, text ≤4000 chars)', + readerCitations: ['issue-trace-state.ts:396,414 — full-file reads with legacy completed→status normalization (:369-379); v3 readers branch-freshness/trace-validation/merge-approval co-located'], + schemaVersion: 'per-artifact Zod schemas (commands ≤200, text ≤4000 chars; v3 receipts ≤10 upserted entries, SHAs 40-hex)', stateClass: 'governed-content', privacyClass: 'metadata', - writeLimits: { bound: 'single rewritten receipt files; bounded fields', scope: 'global', citation: 'record-issue-reproduction.ts:28-31; record-recurrence-sweep.ts:47-61' }, - readBound: { pattern: 'indexed', bound: 'single small JSONs', sync: true, citation: 'src/hooks/issue-trace-state.ts:287-302' }, + writeLimits: { bound: 'single rewritten receipt files; bounded fields', scope: 'global', citation: 'record-issue-reproduction.ts:28-31; record-recurrence-sweep.ts:47-74; record-trace-validation.ts:37-56' }, + readBound: { pattern: 'indexed', bound: 'single small JSONs', sync: true, citation: 'src/hooks/issue-trace-state.ts:414-429' }, lockModel: 'atomic writes; issue pair is transactional with rollback', crashBehavior: 'temp+rename everywhere; fail-open reads', closePolicy: 'untouched (cross-run receipts by design — issue-tracer gates read them)', @@ -2233,7 +2237,7 @@ export const RETENTION_REGISTRY: readonly RetentionRow[] = [ legacyCompatibility: 'legacy completed field normalized', healthSignal: 'n/a', owner: 'this-gate', - disposition: { kind: 'not-a-defect', proof: 'Single rewritten bounded receipt files (Zod-capped fields); no growth dimension (record-* tool schemas).' }, + disposition: { kind: 'not-a-defect', proof: 'Single rewritten bounded receipt files (Zod-capped fields); no growth dimension (record-* tool schemas; trace-validation capped at one entry per v3 phase enum value).' }, }, { id: 'pr-feedback-loop-state', diff --git a/src/adversarial-tests.test.ts b/src/adversarial-tests.test.ts index d8ebefd9e..fadf88e40 100644 --- a/src/adversarial-tests.test.ts +++ b/src/adversarial-tests.test.ts @@ -102,7 +102,7 @@ describe('ADVERSARIAL: constants.architect-whitelist', () => { // This test in the original file checks > 40 // Let's verify it's still a reasonable bound expect(AGENT_TOOL_MAP.architect.length).toBeGreaterThan(40); - expect(AGENT_TOOL_MAP.architect.length).toBeLessThan(100); + expect(AGENT_TOOL_MAP.architect.length).toBeLessThan(110); }); it('all roles should have at least 1 tool (except overlay/synthesis-only roles)', () => { diff --git a/src/hooks/issue-trace-reducer.ts b/src/hooks/issue-trace-reducer.ts index ea55a6b5b..1347ddd4c 100644 --- a/src/hooks/issue-trace-reducer.ts +++ b/src/hooks/issue-trace-reducer.ts @@ -33,9 +33,18 @@ export interface IssueReference { * - `publication_handoff`: all phases complete; the engine has emitted the * commit-pr directive and stopped driving. This is NOT "issue resolved" — * publication is owned by commit-pr and has not yet been confirmed. - * - `published`: a verifiable publication receipt was observed. Terminal. + * - `published`: a verifiable publication receipt was observed. + * - `merge_approval_recorded`: a PR-head-bound merge-approval receipt was + * observed after publication (issue #2564). TERMINAL. The plugin RECORDS the + * human approval for audit; it never certifies, drives, or green-lights the + * merge itself — the merge is human-enforced (mirrors trace-check.sh's + * merge gate posture). */ -export type TraceStatus = 'in_progress' | 'publication_handoff' | 'published'; +export type TraceStatus = + | 'in_progress' + | 'publication_handoff' + | 'published' + | 'merge_approval_recorded'; export interface TraceState { issueNumber: number; @@ -51,12 +60,26 @@ export interface WorkflowArtifacts { allPhasesComplete: boolean; /** Reproduction evidence OR a typed waiver is present (issue #2131 2.6). */ reproductionPermitted: boolean; + /** + * Phase 0 branch-freshness receipt permits the trace: `synced`, or a + * fail-closed fetch failure carrying a recorded user override (issue #2564, + * mirroring trace-check.sh phase0). A `behind` result never permits. + */ + freshnessPermitted: boolean; /** A verifiable publication receipt has been observed (issue #2131 2.4). */ publicationObserved: boolean; /** A valid recurrence-sweep receipt exists (issue #2131 residual B). */ recurrenceSweepVerified: boolean; /** Fresh reviewer + critic APPROVE verdicts recorded (issue #2131 residual B). */ implementationReviewVerified: boolean; + /** + * Every recorded per-phase `trace-check.sh` validation for this issue is a + * pass bound to a 40-hex reviewed-commit + tree-id (issue #2564). A fail + * entry fails closed until that phase is re-recorded as a pass. + */ + traceValidationVerified: boolean; + /** A PR-head-bound merge-approval receipt has been observed (issue #2564). */ + mergeApprovalObserved: boolean; } export interface TransitionResult { @@ -80,18 +103,22 @@ export interface ComputeNextModeParams { * * Decision table (top-to-bottom, first match wins): * (a) No issue reference or trace not requested → no-op - * (b) Trace already published (terminal) → no-op + * (b) merge_approval_recorded (truly terminal) → no-op + * (b') published + merge-approval receipt observed → merge_approval_recorded (RECORDED, never certified) * (c) publication_handoff: observe publication → PUBLISHED; else no-op * (d) Cross-issue guard (spec issue ≠ current issue) → no-op * (e) Spec does not exist → no-op - * (f) Spec exists, no plan, reproduction permitted, + * (f-0) Spec exists, no plan, branch-freshness NOT + * permitted → one-shot FRESHNESS_GATE directive + * (f) Spec exists, no plan, freshness permitted, reproduction permitted, * never transitioned (or re-entrant idempotency) → PLAN - * (f-block) Spec exists, no plan, reproduction NOT permitted → no-op + * (f-block) Spec exists, no plan, reproduction NOT permitted → one-shot REPRO_GATE directive * (g) Plan exists but critic not approved → no-op * (h) Critic approved, phases incomplete, not yet PLAN_TO_EXECUTE → EXECUTE * (i-pre1) Phases complete, impl-review receipt missing → one-shot REVIEW_GATE directive * (i-pre2) Impl-review ok, recurrence-sweep receipt missing → one-shot RECURRENCE_GATE directive - * (i) All phases complete + both gates verified, not yet EXECUTE_TO_COMMIT → publication_handoff + COMMIT directive + * (i-pre3) Both ok, trace-validation receipts missing/failing → one-shot TRACE_VALIDATION_GATE directive + * (i) All phases complete + all gates verified, not yet EXECUTE_TO_COMMIT → publication_handoff + COMMIT directive * * Idempotency: rows (f), (h), (i) return no-op when * `traceState.lastTransition` already equals the target transition value. @@ -112,8 +139,29 @@ export function computeNextMode( return noop; } - // Row (b): trace already published (truly terminal) + // Row (b): merge_approval_recorded — the only truly terminal status. + if (traceState.status === 'merge_approval_recorded') { + return noop; + } + + // Row (b'): published + merge-approval receipt observed → RECORDED, never + // certified (issue #2564). nextMode stays null: the plugin never drives, + // certifies, or green-lights a merge — the merge decision is human-enforced, + // exactly the posture trace-check.sh's merge gate documents ("human-enforced + // gate; this validator checks presence and binding only"). if (traceState.status === 'published') { + if ( + workflowArtifacts.mergeApprovalObserved && + traceState.lastTransition !== 'MERGE_APPROVAL_RECORDED' + ) { + return { + nextMode: null, + directive: + 'Merge approval recorded for this trace, bound to the PR head SHA in .swarm/merge-approval.json. The merge decision is human-enforced: the human user owns and executes it through the host. This plugin records the approval verbatim for audit; it does not drive, gate, or green-light the merge itself.', + nextLastTransition: 'MERGE_APPROVAL_RECORDED', + nextStatus: 'merge_approval_recorded', + }; + } return noop; } @@ -148,15 +196,47 @@ export function computeNextMode( return noop; } - // Row (f): spec exists, no plan → ISSUE_INGEST_TO_PLAN (requires reproduction) + // Row (f): spec exists, no plan → ISSUE_INGEST_TO_PLAN (requires freshness + // AND reproduction evidence) if (!workflowArtifacts.planExists) { + // Row (f-0): Phase 0 branch-freshness gate — fail-closed like + // trace-check.sh phase0 (issue #2564): a `behind` result or a bare + // `fetch-failed` (no recorded user override) must park the trace before + // PLAN. One-shot (sentinel FRESHNESS_GATE) so the engine is not + // silently idle — silence here is indistinguishable from a stuck engine. + // Explicit `=== false` (not falsy): the hook always supplies a real + // boolean, and a v2-shaped artifacts literal from a legacy direct caller + // stays transparent instead of silently parking (pinned by the frozen + // C6 preserving check). + if (workflowArtifacts.freshnessPermitted === false) { + if ( + traceState.lastTransition === null || + traceState.lastTransition === 'REPRO_GATE' + ) { + return { + nextMode: 'ISSUE_INGEST', + directive: + 'Branch freshness is not established for this trace (behind, or fetch failed without a recorded user override). Re-sync with the default branch (git fetch + rebase/merge), then call record_branch_freshness (issueNumber, freshness: "synced") — or, when the fetch genuinely failed and the user has accepted proceeding on the stale base, record freshness: "fetch-failed:" together with the verbatim override string the user provided. The trace will not transition to PLAN until this receipt permits.', + nextLastTransition: 'FRESHNESS_GATE', + nextStatus: 'in_progress', + }; + } + // Already nudged once (FRESHNESS_GATE or later); wait quietly for + // the receipt. + return noop; + } if (!workflowArtifacts.reproductionPermitted) { // Reproduction evidence (or a typed waiver) is required before the // trace can leave localization and transition to PLAN. Emit a ONE-SHOT // directive (sentinel lastTransition REPRO_GATE) so the mode-driving // engine is not silently idle while it waits for evidence — silence - // here is indistinguishable from a stuck engine. - if (traceState.lastTransition === null) { + // here is indistinguishable from a stuck engine. FRESHNESS_GATE also + // counts as "nothing has fired yet": a freshness receipt landing + // after the freshness nudge must still get the reproduction nudge. + if ( + traceState.lastTransition === null || + traceState.lastTransition === 'FRESHNESS_GATE' + ) { return { nextMode: 'ISSUE_INGEST', directive: @@ -171,7 +251,8 @@ export function computeNextMode( if ( traceState.lastTransition === null || traceState.lastTransition === 'ISSUE_INGEST_TO_PLAN' || - traceState.lastTransition === 'REPRO_GATE' + traceState.lastTransition === 'REPRO_GATE' || + traceState.lastTransition === 'FRESHNESS_GATE' ) { if (traceState.lastTransition === 'ISSUE_INGEST_TO_PLAN') { return noop; @@ -243,12 +324,49 @@ export function computeNextMode( }; } - // Row (i): all phases complete + both residual-B gates verified → - // publication_handoff + COMMIT directive. While either gate receipt is + // Row (i-pre3): both residual-B gates verified — the per-phase trace-check.sh + // validator receipts must also be recorded and green before the trace may + // hand off to commit-pr (issue #2564). A missing receipt set, or any fail + // entry, parks the trace with a ONE-SHOT directive (sentinel + // TRACE_VALIDATION_GATE) naming the validator re-run. The precondition on + // both prior gates keeps the directive's text truthful (implementation + // review round 3: without it, a vanished review/sweep receipt after its + // sentinel fired made this row claim the prior gates were satisfied). The + // exclusion set excludes only THIS row's own sentinel plus the handoff + // sentinel — REVIEW_GATE/RECURRENCE_GATE must NOT be excluded, because when + // the recurrence receipt lands right after RECURRENCE_GATE fired, the ladder + // must chain into this directive rather than parking silently (final-critic + // round 1; mirrors how row i-pre2 does not exclude REVIEW_GATE). When a + // prior receipt VANISHES after its sentinel fired, this row declines and + // the final guard no-ops — the same exhausted-one-shot semantics every gate + // row already has (the silent-stall surfacing for those rows is #2600's + // DD-C002 scope, pinned by the frozen C8 preserving check). + if ( + workflowArtifacts.implementationReviewVerified && + workflowArtifacts.recurrenceSweepVerified && + workflowArtifacts.traceValidationVerified === false && + traceState.lastTransition !== 'TRACE_VALIDATION_GATE' && + traceState.lastTransition !== 'EXECUTE_TO_COMMIT' + ) { + return { + nextMode: 'EXECUTE', + directive: + 'The review and recurrence gates are satisfied, but the trace-check.sh validator receipts are not yet green. Run the issue-tracer phase validator for every completed phase (trace-check.sh phase --slug ) and record each outcome with record_trace_validation (issueNumber, phase, outcome, the reviewedCommit, and the treeId the validator reported). Any fail entry must be fixed and re-recorded as a pass before the trace can hand off to commit-pr.', + nextLastTransition: 'TRACE_VALIDATION_GATE', + nextStatus: 'in_progress', + }; + } + + // Row (i): all phases complete + all gates verified → + // publication_handoff + COMMIT directive. While any gate receipt is // still missing, wait quietly (the one-shot directive above already fired). + // The residual-B guards keep their original falsy form (they pair with the + // falsy fire rows above); only the new validator gate uses explicit + // `=== false` for v2-shaped-literal transparency. if ( !workflowArtifacts.implementationReviewVerified || - !workflowArtifacts.recurrenceSweepVerified + !workflowArtifacts.recurrenceSweepVerified || + workflowArtifacts.traceValidationVerified === false ) { return noop; } diff --git a/src/hooks/issue-trace-state.ts b/src/hooks/issue-trace-state.ts index 23f2ce4c0..b475f7dbf 100644 --- a/src/hooks/issue-trace-state.ts +++ b/src/hooks/issue-trace-state.ts @@ -103,6 +103,25 @@ async function _defaultRecurrenceSweepReceiptExists( ) { return false; } + // Issue #2564 (issue-tracer v3 Phase 1): the sweep receipt must carry + // the related-problems sweep results that seed the defect class — + // non-vacuously, on BOTH the real-defect-class path and the "no defect + // class" fast path. A missing key, an empty array, or entries whose ref + // is blank/non-string no longer satisfy the gate. + const relatedProblems = o.relatedProblems; + const validRelatedProblems = + Array.isArray(relatedProblems) && + relatedProblems.length > 0 && + relatedProblems.every( + (rp) => + typeof rp === 'object' && + rp !== null && + typeof (rp as Record).ref === 'string' && + ((rp as Record).ref as string).trim().length > 0, + ); + if (!validRelatedProblems) { + return false; + } const noDefectClass = o.defectClass === 'no defect class'; // Issue-tracer Phase 4.2 contract: a sweep over a real defect class must // record its search predicates, a disposition for every hit, and a @@ -175,6 +194,110 @@ async function _defaultImplementationReviewReceiptExists( } } +async function _defaultBranchFreshnessReceiptExists( + directory: string, + issueNumber: number, +): Promise { + try { + const filePath = path.join(directory, '.swarm', 'branch-freshness.json'); + const raw = _internals.readFileSync(filePath, 'utf-8'); + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== 'object' || parsed === null) return false; + const o = parsed as Record; + if (o.issueNumber !== issueNumber) return false; + if (typeof o.freshness !== 'string') return false; + const freshness = o.freshness; + // Mirrors trace-check.sh phase0's freshness enum exactly: `synced` + // permits; `behind:` NEVER permits (sync first — an override cannot + // rescue it); a bare `fetch-failed:` fails closed, and only a + // recorded non-empty user override rescues the fetch failure. + if (freshness === 'synced') return true; + if (/^behind:[1-9][0-9]*$/.test(freshness)) return false; + if (/^fetch-failed:\S+$/.test(freshness)) { + return typeof o.override === 'string' && o.override.trim().length > 0; + } + return false; + } catch { + return false; + } +} + +const TRACE_VALIDATION_PHASES: readonly string[] = [ + '0', + '1', + '2', + '2.5', + '3', + '4', + '4.2', + '4.5', + '4.6', + '5', +]; + +function isFortyHex(value: unknown): value is string { + return typeof value === 'string' && /^[0-9a-f]{40}$/.test(value); +} + +async function _defaultTraceValidationReceiptExists( + directory: string, + issueNumber: number, +): Promise { + try { + const filePath = path.join(directory, '.swarm', 'trace-validation.json'); + const raw = _internals.readFileSync(filePath, 'utf-8'); + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== 'object' || parsed === null) return false; + const o = parsed as Record; + if (o.issueNumber !== issueNumber) return false; + const validations = o.validations; + if (!Array.isArray(validations) || validations.length === 0) return false; + // The writer upserts (one entry per phase, replaced in place), so + // "every entry is a pass bound to a 40-hex reviewed-commit + tree-id" + // IS "every phase's latest run passed" — any fail entry fails closed + // until that phase is re-recorded as a pass (issue #2564). + for (const entry of validations) { + if (typeof entry !== 'object' || entry === null) return false; + const e = entry as Record; + if ( + !TRACE_VALIDATION_PHASES.includes(String(e.phase)) || + e.outcome !== 'pass' || + !isFortyHex(e.reviewedCommit) || + !isFortyHex(e.treeId) + ) { + return false; + } + } + return true; + } catch { + return false; + } +} + +async function _defaultMergeApprovalReceiptExists( + directory: string, + issueNumber: number, +): Promise { + try { + const filePath = path.join(directory, '.swarm', 'merge-approval.json'); + const raw = _internals.readFileSync(filePath, 'utf-8'); + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== 'object' || parsed === null) return false; + const o = parsed as Record; + // Mirrors trace-check.sh merge_check's binding: the PR head SHA must + // equal the final critic's reviewed commit, both 40-hex. The reader + // only verifies presence and binding — it never certifies the approval + // itself (human-enforced gate). + if (o.issueNumber !== issueNumber) return false; + if (!isFortyHex(o.prHeadSha) || !isFortyHex(o.finalCriticReviewedCommit)) { + return false; + } + return o.prHeadSha === o.finalCriticReviewedCommit; + } catch { + return false; + } +} + export const _internals = { readFileSync, writeFileSync, @@ -187,6 +310,9 @@ export const _internals = { publicationReceiptExists: _defaultPublicationReceiptExists, recurrenceSweepReceiptExists: _defaultRecurrenceSweepReceiptExists, implementationReviewReceiptExists: _defaultImplementationReviewReceiptExists, + branchFreshnessReceiptExists: _defaultBranchFreshnessReceiptExists, + traceValidationReceiptExists: _defaultTraceValidationReceiptExists, + mergeApprovalReceiptExists: _defaultMergeApprovalReceiptExists, }; // ── Helpers ──────────────────────────────────────────────────────── @@ -195,6 +321,7 @@ const TRACE_STATUSES: readonly TraceStatus[] = [ 'in_progress', 'publication_handoff', 'published', + 'merge_approval_recorded', ]; function isTraceStatus(value: unknown): value is TraceStatus { @@ -474,3 +601,44 @@ export async function implementationReviewReceiptExists( ): Promise { return _internals.implementationReviewReceiptExists(directory, issueNumber); } + +/** + * Returns whether a Phase 0 branch-freshness receipt permits the trace for the + * given issue (issue #2564, mirroring trace-check.sh phase0): `synced` permits; + * `behind:` never permits; `fetch-failed:` permits only with a + * recorded non-empty user override. + */ +export async function branchFreshnessReceiptExists( + directory: string, + issueNumber: number, +): Promise { + return _internals.branchFreshnessReceiptExists(directory, issueNumber); +} + +/** + * Returns whether the per-phase trace-check.sh validator receipts for the given + * issue are green (issue #2564): at least one recorded validation exists and + * every entry (one per phase, upserted by the writer) is a `pass` bound to a + * 40-hex reviewedCommit + treeId. Any `fail` entry fails closed until that + * phase is re-recorded as a pass. + */ +export async function traceValidationReceiptExists( + directory: string, + issueNumber: number, +): Promise { + return _internals.traceValidationReceiptExists(directory, issueNumber); +} + +/** + * Returns whether a PR-head-bound merge-approval receipt exists for the given + * issue (issue #2564): both SHAs are 40-hex and prHeadSha equals + * finalCriticReviewedCommit, mirroring trace-check.sh merge_check's binding. + * Presence-and-binding only — the approval itself is human-enforced and is + * never certified by the plugin. + */ +export async function mergeApprovalReceiptExists( + directory: string, + issueNumber: number, +): Promise { + return _internals.mergeApprovalReceiptExists(directory, issueNumber); +} diff --git a/src/hooks/issue-trace.ts b/src/hooks/issue-trace.ts index a5fc27c65..47e3c3f67 100644 --- a/src/hooks/issue-trace.ts +++ b/src/hooks/issue-trace.ts @@ -23,7 +23,9 @@ import { error as _logErrorImpl } from '../utils/logger.js'; import { isPlanCriticApproved } from './delegation-gate'; import { computeNextMode } from './issue-trace-reducer'; import { + branchFreshnessReceiptExists, implementationReviewReceiptExists, + mergeApprovalReceiptExists, publicationReceiptExists, readIssueReference, readPlanPhaseStatus, @@ -32,6 +34,7 @@ import { recurrenceSweepReceiptExists, reproductionReceiptExists, specExists, + traceValidationReceiptExists, writeTraceState, } from './issue-trace-state'; import type { MessageWithParts } from './knowledge-types.js'; @@ -50,6 +53,9 @@ export const _internals = { publicationReceiptExists, recurrenceSweepReceiptExists, implementationReviewReceiptExists, + branchFreshnessReceiptExists, + traceValidationReceiptExists, + mergeApprovalReceiptExists, isPlanCriticApproved, getPlanLedgerState: getPlanLedgerStateReadOnly, logError: _logErrorImpl, @@ -248,6 +254,25 @@ export function createIssueTraceHook( directory, issueRef.number, ); + // v3 receipts (issue #2564): Phase 0 branch-freshness (pre-PLAN), + // per-phase trace-check.sh validator outcomes (pre-handoff), and + // the PR-head-bound merge approval (post-publication, recorded + // but never certified). + const _freshnessPermitted = + await _internals.branchFreshnessReceiptExists( + directory, + issueRef.number, + ); + const _traceValidationVerified = + await _internals.traceValidationReceiptExists( + directory, + issueRef.number, + ); + const _mergeApprovalObserved = + await _internals.mergeApprovalReceiptExists( + directory, + issueRef.number, + ); // 5. Call reducer const result = computeNextMode({ @@ -260,9 +285,12 @@ export function createIssueTraceHook( criticApproved: _criticApproved, allPhasesComplete: phaseStatus.allComplete, reproductionPermitted: _reproductionPermitted, + freshnessPermitted: _freshnessPermitted, publicationObserved: _publicationObserved, implementationReviewVerified: _implementationReviewVerified, recurrenceSweepVerified: _recurrenceSweepVerified, + traceValidationVerified: _traceValidationVerified, + mergeApprovalObserved: _mergeApprovalObserved, }, }); diff --git a/src/tools/index.ts b/src/tools/index.ts index 170458bae..a9824984d 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -229,6 +229,10 @@ export { executeRebindPrFeedbackHead, rebind_pr_feedback_head, } from './rebind-pr-feedback-head'; +export { + executeRecordBranchFreshness, + record_branch_freshness, +} from './record-branch-freshness'; export { executeRecordImplementationReview, record_implementation_review, @@ -241,10 +245,18 @@ export { executeRecordIssueReproduction, record_issue_reproduction, } from './record-issue-reproduction'; +export { + executeRecordMergeApproval, + record_merge_approval, +} from './record-merge-approval'; export { executeRecordRecurrenceSweep, record_recurrence_sweep, } from './record-recurrence-sweep'; +export { + executeRecordTraceValidation, + record_trace_validation, +} from './record-trace-validation'; export { executeRecoverReworkTask, recover_rework_task, diff --git a/src/tools/manifest.ts b/src/tools/manifest.ts index c99cb72fa..a0f6b8889 100644 --- a/src/tools/manifest.ts +++ b/src/tools/manifest.ts @@ -103,11 +103,14 @@ import { prepare_pr_feedback_scope } from './prepare-pr-feedback-scope'; import { prepare_pr_workflow_checkout } from './prepare-pr-workflow-checkout'; import { quality_budget } from './quality-budget'; import { rebind_pr_feedback_head } from './rebind-pr-feedback-head'; +import { record_branch_freshness } from './record-branch-freshness'; import { record_directive_override } from './record-directive-override'; import { record_implementation_review } from './record-implementation-review'; import { record_issue_publication } from './record-issue-publication'; import { record_issue_reproduction } from './record-issue-reproduction'; +import { record_merge_approval } from './record-merge-approval'; import { record_recurrence_sweep } from './record-recurrence-sweep'; +import { record_trace_validation } from './record-trace-validation'; import { recover_rework_task } from './recover-rework-task'; import { repair_gate_evidence } from './repair-gate-evidence'; import { repair_knowledge_receipt_ledger } from './repair-knowledge-receipt-ledger'; @@ -205,6 +208,9 @@ export const TOOL_MANIFEST = defineHandlers({ record_issue_publication: () => record_issue_publication, record_issue_reproduction: () => record_issue_reproduction, record_recurrence_sweep: () => record_recurrence_sweep, + record_branch_freshness: () => record_branch_freshness, + record_trace_validation: () => record_trace_validation, + record_merge_approval: () => record_merge_approval, invalidate_pr_feedback_publication: () => invalidate_pr_feedback_publication, rebind_pr_feedback_head: () => rebind_pr_feedback_head, run_pr_feedback_stage_a: () => run_pr_feedback_stage_a, diff --git a/src/tools/record-branch-freshness.ts b/src/tools/record-branch-freshness.ts new file mode 100644 index 000000000..2ca081468 --- /dev/null +++ b/src/tools/record-branch-freshness.ts @@ -0,0 +1,134 @@ +/** + * record_branch_freshness — persist the issue-tracer v3 Phase 0 + * branch-freshness receipt (issue #2564). + * + * Mirrors trace-check.sh phase0: the receipt RECORDS the fetch outcome — + * `synced`, `behind:`, or `fetch-failed:` — and an optional + * verbatim user override string. Recording any outcome is a successful write; + * the READER (issue-trace-state.ts branchFreshnessReceiptExists) decides + * whether the outcome permits the trace: synced permits, behind never + * permits, fetch-failed permits only with a non-empty recorded override. + * The reducer's FRESHNESS_GATE consumes that verdict before PLAN. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { z } from 'zod'; +import { validateSwarmPath } from '../hooks/utils'; +import { createSwarmTool } from './create-tool'; + +const FreshnessValueSchema = z + .string() + .trim() + .min(1) + .max(500) + .regex( + /^(synced|behind:[1-9][0-9]*|fetch-failed:\S+)$/, + "must be 'synced', 'behind:', or 'fetch-failed:' (reason contains no whitespace)", + ); + +const RecordBranchFreshnessArgsSchema = z + .object({ + issueNumber: z.number().int().min(1), + /** Fetch outcome exactly as phase 0 recorded it. */ + freshness: FreshnessValueSchema, + /** Verbatim user override accepting a failed fetch; rescues fetch-failed only. */ + override: z.string().trim().min(1).max(4000).optional(), + }) + .strict(); + +let tempCounter = 0; + +export async function executeRecordBranchFreshness( + args: unknown, + directory: string, + context: { sessionID?: string } = {}, +): Promise { + const parsed = RecordBranchFreshnessArgsSchema.safeParse(args); + if (!parsed.success) { + return JSON.stringify({ + success: false, + message: `Invalid branch-freshness receipt: ${parsed.error.issues + .map((issue) => `${issue.path.join('.')}: ${issue.message}`) + .join('; ')}`, + }); + } + const data = parsed.data; + if (data.freshness === 'synced' && data.override) { + return JSON.stringify({ + success: false, + message: + 'An override is only meaningful for a fetch-failed outcome; a synced base needs none.', + }); + } + + const receipt: Record = { + issueNumber: data.issueNumber, + freshness: data.freshness, + timestamp: new Date().toISOString(), + }; + if (data.override) receipt.override = data.override; + if (context.sessionID?.trim()) receipt.sessionId = context.sessionID.trim(); + + let validatedPath: string; + try { + validatedPath = validateSwarmPath(directory, 'branch-freshness.json'); + } catch (error) { + return JSON.stringify({ + success: false, + message: + error instanceof Error ? error.message : 'Failed to validate path', + }); + } + + try { + const dir = path.dirname(validatedPath); + await fs.promises.mkdir(dir, { recursive: true }); + tempCounter += 1; + const tmpPath = path.join( + dir, + `.branch-freshness.json.tmp.${process.pid}.${tempCounter}`, + ); + await fs.promises.writeFile( + tmpPath, + JSON.stringify(receipt, null, 2), + 'utf-8', + ); + await fs.promises.rename(tmpPath, validatedPath); + const permits = + data.freshness === 'synced' || + (data.freshness.startsWith('fetch-failed:') && + typeof data.override === 'string' && + data.override.length > 0); + return JSON.stringify({ + success: true, + issueNumber: data.issueNumber, + path: '.swarm/branch-freshness.json', + permits, + message: permits + ? `Branch-freshness receipt recorded for issue #${data.issueNumber}; the trace's freshness gate is satisfied.` + : `Branch-freshness receipt recorded for issue #${data.issueNumber}, but the outcome (${data.freshness}) does NOT permit the trace — ${ + data.freshness.startsWith('behind:') + ? 're-sync with the default branch and re-record as synced.' + : 'a fetch-failed outcome without a recorded user override fails closed.' + }`, + }); + } catch (error) { + return JSON.stringify({ + success: false, + message: error instanceof Error ? error.message : String(error), + }); + } +} + +export const record_branch_freshness: ReturnType = + createSwarmTool({ + description: + 'Record the Phase 0 branch-freshness outcome for the current traced issue (issue #2564): freshness is "synced", "behind:", or "fetch-failed:" exactly as the fetch recorded it, plus an optional verbatim user override for a failed fetch. The /swarm issue --trace workflow will not transition to PLAN until the recorded outcome permits (synced, or fetch-failed with an override).', + args: { + issueNumber: RecordBranchFreshnessArgsSchema.shape.issueNumber, + freshness: RecordBranchFreshnessArgsSchema.shape.freshness, + override: RecordBranchFreshnessArgsSchema.shape.override, + }, + execute: executeRecordBranchFreshness, + }); diff --git a/src/tools/record-merge-approval.ts b/src/tools/record-merge-approval.ts new file mode 100644 index 000000000..317041421 --- /dev/null +++ b/src/tools/record-merge-approval.ts @@ -0,0 +1,122 @@ +/** + * record_merge_approval — persist the PR-head-bound merge-approval receipt + * (issue-tracer v3 Phase 5.1 / 10b-merge-approval, issue #2564). + * + * RECORDED, NEVER CERTIFIED: this receipt mirrors trace-check.sh merge_check's + * presence-and-binding posture — the PR head SHA must equal the final critic's + * reviewed commit (both 40-hex), and the user approval is captured VERBATIM. + * The plugin never verifies the approval's authenticity, never drives the + * merge, and never treats the receipt as authorization; the merge remains a + * human-enforced gate. The reducer records the trace's terminal + * `merge_approval_recorded` status when the reader observes this receipt. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { z } from 'zod'; +import { validateSwarmPath } from '../hooks/utils'; +import { createSwarmTool } from './create-tool'; + +const HEX40 = /^[0-9a-f]{40}$/; + +const RecordMergeApprovalArgsSchema = z + .object({ + issueNumber: z.number().int().min(1), + prHeadSha: z.string().regex(HEX40, 'must be a 40-hex commit SHA'), + finalCriticReviewedCommit: z + .string() + .regex(HEX40, 'must be a 40-hex commit SHA'), + userApprovalVerbatim: z.string().trim().min(1).max(8000), + }) + .strict(); + +let tempCounter = 0; + +export async function executeRecordMergeApproval( + args: unknown, + directory: string, + context: { sessionID?: string } = {}, +): Promise { + const parsed = RecordMergeApprovalArgsSchema.safeParse(args); + if (!parsed.success) { + return JSON.stringify({ + success: false, + message: `Invalid merge-approval receipt: ${parsed.error.issues + .map((issue) => `${issue.path.join('.')}: ${issue.message}`) + .join('; ')}`, + }); + } + const data = parsed.data; + // Binding check mirrors trace-check.sh merge_check: the PR head must equal + // the final critic's reviewed commit. A mismatched pair is rejected at + // write time so a stale critic verdict can never satisfy this receipt. + if (data.prHeadSha !== data.finalCriticReviewedCommit) { + return JSON.stringify({ + success: false, + message: + 'Merge-approval binding failed: prHeadSha must equal finalCriticReviewedCommit (the final critic approval must be bound to the exact PR head).', + }); + } + + const receipt: Record = { + issueNumber: data.issueNumber, + prHeadSha: data.prHeadSha, + finalCriticReviewedCommit: data.finalCriticReviewedCommit, + userApprovalVerbatim: data.userApprovalVerbatim, + timestamp: new Date().toISOString(), + }; + if (context.sessionID?.trim()) receipt.sessionId = context.sessionID.trim(); + + let validatedPath: string; + try { + validatedPath = validateSwarmPath(directory, 'merge-approval.json'); + } catch (error) { + return JSON.stringify({ + success: false, + message: + error instanceof Error ? error.message : 'Failed to validate path', + }); + } + + try { + const dir = path.dirname(validatedPath); + await fs.promises.mkdir(dir, { recursive: true }); + tempCounter += 1; + const tmpPath = path.join( + dir, + `.merge-approval.json.tmp.${process.pid}.${tempCounter}`, + ); + await fs.promises.writeFile( + tmpPath, + JSON.stringify(receipt, null, 2), + 'utf-8', + ); + await fs.promises.rename(tmpPath, validatedPath); + return JSON.stringify({ + success: true, + issueNumber: data.issueNumber, + path: '.swarm/merge-approval.json', + message: `Merge approval recorded for issue #${data.issueNumber}, bound to PR head ${data.prHeadSha}. The approval is captured verbatim for audit; the merge itself is human-enforced and this plugin neither drives nor certifies it.`, + }); + } catch (error) { + return JSON.stringify({ + success: false, + message: error instanceof Error ? error.message : String(error), + }); + } +} + +export const record_merge_approval: ReturnType = + createSwarmTool({ + description: + 'Record the human merge approval for the current traced issue (issue #2564): prHeadSha and finalCriticReviewedCommit must be the same 40-hex SHA (the final critic approval bound to the exact PR head), and userApprovalVerbatim is the user approval quoted verbatim. The receipt is recorded for audit only — the merge stays human-enforced and the plugin never certifies or drives it.', + args: { + issueNumber: RecordMergeApprovalArgsSchema.shape.issueNumber, + prHeadSha: RecordMergeApprovalArgsSchema.shape.prHeadSha, + finalCriticReviewedCommit: + RecordMergeApprovalArgsSchema.shape.finalCriticReviewedCommit, + userApprovalVerbatim: + RecordMergeApprovalArgsSchema.shape.userApprovalVerbatim, + }, + execute: executeRecordMergeApproval, + }); diff --git a/src/tools/record-recurrence-sweep.ts b/src/tools/record-recurrence-sweep.ts index fbbbee0d5..fe366327c 100644 --- a/src/tools/record-recurrence-sweep.ts +++ b/src/tools/record-recurrence-sweep.ts @@ -44,6 +44,13 @@ const GuardrailSchema = z }) .strict(); +const RelatedProblemSchema = z + .object({ + ref: z.string().trim().min(1).max(500), + note: z.string().trim().max(1000).optional(), + }) + .strict(); + const RecordRecurrenceSweepArgsSchema = z .object({ issueNumber: z.number().int().min(1), @@ -57,6 +64,13 @@ const RecordRecurrenceSweepArgsSchema = z .optional(), dispositions: z.array(DispositionSchema).max(200).optional(), guardrail: GuardrailSchema.optional(), + /** + * Phase 1 related-problems sweep results (issue #2564): the related + * issues/PRs the intake sweep found, seeding the defect class. Required + * on BOTH paths — the sweep runs in Phase 1 regardless of defect class — + * and non-vacuous: at least one entry with a non-empty ref. + */ + relatedProblems: z.array(RelatedProblemSchema).min(1).max(200), }) .strict(); @@ -106,6 +120,7 @@ export async function executeRecordRecurrenceSweep( if (data.predicates) receipt.predicates = data.predicates; if (data.dispositions) receipt.dispositions = data.dispositions; if (data.guardrail) receipt.guardrail = data.guardrail; + receipt.relatedProblems = data.relatedProblems; if (context.sessionID?.trim()) receipt.sessionId = context.sessionID.trim(); let validatedPath: string; @@ -148,7 +163,7 @@ export async function executeRecordRecurrenceSweep( export const record_recurrence_sweep: ReturnType = createSwarmTool({ description: - 'Record the recurrence sweep for the current traced issue (issue #2131 residual B). The /swarm issue --trace workflow will not hand off to commit-pr until this receipt exists. For a real defect class supply: defectClass (one-sentence characterization), predicates (the exact search predicates), dispositions (every hit as FIX / FALSE_POSITIVE / OUT_OF_CLASS / DEFERRED_WITH_USER_APPROVAL), and a guardrail (kind + description + proof it catches the original defect). If the change corrects no incorrect behavior, use the literal defectClass "no defect class" with a one-line justification.', + 'Record the recurrence sweep for the current traced issue (issue #2131 residual B; widened in #2564). The /swarm issue --trace workflow will not hand off to commit-pr until this receipt exists. Supply relatedProblems (the Phase 1 related-problems sweep results — at least one {ref, note?} entry) plus, for a real defect class: defectClass (one-sentence characterization), predicates (the exact search predicates), dispositions (every hit as FIX / FALSE_POSITIVE / OUT_OF_CLASS / DEFERRED_WITH_USER_APPROVAL), and a guardrail (kind + description + proof it catches the original defect). If the change corrects no incorrect behavior, use the literal defectClass "no defect class" with a one-line justification (relatedProblems still required).', args: { issueNumber: RecordRecurrenceSweepArgsSchema.shape.issueNumber, defectClass: RecordRecurrenceSweepArgsSchema.shape.defectClass, @@ -156,6 +171,7 @@ export const record_recurrence_sweep: ReturnType = predicates: RecordRecurrenceSweepArgsSchema.shape.predicates, dispositions: RecordRecurrenceSweepArgsSchema.shape.dispositions, guardrail: RecordRecurrenceSweepArgsSchema.shape.guardrail, + relatedProblems: RecordRecurrenceSweepArgsSchema.shape.relatedProblems, }, execute: executeRecordRecurrenceSweep, }); diff --git a/src/tools/record-trace-validation.ts b/src/tools/record-trace-validation.ts new file mode 100644 index 000000000..8e5390160 --- /dev/null +++ b/src/tools/record-trace-validation.ts @@ -0,0 +1,172 @@ +/** + * record_trace_validation — persist a per-phase `trace-check.sh` validator + * receipt for the issue-trace v3 workflow (issue #2564). + * + * One entry per validator run: the phase validated, the outcome (pass/fail), + * and the exact identities the validator reported (reviewedCommit + treeId, + * both 40-hex). The receipt file upserts per phase — re-running a phase + * replaces its entry, other phases are preserved — so the reader + * (issue-trace-state.ts traceValidationReceiptExists) can demand "every + * recorded phase's latest run passed". The reducer's TRACE_VALIDATION_GATE + * blocks the commit-pr handoff while any entry is missing, failing, or + * malformed. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { z } from 'zod'; +import { validateSwarmPath } from '../hooks/utils'; +import { createSwarmTool } from './create-tool'; + +const HEX40 = /^[0-9a-f]{40}$/; + +/** The v3 phase enum trace-check.sh validates (`phase ` subcommand). */ +const TRACE_VALIDATION_PHASES: readonly string[] = [ + '0', + '1', + '2', + '2.5', + '3', + '4', + '4.2', + '4.5', + '4.6', + '5', +]; + +const ValidationEntrySchema = z + .object({ + phase: z.enum(TRACE_VALIDATION_PHASES as [string, ...string[]]), + outcome: z.enum(['pass', 'fail']), + reviewedCommit: z.string().regex(HEX40, 'must be a 40-hex commit SHA'), + treeId: z.string().regex(HEX40, 'must be a 40-hex tree id'), + }) + .strict(); + +const RecordTraceValidationArgsSchema = ValidationEntrySchema.extend({ + issueNumber: z.number().int().min(1), +}).strict(); + +interface StoredValidation { + phase: string; + outcome: 'pass' | 'fail'; + reviewedCommit: string; + treeId: string; + timestamp: string; +} + +let tempCounter = 0; + +export async function executeRecordTraceValidation( + args: unknown, + directory: string, + context: { sessionID?: string } = {}, +): Promise { + const parsed = RecordTraceValidationArgsSchema.safeParse(args); + if (!parsed.success) { + return JSON.stringify({ + success: false, + message: `Invalid trace-validation receipt: ${parsed.error.issues + .map((issue) => `${issue.path.join('.')}: ${issue.message}`) + .join('; ')}`, + }); + } + const data = parsed.data; + + let validatedPath: string; + try { + validatedPath = validateSwarmPath(directory, 'trace-validation.json'); + } catch (error) { + return JSON.stringify({ + success: false, + message: + error instanceof Error ? error.message : 'Failed to validate path', + }); + } + + try { + const dir = path.dirname(validatedPath); + await fs.promises.mkdir(dir, { recursive: true }); + + // Read-modify-write upsert: one entry per phase, replaced in place. + let validations: StoredValidation[] = []; + try { + const raw = await fs.promises.readFile(validatedPath, 'utf-8'); + const existing: unknown = JSON.parse(raw); + if ( + typeof existing === 'object' && + existing !== null && + Array.isArray((existing as Record).validations) + ) { + validations = ( + (existing as Record).validations as unknown[] + ).filter( + (v): v is StoredValidation => + typeof v === 'object' && + v !== null && + typeof (v as StoredValidation).phase === 'string', + ); + } + } catch { + // Absent or unreadable receipt — start fresh. + } + const entry: StoredValidation = { + phase: data.phase, + outcome: data.outcome, + reviewedCommit: data.reviewedCommit, + treeId: data.treeId, + timestamp: new Date().toISOString(), + }; + const idx = validations.findIndex((v) => v.phase === data.phase); + if (idx === -1) validations.push(entry); + else validations[idx] = entry; + + const receipt: Record = { + issueNumber: data.issueNumber, + timestamp: new Date().toISOString(), + validations, + }; + if (context.sessionID?.trim()) receipt.sessionId = context.sessionID.trim(); + + tempCounter += 1; + const tmpPath = path.join( + dir, + `.trace-validation.json.tmp.${process.pid}.${tempCounter}`, + ); + await fs.promises.writeFile( + tmpPath, + JSON.stringify(receipt, null, 2), + 'utf-8', + ); + await fs.promises.rename(tmpPath, validatedPath); + const allGreen = validations.every((v) => v.outcome === 'pass'); + return JSON.stringify({ + success: true, + issueNumber: data.issueNumber, + path: '.swarm/trace-validation.json', + allGreen, + message: allGreen + ? `Validator receipt recorded for issue #${data.issueNumber} phase ${data.phase} (${data.outcome}); every recorded phase is green.` + : `Validator receipt recorded for issue #${data.issueNumber} phase ${data.phase} (${data.outcome}), but at least one recorded phase is failing — fix and re-record that phase as a pass before handoff.`, + }); + } catch (error) { + return JSON.stringify({ + success: false, + message: error instanceof Error ? error.message : String(error), + }); + } +} + +export const record_trace_validation: ReturnType = + createSwarmTool({ + description: + 'Record one per-phase trace-check.sh validator outcome for the current traced issue (issue #2564): the phase (v3 enum 0..5 incl. 2.5/4.2/4.5/4.6), the outcome (pass or fail), and the exact reviewedCommit and treeId the validator reported. Re-running a phase replaces its entry. The /swarm issue --trace workflow will not hand off to commit-pr while any recorded phase is failing or none is recorded.', + args: { + issueNumber: RecordTraceValidationArgsSchema.shape.issueNumber, + phase: RecordTraceValidationArgsSchema.shape.phase, + outcome: RecordTraceValidationArgsSchema.shape.outcome, + reviewedCommit: RecordTraceValidationArgsSchema.shape.reviewedCommit, + treeId: RecordTraceValidationArgsSchema.shape.treeId, + }, + execute: executeRecordTraceValidation, + }); diff --git a/src/tools/tool-metadata.ts b/src/tools/tool-metadata.ts index 8eb7dcedc..25704a422 100644 --- a/src/tools/tool-metadata.ts +++ b/src/tools/tool-metadata.ts @@ -287,7 +287,22 @@ export const TOOL_METADATA = { }, record_recurrence_sweep: { description: - 'record the recurrence sweep (defect class, predicates, hit dispositions, guardrail proof) so the /swarm issue --trace workflow can satisfy its recurrence gate before commit-pr handoff', + 'record the recurrence sweep (defect class, predicates, hit dispositions, guardrail proof, related-problems results) so the /swarm issue --trace workflow can satisfy its recurrence gate before commit-pr handoff', + agents: ['architect'], + }, + record_branch_freshness: { + description: + 'record the Phase 0 branch-freshness outcome (synced / behind: / fetch-failed: plus optional user override) so the /swarm issue --trace workflow can pass its freshness gate before transitioning to PLAN (issue #2564)', + agents: ['architect'], + }, + record_trace_validation: { + description: + 'record one per-phase trace-check.sh validator outcome (phase, pass/fail, reviewedCommit, treeId) so the /swarm issue --trace workflow can satisfy its validator-receipt gate before commit-pr handoff (issue #2564)', + agents: ['architect'], + }, + record_merge_approval: { + description: + 'record the human merge approval bound to the exact PR head SHA so the /swarm issue --trace workflow reaches its terminal merge_approval_recorded state — recorded for audit only, never certified or driven by the plugin (issue #2564)', agents: ['architect'], }, rebind_pr_feedback_head: { diff --git a/src/utils/atomic-write.ts b/src/utils/atomic-write.ts index 099ec6c85..9c2617d97 100644 --- a/src/utils/atomic-write.ts +++ b/src/utils/atomic-write.ts @@ -117,7 +117,7 @@ export const SWARM_TEMP_GRAMMARS: readonly SwarmTempGrammar[] = [ token: 'instance', quarantineEligible: true, parsesTarget: true, - producers: ['src/hooks/issue-trace-state.ts:315'], + producers: ['src/hooks/issue-trace-state.ts:442'], note: 'the .json terminator keeps this shape distinct from the bare two-token family — declared before it so first-match wins', }, { @@ -148,6 +148,9 @@ export const SWARM_TEMP_GRAMMARS: readonly SwarmTempGrammar[] = [ 'src/knowledge/identity.ts:230', 'src/evidence/phase-participation.ts (pre-#2035; migrated-to=src/evidence/phase-participation.ts:atomicWriteBytes)', 'src/turbo/lean/integration.ts:428', + 'src/tools/record-branch-freshness.ts:90', + 'src/tools/record-trace-validation.ts:134', + 'src/tools/record-merge-approval.ts:87', 'src/turbo/lean/reviewer.ts:403', 'src/plan/ledger.ts:1463', 'src/services/synonym-map.ts:389', @@ -301,7 +304,7 @@ export const SWARM_TEMP_GRAMMARS: readonly SwarmTempGrammar[] = [ 'src/tools/write-mutation-evidence.ts:169', 'src/tools/write-hallucination-evidence.ts:121', 'src/tools/record-implementation-review.ts:87', - 'src/tools/record-recurrence-sweep.ts:125', + 'src/tools/record-recurrence-sweep.ts:140', 'src/tools/record-issue-reproduction.ts:85', 'src/tools/record-issue-publication.ts:74', 'src/tools/write-final-council-evidence.ts:403', @@ -469,6 +472,9 @@ export const WRITER_CLASSIFICATION: Readonly< 'src/tools/record-issue-publication.ts': 'registered-bespoke', 'src/tools/record-issue-reproduction.ts': 'registered-bespoke', 'src/tools/record-recurrence-sweep.ts': 'registered-bespoke', + 'src/tools/record-branch-freshness.ts': 'registered-bespoke', + 'src/tools/record-trace-validation.ts': 'registered-bespoke', + 'src/tools/record-merge-approval.ts': 'registered-bespoke', 'src/tools/repo-graph/freshness.ts': 'registered-bespoke', 'src/tools/repo-graph/storage.ts': 'registered-bespoke', 'src/tools/sast-baseline.ts': 'registered-bespoke', diff --git a/tests/unit/hooks/issue-trace-e2e.test.ts b/tests/unit/hooks/issue-trace-e2e.test.ts index 67fc275c3..bccdd401b 100644 --- a/tests/unit/hooks/issue-trace-e2e.test.ts +++ b/tests/unit/hooks/issue-trace-e2e.test.ts @@ -108,7 +108,35 @@ function writeResidualBReceipts(dir: string, number = 42): void { issueNumber: number, defectClass: 'no defect class', justification: 'docs-only change corrects no behavior', + relatedProblems: [ + { ref: '#2131', note: 'residual-B gates this receipt feeds' }, + ], + timestamp: '2026-01-01T00:00:00Z', + }); +} + +/** + * Issue #2564 (issue-tracer v3 receipts): branch-freshness (Phase 0, synced) + * and a green per-phase validator receipt, written as the real tools would. + */ +function writeV3Receipts(dir: string, number = 42): void { + writeSwarmJson(dir, 'branch-freshness.json', { + issueNumber: number, + freshness: 'synced', + timestamp: '2026-01-01T00:00:00Z', + }); + writeSwarmJson(dir, 'trace-validation.json', { + issueNumber: number, timestamp: '2026-01-01T00:00:00Z', + validations: [ + { + phase: '4.6', + outcome: 'pass', + reviewedCommit: '0123456789abcdef0123456789abcdef01234567', + treeId: 'fedcba9876543210fedcba9876543210fedcba98', + timestamp: '2026-01-01T00:00:00Z', + }, + ], }); } @@ -162,7 +190,9 @@ describe('issue-trace e2e — full chain', () => { writeIssueRef(tmpDir, 42); writeTraceState(tmpDir); writeSpec(tmpDir, 42); - // No plan → authoritative planExists is false → row (f) fires. + // v3 Phase 0 receipt present → the freshness gate is satisfied; no plan → + // authoritative planExists is false → row (f) fires. + writeV3Receipts(tmpDir); const messages = await runHook(tmpDir); expectIssueTraceCarrier(messages, '[MODE: PLAN]'); @@ -214,6 +244,7 @@ describe('issue-trace e2e — full chain', () => { _internals.readPlanPhaseStatus = () => Promise.resolve({ planExists: true, allComplete: true }); writeResidualBReceipts(tmpDir); + writeV3Receipts(tmpDir); const messages = await runHook(tmpDir); expect(messages).toHaveLength(1); diff --git a/tests/unit/hooks/issue-trace-hook-v3-gates.test.ts b/tests/unit/hooks/issue-trace-hook-v3-gates.test.ts new file mode 100644 index 000000000..f5cd258ad --- /dev/null +++ b/tests/unit/hooks/issue-trace-hook-v3-gates.test.ts @@ -0,0 +1,180 @@ +/** + * Hook-level gate tests for the v3 receipts (issue #2564 PR-review F-003): + * drives createIssueTraceHook — not just the reducer/reader layer — for the + * two paths the review identified as hook-uncovered: + * 1. a FAILING trace-validation entry blocks the commit-pr handoff; + * 2. a fetch-failed freshness receipt WITH a recorded override permits PLAN. + * Under 500 lines (FR-006). + */ + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + _internals, + createIssueTraceHook, + resetApprovalCache, + resetPhaseStatusCache, +} from '../../../src/hooks/issue-trace'; +import type { TraceState } from '../../../src/hooks/issue-trace-reducer'; +import { + type GuidanceMessage, + isGuidanceCarrier, + messageTextOf, +} from '../../../src/hooks/system-guidance-carrier'; +import { canonicalMkdtemp } from '../../helpers/tmpdir'; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = canonicalMkdtemp('hook-v3-gates-'); + fs.mkdirSync(path.join(tmpDir, '.swarm'), { recursive: true }); +}); + +const originals = { ..._internals }; +afterEach(() => { + Object.assign(_internals, originals); + resetApprovalCache(); + resetPhaseStatusCache(); + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function writeJson(name: string, data: unknown): void { + fs.writeFileSync( + path.join(tmpDir, '.swarm', name), + JSON.stringify(data, null, 2), + 'utf-8', + ); +} + +function writeIssueRef(): void { + writeJson('issue-reference.json', { + url: 'https://github.com/owner/repo/issues/42', + owner: 'owner', + repo: 'repo', + number: 42, + timestamp: '2026-01-01T00:00:00Z', + flags: { trace: true, noRepro: true }, + }); +} + +function writeTraceState(over: Partial = {}): void { + writeJson('issue-trace-state.json', { + issueNumber: 42, + lastTransition: null, + status: 'in_progress', + ...over, + }); +} + +function writeSpec(): void { + fs.writeFileSync( + path.join(tmpDir, '.swarm', 'spec.md'), + '# Spec\n\n## Source Issue\n\n- Number: 42\n', + 'utf-8', + ); +} + +function writeResidualBReceipts(): void { + writeJson('implementation-review.json', { + issueNumber: 42, + reviewerVerdict: 'APPROVE', + criticVerdict: 'APPROVE', + diffBase: 'abc1234', + diffHead: 'def5678', + }); + writeJson('recurrence-sweep.json', { + issueNumber: 42, + defectClass: 'no defect class', + justification: 'docs-only change corrects no behavior', + relatedProblems: [{ ref: '#2131' }], + }); +} + +function completePlan(): void { + _internals.isPlanCriticApproved = () => Promise.resolve(true); + _internals.readPlanPhaseStatus = () => + Promise.resolve({ planExists: true, allComplete: true }); +} + +async function runHook(): Promise<{ + messages: unknown[]; + state: TraceState | null; +}> { + const hook = createIssueTraceHook({}, tmpDir, 100); + const output = { messages: [] as unknown[] }; + await hook.messagesTransform({}, output); + const raw = fs.readFileSync( + path.join(tmpDir, '.swarm', 'issue-trace-state.json'), + 'utf-8', + ); + return { messages: output.messages, state: JSON.parse(raw) as TraceState }; +} + +describe('hook-level v3 gates (PR #2783 review F-003)', () => { + test('a FAILING trace-validation entry blocks the commit-pr handoff', async () => { + writeIssueRef(); + writeTraceState({ lastTransition: 'PLAN_TO_EXECUTE' }); + writeSpec(); + completePlan(); + writeResidualBReceipts(); + writeJson('branch-freshness.json', { + issueNumber: 42, + freshness: 'synced', + }); + // One green entry plus one FAILING entry: the reader must fail closed, + // so the hook emits the TRACE_VALIDATION_GATE directive instead of the + // commit-pr handoff. + writeJson('trace-validation.json', { + issueNumber: 42, + validations: [ + { + phase: '0', + outcome: 'pass', + reviewedCommit: '0123456789abcdef0123456789abcdef01234567', + treeId: 'fedcba9876543210fedcba9876543210fedcba98', + }, + { + phase: '4.6', + outcome: 'fail', + reviewedCommit: '0123456789abcdef0123456789abcdef01234567', + treeId: 'fedcba9876543210fedcba9876543210fedcba98', + }, + ], + }); + + const { messages, state } = await runHook(); + // Two carriers: the [MODE: EXECUTE] mode signal plus the gate directive. + expect(messages).toHaveLength(2); + const text = messages + .filter((m) => isGuidanceCarrier(m)) + .map((m) => messageTextOf(m as GuidanceMessage)) + .join('\n'); + expect(text).toMatch(/\[MODE: EXECUTE\]/); + expect(text).toMatch(/trace-check|validator|validation/i); + // The commit-pr HANDOFF directive opens with "Compose commit-pr"; the + // gate directive merely names the gate it is blocking. + expect(text).not.toContain('Compose commit-pr'); + expect(state?.lastTransition).toBe('TRACE_VALIDATION_GATE'); + expect(state?.status).toBe('in_progress'); + }); + + test('a fetch-failed freshness receipt WITH an override permits PLAN', async () => { + writeIssueRef(); + writeTraceState(); + writeSpec(); + writeJson('branch-freshness.json', { + issueNumber: 42, + freshness: 'fetch-failed:network-offline', + override: 'user accepted proceeding on the stale base', + }); + + const { messages, state } = await runHook(); + expect(messages).toHaveLength(1); + expect(isGuidanceCarrier(messages[0])).toBe(true); + const text = messageTextOf(messages[0] as GuidanceMessage); + expect(text).toContain('[MODE: PLAN]'); + expect(state?.lastTransition).toBe('ISSUE_INGEST_TO_PLAN'); + expect(state?.status).toBe('in_progress'); + }); +}); diff --git a/tests/unit/hooks/issue-trace-journey-v3-completion.test.ts b/tests/unit/hooks/issue-trace-journey-v3-completion.test.ts new file mode 100644 index 000000000..4500f84c2 --- /dev/null +++ b/tests/unit/hooks/issue-trace-journey-v3-completion.test.ts @@ -0,0 +1,121 @@ +/** + * Ingestion journey (issue #2564), part 2: gates ladder → handoff → published + * → merge_approval_recorded (PR-head bound, recorded never certified). Real + * machinery only. Under 500 lines (FR-006). + */ + +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + createJourneyProject, + handleIssueCommand, + PR_HEAD, +} from './issue-trace-journey-v3-helpers'; + +let project: ReturnType | null = null; +afterEach(() => { + project?.cleanup(); + project = null; +}); + +/** Drives the journey through EXECUTE (phases complete), returning the project. */ +async function journeyThroughExecute() { + const p = createJourneyProject(); + handleIssueCommand(p.dir, ['ZaxbyHub/opencode-swarm#2564', '--trace']); + p.writeSpec(); + return p; +} + +describe('issue-ingestion journey v3 — gates through merge approval', () => { + test('review gate surfaces the actionable directive before the receipt lands', async () => { + project = await journeyThroughExecute(); + await project.recordFreshness(); + await project.recordRepro(); + await project.cycle(); + await project.saveJourneyPlan('in_progress', 'in_progress'); + await project.approveCritic(); + await project.cycle(); // EXECUTE + await project.saveJourneyPlan('complete', 'completed'); + + const step = await project.cycle(); + expect(step.state.lastTransition).toBe('REVIEW_GATE'); + expect(step.text).toMatch(/implementation review/i); + }); + + test('full ladder: handoff → published → merge_approval_recorded with PR-head binding', async () => { + project = await journeyThroughExecute(); + await project.recordFreshness(); + await project.recordRepro(); + await project.cycle(); + await project.saveJourneyPlan('in_progress', 'in_progress'); + await project.approveCritic(); + await project.cycle(); // EXECUTE + await project.saveJourneyPlan('complete', 'completed'); + + await project.cycle(); // REVIEW_GATE one-shot + const review = await project.recordReview(); + expect(review.success).toBe(true); + + await project.cycle(); // RECURRENCE_GATE one-shot + const sweep = await project.recordSweep(); + expect(sweep.success).toBe(true); + + // The cycle after the sweep receipt lands must chain into the + // validation directive (final-critic round 1: no silent park at the + // gate the reducer itself just made blocking). + const validationGate = await project.cycle(); + expect(validationGate.state.lastTransition).toBe('TRACE_VALIDATION_GATE'); + expect(validationGate.text).toMatch(/trace-check|validator|validation/i); + const validation = await project.recordValidation(); + expect(validation.success).toBe(true); + + // Handoff: publication_handoff + commit-pr directive. + const handoff = await project.cycle(); + expect(handoff.state.lastTransition).toBe('EXECUTE_TO_COMMIT'); + expect(handoff.state.status).toBe('publication_handoff'); + expect(handoff.text).toMatch(/commit-pr|publication/i); + + const publication = await project.recordPublication(); + expect(publication.success).toBe(true); + + const published = await project.cycle(); + expect(published.state.status).toBe('published'); + expect(published.state.lastTransition).toBe('PUBLISHED'); + + const merge = await project.recordMergeApproval(); + expect(merge.success).toBe(true); + + const recorded = await project.cycle(); + expect(recorded.state.status).toBe('merge_approval_recorded'); + expect(recorded.state.lastTransition).toBe('MERGE_APPROVAL_RECORDED'); + // Recorded, never certified: no mode is driven. + expect(recorded.text).toMatch(/human/i); + expect(recorded.text).not.toMatch(/\[MODE:/); + + // PR-head binding across receipts. + const mergeReceipt = JSON.parse( + fs.readFileSync( + path.join(project.dir, '.swarm', 'merge-approval.json'), + 'utf-8', + ), + ); + const pubReceipt = JSON.parse( + fs.readFileSync( + path.join(project.dir, '.swarm', 'issue-publication.json'), + 'utf-8', + ), + ); + expect(mergeReceipt.prHeadSha).toBe(PR_HEAD); + expect(pubReceipt.headSha).toBe(PR_HEAD); + + // All v3 receipts durably on disk. + for (const name of [ + 'branch-freshness.json', + 'trace-validation.json', + 'merge-approval.json', + ]) { + expect(fs.existsSync(path.join(project.dir, '.swarm', name))).toBe(true); + } + }); +}); diff --git a/tests/unit/hooks/issue-trace-journey-v3-helpers.ts b/tests/unit/hooks/issue-trace-journey-v3-helpers.ts new file mode 100644 index 000000000..d21614a44 --- /dev/null +++ b/tests/unit/hooks/issue-trace-journey-v3-helpers.ts @@ -0,0 +1,235 @@ +/** + * Shared fixtures for the v3 ingestion-journey tests (issue #2564). Drives the + * REAL machinery only — handleIssueCommand, the real record_* receipt + * executors, savePlan/loadPlan through the real ledger, approve_plan_critic + * via ensureAgentSession, and the engine hook — with NO `_internals` + * behavioral overrides. Non-test module (no FR-006 cap concern). + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { handleIssueCommand } from '../../../src/commands/issue'; +import { + createIssueTraceHook, + resetApprovalCache, + resetPhaseStatusCache, +} from '../../../src/hooks/issue-trace'; +import { readTraceState } from '../../../src/hooks/issue-trace-state'; +import { loadPlan, savePlan } from '../../../src/plan/manager'; +import { ensureAgentSession } from '../../../src/state'; +import { executeApprovePlanCritic } from '../../../src/tools/approve-plan-critic'; +import { executeRecordBranchFreshness } from '../../../src/tools/record-branch-freshness'; +import { executeRecordImplementationReview } from '../../../src/tools/record-implementation-review'; +import { executeRecordIssuePublication } from '../../../src/tools/record-issue-publication'; +import { executeRecordIssueReproduction } from '../../../src/tools/record-issue-reproduction'; +import { executeRecordMergeApproval } from '../../../src/tools/record-merge-approval'; +import { executeRecordRecurrenceSweep } from '../../../src/tools/record-recurrence-sweep'; +import { executeRecordTraceValidation } from '../../../src/tools/record-trace-validation'; + +export const JOURNEY_ISSUE = 2564; +export const PR_HEAD = '0123456789abcdef0123456789abcdef01234567'; +export const TREE_ID = 'fedcba9876543210fedcba9876543210fedcba98'; +export const JOURNEY_SESSION = 'journey-v3-architect'; + +export interface JourneyProject { + dir: string; + call: ( + fn: ( + args: unknown, + dir: string, + ctx: { sessionID: string }, + ) => Promise, + args: unknown, + ) => Promise>; + cycle: () => Promise<{ + text: string; + state: { + issueNumber: number; + lastTransition: string | null; + status: string; + }; + }>; + writeSpec: () => void; + saveJourneyPlan: (phaseStatus: string, taskStatus: string) => Promise; + approveCritic: () => Promise>; + recordFreshness: () => Promise>; + recordRepro: () => Promise>; + recordReview: () => Promise>; + recordSweep: () => Promise>; + recordValidation: () => Promise>; + recordPublication: () => Promise>; + recordMergeApproval: () => Promise>; + resetCaches: () => void; + cleanup: () => void; +} + +export function createJourneyProject(): JourneyProject { + const dir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'journey-v3-')), + ); + fs.mkdirSync(path.join(dir, '.git'), { recursive: true }); + const swarm = (...p: string[]) => path.join(dir, '.swarm', ...p); + const call: JourneyProject['call'] = (fn, args) => + fn(args, dir, { sessionID: JOURNEY_SESSION }).then((s) => JSON.parse(s)); + const cycle = async () => { + const hook = createIssueTraceHook({}, dir, 300); + const output = { messages: [] }; + await hook.messagesTransform({ messages: [] }, output); + const text = output.messages + .map((m: { parts?: Array<{ type?: string; text?: string }> }) => + Array.isArray(m?.parts) + ? m.parts + .filter((p) => p?.type === 'text') + .map((p) => p.text) + .join('\n') + : '', + ) + .join('\n'); + return { text, state: readTraceState(dir) }; + }; + const planV = (phaseStatus: string, taskStatus: string) => ({ + schema_version: '1.0.0', + title: 'Wire issue-tracer v3 receipts plugin-side (#2564)', + swarm: 'architect', + current_phase: 1, + phases: [ + { + id: 1, + name: 'Wire v3 receipt surfaces', + status: phaseStatus, + tasks: [ + { + id: '1.1', + phase: 1, + status: taskStatus, + description: + 'Add branch-freshness, trace-validation, and merge-approval receipts with reducer consumption', + acceptance: 'AC1-AC9 acceptance table green post-fix', + files_touched: ['src/hooks/issue-trace-reducer.ts'], + fr_refs: ['FR-2564'], + }, + ], + }, + ], + }); + return { + dir, + call, + cycle, + writeSpec: () => { + fs.mkdirSync(swarm(), { recursive: true }); + fs.writeFileSync( + swarm('spec.md'), + '# Spec\n\n## Source Issue\n\n- Number: 2564\n\n## Details\n\nWire the v3 receipts.\n', + 'utf-8', + ); + }, + saveJourneyPlan: async (phaseStatus, taskStatus) => { + await savePlan(dir, planV(phaseStatus, taskStatus) as never); + }, + approveCritic: () => { + ensureAgentSession(JOURNEY_SESSION, 'architect'); + return call(executeApprovePlanCritic, { + reason: + 'journey fixture: critic returned APPROVED for the v3 receipt wiring plan', + }); + }, + recordFreshness: () => + call(executeRecordBranchFreshness, { + issueNumber: JOURNEY_ISSUE, + freshness: 'synced', + }), + recordRepro: () => + call(executeRecordIssueReproduction, { + issueNumber: JOURNEY_ISSUE, + performed: true, + commands: ['bun repro/C1.mjs'], + output_summary: + 'pre-fix check fails closed on the missing freshness surface', + }), + recordReview: () => + call(executeRecordImplementationReview, { + issueNumber: JOURNEY_ISSUE, + reviewerVerdict: 'APPROVE', + criticVerdict: 'APPROVE', + diffBase: 'b865ba262f', + diffHead: PR_HEAD, + notes: 'fresh reviewer and fresh critic both approved', + }), + recordSweep: () => + call(executeRecordRecurrenceSweep, { + issueNumber: JOURNEY_ISSUE, + defectClass: 'no defect class', + justification: 'receipt wiring corrects no incorrect behavior', + relatedProblems: [{ ref: '#2600', note: 'coordinated, not absorbed' }], + }), + recordValidation: () => + call(executeRecordTraceValidation, { + issueNumber: JOURNEY_ISSUE, + phase: '4.6', + outcome: 'pass', + reviewedCommit: PR_HEAD, + treeId: TREE_ID, + }), + recordPublication: () => + call(executeRecordIssuePublication, { + issueNumber: JOURNEY_ISSUE, + prNumber: 4321, + prUrl: 'https://github.com/ZaxbyHub/opencode-swarm/pull/4321', + headSha: PR_HEAD, + }), + recordMergeApproval: () => + call(executeRecordMergeApproval, { + issueNumber: JOURNEY_ISSUE, + prHeadSha: PR_HEAD, + finalCriticReviewedCommit: PR_HEAD, + userApprovalVerbatim: + 'User approved merging PR #4321 after the final critic report', + }), + resetCaches: () => { + resetApprovalCache(); + resetPhaseStatusCache(); + }, + cleanup: () => { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + /* best effort */ + } + }, + }; +} + +/** Writes the v3 receipt fixtures (issue #2564) into a project's .swarm dir. */ +export function writeV3ReceiptFiles(dir: string, number = 42): void { + const swarmDir = path.join(dir, '.swarm'); + fs.mkdirSync(swarmDir, { recursive: true }); + fs.writeFileSync( + path.join(swarmDir, 'branch-freshness.json'), + JSON.stringify({ issueNumber: number, freshness: 'synced' }, null, 2), + 'utf-8', + ); + fs.writeFileSync( + path.join(swarmDir, 'trace-validation.json'), + JSON.stringify( + { + issueNumber: number, + validations: [ + { + phase: '4.6', + outcome: 'pass', + reviewedCommit: PR_HEAD, + treeId: TREE_ID, + }, + ], + }, + null, + 2, + ), + 'utf-8', + ); +} + +export { handleIssueCommand, loadPlan }; diff --git a/tests/unit/hooks/issue-trace-journey-v3.test.ts b/tests/unit/hooks/issue-trace-journey-v3.test.ts new file mode 100644 index 000000000..14ecbc96b --- /dev/null +++ b/tests/unit/hooks/issue-trace-journey-v3.test.ts @@ -0,0 +1,143 @@ +/** + * Ingestion journey (issue #2564), part 1: issue input → spec → v3 receipts → + * PLAN → durable plan through the real ledger → interruption/resume → EXECUTE. + * Real machinery only (no _internals behavioral overrides). Under 500 lines + * (FR-006). + */ + +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + createJourneyProject, + handleIssueCommand, + loadPlan, +} from './issue-trace-journey-v3-helpers'; + +let project: ReturnType | null = null; +afterEach(() => { + project?.cleanup(); + project = null; +}); + +describe('issue-ingestion journey v3 — ingestion through resume', () => { + test('issue input emits the ISSUE_INGEST signal and persists durable state', () => { + project = createJourneyProject(); + const signal = handleIssueCommand(project.dir, [ + 'ZaxbyHub/opencode-swarm#2564', + '--trace', + ]); + expect(signal).toBe( + '[MODE: ISSUE_INGEST issue="https://github.com/ZaxbyHub/opencode-swarm/issues/2564" plan=true trace=true]', + ); + expect( + fs.existsSync(path.join(project.dir, '.swarm', 'issue-reference.json')), + ).toBe(true); + expect( + fs.existsSync(path.join(project.dir, '.swarm', 'issue-trace-state.json')), + ).toBe(true); + }); + + test('spec missing: the engine waits (row e) with no state change', async () => { + project = createJourneyProject(); + handleIssueCommand(project.dir, [ + 'ZaxbyHub/opencode-swarm#2564', + '--trace', + ]); + const step = await project.cycle(); + expect(step.state.lastTransition).toBeNull(); + expect(step.state.status).toBe('in_progress'); + }); + + test('freshness receipt then repro gate: each gate surfaces the actionable next step', async () => { + project = createJourneyProject(); + handleIssueCommand(project.dir, [ + 'ZaxbyHub/opencode-swarm#2564', + '--trace', + ]); + project.writeSpec(); + + // Phase 0 v3 receipt via the REAL tool. + const fresh = await project.recordFreshness(); + expect(fresh.success).toBe(true); + + // Without reproduction evidence the engine nudges exactly once. + const step = await project.cycle(); + expect(step.state.lastTransition).toBe('REPRO_GATE'); + expect(step.text).toMatch(/reproduc/i); + + const repro = await project.recordRepro(); + expect(repro.success).toBe(true); + + const plan = await project.cycle(); + expect(plan.state.lastTransition).toBe('ISSUE_INGEST_TO_PLAN'); + expect(plan.state.status).toBe('in_progress'); + }); + + test('durable plan through the real ledger carries acceptance, files, and requirement references', async () => { + project = createJourneyProject(); + handleIssueCommand(project.dir, [ + 'ZaxbyHub/opencode-swarm#2564', + '--trace', + ]); + project.writeSpec(); + await project.recordFreshness(); + await project.recordRepro(); + await project.cycle(); // ISSUE_INGEST_TO_PLAN + + await project.saveJourneyPlan('in_progress', 'in_progress'); + expect( + fs.existsSync(path.join(project.dir, '.swarm', 'plan-ledger.jsonl')), + ).toBe(true); + const loaded = await loadPlan(project.dir); + const task = loaded?.phases?.[0]?.tasks?.[0]; + expect(typeof task?.acceptance).toBe('string'); + expect((task?.acceptance ?? '').length).toBeGreaterThan(0); + expect(Array.isArray(task?.files_touched)).toBe(true); + expect((task?.files_touched ?? []).length).toBeGreaterThan(0); + expect(Array.isArray(task?.fr_refs)).toBe(true); + expect((task?.fr_refs ?? []).length).toBeGreaterThan(0); + }); + + test('interruption/resume: cache resets do not restart the trace (row g waits silently)', async () => { + project = createJourneyProject(); + handleIssueCommand(project.dir, [ + 'ZaxbyHub/opencode-swarm#2564', + '--trace', + ]); + project.writeSpec(); + await project.recordFreshness(); + await project.recordRepro(); + await project.cycle(); + await project.saveJourneyPlan('in_progress', 'in_progress'); + + // Simulated restart: in-memory caches drop, on-disk state is all that + // remains. The trace resumes where it left off (critic gate pending, + // row g silent) rather than re-running earlier gates. + project.resetCaches(); + const step = await project.cycle(); + expect(step.state.lastTransition).toBe('ISSUE_INGEST_TO_PLAN'); + expect(step.state.status).toBe('in_progress'); + expect(step.text).toBe(''); + }); + + test('critic approval via the real architect-session path drives EXECUTE', async () => { + project = createJourneyProject(); + handleIssueCommand(project.dir, [ + 'ZaxbyHub/opencode-swarm#2564', + '--trace', + ]); + project.writeSpec(); + await project.recordFreshness(); + await project.recordRepro(); + await project.cycle(); + await project.saveJourneyPlan('in_progress', 'in_progress'); + + const approved = await project.approveCritic(); + expect(approved.success).toBe(true); + + const step = await project.cycle(); + expect(step.state.lastTransition).toBe('PLAN_TO_EXECUTE'); + expect(step.text).toMatch(/\[MODE: EXECUTE\]/); + }); +}); diff --git a/tests/unit/hooks/issue-trace-reducer-v3.test.ts b/tests/unit/hooks/issue-trace-reducer-v3.test.ts new file mode 100644 index 000000000..211bbf4cc --- /dev/null +++ b/tests/unit/hooks/issue-trace-reducer-v3.test.ts @@ -0,0 +1,266 @@ +/** + * Reducer v3 receipt rows (issue #2564): FRESHNESS_GATE (pre-PLAN), + * TRACE_VALIDATION_GATE (pre-handoff), and published → merge_approval_recorded + * (recorded, never certified). Mirrors the frozen acceptance-check contracts + * (repro/C1, C2, C4) as repo-conventional bun:test coverage. Under 500 lines + * (FR-006). + */ + +import { describe, expect, test } from 'bun:test'; +import { + computeNextMode, + type IssueReference, + type TraceState, + type WorkflowArtifacts, +} from '../../../src/hooks/issue-trace-reducer'; + +const ISSUE = 2564; +const issueReference: IssueReference = { + url: 'https://github.com/ZaxbyHub/opencode-swarm/issues/2564', + owner: 'ZaxbyHub', + repo: 'opencode-swarm', + number: ISSUE, + timestamp: '2026-09-14T00:00:00Z', + flags: { trace: true }, +}; + +const prePlanArtifacts: WorkflowArtifacts = { + specExists: true, + specIssueNumber: ISSUE, + planExists: false, + criticApproved: false, + allPhasesComplete: false, + reproductionPermitted: true, + freshnessPermitted: true, + publicationObserved: false, + recurrenceSweepVerified: true, + implementationReviewVerified: true, + traceValidationVerified: true, + mergeApprovalObserved: false, +}; + +const completeArtifacts: WorkflowArtifacts = { + specExists: true, + specIssueNumber: ISSUE, + planExists: true, + criticApproved: true, + allPhasesComplete: true, + reproductionPermitted: true, + freshnessPermitted: true, + publicationObserved: false, + recurrenceSweepVerified: true, + implementationReviewVerified: true, + traceValidationVerified: true, + mergeApprovalObserved: false, +}; + +const state = (over: Partial = {}): TraceState => ({ + issueNumber: ISSUE, + lastTransition: null, + status: 'in_progress', + ...over, +}); + +describe('row (f-0): FRESHNESS_GATE before PLAN (v3 Phase 0)', () => { + test('freshness not permitted blocks PLAN with a one-shot directive', () => { + const r = computeNextMode({ + issueReference, + traceState: state(), + workflowArtifacts: { ...prePlanArtifacts, freshnessPermitted: false }, + }); + expect(r.nextLastTransition).toBe('FRESHNESS_GATE'); + expect(r.nextMode).not.toBe('PLAN'); + expect(r.nextStatus).toBe('in_progress'); + expect(r.directive).toMatch(/freshness|branch|sync|fetch/i); + }); + + test('freshness permitted is transparent (PLAN row unchanged)', () => { + const r = computeNextMode({ + issueReference, + traceState: state(), + workflowArtifacts: prePlanArtifacts, + }); + expect(r.nextMode).toBe('PLAN'); + expect(r.nextLastTransition).toBe('ISSUE_INGEST_TO_PLAN'); + }); + + test('one-shot: after the sentinel fired the reducer waits quietly', () => { + const r = computeNextMode({ + issueReference, + traceState: state({ lastTransition: 'FRESHNESS_GATE' }), + workflowArtifacts: { ...prePlanArtifacts, freshnessPermitted: false }, + }); + expect(r.nextMode).toBeNull(); + expect(r.directive).toBeNull(); + expect(r.nextLastTransition).toBe('FRESHNESS_GATE'); + }); + + test('a freshness receipt landing after REPRO_GATE still gets the repro nudge', () => { + // Freshness satisfied but reproduction missing, sentinel FRESHNESS_GATE: + // the repro one-shot must fire (no silent stall). + const r = computeNextMode({ + issueReference, + traceState: state({ lastTransition: 'FRESHNESS_GATE' }), + workflowArtifacts: { + ...prePlanArtifacts, + reproductionPermitted: false, + }, + }); + expect(r.nextLastTransition).toBe('REPRO_GATE'); + }); + + test('PLAN row accepts the FRESHNESS_GATE sentinel once both gates pass', () => { + const r = computeNextMode({ + issueReference, + traceState: state({ lastTransition: 'FRESHNESS_GATE' }), + workflowArtifacts: prePlanArtifacts, + }); + expect(r.nextMode).toBe('PLAN'); + }); + + test('v2-shaped artifacts (fields absent) stay transparent', () => { + const v2Shape = { ...prePlanArtifacts } as Record; + delete v2Shape.freshnessPermitted; + delete v2Shape.traceValidationVerified; + delete v2Shape.mergeApprovalObserved; + const r = computeNextMode({ + issueReference, + traceState: state(), + workflowArtifacts: v2Shape as unknown as WorkflowArtifacts, + }); + expect(r.nextMode).toBe('PLAN'); + }); +}); + +describe('row (i-pre3): TRACE_VALIDATION_GATE before the handoff', () => { + test('missing/failing validator receipts block the handoff with a one-shot directive', () => { + const r = computeNextMode({ + issueReference, + traceState: state({ lastTransition: 'PLAN_TO_EXECUTE' }), + workflowArtifacts: { + ...completeArtifacts, + traceValidationVerified: false, + }, + }); + expect(r.nextStatus).not.toBe('publication_handoff'); + expect(r.nextLastTransition).toBe('TRACE_VALIDATION_GATE'); + expect(r.nextStatus).toBe('in_progress'); + expect(r.directive).toMatch(/trace-check|validator|validation/i); + }); + + test('verified validator receipts leave the handoff row unchanged', () => { + const r = computeNextMode({ + issueReference, + traceState: state({ lastTransition: 'PLAN_TO_EXECUTE' }), + workflowArtifacts: completeArtifacts, + }); + expect(r.nextStatus).toBe('publication_handoff'); + expect(r.nextLastTransition).toBe('EXECUTE_TO_COMMIT'); + }); + + test('one-shot: after the sentinel fired the reducer waits quietly', () => { + const r = computeNextMode({ + issueReference, + traceState: state({ lastTransition: 'TRACE_VALIDATION_GATE' }), + workflowArtifacts: { + ...completeArtifacts, + traceValidationVerified: false, + }, + }); + expect(r.nextMode).toBeNull(); + expect(r.directive).toBeNull(); + expect(r.nextLastTransition).toBe('TRACE_VALIDATION_GATE'); + }); + + test('fires after RECURRENCE_GATE/REVIEW_GATE landed their receipts (no silent park)', () => { + // Final-critic round 1: in the canonical ladder the reducer itself + // drives — RECURRENCE_GATE fires, the sweep receipt lands, and the very + // next cycle must chain into the validation directive, not park silently. + for (const sentinel of ['RECURRENCE_GATE', 'REVIEW_GATE']) { + const r = computeNextMode({ + issueReference, + traceState: state({ lastTransition: sentinel }), + workflowArtifacts: { + ...completeArtifacts, + traceValidationVerified: false, + }, + }); + expect(r.nextLastTransition).toBe('TRACE_VALIDATION_GATE'); + expect(r.directive).toMatch(/trace-check|validator|validation/i); + expect(r.nextStatus).toBe('in_progress'); + } + }); + + test('does not claim the prior gates are satisfied when a receipt vanished (truthful text)', () => { + // Implementation review round 3: with the sentinel present but the + // underlying review/sweep receipt no longer verified, the row must + // decline (quiet noop under the exhausted-one-shot semantics every + // gate row shares) instead of emitting a directive that lies about + // the prior gates. + for (const [sentinel, artifacts] of [ + [ + 'REVIEW_GATE', + { ...completeArtifacts, implementationReviewVerified: false }, + ], + [ + 'RECURRENCE_GATE', + { ...completeArtifacts, recurrenceSweepVerified: false }, + ], + ] as Array<[string, WorkflowArtifacts]>) { + const r = computeNextMode({ + issueReference, + traceState: state({ lastTransition: sentinel }), + workflowArtifacts: { + ...artifacts, + traceValidationVerified: false, + }, + }); + expect(r.nextMode).toBeNull(); + expect(r.directive).toBeNull(); + expect(r.nextLastTransition).toBe(sentinel); + } + }); +}); + +describe("row (b'): published → merge_approval_recorded (recorded, never certified)", () => { + const published = state({ lastTransition: 'PUBLISHED', status: 'published' }); + + test('merge approval observed records the terminal status without driving a merge', () => { + const r = computeNextMode({ + issueReference, + traceState: published, + workflowArtifacts: { ...completeArtifacts, mergeApprovalObserved: true }, + }); + expect(r.nextStatus).toBe('merge_approval_recorded'); + expect(r.nextLastTransition).toBe('MERGE_APPROVAL_RECORDED'); + expect(r.nextMode).toBeNull(); + expect(r.directive).toMatch(/human/i); + expect(r.directive).toMatch(/record/i); + expect(r.directive).not.toMatch(/certif|authoriz/i); + }); + + test('no merge-approval receipt: stays published quietly', () => { + const r = computeNextMode({ + issueReference, + traceState: published, + workflowArtifacts: completeArtifacts, + }); + expect(r.nextStatus).toBe('published'); + expect(r.nextMode).toBeNull(); + expect(r.directive).toBeNull(); + }); + + test('merge_approval_recorded is the truly-terminal noop', () => { + const r = computeNextMode({ + issueReference, + traceState: state({ + lastTransition: 'MERGE_APPROVAL_RECORDED', + status: 'merge_approval_recorded', + }), + workflowArtifacts: { ...completeArtifacts, mergeApprovalObserved: true }, + }); + expect(r.nextMode).toBeNull(); + expect(r.directive).toBeNull(); + expect(r.nextStatus).toBe('merge_approval_recorded'); + }); +}); diff --git a/tests/unit/hooks/issue-trace-state-v3-receipts.test.ts b/tests/unit/hooks/issue-trace-state-v3-receipts.test.ts new file mode 100644 index 000000000..3df61798d --- /dev/null +++ b/tests/unit/hooks/issue-trace-state-v3-receipts.test.ts @@ -0,0 +1,176 @@ +/** + * v3 receipt readers (issue #2564): branchFreshnessReceiptExists, + * traceValidationReceiptExists, mergeApprovalReceiptExists in + * src/hooks/issue-trace-state.ts. Mirrors the frozen acceptance-check + * contracts (repro/C1, C2, C4) as repo-conventional bun:test coverage for the + * reader side. Under 500 lines (FR-006). + */ + +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + branchFreshnessReceiptExists, + mergeApprovalReceiptExists, + traceValidationReceiptExists, +} from '../../../src/hooks/issue-trace-state'; +import { canonicalMkdtemp } from '../../helpers/tmpdir'; + +const HEX_A = '0123456789abcdef0123456789abcdef01234567'; +const HEX_B = 'fedcba9876543210fedcba9876543210fedcba98'; + +const dirs: string[] = []; +function makeDir(): string { + const dir = canonicalMkdtemp('v3-readers-'); + fs.mkdirSync(path.join(dir, '.swarm'), { recursive: true }); + dirs.push(dir); + return dir; +} +function writeReceipt(dir: string, name: string, data: unknown): void { + fs.writeFileSync( + path.join(dir, '.swarm', name), + JSON.stringify(data, null, 2), + 'utf-8', + ); +} +afterEach(() => { + for (const d of dirs.splice(0)) { + try { + fs.rmSync(d, { recursive: true, force: true }); + } catch { + /* best effort */ + } + } +}); + +describe('branchFreshnessReceiptExists (mirrors trace-check.sh phase0)', () => { + test('synced permits; behind never permits', async () => { + const dir = makeDir(); + writeReceipt(dir, 'branch-freshness.json', { + issueNumber: 2564, + freshness: 'synced', + }); + expect(await branchFreshnessReceiptExists(dir, 2564)).toBe(true); + + writeReceipt(dir, 'branch-freshness.json', { + issueNumber: 2564, + freshness: 'behind:3', + }); + expect(await branchFreshnessReceiptExists(dir, 2564)).toBe(false); + }); + + test('fetch-failed fails closed without an override, permits with one', async () => { + const dir = makeDir(); + writeReceipt(dir, 'branch-freshness.json', { + issueNumber: 2564, + freshness: 'fetch-failed:auth-revoked', + }); + expect(await branchFreshnessReceiptExists(dir, 2564)).toBe(false); + + writeReceipt(dir, 'branch-freshness.json', { + issueNumber: 2564, + freshness: 'fetch-failed:auth-revoked', + override: 'user said proceed on the stale base', + }); + expect(await branchFreshnessReceiptExists(dir, 2564)).toBe(true); + }); + + test('issue-bound and malformed-fail-closed', async () => { + const dir = makeDir(); + writeReceipt(dir, 'branch-freshness.json', { + issueNumber: 2564, + freshness: 'synced', + }); + expect(await branchFreshnessReceiptExists(dir, 999)).toBe(false); + + writeReceipt(dir, 'branch-freshness.json', { + issueNumber: 2564, + freshness: 'weird-value', + }); + expect(await branchFreshnessReceiptExists(dir, 2564)).toBe(false); + }); +}); + +describe('traceValidationReceiptExists (per-phase validator receipts)', () => { + test('empty or absent receipt fails closed', async () => { + const dir = makeDir(); + expect(await traceValidationReceiptExists(dir, 2564)).toBe(false); + + writeReceipt(dir, 'trace-validation.json', { + issueNumber: 2564, + validations: [], + }); + expect(await traceValidationReceiptExists(dir, 2564)).toBe(false); + }); + + test('all-pass entries satisfy; any fail entry or malformed SHA fails closed', async () => { + const dir = makeDir(); + writeReceipt(dir, 'trace-validation.json', { + issueNumber: 2564, + validations: [ + { phase: '0', outcome: 'pass', reviewedCommit: HEX_A, treeId: HEX_B }, + { + phase: '4.6', + outcome: 'pass', + reviewedCommit: HEX_A, + treeId: HEX_B, + }, + ], + }); + expect(await traceValidationReceiptExists(dir, 2564)).toBe(true); + + writeReceipt(dir, 'trace-validation.json', { + issueNumber: 2564, + validations: [ + { phase: '0', outcome: 'pass', reviewedCommit: HEX_A, treeId: HEX_B }, + { + phase: '4.6', + outcome: 'fail', + reviewedCommit: HEX_A, + treeId: HEX_B, + }, + ], + }); + expect(await traceValidationReceiptExists(dir, 2564)).toBe(false); + + writeReceipt(dir, 'trace-validation.json', { + issueNumber: 2564, + validations: [ + { phase: '0', outcome: 'pass', reviewedCommit: 'abc', treeId: HEX_B }, + ], + }); + expect(await traceValidationReceiptExists(dir, 2564)).toBe(false); + + expect(await traceValidationReceiptExists(dir, 999)).toBe(false); + }); +}); + +describe('mergeApprovalReceiptExists (recorded, never certified)', () => { + test('bound 40-hex pair satisfies; mismatch or short SHA fails closed', async () => { + const dir = makeDir(); + writeReceipt(dir, 'merge-approval.json', { + issueNumber: 2564, + prHeadSha: HEX_A, + finalCriticReviewedCommit: HEX_A, + userApprovalVerbatim: 'approved by the user', + }); + expect(await mergeApprovalReceiptExists(dir, 2564)).toBe(true); + expect(await mergeApprovalReceiptExists(dir, 999)).toBe(false); + + writeReceipt(dir, 'merge-approval.json', { + issueNumber: 2564, + prHeadSha: HEX_A, + finalCriticReviewedCommit: HEX_B, + userApprovalVerbatim: 'approved by the user', + }); + expect(await mergeApprovalReceiptExists(dir, 2564)).toBe(false); + + writeReceipt(dir, 'merge-approval.json', { + issueNumber: 2564, + prHeadSha: 'short', + finalCriticReviewedCommit: 'short', + userApprovalVerbatim: 'approved by the user', + }); + expect(await mergeApprovalReceiptExists(dir, 2564)).toBe(false); + }); +}); diff --git a/tests/unit/hooks/issue-trace.test.ts b/tests/unit/hooks/issue-trace.test.ts index 1a3c8b647..4479aa291 100644 --- a/tests/unit/hooks/issue-trace.test.ts +++ b/tests/unit/hooks/issue-trace.test.ts @@ -22,6 +22,7 @@ import { resetPhaseStatusCache, } from '../../../src/hooks/issue-trace'; import type { TraceState } from '../../../src/hooks/issue-trace-reducer'; +import { writeV3ReceiptFiles } from './issue-trace-journey-v3-helpers'; // ── Helpers ──────────────────────────────────────────────────────── @@ -134,6 +135,9 @@ function writeResidualBReceipts(dir: string, number = 42): void { issueNumber: number, defectClass: 'no defect class', justification: 'docs-only change corrects no behavior', + relatedProblems: [ + { ref: '#2131', note: 'residual-B gates this receipt feeds' }, + ], timestamp: '2026-01-01T00:00:00Z', }); } @@ -196,6 +200,7 @@ describe('issue-trace hook', () => { writeIssueRef(tmpDir); writeTraceState(tmpDir); writeSpecWithIssue(tmpDir); + writeV3ReceiptFiles(tmpDir); const messages = await runHook(tmpDir); expect(messages).toHaveLength(1); @@ -206,6 +211,7 @@ describe('issue-trace hook', () => { writeIssueRef(tmpDir); writeTraceState(tmpDir); writeSpecWithIssue(tmpDir); + writeV3ReceiptFiles(tmpDir); await runHook(tmpDir); @@ -222,6 +228,7 @@ describe('issue-trace hook', () => { writeIssueRef(tmpDir); writeTraceState(tmpDir); writeSpecWithIssue(tmpDir); + writeV3ReceiptFiles(tmpDir); const logErrorCalls: Array<[string, unknown]> = []; _internals.logError = mock((msg: string, err: unknown) => { @@ -246,6 +253,7 @@ describe('issue-trace hook', () => { writeIssueRef(tmpDir); writeTraceState(tmpDir, { lastTransition: 'ISSUE_INGEST_TO_PLAN' }); writeSpecWithIssue(tmpDir); + writeV3ReceiptFiles(tmpDir); const messages = await runHook(tmpDir); expect(messages).toHaveLength(0); @@ -264,6 +272,7 @@ describe('issue-trace hook', () => { writeIssueRef(tmpDir); writeTraceState(tmpDir); writeSpecWithIssue(tmpDir); + writeV3ReceiptFiles(tmpDir); // An authoritative plan exists with an incomplete phase (so planExists is // true and row g — plan exists, critic pending — applies). _internals.readPlanPhaseStatus = () => @@ -302,6 +311,7 @@ describe('issue-trace hook', () => { writeIssueRef(tmpDir); writeTraceState(tmpDir, { status: 'published' }); writeSpecWithIssue(tmpDir); + writeV3ReceiptFiles(tmpDir); const messages = await runHook(tmpDir); expect(messages).toHaveLength(0); @@ -311,6 +321,7 @@ describe('issue-trace hook', () => { writeIssueRef(tmpDir); writeTraceState(tmpDir); writeSpecWithIssue(tmpDir); + writeV3ReceiptFiles(tmpDir); _internals.isPlanCriticApproved = () => { throw new Error('approval check crashed'); }; @@ -327,6 +338,7 @@ describe('issue-trace hook', () => { writeIssueRef(tmpDir); writeTraceState(tmpDir); writeSpecWithIssue(tmpDir); + writeV3ReceiptFiles(tmpDir); const hook = createIssueTraceHook({}, tmpDir, 100); const output = {}; // no messages array — cannot durably deliver @@ -349,6 +361,7 @@ describe('issue-trace hook', () => { writeIssueRef(tmpDir); writeTraceState(tmpDir, { lastTransition: 'PLAN_TO_EXECUTE' }); writeSpecWithIssue(tmpDir); + writeV3ReceiptFiles(tmpDir); writeJson(tmpDir, 'plan.json', { title: 'test', swarm_id: 'test', @@ -379,6 +392,7 @@ describe('issue-trace hook', () => { status: 'publication_handoff', }); writeSpecWithIssue(tmpDir); + writeV3ReceiptFiles(tmpDir); writeJson(tmpDir, 'issue-publication.json', { published: true, issueNumber: 42, @@ -406,6 +420,7 @@ describe('issue-trace hook', () => { status: 'publication_handoff', }); writeSpecWithIssue(tmpDir); + writeV3ReceiptFiles(tmpDir); const messages = await runHook(tmpDir); expect(messages).toHaveLength(0); @@ -417,6 +432,7 @@ describe('issue-trace hook', () => { writeIssueRef(tmpDir, 42, { noRepro: false }); writeTraceState(tmpDir); writeSpecWithIssue(tmpDir); + writeV3ReceiptFiles(tmpDir); // First drive: one-shot directive + [MODE: ISSUE_INGEST], state → REPRO_GATE. const messages = await runHook(tmpDir); @@ -433,6 +449,7 @@ describe('issue-trace hook', () => { writeIssueRef(tmpDir, 42, { noRepro: false }); writeTraceState(tmpDir); writeSpecWithIssue(tmpDir); + writeV3ReceiptFiles(tmpDir); writeReproReceipt(tmpDir, 42); const messages = await runHook(tmpDir); @@ -444,6 +461,7 @@ describe('issue-trace hook', () => { writeIssueRef(tmpDir, 42, { noRepro: false }); writeTraceState(tmpDir); writeSpecWithIssue(tmpDir); + writeV3ReceiptFiles(tmpDir); writeReproReceipt(tmpDir, 999); // bound to a different issue // The foreign receipt does not satisfy the gate, so the one-shot @@ -470,6 +488,7 @@ describe('issue-trace hook', () => { }); writeTraceState(tmpDir); writeSpecWithIssue(tmpDir); + writeV3ReceiptFiles(tmpDir); const messages = await runHook(tmpDir); expect(messages).toHaveLength(1); diff --git a/tests/unit/tools/record-residual-b-receipts.test.ts b/tests/unit/tools/record-residual-b-receipts.test.ts index d7c65864e..e81d1d07a 100644 --- a/tests/unit/tools/record-residual-b-receipts.test.ts +++ b/tests/unit/tools/record-residual-b-receipts.test.ts @@ -83,6 +83,7 @@ describe('record_recurrence_sweep', () => { description: 'repro-704 init deadline check', proof: 'fails when an init-path await exceeds the deadline', }, + relatedProblems: [{ ref: '#704', note: 'original init hang' }], }, dir, { sessionID: 's1' }, @@ -93,7 +94,11 @@ describe('record_recurrence_sweep', () => { test('"no defect class" fast path requires a justification', async () => { const bad = await executeRecordRecurrenceSweep( - { issueNumber: 42, defectClass: 'no defect class' }, + { + issueNumber: 42, + defectClass: 'no defect class', + relatedProblems: [{ ref: '#2131' }], + }, dir, ); expect(JSON.parse(bad).success).toBe(false); @@ -103,6 +108,7 @@ describe('record_recurrence_sweep', () => { issueNumber: 42, defectClass: 'no defect class', justification: 'docs-only change corrects no behavior', + relatedProblems: [{ ref: '#2131' }], }, dir, ); @@ -112,22 +118,57 @@ describe('record_recurrence_sweep', () => { test('a defect-class sweep without predicates/dispositions/guardrail is rejected', async () => { const result = await executeRecordRecurrenceSweep( - { issueNumber: 42, defectClass: 'some real defect class' }, + { + issueNumber: 42, + defectClass: 'some real defect class', + relatedProblems: [{ ref: '#2131' }], + }, dir, ); expect(JSON.parse(result).success).toBe(false); expect(await recurrenceSweepReceiptExists(dir, 42)).toBe(false); }); - test('receipt is issue-bound', async () => { - await executeRecordRecurrenceSweep( + test('the widened relatedProblems field is required and non-vacuous (issue #2564)', async () => { + const missing = await executeRecordRecurrenceSweep( { - issueNumber: 999, + issueNumber: 42, defectClass: 'no defect class', - justification: 'n/a', + justification: 'docs-only change corrects no behavior', }, dir, ); + expect(JSON.parse(missing).success).toBe(false); + + const empty = await executeRecordRecurrenceSweep( + { + issueNumber: 42, + defectClass: 'no defect class', + justification: 'docs-only change corrects no behavior', + relatedProblems: [], + }, + dir, + ); + expect(JSON.parse(empty).success).toBe(false); + }); + + test('receipt is issue-bound', async () => { + // The fixture itself is valid (v3 shape incl. relatedProblems) so the + // assertion exercises ISSUE binding, not schema rejection: a receipt + // written for issue 999 must never satisfy the gate for issue 42. + const written = JSON.parse( + await executeRecordRecurrenceSweep( + { + issueNumber: 999, + defectClass: 'no defect class', + justification: 'n/a', + relatedProblems: [{ ref: '#2131' }], + }, + dir, + ), + ); + expect(written.success).toBe(true); + expect(await recurrenceSweepReceiptExists(dir, 999)).toBe(true); expect(await recurrenceSweepReceiptExists(dir, 42)).toBe(false); }); }); @@ -210,6 +251,27 @@ describe('trace hook residual-B gates (issue #2131)', () => { path.join(dir, '.swarm', 'spec.md'), '# Spec\n\n## Source Issue\n\n- Number: 42\n', ); + // v3 receipts (issue #2564): Phase 0 freshness + a green per-phase + // validator entry, so the hook's pre-plan and pre-handoff gates stay + // satisfied in these fixtures. + fs.writeFileSync( + path.join(dir, '.swarm', 'branch-freshness.json'), + JSON.stringify({ issueNumber: 42, freshness: 'synced' }), + ); + fs.writeFileSync( + path.join(dir, '.swarm', 'trace-validation.json'), + JSON.stringify({ + issueNumber: 42, + validations: [ + { + phase: '4.6', + outcome: 'pass', + reviewedCommit: '0123456789abcdef0123456789abcdef01234567', + treeId: 'fedcba9876543210fedcba9876543210fedcba98', + }, + ], + }), + ); } test('missing review receipt → one-shot REVIEW_GATE directive; then quiet', async () => { @@ -307,6 +369,7 @@ describe('trace hook residual-B gates (issue #2131)', () => { issueNumber: 42, defectClass: 'no defect class', justification: 'docs-only change', + relatedProblems: [{ ref: '#2131' }], }), ); diff --git a/tests/unit/tools/record-v3-receipts.test.ts b/tests/unit/tools/record-v3-receipts.test.ts new file mode 100644 index 000000000..aad8033a3 --- /dev/null +++ b/tests/unit/tools/record-v3-receipts.test.ts @@ -0,0 +1,264 @@ +/** + * v3 receipt tool executors (issue #2564): record_branch_freshness, + * record_trace_validation, record_merge_approval. Mirrors the frozen + * acceptance-check contracts (repro/C1, C2, C4) as repo-conventional bun:test + * coverage for the producer side. Under 500 lines (FR-006). + */ + +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { executeRecordBranchFreshness } from '../../../src/tools/record-branch-freshness'; +import { executeRecordMergeApproval } from '../../../src/tools/record-merge-approval'; +import { executeRecordTraceValidation } from '../../../src/tools/record-trace-validation'; +import { canonicalMkdtemp } from '../../helpers/tmpdir'; + +const HEX_A = '0123456789abcdef0123456789abcdef01234567'; +const HEX_B = 'fedcba9876543210fedcba9876543210fedcba98'; + +const dirs: string[] = []; +function makeDir(): string { + const dir = canonicalMkdtemp('v3-tools-'); + fs.mkdirSync(path.join(dir, '.git'), { recursive: true }); + fs.mkdirSync(path.join(dir, '.swarm'), { recursive: true }); + dirs.push(dir); + return dir; +} +afterEach(() => { + for (const d of dirs.splice(0)) { + try { + fs.rmSync(d, { recursive: true, force: true }); + } catch { + /* best effort */ + } + } +}); + +describe('record_branch_freshness (issue #2564, v3 Phase 0)', () => { + test('records synced and the receipt file lands under .swarm/', async () => { + const dir = makeDir(); + const out = JSON.parse( + await executeRecordBranchFreshness( + { issueNumber: 2564, freshness: 'synced' }, + dir, + { sessionID: 's1' }, + ), + ); + expect(out.success).toBe(true); + expect(out.permits).toBe(true); + expect( + fs.existsSync(path.join(dir, '.swarm', 'branch-freshness.json')), + ).toBe(true); + }); + + test('records behind and fetch-failed outcomes without inventing permission', async () => { + const dir = makeDir(); + const behind = JSON.parse( + await executeRecordBranchFreshness( + { issueNumber: 2564, freshness: 'behind:2' }, + dir, + ), + ); + expect(behind.success).toBe(true); + expect(behind.permits).toBe(false); + + const bare = JSON.parse( + await executeRecordBranchFreshness( + { issueNumber: 2564, freshness: 'fetch-failed:network-offline' }, + dir, + ), + ); + expect(bare.success).toBe(true); + expect(bare.permits).toBe(false); + + const over = JSON.parse( + await executeRecordBranchFreshness( + { + issueNumber: 2564, + freshness: 'fetch-failed:network-offline', + override: 'user accepted the stale base', + }, + dir, + ), + ); + expect(over.success).toBe(true); + expect(over.permits).toBe(true); + }); + + test('rejects malformed freshness values and pointless overrides', async () => { + const dir = makeDir(); + const bad = JSON.parse( + await executeRecordBranchFreshness( + { issueNumber: 2564, freshness: 'stale' }, + dir, + ), + ); + expect(bad.success).toBe(false); + + const syncedOverride = JSON.parse( + await executeRecordBranchFreshness( + { issueNumber: 2564, freshness: 'synced', override: 'why' }, + dir, + ), + ); + expect(syncedOverride.success).toBe(false); + }); +}); + +describe('record_trace_validation (issue #2564, per-phase validator receipts)', () => { + test('upserts per phase: a fail entry then a pass for the same phase recovers', async () => { + const dir = makeDir(); + const pass0 = JSON.parse( + await executeRecordTraceValidation( + { + issueNumber: 2564, + phase: '0', + outcome: 'pass', + reviewedCommit: HEX_A, + treeId: HEX_B, + }, + dir, + { sessionID: 's1' }, + ), + ); + expect(pass0.success).toBe(true); + expect(pass0.allGreen).toBe(true); + + const fail46 = JSON.parse( + await executeRecordTraceValidation( + { + issueNumber: 2564, + phase: '4.6', + outcome: 'fail', + reviewedCommit: HEX_A, + treeId: HEX_B, + }, + dir, + ), + ); + expect(fail46.success).toBe(true); + expect(fail46.allGreen).toBe(false); + + const pass46 = JSON.parse( + await executeRecordTraceValidation( + { + issueNumber: 2564, + phase: '4.6', + outcome: 'pass', + reviewedCommit: HEX_A, + treeId: HEX_B, + }, + dir, + ), + ); + expect(pass46.allGreen).toBe(true); + + const receipt = JSON.parse( + fs.readFileSync( + path.join(dir, '.swarm', 'trace-validation.json'), + 'utf-8', + ), + ); + expect(receipt.issueNumber).toBe(2564); + expect(receipt.validations).toHaveLength(2); + expect( + receipt.validations.find((v: { phase: string }) => v.phase === '4.6') + .outcome, + ).toBe('pass'); + }); + + test('rejects unknown phases and short SHAs', async () => { + const dir = makeDir(); + const badPhase = JSON.parse( + await executeRecordTraceValidation( + { + issueNumber: 2564, + phase: '9', + outcome: 'pass', + reviewedCommit: HEX_A, + treeId: HEX_B, + }, + dir, + ), + ); + expect(badPhase.success).toBe(false); + + const shortSha = JSON.parse( + await executeRecordTraceValidation( + { + issueNumber: 2564, + phase: '1', + outcome: 'pass', + reviewedCommit: 'abc1234', + treeId: HEX_B, + }, + dir, + ), + ); + expect(shortSha.success).toBe(false); + }); +}); + +describe('record_merge_approval (issue #2564, recorded never certified)', () => { + test('records a bound pair and rejects mismatched SHAs at write time', async () => { + const dir = makeDir(); + const good = JSON.parse( + await executeRecordMergeApproval( + { + issueNumber: 2564, + prHeadSha: HEX_A, + finalCriticReviewedCommit: HEX_A, + userApprovalVerbatim: 'User said: merge it once CI is green', + }, + dir, + { sessionID: 's1' }, + ), + ); + expect(good.success).toBe(true); + expect(fs.existsSync(path.join(dir, '.swarm', 'merge-approval.json'))).toBe( + true, + ); + + const mismatch = JSON.parse( + await executeRecordMergeApproval( + { + issueNumber: 2564, + prHeadSha: HEX_A, + finalCriticReviewedCommit: HEX_B, + userApprovalVerbatim: 'User said: merge it once CI is green', + }, + dir, + ), + ); + expect(mismatch.success).toBe(false); + }); + + test('rejects short SHAs and empty verbatim approvals', async () => { + const dir = makeDir(); + const short = JSON.parse( + await executeRecordMergeApproval( + { + issueNumber: 2564, + prHeadSha: 'abc1234', + finalCriticReviewedCommit: 'abc1234', + userApprovalVerbatim: 'approved', + }, + dir, + ), + ); + expect(short.success).toBe(false); + + const empty = JSON.parse( + await executeRecordMergeApproval( + { + issueNumber: 2564, + prHeadSha: HEX_A, + finalCriticReviewedCommit: HEX_A, + userApprovalVerbatim: '', + }, + dir, + ), + ); + expect(empty.success).toBe(false); + }); +});