From d749946dded217e93601bc4b7c5f1e5f36e8c2d9 Mon Sep 17 00:00:00 2001 From: AnzoBenjamin Date: Wed, 2 Sep 2026 03:48:06 +0300 Subject: [PATCH] feat(gate): bind plan-task completion to gate-issued receipts EXECUTE_PLAN task completion accepted any non-empty checkpoint.receiptIds, so a model-invented string satisfied the rule without a gate ever having passed. Per-task receipts are now minted only by base2's own validation/reviewer gate pass, and update_plan_status verifies the cited ID against gate state. Receipts carry an evidence kind so a gate cycle that produced no reviewable diff is still completable without the receipt overstating what it attests: reviewed-diff, unreviewed-scope, and no-diff. Every kind holds the same fingerprint invariant so verification is uniform, and the ID always derives from the fingerprint base2 computed itself rather than a reviewer-reported one. A non-attestable fingerprint mints nothing. A receipt also stops authorizing completion once the work it covers changes: content verification recomputes each invariant at turn start and again immediately before the mint, and change supersession drops receipts whose files intersect a recorded change plus every non-reviewed-diff receipt. At most one receipt is live per task, and because supersession changes the ID, the live ID is re-printed on every gate pass and surfaced in the pinned active-work block. Closes two fail-open cases in that evidence path: a present-but-non-array planTaskGateReceipts is normalized at hydration and read through one guarded accessor instead of throwing a TypeError inside handleSteps, and the update_plan_status reader treats such a ledger as verification-active-with-no-evidence instead of falling back to the legacy rule. Pointer-only update_plan_status results are recognized via an opt-in success pattern, so releasing a task no longer leaves a stale claim minting receipts for it, while the shared failure-word veto still rejects an unapplied call. The persisted gate-state additions and the new optional gateIssuedReceipts parameter are additive, so older serialized state and existing callers keep their behavior. Validation: agents unit and e2e 1145 pass / 0 fail; plan-execution-state plus update-plan-status handler tests 44 pass / 0 fail; configured hooks green (script:typecheck, typecheck-agents, typecheck-agent-runtime); reviewer gate LOOKS_GOOD for the full 8-file set. --- agents/__tests__/base2.test.ts | 1274 +++++++++++++++++ agents/base2/base2.ts | 544 ++++++- agents/base2/gate-state.ts | 168 +++ common/docs/update-plan-status.md | 34 + .../tool/__tests__/update-plan-status.test.ts | 186 +++ .../tools/handlers/tool/update-plan-status.ts | 53 +- .../__tests__/plan-execution-state.test.ts | 154 ++ .../src/util/plan-execution-state.ts | 40 + 8 files changed, 2441 insertions(+), 12 deletions(-) diff --git a/agents/__tests__/base2.test.ts b/agents/__tests__/base2.test.ts index 36136dac76..97a0125db2 100644 --- a/agents/__tests__/base2.test.ts +++ b/agents/__tests__/base2.test.ts @@ -11291,3 +11291,1277 @@ describe('base2 reviewer skip via the durable receipt ledger', () => { } }) }) + +describe('base2 EXECUTE_PLAN gate-issued plan-task receipts', () => { + /** update_plan_status tool call plus its paired tool result. */ + function planStatusHistory( + input: Record, + result: Record = { message: 'Updated 1 task line(s).' }, + ) { + return [ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'plan-1', + toolName: 'update_plan_status', + input: { + path: '.agents/sessions/demo/PLAN.md', + ...input, + }, + }, + ], + }, + { + role: 'tool', + toolCallId: 'plan-1', + toolName: 'update_plan_status', + content: [{ type: 'json', value: result }], + }, + ] + } + + /** + * Run a turn far enough for the turn-start extraction to publish the claimed + * plan task, then read it back off durable gate state. + */ + function claimedTaskAfterTurn(params: { + messageHistory?: unknown[] + seededActivePlanTaskId?: string + }): string | undefined { + const base2 = createBase2('default', { executePlan: true }) + const agentState: Record = { + agentId: 'base2-execute-plan', + ...(params.messageHistory + ? { messageHistory: params.messageHistory } + : {}), + ...(params.seededActivePlanTaskId + ? { + base2ActiveWork: { + activePlanTaskId: params.seededActivePlanTaskId, + }, + } + : {}), + } + const gen = base2.handleSteps!({ + agentState, + prompt: 'Continue the plan.', + params: {}, + config: base2.programmaticConfig, + } as any) + expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) + return (agentState as any).base2ActiveWork.activePlanTaskId + } + + /** Drive one reviewable edit through validation + code-reviewer to the gate pass. */ + function driveToPlanGatePass(params: { + gateFile: string + agentState: Record + }): { content: string; reviewFingerprint: string } { + const base2 = createBase2('default', { executePlan: true }) + const gen = base2.handleSteps!({ + agentState: params.agentState, + prompt: 'Continue the plan.', + params: {}, + config: base2.programmaticConfig, + } as any) + + expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) + expect(gen.next(feedJson({ status: '' })).value).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + const maybePinned = gen.next().value + if (maybePinned !== 'STEP') { + expect(maybePinned).toMatchObject({ toolName: 'add_message' }) + expect(gen.next().value).toBe('STEP') + } + expect( + gen.next(finishStepWithToolResult(editReceipt(params.gateFile))).value, + ).toMatchObject({ toolName: 'git_status' }) + expect( + gen.next(feedJson({ status: ` M ${params.gateFile}` })).value, + ).toMatchObject({ toolName: 'run_file_change_hooks' }) + expect(gen.next(feedJson([])).value).toMatchObject({ + toolName: 'git_status', + }) + const reviewCall = gen.next(feedJson({ status: ` M ${params.gateFile}` })) + .value as any + expect(reviewCall).toMatchObject({ + toolName: 'spawn_agents', + input: { agents: [{ agent_type: 'code-reviewer' }] }, + }) + const reviewFingerprint = + String(reviewCall.input.agents[0].prompt).match( + /Snapshot fingerprint \(echo exactly\): ([^\n]+)/, + )?.[1] ?? '' + expect( + gen.next(attestedReviewerResult(reviewCall) as any).value, + ).toMatchObject({ toolName: 'git_status' }) + const gatePassed = gen.next(feedJson({ status: ` M ${params.gateFile}` })) + expect(gatePassed.value).toMatchObject({ + toolName: 'add_message', + input: { role: 'user' }, + }) + return { + content: (gatePassed.value as any).input.content as string, + reviewFingerprint, + } + } + + /** + * Same gate pass, but for a NON-reviewable pending file so the final reviewer + * is skipped. That is the only way to reach a gate pass while the snapshot + * fingerprint is non-attestable: a reviewer can never attest one, so the + * spawn path would block on attestation instead of passing. + */ + function driveToGatePassViaReviewerSkip(params: { + gateFile: string + agentState: Record + }): string { + const base2 = createBase2('default', { executePlan: true }) + const gen = base2.handleSteps!({ + agentState: params.agentState, + prompt: 'Continue the plan.', + params: {}, + config: base2.programmaticConfig, + } as any) + + expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) + expect(gen.next(feedJson({ status: '' })).value).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + const maybePinned = gen.next().value + if (maybePinned !== 'STEP') { + expect(maybePinned).toMatchObject({ toolName: 'add_message' }) + expect(gen.next().value).toBe('STEP') + } + expect( + gen.next(finishStepWithToolResult(editReceipt(params.gateFile))).value, + ).toMatchObject({ toolName: 'git_status' }) + expect( + gen.next(feedJson({ status: ` M ${params.gateFile}` })).value, + ).toMatchObject({ toolName: 'run_file_change_hooks' }) + expect(gen.next(feedJson([])).value).toMatchObject({ + toolName: 'git_status', + }) + const reviewerSkip = gen.next(feedJson({ status: ` M ${params.gateFile}` })) + expect(reviewerSkip.value).toMatchObject({ toolName: 'add_message' }) + expect((reviewerSkip.value as any).input.content).toContain( + 'Reviewer gate skipped', + ) + expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) + const gatePassed = gen.next(feedJson({ status: ` M ${params.gateFile}` })) + expect(gatePassed.value).toMatchObject({ + toolName: 'add_message', + input: { role: 'user' }, + }) + return (gatePassed.value as any).input.content as string + } + + function seedIdleGateState( + overrides: Record, + ): Record { + return { + touchedFiles: [], + changedFiles: [], + pendingGateFiles: [], + currentPhase: 'idle', + latestWorkSummary: '', + openReviewerBlockers: [], + lastValidationSummary: '', + nextRequiredAction: '', + lastPinnedStateMessage: '', + ...overrides, + } + } + + /** + * Gate state parked mid-gate on one already-pending file, with the aux gates + * credited so only the FINAL validation + code-reviewer decision runs. Unlike + * seedIdleGateState + a fresh edit, resuming a seeded pending file records NO + * change this turn, which is the only way to observe the mint's idempotent + * same-receiptId branch (a re-recorded change to a covered file supersedes the + * receipt first). + */ + function seedPendingGateState( + gateFile: string, + overrides: Record, + ): Record { + return { + touchedFiles: [gateFile], + changedFiles: [gateFile], + pendingGateFiles: [gateFile], + currentPhase: 'awaiting_validation', + latestWorkSummary: '', + openReviewerBlockers: [], + openReviewerFindings: [], + lastValidationSummary: '', + nextRequiredAction: '', + lastPinnedStateMessage: '', + gatePassedFiles: [], + gatePassedFileMarkers: {}, + gatePassedPendingFiles: [], + gatePassedReviewerVerdict: '', + gatePassedValidationSummary: '', + gatePassedFingerprint: '', + reviewedReviewableFingerprint: '', + lastReviewerGateSkipReason: '', + reviewReceipts: [], + testWriterGateDone: true, + docWriterGateDone: true, + securityReviewGateDone: true, + preEditSecurityReviewDone: true, + specialistReviewGatesDone: [], + auxGatesLastPendingFiles: [gateFile], + ...overrides, + } + } + + /** Resume a seeded pending file through validation + review to the gate pass. */ + function driveSeededPendingGatePass(params: { + gateFile: string + agentState: Record + }): { content: string; reviewFingerprint: string } { + const base2 = createBase2('default', { executePlan: true }) + const gen = base2.handleSteps!({ + agentState: params.agentState, + prompt: 'Continue the plan.', + params: {}, + config: base2.programmaticConfig, + } as any) + + expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) + expect( + gen.next(feedJson({ status: ` M ${params.gateFile}` })).value, + ).toMatchObject({ toolName: 'spawn_agent_inline' }) + const maybePinned = gen.next().value + if (maybePinned !== 'STEP') { + expect(maybePinned).toMatchObject({ toolName: 'add_message' }) + expect(gen.next().value).toBe('STEP') + } + expect(gen.next(finishStepWithToolResult({})).value).toMatchObject({ + toolName: 'git_status', + }) + expect( + gen.next(feedJson({ status: ` M ${params.gateFile}` })).value, + ).toMatchObject({ toolName: 'run_file_change_hooks' }) + expect(gen.next(feedJson([])).value).toMatchObject({ + toolName: 'git_status', + }) + const reviewCall = gen.next(feedJson({ status: ` M ${params.gateFile}` })) + .value as any + expect(reviewCall).toMatchObject({ + toolName: 'spawn_agents', + input: { agents: [{ agent_type: 'code-reviewer' }] }, + }) + const reviewFingerprint = + String(reviewCall.input.agents[0].prompt).match( + /Snapshot fingerprint \(echo exactly\): ([^\n]+)/, + )?.[1] ?? '' + expect( + gen.next(attestedReviewerResult(reviewCall) as any).value, + ).toMatchObject({ toolName: 'git_status' }) + const gatePassed = gen.next(feedJson({ status: ` M ${params.gateFile}` })) + expect(gatePassed.value).toMatchObject({ + toolName: 'add_message', + input: { role: 'user' }, + }) + return { + content: (gatePassed.value as any).input.content as string, + reviewFingerprint, + } + } + + /** Live gate-issued plan-task receipt ledger published on durable gate state. */ + function planTaskReceiptsOf( + agentState: Record, + ): Array> { + return (agentState as any).base2ActiveWork.planTaskGateReceipts as Array< + Record + > + } + + /** + * Drive a turn only as far as the turn-start bookkeeping (hydration, credited + * file eviction, plan-task receipt content verification), which all runs right + * after the first git_status result is fed back. + */ + function driveToTurnStartBookkeeping( + agentState: Record, + status = '', + ): void { + const base2 = createBase2('default', { executePlan: true }) + const gen = base2.handleSteps!({ + agentState, + prompt: 'Continue the plan.', + params: {}, + config: base2.programmaticConfig, + } as any) + expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) + gen.next(feedJson({ status })) + } + + test('an explicit currentTask pointer is normalized to its stable ID token', () => { + // " " is a legitimate pointer shape, and validatePlanTransition + // matches it against a task id by prefix, so the claim must store the ID. + expect( + claimedTaskAfterTurn({ + messageHistory: planStatusHistory({ + currentTask: 'P2-T3 Implement the thing', + updates: [{ taskId: 'P2-T3', status: 'in_progress' }], + }), + }), + ).toBe('P2-T3') + }) + + test('the last in_progress update is claimed when no currentTask is supplied', () => { + expect( + claimedTaskAfterTurn({ + messageHistory: planStatusHistory({ + updates: [ + { taskId: 'P1-T9', status: 'pending' }, + { taskId: 'P2-T3', status: 'in_progress' }, + ], + }), + }), + ).toBe('P2-T3') + }) + + test('an in_progress update falls back to task when taskId is absent', () => { + expect( + claimedTaskAfterTurn({ + messageHistory: planStatusHistory({ + updates: [{ task: 'P4-T2 — do the thing', status: 'in_progress' }], + }), + }), + ).toBe('P4-T2') + }) + + test('a successful empty currentTask clears the claimed task', () => { + // The handler's message for a call that both rewrites a line and empties the + // pointer; the unrelated pending update cannot itself clear P2-T3, so only + // the empty currentTask can. + expect( + claimedTaskAfterTurn({ + seededActivePlanTaskId: 'P2-T3', + messageHistory: planStatusHistory( + { + currentTask: '', + updates: [{ taskId: 'P9-T1', status: 'pending' }], + }, + { + message: 'Updated 1 task line(s). Current task pointer cleared.', + }, + ), + }), + ).toBeUndefined() + }) + + test('a pointer-only clear message clears the claimed task', () => { + // A call that ONLY empties the pointer (`currentTask: ''`, no `updates`) + // returns exactly 'Current task pointer cleared.', which matches none of the + // shared success verbs; the claim tracker must still recognize it, or later + // gate passes keep minting receipts for a released task. + expect( + claimedTaskAfterTurn({ + seededActivePlanTaskId: 'P2-T3', + messageHistory: planStatusHistory( + { currentTask: '' }, + { message: 'Current task pointer cleared.' }, + ), + }), + ).toBeUndefined() + }) + + test('a pointer-only claim message claims the task', () => { + // Same root cause in the other direction: a pointer-only SET returns exactly + // 'Current task -> "".', so without recognizing it the claim is never + // recorded and the gate can never mint a receipt for that task. + expect( + claimedTaskAfterTurn({ + messageHistory: planStatusHistory( + { currentTask: 'P2-T3 Implement the thing' }, + { message: 'Current task -> "P2-T3 Implement the thing".' }, + ), + }), + ).toBe('P2-T3') + }) + + test('a pointer-only clear that applied nothing leaves the claim intact', () => { + // Fail closed: the handler reports an unapplied call with a failure phrase, + // so the opt-in pointer-message pattern must not credit it. + expect( + claimedTaskAfterTurn({ + seededActivePlanTaskId: 'P2-T3', + messageHistory: planStatusHistory( + { currentTask: '' }, + { message: 'No changes applied.' }, + ), + }), + ).toBe('P2-T3') + }) + + test('moving the claimed task to done clears the claim', () => { + expect( + claimedTaskAfterTurn({ + seededActivePlanTaskId: 'P2-T3', + messageHistory: planStatusHistory({ + updates: [{ taskId: 'P2-T3', status: 'done' }], + }), + }), + ).toBeUndefined() + }) + + test('a rejected update_plan_status call never claims a task', () => { + // The runtime handler refuses a plan transition atomically, so a claim it + // never applied must not let the gate mint a receipt for that task. + expect( + claimedTaskAfterTurn({ + messageHistory: planStatusHistory( + { + currentTask: 'P2-T3 Implement the thing', + updates: [{ taskId: 'P2-T3', status: 'in_progress' }], + }, + { + errorMessage: + 'update_plan_status: PLAN transition is atomic; no task matched: P2-T3.', + }, + ), + }), + ).toBeUndefined() + }) + + test('a fresh gate pass mints one receipt and names it in the gate-pass message', () => { + const tmpDir = makeProjectTempDir('base2-plan-gate-receipt-') + try { + const tmpFile = join(tmpDir, 'a.ts') + writeFileSync(tmpFile, 'export const value = 1\n') + const gateFile = normalizeGateFilePath(tmpFile) + const agentState: Record = { + agentId: 'base2-execute-plan', + messageHistory: planStatusHistory({ + currentTask: 'P2-T3 Implement the thing', + updates: [{ taskId: 'P2-T3', status: 'in_progress' }], + }), + } + + const { content, reviewFingerprint } = driveToPlanGatePass({ + gateFile, + agentState, + }) + + // The receipt must be bound to the fingerprint base2 hashed itself for + // this review, never to a reviewer-reported value. + expect(reviewFingerprint).toBe( + buildFingerprint( + [{ file: gateFile, contentMarker: buildContentMarker(tmpFile) }], + '', + ), + ) + const expectedReceiptId = `plan-gate:P2-T3:${reviewFingerprint.slice(0, 16)}` + expect(expectedReceiptId).toMatch(/^plan-gate:P2-T3:v3:[a-f0-9]{13}$/) + + const receipts = (agentState as any).base2ActiveWork + .planTaskGateReceipts as Array> + expect(receipts).toHaveLength(1) + expect(receipts[0]).toMatchObject({ + receiptId: expectedReceiptId, + taskId: 'P2-T3', + evidence: 'reviewed-diff', + snapshotFingerprint: reviewFingerprint, + files: [gateFile], + validationSummary: 'No configured file-change hooks ran.', + reviewerVerdict: 'LOOKS_GOOD', + }) + + expect(content).toContain( + `Plan task P2-T3 gate receipt: ${expectedReceiptId}.`, + ) + expect(content).toContain( + 'Pass this exact string in update_plan_status checkpoint.receiptIds when marking P2-T3 done; do not invent a receipt ID.', + ) + // The evidence sentence is APPENDED after the pinned instruction above, so + // both existing substrings still match unchanged. + expect(content).toContain( + 'Evidence: reviewed diff over 1 file(s). This receipt is superseded when any covered file changes again; re-read the current ID after the next gate pass.', + ) + // The receipt line is an extra LINE inside the existing gate-pass + // message, inserted before the finalization instruction so that + // instruction stays last. + expect(content.indexOf(expectedReceiptId)).toBeLessThan( + content.indexOf( + 'Provide your single user-visible completion summary now', + ), + ) + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + test('a repeat pass on the identical snapshot appends no duplicate receipt', () => { + const tmpDir = makeProjectTempDir('base2-plan-gate-receipt-dedupe-') + try { + const tmpFile = join(tmpDir, 'a.ts') + writeFileSync(tmpFile, 'export const value = 1\n') + const gateFile = normalizeGateFilePath(tmpFile) + const reviewableFingerprint = buildFingerprint( + [{ file: gateFile, contentMarker: buildContentMarker(tmpFile) }], + '', + ) + const seededReceipt = { + receiptId: `plan-gate:P2-T3:${reviewableFingerprint.slice(0, 16)}`, + taskId: 'P2-T3', + evidence: 'reviewed-diff', + snapshotFingerprint: reviewableFingerprint, + files: [gateFile], + validationSummary: 'No configured file-change hooks ran.', + reviewerVerdict: 'LOOKS_GOOD', + recordedAt: '2025-01-01T00:00:00.000Z', + } + const agentState: Record = { + agentId: 'base2-execute-plan', + // Resumed mid-gate rather than re-edited: a fresh edit to a covered file + // is a recorded CHANGE, which supersedes the receipt before the mint + // runs. This path records no change, so the mint hits its idempotent + // same-receiptId branch and must leave the seed (and its recordedAt) + // exactly as it was. + base2ActiveWork: seedPendingGateState(gateFile, { + activePlanTaskId: 'P2-T3', + planTaskGateReceipts: [seededReceipt], + }), + } + + const { content, reviewFingerprint } = driveSeededPendingGatePass({ + gateFile, + agentState, + }) + + expect(reviewFingerprint).toBe(reviewableFingerprint) + expect(planTaskReceiptsOf(agentState)).toEqual([seededReceipt]) + // The content now DOES name the receipt: the ID is printed whenever a live + // receipt exists for the claimed task, not only when this pass minted one, + // because supersession changes the ID and it must stay recoverable. + expect(content).toContain( + `Plan task P2-T3 gate receipt: ${seededReceipt.receiptId}.`, + ) + expect(content).toContain('Evidence: reviewed diff over 1 file(s).') + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + test('with no claimed plan task the gate mints nothing and the pass content is unchanged', () => { + const tmpDir = makeProjectTempDir('base2-plan-gate-receipt-unclaimed-') + try { + const tmpFile = join(tmpDir, 'a.ts') + writeFileSync(tmpFile, 'export const value = 1\n') + const gateFile = normalizeGateFilePath(tmpFile) + const agentState: Record = { + agentId: 'base2-execute-plan', + } + + const { content } = driveToPlanGatePass({ gateFile, agentState }) + + expect( + (agentState as any).base2ActiveWork.activePlanTaskId, + ).toBeUndefined() + // The key is PRESENT (the gate is active) but empty, and that is what + // makes the runtime reject an invented checkpoint receipt. + expect((agentState as any).base2ActiveWork.planTaskGateReceipts).toEqual( + [], + ) + expect(content).not.toContain('gate receipt') + expect(content).toContain( + `Reviewer gate passed with LOOKS_GOOD for pending files: ${gateFile}.`, + ) + expect(content).toContain( + 'Provide your single user-visible completion summary now', + ) + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + test('a non-attestable gate fingerprint mints no receipt (fail closed)', () => { + // Without a collision-resistant hash, hashGateSnapshotDetails returns the + // STABLE 'unreadable:no-crypto' sentinel. That is an error string, not + // content evidence — two unrelated snapshots compare equal under it — so it + // must never become a receipt ID. + const originalGetBuiltinModule = (process as any).getBuiltinModule + const originalRequire = (globalThis as any).require + try { + ;(process as any).getBuiltinModule = undefined + ;(globalThis as any).require = undefined + const agentState: Record = { + agentId: 'base2-execute-plan', + base2ActiveWork: seedIdleGateState({ activePlanTaskId: 'P2-T3' }), + } + + const content = driveToGatePassViaReviewerSkip({ + gateFile: 'docs/plan-notes.md', + agentState, + }) + + expect((agentState as any).base2ActiveWork.planTaskGateReceipts).toEqual( + [], + ) + expect(content).not.toContain('gate receipt') + } finally { + ;(process as any).getBuiltinModule = originalGetBuiltinModule + ;(globalThis as any).require = originalRequire + } + }) + + // R1: a plan task whose gate cycle produced NO reviewable diff must still be + // completable, and its receipt must say so instead of carrying the hash of an + // empty file list while presenting as reviewed-diff evidence. + test('a docs-only gate cycle mints an unreviewed-scope receipt over the validated pending set', () => { + const tmpDir = makeProjectTempDir('base2-plan-gate-receipt-docs-') + try { + const docsFile = join(tmpDir, 'plan-notes.md') + writeFileSync(docsFile, '# Plan notes\n') + const gateFile = normalizeGateFilePath(docsFile) + const agentState: Record = { + agentId: 'base2-execute-plan', + base2ActiveWork: seedIdleGateState({ activePlanTaskId: 'P2-T3' }), + } + + const content = driveToGatePassViaReviewerSkip({ gateFile, agentState }) + + const expectedFingerprint = buildFingerprint( + [{ file: gateFile, contentMarker: buildContentMarker(docsFile) }], + '', + ) + // The whole point of the kind: the fingerprint is the hash of the + // VALIDATED pending set, not of the empty reviewable subset (which is a + // constant and would claim content evidence that does not exist). + expect(expectedFingerprint).not.toBe(buildFingerprint([], '')) + const receipts = planTaskReceiptsOf(agentState) + expect(receipts).toHaveLength(1) + expect(receipts[0]).toMatchObject({ + receiptId: `plan-gate:P2-T3:unreviewed-scope:${expectedFingerprint.slice(0, 16)}`, + taskId: 'P2-T3', + evidence: 'unreviewed-scope', + snapshotFingerprint: expectedFingerprint, + files: [gateFile], + reviewerVerdict: 'LOOKS_GOOD', + }) + expect(receipts[0].receiptId).toMatch( + /^plan-gate:P2-T3:unreviewed-scope:v3:[a-f0-9]{13}$/, + ) + + expect(content).toContain( + `Plan task P2-T3 gate receipt: ${receipts[0].receiptId}.`, + ) + expect(content).toContain( + 'Evidence: no reviewable diff in this gate cycle; validation covered 1 non-reviewable file(s). This receipt is superseded as soon as any further change is recorded.', + ) + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + // R1: zero pending files reaches the same gate-pass emission with + // reviewerFinalizationVerdict EMPTY (passVerdict falls back to LOOKS_GOOD), so + // the 'no-diff' mint must not depend on a verdict being present. + test('a gate pass with no pending files mints a no-diff receipt', () => { + const agentState: Record = { + agentId: 'base2-execute-plan', + base2ActiveWork: seedIdleGateState({ activePlanTaskId: 'P2-T3' }), + } + const base2 = createBase2('default', { executePlan: true }) + const gen = base2.handleSteps!({ + agentState, + prompt: 'Continue the plan.', + params: {}, + config: base2.programmaticConfig, + } as any) + + expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) + expect(gen.next(feedJson({ status: '' })).value).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + const maybePinned = gen.next().value + if (maybePinned !== 'STEP') { + expect(maybePinned).toMatchObject({ toolName: 'add_message' }) + expect(gen.next().value).toBe('STEP') + } + // No edit artifact at all: verification-only work. + expect(gen.next(finishStepWithToolResult({})).value).toMatchObject({ + toolName: 'git_status', + }) + const gatePassed = gen.next(feedJson({ status: '' })) + expect(gatePassed.value).toMatchObject({ + toolName: 'add_message', + input: { role: 'user' }, + }) + const content = (gatePassed.value as any).input.content as string + + const emptyFingerprint = buildFingerprint([], '') + const receipts = planTaskReceiptsOf(agentState) + expect(receipts).toHaveLength(1) + expect(receipts[0]).toMatchObject({ + receiptId: `plan-gate:P2-T3:no-diff:${emptyFingerprint.slice(0, 16)}`, + taskId: 'P2-T3', + evidence: 'no-diff', + snapshotFingerprint: emptyFingerprint, + files: [], + reviewerVerdict: 'LOOKS_GOOD', + }) + expect(receipts[0].receiptId).toMatch( + /^plan-gate:P2-T3:no-diff:v3:[a-f0-9]{13}$/, + ) + expect(content).toContain( + `Plan task P2-T3 gate receipt: ${receipts[0].receiptId}.`, + ) + expect(content).toContain( + 'Evidence: no file changes in this gate cycle. This receipt is superseded as soon as any further change is recorded.', + ) + }) + + // R2 mechanism 1: content verification at turn start. A receipt whose covered + // bytes changed is no longer true, so it must stop authorizing completion even + // though its taskId/receiptId still "match" a checkpoint. + test('turn-start content verification drops a receipt whose covered bytes changed', () => { + const tmpDir = makeProjectTempDir('base2-plan-gate-receipt-stale-') + try { + const tmpFile = join(tmpDir, 'a.ts') + writeFileSync(tmpFile, 'export const value = 1\n') + const gateFile = normalizeGateFilePath(tmpFile) + const staleFingerprint = buildFingerprint( + [{ file: gateFile, contentMarker: buildContentMarker(tmpFile) }], + '', + ) + // The code changed after the receipt was issued. + writeFileSync(tmpFile, 'export const value = 2\n') + const agentState: Record = { + agentId: 'base2-execute-plan', + base2ActiveWork: seedIdleGateState({ + activePlanTaskId: 'P2-T3', + planTaskGateReceipts: [ + { + receiptId: `plan-gate:P2-T3:${staleFingerprint.slice(0, 16)}`, + taskId: 'P2-T3', + evidence: 'reviewed-diff', + snapshotFingerprint: staleFingerprint, + files: [gateFile], + validationSummary: 'No configured file-change hooks ran.', + reviewerVerdict: 'LOOKS_GOOD', + recordedAt: '2025-01-01T00:00:00.000Z', + }, + ], + }), + } + + driveToTurnStartBookkeeping(agentState) + + const activeWork = (agentState as any).base2ActiveWork + expect(activeWork.planTaskGateReceipts).toEqual([]) + // PRUNED, not deleted: presence is what keeps gate-issued verification + // active in the runtime handler, so an invented ID still fails. + expect('planTaskGateReceipts' in activeWork).toBe(true) + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + test('turn-start content verification keeps a receipt whose bytes still match', () => { + const tmpDir = makeProjectTempDir('base2-plan-gate-receipt-fresh-') + try { + const tmpFile = join(tmpDir, 'a.ts') + writeFileSync(tmpFile, 'export const value = 1\n') + const gateFile = normalizeGateFilePath(tmpFile) + const liveReceipt = { + receiptId: `plan-gate:P2-T3:${buildFingerprint( + [{ file: gateFile, contentMarker: buildContentMarker(tmpFile) }], + '', + ).slice(0, 16)}`, + taskId: 'P2-T3', + evidence: 'reviewed-diff', + snapshotFingerprint: buildFingerprint( + [{ file: gateFile, contentMarker: buildContentMarker(tmpFile) }], + '', + ), + files: [gateFile], + validationSummary: 'No configured file-change hooks ran.', + reviewerVerdict: 'LOOKS_GOOD', + recordedAt: '2025-01-01T00:00:00.000Z', + } + const agentState: Record = { + agentId: 'base2-execute-plan', + base2ActiveWork: seedIdleGateState({ + activePlanTaskId: 'P2-T3', + planTaskGateReceipts: [liveReceipt], + }), + } + + driveToTurnStartBookkeeping(agentState) + + expect(planTaskReceiptsOf(agentState)).toEqual([liveReceipt]) + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + // A PRESENT-but-non-array ledger (corrupt or hand-edited serialized state) + // must fail closed rather than throw: the mint's `.some(...)`, the printed + // live-receipt `.find(...)`, and the pinned recovery line all read this key. + test('a non-array planTaskGateReceipts ledger is normalized to an empty array', () => { + const agentState: Record = { + agentId: 'base2-execute-plan', + base2ActiveWork: seedIdleGateState({ + activePlanTaskId: 'P2-T3', + planTaskGateReceipts: { + receiptId: 'plan-gate:P2-T3:v3:0123456789abc', + taskId: 'P2-T3', + }, + }), + } + + driveToTurnStartBookkeeping(agentState) + + const activeWork = (agentState as any).base2ActiveWork + expect(activeWork.planTaskGateReceipts).toEqual([]) + // NORMALIZED, not deleted: presence is what keeps gate-issued verification + // active in the runtime handler, so an invented ID still fails. + expect('planTaskGateReceipts' in activeWork).toBe(true) + }) + + test('a gate pass over a non-array ledger still mints and prints one receipt', () => { + const tmpDir = makeProjectTempDir('base2-plan-gate-receipt-nonarray-') + try { + const tmpFile = join(tmpDir, 'a.ts') + writeFileSync(tmpFile, 'export const value = 1\n') + const gateFile = normalizeGateFilePath(tmpFile) + const agentState: Record = { + agentId: 'base2-execute-plan', + base2ActiveWork: seedIdleGateState({ + activePlanTaskId: 'P2-T3', + // Not an array: the mint's `.some(...)`, the printed receipt's + // `.find(...)`, and the pinned recovery line's `.find(...)` would each + // throw a TypeError and fail the whole turn. + planTaskGateReceipts: 'corrupt', + }), + } + + const { content, reviewFingerprint } = driveToPlanGatePass({ + gateFile, + agentState, + }) + + const expectedReceiptId = `plan-gate:P2-T3:${reviewFingerprint.slice(0, 16)}` + const receipts = planTaskReceiptsOf(agentState) + expect(receipts).toHaveLength(1) + expect(receipts[0]).toMatchObject({ + receiptId: expectedReceiptId, + taskId: 'P2-T3', + evidence: 'reviewed-diff', + files: [gateFile], + }) + expect(content).toContain( + `Plan task P2-T3 gate receipt: ${expectedReceiptId}.`, + ) + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + // R2 mechanism 2: change supersession. A 'no-diff' fingerprint is a constant + // and an 'unreviewed-scope' one attests no review, so content verification can + // never retire them — only supersession can. + test('a recorded change supersedes non-reviewed receipts and intersecting reviewed ones', () => { + const tmpDir = makeProjectTempDir('base2-plan-gate-receipt-supersede-') + try { + const editedFile = join(tmpDir, 'a.ts') + const unrelatedFile = join(tmpDir, 'b.ts') + const docsFile = join(tmpDir, 'notes.md') + writeFileSync(editedFile, 'export const a = 1\n') + writeFileSync(unrelatedFile, 'export const b = 1\n') + writeFileSync(docsFile, '# notes\n') + const editedGateFile = normalizeGateFilePath(editedFile) + const unrelatedGateFile = normalizeGateFilePath(unrelatedFile) + const docsGateFile = normalizeGateFilePath(docsFile) + + const receiptFor = (params: { + taskId: string + evidence: string + files: Array<{ gateFile: string; absolutePath: string }> + }) => { + const fingerprint = buildFingerprint( + params.files.map((file) => ({ + file: file.gateFile, + contentMarker: buildContentMarker(file.absolutePath), + })), + '', + ) + return { + receiptId: `plan-gate:${params.taskId}:${params.evidence === 'reviewed-diff' ? '' : `${params.evidence}:`}${fingerprint.slice(0, 16)}`, + taskId: params.taskId, + evidence: params.evidence, + snapshotFingerprint: fingerprint, + files: params.files.map((file) => file.gateFile), + validationSummary: 'No configured file-change hooks ran.', + reviewerVerdict: 'LOOKS_GOOD', + recordedAt: '2025-01-01T00:00:00.000Z', + } + } + const intersectingReviewed = receiptFor({ + taskId: 'P1-T1', + evidence: 'reviewed-diff', + files: [{ gateFile: editedGateFile, absolutePath: editedFile }], + }) + const unrelatedReviewed = receiptFor({ + taskId: 'P1-T2', + evidence: 'reviewed-diff', + files: [{ gateFile: unrelatedGateFile, absolutePath: unrelatedFile }], + }) + const unreviewedScope = receiptFor({ + taskId: 'P1-T3', + evidence: 'unreviewed-scope', + files: [{ gateFile: docsGateFile, absolutePath: docsFile }], + }) + const noDiff = { + receiptId: `plan-gate:P1-T4:no-diff:${buildFingerprint([], '').slice(0, 16)}`, + taskId: 'P1-T4', + evidence: 'no-diff', + snapshotFingerprint: buildFingerprint([], ''), + files: [] as string[], + validationSummary: 'No configured file-change hooks ran.', + reviewerVerdict: 'LOOKS_GOOD', + recordedAt: '2025-01-01T00:00:00.000Z', + } + const agentState: Record = { + agentId: 'base2-execute-plan', + base2ActiveWork: seedIdleGateState({ + activePlanTaskId: 'P1-T1', + planTaskGateReceipts: [ + intersectingReviewed, + unrelatedReviewed, + unreviewedScope, + noDiff, + ], + }), + } + + const base2 = createBase2('default', { executePlan: true }) + const gen = base2.handleSteps!({ + agentState, + prompt: 'Continue the plan.', + params: {}, + config: base2.programmaticConfig, + } as any) + expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) + expect(gen.next(feedJson({ status: '' })).value).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + // Every seeded receipt survives turn-start content verification, so the + // drops below are attributable to supersession alone. + expect(planTaskReceiptsOf(agentState)).toEqual([ + intersectingReviewed, + unrelatedReviewed, + unreviewedScope, + noDiff, + ]) + const maybePinned = gen.next().value + if (maybePinned !== 'STEP') { + expect(maybePinned).toMatchObject({ toolName: 'add_message' }) + expect(gen.next().value).toBe('STEP') + } + // One recorded change to editedGateFile. + expect( + gen.next(finishStepWithToolResult(editReceipt(editedGateFile))).value, + ).toMatchObject({ toolName: 'git_status' }) + + expect(planTaskReceiptsOf(agentState)).toEqual([unrelatedReviewed]) + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + // One live receipt per task: the printed ID must be unambiguous, so a mint + // REPLACES that task's earlier receipt instead of appending. + test('a second fresh pass for the same task replaces its earlier receipt', () => { + const tmpDir = makeProjectTempDir('base2-plan-gate-receipt-replace-') + try { + const tmpFile = join(tmpDir, 'a.ts') + const unrelatedFile = join(tmpDir, 'b.ts') + writeFileSync(tmpFile, 'export const value = 1\n') + writeFileSync(unrelatedFile, 'export const other = 1\n') + const gateFile = normalizeGateFilePath(tmpFile) + const unrelatedGateFile = normalizeGateFilePath(unrelatedFile) + // Same task, covering a DIFFERENT file whose bytes never change, so it + // survives both content verification and supersession. Only the + // one-live-receipt-per-task replacement can remove it. + const earlierSameTaskFingerprint = buildFingerprint( + [ + { + file: unrelatedGateFile, + contentMarker: buildContentMarker(unrelatedFile), + }, + ], + '', + ) + const earlierSameTaskReceipt = { + receiptId: `plan-gate:P2-T3:${earlierSameTaskFingerprint.slice(0, 16)}`, + taskId: 'P2-T3', + evidence: 'reviewed-diff', + snapshotFingerprint: earlierSameTaskFingerprint, + files: [unrelatedGateFile], + validationSummary: 'No configured file-change hooks ran.', + reviewerVerdict: 'LOOKS_GOOD', + recordedAt: '2025-01-01T00:00:00.000Z', + } + const agentState: Record = { + agentId: 'base2-execute-plan', + base2ActiveWork: seedIdleGateState({ + activePlanTaskId: 'P2-T3', + planTaskGateReceipts: [earlierSameTaskReceipt], + }), + } + + const first = driveToPlanGatePass({ gateFile, agentState }) + const firstReceiptId = `plan-gate:P2-T3:${first.reviewFingerprint.slice(0, 16)}` + expect(firstReceiptId).not.toBe(earlierSameTaskReceipt.receiptId) + expect(planTaskReceiptsOf(agentState)).toHaveLength(1) + expect(planTaskReceiptsOf(agentState)[0].receiptId).toBe(firstReceiptId) + expect(first.content).not.toContain(earlierSameTaskReceipt.receiptId) + + // A later pass over different bytes for the same claimed task likewise + // leaves exactly one receipt, carrying the new ID. + writeFileSync(tmpFile, 'export const value = 2\n') + const second = driveToPlanGatePass({ gateFile, agentState }) + const secondReceiptId = `plan-gate:P2-T3:${second.reviewFingerprint.slice(0, 16)}` + + expect(secondReceiptId).not.toBe(firstReceiptId) + const receipts = planTaskReceiptsOf(agentState) + expect(receipts).toHaveLength(1) + expect(receipts[0]).toMatchObject({ + receiptId: secondReceiptId, + taskId: 'P2-T3', + evidence: 'reviewed-diff', + snapshotFingerprint: second.reviewFingerprint, + files: [gateFile], + }) + expect(second.content).toContain( + `Plan task P2-T3 gate receipt: ${secondReceiptId}.`, + ) + expect(second.content).not.toContain(firstReceiptId) + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + // R3: pinned state survives context compaction, so it is the durable place to + // re-read the live receipt ID after supersession changed it. + test('the pinned active-work message names the live plan-task gate receipt', () => { + const tmpDir = makeProjectTempDir('base2-plan-gate-receipt-pinned-') + try { + const tmpFile = join(tmpDir, 'a.ts') + writeFileSync(tmpFile, 'export const value = 1\n') + const gateFile = normalizeGateFilePath(tmpFile) + const liveFingerprint = buildFingerprint( + [{ file: gateFile, contentMarker: buildContentMarker(tmpFile) }], + '', + ) + const liveReceipt = { + receiptId: `plan-gate:P2-T3:${liveFingerprint.slice(0, 16)}`, + taskId: 'P2-T3', + evidence: 'reviewed-diff', + snapshotFingerprint: liveFingerprint, + files: [gateFile], + validationSummary: 'No configured file-change hooks ran.', + reviewerVerdict: 'LOOKS_GOOD', + recordedAt: '2025-01-01T00:00:00.000Z', + } + + /** Pinned block emitted right after the turn's context-pruner spawn. */ + function pinnedMessageFor(activeWork: Record): string { + const base2 = createBase2('default', { executePlan: true }) + const gen = base2.handleSteps!({ + agentState: { + agentId: 'base2-execute-plan', + base2ActiveWork: activeWork, + }, + prompt: 'Continue the plan.', + params: {}, + config: base2.programmaticConfig, + } as any) + expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) + expect( + gen.next(feedJson({ status: ` M ${gateFile}` })).value, + ).toMatchObject({ toolName: 'spawn_agent_inline' }) + const pinned = gen.next() + expect(pinned.value).toMatchObject({ + toolName: 'add_message', + input: { role: 'user' }, + }) + return (pinned.value as any).input.content as string + } + + expect( + pinnedMessageFor( + seedPendingGateState(gateFile, { + activePlanTaskId: 'P2-T3', + planTaskGateReceipts: [liveReceipt], + }), + ), + ).toContain( + `Live plan-task gate receipt: ${liveReceipt.receiptId} (task P2-T3, evidence reviewed-diff)`, + ) + + // No claimed task: nothing to recover, so the line is omitted entirely. + expect( + pinnedMessageFor( + seedPendingGateState(gateFile, { + planTaskGateReceipts: [liveReceipt], + }), + ), + ).not.toContain('Live plan-task gate receipt:') + + // Claimed task with no live receipt (e.g. it was just superseded). + expect( + pinnedMessageFor( + seedPendingGateState(gateFile, { + activePlanTaskId: 'P2-T3', + planTaskGateReceipts: [], + }), + ), + ).not.toContain('Live plan-task gate receipt:') + + // Claimed task with a PRESENT-but-non-array ledger: the pinned line reads + // this key with `.find(...)`, so it must fail closed (line omitted, turn + // still produces the pinned block) instead of throwing a TypeError. + expect( + pinnedMessageFor( + seedPendingGateState(gateFile, { + activePlanTaskId: 'P2-T3', + planTaskGateReceipts: 'corrupt', + }), + ), + ).not.toContain('Live plan-task gate receipt:') + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + // Presence-vs-absence is load-bearing in the other direction too: with the + // gate disabled no receipt could ever be minted, so publishing the key would + // make every plan task impossible to complete. + test('a no-validation run leaves planTaskGateReceipts absent entirely', () => { + const base2 = createBase2('default', { hasNoValidation: true }) + const agentState: Record = { + agentId: 'base2-no-validation', + messageHistory: planStatusHistory({ + currentTask: 'P2-T3 Implement the thing', + updates: [{ taskId: 'P2-T3', status: 'in_progress' }], + }), + } + const gen = base2.handleSteps!({ + agentState, + prompt: 'Continue the plan.', + params: {}, + config: base2.programmaticConfig, + } as any) + expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) + + const activeWork = (agentState as any).base2ActiveWork + // The task is still tracked; only the receipt ledger stays unpublished. + expect(activeWork.activePlanTaskId).toBe('P2-T3') + expect('planTaskGateReceipts' in activeWork).toBe(false) + }) + + // Same invariant on the resume path: a session that published the key under a + // gate-enabled run must not carry it into a gate-disabled variant, where no + // receipt can ever be minted and every new plan task would become impossible + // to complete. + test('a gate-disabled variant clears an inherited planTaskGateReceipts key', () => { + const seededReceipt = { + receiptId: 'plan-gate:P1-T1:v3:0123456789abc', + taskId: 'P1-T1', + evidence: 'reviewed-diff', + snapshotFingerprint: 'v3:0123456789abc', + files: ['src/a.ts'], + validationSummary: 'No configured file-change hooks ran.', + reviewerVerdict: 'LOOKS_GOOD', + recordedAt: '2025-01-01T00:00:00.000Z', + } + const variants = [ + { + agentId: 'base2-no-validation', + base2: createBase2('default', { hasNoValidation: true }), + passConfig: true, + }, + { + agentId: 'base2-plan', + base2: createBase2('default', { planOnly: true }), + passConfig: true, + }, + { + // base2-fast disables the gate through the agentId fallback, which is + // only consulted when no programmaticConfig is supplied. + agentId: 'base2-fast', + base2: createBase2('fast'), + passConfig: false, + }, + ] + + for (const variant of variants) { + const agentState: Record = { + agentId: variant.agentId, + base2ActiveWork: seedIdleGateState({ + activePlanTaskId: 'P2-T3', + planTaskGateReceipts: [seededReceipt], + }), + } + const gen = variant.base2.handleSteps!({ + agentState, + prompt: 'Continue the plan.', + params: {}, + ...(variant.passConfig + ? { config: variant.base2.programmaticConfig } + : {}), + } as any) + expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) + + const activeWork = (agentState as any).base2ActiveWork + // Absent again, so update_plan_status falls back to the legacy "any + // non-empty receiptIds" rule instead of demanding gate evidence this run + // can never issue. + expect('planTaskGateReceipts' in activeWork).toBe(false) + // The claim is execution tracking, not gate credit, so it stays. + expect(activeWork.activePlanTaskId).toBe('P2-T3') + } + }) + + test('the EXECUTE_PLAN prompts state the gate-issued receipt contract', () => { + const executePlan = createBase2('default', { executePlan: true }) + + expect(executePlan.stepPrompt).toContain( + 'copy the gate-issued receipt ID from the gate-pass message into update_plan_status checkpoint.receiptIds', + ) + expect(executePlan.stepPrompt).toContain( + 'the runtime verifies them against gate state and rejects an unmatched one', + ) + expect(executePlan.instructionsPrompt).toContain( + 'plan-gate::', + ) + expect(executePlan.instructionsPrompt).toContain( + 'never invent a receipt ID', + ) + // Supersession changes the ID, so both prompts must say the model has to + // re-read the NEW one instead of reusing an earlier gate-pass message. + expect(executePlan.instructionsPrompt).toContain( + "A receipt is SUPERSEDED when the task's files change again", + ) + expect(executePlan.instructionsPrompt).toContain( + 'never reuse an ID from an earlier gate-pass message', + ) + expect(executePlan.stepPrompt).toContain( + 'superseded once the files it covers change again', + ) + expect(executePlan.stepPrompt).toContain( + 'never reuse an ID from an earlier gate-pass message', + ) + }) +}) diff --git a/agents/base2/base2.ts b/agents/base2/base2.ts index f20d30a2bb..d3cf798633 100644 --- a/agents/base2/base2.ts +++ b/agents/base2/base2.ts @@ -10,6 +10,7 @@ import { FALLBACK_GUIDES } from '@codebuff/common/util/guides' import type { Base2ActiveWorkPhase, Base2ActiveWorkState, + Base2PlanTaskGateReceipt, Base2WorkflowTodo, Base2WorkflowTodoProgress, Base2ReviewReceipt, @@ -1145,6 +1146,45 @@ ${guideSections} activeWorkState.specialistNoVerdictCounts ??= {} activeWorkState.reviewReceipts ??= [] activeWorkState.auxGatesLastPendingFiles ??= [] + // Gate-issued per-task plan validation receipts, published ONLY when the + // validation/reviewer gate actually runs. The PRESENCE of this key is + // what switches the update_plan_status handler from the legacy "any + // non-empty receiptIds" rule to gate-issued verification, and + // present-and-empty REJECTS (the gate is active but has issued no + // evidence yet). With the gate disabled (hasNoValidation / plan-only / + // base2-fast) no receipt could ever be minted, so publishing an empty + // array there would hard-regress those runs by making every plan task + // impossible to complete — the key must stay ABSENT so they keep the + // legacy behavior. + // + // An INHERITED key must be DELETED for exactly the same reason: a session + // that published it under EXECUTE_PLAN/base2 and later resumes through a + // gate-disabled variant would otherwise restore base2ActiveWork with + // verification still on while no receipt can ever be minted. The + // invariant is "present ⇔ the gate is active for THIS run", not "present ⇔ + // the gate ran at some point in this session". Dropping the stale ledger + // is safe in both directions: the gate-disabled run falls back to the + // legacy rule, and the next fresh gate pass re-mints a receipt for + // whatever task is claimed then. + if (runValidationGate) { + // Normalize rather than `??= []`: a PRESENT-but-non-array ledger (corrupt + // or hand-edited serialized state) is not usable evidence, and `??= []` + // left such a value intact for every reader below — the gate-pass mint's + // `.some(...)`, the printed live-receipt `.find(...)`, and + // buildPinnedActiveWorkMessage's `.find(...)` — which then threw a + // TypeError inside handleSteps and failed the whole turn instead of + // failing closed. Normalizing to an EMPTY array keeps the key PRESENT, so + // the runtime handler still treats gate-issued verification as active and + // rejects a checkpoint citing an unmatched receipt ID; that is exactly the + // fail-closed reading its own readGateIssuedPlanTaskReceipts applies to a + // malformed ledger, and it matches the Array.isArray guards in + // prunePlanTaskGateReceipts / supersedePlanTaskGateReceiptsForChangedFiles. + if (!Array.isArray(activeWorkState.planTaskGateReceipts)) { + activeWorkState.planTaskGateReceipts = [] + } + } else { + delete activeWorkState.planTaskGateReceipts + } // Condoned finding texts: finding texts that a repair-editor has already // reported as addressed via findingsAddressed. When a fresh reviewer // re-returns identical text, the finding is 'condoned' — no longer @@ -1192,6 +1232,12 @@ ${guideSections} activeWorkState.gatePassedPendingFiles, ) updateWorkflowTodoProgressFromMessages(mutableAgentState.messageHistory) + // Track the EXECUTE_PLAN task the model has claimed through + // update_plan_status so the gate-pass path can mint a receipt bound to + // that exact task. Done at turn start as well as post-STEP so a task + // claimed in an earlier turn is already known when this turn's gate + // passes. + updateActivePlanTaskFromMessages(mutableAgentState.messageHistory) // Recognize a user-issued "COMMIT ANYWAY" at turn start (not only in // the post-STEP messageHistory branch) so a git-committer spawned in // the first step of the 'COMMIT ANYWAY' turn already sees the @@ -1378,7 +1424,7 @@ ${guideSections} // uncommittedUnvalidatedFiles publication and any commit-guard evaluation. { const ledgerMarkers = (activeWorkState.gatePassedFileMarkers ??= {}) - let evictedDriftedGatePassedFile = false + const evictedGatePassedFiles: string[] = [] for (const file of Array.from(gatePassedFiles)) { const storedMarker = ledgerMarkers[file] const currentMarker = readGateFileContentMarker(file) @@ -1395,10 +1441,10 @@ ${guideSections} delete ledgerMarkers[file] changedFiles.add(file) pendingGateFiles.add(file) - evictedDriftedGatePassedFile = true + evictedGatePassedFiles.push(file) } } - if (evictedDriftedGatePassedFile) { + if (evictedGatePassedFiles.length > 0) { activeWorkState.pendingGateFiles = Array.from(pendingGateFiles) activeWorkState.gatePassedFiles = Array.from(gatePassedFiles) activeWorkState.currentPhase = 'awaiting_validation' @@ -1406,9 +1452,20 @@ ${guideSections} 'A previously gate-passed file changed after crediting; validation and review were reopened.' editsHappened = true finalResponseGateOpen = false + // An evicted path is back in the pending set, so any gate-issued + // plan-task receipt that covered it must stop authorizing a `done` + // transition (and every receipt with no verifiable content identity + // goes with it — see the helper). + supersedePlanTaskGateReceiptsForChangedFiles(evictedGatePassedFiles) markActiveWorkStateChanged() } } + // Turn-start content verification for the gate-issued plan-task receipt + // ledger. Runs right after the eviction block (and well after hydration, + // which is what publishes/deletes the key) so the ledger the runtime + // handler reads this turn only contains receipts whose covered bytes still + // hash to the fingerprint they were minted with. + prunePlanTaskGateReceipts() // Latest dirty working-tree snapshot for P0 re-arm / P2 pin lag / P3 // unvalidated publication. Starts as the turn-start dirty set and is // refreshed whenever a real mid-turn git_status result is extracted. @@ -1620,6 +1677,7 @@ ${guideSections} if (Array.isArray(messageHistory)) { currentConversationMessages = messageHistory updateWorkflowTodoProgressFromMessages(messageHistory) + updateActivePlanTaskFromMessages(messageHistory) updateCommitScopeBypassFromMessages(messageHistory) processedMessageHistoryLength = messageHistory.length } @@ -5532,6 +5590,13 @@ ${guideSections} } } let activeWorkStateChanged = false + // The live gate-issued plan-task receipt named in the gate-pass + // message below. Assigned only when the FRESH mint site further down + // issues a new receipt; the message falls back to the task's existing + // live receipt, because supersession changes the ID and the model must + // always be able to read the CURRENT one. Kept as a single declaration + // here so the message builder can see it. + let mintedPlanTaskReceipt: Base2PlanTaskGateReceipt | undefined if (passedPendingFiles.length > 0 && reviewerFinalizationVerdict) { // No pinned emission happens between here and the end of the gate, // so a transient 'gate: passed' line could never be rendered. @@ -5617,6 +5682,131 @@ ${guideSections} validationSummary === 'Configured file-change hooks were skipped because none matched the changed files.' const passVerdict = reviewerFinalizationVerdict || 'LOOKS_GOOD' + // Content verification immediately before the mint, so the ledger this + // pass republishes (and the ID it prints) cannot carry a receipt whose + // covered bytes changed earlier in the turn. + prunePlanTaskGateReceipts() + // Gate-issued per-task plan validation receipt. Minted ONLY on this + // FRESH gate-pass emission, and only while a plan task is claimed: + // this is the only path with a live snapshot base2 just hashed itself. + // The conversation-reuse and durable-fingerprint-reuse pass paths above + // `continue` before reaching here and deliberately mint nothing — one + // review would otherwise keep issuing receipts for several different + // tasks across turns. + // + // Three shapes, so a task whose cycle had no reviewable diff is still + // completable WITHOUT the receipt overstating its evidence: + // - reviewable subset non-empty -> 'reviewed-diff' (files = that subset) + // - pending files but none reviewable -> 'unreviewed-scope' (files = validated pending set) + // - no pending files at all -> 'no-diff' (files = []) + // The 'no-diff' shape must NOT depend on a reviewer verdict: that path + // reaches this emission with `reviewerFinalizationVerdict` empty, which + // is exactly why the receipt records `passVerdict`. + // + // INVARIANT for every kind: + // `snapshotFingerprint === hashGateSnapshotDetails(buildGateSnapshotDetails(files, ''))` + // — content only, empty summary component — so verification is uniform. + // For 'reviewed-diff' that value IS `reviewSnapshotFingerprint`, the + // fingerprint base2 computed for THIS review, so the receipt stays bound + // to the exact reviewed bytes. The id is always derived from that + // gate-computed fingerprint, NEVER from a reviewer-REPORTED + // snapshotFingerprint, which the attestation path deliberately + // drift-tolerates and which would therefore make the receipt forgeable. + // Same gate-COMPUTED provenance rule as a review receipt's `gateId`. + // + // A non-attestable fingerprint mints NOTHING for any kind: a stable + // `unreadable:*` marker is an error string, not content evidence, so two + // unrelated snapshots would compare equal under it. + const claimedPlanTaskId = activeWorkState.activePlanTaskId + if ( + typeof claimedPlanTaskId === 'string' && + claimedPlanTaskId.length > 0 + ) { + const receiptEvidence = + reviewableGateScopeFiles.length > 0 + ? 'reviewed-diff' + : passedPendingFiles.length > 0 + ? 'unreviewed-scope' + : 'no-diff' + const receiptFiles = + receiptEvidence === 'reviewed-diff' + ? [...reviewableGateScopeFiles] + : receiptEvidence === 'unreviewed-scope' + ? [...passedPendingFiles] + : [] + const receiptFingerprint = + receiptEvidence === 'reviewed-diff' + ? reviewSnapshotFingerprint + : hashGateSnapshotDetails( + buildGateSnapshotDetails(receiptFiles, ''), + ) + if (isAttestableSnapshotFingerprint(receiptFingerprint)) { + // The evidence kind is part of the id for the two non-reviewed + // kinds, so a receipt that claims no content review can never be + // mistaken for one that does. + const receiptKindSegment = + receiptEvidence === 'reviewed-diff' ? '' : `${receiptEvidence}:` + const planTaskReceiptId = `plan-gate:${claimedPlanTaskId}:${receiptKindSegment}${receiptFingerprint.slice(0, 16)}` + const existingPlanTaskReceipts = readPlanTaskGateReceipts( + activeWorkState.planTaskGateReceipts, + ) + // Idempotent repeat pass: this task already has a live receipt with + // the identical id, i.e. the same evidence, so leave it (and its + // recordedAt) untouched. + if ( + !existingPlanTaskReceipts.some( + (receipt) => + receipt.taskId === claimedPlanTaskId && + receipt.receiptId === planTaskReceiptId, + ) + ) { + mintedPlanTaskReceipt = { + receiptId: planTaskReceiptId, + taskId: claimedPlanTaskId, + evidence: receiptEvidence, + snapshotFingerprint: receiptFingerprint, + files: receiptFiles, + validationSummary, + reviewerVerdict: passVerdict, + recordedAt: new Date().toISOString(), + } + // REPLACE, never append, for this task: exactly one receipt is + // live per task so the printed id is unambiguous. Still bounded + // to the most recent 24 entries over the remaining tasks, the + // same convention as reviewReceipts. + activeWorkState.planTaskGateReceipts = [ + ...existingPlanTaskReceipts.filter( + (receipt) => receipt.taskId !== claimedPlanTaskId, + ), + mintedPlanTaskReceipt, + ].slice(-24) + markActiveWorkStateChanged() + } + } + } + // Printed on every fresh gate-pass emission whenever a live receipt + // exists for the claimed task — not only when this pass minted one — + // because supersession changes the id and the model must always be able + // to read the CURRENT one. Fully omitted with no claim or no live + // receipt, so non-plan gate-pass content stays byte-identical. + const livePlanTaskReceipt = + mintedPlanTaskReceipt ?? + (typeof claimedPlanTaskId === 'string' && + claimedPlanTaskId.length > 0 + ? readPlanTaskGateReceipts( + activeWorkState.planTaskGateReceipts, + ).find((receipt) => receipt.taskId === claimedPlanTaskId) + : undefined) + const planTaskReceiptLines = livePlanTaskReceipt + ? [ + `Plan task ${livePlanTaskReceipt.taskId} gate receipt: ${livePlanTaskReceipt.receiptId}. Pass this exact string in update_plan_status checkpoint.receiptIds when marking ${livePlanTaskReceipt.taskId} done; do not invent a receipt ID.`, + livePlanTaskReceipt.evidence === 'reviewed-diff' + ? `Evidence: reviewed diff over ${livePlanTaskReceipt.files.length} file(s). This receipt is superseded when any covered file changes again; re-read the current ID after the next gate pass.` + : livePlanTaskReceipt.evidence === 'unreviewed-scope' + ? `Evidence: no reviewable diff in this gate cycle; validation covered ${livePlanTaskReceipt.files.length} non-reviewable file(s). This receipt is superseded as soon as any further change is recorded.` + : 'Evidence: no file changes in this gate cycle. This receipt is superseded as soon as any further change is recorded.', + ] + : [] const passDetails = passedPendingFiles.length > 0 ? `reviewer verdict ${passVerdict}; ${validationHooksSkipped ? validationSummary : 'validation hooks ran'}; pending files: ${passedPendingFiles.join(', ')}` @@ -5663,6 +5853,11 @@ ${guideSections} passedPendingFiles.length > 0 ? 'The preceding Change review diff is the user-visible filesystem evidence for this gate. Use /diff for the full current working-tree diff, /changes for the file list, or /diff -- to inspect one file.' : '', + // Only when a live plan-task receipt exists, so the gate-pass + // content stays byte-identical for non-plan turns (prompt/gate + // snapshots and the gate e2e tests pin it). Placed BEFORE the + // finalization notice so that instruction stays last. + ...planTaskReceiptLines, buildGatePassFinalizationNotice(), formatGateStateBlock( 'validation/reviewer', @@ -7737,6 +7932,15 @@ function hashGateSnapshotDetails(details: string): string { activeWorkState.pendingGateFiles.push(file) } } + // Every recorded change supersedes gate-issued plan-task receipts that + // covered a changed path, plus every receipt with no verifiable content + // identity. This deliberately also fires on the status-observation and + // gate re-arm paths: the gate itself re-arms there, so a receipt must + // stop authorizing completion. Guarded on normalizedFiles so a call that + // recorded nothing can never drop a live receipt. + if (normalizedFiles.length > 0) { + supersedePlanTaskGateReceiptsForChangedFiles(normalizedFiles) + } if ( normalizedFiles.length > 0 && (!opts?.fromStatusObservation || discoveredNewPendingFile) @@ -7756,6 +7960,116 @@ function hashGateSnapshotDetails(details: string): string { } } + // Content verification for the gate-issued plan-task receipt ledger. A + // receipt only authorizes a `done` transition while it is still TRUE, so + // recompute each receipt's own invariant + // (`snapshotFingerprint === hash(details(files, ''))`) against the live + // working tree and drop every receipt that no longer holds. Structurally + // invalid entries (older/corrupt serialized state) are dropped too, so a + // malformed ledger can never grant completion. + // + // Mirrors the shape of the credited-file eviction ledger above: iterate, + // drop on mismatch/unattestable/missing, write back once, mark changed + // once. Pruning to an EMPTY array is deliberate and is NOT the same as + // deleting the key: presence keeps gate-issued verification active for the + // runtime handler, which is what makes an invented receipt ID fail. + // + // Inline because handleSteps is serialized via .toString() + + // new Function(...), so it must not reference module-scope imports; it + // reuses the inline buildGateSnapshotDetails / hashGateSnapshotDetails / + // isAttestableSnapshotFingerprint helpers (which resolve node builtins at + // call time) and must stay a hoisted `function` declaration because both + // call sites appear EARLIER in the source than this declaration. + function prunePlanTaskGateReceipts(): void { + const receipts = activeWorkState.planTaskGateReceipts + if (!Array.isArray(receipts) || receipts.length === 0) return + const liveReceipts = receipts.filter((receipt) => { + if (!receipt || typeof receipt !== 'object') return false + if ( + typeof receipt.receiptId !== 'string' || + receipt.receiptId.length === 0 + ) { + return false + } + if ( + typeof receipt.taskId !== 'string' || + receipt.taskId.length === 0 + ) { + return false + } + // A non-array `files` (or a non-string entry) cannot be re-hashed at + // all, so it is not verifiable evidence. + if (!Array.isArray(receipt.files)) return false + if (receipt.files.some((file) => typeof file !== 'string')) { + return false + } + const recomputedFingerprint = hashGateSnapshotDetails( + buildGateSnapshotDetails(receipt.files, ''), + ) + // A non-attestable recomputation is a stable error string, not content + // evidence, so two unrelated snapshots would compare equal under it. + if (!isAttestableSnapshotFingerprint(recomputedFingerprint)) { + return false + } + return recomputedFingerprint === receipt.snapshotFingerprint + }) + if (liveReceipts.length === receipts.length) return + activeWorkState.planTaskGateReceipts = liveReceipts + markActiveWorkStateChanged() + } + + // Change supersession for the gate-issued plan-task receipt ledger: the + // complement of prunePlanTaskGateReceipts. Two drops, both required: + // - every receipt whose covered `files` intersect the changed paths (its + // content evidence no longer describes the workspace); + // - every receipt whose `evidence` is not 'reviewed-diff', because those + // have no verifiable content identity at all — a 'no-diff' receipt's + // fingerprint is the hash of an EMPTY file list, i.e. a constant, so it + // can never fail content verification and supersession is the only + // thing that can retire it. Legacy receipts serialized before + // `evidence` existed fail closed the same way. + // Inline (hoisted `function`) for the same serialization reason as + // prunePlanTaskGateReceipts; reuses the inline normalizeGateFileList so the + // changed paths are compared in the same normalized form the receipts + // store. + function supersedePlanTaskGateReceiptsForChangedFiles( + files: string[], + ): void { + const receipts = activeWorkState.planTaskGateReceipts + if (!Array.isArray(receipts) || receipts.length === 0) return + const changedFilePaths = new Set(normalizeGateFileList(files)) + // Nothing was actually recorded (every path normalized away), so there is + // no change to supersede and a live receipt must not be dropped. + if (changedFilePaths.size === 0) return + const survivingReceipts = receipts.filter((receipt) => { + if (!receipt || receipt.evidence !== 'reviewed-diff') return false + const receiptFiles = Array.isArray(receipt.files) ? receipt.files : [] + return !receiptFiles.some((file) => changedFilePaths.has(file)) + }) + if (survivingReceipts.length === receipts.length) return + activeWorkState.planTaskGateReceipts = survivingReceipts + markActiveWorkStateChanged() + } + + // Single guarded READ of the gate-issued plan-task receipt ledger, shared + // by the three readers that only look at it: the gate-pass mint, the + // printed live-receipt lookup, and buildPinnedActiveWorkMessage's durable + // recovery line. They each used `(... ?? []).some/.find(...)`, which a + // PRESENT-but-non-array ledger turns into a TypeError that fails the whole + // turn. Hydration now normalizes such a value to `[]`, so this is the + // second layer: it keeps all three readers as fail-closed as + // prunePlanTaskGateReceipts / supersedePlanTaskGateReceiptsForChangedFiles + // (both already guard with Array.isArray) so a future writer cannot + // reintroduce the crash. Inline hoisted `function` for the same + // serialization reason as those two: handleSteps is serialized via + // .toString() + new Function(...), so it must not reference module-scope + // imports, and every call site appears EARLIER in the source. + function readPlanTaskGateReceipts( + receipts: Base2PlanTaskGateReceipt[] | undefined, + ): Base2PlanTaskGateReceipt[] { + return Array.isArray(receipts) ? receipts : [] + } + function reviewChallengeFingerprint(files: string[]): string { return hashGateSnapshotDetails(buildGateSnapshotDetails(files, '')) } @@ -8919,6 +9233,24 @@ function hashGateSnapshotDetails(details: string): string { ) { sections.push(`Gate progress: ${state.gateProgressLine}`) } + // Durable recovery surface for the current gate-issued plan-task receipt + // ID. A receipt is superseded when the work it covers changes, so the ID + // printed in an earlier gate-pass message goes stale; the pinned block + // survives context compaction, which makes this the reliable place to + // re-read the live one. Omitted entirely with no claimed task or no + // matching receipt. + const pinnedPlanTaskId = state.activePlanTaskId + const pinnedPlanTaskReceipt = + typeof pinnedPlanTaskId === 'string' && pinnedPlanTaskId.length > 0 + ? readPlanTaskGateReceipts(state.planTaskGateReceipts).find( + (receipt) => receipt.taskId === pinnedPlanTaskId, + ) + : undefined + if (pinnedPlanTaskReceipt) { + sections.push( + `Live plan-task gate receipt: ${pinnedPlanTaskReceipt.receiptId} (task ${pinnedPlanTaskReceipt.taskId}, evidence ${pinnedPlanTaskReceipt.evidence})`, + ) + } if (hasUnresolvedGateWork) { sections.push( 'suggest_followups: BLOCKED — GATE: PENDING. End your turn; call suggest_followups only after GATE: PASSED.', @@ -9018,6 +9350,165 @@ function hashGateSnapshotDetails(details: string): string { if (progressChanged) markActiveWorkStateChanged() } + // EXECUTE_PLAN active-task tracking. The claimed PLAN.md task is what + // binds a gate pass to ONE plan task, so the gate-pass path can mint a + // per-task validation receipt the runtime later verifies a + // `update_plan_status` checkpoint against. Same structure as + // extractLatestWorkflowTodoProgress / updateWorkflowTodoProgressFromMessages + // (walk history for a tool call, pair it with its SUCCESSFUL result, + // derive durable state, write it only on change), including the shared + // toolCallSucceeded result check. Self-contained inline helpers because + // handleSteps is serialized via .toString() + new Function(...), so they + // must not reference module-scope imports; `function` declarations hoist + // above both call sites (turn start and the post-STEP messageHistory + // block), which appear earlier in the source. + // + // Local type aliases so BOTH annotations below stay bracket-free tokens: + // agents/__tests__/helpers/extract-inline-function-source.ts cannot slice + // a return annotation that opens with a leading `|` union (it would emit a + // body-less signature TypeScript erases as an overload, and the helper is + // then missing at runtime). `boundWorkflowProgress` is the same precedent. + type ActivePlanTaskId = string | undefined + type PlanTaskClaimIntent = { + /** Normalized stable ID this call claimed, or '' when it claimed none. */ + claimed: string + /** True when the call explicitly emptied the currentTask pointer. */ + cleared: boolean + /** Normalized stable IDs this call moved to done/cancelled. */ + completed: string[] + } + + // Normalize a raw currentTask / taskId / task pointer to its leading + // stable-ID token: trim, then keep the text before the first whitespace or + // ':' (which also covers the ' — ' form). `"P2-T3 Implement the thing"` + // therefore becomes `"P2-T3"`, mirroring how validatePlanTransition + // matches a currentTask pointer against a task id (`=== id`, + // `startsWith(id + ' ')`, `startsWith(id + ':')`, + // `startsWith(id + ' —')`). Deliberately conservative: splitting on '-' + // would corrupt a legitimate ID such as `P2-T3`. + function normalizePlanTaskPointer(value: unknown): string { + if (typeof value !== 'string') return '' + const trimmed = value.trim() + if (!trimmed) return '' + const separator = trimmed.search(/[\s:]/) + return separator < 0 ? trimmed : trimmed.slice(0, separator) + } + + function extractPlanTaskClaimIntent(input: unknown): PlanTaskClaimIntent { + const intent: PlanTaskClaimIntent = { + claimed: '', + cleared: false, + completed: [], + } + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return intent + } + const record = input as Record + const rawUpdates = Array.isArray(record.updates) ? record.updates : [] + for (const update of rawUpdates) { + if (!update || typeof update !== 'object') continue + const entry = update as Record + const pointer = normalizePlanTaskPointer(entry.taskId ?? entry.task) + if (!pointer) continue + if (entry.status === 'in_progress') { + // LAST in_progress entry wins: at most one task may be in progress, + // so a later entry in the same atomic call supersedes an earlier one. + intent.claimed = pointer + } else if (entry.status === 'done' || entry.status === 'cancelled') { + intent.completed.push(pointer) + } + } + if (typeof record.currentTask === 'string') { + // The explicit pointer wins over a derived in_progress entry, matching + // the handler's own currentTask-then-fallback precedence. + const pointer = normalizePlanTaskPointer(record.currentTask) + if (pointer) { + intent.claimed = pointer + } else if (record.currentTask.trim().length === 0) { + intent.claimed = '' + intent.cleared = true + } + } + return intent + } + + function extractActivePlanTaskIdFromMessages( + messages: unknown, + ): ActivePlanTaskId { + // Seeded from durable state so a task claimed in an earlier turn (or + // before context compaction dropped its tool call) stays claimed until + // a successful call clears it. + let activeTaskId = activeWorkState.activePlanTaskId + if (!Array.isArray(messages)) return activeTaskId + const pendingToolCalls = new Map() + + for (const message of messages) { + if (!message || typeof message !== 'object') continue + const record = message as Record + if (record.role === 'assistant' && Array.isArray(record.content)) { + for (const part of record.content) { + if (!part || typeof part !== 'object') continue + const toolCall = part as Record + if (toolCall.type !== 'tool-call') continue + const toolName = + typeof toolCall.toolName === 'string' ? toolCall.toolName : '' + if (toolName !== 'update_plan_status') continue + const toolCallId = + typeof toolCall.toolCallId === 'string' + ? toolCall.toolCallId + : '' + if (!toolCallId) continue + pendingToolCalls.set( + toolCallId, + extractPlanTaskClaimIntent(toolCall.input), + ) + } + } + + if (record.role !== 'tool') continue + const toolCallId = + typeof record.toolCallId === 'string' ? record.toolCallId : '' + const intent = pendingToolCalls.get(toolCallId) + if (!intent) continue + // Fail closed on a rejected transition: the runtime handler refuses a + // plan update atomically, so a claim it never applied must not let the + // gate mint a receipt for that task. + // + // The handler's own POINTER-only messages are opted in here: a call + // that only manipulates `currentTask` (no `updates`) returns exactly + // `Current task pointer cleared.` or `Current task -> "".`, which + // match none of the shared success verbs. Without this the shared + // predicate rejected the handler's own success message, so a + // pointer-only release left `activePlanTaskId` stale and later gate + // passes kept minting and printing receipts for a RELEASED task — + // contradicting the documented contract in gate-state.ts ("Cleared when + // a successful call empties `currentTask`") — while a pointer-only claim + // was never recorded at all, so no receipt could be minted for it. The + // failure-word veto inside toolCallSucceeded still rejects + // `No changes applied.` and every `errorMessage` refusal. + if (!toolCallSucceeded(record.content, /\bcurrent task\b/i)) continue + if (intent.claimed) { + activeTaskId = intent.claimed + } else if (intent.cleared) { + activeTaskId = undefined + } + // Completing (or cancelling) the claimed task releases the claim, so a + // later gate pass cannot keep minting receipts for a finished task. + if (activeTaskId && intent.completed.includes(activeTaskId)) { + activeTaskId = undefined + } + } + + return activeTaskId + } + + function updateActivePlanTaskFromMessages(messages: unknown): void { + const nextActiveTaskId = extractActivePlanTaskIdFromMessages(messages) + if (activeWorkState.activePlanTaskId === nextActiveTaskId) return + activeWorkState.activePlanTaskId = nextActiveTaskId + markActiveWorkStateChanged() + } + // Detects an exact standalone "COMMIT ANYWAY" user message and publishes // a durable session-scoped bypass flag for the git-committer // uncommitted-unvalidated-files commit guard in the tool executor. Text @@ -9234,13 +9725,33 @@ function hashGateSnapshotDetails(details: string): string { }) } - function toolCallSucceeded(value: unknown): boolean { + // `extraSuccessPattern` is an OPT-IN per-call-site success verb, consulted + // only after the shared failure-word veto below and only when the shared + // verb list did not already match. It exists because that list + // (success|updated|wrote|written|saved) does not cover every handler's own + // success message — see the plan-task claim tracker, whose pointer-only + // `update_plan_status` results are exactly `Current task -> "".` and + // `Current task pointer cleared.` — and broadening the shared list would + // change the verdict for every other consumer (workflow-todo progress + // included). Callers that pass nothing keep the previous behavior + // unchanged. Pass a NON-global pattern: `RegExp.test` is stateful for /g. + function toolCallSucceeded( + value: unknown, + extraSuccessPattern?: RegExp, + ): boolean { if (!value) return false - if (Array.isArray(value)) return value.some(toolCallSucceeded) + // Explicit arrow, never a bare `value.some(toolCallSucceeded)`: `.some` + // passes the element INDEX as the second argument, which would arrive + // here as `extraSuccessPattern` and throw on `.test`. + if (Array.isArray(value)) { + return value.some((item) => + toolCallSucceeded(item, extraSuccessPattern), + ) + } if (typeof value !== 'object') return false const record = value as Record if (record.type === 'json' && 'value' in record) { - return toolCallSucceeded(record.value) + return toolCallSucceeded(record.value, extraSuccessPattern) } if ( record.success === false || @@ -9253,7 +9764,10 @@ function hashGateSnapshotDetails(details: string): string { if (typeof record.message === 'string') { // Only trust the success-verb regex when the message does not itself // contain a failure indicator, otherwise messages like "No updates - // were saved" would false-positive on "saved". + // were saved" would false-positive on "saved". The veto guards + // extraSuccessPattern too, so an opt-in verb can never credit a message + // the handler used to report that nothing was applied (e.g. + // `No changes applied.`). if ( /\b(failed|failure|unable|could not|cannot|did not|was not|were not|skipped|no[- ]op|no changes|error)\b/i.test( record.message, @@ -9261,8 +9775,16 @@ function hashGateSnapshotDetails(details: string): string { ) { return false } - return /\b(success|successful|updated|wrote|written|saved)\b/i.test( - record.message, + if ( + /\b(success|successful|updated|wrote|written|saved)\b/i.test( + record.message, + ) + ) { + return true + } + return ( + extraSuccessPattern !== undefined && + extraSuccessPattern.test(record.message) ) } return Object.keys(record).length > 0 @@ -10202,7 +10724,7 @@ function buildExecutePlanInstructionsPrompt(params: { '## Durable plan execution mode', '', 'You are in EXECUTE_PLAN mode. Your job is to execute or resume durable plan artifacts, not merely revise them. Treat durable artifact contents already provided in the conversation as the initial authoritative context; read artifacts directly only when their contents are missing, truncated, stale, or have changed. Continue from the next actionable milestone, and use normal project source editing tools when implementation work is required.', - 'Run the plan preflight before editing. Tasks should have stable IDs, dependencies, Acceptance criteria, and Validate gates. Claim exactly one actionable task by moving it to in_progress and recording its stable ID as currentTask. A task may move to done only after its validation gate passes; record validation/review evidence as a checkpoint. If preflight fails, repair the durable plan before implementation. Use STATE.json revisions to avoid overwriting newer execution state.', + 'Run the plan preflight before editing. Tasks should have stable IDs, dependencies, Acceptance criteria, and Validate gates. Claim exactly one actionable task by moving it to in_progress and recording its stable ID as currentTask. A task may move to done only after its validation gate passes; record validation/review evidence as a checkpoint. That checkpoint must cite the gate-issued receipt ID printed in the gate-pass message (shaped `plan-gate::`, or `plan-gate::unreviewed-scope:` / `plan-gate::no-diff:` when the cycle had no reviewable diff) in checkpoint.receiptIds; never invent a receipt ID, because the runtime verifies it against gate state and rejects an ID that matches no gate-issued receipt for that task. A receipt is SUPERSEDED when the task\'s files change again, so after further edits you must let the gate close again and copy the NEW ID from the newest gate-pass message (or the pinned harness state); never reuse an ID from an earlier gate-pass message. If preflight fails, repair the durable plan before implementation. Use STATE.json revisions to avoid overwriting newer execution state.', '', 'Keep STATUS.md and LESSONS.md current throughout execution. Prefer update_plan_status for incremental STATUS.md / LESSONS.md updates; use create_plan for SPEC.md / PLAN.md revisions, substantial rewrites, or creating missing artifacts. PLAN mode remains plan-only, but EXECUTE_PLAN is allowed to edit project source to complete the plan. Do not let plan artifacts drift behind actual implementation state.', ].join('\n') @@ -10253,7 +10775,7 @@ function buildExecutePlanStepPrompt({}: {}) { 'You are in EXECUTE_PLAN mode. Execute or resume durable plan artifacts, using the project source editing tools when implementation work is required. Unlike PLAN mode, you may edit project source files to complete planned tasks.', 'Treat SPEC.md, PLAN.md, STATUS.md, and LESSONS.md under the durable plan session as authoritative. Use any artifact contents already present in the conversation as the initial source of truth, confirm the next incomplete or blocked item from that context, and read artifacts directly only when contents are missing, truncated, stale, or have changed. Do not repeatedly re-read unchanged artifacts or source files after confirming the next item; continue from it unless the artifacts say completed work must be revisited.', 'Honor the deterministic preflight included with resumed artifacts. Do not edit source when preflight reports errors. Use stable task IDs for updates, keep at most one task in_progress, respect dependencies, and do not mark a task done until its Validate gate passes and the checkpoint is recorded.', - 'Completing one plan task and passing its validation gate is not the end of the turn: claim the next actionable task and keep executing in this same turn. This does not relax the at-most-one-task-in_progress rule above — advance through the tasks sequentially, one in_progress at a time, never claiming several at once. If you stop before the plan is complete, say so explicitly and state the reason, naming the task ID you reached and what remains.', + 'Completing one plan task and passing its validation gate is not the end of the turn: claim the next actionable task and keep executing in this same turn. This does not relax the at-most-one-task-in_progress rule above — advance through the tasks sequentially, one in_progress at a time, never claiming several at once. After a claimed task passes its validation/reviewer gate, copy the gate-issued receipt ID from the gate-pass message into update_plan_status checkpoint.receiptIds, mark that task done, then claim the next one. Receipt IDs must never be invented: the runtime verifies them against gate state and rejects an unmatched one, so a fabricated ID fails the transition instead of completing the task. A receipt is also superseded once the files it covers change again (and a `plan-gate::unreviewed-scope:...` / `plan-gate::no-diff:...` receipt as soon as any further change is recorded), so if you edit more after a gate pass you must let the gate close again and copy the NEW ID; never reuse an ID from an earlier gate-pass message. The live ID is also repeated in the pinned harness state. If you stop before the plan is complete, say so explicitly and state the reason, naming the task ID you reached and what remains.', 'Keep STATUS.md current as you progress: update completed/pending/blocked items, current state, validation results, and the next checkpoint. Keep LESSONS.md current with gotchas, decisions, reusable findings, and follow-up notes discovered during execution. Prefer update_plan_status for incremental STATUS.md / LESSONS.md updates; use create_plan for SPEC.md / PLAN.md revisions, substantial rewrites, or creating missing artifacts.', 'Use normal implementation behavior for source changes: gather context before editing, follow project conventions, validate meaningful changes when appropriate, and summarize the completed work concisely. Do not let plan artifacts drift behind actual implementation state.', ).join('\n') diff --git a/agents/base2/gate-state.ts b/agents/base2/gate-state.ts index 94cd7c0e9d..c4957f8085 100644 --- a/agents/base2/gate-state.ts +++ b/agents/base2/gate-state.ts @@ -62,6 +62,117 @@ export type Base2ReviewReceipt = { recordedAt: string } +/** + * Gate-issued per-task validation receipt for one EXECUTE_PLAN plan task. + * + * This is the evidence `update_plan_status` verifies a `checkpoint.receiptIds` + * entry against before a PLAN.md task may move to `done`. The runtime's + * `validatePlanTransition` already refused a `done` transition without a passed + * validation checkpoint carrying at least one receipt ID, but those IDs were + * entirely model-supplied, so an invented string satisfied the rule. A receipt + * here is minted ONLY by base2's own fresh validation/reviewer gate pass, which + * is what ties task completion to real gate evidence. + * + * EVIDENCE KINDS. A plan task whose gate cycle produced no reviewable diff must + * still be completable, and its receipt must not claim content evidence it does + * not have, so `evidence` records exactly what the cycle covered: + * - `'reviewed-diff'`: the reviewable subset the reviewer attested; `files` is + * that subset. + * - `'unreviewed-scope'`: pending files existed but NONE of them were + * reviewable (docs-only / `.md` / `.agents/`), so the reviewer was skipped. + * `files` is the VALIDATED pending set and the receipt claims no content + * review. Without this kind the mint produced a receipt whose fingerprint + * was the hash of an EMPTY file list — a constant — while presenting as + * reviewed-diff evidence. + * - `'no-diff'`: the cycle had no pending files at all (work that is pure + * verification, or whose only output is a non-reviewable artifact). `files` + * is empty, so this fingerprint is a CONSTANT by construction. + * + * `files` is the gate-covered set this receipt attests — for + * `'unreviewed-scope'` that is the validated pending set, not a reviewed subset. + * The invariant that makes verification uniform across all three kinds is + * `snapshotFingerprint === hashGateSnapshotDetails(buildGateSnapshotDetails(files, ''))`: + * content only, with an EMPTY summary component. For `'reviewed-diff'` that is + * exactly the reviewable-set fingerprint base2 computed for the review. + * + * Why `receiptId` is GATE-COMPUTED rather than reviewer-reported: it embeds the + * prefix of the fingerprint base2 hashed itself, the same provenance rule that + * makes `Base2ReviewReceipt.gateId` trustworthy. A reviewer-REPORTED + * `snapshotFingerprint` is deliberately drift-tolerated by the attestation path, + * so deriving the receipt from it would let a reviewer (or a model quoting one) + * choose its own receipt ID and forge completion evidence. A non-attestable + * fingerprint (a stable `unreadable:*` marker) is an error string rather than + * content evidence and never mints a receipt, for any kind. + * + * LIFETIME. At most ONE receipt is live per `taskId`: a newly minted receipt + * REPLACES the task's previous one instead of appending, so the printed ID is + * unambiguous. Two complementary mechanisms retire a receipt that has stopped + * being true, keeping the published ledger to receipts that hold right now: + * 1. Content verification (`prunePlanTaskGateReceipts` in base2.ts, at turn + * start and immediately before the mint): recompute + * `hashGateSnapshotDetails(buildGateSnapshotDetails(files, ''))` and drop + * the receipt unless the recomputation is attestable AND equal to + * `snapshotFingerprint`. Structurally invalid entries are dropped too. + * 2. Change supersession (`supersedePlanTaskGateReceiptsForChangedFiles` in + * base2.ts, called from `recordChangedFiles` and from the credited-file + * eviction ledger): drop every receipt whose `files` intersect the changed + * paths, plus EVERY receipt whose `evidence` is not `'reviewed-diff'` — + * those have no verifiable content identity (a `'no-diff'` fingerprint is a + * constant and can never fail verification), so only supersession can + * retire them. Legacy receipts serialized before `evidence` existed are + * retired the same way (fail closed). + * + * BOUND ON THE GUARANTEE: the runtime handler reads the LIVE + * `agentState.base2ActiveWork` during a step, so the ledger it sees is whatever + * base2 wrote at the last gate pass. A model that edits files and marks the task + * done inside the SAME step is therefore still outside supersession's reach. + * The property is "this receipt was true as of the last gate pass", not an + * airtight proof at the moment of the transition. + * + * PRODUCTION READERS (no field here is documented-but-unread): + * - base2.ts `prunePlanTaskGateReceipts` reads `receiptId`, `taskId`, `files`, + * and `snapshotFingerprint`; + * - base2.ts `supersedePlanTaskGateReceiptsForChangedFiles` reads `evidence` + * and `files`; + * - base2.ts's gate-pass mint site reads `taskId` (one live receipt per task) + * and `receiptId` (an identical ID is the idempotent repeat pass and is left + * untouched rather than churning `recordedAt`); + * - base2.ts's gate-pass `add_message` reads `taskId`, `receiptId`, + * `evidence`, and `files.length` for the printed evidence sentence; + * - base2.ts `buildPinnedActiveWorkMessage` reads `receiptId`, `taskId`, and + * `evidence` for the durable recovery line (pinned state survives context + * compaction, which is what makes a superseded ID recoverable); + * - the runtime `update_plan_status` handler's + * `readGateIssuedPlanTaskReceipts` reads `receiptId` and `taskId`, and + * `validatePlanTransition` matches them against `checkpoint.receiptIds` and + * lists the live IDs for the task when it rejects. + * `validationSummary`, `reviewerVerdict`, and `recordedAt` are durable audit + * fields surfaced through gate state itself; no decision branches on them. + */ +export type Base2PlanTaskGateReceipt = { + /** + * Gate-issued receipt id, derived from the fingerprint base2 computed itself: + * `plan-gate::` for `'reviewed-diff'`, + * `plan-gate::unreviewed-scope:`, or + * `plan-gate::no-diff:`, where `` is the first 16 chars of + * `snapshotFingerprint`. The kind is part of the id for the two non-reviewed + * kinds so a receipt that claims no content review can never be mistaken for + * one that does. + */ + receiptId: string + /** Stable PLAN.md task ID this gate cycle covered. */ + taskId: string + /** What the gate cycle actually covered; see the docblock above. */ + evidence: 'reviewed-diff' | 'unreviewed-scope' | 'no-diff' + /** Always `hashGateSnapshotDetails(buildGateSnapshotDetails(files, ''))`. */ + snapshotFingerprint: string + /** The gate-covered set this receipt attests (empty for `'no-diff'`). */ + files: string[] + validationSummary: string + reviewerVerdict: string + recordedAt: string +} + // Typed runtime-owned gate state. Field names are kept identical to the // historical Base2ActiveWorkState shape so existing serialized // base2ActiveWork objects keep round-tripping. The new @@ -348,6 +459,63 @@ export type Base2ActiveWorkState = Base2GateState & { specialistNoVerdictCounts?: Record /** Compact source-backed receipts from successful reviewer passes. */ reviewReceipts?: Base2ReviewReceipt[] + /** + * Stable PLAN.md task ID currently claimed by the model, extracted from + * successful `update_plan_status` tool calls in message history (the + * `currentTask` pointer, else the last `updates` entry moved to + * `in_progress`). Normalized to its leading stable-ID token, so + * `"P2-T3 Implement the thing"` is stored as `"P2-T3"` — the same form + * `validatePlanTransition` matches a `currentTask` pointer against a task id. + * Cleared when a successful call empties `currentTask` or moves the claimed + * task to `done`/`cancelled`. "Successful" includes the handler's POINTER-only + * messages (`Current task -> "".` / `Current task pointer cleared.`), + * which carry none of the shared success verbs and are opted in explicitly at + * the extraction site. Execution-tracking state, NOT gate credit, + * which is why it lives here and not on `Base2GateState`. + * Backward-compatible: older serialized state lacks it (treated as no claim). + */ + activePlanTaskId?: string + /** + * Gate-issued per-task validation receipts (see `Base2PlanTaskGateReceipt`), + * written only on base2's FRESH validation/reviewer gate-pass path while a + * plan task is claimed. MUST stay a plain JSON-serializable array (never a + * Map/Set) and is bounded to the most recent 24 entries at every write site, + * the same convention as `reviewReceipts`, so durable state cannot grow + * without bound across a long plan run. base2's hydration ENFORCES that + * shape: a present-but-non-array value (corrupt or hand-edited serialized + * state) is normalized to an EMPTY array instead of being left intact, so + * every reader fails closed instead of throwing a TypeError mid-turn while + * the key stays PRESENT and gate-issued verification stays active (see + * below). + * + * This is a ledger of receipts that are TRUE RIGHT NOW, not an append-only + * history: at most one receipt is live per task (a new mint replaces that + * task's previous one over the remaining entries), content verification drops + * any receipt whose covered bytes no longer hash to its `snapshotFingerprint`, + * and change supersession drops receipts a recorded change invalidated. Both + * mechanisms PRUNE the array; neither ever deletes the key, because presence + * is what keeps verification active (see below). + * + * Its PRESENCE is the signal that gate-issued verification is active: the + * `update_plan_status` handler forwards this array to + * `validatePlanTransition`, which then requires the checkpoint to cite a + * gate-issued receipt ID for the task being completed. A PRESENT array — + * including an EMPTY one — rejects (the gate is active but has issued no + * evidence yet). When the key is ABSENT the handler falls back to the + * pre-existing "any non-empty receiptIds" rule, which is what keeps non-base2 + * agents and a base2 run with the validation gate disabled + * (`hasNoValidation` / plan-only, where no receipt could ever be minted) able + * to complete plan tasks. base2 therefore initializes this key only when the + * gate actually runs, and DELETES an inherited key on a gate-disabled turn: + * the invariant is "present ⇔ the gate is active for THIS run", not "present ⇔ + * the gate ran at some point in this session". Without that deletion a session + * that published the key under EXECUTE_PLAN/base2 and later resumed through a + * gate-disabled variant would restore verification with no way to mint + * evidence, making every new plan task impossible to move to `done`. Dropping + * the stale ledger is safe: the next fresh gate pass re-mints a receipt for + * whatever task is claimed then. + */ + planTaskGateReceipts?: Base2PlanTaskGateReceipt[] /** * M3 (R1d) — snapshot of the pendingGateFiles used to detect that the * pending gate file set has changed, so the three aux-gate done-flags above diff --git a/common/docs/update-plan-status.md b/common/docs/update-plan-status.md index d3754835e1..b1d18aff3a 100644 --- a/common/docs/update-plan-status.md +++ b/common/docs/update-plan-status.md @@ -86,6 +86,40 @@ task requires a passed validation checkpoint with `receiptIds`. | `summary` | string, optional | Short human-readable summary. | | `receiptIds` | array of non-empty strings, optional | Supporting receipt IDs. An empty array is treated as absent. | +When the caller's agent state carries gate-issued receipts — base2 publishes +`planTaskGateReceipts` in `base2ActiveWork` while its automated validation/reviewer +gate is active — `receiptIds` must cite at least one of those receipts whose task ID +matches the task being completed. Gate-issued receipt IDs are printed in the gate-pass +message and come in three shapes, one per evidence kind: + +- `plan-gate::` — a reviewable diff was reviewed. +- `plan-gate::unreviewed-scope:` — pending files existed but + none of them were reviewable (docs-only, `.md`, `.agents/`), so validation covered + them and the reviewer was skipped. +- `plan-gate::no-diff:` — the cycle had no file changes at + all (verification-only work). + +A task whose gate cycle had no reviewable diff is therefore still completable, via an +`unreviewed-scope` or `no-diff` receipt that records exactly what was covered instead +of implying a content review that never happened. Arbitrary strings are rejected, and +a present-but-empty receipt list means the gate has issued no evidence for any task +yet, so completion is refused. + +Receipts are superseded rather than permanent. A receipt stops authorizing completion +once the files it covers change again, and an `unreviewed-scope` / `no-diff` receipt — +which has no verifiable content identity — is superseded as soon as any further change +is recorded. After more edits, let the validation/reviewer gate close again and cite +the NEW ID from the newest gate-pass message; the current live ID is also repeated in +the pinned harness state, which survives context compaction. When the check rejects, +the error lists the receipt IDs that are live for that task, or reports that none is. + +A published ledger that is present but malformed (not an array, or entries without a +string `receiptId`/`taskId`) fails closed: verification stays active with no usable +evidence, so the completion is refused instead of falling back to the legacy rule. +Callers with no gate-issued receipts at all (a non-base2 agent, or a base2 run with the +gate disabled) keep the previous behavior: any non-empty `receiptIds` satisfies the +check. + ## Usage example ```json diff --git a/packages/agent-runtime/src/tools/handlers/tool/__tests__/update-plan-status.test.ts b/packages/agent-runtime/src/tools/handlers/tool/__tests__/update-plan-status.test.ts index db14eda647..3273711f18 100644 --- a/packages/agent-runtime/src/tools/handlers/tool/__tests__/update-plan-status.test.ts +++ b/packages/agent-runtime/src/tools/handlers/tool/__tests__/update-plan-status.test.ts @@ -12,6 +12,7 @@ import { import type { CodebuffToolCall } from '@codebuff/common/tools/list' import type { Logger } from '@codebuff/common/types/contracts/logger' +import type { AgentState } from '@codebuff/common/types/session-state' const silentLogger: Logger = { debug: () => undefined, @@ -489,4 +490,189 @@ describe('handleUpdatePlanStatus', () => { const value = result.output[0].value as { errorMessage?: string } expect(value.errorMessage).toMatch(/unknown sessionStatus/) }) + + // The gate-issued receipts are read out of agentState.base2ActiveWork, which + // base2 publishes only while its validation/reviewer gate is active. Same PLAN + // content and same checkpoint shape in both cases: only the cited receipt ID + // differs, so these pin that the handler forwards the real evidence rather + // than trusting the model-supplied ID. + const PLAN_WITH_CLAIMED_TASK = [ + '# Plan', + '', + '- [~] P1-T1 Implement the thing', + ' - Acceptance: observable result', + ' - Validate: bun test', + '', + ].join('\n') + const GATE_RECEIPT_ID = `plan-gate:P1-T1:v3:${'a'.repeat(13)}` + + function agentStateWithGateReceipts( + planTaskGateReceipts: unknown, + ): AgentState { + // Only the fields this handler reads; the runtime supplies the full state. + return { + base2ActiveWork: { planTaskGateReceipts }, + } as unknown as AgentState + } + + test('completes a plan task when the checkpoint cites a gate-issued receipt', async () => { + const planPath = path.join(tempDir, '.agents/sessions/demo/PLAN.md') + fs.writeFileSync(planPath, PLAN_WITH_CLAIMED_TASK) + + const result = await handleUpdatePlanStatus({ + previousToolCallFinished: Promise.resolve(), + toolCall: makeCall({ + path: '.agents/sessions/demo/PLAN.md', + updates: [{ taskId: 'P1-T1', status: 'done' }], + checkpoint: { + taskId: 'P1-T1', + phase: 'validation', + passed: true, + receiptIds: [GATE_RECEIPT_ID], + }, + }), + agentState: agentStateWithGateReceipts([ + { + receiptId: GATE_RECEIPT_ID, + taskId: 'P1-T1', + snapshotFingerprint: `v3:${'a'.repeat(64)}`, + files: ['src/a.ts'], + validationSummary: 'validation hooks ran', + reviewerVerdict: 'LOOKS_GOOD', + recordedAt: '2025-01-01T00:00:00.000Z', + }, + ]), + logger: silentLogger, + }) + + const value = result.output[0].value as { message?: string } + expect(value.message).toMatch(/Updated 1 task line/) + expect(fs.readFileSync(planPath, 'utf8')).toContain( + '- [x] P1-T1 Implement the thing', + ) + }) + + test('rejects an invented receipt ID against the published gate-issued receipts', async () => { + const planPath = path.join(tempDir, '.agents/sessions/demo/PLAN.md') + fs.writeFileSync(planPath, PLAN_WITH_CLAIMED_TASK) + + const result = await handleUpdatePlanStatus({ + previousToolCallFinished: Promise.resolve(), + toolCall: makeCall({ + path: '.agents/sessions/demo/PLAN.md', + updates: [{ taskId: 'P1-T1', status: 'done' }], + checkpoint: { + taskId: 'P1-T1', + phase: 'validation', + passed: true, + receiptIds: ['validation-1'], + }, + }), + agentState: agentStateWithGateReceipts([ + { + receiptId: GATE_RECEIPT_ID, + taskId: 'P1-T1', + snapshotFingerprint: `v3:${'a'.repeat(64)}`, + files: ['src/a.ts'], + validationSummary: 'validation hooks ran', + reviewerVerdict: 'LOOKS_GOOD', + recordedAt: '2025-01-01T00:00:00.000Z', + }, + ]), + logger: silentLogger, + }) + + const value = result.output[0].value as { errorMessage?: string } + expect(value.errorMessage).toMatch(/must cite a gate-issued receipt ID/) + // Atomic: the artifact is untouched when the transition is refused. + expect(fs.readFileSync(planPath, 'utf8')).toBe(PLAN_WITH_CLAIMED_TASK) + }) + + test('keeps the legacy receipt rule when no gate-issued receipts are published', async () => { + const planPath = path.join(tempDir, '.agents/sessions/demo/PLAN.md') + fs.writeFileSync(planPath, PLAN_WITH_CLAIMED_TASK) + + const result = await handleUpdatePlanStatus({ + previousToolCallFinished: Promise.resolve(), + toolCall: makeCall({ + path: '.agents/sessions/demo/PLAN.md', + updates: [{ taskId: 'P1-T1', status: 'done' }], + checkpoint: { + taskId: 'P1-T1', + phase: 'validation', + passed: true, + receiptIds: ['validation-1'], + }, + }), + // planTaskGateReceipts absent (gate disabled / non-base2 caller). + agentState: agentStateWithGateReceipts(undefined), + logger: silentLogger, + }) + + const value = result.output[0].value as { message?: string } + expect(value.message).toMatch(/Updated 1 task line/) + expect(fs.readFileSync(planPath, 'utf8')).toContain( + '- [x] P1-T1 Implement the thing', + ) + }) + + // A PRESENT ledger means verification is ACTIVE, so a malformed one must fail + // closed instead of silently reopening the legacy "any non-empty receiptIds" + // rule. Only a genuinely absent key may fall back. + test('a present-but-non-array planTaskGateReceipts fails closed', async () => { + const planPath = path.join(tempDir, '.agents/sessions/demo/PLAN.md') + fs.writeFileSync(planPath, PLAN_WITH_CLAIMED_TASK) + + const result = await handleUpdatePlanStatus({ + previousToolCallFinished: Promise.resolve(), + toolCall: makeCall({ + path: '.agents/sessions/demo/PLAN.md', + updates: [{ taskId: 'P1-T1', status: 'done' }], + checkpoint: { + taskId: 'P1-T1', + phase: 'validation', + passed: true, + receiptIds: [GATE_RECEIPT_ID], + }, + }), + agentState: agentStateWithGateReceipts({ corrupted: true }), + logger: silentLogger, + }) + + const value = result.output[0].value as { errorMessage?: string } + expect(value.errorMessage).toMatch(/must cite a gate-issued receipt ID/) + expect(value.errorMessage).toMatch(/No gate-issued receipt is live for/) + // Atomic: the artifact is untouched when the transition is refused. + expect(fs.readFileSync(planPath, 'utf8')).toBe(PLAN_WITH_CLAIMED_TASK) + }) + + test('entries with non-string receiptId/taskId are dropped and therefore reject', async () => { + const planPath = path.join(tempDir, '.agents/sessions/demo/PLAN.md') + fs.writeFileSync(planPath, PLAN_WITH_CLAIMED_TASK) + + const result = await handleUpdatePlanStatus({ + previousToolCallFinished: Promise.resolve(), + toolCall: makeCall({ + path: '.agents/sessions/demo/PLAN.md', + updates: [{ taskId: 'P1-T1', status: 'done' }], + checkpoint: { + taskId: 'P1-T1', + phase: 'validation', + passed: true, + receiptIds: [GATE_RECEIPT_ID], + }, + }), + agentState: agentStateWithGateReceipts([ + { receiptId: 42, taskId: 'P1-T1' }, + { receiptId: GATE_RECEIPT_ID, taskId: null }, + { receiptId: ' ', taskId: 'P1-T1' }, + 'not-a-receipt', + ]), + logger: silentLogger, + }) + + const value = result.output[0].value as { errorMessage?: string } + expect(value.errorMessage).toMatch(/must cite a gate-issued receipt ID/) + expect(fs.readFileSync(planPath, 'utf8')).toBe(PLAN_WITH_CLAIMED_TASK) + }) }) diff --git a/packages/agent-runtime/src/tools/handlers/tool/update-plan-status.ts b/packages/agent-runtime/src/tools/handlers/tool/update-plan-status.ts index cd6d024ce4..fcd239b26b 100644 --- a/packages/agent-runtime/src/tools/handlers/tool/update-plan-status.ts +++ b/packages/agent-runtime/src/tools/handlers/tool/update-plan-status.ts @@ -25,6 +25,7 @@ import type { CodebuffToolOutput, } from '@codebuff/common/tools/list' import type { Logger } from '@codebuff/common/types/contracts/logger' +import type { AgentState } from '@codebuff/common/types/session-state' type ToolName = 'update_plan_status' @@ -111,6 +112,47 @@ export function applyTaskUpdate( return { lines, matched: false } } +/** + * Gate-issued per-task validation receipts published by base2 into + * `agentState.base2ActiveWork.planTaskGateReceipts`. + * + * Returns `undefined` ONLY when there is no gate-issued ledger at all — no + * `base2ActiveWork`, or a genuinely absent `planTaskGateReceipts` — so + * `validatePlanTransition` keeps its legacy "any non-empty receiptIds" rule for + * non-base2 agents and for a base2 run with the validation gate disabled. A + * PRESENT ledger (including an empty one) turns verification on, so the + * absent-vs-present distinction is load-bearing and must not be collapsed to + * `[]` — nor, in the other direction, may a malformed ledger silently reopen the + * legacy rule: a present key whose value is not an array is verification ACTIVE + * with no usable evidence, and returns `[]` so the completion fails closed. + * + * `base2ActiveWork` is typed `Record`, so each entry is + * narrowed field by field rather than cast: only entries whose `receiptId` and + * `taskId` are both non-empty strings can match a checkpoint, and any other + * entry is dropped (an all-malformed ledger therefore also rejects). + */ +function readGateIssuedPlanTaskReceipts( + base2ActiveWork: Record | undefined, +): Array<{ receiptId: string; taskId: string }> | undefined { + if (!base2ActiveWork) return undefined + const receipts = base2ActiveWork.planTaskGateReceipts + // Absent ledger: base2 DELETES the key on a gate-disabled turn, and a JSON + // round-trip drops an `undefined` value, so both read back as "no ledger". + if (receipts === undefined) return undefined + // Present but unusable => fail closed rather than fall back to the legacy rule. + if (!Array.isArray(receipts)) return [] + return receipts.flatMap((entry) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return [] + const record = entry as Record + const { receiptId, taskId } = record + if (typeof receiptId !== 'string' || receiptId.trim().length === 0) { + return [] + } + if (typeof taskId !== 'string' || taskId.trim().length === 0) return [] + return [{ receiptId, taskId }] + }) +} + function buildAppendBlock(entry: AppendEntry, nowIso: string): string { const heading = entry.heading.trim().replace(/\s+/g, ' ') const body = entry.body.replace(/\s+$/g, '') @@ -127,9 +169,13 @@ function buildAppendBlock(entry: AppendEntry, nowIso: string): string { export const handleUpdatePlanStatus = (async (params: { previousToolCallFinished: Promise toolCall: CodebuffToolCall + // The runtime always supplies agentState (CodebuffToolHandlerFunction + // requires it); it is optional here only so callers that construct just the + // fields this handler reads keep type-checking. + agentState?: AgentState logger: Logger }): Promise<{ output: CodebuffToolOutput }> => { - const { previousToolCallFinished, toolCall, logger } = params + const { previousToolCallFinished, toolCall, agentState, logger } = params const { path: artifactPath, updates, @@ -266,6 +312,11 @@ export const handleUpdatePlanStatus = (async (params: { : currentTaskApplied, existingState, checkpoint, + // Present array (even empty) => the checkpoint must cite a gate-issued + // receipt for this task; undefined => legacy any-non-empty behavior. + gateIssuedReceipts: readGateIssuedPlanTaskReceipts( + agentState?.base2ActiveWork, + ), }) if (!transition.ok) { return { diff --git a/packages/agent-runtime/src/util/__tests__/plan-execution-state.test.ts b/packages/agent-runtime/src/util/__tests__/plan-execution-state.test.ts index a42b1951d3..70c5a58402 100644 --- a/packages/agent-runtime/src/util/__tests__/plan-execution-state.test.ts +++ b/packages/agent-runtime/src/util/__tests__/plan-execution-state.test.ts @@ -144,4 +144,158 @@ describe('validatePlanTransition', () => { expect(result.errors.join(' ')).toContain('atomic') expect(result.errors.join(' ')).toContain('sole in-progress task') }) + + // Receipt IDs on a checkpoint are model-supplied, so the non-empty rule above + // is satisfied by an invented string. These cases pin the real-evidence rule: + // a PRESENT gateIssuedReceipts array requires the checkpoint to cite an ID the + // gate itself issued for THAT task, while an ABSENT array keeps the legacy + // behavior for callers with no gate-issued evidence. + describe('gate-issued receipt verification', () => { + const originalContent = plan([{ id: 'P1.1', status: 'in_progress' }]) + const nextContent = plan([{ id: 'P1.1', status: 'done' }]) + const gateReceiptId = `plan-gate:P1.1:v3:${'a'.repeat(13)}` + + function completeWith(params: { + receiptIds: string[] + gateIssuedReceipts?: Array<{ receiptId: string; taskId: string }> + }) { + return validatePlanTransition({ + originalContent, + nextContent, + updates: [{ taskId: 'P1.1', status: 'done' }], + unmatchedTasks: [], + existingState: null, + checkpoint: { + taskId: 'P1.1', + phase: 'validation', + passed: true, + receiptIds: params.receiptIds, + }, + ...(params.gateIssuedReceipts + ? { gateIssuedReceipts: params.gateIssuedReceipts } + : {}), + }) + } + + test('accepts a checkpoint citing a gate-issued receipt for that task', () => { + const result = completeWith({ + receiptIds: [gateReceiptId], + gateIssuedReceipts: [{ receiptId: gateReceiptId, taskId: 'P1.1' }], + }) + + expect(result).toMatchObject({ ok: true, completedTaskIds: ['P1.1'] }) + }) + + test('rejects an invented receipt ID when gate-issued receipts exist', () => { + const result = completeWith({ + receiptIds: ['validation-1'], + gateIssuedReceipts: [{ receiptId: gateReceiptId, taskId: 'P1.1' }], + }) + + expect(result.ok).toBe(false) + expect(result.errors.join(' ')).toContain( + 'must cite a gate-issued receipt ID', + ) + expect(result.errors.join(' ')).toContain('P1.1') + }) + + // Receipts are superseded when the work they cover changes, so the rejection + // has to name the IDs that are live for this task right now. + test('lists the live gate-issued receipt IDs for the task it rejected', () => { + const otherTaskReceiptId = `plan-gate:P9.9:v3:${'b'.repeat(13)}` + const noDiffReceiptId = `plan-gate:P1.1:no-diff:v3:${'c'.repeat(13)}` + const result = completeWith({ + receiptIds: ['validation-1'], + gateIssuedReceipts: [ + { receiptId: gateReceiptId, taskId: 'P1.1' }, + { receiptId: noDiffReceiptId, taskId: 'P1.1' }, + // Another task's receipt must not be offered as a candidate. + { receiptId: otherTaskReceiptId, taskId: 'P9.9' }, + ], + }) + + expect(result.ok).toBe(false) + const message = result.errors.join(' ') + expect(message).toContain( + `Live gate-issued receipt IDs for P1.1: ${gateReceiptId}, ${noDiffReceiptId}.`, + ) + expect(message).not.toContain(otherTaskReceiptId) + }) + + test('bounds the listed live receipt IDs to the first four', () => { + const liveReceiptIds = Array.from( + { length: 6 }, + (_entry, index) => `plan-gate:P1.1:v3:${String(index).repeat(13)}`, + ) + const result = completeWith({ + receiptIds: ['validation-1'], + gateIssuedReceipts: liveReceiptIds.map((receiptId) => ({ + receiptId, + taskId: 'P1.1', + })), + }) + + const message = result.errors.join(' ') + expect(message).toContain( + `Live gate-issued receipt IDs for P1.1: ${liveReceiptIds.slice(0, 4).join(', ')}.`, + ) + expect(message).not.toContain(liveReceiptIds[4]) + expect(message).not.toContain(liveReceiptIds[5]) + }) + + test('rejects a gate-issued receipt issued for a different task', () => { + const otherTaskReceiptId = `plan-gate:P9.9:v3:${'b'.repeat(13)}` + const result = completeWith({ + receiptIds: [otherTaskReceiptId], + gateIssuedReceipts: [{ receiptId: otherTaskReceiptId, taskId: 'P9.9' }], + }) + + expect(result.ok).toBe(false) + expect(result.errors.join(' ')).toContain( + 'must cite a gate-issued receipt ID', + ) + }) + + test('rejects when verification is active but no receipt has been issued yet', () => { + // Present-but-empty is load-bearing: the gate is active and has issued no + // evidence, so completion must fail closed rather than fall back. + const result = completeWith({ + receiptIds: [gateReceiptId], + gateIssuedReceipts: [], + }) + + expect(result.ok).toBe(false) + expect(result.errors.join(' ')).toContain( + 'must cite a gate-issued receipt ID', + ) + // Actionable: say the receipt is not live and what closes that gap, rather + // than listing candidates that do not exist. + expect(result.errors.join(' ')).toContain( + "No gate-issued receipt is live for P1.1; let the validation/reviewer gate close for that task's changes first (a receipt is superseded when its files change again).", + ) + }) + + test('reports the no-live-receipt wording when only other tasks have receipts', () => { + const result = completeWith({ + receiptIds: ['validation-1'], + gateIssuedReceipts: [ + { receiptId: `plan-gate:P9.9:v3:${'b'.repeat(13)}`, taskId: 'P9.9' }, + ], + }) + + expect(result.ok).toBe(false) + expect(result.errors.join(' ')).toContain( + 'No gate-issued receipt is live for P1.1;', + ) + expect(result.errors.join(' ')).not.toContain( + 'Live gate-issued receipt IDs for P1.1', + ) + }) + + test('omitting gateIssuedReceipts keeps the legacy any-non-empty rule', () => { + const result = completeWith({ receiptIds: ['validation-1'] }) + + expect(result).toMatchObject({ ok: true, completedTaskIds: ['P1.1'] }) + }) + }) }) diff --git a/packages/agent-runtime/src/util/plan-execution-state.ts b/packages/agent-runtime/src/util/plan-execution-state.ts index cbf48d007d..615da24fc5 100644 --- a/packages/agent-runtime/src/util/plan-execution-state.ts +++ b/packages/agent-runtime/src/util/plan-execution-state.ts @@ -37,6 +37,14 @@ export function validatePlanTransition(params: { currentTask?: string | null existingState: PlanSessionState | null checkpoint?: PlanCheckpoint + /** + * Gate-issued per-task validation receipts read from base2 gate state. + * `undefined` means the caller has no gate-issued evidence to check against + * (non-base2 agent, or a base2 run with the validation gate disabled), and + * the legacy "any non-empty receiptIds" rule applies unchanged. A PRESENT + * array — including an empty one — means verification is active. + */ + gateIssuedReceipts?: Array<{ receiptId: string; taskId: string }> }): PlanTransitionValidation { const errors: string[] = [] const original = preflightPlan(params.originalContent) @@ -115,6 +123,38 @@ export function validatePlanTransition(params: { `Task ${task.id} validation checkpoint must reference at least one receipt ID.`, ) } + // Real evidence check: the cited receipt must be one the + // validation/reviewer gate itself issued for THIS task. Receipt IDs are + // otherwise entirely model-supplied, so an invented string satisfies the + // non-empty rule above without any gate ever having passed. A PRESENT + // gateIssuedReceipts array — including an EMPTY one — turns verification + // on and must reject (gate active, no evidence yet); an ABSENT one leaves + // the legacy rule untouched for callers with no gate-issued evidence. + if (params.gateIssuedReceipts) { + const citedReceiptIds = checkpoint?.receiptIds ?? [] + const citesGateIssuedReceipt = citedReceiptIds.some((receiptId) => + params.gateIssuedReceipts?.some( + (issued) => + issued.taskId === task.id && issued.receiptId === receiptId, + ), + ) + if (!citesGateIssuedReceipt) { + // Receipts are superseded when the work they cover changes, so the + // rejection has to be actionable NOW: name the IDs that are live for + // this task (bounded so a long ledger cannot flood the message), or + // say that none is and why. + const liveReceiptIdsForTask = params.gateIssuedReceipts + .filter((issued) => issued.taskId === task.id) + .map((issued) => issued.receiptId) + const liveReceiptGuidance = + liveReceiptIdsForTask.length > 0 + ? `Live gate-issued receipt IDs for ${task.id}: ${liveReceiptIdsForTask.slice(0, 4).join(', ')}.` + : `No gate-issued receipt is live for ${task.id}; let the validation/reviewer gate close for that task's changes first (a receipt is superseded when its files change again).` + errors.push( + `Task ${task.id} validation checkpoint must cite a gate-issued receipt ID from a passed validation/reviewer gate for that task; invented receipt IDs are rejected. ${liveReceiptGuidance}`, + ) + } + } } } const requestedCurrentTask = params.currentTask?.trim() || null