Files
gstack/test/autoplan-method-read-audit.test.ts
T
ac20ef1e13 v1.91.16.0 fix: safe installs and upgrades, skills start everywhere, truthful completion (parallel wave) (#3014)
* fix(make-pdf,design): real TOC page numbers and anchors; design models move to gpt-5.5 + gpt-image-2 (#2903, #2807)

* ci: pin every action to a SHA, Scorecard, checksummed Bun, scoped Codex CLI in the CI image, macOS named-regressions job (#1948, #1706, #1997)

* fix(cso,browse): Windows/macOS lease identity, retention skips bad runs, truthful 'not assessed' reports, env.kv false positive, Windows browse spawn fixes (#2894, #2912, #2048, #2637, #2805)

* fix(outside-voice): one Codex model precedence for probe and dispatch, bounded timeouts with KILL escalation, probe cache identity, guarded mktemp, skills hidden in nested Codex (#2776, #2914, #2787, #2881, #2847)

* feat(setup): install registry, ./setup --status, staged activation, per-scope migrations, host tiers and conformance kit, OpenCode/Hermes fixes, Copilot CLI (experimental), disabled_skills, slimmer CLAUDE.md (#1925, #2879, #2347, #2338, #2626, #2629, #2825, #2015, #393, #1206, #2096)

Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com>
Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com>
Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com>

* fix(preamble): skills start in worktree-isolated Claude Code sessions — one literal skill-start line, context-recovery helper, no eval in skill blocks, $N token tripwire, honest plan-mode precedence (#2763, #2896, #2851)

* fix(setup): per-install renders for renamed, vendored and custom-config installs, space-safe alias roots, per-host /cso launcher path (#1882, #2906)

* test: free tests never write the real HOME (home-write tripwire), no orphaned browse daemons, symlinked-TMPDIR (macOS) fixes, gbrain-on-PATH isolation (#2895, #2816, #2829, #2841, #2982, #2911, #2909, #2908, #2910)

* chore: integrate wave follow-ups — regenerate all hosts, README model docs, TODOS, touchfiles, duration seeds, Bun 1.3 telemetry test fix, replay fixtures pinned to their recorded bytes

* v1.91.14.0: release metadata

* test(browse): group-kill cleanup falls back to the leader when macOS returns EPERM for a live group

* ci: Codex login steps run under bash (the CI container's default sh rejects pipefail)

* fix: full-suite repairs — private state roots for 7 real-HOME writers, truthful home-guard attribution with --attribute-home, gstack-paths shim in ship-hook fixtures, Copilot in team-init probes, test-of-test detector, cookie review fixture pinned to the approved excerpt

* fix(windows): LF for Dockerfiles, normalized home-guard exclusions, path.relative fixture containment, ship push test spawn timeouts

* test(design-detect): widen the whole-scan budget test's timing margins so a loaded macOS runner can't time out the single-batch call

* fix(evals): qa protocol names defined for the judge, async PTY judge so overlapping sessions keep their cleanup deadline, bws setup cases use the standard JUDGE_MS tier

* test(pty): the non-blocking judge case is POSIX-only (its fake judge is a shebang script, like the PTY harness it covers)

* test(evals): ship-coverage-audit names absolute skill paths, uses Read, and skips the Step 7 artifact nothing consumes (removes the mechanism behind 3 of 4 historical timeouts)

* test: deflake terminal-agent concurrent startup (write the placeholder record before spawning, like the product) and qa-evidence resumed pipe (barrier on a full high-water mark instead of a sleep)

* fix(design): daemon log stream handles open errors instead of crashing the daemon; shutdown test awaits the stubbed exit

* test(evals): skillify checks the product's resolved state root (no GSTACK_HOME bypass), negative cases check every root; tpa shims live outside the agent's cwd and answer consistently

* test(evals): coverage-audit detector splits parallel columns at │ rails and [+] groups; coverage prompts name absolute skill paths; TODOs from the gate eval census

* fix(plan-eng-review): scope gate always sends the menu (no headless 'Scope pending' exit); PTY harness ends idle turn-ends and API-error panels promptly with a precise diagnostic

* test(evals): one position-bound coverage-read rule credits read-only && chains (labels, context ranges, git displays in either order) and still rejects forged output

* fix(ship): documentation section has one saved source inserted by cat (never retyped); spawned-marker evidence is the child output; refreshed codex/factory ship goldens

* test(docsync): the byte-exact insert positive is POSIX-only (the bash-command observer refuses backslash paths by design)

* test(evals): plan-design-with-ui focus-title rule accepts the comma-less 'dimensions or focus' wording (replayed on the captured CI run, negative controls kept)

* fix(qa-callers): exploratory preflight says the scope/method Reads come after exploratory.md; design-review scan check credits an executed scan proven by one engine run over the changed files

---------

Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com>
Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com>
Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com>
2026-10-03 07:54:09 -07:00

466 lines
32 KiB
TypeScript

import { afterEach, describe, expect, test } from 'bun:test';
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { tmpdir } from 'node:os';
import { join, resolve, posix } from 'node:path';
import { spawnSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';
import { prepareMethodology, createSnapshot } from '../bin/gstack-autoplan-snapshot';
import { auditAutoplanMethodReads, loadAutoplanMethodologyBinding, prematureAutoplanPhaseEntry, registerAutoplanPhaseInstructionAliases,
type AutoplanPhaseInstruction } from './helpers/autoplan-method-read-audit';
import { readPlanCountTranscript, type NativePublicToolEvent } from './helpers/plan-count-transcript';
import recorded from './fixtures/autoplan-method-read-aa-events.json';
import phaseEntry from './fixtures/autoplan-phase-entry-cf74.json';
import aliasEntry from './fixtures/autoplan-phase-entry-alias-f359.json';
import homeEntry from './fixtures/autoplan-home-phase-entry-fb10.json';
const ROOT = resolve(import.meta.dir, '..');
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value));
const events = () => clone(recorded.events) as NativePublicToolEvent[];
const binding = () => clone(recorded.binding);
function complete(): NativePublicToolEvent[] {
const list = events();
const last = list.pop()!;
const common = { sessionId: last.sessionId, toolUseId: 'synthetic-tail-repair-before-dispatch' };
list.push({ ...common, kind: 'use', timestamp: '2026-09-09T12:58:00.000Z', name: 'Read',
input: { file_path: recorded.binding.path, offset: 2200, limit: 60 } });
list.push({ ...common, kind: 'result', timestamp: '2026-09-09T12:58:00.020Z', isError: false,
file: { filePath: recorded.binding.path, startLine: 2200, numLines: 60, totalLines: 2259,
content: recorded.binding.content.split('\n').slice(2199).join('\n') } });
list.push(last); return list;
}
const audit = (list = events()) => auditAutoplanMethodReads(list, binding)[0]!;
const owned: string[] = [];
afterEach(() => { for (const dir of owned.splice(0)) rmSync(dir, { recursive: true, force: true }); });
describe('actual AA parent methodology delivery boundary', () => {
test('actual six full-content ranges miss the tail at the actual dispatch', () => {
const actual = audit();
expect(actual.passed).toBe(false);
expect(actual.ranges).toHaveLength(6);
expect(actual.missing).toEqual([{ startLine: 2200, endLine: 2259 }]);
expect(actual.at).toBe('2026-09-09T12:59:34.639Z');
});
test('explicitly synthetic final range repair before dispatch completes the same content', () => {
const fixed = audit(complete());
expect(fixed.passed).toBe(true);
expect(fixed.missing).toEqual([]);
expect(fixed.ranges.at(-1)).toMatchObject({ startLine: 2200, endLine: 2259 });
});
test('a later result, foreign session, wrong path, error, or unpaired result supplies no tail', () => {
for (const change of ['later', 'foreign-session', 'wrong-path', 'error', 'unpaired']) {
const list = complete(); const result = list.at(-2)!;
if (change === 'later') { list.splice(list.length - 2, 1); list.push(result); }
if (change === 'foreign-session') result.sessionId = 'different-parent';
if (change === 'wrong-path') (result.file as any).filePath += '.other';
if (change === 'error') result.isError = true;
if (change === 'unpaired') result.toolUseId += '-orphan';
expect(audit(list).passed, change).toBe(false);
}
});
test('matching ranges/hash claims cannot replace exact delivered bytes or EOF accounting', () => {
for (const change of ['content', 'missing-eof', 'total', 'start', 'limit']) {
const list = complete(); const result = list.at(-2)!; const file = result.file as any;
if (change === 'content') file.content = file.content.replace('MODE COMPARISON', 'METHOD COMPLETE');
if (change === 'missing-eof') { file.content = file.content.slice(0, -1); file.numLines--; }
if (change === 'total') file.totalLines--;
if (change === 'start') file.startLine--;
if (change === 'limit') list.at(-3)!.input!.limit = 59;
expect(audit(list).passed, change).toBe(false);
}
});
test('malformed dispatch identities/timestamps and empty Read IDs cannot supply coverage', () => {
for (const change of ['timestamp', 'session', 'dispatch-id', 'read-id']) {
const list = complete();
if (change === 'timestamp') list.at(-1)!.timestamp = 'not-a-timestamp';
if (change === 'session') for (const event of list) event.sessionId = '';
if (change === 'dispatch-id') list.at(-1)!.toolUseId = ' ';
if (change === 'read-id') list.at(-3)!.toolUseId = list.at(-2)!.toolUseId = '';
expect(audit(list).passed, change).toBe(false);
}
});
test('conflicting same-ID results fail; identical repeated records add no extra credit', () => {
const list = complete(); list.splice(list.length - 1, 0, clone(list.at(-2)!));
expect(audit(list).passed).toBe(true);
(list.at(-2)!.file as any).content += 'altered';
expect(audit(list).passed).toBe(false);
expect(audit(list).error).toContain('Conflicting');
});
test('self-report, child reads and backward request/result chronology do not fill the gap', () => {
const list = complete(); list.at(-2)!.timestamp = '2026-09-09T12:57:00.000Z';
expect(audit(list).passed).toBe(false);
const child = complete(); child.at(-3)!.sessionId = child.at(-2)!.sessionId = 'child';
expect(audit(child).passed).toBe(false);
const claim = events(); claim.splice(claim.length - 1, 0, { sessionId: recorded.events[0]!.sessionId,
timestamp: '2026-09-09T12:59:00.000Z', toolUseId: 'claim', kind: 'result',
content: 'Methodology read completely (lines 1-2259); sha256 ' + recorded.binding.sha256 });
expect(audit(claim).passed).toBe(false);
});
test('existing owned native transcript filter projects public tool content only', () => {
const dir = mkdtempSync(join(tmpdir(), 'gstack-method-events-')); owned.push(dir);
const cwd = '/fixture/cwd'; const project = join(dir, 'projects', 'fixture'); mkdirSync(project, { recursive: true });
const records = complete().map(event => ({ cwd, isSidechain: false, sessionId: event.sessionId,
timestamp: event.timestamp, message: { role: event.kind === 'use' ? 'assistant' : 'user', content: [event.kind === 'use'
? { type: 'tool_use', id: event.toolUseId, name: event.name, input: event.input }
: { type: 'tool_result', tool_use_id: event.toolUseId, is_error: event.isError, content: event.content ?? '' }] },
toolUseResult: event.kind === 'result' ? { file: event.file } : undefined }));
const text = records.map(row => JSON.stringify(row)).join('\n') + '\n';
const native = join(project, `${recorded.events[0]!.sessionId}.jsonl`); writeFileSync(native, text);
const projection: NativePublicToolEvent[] = []; const transcript = readPlanCountTranscript(dir, cwd, e => projection.push(e));
expect(transcript.status).toBe('ready'); expect(audit(projection).passed).toBe(true);
writeFileSync(native, records.map(row => JSON.stringify({ ...row, isSidechain: true })).join('\n') + '\n');
const foreign: NativePublicToolEvent[] = []; readPlanCountTranscript(dir, cwd, e => foreign.push(e));
expect(foreign).toEqual([]);
writeFileSync(native, text.slice(0, text.lastIndexOf('\n', text.length - 2) + 1) + '{"incomplete":');
const partial: NativePublicToolEvent[] = []; readPlanCountTranscript(dir, cwd, e => partial.push(e));
expect(auditAutoplanMethodReads(partial, binding)).toEqual([]);
});
});
describe('actual immutable snapshot methodology binding', () => {
function fixture() {
const dir = mkdtempSync(join(tmpdir(), 'gstack-method-binding-')); owned.push(dir);
const restore = join(dir, 'restore.md'); const active = join(dir, 'active.md');
writeFileSync(restore, '# Original\n'); writeFileSync(active, '## Implementation plan\n# Original\n\n## Review record\n');
const method = prepareMethodology('ceo', join(ROOT, 'plan-ceo-review/SKILL.md'), restore);
const snapshot = createSnapshot('ceo', active, restore, method.methodologyPath);
return { dir, method, snapshot };
}
test('dispatch binds actual immutable snapshot/method bytes independent of filtered helper stdout', () => {
const f = fixture(); const result = loadAutoplanMethodologyBinding(f.snapshot.nativeDispatchPrompt, [f.dir]);
expect(result.sha256).toBe(f.method.sha256); expect(result.content).toBe(readFileSync(f.method.methodologyPath, 'utf8'));
expect(result.lines).toBe(f.method.lines);
expect(() => loadAutoplanMethodologyBinding(f.snapshot.nativeDispatchPrompt.replace('CEO', 'DESIGN'), [f.dir])).toThrow();
const other = fixture(); expect(() => loadAutoplanMethodologyBinding(f.snapshot.nativeDispatchPrompt, [other.dir])).toThrow();
});
test('mutable or altered snapshot/method data cannot supply valid dispatch coverage', () => {
for (const kind of ['mutable', 'native', 'method', 'manifest']) {
const f = fixture(); const target = kind === 'native' ? f.snapshot.nativePromptPath : kind === 'manifest'
? join(f.method.methodologyPath, '..', 'methodology.json') : f.method.methodologyPath;
chmodSync(target, 0o600);
if (kind !== 'mutable') { writeFileSync(target, readFileSync(target, 'utf8') + 'tamper'); chmodSync(target, 0o444); }
if (kind === 'mutable') {
if (process.platform !== 'win32') expect(() => loadAutoplanMethodologyBinding(f.snapshot.nativeDispatchPrompt, [f.dir]), kind).toThrow();
// Windows does not use POSIX permission bits. Exercise that policy in
// an isolated process with observed modes, keeping actual artifact
// paths/bytes and both the immutable control and writable rejection.
const worker = join(f.dir, 'observed-mode.ts');
writeFileSync(worker, `import { mock } from 'bun:test';
const real = { ...await import('node:fs') };
await import('node:path');
const input = JSON.parse(await Bun.stdin.text());
Object.defineProperty(process, 'platform', { value: 'linux' });
mock.module('node:fs', () => ({ ...real, lstatSync(file) {
const stat = real.lstatSync(file);
stat.mode = (stat.mode & ~0o777) | (file === input.target && input.writable ? 0o600 : 0o444);
return stat;
} }));
const { loadAutoplanMethodologyBinding } = await import(${JSON.stringify(pathToFileURL(join(ROOT, 'test/helpers/autoplan-method-read-audit.ts')).href)});
loadAutoplanMethodologyBinding(input.prompt, input.roots);
`);
for (const writable of [false, true]) {
const result = spawnSync(process.execPath, [worker], { encoding: 'utf8', timeout: 10_000,
input: JSON.stringify({ prompt: f.snapshot.nativeDispatchPrompt, roots: [f.dir], target, writable }) });
expect(result.error).toBeUndefined();
expect(result.status, result.stderr).toBe(writable ? 1 : 0);
if (writable) expect(result.stderr).toContain('Artifact is not immutable bounded regular data');
}
} else {
expect(() => loadAutoplanMethodologyBinding(f.snapshot.nativeDispatchPrompt, [f.dir]), kind).toThrow();
}
}
});
});
describe('completed parent phase-instruction Reads require an earlier published report', () => {
function entryFixture() {
const events = clone(phaseEntry.events) as NativePublicToolEvent[];
const transcript = { status: 'ready' as const, calls: [], assistantMessages: clone(phaseEntry.assistantMessages) };
const file = events[1]!.file as { filePath: string; content: string };
const instruction: AutoplanPhaseInstruction = { phase: 'design', requiredPhase: 1,
paths: [file.filePath], content: file.content };
const at = Date.parse(events[0]!.timestamp);
const startedAt = Date.parse(transcript.assistantMessages[0]!.timestamp);
const addReport = (delta: number, sessionId = events[0]!.sessionId) => transcript.assistantMessages.push({
sessionId, timestamp: new Date(at + delta).toISOString(), text: 'Phase 1 complete.' });
return { events, transcript, instruction, at, startedAt, addReport,
audit: () => prematureAutoplanPhaseEntry(events, transcript, [instruction], startedAt) };
}
test('the exact cf74 completed Design Read fails despite successful full CEO export readback', () => {
const f = entryFixture();
expect(phaseEntry.originalOutcome).toContain('root cancellation, no Bun verdict invented');
expect(phaseEntry.readback.equalsCurrentExport).toBe(true);
expect(f.audit()).toMatchObject({ phase: 'design', requiredPhase: 1,
readToolUseId: 'toolu_01XvX1QbuKqv1xWjpdHsFLnj', readAt: '2026-09-16T01:28:04.501Z' });
});
test.each([-1, 0, 1, 1000])('a report at request %+d ms preserves its actual temporal meaning', delta => {
const f = entryFixture(); f.addReport(delta);
// Equality is indeterminate, not evidence of ordering. Neither null result
// nor this early abort adds a completion to the unchanged end assertions.
expect(f.audit() === null).toBe(delta <= 0);
if (delta > 0) expect(f.audit()!.reportAt).toBe(new Date(f.at + delta).toISOString());
});
test('another parent session or source/future text cannot supply the required report', () => {
const f = entryFixture(); f.addReport(-1, 'different-session');
expect(f.audit()).not.toBeNull();
f.transcript.assistantMessages.push({ sessionId: f.events[0]!.sessionId,
timestamp: new Date(f.at - 1).toISOString(), text: '```text\nPhase 1 complete.\n```\nI will publish after Design.' });
expect(f.audit()).not.toBeNull();
});
test.each(['foreign-request', 'foreign-result', 'foreign-session', 'unpaired', 'missing-result', 'missing-use',
'error', 'unknown-status', 'backward-time', 'backward-order', 'stale', 'content', 'total', 'offset', 'limit',
'empty-id', 'invalid-time', 'conflicting-result', 'conflicting-request'] as const)
('%s does not establish an owned successful phase entry', change => {
const f = entryFixture(), use = f.events[0]!, result = f.events[1]!, file = result.file as any;
if (change === 'foreign-request') use.input!.file_path = '/foreign/autoplan/sections/design-phase.md';
if (change === 'foreign-result') file.filePath = '/foreign/autoplan/sections/design-phase.md';
if (change === 'foreign-session') result.sessionId = 'other';
if (change === 'unpaired') result.toolUseId += '-other';
if (change === 'missing-result') f.events.pop();
if (change === 'missing-use') f.events.shift();
if (change === 'error') result.isError = true;
if (change === 'unknown-status') delete result.isError;
if (change === 'backward-time') result.timestamp = new Date(f.at - 1).toISOString();
if (change === 'backward-order') f.events.reverse();
if (change === 'stale') use.timestamp = new Date(f.startedAt - 1).toISOString();
if (change === 'content') file.content += 'Changed';
if (change === 'total') file.totalLines++;
if (change === 'offset') use.input!.offset = 2;
if (change === 'limit') use.input!.limit = 1;
if (change === 'empty-id') use.toolUseId = result.toolUseId = '';
if (change === 'invalid-time') use.timestamp = 'invalid';
if (change === 'conflicting-result') f.events.push({ ...result, isError: true });
if (change === 'conflicting-request') f.events.unshift({ ...use, input: { file_path: '/foreign/design-phase.md' } });
expect(f.audit()).toBeNull();
});
test('identical duplicate records and a successful partial source Read still establish entry', () => {
const f = entryFixture(); f.events.push(clone(f.events[1]!));
expect(f.audit()).not.toBeNull();
const partial = entryFixture(), file = partial.events[1]!.file as any;
partial.events[0]!.input!.offset = file.startLine = 2;
partial.events[0]!.input!.limit = file.numLines = 3;
file.content = partial.instruction.content.split('\n').slice(1, 4).join('\n');
expect(partial.audit()).not.toBeNull();
});
test.each([['design', 1], ['dx', 2], ['eng', 2.5]] as const)
('current %s instruction is bound to required phase %s', (phase, requiredPhase) => {
const f = entryFixture(), path = join(ROOT, 'autoplan', 'sections', `${phase}-phase.md`);
f.instruction = { phase, requiredPhase, paths: [path], content: readFileSync(path, 'utf8') };
f.events[0]!.input = { file_path: path };
f.events[1]!.file = { filePath: path, content: f.instruction.content, startLine: 1,
numLines: f.instruction.content.split('\n').length, totalLines: f.instruction.content.split('\n').length };
expect(prematureAutoplanPhaseEntry(f.events, f.transcript, [f.instruction], f.startedAt))
.toMatchObject({ phase, requiredPhase });
f.transcript.assistantMessages.push({ sessionId: f.events[0]!.sessionId,
timestamp: new Date(f.at - 1).toISOString(), text: `Phase ${requiredPhase} complete.` });
expect(prematureAutoplanPhaseEntry(f.events, f.transcript, [f.instruction], f.startedAt)).toBeNull();
});
test('the existing native parent reader excludes child and foreign-cwd phase Reads', () => {
const f = entryFixture(), dir = mkdtempSync(join(tmpdir(), 'gstack-phase-entry-')); owned.push(dir);
const project = join(dir, 'projects', 'fixture'); mkdirSync(project, { recursive: true });
const cwd = '/owned/fixture';
const rows = f.events.map(event => ({ cwd, isSidechain: false, sessionId: event.sessionId,
timestamp: event.timestamp, message: { role: event.kind === 'use' ? 'assistant' : 'user', content: [event.kind === 'use'
? { type: 'tool_use', id: event.toolUseId, name: event.name, input: event.input }
: { type: 'tool_result', tool_use_id: event.toolUseId, is_error: event.isError, content: event.content }] },
toolUseResult: event.kind === 'result' ? { file: event.file } : undefined }));
const journal = join(project, `${f.events[0]!.sessionId}.jsonl`);
for (const variant of ['parent', 'child', 'foreign-cwd']) {
writeFileSync(journal, rows.map(row => JSON.stringify({ ...row,
...(variant === 'child' ? { isSidechain: true } : {}),
...(variant === 'foreign-cwd' ? { cwd: '/other/fixture' } : {}) })).join('\n') + '\n');
const projected: NativePublicToolEvent[] = [];
const transcript = readPlanCountTranscript(dir, cwd, event => projected.push(event));
expect(prematureAutoplanPhaseEntry(projected, transcript, [f.instruction], f.startedAt) !== null).toBe(variant === 'parent');
}
});
});
describe('owned installed phase aliases use the actual chain registration', () => {
function aliasFixture(phase: AutoplanPhaseInstruction['phase'] = 'design',
content = readFileSync(join(ROOT, 'autoplan', 'sections', `${phase}-phase.md`), 'utf8')) {
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'gstack-phase-alias-'))); owned.push(dir);
const source = join(dir, 'source', 'autoplan'), config = join(dir, '.claude');
const canonical = join(source, 'sections', `${phase}-phase.md`);
// Populate the owned source before installing links; never write through a registration.
mkdirSync(join(source, 'sections'), { recursive: true }); writeFileSync(canonical, content);
mkdirSync(join(config, 'skills', 'gstack'), { recursive: true });
const short = join(config, 'skills', 'autoplan'), legacy = join(config, 'skills', 'gstack', 'autoplan');
for (const alias of [short, legacy]) symlinkSync(source, alias, 'junction');
const events = clone(aliasEntry.events) as NativePublicToolEvent[];
const transcript = { status: 'ready' as const, calls: [], assistantMessages: clone(aliasEntry.assistantMessages) };
const instruction: AutoplanPhaseInstruction = { phase, requiredPhase: phase === 'design' ? 1 : phase === 'dx' ? 2 : 2.5,
paths: [canonical], content };
const usePath = (filePath: string) => {
events[0]!.input!.file_path = filePath;
events[1]!.file = { ...(events[1]!.file as object), filePath, content,
numLines: content.split('\n').length, totalLines: content.split('\n').length };
};
usePath(join(short, 'sections', `${phase}-phase.md`));
const register = () => registerAutoplanPhaseInstructionAliases([instruction], config);
const startedAt = Date.parse(transcript.assistantMessages[0]!.timestamp);
const audit = () => prematureAutoplanPhaseEntry(events, transcript, [instruction], startedAt);
return { dir, source, config, canonical, short, legacy, instruction, events, transcript, startedAt, register, usePath, audit };
}
test('the retained f359 short alias Read/ACK establishes the missed premature Design entry', () => {
// The retained Read carries the section bytes installed at capture time.
const f = aliasFixture('design', (aliasEntry.events[1]!.file as { content: string }).content);
expect(createHash('sha256').update(f.instruction.content).digest('hex')).toBe(aliasEntry.sourceSha256);
expect(f.instruction.content).toBe((aliasEntry.events[1]!.file as { content: string }).content);
expect(f.transcript.assistantMessages).toHaveLength(34);
expect(f.audit()).toBeNull(); // Canonical alone reproduces the actual unregistered path.
f.register();
expect(f.audit()).toMatchObject({ phase: 'design', requiredPhase: 1,
sessionId: '45abf2fa-0d62-471f-9efa-9a0d5b2ec1b5', readToolUseId: 'toolu_0116k1GsR8JxowqSYBJtJbpi',
readAt: '2026-09-16T08:26:52.230Z', resultAt: '2026-09-16T08:26:52.251Z' });
});
test.each(['design', 'dx', 'eng'] as const)('both supported %s aliases and the canonical source retain the same boundary', phase => {
const f = aliasFixture(phase); f.register(); f.register();
const paths = [f.canonical, ...[f.short, f.legacy].map(alias => join(alias, 'sections', `${phase}-phase.md`))];
expect([...f.instruction.paths].sort()).toEqual(paths.sort());
for (const filePath of paths) {
f.usePath(filePath);
expect(f.audit()).toMatchObject({ phase, requiredPhase: f.instruction.requiredPhase });
}
});
test.each(['foreign-target', 'unregistered-path', 'changed-source', 'missing-alias'] as const)
('%s cannot register an owned-looking phase entry', change => {
const f = aliasFixture();
const foreign = join(f.dir, 'foreign', 'autoplan'); mkdirSync(join(foreign, 'sections'), { recursive: true });
writeFileSync(join(foreign, 'sections', 'design-phase.md'), f.instruction.content);
if (change === 'foreign-target' || change === 'missing-alias') {
rmSync(f.short);
symlinkSync(change === 'foreign-target' ? foreign : join(f.dir, 'missing'), f.short, 'junction');
}
if (change === 'unregistered-path') f.usePath(join(foreign, 'sections', 'design-phase.md'));
if (change === 'changed-source') writeFileSync(f.canonical, f.instruction.content + 'Changed after binding.\n');
f.register();
expect(f.audit()).toBeNull();
});
test.each(['foreign-result', 'changed-result', 'error', 'unknown-status', 'unpaired', 'missing-ack', 'backward-ack'] as const)
('a registered alias with %s cannot establish a successful entry', change => {
const f = aliasFixture(); f.register(); const result = f.events[1]!, file = result.file as any;
if (change === 'foreign-result') file.filePath = join(f.dir, 'foreign', 'design-phase.md');
if (change === 'changed-result') file.content += 'Changed';
if (change === 'error') result.isError = true;
if (change === 'unknown-status') delete result.isError;
if (change === 'unpaired') result.toolUseId += '-orphan';
if (change === 'missing-ack') f.events.pop();
if (change === 'backward-ack') result.timestamp = new Date(Date.parse(f.events[0]!.timestamp) - 1).toISOString();
expect(f.audit()).toBeNull();
});
test.each([-1, 0, 1, 30_000])('publication at alias request %+d ms keeps request-time ordering even with a later ACK', delta => {
const f = aliasFixture(); f.register(); const at = Date.parse(f.events[0]!.timestamp);
f.events[1]!.timestamp = new Date(at + 60_000).toISOString();
f.transcript.assistantMessages.push({ sessionId: f.events[0]!.sessionId,
timestamp: new Date(at + delta).toISOString(), text: 'Phase 1 complete.' });
expect(f.audit() === null).toBe(delta <= 0);
});
test.each(['parent', 'child', 'foreign-cwd', 'pending-ack'] as const)
('the owned native reader preserves %s semantics for the captured short alias', variant => {
const f = aliasFixture(); f.register();
const project = join(f.config, 'projects', 'fixture'); mkdirSync(project, { recursive: true });
const rows = f.events.slice(0, variant === 'pending-ack' ? 1 : 2).map(event => ({
cwd: variant === 'foreign-cwd' ? join(f.dir, 'foreign-cwd') : f.dir, isSidechain: variant === 'child',
sessionId: event.sessionId, timestamp: event.timestamp,
message: { role: event.kind === 'use' ? 'assistant' : 'user', content: [event.kind === 'use'
? { type: 'tool_use', id: event.toolUseId, name: event.name, input: event.input }
: { type: 'tool_result', tool_use_id: event.toolUseId, is_error: event.isError, content: event.content }] },
toolUseResult: event.kind === 'result' ? { file: event.file } : undefined,
}));
writeFileSync(join(project, `${f.events[0]!.sessionId}.jsonl`), rows.map(row => JSON.stringify(row)).join('\n') + '\n');
const projected: NativePublicToolEvent[] = [];
const transcript = readPlanCountTranscript(f.config, f.dir, event => projected.push(event));
expect(prematureAutoplanPhaseEntry(projected, transcript, [f.instruction], f.startedAt) !== null).toBe(variant === 'parent');
});
});
describe('the seeded launcher HOME registry preserves the phase publication boundary', () => {
function fixture(phase: AutoplanPhaseInstruction['phase'] = 'design',
content = readFileSync(join(ROOT, 'autoplan', 'sections', `${phase}-phase.md`), 'utf8')) {
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'gstack-phase-home-'))); owned.push(dir);
const runRoot = join(dir, 'run'), config = join(runRoot, 'with-skills', '.claude');
const home = join(runRoot, 'skill-home-owned'), stateRoot = join(home, '.gstack');
const registry = join(home, '.claude', 'skills'), source = join(dir, 'source');
const canonical = join(source, 'autoplan', 'sections', `${phase}-phase.md`);
// Populate canonical files before creating links. All fixture writes stay in dir.
for (const path of [config, stateRoot, registry, join(source, 'autoplan', 'sections')]) mkdirSync(path, { recursive: true });
writeFileSync(canonical, content);
symlinkSync(source, join(registry, 'gstack'), 'junction');
symlinkSync(join(source, 'autoplan'), join(registry, 'autoplan'), 'junction');
const installed = join(registry, 'gstack', 'autoplan', 'sections', `${phase}-phase.md`);
const instruction: AutoplanPhaseInstruction = { phase, requiredPhase: phase === 'design' ? 1 : phase === 'dx' ? 2 : 2.5,
paths: [canonical], content };
// Preserve the literal native packet and all parent messages; only map the
// captured file path to this isolated install for executable ownership checks.
const events = clone(homeEntry.events) as NativePublicToolEvent[];
const transcript = { status: 'ready' as const, calls: [], assistantMessages: clone(homeEntry.assistantMessages) };
const usePath = (filePath: string) => {
events[0]!.input!.file_path = filePath;
events[1]!.file = { ...(events[1]!.file as object), filePath, content,
numLines: content.split('\n').length, totalLines: content.split('\n').length };
};
usePath(installed);
const register = (state = stateRoot) => registerAutoplanPhaseInstructionAliases([instruction], config, state);
const startedAt = Date.parse(transcript.assistantMessages[0]!.timestamp);
const audit = () => prematureAutoplanPhaseEntry(events, transcript, [instruction], startedAt);
return { dir, runRoot, config, home, stateRoot, registry, source, canonical, installed, instruction,
events, transcript, usePath, register, audit };
}
test('actual fb10 HOME Read was missed despite exact canonical bytes and no parent publication', () => {
const f = fixture('design', homeEntry.events[1]!.file!.content);
expect(createHash('sha256').update(f.instruction.content).digest('hex')).toBe(homeEntry.sourceSha256);
expect(homeEntry.observedPrematurePhaseEntry).toBeNull();
expect(homeEntry.events[1]!.file!.content).toBe(f.instruction.content);
expect(homeEntry.assistantMessages).toHaveLength(35);
expect(homeEntry.events[0]!.input!.file_path).toBe(posix.join(homeEntry.ownedSkillStateRoot, '..', '.claude', 'skills', 'gstack', 'autoplan', 'sections', 'design-phase.md'));
registerAutoplanPhaseInstructionAliases([f.instruction], f.config);
expect(f.audit()).toBeNull(); // Original registration reproduces the missing alias.
f.register();
expect(f.audit()).toEqual({ phase: 'design', requiredPhase: 1,
sessionId: 'd3dddf71-ec90-4aa3-a510-f0eb9d85ad5d', readToolUseId: 'toolu_01CvVuWnRgxP6wn1iFM31wnt',
readAt: '2026-09-17T01:18:58.990Z', resultAt: '2026-09-17T01:18:59.007Z' });
});
test.each(['design', 'dx', 'eng'] as const)('the same owned root binds both %s HOME aliases once', phase => {
const f = fixture(phase); f.register(); f.register();
const paths = [f.canonical, ...[['autoplan'], ['gstack', 'autoplan']].map(parts => join(f.registry, ...parts, 'sections', `${phase}-phase.md`))];
expect([...f.instruction.paths].sort()).toEqual(paths.sort());
for (const path of paths) { f.usePath(path); expect(f.audit()).toMatchObject({ phase, requiredPhase: f.instruction.requiredPhase }); }
});
test.each(['missing-state', 'foreign-state', 'wrong-state-name', 'non-seeded-home', 'state-symlink', 'home-symlink',
'config-symlink', 'registry-symlink', 'foreign-target', 'missing-target', 'stale-source'] as const)
('%s establishes no installed alias', change => {
const f = fixture(); let state = f.stateRoot;
const foreign = join(f.dir, 'foreign'); mkdirSync(foreign);
if (change === 'missing-state') rmSync(f.stateRoot, { recursive: true });
if (change === 'foreign-state') { state = join(foreign, 'skill-home-other', '.gstack'); mkdirSync(state, { recursive: true }); }
if (change === 'wrong-state-name') { state = join(f.home, 'other'); mkdirSync(state); }
if (change === 'non-seeded-home') { state = join(f.runRoot, 'other', '.gstack'); mkdirSync(state, { recursive: true }); }
const replaced = change === 'state-symlink' ? f.stateRoot : change === 'home-symlink' ? f.home
: change === 'config-symlink' ? f.config : change === 'registry-symlink' ? f.registry : null;
if (replaced) { renameSync(replaced, replaced + '-saved'); symlinkSync(replaced + '-saved', replaced, 'junction'); }
if (change === 'foreign-target' || change === 'missing-target') {
mkdirSync(join(foreign, 'autoplan', 'sections'), { recursive: true });
writeFileSync(join(foreign, 'autoplan', 'sections', 'design-phase.md'), f.instruction.content);
rmSync(join(f.registry, 'gstack'));
symlinkSync(change === 'foreign-target' ? foreign : join(f.dir, 'missing'), join(f.registry, 'gstack'), 'junction');
}
if (change === 'stale-source') writeFileSync(f.canonical, f.instruction.content + 'Changed after binding.\n');
f.register(state); expect(f.audit()).toBeNull();
});
test.each(['missing-ack', 'failed-ack', 'changed-content', 'foreign-result'] as const)
('a registered HOME alias with %s cannot establish entry', change => {
const f = fixture(); f.register(); const result = f.events[1]!;
if (change === 'missing-ack') f.events.pop();
if (change === 'failed-ack') result.isError = true;
if (change === 'changed-content') (result.file as any).content += 'Changed';
if (change === 'foreign-result') (result.file as any).filePath = join(f.dir, 'foreign.md');
expect(f.audit()).toBeNull();
});
test.each([-1, 0, 1])('only actual parent publication by request time %+d ms avoids early rejection', delta => {
const f = fixture(); f.register();
f.transcript.assistantMessages.push({ sessionId: f.events[0]!.sessionId,
timestamp: new Date(Date.parse(f.events[0]!.timestamp) + delta).toISOString(), text: 'Phase 1 complete.' });
expect(f.audit() === null).toBe(delta <= 0);
});
});