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 });