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 <noreply@anthropic.com>

* 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 <wyatt@Wyatts-MacBook-Pro.local>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-08 15:26:56 +00:00
1 parent 9a1dc81a2b
commit 4e5ffbb857
10 files changed
+232 -33

No files matched your search

+23
View File
@@ -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 `<active>` and `<restore>` 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.**
+1 -1
View File
@@ -1 +1 @@
1.91.54.0
1.91.59.0
+1 -1
View File
@@ -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,
+2 -2
View File
@@ -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 "<SNAPSHOT_TOOL>" init "<SOURCE_PLAN>" "<ACTIVE_PLAN>" "<RESTORE_PATH>"` (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.' },
+39 -12
View File
@@ -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<string, readonly string[]> = { 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<string, any> | 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 };
}
+17 -6
View File
@@ -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 };
+27 -3
View File
@@ -177,8 +177,14 @@ previous report.
<a id="init-mismatch"></a>
### `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 `<active>` and `<restore>`
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.
<a id="snapshot"></a>
### `snapshot`
@@ -241,7 +247,10 @@ A new `/autoplan` turn needs its own init (it may answer `reused: true`).
<a id="init-failed"></a>
### `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.
<a id="init-unbindable"></a>
### `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.
<a id="symlinked-plan-directories"></a>
## 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 `<active>` and `<restore>` 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`).
+1 -1
View File
@@ -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",
+33 -7
View File
@@ -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);
});
+88
View File
@@ -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,') });
});
});