From 4e5ffbb857c49a71f69721172e6a435b454c49be Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 8 Oct 2026 08:26:56 -0700 Subject: [PATCH] v1.91.59.0 fix(autoplan): bind init through symlinked plan directories and superseded failed inits (#3083) * fix(autoplan): bind init through symlinked plan directories and superseded failed inits The publication guard compared snapshot init's JSON against the literal argv spelling, but init reports canonical destinations, so a plan under macOS /tmp or /var (realpath /private/...) always denied init_mismatch. The guard also hard-failed on the first errored init, so a corrected re-run never bound. - bin/gstack-autoplan-snapshot.ts: export canonicalDestination (the inner destination() path math); initializePlan output is unchanged. - autoplan/bin/phase-publication-hook.ts: compare activePlan/restorePath against the canonicalized argv; a mistyped tool path is unbindable, not a crash; skip errored inits and deny init_failed only when the most recent init errored; Write/Edit to the active plan match by canonical path. - autoplan/bin/guard-reasons.ts: init_mismatch never says re-run; init_failed does. - tests: PAX-3638 Fix A/A2/B/C cases (bun test -t "Fix B" runs one fix); the three autoplan-init assertions that failed on macOS now compare canonical paths. 9 of the 11 new guard cases fail against the previous hook. - docs: init_mismatch, init_failed, Symlinked plan directories. Co-Authored-By: Claude Fable 5.1 * docs(autoplan): canonicalRequested comment matches realpath behavior realpathSync resolves a leaf link as well as directory links; only a missing target or dangling link keeps the literal spelling. Note why resolving a leaf link cannot bind an aliased active plan, and drop Read from the callers list (only Write/Edit use it). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.59.0 fix(autoplan): bind init through symlinked plan directories and superseded failed inits Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: Wyatt Barrett Co-authored-by: Claude Fable 5.1 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 23 +++++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- autoplan/bin/guard-reasons.ts | 4 +- autoplan/bin/phase-publication-hook.ts | 51 ++++++++++---- bin/gstack-autoplan-snapshot.ts | 23 +++++-- docs/autoplan-guard-troubleshooting.md | 30 ++++++++- package.json | 2 +- test/autoplan-init.test.ts | 40 +++++++++-- test/autoplan-publication-guard.test.ts | 88 +++++++++++++++++++++++++ 10 files changed, 232 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d50b8b3f8..0cf4c0dbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## [1.91.59.0] - 2026-10-08 + +**/autoplan's phase guard accepts a plan under a symlinked directory, and a failed snapshot init no longer blocks a corrected re-run.** + +The /autoplan publication guard checks that snapshot `init` reported the same active-plan and restore paths it was given. `init` reports canonical paths, with every directory link resolved, but the guard compared them against the command arguments as typed. On macOS `/tmp` and `/var` are links to `/private/tmp` and `/private/var`, so every plan under them, and any plan reached through a symlinked directory elsewhere, was denied with `init_mismatch` at the first phase. Separately, the guard stopped at the first `init` call that errored, so one bad call denied the whole session even after a correct `init` succeeded. + +### What this means for you + +Run `/gstack-upgrade`. `/autoplan` on a plan under `/tmp`, `/var/folders` or another symlinked directory now enters its phases instead of stopping at `init_mismatch`. If an `init` call fails, re-run the same command with the same three paths: the later success binds, and `init_failed` now tells you to do exactly that. + +### Itemized changes + +#### Fixed +- The phase publication guard canonicalizes `init`'s `` and `` arguments the same way `init` does before comparing them with its result. Writes and Edits to the active plan are matched by canonical path, so a pending Edit through a symlinked spelling still counts as pending. (#3072) +- An errored `init` call is skipped. The guard denies `init_failed` only when the most recent `init` call errored, and the message says to re-run the same command. A mistyped snapshot-tool path is treated as an unbindable call rather than crashing the hook. (#3072) +- `init_mismatch` explains that `init` returned paths that don't match its own arguments and no longer suggests re-running the same call. (#3072) + +#### For contributors +- `bin/gstack-autoplan-snapshot.ts` exports `canonicalDestination`, the path computation `initializePlan` already used, so the guard and `init` share one definition. `init`'s output is unchanged. +- New `PAX-3638` cases in `test/autoplan-publication-guard.test.ts` and `test/autoplan-init.test.ts` drive the guard through a symlinked fixture directory, so the bug reproduces on Linux: 9 of the 11 new guard cases fail against the previous hook. `docs/autoplan-guard-troubleshooting.md` gains a "Symlinked plan directories" section and updated `init_mismatch` and `init_failed` entries. + +Contributed by @wyatt-paxiv (#3072). + ## [1.91.54.0] - 2026-10-08 **Your first /office-hours session gets the first-session closing again, and you see the design doc before you approve it.** diff --git a/VERSION b/VERSION index e4786cb24..db62d22ce 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.54.0 +1.91.59.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 2b29fc785..cd91c5392 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.54.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.59.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/autoplan/bin/guard-reasons.ts b/autoplan/bin/guard-reasons.ts index 4093c914c..ad143c2d5 100644 --- a/autoplan/bin/guard-reasons.ts +++ b/autoplan/bin/guard-reasons.ts @@ -54,11 +54,11 @@ export const REASONS = { foreign_install: { disposition: 'corrective', text: () => 'Autoplan phase entry belongs to a different or unavailable installation. Restore this invocation’s hook installation (run ./setup), then make this call again.' }, init_required: { disposition: 'corrective', text: () => 'Autoplan invocation evidence is unavailable. Complete the existing snapshot init step before phase entry.' }, init_own: { disposition: 'corrective', text: () => 'This Autoplan invocation needs its own successful init before phase entry. Complete the existing snapshot init step.' }, - init_failed: { disposition: 'corrective', text: () => 'Autoplan initialization did not succeed. Complete the existing init step first.' }, + init_failed: { disposition: 'corrective', text: d => d.cause ? `${d.cause}.` : 'Autoplan initialization did not succeed. Complete the existing init step first.' }, init_unbindable: { disposition: 'corrective', text: () => '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 make this call again.' }, - init_mismatch: { disposition: 'fallback', text: () => 'Autoplan initialization artifacts do not match this parent invocation.' }, + init_mismatch: { disposition: 'fallback', text: d => 'Autoplan initialization artifacts do not match this parent invocation.' + (d.cause ? ` ${d.cause}.` : '') }, snapshot: { disposition: 'fallback', text: d => `${d.cause ?? 'A native phase artifact is unavailable or changed'}. The immutable phase snapshot is missing or was changed.` }, dispatch_prompt: { disposition: 'fallback', text: () => 'Native phase dispatch differs from its exact immutable snapshot prompt.' }, phase_order: { disposition: 'corrective', text: () => 'Read the current Phase 1 CEO entry successfully before entering a later phase.' }, diff --git a/autoplan/bin/phase-publication-hook.ts b/autoplan/bin/phase-publication-hook.ts index e069926a3..485e891db 100644 --- a/autoplan/bin/phase-publication-hook.ts +++ b/autoplan/bin/phase-publication-hook.ts @@ -6,7 +6,7 @@ import * as os from 'node:os'; import { createHash } from 'node:crypto'; import { fileURLToPath } from 'node:url'; import { isDeepStrictEqual } from 'node:util'; -import { extractImplementationPlan, checkPhaseImplementation, acceptedBlocks } from '../../bin/gstack-autoplan-snapshot'; +import { extractImplementationPlan, checkPhaseImplementation, acceptedBlocks, canonicalDestination } from '../../bin/gstack-autoplan-snapshot'; import { autoplanPhaseCompletions } from '../../lib/autoplan-phase-publication'; import { nativePathSpelling, ownedNativePath, sameNativePath, type ClaudeParentPublicEvent } from '../../lib/claude-journal-records'; import { ownedRecordLimit, ownedRetainedLimit, type JournalPrefix, type OwnedRead, type OwnedReadMeasure } from '../../lib/claude-owned-journal'; @@ -43,6 +43,17 @@ const samePath = (a: unknown, b: unknown): boolean => sameNativePath(a, b); /** Claude's Read expands a leading ~ before the tool runs; resolve the same file. */ const requestedPath = (cwd: string, file: string) => nativePathSpelling(path.resolve(cwd, file.replace(/^~(?=[\\/]|$)/, () => os.homedir()))); +/** + * The file a Write/Edit path names, realpathed so a symlinked plan directory + * compares equal to init's canonical paths. realpath resolves a leaf link too; a + * missing target or dangling link keeps the literal spelling. An aliased active + * plan never binds, because invocation() reads it through read(), which denies aliases. + */ +const canonicalRequested = (cwd: string, file: unknown): string | undefined => { + if (typeof file !== 'string') return; + const literal = requestedPath(cwd, file); + try { return fs.realpathSync(literal); } catch { return literal; } +}; /** Keys Claude Code's schema parse may drop from the journaled raw input (Agent's fork-subagent gate drops run_in_background). */ export const SCHEMA_STRIPPED: Record = { Agent: ['run_in_background'] }; /** The reviewer dispatch input the guard accepts; snapshot manifests are unchanged by it. */ @@ -254,10 +265,16 @@ function initArguments(command: unknown, root: string): string[] | undefined { // Git Bash accepts forward slashes and hands native programs /c/x as C:/x. // Fold that spelling only; every canonical-path check still applies. .map(x => nativePathSpelling(x, path)); - if (!args.every(ownPath) || !samePath(fs.realpathSync(args[0]!), path.join(root, 'bin', 'gstack-autoplan-snapshot.ts'))) return; + if (!args.every(ownPath)) return; + // A mistyped tool path is unbindable, not a hook crash. + try { if (!samePath(fs.realpathSync(args[0]!), path.join(root, 'bin', 'gstack-autoplan-snapshot.ts'))) return; } + catch { return; } return args.slice(1); } +const INIT_MISMATCH_CAUSE = 'Snapshot init returned paths that do not match the canonical form of its own command arguments'; +const INIT_FAILED_CAUSE = 'The most recent snapshot init call failed; re-run the same init command with the same three paths'; + /** 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/; @@ -265,6 +282,8 @@ function invocation(events: Event[], root: string): Invocation { let bound: Invocation | undefined; let chosen: Record | undefined; let unbindable = false; + // True while the most recent bindable init use errored (a later success clears it). + let lastErrored = false; const tools = byTool(events); for (const use of events) { if (use.kind !== 'use' || use.name !== 'Bash') continue; @@ -272,17 +291,25 @@ function invocation(events: Event[], root: string): Invocation { if (!args) { unbindable ||= typeof use.input?.command === 'string' && UNBINDABLE_INIT.test(use.input.command) && /\.md\b/.test(use.input.command); continue; } const results = tools.results(use.toolUseId).filter(x => x.order > use.order); if (results.length !== 1) fail('init_mismatch'); + // An errored init published nothing; a later correct init may still bind. + if (results[0]!.isError === true) { lastErrored = true; continue; } const text = textResult(results[0]!); if (text === undefined) fail('init_failed'); const result = JSON.parse(text); - if (!object(result) || !samePath(result.sourcePlan, fs.realpathSync(args[0]!)) || !samePath(result.activePlan, args[1]) || - !samePath(result.restorePath, args[2]) || typeof result.reused !== 'boolean' || !positive(result.originalBytes) || - !/^[a-f0-9]{64}$/.test(result.originalSha256)) fail('init_mismatch'); + // init reports canonical destinations (macOS /tmp is /private/tmp); canonicalize the argv the same way. + let active: string, restore: string; + try { active = canonicalDestination(args[1]!); restore = canonicalDestination(args[2]!); } + catch { fail('init_mismatch', { cause: INIT_MISMATCH_CAUSE }); } + if (!object(result) || !samePath(result.sourcePlan, fs.realpathSync(args[0]!)) || !samePath(result.activePlan, active) || + !samePath(result.restorePath, restore) || typeof result.reused !== 'boolean' || !positive(result.originalBytes) || + !/^[a-f0-9]{64}$/.test(result.originalSha256)) fail('init_mismatch', { cause: INIT_MISMATCH_CAUSE }); + lastErrored = false; if (result.reused && bound && bound.activePlan === result.activePlan && bound.restorePath === result.restorePath) continue; chosen = result; bound = { activePlan: result.activePlan, restorePath: result.restorePath, originalSha256: result.originalSha256, start: results[0]!.order }; } + if (lastErrored) fail('init_failed', { cause: INIT_FAILED_CAUSE }); if ((!chosen || !bound) && unbindable) fail('init_unbindable'); if (!chosen || !bound) fail('init_required'); const restore = read(bound.restorePath, true), active = read(bound.activePlan); @@ -389,9 +416,9 @@ function disarmedAt(events: Event[], root: string): boolean[] { } /** Only exact reversible successful Edits can establish a report-only change. */ -function verifyCloseEdits(events: Event[], closeOrder: number, init: Invocation): void { +function verifyCloseEdits(events: Event[], closeOrder: number, init: Invocation, cwd: string): void { const edits = events.filter((e): e is Use => e.kind === 'use' && e.order > closeOrder && - ['Write', 'Edit'].includes(e.name ?? '') && samePath(e.input?.file_path, init.activePlan)); + ['Write', 'Edit'].includes(e.name ?? '') && samePath(canonicalRequested(cwd, e.input?.file_path), init.activePlan)); if (!edits.length) return; const current = read(init.activePlan), tools = byTool(events); let prior = current; @@ -427,7 +454,7 @@ function verifyCloseEdits(events: Event[], closeOrder: number, init: Invocation) const sameMessage = (a: Event, b: Event) => a.messageId !== undefined && a.messageId === b.messageId; interface Flush { current: Use; journaled: boolean } function requirePublication(phase: Phase, entryOrder: number, entered: Event[], init: Invocation, current: boolean, - checkpoint?: string, flush?: Flush): void { + cwd: string, checkpoint?: string, flush?: Flush): void { const native = (e: Event) => e.kind === 'use' && typeof e.input?.file_path === 'string' ? nativePathSpelling(e.input.file_path) : undefined; const closeReads = entered.filter((e): e is Use => e.kind === 'use' && e.name === 'Read' && e.order >= entryOrder && ownPath(native(e)) && path.basename(native(e)!) === 'close-packet.md' && @@ -450,9 +477,9 @@ function requirePublication(phase: Phase, entryOrder: number, entered: Event[], } if (covered.size !== content.split('\n').length) fail('close_incomplete', { phase: number[phase] }); const pending = entered.some(e => e.kind === 'use' && e.order > closeOrder && ['Write', 'Edit'].includes(e.name ?? '') && - samePath(e.input?.file_path, init.activePlan) && !tools.results(e.toolUseId).length); + samePath(canonicalRequested(cwd, e.input?.file_path), init.activePlan) && !tools.results(e.toolUseId).length); if (pending) fail('mutation_pending'); - if (current) verifyCloseEdits(entered, closeOrder, init); + if (current) verifyCloseEdits(entered, closeOrder, init, cwd); const reports = entered.filter((e): e is Event & { kind: 'message' } => e.kind === 'message' && e.order > closeOrder && autoplanPhaseCompletions({ status: 'ready', calls: [], assistantMessages: [e] }, 0).some(hit => hit.phase === number[phase])); if (!reports.length) fail('publication_missing', { phase: number[phase] }); @@ -589,7 +616,7 @@ function phaseDecision(input: PublicationHookInput, root: string, before: Event[ if (!phase || number[next.phase] > number[phase]) { // An unguarded earlier delivery cannot erase its predecessor's missing // publication. Recovery still uses that predecessor's existing close. - if (phase) try { requirePublication(phase, entryOrder, entered.filter(e => e.order < use.order), init, false, checkpoint); } + if (phase) try { requirePublication(phase, entryOrder, entered.filter(e => e.order < use.order), init, false, input.cwd, checkpoint); } catch { continue; } phase = next.phase; entryOrder = use.order; checkpoint = preparedCheckpoints.get(phase); preparedCheckpoints.delete(phase); @@ -606,7 +633,7 @@ function phaseDecision(input: PublicationHookInput, root: string, before: Event[ return { allow: true }; } if (number[target] <= number[phase]) return { allow: true }; - requirePublication(phase, entryOrder, entered, init, true, checkpoint, { current, journaled }); + requirePublication(phase, entryOrder, entered, init, true, input.cwd, checkpoint, { current, journaled }); return { allow: true }; } diff --git a/bin/gstack-autoplan-snapshot.ts b/bin/gstack-autoplan-snapshot.ts index f99e72516..9de6d55b9 100644 --- a/bin/gstack-autoplan-snapshot.ts +++ b/bin/gstack-autoplan-snapshot.ts @@ -367,17 +367,28 @@ export function amendImplementation(phase: string, activePlan: string, snapshotP return checkPhaseImplementation(phase, source, snapshotPath, extractImplementationPlan(next) === prior ? 'unchanged' : 'changed'); } +/** + * The canonical spelling of an init destination: the deepest existing ancestor is + * realpathed (so /tmp -> /private/tmp on macOS) and any missing segments plus the + * basename are re-joined. Pure path computation; the regular-file check stays in + * initializePlan. Init reports this spelling, so callers comparing against the + * argv they passed must canonicalize the argv the same way. + */ +export function canonicalDestination(file: string): string { + let parent = dirname(file); + const missing: string[] = []; + while (!lstatSync(parent, { throwIfNoEntry: false })) { + missing.unshift(basename(parent)); parent = dirname(parent); + } + return join(realpathSync(parent), ...missing, basename(file)); +} + /** Initialize the existing strict section contract before any scope or review call. */ export function initializePlan(sourcePlan: string, activePlan: string, restorePath: string) { if (![sourcePlan, activePlan, restorePath].every(isAbsolute)) throw new Error('Initialization requires three absolute paths'); const source = realpathSync(sourcePlan); const destination = (file: string) => { - let parent = dirname(file); - const missing: string[] = []; - while (!lstatSync(parent, { throwIfNoEntry: false })) { - missing.unshift(basename(parent)); parent = dirname(parent); - } - const canonical = join(realpathSync(parent), ...missing, basename(file)); + const canonical = canonicalDestination(file); const state = lstatSync(canonical, { throwIfNoEntry: false, bigint: true }); if (state && !state.isFile()) throw new Error('Initialization destinations must be regular files, not links or directories'); return { file: canonical, state, bytes: state ? readFileSync(canonical) : undefined }; diff --git a/docs/autoplan-guard-troubleshooting.md b/docs/autoplan-guard-troubleshooting.md index f2bbf073e..22fd71e44 100644 --- a/docs/autoplan-guard-troubleshooting.md +++ b/docs/autoplan-guard-troubleshooting.md @@ -177,8 +177,14 @@ previous report. ### `init_mismatch` -The snapshot init result or its artifacts (restore point, active plan header) -do not match this invocation. +Snapshot init returned paths that do not match the canonical form of its own +command arguments, or its artifacts (restore point, active plan header) do not +match this invocation. The guard canonicalizes the `` and `` +arguments the way `init` does (see +[Symlinked plan directories](#symlinked-plan-directories)) before comparing, so +a retargeted or forged init result is denied here. Zero results or duplicate +results for one init use also deny as `init_mismatch`. Do not re-run the same +call: run init with the paths the guard expects. ### `snapshot` @@ -241,7 +247,10 @@ A new `/autoplan` turn needs its own init (it may answer `reused: true`). ### `init_failed` -The init step failed. Complete it first. +The most recent snapshot init call failed; re-run the same init command with +the same three paths. An errored init is skipped, so a later successful init +still binds. This denial applies when no init ever succeeded, or when an +errored init follows a good one. ### `init_unbindable` @@ -335,3 +344,18 @@ write it never changes a decision. Claude Code and Git Bash can spell one path as `C:\x`, `c:\x`, `C:/x` or `/c/x`. The guard treats these as the same path. It does not resolve `.` or `..`, so a path containing them is still rejected. + + +## Symlinked plan directories (macOS /tmp, /var) + +The guard compares plan paths canonically, not by argv spelling. On macOS +`/tmp` and `/var` are symlinks to `/private/tmp` and `/private/var`, so a plan +under `/tmp` or `/var/folders` is expected to appear as `/private/...` in the +init JSON. That is by design: `init` reports canonical paths (the deepest +existing ancestor is resolved with `realpath` and the missing segments are +re-joined, `canonicalDestination` in `bin/gstack-autoplan-snapshot.ts`), and +the guard canonicalizes the init command's `` and `` the same +way. Writes and Edits to the active plan are matched by canonical path too, so +editing the plan through an alias spelling is recognized. Symlinked plan +directories are allowed; only a result that differs from the canonical form of +its own arguments is denied (`init_mismatch`). diff --git a/package.json b/package.json index ce0ce8839..669769dec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.54", + "version": "1.91.59", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/autoplan-init.test.ts b/test/autoplan-init.test.ts index a8a059b87..8e17cddc6 100644 --- a/test/autoplan-init.test.ts +++ b/test/autoplan-init.test.ts @@ -1,10 +1,10 @@ import { afterEach, expect, test } from 'bun:test'; import { createHash } from 'node:crypto'; -import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'; +import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; -import { prepareMethodology } from '../bin/gstack-autoplan-snapshot'; +import { canonicalDestination, prepareMethodology } from '../bin/gstack-autoplan-snapshot'; const ROOT = resolve(import.meta.dir, '..'); const TOOL = join(ROOT, 'bin/gstack-autoplan-snapshot.ts'); @@ -41,7 +41,7 @@ test('actual raw R input initializes before scope and reaches a complete CEO dis expect(missing.status).toBe(1); expect(missing.stderr).toContain('Expected one Implementation plan section'); const initialized = invoke('init', f.source, f.source, f.restore); - expect(initialized.activePlan).toBe(f.source); + expect(initialized.activePlan).toBe(canonicalDestination(f.source)); expect(initialized.originalBytes).toBe(4607); expect(initialized.originalSha256).toBe(hash(original)); expect(readFileSync(f.restore)).toEqual(original); @@ -64,8 +64,8 @@ test('assigned active path preserves the separate original source and exact rest if (emptyAssigned) writeFileSync(f.active, ''); const sourceMtime = statSync(f.source).mtimeMs; const initialized = invoke('init', f.source, f.active, f.restore); - expect(initialized.activePlan).toBe(f.active); - expect(initialized.restorePath).toBe(f.restore); + expect(initialized.activePlan).toBe(canonicalDestination(f.active)); + expect(initialized.restorePath).toBe(canonicalDestination(f.restore)); expect(readFileSync(f.source)).toEqual(original); expect(statSync(f.source).mtimeMs).toBe(sourceMtime); expect(readFileSync(f.restore)).toEqual(original); @@ -79,8 +79,8 @@ test('the observed missing harness plans directory is initialized without a sepa const active = join(f.dir, 'harness', 'plans', 'assigned.md'); const restore = join(f.dir, 'state', 'project', 'restore.md'); const initialized = invoke('init', f.source, active, restore); - expect(initialized.activePlan).toBe(active); - expect(initialized.restorePath).toBe(restore); + expect(initialized.activePlan).toBe(canonicalDestination(active)); + expect(initialized.restorePath).toBe(canonicalDestination(restore)); expect(initialized.scope.dxRequired).toBe(true); expect(readFileSync(f.source)).toEqual(original); expect(readFileSync(restore)).toEqual(original); @@ -241,3 +241,29 @@ catch (error) { console.error(error.message); process.exitCode = 1; } expect(existsSync(active)).toBe(false); expect(existsSync(restore)).toBe(false); }); + +test.skipIf(process.platform === 'win32')('PAX-3638 Fix A: init through a symlinked plan directory reports the canonical destinations', () => { + const f = fixture(); + const real = join(f.dir, 'real'), alias = join(f.dir, 'alias'); + mkdirSync(real); symlinkSync(real, alias, 'dir'); + const active = join(alias, 'nested', 'active.md'), restore = join(alias, 'restore.md'); + const canonicalActive = join(realpathSync(real), 'nested', 'active.md'); + expect(canonicalDestination(active)).toBe(canonicalActive); + expect(canonicalDestination(restore)).toBe(join(realpathSync(real), 'restore.md')); + const initialized = invoke('init', f.source, active, restore); + expect(initialized.activePlan).toBe(canonicalActive); + expect(initialized.restorePath).toBe(join(realpathSync(real), 'restore.md')); + expect(initialized.activePlan).not.toBe(active); + expect(readFileSync(canonicalActive, 'utf8')).toContain('## Implementation plan\n'); +}); + +test('PAX-3638 Fix A: canonicalDestination is pure path math and leaves a leaf link or directory to initializePlan', () => { + const f = fixture(); + const dirLeaf = join(f.dir, 'a-directory'); mkdirSync(dirLeaf); + expect(canonicalDestination(dirLeaf)).toBe(join(realpathSync(f.dir), 'a-directory')); + expect(existsSync(join(f.dir, 'missing', 'deeper'))).toBe(false); + expect(canonicalDestination(join(f.dir, 'missing', 'deeper', 'x.md'))).toBe(join(realpathSync(f.dir), 'missing', 'deeper', 'x.md')); + expect(existsSync(join(f.dir, 'missing'))).toBe(false); + // initializePlan still refuses a directory destination. + expect(cli('init', f.source, dirLeaf, f.restore).status).not.toBe(0); +}); diff --git a/test/autoplan-publication-guard.test.ts b/test/autoplan-publication-guard.test.ts index c174b8c60..bb81e3a8e 100644 --- a/test/autoplan-publication-guard.test.ts +++ b/test/autoplan-publication-guard.test.ts @@ -1510,3 +1510,91 @@ describe('Autoplan ownership in a linked git worktree session', () => { expect(output.hookSpecificOutput.permissionDecisionReason).toContain('code foreign_cwd'); }); }); + +describe('PAX-3638: symlinked plan directory and superseded failed inits', () => { + const skipWin = process.platform === 'win32'; + /** The init Bash command spells active/restore through a symlinked directory; the journaled JSON stays canonical. */ + function aliasFixture() { + const f = fixture(); + const aliasDir = `${f.cwd}-alias`; dirs.push(aliasDir); + fs.symlinkSync(f.cwd, aliasDir, 'dir'); + const aliasActive = path.join(aliasDir, 'active.md'), aliasRestore = path.join(aliasDir, 'restore.md'); + const command = `bun "${ROOT}/bin/gstack-autoplan-snapshot.ts" init "${f.source}" "${aliasActive}" "${aliasRestore}"`; + (f.events[0] as any).input.command = command; + expect(f.init.activePlan).toBe(f.active); // canonical, unlike the argv above + expect(aliasActive).not.toBe(f.active); + const erroredInit = (id: string) => [ + { ...(structuredClone(f.events[0]!) as any), toolUseId: id }, + { ...(structuredClone(f.events[1]!) as any), toolUseId: id, isError: true, content: 'Error: Initialization source must be nonempty UTF-8 text' }, + ]; + const ready = () => { f.message(); f.current(); f.reorder(); }; + return { ...f, aliasDir, aliasActive, aliasRestore, command, erroredInit, ready }; + } + + test.skipIf(skipWin)('PAX-3638 Fix A: alias argv binds to init canonical paths', () => { + const f = aliasFixture(); f.ready(); + expect(f.evaluate()).toEqual({ allow: true }); + }); + + test.skipIf(skipWin)('PAX-3638 Fix B: errored first init followed by a correct init binds', () => { + const f = aliasFixture(); f.events.unshift(...f.erroredInit('bad-init') as any); f.ready(); + expect(f.evaluate()).toEqual({ allow: true }); + }); + + test.skipIf(skipWin)('PAX-3638 Fix C: errored init with no later success is init_failed and says re-run', () => { + const f = aliasFixture(); (f.events[1] as any).isError = true; f.ready(); + const decision = f.evaluate() as any, reason: string = decision.reason; + expect(decision.allow).toBe(false); expect(reason).toContain('(code init_failed,'); + expect(reason).toContain('re-run'); + expect(reason).toContain('The most recent snapshot init call failed'); + }); + + test.skipIf(skipWin)('PAX-3638 Fix B: a trailing errored init after a good bind is init_failed', () => { + const f = aliasFixture(); f.events.splice(2, 0, ...f.erroredInit('late-bad-init') as any); f.ready(); + expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('(code init_failed,') }); + }); + + test.skipIf(skipWin)('PAX-3638 Fix B: a later good init supersedes a trailing errored one', () => { + const f = aliasFixture(); f.events.splice(2, 0, ...f.erroredInit('late-bad-init') as any); + f.events.splice(4, 0, { ...(structuredClone(f.events[0]!) as any), toolUseId: 'again' }, + { ...(structuredClone(f.events[1]!) as any), toolUseId: 'again', content: JSON.stringify({ ...f.init, reused: true }) }); + f.ready(); + expect(f.evaluate()).toEqual({ allow: true }); + }); + + for (const field of ['activePlan', 'restorePath'] as const) + test.skipIf(skipWin)(`PAX-3638 Fix C: retargeted ${field} is init_mismatch without a re-run hint`, () => { + const f = aliasFixture(); (f.events[1] as any).content = JSON.stringify({ ...f.init, [field]: f.source }); f.ready(); + const decision = f.evaluate() as any, reason: string = decision.reason; + expect(decision.allow).toBe(false); expect(reason).toContain('(code init_mismatch,'); + expect(reason).toContain('do not match the canonical form of its own command arguments'); + expect(reason).not.toContain('re-run'); + }); + + test.skipIf(skipWin)('PAX-3638 Fix B: zero results for an init use still denies', () => { + const f = aliasFixture(); f.events.splice(1, 1); f.ready(); + expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('(code init_mismatch,') }); + }); + + test.skipIf(skipWin)('PAX-3638 Fix A: an argv that cannot be canonicalized is init_mismatch, not a hook crash', () => { + const f = aliasFixture(); + (f.events[0] as any).input.command = `bun "${ROOT}/bin/gstack-autoplan-snapshot.ts" init "${f.source}" "${f.source}/not-a-dir/active.md" "${f.aliasRestore}"`; + f.ready(); + expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('(code init_mismatch,') }); + }); + + test.skipIf(skipWin)('PAX-3638 Fix A: a mistyped tool path is unbindable, and a later correct init still binds', () => { + const f = aliasFixture(); + const bad = { ...(structuredClone(f.events[0]!) as any), toolUseId: 'typo' }; + bad.input.command = bad.input.command.replace('gstack-autoplan-snapshot.ts', 'gstack-autoplan-snapshto.ts'); + f.events.unshift(bad); f.ready(); + expect(f.evaluate()).toEqual({ allow: true }); + }); + + test.skipIf(skipWin)('PAX-3638 Fix A2: a pending Edit through the alias spelling is the same file', () => { + const f = aliasFixture(); f.message(); + f.use('alias-edit', 'Edit', { file_path: f.aliasActive, old_string: 'Keep documented behavior.', new_string: 'Implement new behavior.' }); + f.current(); f.reorder(); + expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('(code mutation_pending,') }); + }); +});