From 2328cd3bbc77f2622378a269f99835d0264bc405 Mon Sep 17 00:00:00 2001 From: garrytan Date: Tue, 6 Oct 2026 18:20:37 +0000 Subject: [PATCH] fix(autoplan): name a shell-built snapshot init as the cause of the missing-evidence denial (#3045) The guard binds a phase entry only to a literal snapshot-init Bash call. When the model resolved the tool path into a variable, chained or piped the call, init ran and succeeded but the denial said 'Complete the existing snapshot init step', which reads as already done, so the run gave up. When no init binds but a Bash call looks like one, the denial now says the shape cannot be bound and prints the literal re-run (answers reused:true). The binding rule is unchanged, and the skill says to run init on its own. Co-authored-by: CharlesBerg85 --- autoplan/SKILL.md | 2 ++ autoplan/SKILL.md.tmpl | 2 ++ autoplan/bin/phase-publication-hook.ts | 10 +++++++++- test/autoplan-publication-guard.test.ts | 23 +++++++++++++++++++++++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/autoplan/SKILL.md b/autoplan/SKILL.md index 8fb5117ca..8b4d6850a 100644 --- a/autoplan/SKILL.md +++ b/autoplan/SKILL.md @@ -685,6 +685,8 @@ Before scope/review: ```bash bun "" init "" "" "" ``` +Run init as its own Bash call with the literal absolute paths: no variables, +substitutions, chaining, pipes or redirects, which the guard cannot bind. Use returned paths/`scope`; never hand-wrap. init backs up SOURCE_PLAN exactly, then initializes ACTIVE_PLAN atomically without losing requirements. Reviewers get only `## Implementation plan`; analysis stays in `## Review record`, diff --git a/autoplan/SKILL.md.tmpl b/autoplan/SKILL.md.tmpl index d3e9015c0..287a64b6a 100644 --- a/autoplan/SKILL.md.tmpl +++ b/autoplan/SKILL.md.tmpl @@ -215,6 +215,8 @@ Before scope/review: ```bash bun "" init "" "" "" ``` +Run init as its own Bash call with the literal absolute paths: no variables, +substitutions, chaining, pipes or redirects, which the guard cannot bind. Use returned paths/`scope`; never hand-wrap. init backs up SOURCE_PLAN exactly, then initializes ACTIVE_PLAN atomically without losing requirements. Reviewers get only `## Implementation plan`; analysis stays in `## Review record`, diff --git a/autoplan/bin/phase-publication-hook.ts b/autoplan/bin/phase-publication-hook.ts index adaeea4be..6ed3bc29e 100644 --- a/autoplan/bin/phase-publication-hook.ts +++ b/autoplan/bin/phase-publication-hook.ts @@ -224,13 +224,17 @@ function initArguments(command: unknown, root: string): string[] | undefined { return args.slice(1); } +/** Diagnostic only, never binds: a Bash call that ran snapshot init in a shape the guard cannot bind (#3045). */ +const UNBINDABLE_INIT = /(?:gstack-autoplan-snapshot|SNAPSHOT_TOOL|\$\{?[A-Z_]+\}?"?)\S*\s+init\s/; + function invocation(events: Event[], root: string): Invocation { let bound: Invocation | undefined; let chosen: Record | undefined; + let unbindable = false; for (const use of events) { if (use.kind !== 'use' || use.name !== 'Bash') continue; const args = initArguments(use.input?.command, root); - if (!args) continue; + if (!args) { unbindable ||= typeof use.input?.command === 'string' && UNBINDABLE_INIT.test(use.input.command) && /\.md\b/.test(use.input.command); continue; } const results = events.filter(x => x.kind === 'result' && x.toolUseId === use.toolUseId && x.order > use.order); if (results.length !== 1) fail('Autoplan initialization acknowledgment is unavailable or ambiguous.'); const text = textResult(results[0]!); @@ -244,6 +248,10 @@ function invocation(events: Event[], root: string): Invocation { bound = { activePlan: result.activePlan, restorePath: result.restorePath, originalSha256: result.originalSha256, start: results[0]!.order }; } + if ((!chosen || !bound) && unbindable) + fail('Autoplan invocation evidence is unavailable: snapshot init ran through a shell variable, substitution, chaining, ' + + 'a pipe or a redirect, which this guard cannot bind. Re-run it as one Bash call with the literal absolute paths: ' + + '`bun "" init "" "" ""` (it answers reused:true), then retry this tool.'); if (!chosen || !bound) fail('Autoplan invocation evidence is unavailable. Complete the existing snapshot init step before phase entry.'); const restore = read(bound.restorePath, true), active = read(bound.activePlan); const reference = JSON.stringify(bound.restorePath).replace(/--/g, '\\u002d\\u002d'); diff --git a/test/autoplan-publication-guard.test.ts b/test/autoplan-publication-guard.test.ts index dcfac0156..943361712 100644 --- a/test/autoplan-publication-guard.test.ts +++ b/test/autoplan-publication-guard.test.ts @@ -483,6 +483,29 @@ describe('Autoplan parent publication guard', () => { }); } + test('#3045: no init at all keeps the generic missing-evidence denial', () => { + const f = fixture(); f.message(); f.current(); f.events.splice(0, 2); f.reorder(); + const reason = (f.evaluate() as any).reason; + expect(reason).toContain('Complete the existing snapshot init step'); + expect(reason).not.toContain('cannot bind'); + }); + for (const [name, command] of [ + ['a resolved-variable init piped to tail', (f: any) => `ST=$(bun -e 'console.log(require("fs").realpathSync(process.argv[1]))' "${ROOT}/bin/gstack-autoplan-snapshot.ts"); bun "$ST" init "${f.source}" "${f.active}" "${f.restore}" 2>&1 | tail -5`], + ['an init through a variable from an earlier call', (f: any) => `bun "$SNAPSHOT_TOOL" init "${f.source}" "${f.active}" "${f.restore}"`], + ['a chained literal init', (f: any) => `bun "${ROOT}/bin/gstack-autoplan-snapshot.ts" init "${f.source}" "${f.active}" "${f.restore}" && echo done`], + ] as const) + test(`#3045: ${name} is named as the cause with the literal re-run; only the literal re-run binds`, () => { + const f = fixture(); f.message(); f.current(); + (f.events[0] as any).input.command = command(f); + const reason = (f.evaluate() as any).reason; + expect(reason).toContain('which this guard cannot bind'); + expect(reason).toContain('`bun "" init "" "" ""`'); + f.events.splice(2, 0, { ...structuredClone(f.events[0]!), toolUseId: 're-init', + input: { command: `bun "${ROOT}/bin/gstack-autoplan-snapshot.ts" init "${f.source}" "${f.active}" "${f.restore}"` } }, + { ...structuredClone(f.events[1]!), toolUseId: 're-init', content: JSON.stringify({ ...f.init, reused: true }) }); + f.reorder(); expect(f.evaluate()).toEqual({ allow: true }); + }); + test('split successful close ranges through EOF are sufficient', () => { const f = fixture(); f.events.splice(4, 2); f.read('part1', f.packet.closePacketPath, 1, 10); f.read('part2', f.packet.closePacketPath, 11); f.message(); f.current(); expect(f.evaluate()).toEqual({ allow: true });