mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
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:
10 files changed
+232
-33
No files matched your search
@@ -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,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,
|
||||
|
||||
@@ -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.' },
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,') });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user