Merge remote-tracking branch 'origin/capy/next-wave-fix-b' into capy/next-wave

This commit is contained in:
garrytan committed 2026-10-15 01:40:49 +00:00
commit d5f3501cba
20 files changed
+669 -200

No files matched your search

+3 -3
View File
@@ -74,7 +74,7 @@ line yourself; gstack never writes the plan.
- `gstack-autoplan status|resume --out <dir>`
read the manifest (PENDING: lines name open attempts); take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts).
- `gstack-autoplan resume --out <new dir> --from <checkpoint>`
restore a hash-verified checkpoint (GSTACK_AUTOPLAN_CHECKPOINT_DIR) into an empty directory and take ownership.
restore the newest valid checkpoint generation (GSTACK_AUTOPLAN_CHECKPOINT_DIR) into an empty directory and take ownership; `checkpoints --from <dir>` lists them.
## Reviewer result format
@@ -127,8 +127,8 @@ is the completion check. Copy the whole directory, never single files.
- `OWNER_REPLY_REQUIRED`: pass the owner's words exactly with `--owner-reply "<text>"`, `--owner-reply -` (stdin) or `--owner-reply-file <path>`
- `GATE_OWNER_REPLY_MISMATCH`: relay exactly what the owner chose (or their reply itself, e.g. `--reply rec`); ask the owner when their words and the tokens disagree
- `PLAN_AMEND_PENDING`: edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id,...>`; settle a packaging or ordering change with `gstack-autoplan revise --out <dir> --settle <id>`; or drop one with `gstack-autoplan revise --out <dir> --withdraw <decision id>`
- `AMENDMENT_NOT_SETTLEABLE`: edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id>`; settle only packaging or ordering changes on an unchanged plan
- `CHECKPOINT_INVALID`: restore from an intact copy of `<GSTACK_AUTOPLAN_CHECKPOINT_DIR>/<run>/` (its `restore.md` names the command); nothing was written to --out
- `AMENDMENT_NOT_SETTLEABLE`: edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id>` (add `--settle <id>` to settle packaging/ordering changes in the same call); settle alone only on the plan the run already reviews
- `CHECKPOINT_INVALID`: run `gstack-autoplan checkpoints --from <dir>` to see each generation's hash status and restore an intact one; nothing was written to --out
- `GSTACK_VERSION_CHANGED`: nothing to do for an older run (journaled forward migrations ran); a run written by a newer gstack needs that gstack: upgrade with bin/gstack-upgrade
## Coordinator contract (inherited by every lane)
+25 -12
View File
@@ -16,6 +16,7 @@
* gstack-autoplan status --out <dir> [--json] query: the manifest, no GSTACK_RESULT line
* gstack-autoplan export --out <dir> [--json] rewrite plan.md, review-record.md, findings/tasks.jsonl and run.json hashes
* gstack-autoplan release --out <dir> --attempt <id> drop a reservation for an attempt that never ran
* gstack-autoplan checkpoints --from <dir> list checkpoint generations, newest first, with hash ok|bad
* gstack-autoplan contract print the coordinator contract every lane prompt inherits (plan E2)
*
* Logic: lib/autoplan-run.ts (next/submit/resume), lib/autoplan-gate.ts (close, gate, answer),
@@ -26,8 +27,8 @@
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { answerGate, applyGuidance, recommendGate } from '../lib/autoplan-gate';
import { checkpointRun, restoreCheckpoint } from '../lib/autoplan-checkpoint';
import { amendmentSteps, answerGate, recommendGate } from '../lib/autoplan-gate';
import { checkpointLines, checkpointRun, restoreCheckpoint } from '../lib/autoplan-checkpoint';
import { admit, touchInstallLease } from '../lib/autoplan-admission';
import { exportRun } from '../lib/autoplan-export';
import { migrateRun } from '../lib/autoplan-migrate';
@@ -69,7 +70,9 @@ DRIVER: ${driverFile()}
recommend --out <dir> --gate-rev <n> --item <id> --option <k> --basis "<why>" [--by <who>]
set one item's recommendation before relay (provenance recorded, p1 follows); re-render and relay afterwards
resume --out <dir> [--from <checkpoint>] [--json] --from restores a hash-verified checkpoint into an empty --out first
(GSTACK_AUTOPLAN_CHECKPOINT_DIR set: every command leaving the run at rest writes <dir>/<run>/ and prints CHECKPOINT:)
(a collection or root restores its newest valid generation; GSTACK_AUTOPLAN_CHECKPOINT_DIR set: every command
leaving the run at rest publishes a generation under <dir>/<run>/ and prints CHECKPOINT: on stderr)
checkpoints --from <dir> list checkpoint generations, newest first: run, gate_rev, time, hash ok|bad, current
status --out <dir> [--json] export --out <dir> release --out <dir> --attempt <id>
contract print lib/coordinator-contract.ts (the block every lane and reviewer prompt inherits word for word)
@@ -98,11 +101,7 @@ const resultLineFor = (status: string, out: string) => `GSTACK_RESULT: skill=aut
const listFlag = (args: string[], name: string) => args.flatMap((a, i) => (a === name && args[i + 1] ? args[i + 1]!.split(',').map(x => x.trim()).filter(Boolean) : []));
/** The NEXT line after an answer or revise: per pending change, settle it or edit the plan and revise; else next. */
function nextLine(state: RunState, out: string): string {
const pending = pendingAmendments(state);
const settle = pending.filter(a => applyGuidance(a).startsWith('no plan edit')).map(a => a.item ?? a.decision);
const edit = pending.filter(a => !applyGuidance(a).startsWith('no plan edit')).map(a => a.decision);
if (!pending.length) return `NEXT: gstack-autoplan next --out ${out}`;
return `NEXT: ${[edit.length ? `edit the plan, then gstack-autoplan revise --out ${out} --plan ${state.plan_input ?? state.plan_source} --incorporates ${edit.join(',')}` : '', settle.length ? `gstack-autoplan revise --out ${out} --settle ${settle.join(',')}` : ''].filter(Boolean).join('; ')}`;
return pendingAmendments(state).length ? `NEXT: ${amendmentSteps(state, out)}` : `NEXT: gstack-autoplan next --out ${out}`;
}
/**
@@ -128,12 +127,21 @@ function mutating(out: string, json: boolean, fn: () => number): number {
if (notices.length) (json ? process.stderr : process.stdout).write(notices.join('\n') + '\n');
const code = fn();
if (admission.deviation && fs.existsSync(path.join(out, FILES.run))) { const state = readState(out); if (record(state)) writeState(state); }
const checkpoint = code === EXIT.ok ? checkpointRun(out) : undefined;
if (checkpoint) process.stderr.write(checkpoint + '\n');
if (code === EXIT.ok) checkpointQuietly(out);
return code;
});
}
/** A checkpoint never changes a committed command's outcome: a failure is a stderr warning, the exit code stands. */
function checkpointQuietly(out: string): void {
try {
const line = checkpointRun(out);
if (line) process.stderr.write(line + '\n');
} catch (e: any) {
process.stderr.write(`CHECKPOINT: failed for ${out}: ${e instanceof RunError ? `${e.detail} (${e.code})` : e?.message ?? e}; the command's result stands, the previous checkpoint stays current\n`);
}
}
function emit(json: boolean, lines: string[], payload: Record<string, unknown>): void {
if (json) process.stdout.write(JSON.stringify(payload) + '\n');
else process.stdout.write(lines.join('\n') + '\n');
@@ -238,6 +246,12 @@ async function main(argv: string[]): Promise<number> {
const json = rest.includes('--json');
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; }
if (cmd === 'contract') { process.stdout.write(COORDINATOR_CONTRACT + '\n'); return EXIT.ok; }
if (cmd === 'checkpoints') {
const from = flag(rest, '--from');
if (!from) return usage('checkpoints needs --from <checkpoint dir>');
try { process.stdout.write(checkpointLines(from).join('\n') + '\n'); return EXIT.ok; }
catch (e: any) { if (!(e instanceof RunError)) throw e; process.stderr.write(resultLine('gstack-autoplan checkpoints', e.code, e.detail) + '\n'); return EXIT.refused; }
}
const outFlag = flag(rest, '--out');
if (!outFlag) return usage(`${cmd} needs --out <dir>`);
const out = path.resolve(outFlag);
@@ -256,8 +270,7 @@ async function main(argv: string[]): Promise<number> {
const restored = from !== undefined ? restoreCheckpoint(from, out) : [];
return withRunLock(out, () => {
const r = resumeRun(out);
const checkpoint = checkpointRun(out);
if (checkpoint) process.stderr.write(checkpoint + '\n');
checkpointQuietly(out);
const lines = [...restored, ...r.lines, resultLineFor(r.state.status, out)];
emit(json, lines, { run: r.state.run, status: r.state.status, execution_unknown: r.unknown, rebound: r.rebound, out });
return EXIT.ok;
+1
View File
@@ -192,6 +192,7 @@ gstack_result_anchor() {
AMENDMENT_NOT_SETTLEABLE) printf '%s' 'amendment-not-settleable' ;;
GATE_REC_STALE) printf '%s' 'gate-rec-stale' ;;
CHECKPOINT_INVALID) printf '%s' 'checkpoint-invalid' ;;
CHECKPOINT_OVERLAP) printf '%s' 'checkpoint-overlap' ;;
CHECKPOINT_TARGET_EXISTS) printf '%s' 'checkpoint-target-exists' ;;
OWNER_REPLY_REQUIRED) printf '%s' 'owner-reply-required' ;;
GATE_OWNER_REPLY_MISMATCH) printf '%s' 'gate-owner-reply-mismatch' ;;
+41 -17
View File
@@ -2813,14 +2813,18 @@ pending, revise without `--incorporates`:
**Meaning.** `gstack-autoplan revise --settle <id>` discharges an accepted change
without a plan edit, which is honest only for a packaging or ordering change
(the gate item's `changes` is `packaging` or `ordering`) on a plan nobody has
edited since it was reviewed. The named change alters scope, cost or direction,
names no `changes` value, or the plan file's Implementation plan no longer
matches the reviewed snapshot. Nothing was changed.
(the gate item's `changes` is `packaging` or `ordering`) on the plan the run
already reviews: the plan the owner accepted it on, or one a later `revise`
recorded. The named change alters scope, cost or direction, names no `changes`
value, the plan file has edits no `revise` has recorded, or `--settle` came with
a `--plan` that changes the Implementation plan while incorporating nothing.
Nothing was changed.
**Fix.** Edit the plan, then
`gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id>`.
The `APPLY:` line for each change says which path it takes.
`gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id>`,
adding `--settle <id>` to settle packaging or ordering changes in the same call
(or settle them after the revise). The `APPLY:` line for each change says which
path it takes, and `NEXT:` lists the settle step first.
<a id="gate-rec-stale"></a>
### `GATE_REC_STALE`
@@ -2829,24 +2833,44 @@ The `APPLY:` line for each change says which path it takes.
was rendered, so a `rec` (or `all`) reply the owner gave to the earlier render
would take recommendations they never saw. Every render prints
`rec_hash=<h>`; once `recommend` has run, a `rec` answer must name the hash of
the render the owner answered.
the render the owner answered. The same refusal meets a `rec`/`all` reply to a
gate written before `rec` existed (a `gate.json` with no `rec_rev`, from
1.91.101.0 or earlier): that render told the owner `all` leaves items
recommended `b` pending, and `all` now takes them.
**Fix.** Render again (`gstack-gate render <dir>/gate.json --all`), relay it,
and answer with `--rec-hash <h>` from that render.
and answer with `--rec-hash <h>` from that render. Explicit `<id><option>`
tokens are never refused.
<a id="checkpoint-invalid"></a>
### `CHECKPOINT_INVALID`
**Meaning.** `gstack-autoplan resume --out <dir> --from <checkpoint>` refused
the checkpoint: `checkpoint.json` is unreadable or not a schema_version 1
checkpoint, or a listed file is missing, is not a regular file, escapes the
checkpoint directory, or does not match its recorded sha256. Nothing was
written to `--out`.
**Meaning.** `gstack-autoplan resume --out <dir> --from <checkpoint>` found no
generation it can restore: a `checkpoint.json` is unreadable or not a
schema_version 1 checkpoint, a listed file is missing, is not a regular file,
escapes the generation (an `..` path or a symlinked directory), or does not
match its recorded sha256; or the staged run does not pass
`gstack-artifact validate`. `--from` naming a run's collection restores the
newest generation that verifies, skipping corrupt newer ones; a root holding
several runs must name one. Nothing was written to `--out`.
**Fix.** Restore from an intact copy. With `GSTACK_AUTOPLAN_CHECKPOINT_DIR`
set, every command that leaves the run at rest rewrites
`<dir>/<run>/` (its `restore.md` names the exact command); a copy that was
edited or partially synced fails here by design.
**Fix.** `gstack-autoplan checkpoints --from <dir>` lists every generation
with `hash=ok` or `hash=bad (<reason>)`; restore an intact one by passing its
directory. With `GSTACK_AUTOPLAN_CHECKPOINT_DIR` set, every command that
leaves the run at rest publishes a new generation and keeps the previous one.
<a id="checkpoint-overlap"></a>
### `CHECKPOINT_OVERLAP`
**Meaning.** The checkpoint directory for this run and the run directory (or,
on restore, the checkpoint and `--out` or its staging directory) are the same
directory or one contains the other, after resolving symlinks. Copying one
into the other would replace or delete the live run, so nothing was touched.
A checkpoint refusal after a committed command prints `CHECKPOINT: failed …`
on stderr and leaves that command's exit code alone.
**Fix.** Point `GSTACK_AUTOPLAN_CHECKPOINT_DIR` (or `--from` / `--out`) at a
directory outside the run, for example the private project-user Drive folder.
<a id="checkpoint-target-exists"></a>
### `CHECKPOINT_TARGET_EXISTS`
+8 -2
View File
@@ -239,8 +239,14 @@ the plan.
consumption steps above apply unchanged. `$AP status --out <dir>` and
`resume` work after the run directory moves (paths are re-rooted; journal
paths are run-relative). With `GSTACK_AUTOPLAN_CHECKPOINT_DIR` set, a run
at rest is copied, hash-listed, to `<dir>/<run>/`; `$AP resume --out <new
dir> --from <dir>/<run>` verifies and restores it (`CHECKPOINT_INVALID`).
at rest publishes an immutable, hash-listed generation under
`<dir>/<run>/generations/` and switches `current.json` to it (the previous
generation is kept; a stale copy never replaces a newer one; a directory
overlapping the run is refused with `CHECKPOINT_OVERLAP`; a failure is a
`CHECKPOINT: failed` warning that leaves the command's exit code alone).
`$AP checkpoints --from <dir>` lists generations with their hash status, and
`$AP resume --out <new dir> --from <dir>/<run>` restores the newest valid
one after validating the staged run (`CHECKPOINT_INVALID`).
Concurrency and recovery: every state write is a locked read-modify-write
(`run.lock`; a second writer sees `RUN_LOCKED`), attempts are journaled to
+185 -72
View File
@@ -2,26 +2,71 @@
* autoplan-checkpoint — an opt-in durable copy of a run directory (next-wave
* B12). With GSTACK_AUTOPLAN_CHECKPOINT_DIR set, every mutating command that
* leaves the run at rest (gate_pending, amend_pending, complete, incomplete)
* publishes `<dir>/<run>/`: `checkpoint.json` (every file's sha256), the run
* files under `run/`, the owner's plan file under `plan-input/` when it lives
* outside the run, and `restore.md`, the human index. Publication swaps a
* fully written sibling in, so a reader sees the old copy or the new one.
* `restoreCheckpoint` verifies containment and every hash before it publishes
* the run directory with one rename; the caller then resumes and owns it.
* publishes one immutable generation under `<dir>/<run>/generations/<name>/`:
* `checkpoint.json` (every file's sha256), the run files under `run/`, the
* owner's plan file under `plan-input/` when it lives outside the run, and
* `restore.md`. `current.json` then switches to it with one rename; the
* previous generation stays until the switch commits, so an interrupted
* publish leaves the last checkpoint current. A generation never replaces a
* newer one (higher run revision). Publication and restore hold the
* collection's `.lock`; restore also holds `<out>.lock`.
*
* Restore picks the named generation, or the newest generation that verifies
* when `--from` names the collection (or a root holding one run). It refuses
* overlapping source and target paths (symlink aliases included), any listed
* file whose real path leaves the generation, and any hash mismatch; it
* validates the staged run (gstack-artifact's validateRun) before publishing
* it to `--out` with one rename, so a refusal leaves `--out` absent.
*/
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { sha256File } from './headless-artifacts';
import { sha256File, validateRun } from './headless-artifacts';
import { FILES, RunError, journal, now, readState, type RunState } from './autoplan-state';
const MANIFEST = 'checkpoint.json';
const CURRENT = 'current.json';
const GENERATIONS = 'generations';
const AT_REST = ['gate_pending', 'amend_pending', 'complete', 'incomplete'];
const FOREIGN_LOCK_STALE_MS = 10 * 60_000;
interface CheckpointManifest {
schema_version: 1; run: string; status: string; gate_rev: number; revision: number; at: string; host: string; gstack_version?: string;
files: Record<string, string>;
plan_input?: { original: string; file: string; sha256: string };
}
export interface Generation { dir: string; manifest?: CheckpointManifest; problem?: string }
const inside = (root: string, p: string) => { const rel = path.relative(root, p); return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); };
const overlaps = (a: string, b: string) => a === b || inside(a, b) || inside(b, a);
/** The real path of `p`, resolving symlinks through its deepest existing ancestor. */
function canonical(p: string): string {
let cur = path.resolve(p);
const rest: string[] = [];
while (!fs.existsSync(cur) && path.dirname(cur) !== cur) { rest.unshift(path.basename(cur)); cur = path.dirname(cur); }
return path.join(fs.realpathSync(cur), ...rest);
}
function refuseOverlap(what: string, a: string, b: string): void {
const ca = canonical(a), cb = canonical(b);
if (overlaps(ca, cb)) throw new RunError('CHECKPOINT_OVERLAP', `${what}: ${a} (${ca}) and ${b} (${cb}) are the same directory or one contains the other`, 3);
}
function exclusive<T>(lock: string, fn: () => T): T {
fs.mkdirSync(path.dirname(lock), { recursive: true });
const mine = JSON.stringify({ pid: process.pid, host: os.hostname(), at: now() });
for (let i = 0; ; i++) {
try { fs.writeFileSync(lock, mine, { flag: 'wx' }); break; } catch (e: any) {
if (e.code !== 'EEXIST') throw e;
let owner: { pid?: number; host?: string; at?: string } = {};
try { owner = JSON.parse(fs.readFileSync(lock, 'utf8')); } catch { /* being written */ }
let alive = true;
if (owner.host === os.hostname() && typeof owner.pid === 'number') { try { process.kill(owner.pid, 0); } catch (k: any) { alive = k.code === 'EPERM'; } }
else if (owner.host && owner.at) alive = Date.now() - Date.parse(owner.at) < FOREIGN_LOCK_STALE_MS;
if (alive || i > 3) throw new RunError('RUN_LOCKED', `${lock} is held by pid ${owner.pid ?? '?'} on ${owner.host ?? '?'} since ${owner.at ?? '?'}`, 3);
fs.rmSync(lock, { force: true });
}
}
try { return fn(); } finally { try { if (fs.readFileSync(lock, 'utf8') === mine) fs.unlinkSync(lock); } catch { /* gone */ } }
}
function runFiles(dir: string, rel = ''): string[] {
return fs.readdirSync(path.join(dir, rel), { withFileTypes: true }).flatMap(e => {
@@ -30,86 +75,154 @@ function runFiles(dir: string, rel = ''): string[] {
return e.isFile() && r !== FILES.lock && !r.endsWith('.tmp') ? [r] : [];
});
}
const copy = (from: string, to: string) => { fs.mkdirSync(path.dirname(to), { recursive: true }); fs.copyFileSync(from, to); };
const inside = (root: string, p: string) => !path.relative(root, p).startsWith('..') && !path.isAbsolute(path.relative(root, p));
const writeAtomic = (file: string, text: string) => { const tmp = `${file}.${process.pid}.tmp`; fs.writeFileSync(tmp, text); fs.renameSync(tmp, file); };
function restoreIndex(m: CheckpointManifest, target: string): string {
function restoreIndex(m: CheckpointManifest, collection: string): string {
return [`# autoplan checkpoint: ${m.run}`, '',
`Written ${m.at} on ${m.host} at status=${m.status}, gate_rev=${m.gate_rev}; ${Object.keys(m.files).length} files, each listed with its sha256 in checkpoint.json.`, '',
'Restore on any machine (the target directory must not hold a run):', '',
` gstack-autoplan resume --out <new run dir> --from ${target}`, '',
'resume verifies every hash, publishes the run directory, takes ownership and prints the next step.',
...(m.plan_input ? ['', `The owner's plan file was ${m.plan_input.original}; a copy is in ${m.plan_input.file} and is restored into the run directory when the original is missing.`] : []), ''].join('\n');
`Current generation written ${m.at} on ${m.host} at status=${m.status}, gate_rev=${m.gate_rev}, revision=${m.revision}; ${Object.keys(m.files).length} files, each listed with its sha256 in generations/<name>/checkpoint.json.`, '',
'List the generations and their hash status, then restore the newest valid one on any machine (the target directory must not hold a run):', '',
` gstack-autoplan checkpoints --from ${collection}`,
` gstack-autoplan resume --out <new run dir> --from ${collection}`, '',
'resume verifies every hash and the staged run, publishes the run directory, takes ownership and prints the next step.',
...(m.plan_input ? ['', `The owner's plan file was ${m.plan_input.original}; a copy is in plan-input/ and is restored into the run directory when the original is missing.`] : []), ''].join('\n');
}
/** Publish the checkpoint when the env opts in and the run is at rest; returns the CHECKPOINT line, or nothing. */
/** The newest run revision any generation in the collection records (unreadable generations count as nothing). */
function newestRevision(collection: string): number {
const dir = path.join(collection, GENERATIONS);
if (!fs.existsSync(dir)) return -1;
return Math.max(-1, ...fs.readdirSync(dir).map(g => { try { return Number(JSON.parse(fs.readFileSync(path.join(dir, g, MANIFEST), 'utf8')).revision); } catch { return -1; } }).filter(Number.isFinite));
}
/** Publish a generation when the env opts in and the run is at rest; returns the CHECKPOINT line, or nothing. */
export function checkpointRun(out: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
const root = env.GSTACK_AUTOPLAN_CHECKPOINT_DIR;
if (!root || !fs.existsSync(path.join(out, FILES.run))) return undefined;
const state = readState(out);
if (!AT_REST.includes(state.status)) return undefined;
const target = path.join(path.resolve(root), state.run);
const tmp = `${target}.${process.pid}.tmp`;
fs.rmSync(tmp, { recursive: true, force: true });
const files: Record<string, string> = {};
for (const rel of runFiles(state.out)) { copy(path.join(state.out, rel), path.join(tmp, 'run', rel)); files[rel] = sha256File(path.join(tmp, 'run', rel)); }
const input = state.plan_input && !inside(state.out, state.plan_input) && fs.existsSync(state.plan_input) ? state.plan_input : undefined;
const planFile = input ? `plan-input/${path.basename(input)}` : undefined;
if (input) copy(input, path.join(tmp, planFile!));
const m: CheckpointManifest = { schema_version: 1, run: state.run, status: state.status, gate_rev: state.gate_rev ?? 0, revision: state.revision, at: now(), host: os.hostname(),
...(state.gstack_version ? { gstack_version: state.gstack_version } : {}), files, ...(input ? { plan_input: { original: input, file: planFile!, sha256: sha256File(path.join(tmp, planFile!)) } } : {}) };
fs.writeFileSync(path.join(tmp, 'restore.md'), restoreIndex(m, target));
fs.writeFileSync(path.join(tmp, MANIFEST), JSON.stringify(m, null, 2) + '\n');
const old = `${target}.${process.pid}.old`;
if (fs.existsSync(target)) fs.renameSync(target, old);
fs.renameSync(tmp, target);
fs.rmSync(old, { recursive: true, force: true });
return `CHECKPOINT: ${target} status=${m.status} gate_rev=${m.gate_rev} files=${Object.keys(files).length}`;
const collection = path.join(path.resolve(root), state.run);
refuseOverlap('the checkpoint directory overlaps the run directory', collection, state.out);
return exclusive(path.join(collection, '.lock'), () => {
const newest = newestRevision(collection);
if (newest > state.revision) return `CHECKPOINT: skipped ${collection}: it holds a newer checkpoint (revision ${newest} > ${state.revision}); nothing replaced`;
const name = `g${String(state.revision).padStart(6, '0')}-${now().replace(/[-:.TZ]/g, '')}-${process.pid}`;
const tmp = path.join(collection, `.tmp-${name}`);
const files: Record<string, string> = {};
for (const rel of runFiles(state.out)) { copy(path.join(state.out, rel), path.join(tmp, 'run', rel)); files[rel] = sha256File(path.join(tmp, 'run', rel)); }
const input = state.plan_input && !inside(state.out, path.resolve(state.plan_input)) && fs.existsSync(state.plan_input) ? state.plan_input : undefined;
const planFile = input ? `plan-input/${path.basename(input)}` : undefined;
if (input) copy(input, path.join(tmp, planFile!));
const m: CheckpointManifest = { schema_version: 1, run: state.run, status: state.status, gate_rev: state.gate_rev ?? 0, revision: state.revision, at: now(), host: os.hostname(),
...(state.gstack_version ? { gstack_version: state.gstack_version } : {}), files, ...(input ? { plan_input: { original: input, file: planFile!, sha256: sha256File(path.join(tmp, planFile!)) } } : {}) };
fs.writeFileSync(path.join(tmp, 'restore.md'), restoreIndex(m, collection));
fs.writeFileSync(path.join(tmp, MANIFEST), JSON.stringify(m, null, 2) + '\n');
fs.mkdirSync(path.join(collection, GENERATIONS), { recursive: true });
fs.renameSync(tmp, path.join(collection, GENERATIONS, name));
if (env.GSTACK_AUTOPLAN_FAULT === 'checkpoint-before-pointer') throw new Error('fault injected: checkpoint interrupted before switching current.json (GSTACK_AUTOPLAN_FAULT, tests only)');
let previous: string | undefined;
try { previous = JSON.parse(fs.readFileSync(path.join(collection, CURRENT), 'utf8')).generation; } catch { /* first generation */ }
writeAtomic(path.join(collection, CURRENT), JSON.stringify({ generation: name, run: m.run, status: m.status, gate_rev: m.gate_rev, revision: m.revision, at: m.at }, null, 2) + '\n');
writeAtomic(path.join(collection, 'restore.md'), restoreIndex(m, collection));
for (const g of fs.readdirSync(path.join(collection, GENERATIONS))) if (g !== name && g !== previous) fs.rmSync(path.join(collection, GENERATIONS, g), { recursive: true, force: true });
for (const e of fs.readdirSync(collection)) if (e.startsWith('.tmp-')) fs.rmSync(path.join(collection, e), { recursive: true, force: true });
return `CHECKPOINT: ${path.join(collection, GENERATIONS, name)} status=${m.status} gate_rev=${m.gate_rev} revision=${m.revision} files=${Object.keys(files).length}`;
});
}
/** Verify a checkpoint and publish it as the run directory `out`. Nothing is written to `out` unless every hash matches. */
/** Verify one generation without throwing: manifest shape, containment by real path, regular files, hashes. */
export function verifyGeneration(dir: string): Generation {
let m: CheckpointManifest;
try { m = JSON.parse(fs.readFileSync(path.join(dir, MANIFEST), 'utf8')); } catch (e) { return { dir, problem: `no readable ${MANIFEST} (${(e as Error).message})` }; }
if (m.schema_version !== 1 || typeof m.run !== 'string' || !m.files || typeof m.files !== 'object' || !m.files[FILES.run]) return { dir, manifest: m, problem: `${MANIFEST} is not a schema_version 1 checkpoint with run.json listed` };
const check = (rel: string, sha: string, base: string): string | undefined => {
if (typeof rel !== 'string' || path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) return `${rel} escapes the checkpoint`;
const file = path.join(base, rel);
let st: fs.Stats;
try { st = fs.lstatSync(file); } catch { return `${rel} is listed but missing`; }
if (!st.isFile()) return `${rel} is not a regular file`;
if (!inside(fs.realpathSync(base), fs.realpathSync(file))) return `${rel} escapes the checkpoint through a symlinked directory`;
if (sha256File(file) !== sha) return `${rel} does not match its sha256 in ${MANIFEST}`;
return undefined;
};
for (const [rel, sha] of Object.entries(m.files)) { const problem = check(rel, sha, path.join(dir, 'run')); if (problem) return { dir, manifest: m, problem }; }
if (m.plan_input) { const problem = check(m.plan_input.file, m.plan_input.sha256, dir); if (problem) return { dir, manifest: m, problem }; }
return { dir, manifest: m };
}
/** Every generation `from` names: one generation, a run's collection, or a root of collections; newest first. */
export function listGenerations(from: string): Generation[] {
const src = path.resolve(from);
if (fs.existsSync(path.join(src, MANIFEST))) return [verifyGeneration(src)];
const collections = fs.existsSync(path.join(src, GENERATIONS)) ? [src]
: fs.existsSync(src) ? fs.readdirSync(src).map(e => path.join(src, e)).filter(c => fs.existsSync(path.join(c, GENERATIONS))) : [];
const order = (g: Generation) => [g.manifest?.revision ?? -1, Date.parse(g.manifest?.at ?? '') || 0] as const;
return collections.flatMap(c => fs.readdirSync(path.join(c, GENERATIONS)).map(g => verifyGeneration(path.join(c, GENERATIONS, g))))
.sort((a, b) => order(b)[0] - order(a)[0] || order(b)[1] - order(a)[1]);
}
/** `gstack-autoplan checkpoints --from <dir>`: one line per generation, newest first. */
export function checkpointLines(from: string): string[] {
const gens = listGenerations(from);
if (!gens.length) throw new RunError('CHECKPOINT_INVALID', `${path.resolve(from)}: no checkpoint generations found`, 3);
return gens.map(g => {
const m = g.manifest;
const collection = path.dirname(path.dirname(g.dir));
let current = false;
try { current = JSON.parse(fs.readFileSync(path.join(collection, CURRENT), 'utf8')).generation === path.basename(g.dir); } catch { /* no pointer */ }
return `CHECKPOINT: ${g.dir} run=${m?.run ?? '?'} gate_rev=${m?.gate_rev ?? '?'} status=${m?.status ?? '?'} revision=${m?.revision ?? '?'} at=${m?.at ?? '?'} hash=${g.problem ? `bad (${g.problem})` : 'ok'}${current ? ' current' : ''}`;
});
}
/** The generation to restore: the one named, or the newest that verifies in a collection or a root holding one run. */
function chooseGeneration(from: string): Generation {
const gens = listGenerations(from);
const runs = [...new Set(gens.map(g => g.manifest?.run).filter(Boolean))];
if (runs.length > 1) throw new RunError('CHECKPOINT_INVALID', `${path.resolve(from)} holds checkpoints of ${runs.length} runs (${runs.join(', ')}); pass --from <dir>/<run>`, 3);
const valid = gens.find(g => !g.problem);
if (!valid) throw new RunError('CHECKPOINT_INVALID', `${path.resolve(from)}: ${gens.length ? gens.map(g => `${path.basename(g.dir)}: ${g.problem}`).join('; ') : 'no checkpoint generations found'}`, 3);
return valid;
}
/** Verify a checkpoint and publish it as the run directory `out`. Nothing is written to `out` unless every check passes. */
export function restoreCheckpoint(from: string, out: string): string[] {
const src = path.resolve(from);
const bad = (detail: string): never => { throw new RunError('CHECKPOINT_INVALID', `${src}: ${detail}`, 3); };
let m: CheckpointManifest;
try { m = JSON.parse(fs.readFileSync(path.join(src, MANIFEST), 'utf8')); } catch (e) { return bad(`no readable ${MANIFEST} (${(e as Error).message})`); }
if (m.schema_version !== 1 || typeof m.run !== 'string' || !m.files || typeof m.files !== 'object' || !m.files[FILES.run]) bad(`${MANIFEST} is not a schema_version 1 checkpoint with run.json listed`);
const target = path.resolve(out);
if (fs.existsSync(target) && fs.readdirSync(target).length) throw new RunError('CHECKPOINT_TARGET_EXISTS', `${target} is not empty`, 3);
const verify = (rel: string, sha: string, base: string) => {
const file = path.resolve(base, rel);
if (path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..') || !inside(base, file)) bad(`${rel} escapes the checkpoint`);
let st: fs.Stats;
try { st = fs.lstatSync(file); } catch { return bad(`${rel} is listed but missing`); }
if (!st.isFile()) bad(`${rel} is not a regular file`);
if (sha256File(file) !== sha) bad(`${rel} does not match its sha256 in ${MANIFEST}`);
return file;
};
const staging = `${target}.restore-${process.pid}`;
fs.rmSync(staging, { recursive: true, force: true });
try {
for (const [rel, sha] of Object.entries(m.files)) copy(verify(rel, sha, path.join(src, 'run')), path.join(staging, rel));
for (const [rel, sha] of Object.entries(m.files)) verify(rel, sha, staging);
const lines: string[] = [];
if (m.plan_input) {
const copyOf = verify(m.plan_input.file, m.plan_input.sha256, src);
if (!fs.existsSync(m.plan_input.original)) {
const restored = path.join(staging, m.plan_input.file);
copy(copyOf, restored);
const state = JSON.parse(fs.readFileSync(path.join(staging, FILES.run), 'utf8')) as RunState;
state.plan_input = path.join(target, m.plan_input.file);
fs.writeFileSync(path.join(staging, FILES.run), JSON.stringify(state, null, 2) + '\n');
lines.push(`PLAN_INPUT: ${m.plan_input.original} is missing; restored the checkpoint copy to ${state.plan_input}`);
} else if (sha256File(m.plan_input.original) !== m.plan_input.sha256) lines.push(`PLAN_INPUT: ${m.plan_input.original} differs from the checkpoint copy; kept the file on disk`);
}
if (fs.existsSync(target)) fs.rmdirSync(target);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.renameSync(staging, target);
const state = readState(target);
journal(target, { event: 'restored', from: src, checkpoint_at: m.at, checkpoint_host: m.host, host: os.hostname(), files: Object.keys(m.files).length });
return [`RESTORED: run=${state.run} from=${src} status=${m.status} gate_rev=${m.gate_rev} files=${Object.keys(m.files).length} (written ${m.at} on ${m.host})`, ...lines];
} finally {
refuseOverlap('the checkpoint and --out', src, target);
refuseOverlap('the checkpoint and the restore staging directory', src, staging);
const collectionLock = fs.existsSync(path.join(src, MANIFEST)) ? path.join(path.dirname(path.dirname(src)), '.lock') : path.join(src, '.lock');
return exclusive(`${target}.lock`, () => exclusive(collectionLock, () => {
if (fs.existsSync(target) && fs.readdirSync(target).length) throw new RunError('CHECKPOINT_TARGET_EXISTS', `${target} is not empty`, 3);
const gen = chooseGeneration(src);
const m = gen.manifest!;
const skipped = listGenerations(src).filter(g => g.problem && (g.manifest?.revision ?? -1) >= m.revision).map(g => `CHECKPOINT: skipped ${g.dir} (${g.problem})`);
fs.rmSync(staging, { recursive: true, force: true });
}
try {
for (const rel of Object.keys(m.files)) copy(path.join(gen.dir, 'run', rel), path.join(staging, rel));
for (const [rel, sha] of Object.entries(m.files)) if (sha256File(path.join(staging, rel)) !== sha) throw new RunError('CHECKPOINT_INVALID', `${gen.dir}: ${rel} changed while it was copied`, 3);
const lines: string[] = [];
const runFile = path.join(staging, FILES.run);
let state: RunState;
try { state = JSON.parse(fs.readFileSync(runFile, 'utf8')); } catch (e) { throw new RunError('CHECKPOINT_INVALID', `${gen.dir}: run.json does not parse (${(e as Error).message})`, 3); }
if (state.run !== m.run || typeof state.out !== 'string' || !state.phases || typeof state.phases !== 'object') throw new RunError('CHECKPOINT_INVALID', `${gen.dir}: run.json is not the run state of ${m.run}`, 3);
if (m.plan_input && !fs.existsSync(m.plan_input.original)) {
copy(path.join(gen.dir, m.plan_input.file), path.join(staging, m.plan_input.file));
state.plan_input = path.join(state.out, m.plan_input.file);
if (!fs.existsSync(state.plan_source)) state.plan_source = state.plan_input;
fs.writeFileSync(runFile, JSON.stringify(state, null, 2) + '\n');
lines.push(`PLAN_INPUT: ${m.plan_input.original} is missing; restored the checkpoint copy to ${path.join(target, m.plan_input.file)}`);
} else if (m.plan_input && sha256File(m.plan_input.original) !== m.plan_input.sha256) lines.push(`PLAN_INPUT: ${m.plan_input.original} differs from the checkpoint copy; kept the file on disk`);
const v = validateRun(runFile);
if (!v.valid) throw new RunError('CHECKPOINT_INVALID', `${gen.dir}: the staged run does not validate: ${v.errors.slice(0, 3).map(e => `${e.code} ${e.path}: ${e.message}`).join('; ')}`, 3);
if (fs.existsSync(target)) fs.rmdirSync(target);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.renameSync(staging, target);
readState(target);
journal(target, { event: 'restored', from: gen.dir, checkpoint_at: m.at, checkpoint_host: m.host, revision: m.revision, host: os.hostname(), files: Object.keys(m.files).length });
return [...skipped, `RESTORED: run=${m.run} from=${gen.dir} status=${m.status} gate_rev=${m.gate_rev} revision=${m.revision} files=${Object.keys(m.files).length} (written ${m.at} on ${m.host})`, ...lines];
} finally {
fs.rmSync(staging, { recursive: true, force: true });
}
}));
}
+16 -6
View File
@@ -19,8 +19,8 @@ import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { exportRun } from './autoplan-export';
import { driftLine } from './autoplan-plan-text';
import { consensusRecord, reconcile, renderConsensus, SETTLEABLE_CHANGES, type ConsensusRecord, type ReconciliationOverride, type ReconciliationRow } from './autoplan-reconcile';
import { driftLine, reviewHash } from './autoplan-plan-text';
import { consensusRecord, reconcile, remedyKey, renderConsensus, SETTLEABLE_CHANGES, type ConsensusRecord, type ReconciliationOverride, type ReconciliationRow } from './autoplan-reconcile';
import type { Phase } from './autoplan-prompts';
import { FILES, RunError, journal as journalRow, now, readDecisions, voiceRows, writeDecisions, type Amendment, type RunState, type VoiceState } from './autoplan-state';
import { phaseClose } from './autoplan-timing';
@@ -71,7 +71,6 @@ const CONTENT_SCOPE: Record<string, string> = { ceo: 'scope', design: 'design',
const optionHash = (options: GateOption[]) => createHash('sha256').update(JSON.stringify(options.map(o => [o.key, o.text]))).digest('hex').slice(0, 16);
const KEEP = (text: string): GateOption => ({ key: 'a', text, changes_plan: false });
const remedyKey = (text: string | undefined) => (text ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
/**
* A user challenge both voices raised recommends the change only with an
@@ -88,7 +87,7 @@ export function challengeDefault(row: ReconciliationRow, n: FindingRow | undefin
: nf?.resolution || of.resolution ? ` (native: ${nf?.resolution ?? 'no remedy stated'}; outside: ${of.resolution ?? 'no remedy stated'})` : '';
const text = `accept the change both reviewers recommend${remedies}`;
const keep = (basis: string) => ({ recommended: 'a' as const, basis: `plan direction; ${basis}`, text });
if (row.disposition === 'unverified') return keep('same location, agreement unverified');
if (row.disposition === 'unverified') return keep(row.resolution.startsWith('same title, a remedy is missing') ? 'a remedy is missing' : row.resolution.startsWith('same title') ? 'remedies differ; no shared proposal' : 'same location, agreement unverified');
if (row.disposition !== 'confirmed') return keep('voices disagree');
if (!nf?.changes || !of.changes) return keep('no shared changes value');
if (nf.changes !== of.changes) return keep('voices name different changes');
@@ -147,7 +146,7 @@ export function buildGate(state: RunState, gateRev: number): GateList {
}
}
items.push(planApproval(state, items));
const list: GateList = { schema_version: 1, run: state.run, gate_rev: gateRev, items, title: '/autoplan final approval gate' };
const list: GateList = { schema_version: 1, run: state.run, gate_rev: gateRev, items, title: '/autoplan final approval gate', rec_rev: 0 };
const problems = validateGateList(list);
if (problems.length) throw new Error(`gate list invalid: ${problems.join('; ')}`);
return list;
@@ -237,6 +236,15 @@ export function applyGuidance(a: Pick<Amendment, 'changes' | 'item'>): string {
return a.changes && SETTLEABLE_CHANGES.includes(a.changes) ? `no plan edit needed: revise --settle ${a.item}` : 'edit the plan, then revise';
}
/** The revise steps that discharge the pending amendments: settle the packaging/ordering changes first, then edit for the rest. */
export function amendmentSteps(state: RunState, out: string): string {
const pending = (state.amendments ?? []).filter(a => a.status === 'pending');
const settle = pending.filter(a => applyGuidance(a).startsWith('no plan edit')).map(a => a.item ?? a.decision);
const edit = pending.filter(a => !applyGuidance(a).startsWith('no plan edit')).map(a => a.decision);
return [settle.length ? `gstack-autoplan revise --out ${out} --settle ${settle.join(',')}` : '',
edit.length ? `edit the plan, then gstack-autoplan revise --out ${out} --plan ${state.plan_input ?? state.plan_source} --incorporates ${edit.join(',')}` : ''].filter(Boolean).join('; then ');
}
/** The owner's words must not contradict the relayed tokens: when they parse, both must resolve to the same choices. */
function checkOwnerReply(list: GateList, gateRev: number, prior: Record<string, string>, relayed: ParsedReply, ownerReply: string): boolean {
const owner = parseReply(ownerReply, list, gateRev, prior);
@@ -270,6 +278,7 @@ export function answerGate(state: RunState, gateRev: number, reply: string, answ
if (!parsed.valid) throw new RunError('GATE_REPLY_UNPARSED', parsed.error === 'GATE_EMPTY' ? 'empty reply' : `unparsed: ${parsed.unparsed.join(', ')}`);
const currentHash = recHash(list);
if (o.recHash !== undefined && o.recHash !== currentHash) throw new RunError('GATE_REC_STALE', `--rec-hash ${o.recHash} was rendered before the recommendations changed; current rec_hash=${currentHash}`, 3);
if (parsed.mode === 'rec' && list.rec_rev === undefined && o.recHash === undefined) throw new RunError('GATE_REC_STALE', `this gate was written before \`rec\`/\`all\` took plan changes (gate.json has no rec_rev), so the owner saw a different meaning; render it again (gstack-gate render ${gateFile} --all), relay it, and answer with --rec-hash ${currentHash}, or reply with explicit <id><option> tokens`, 3);
if (parsed.mode === 'rec' && (list.rec_rev ?? 0) > 0 && o.recHash === undefined) throw new RunError('GATE_REC_STALE', `recommend changed this gate (rec_rev=${list.rec_rev}); pass --rec-hash from the render the owner saw (current rec_hash=${currentHash})`, 3);
const ownerParsed = o.ownerReply !== undefined ? checkOwnerReply(list, gateRev, prior, parsed, o.ownerReply) : undefined;
const provenance = o.ownerReply !== undefined ? { owner_reply: o.ownerReply, owner_reply_parsed: ownerParsed } : {};
@@ -310,10 +319,11 @@ export function answerGate(state: RunState, gateRev: number, reply: string, answ
lines.push('REJECTED: run.json.status=incomplete');
return { state, applied: true, pending: [], reopened: [], outcome: 'rejected', lines };
}
const acceptedPlan = changing.length ? reviewHash(fs.readFileSync(state.active_plan, 'utf8')) : '';
for (const c of changing) {
const item = itemOf(c.id);
const amendment: Amendment = { decision: decisionId(state.run, gateRev, c.id), title: item.title, chosen: c.chosen, text: optionOf(c).text, gate_rev: gateRev, status: 'pending', at: answeredAt,
item: c.id, ...(item.changes ? { changes: item.changes } : {}), ...(item.phase ? { phase: item.phase } : {}) };
item: c.id, ...(item.changes ? { changes: item.changes } : {}), ...(item.phase ? { phase: item.phase } : {}), plan_sha256: acceptedPlan };
(state.amendments ??= []).push(amendment);
journalRow(state.out, { event: 'amendment_pending', decision: amendment.decision, gate_rev: gateRev, ...(item.changes ? { changes: item.changes } : {}) });
lines.push(`APPLY: ${amendment.decision} ${item.title} -> ${amendment.text}; ${applyGuidance(amendment)}`);
+24 -10
View File
@@ -209,13 +209,26 @@ function jaccard(a: Set<string>, b: Set<string>): number {
return inter / (a.size + b.size - inter);
}
const level = (s: Severity) => SEVERITIES.indexOf(s);
/** A remedy compared as words: case, punctuation and spacing ignored. */
export const remedyKey = (text: string | undefined) => (text ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
const NEGATING = /\b(keep|leave|retain|not|no|never|without|avoid|remove|drop|revert|skip)\b|n't\b/;
/** Opposite remedies: they name the same thing and exactly one of them keeps, drops or negates it. */
function contradictory(a: string, b: string): boolean {
const ka = remedyKey(a), kb = remedyKey(b);
if (NEGATING.test(ka) === NEGATING.test(kb)) return false;
const words = (k: string) => new Set(k.split(' ').filter(t => t.length > 3 && !NEGATING.test(t)));
const wb = words(kb);
return [...words(ka)].some(t => wb.has(t));
}
/**
* Structural reconciliation. Findings associate by a title overlap of at least
* half the words, or by file:line (±3 lines). A title association is
* `confirmed` unless severities differ by two or more levels (`disagree`); a
* location-only association is `unverified` (same location, agreement
* unverified: two findings near one line are not one finding); else `new`.
* half the words, or by file:line (±3 lines). Association alone never
* confirms: a title association is `confirmed` only when both voices state the
* same remedy, `disagree` when severities differ by two or more levels or the
* remedies contradict (one keeps, drops or negates what the other changes),
* and otherwise `unverified` (remedies differ or one is missing); a
* location-only association is `unverified`; else `new`.
* Each native finding is matched at most once. Overrides (the parent's
* semantic reconciliation, with an optional shared `proposal`) replace the
* structural row for their outside id and must name ids from these two lists.
@@ -263,12 +276,13 @@ export function reconcile(native: FindingRow[], outside: FindingRow[], overrides
rows.push({ outside_id: o.id, native_id: match.id, disposition: 'unverified', resolution: `same location, agreement unverified (${where}): ${match.title}`, basis, severity: { outside: o.severity, native: match.severity } });
continue;
}
const delta = Math.abs(level(match.severity) - level(o.severity));
rows.push({
outside_id: o.id, native_id: match.id, disposition: delta >= 2 ? 'disagree' : 'confirmed',
resolution: delta >= 2 ? `severity: native ${match.severity}, outside ${o.severity} (${where})` : `both voices (${where}): ${match.title}`,
basis, severity: { outside: o.severity, native: match.severity },
});
const severity = { outside: o.severity, native: match.severity };
const pair = (disposition: ReconciliationDisposition, resolution: string) => rows.push({ outside_id: o.id, native_id: match!.id, disposition, resolution, basis, severity });
if (Math.abs(level(match.severity) - level(o.severity)) >= 2) { pair('disagree', `severity: native ${match.severity}, outside ${o.severity} (${where})`); continue; }
if (!match.resolution || !o.resolution) { pair('unverified', `same title, a remedy is missing (${where}): ${match.title}`); continue; }
if (contradictory(match.resolution, o.resolution)) { pair('disagree', `remedies conflict (${where}): native "${match.resolution}", outside "${o.resolution}"`); continue; }
if (remedyKey(match.resolution) !== remedyKey(o.resolution)) { pair('unverified', `same title, remedies differ (${where}): ${match.title}`); continue; }
pair('confirmed', `both voices, same remedy (${where}): ${match.title}`);
}
return { rows, native_only: native.filter(n => !used.has(n.id)).map(n => n.id), errors };
}
+18 -11
View File
@@ -57,28 +57,34 @@ function resolveIds(state: RunState, ids: string[]): string[] {
}
/**
* `--settle` evidence: a packaging/ordering change on a plan whose reviewed Implementation plan is unchanged (the
* active plan and the owner's plan file both hash to the snapshot the phases reviewed). A real change never settles.
* `--settle` evidence: a packaging/ordering change settles on the plan the run already reviews, never on an
* unrevised edit. Alone, the owner's plan file must hash to the active plan, and the active plan must be the one
* the owner accepted the change on or one a later `revise` recorded (plan_revised). With `--plan` (beside
* `--incorporates`), it settles on that revision's hash, which the reopened phases then review.
*/
function settleEvidence(state: RunState, ids: string[]): { plan_sha256: string; changes: Record<string, string> } {
const reviewed = state.phase_order.map(p => state.phases[p]?.snapshot).find(Boolean);
function settleEvidence(state: RunState, ids: string[], revised?: string): { plan_sha256: string; changes: Record<string, string> } {
const active = reviewHash(fs.readFileSync(state.active_plan, 'utf8'));
let input = active;
try { if (state.plan_input && fs.existsSync(state.plan_input)) input = reviewHash(revisionText(state, state.plan_input)); } catch { input = ''; }
const changes: Record<string, string> = {};
for (const id of ids) {
const a = pendingAmendments(state).find(x => x.decision === id)!;
if (!a.changes || !SETTLEABLE_CHANGES.includes(a.changes)) throw new RunError('AMENDMENT_NOT_SETTLEABLE', `${id} changes ${a.changes ?? 'nothing named'}; only packaging or ordering changes settle without a plan edit`, 3);
changes[id] = a.changes;
if (revised || !a.plan_sha256) continue;
const recorded = [a.plan_sha256, ...readJournal(state.out).filter(r => r.event === 'plan_revised' && r.ts >= a.at).map(r => r.new_plan_sha256 as string)];
if (!recorded.includes(active)) throw new RunError('AMENDMENT_NOT_SETTLEABLE', `the active plan ${active.slice(0, 12)} is neither the plan ${id} was accepted on (${a.plan_sha256.slice(0, 12)}) nor one revise recorded; revise --plan instead`, 3);
}
if (!reviewed || active !== reviewed || input !== reviewed) throw new RunError('AMENDMENT_NOT_SETTLEABLE', `the reviewed plan changed (reviewed ${String(reviewed).slice(0, 12)}, active ${active.slice(0, 12)}, plan file ${input.slice(0, 12) || 'unreadable'}); revise --plan instead`, 3);
return { plan_sha256: reviewed, changes };
if (revised) return { plan_sha256: revised, changes };
let input = '';
try { input = state.plan_input && fs.existsSync(state.plan_input) ? reviewHash(revisionText(state, state.plan_input)) : active; } catch { /* unreadable: refused below */ }
if (input !== active) throw new RunError('AMENDMENT_NOT_SETTLEABLE', `the plan file has edits the run has not reviewed (active ${active.slice(0, 12)}, plan file ${input.slice(0, 12) || 'unreadable'}); revise --plan <file> --incorporates <ids> --settle ${ids.join(',')} instead`, 3);
return { plan_sha256: active, changes };
}
/** Every accepted change is discharged and nothing reopened: the answered gate stands and the run is complete. */
/** Every accepted change is discharged: the answered gate stands (complete) unless a revise reopened phases still to rerun. */
function finishAmendments(state: RunState, at: string): string[] {
if (state.status !== 'amend_pending' || pendingAmendments(state).length) return [];
journal(state.out, { event: 'amendments_settled', gate_rev: state.gate_rev ?? null });
if (state.phase_order.some(p => !state.phases[p]?.closed_at)) { state.status = 'running'; return []; }
return [...approve(state, at), `APPROVED: gate_rev=${state.gate_rev} (every accepted change is in the plan or settled)`];
}
@@ -92,9 +98,9 @@ export function reviseRun(o: ReviseOptions): ReviseResult {
for (const id of [...incorporates, ...withdraw, ...settle]) {
if (!pending.includes(id)) throw new RunError('AMENDMENT_UNKNOWN', `${id} is not a pending amendment (pending: ${pending.join(', ') || 'none'})`, 3);
}
const evidence = settle.length ? settleEvidence(state, settle) : undefined;
const at = now();
if (!o.plan) {
const evidence = settle.length ? settleEvidence(state, settle) : undefined;
for (const row of settleAmendments(state, withdraw, 'withdrawn', at, { by: 'owner' })) journal(out, row);
for (const row of settleAmendments(state, settle, 'settled', at, { plan_sha256: evidence?.plan_sha256 })) journal(out, { ...row, changes: evidence!.changes[row.decision as string] });
const lines = [...settle.map(id => `SETTLED: ${id} (${evidence!.changes[id]}; plan ${evidence!.plan_sha256.slice(0, 12)} unchanged; nothing reopens)`), amendmentLine(state), ...finishAmendments(state, at)];
@@ -109,7 +115,8 @@ export function reviseRun(o: ReviseOptions): ReviseResult {
if (incorporates.length && newHash === oldHash) {
throw new RunError('PLAN_AMEND_PENDING', `the Implementation plan did not change (${oldHash.slice(0, 12)}); incorporating ${incorporates.join(', ')} needs the edit in ${file}`, 3);
}
if (settle.length && newHash !== oldHash) throw new RunError('AMENDMENT_NOT_SETTLEABLE', `the revised plan changes the Implementation plan (${oldHash.slice(0, 12)} -> ${newHash.slice(0, 12)}); incorporate ${settle.join(', ')} instead of settling`, 3);
if (settle.length && newHash !== oldHash && !incorporates.length) throw new RunError('AMENDMENT_NOT_SETTLEABLE', `the revised plan changes the Implementation plan (${oldHash.slice(0, 12)} -> ${newHash.slice(0, 12)}) and incorporates nothing; incorporate ${settle.join(', ')} instead of settling`, 3);
const evidence = settle.length ? settleEvidence(state, settle, newHash) : undefined;
const k = (state.plan_revision ?? 0) + 1;
const staging = path.join(out, `.revise-v${k}`);
fs.rmSync(staging, { recursive: true, force: true });
+2 -2
View File
@@ -19,7 +19,7 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { canonicalRows, parseReceipt, parseRecordFindings, parseReviewResult, type CanonicalFinding } from './autoplan-reconcile';
import { applyGuidance, closePhase, finishGate } from './autoplan-gate';
import { amendmentSteps, applyGuidance, closePhase, finishGate } from './autoplan-gate';
import { GSTACK_VERSION } from './autoplan-migrate';
import { driftLine, normalizeFindingFile, reviewHash, scaffold } from './autoplan-plan-text';
import { PHASES, SNAPSHOT_TOOL, attemptFile, bindPromptResult, detectScope, driverFile, extractHeading, phaseOrder, reviewSkillFile, scopeLines, scopeSourceLabel, sectionLoadFor, snapshotTool, writePrompts, type Phase, type Scope, type ScopeFlags } from './autoplan-prompts';
@@ -179,7 +179,7 @@ export function nextStep(o: NextOptions): NextResult {
const pending = pendingAmendments(state).map(a => a.decision);
if (pending.length) {
const each = pendingAmendments(state).map(a => `APPLY ${a.item ?? a.decision}: ${applyGuidance(a)}`).join('; ');
throw new RunError('PLAN_AMEND_PENDING', `accepted at the gate but not in the plan: ${pending.join(', ')} (${each}); edit the plan, then gstack-autoplan revise --out ${state.out} --plan ${state.plan_input ?? state.plan_source} --incorporates ${pending.join(',')} (or --settle <id> for a packaging/ordering change, --withdraw <id>)`, 3);
throw new RunError('PLAN_AMEND_PENDING', `accepted at the gate but not in the plan: ${pending.join(', ')} (${each}); ${amendmentSteps(state, state.out)} (or --withdraw <id>)`, 3);
}
const phase = currentPhase(state);
if (!phase) { finishGate(state); writeState(state); return result; }
+3 -1
View File
@@ -24,6 +24,8 @@ export interface Amendment {
decision: string; title: string; chosen: string; text: string; gate_rev: number; status: 'pending' | 'incorporated' | 'withdrawn' | 'settled'; at: string; settled_at?: string;
/** The gate item's `changes` and phase: packaging/ordering changes settle without a plan edit (`revise --settle`). */
changes?: string; phase?: string; item?: string;
/** The reviewed Implementation-plan hash of the active plan when the owner accepted it: `--settle` evidence. */
plan_sha256?: string;
}
export interface PhaseState {
snapshot?: string; snapshot_dir?: string; snapshot_path?: string; methodology?: string; opened_at?: string; closed_at?: string;
@@ -81,7 +83,7 @@ export function readState(out: string): RunState {
state.out = path.resolve(out);
if (previous && previous !== state.out) {
const reroot = (p: string | undefined) => (p && p.startsWith(previous + path.sep) ? path.join(state.out, path.relative(previous, p)) : p);
state.active_plan = reroot(state.active_plan)!; state.restore = reroot(state.restore)!;
state.active_plan = reroot(state.active_plan)!; state.restore = reroot(state.restore)!; state.plan_source = reroot(state.plan_source)!; state.plan_input = reroot(state.plan_input);
for (const ps of Object.values(state.phases)) {
ps.snapshot_dir = reroot(ps.snapshot_dir); ps.snapshot_path = reroot(ps.snapshot_path); ps.methodology = reroot(ps.methodology);
if (ps.prompts) for (const k of Object.keys(ps.prompts) as Array<keyof typeof ps.prompts>) ps.prompts[k] = reroot(ps.prompts[k])!;
+10 -5
View File
@@ -533,18 +533,23 @@ export const RESULT_CODES = {
},
AMENDMENT_NOT_SETTLEABLE: {
anchor: 'amendment-not-settleable',
summary: '`revise --settle` named an accepted change that needs a plan edit (its item changes scope, cost or direction, or names no packaging/ordering change), or the reviewed plan changed since the gate',
fix: 'edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id>`; settle only packaging or ordering changes on an unchanged plan',
summary: '`revise --settle` named an accepted change that needs a plan edit (its item changes scope, cost or direction, or names no packaging/ordering change), or the plan has edits no revise has recorded',
fix: 'edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id>` (add `--settle <id>` to settle packaging/ordering changes in the same call); settle alone only on the plan the run already reviews',
},
GATE_REC_STALE: {
anchor: 'gate-rec-stale',
summary: 'a `rec` reply was rendered before `gstack-autoplan recommend` changed the recommendations it takes',
summary: 'a `rec` reply was rendered before `gstack-autoplan recommend` changed the recommendations it takes, or the gate predates `rec` (a gate.json without rec_rev, rendered when `all` left changes pending)',
fix: 'render the gate again (`gstack-gate render <dir>/gate.json --all`), relay it, and answer with the printed `--rec-hash`',
},
CHECKPOINT_INVALID: {
anchor: 'checkpoint-invalid',
summary: 'the autoplan checkpoint cannot be restored: its manifest is unreadable, a listed file is missing, escapes the checkpoint or does not match its sha256',
fix: 'restore from an intact copy of `<GSTACK_AUTOPLAN_CHECKPOINT_DIR>/<run>/` (its `restore.md` names the command); nothing was written to --out',
summary: 'no autoplan checkpoint generation can be restored: a manifest is unreadable, a listed file is missing, escapes the generation (by path or symlink) or does not match its sha256, or the staged run does not validate',
fix: 'run `gstack-autoplan checkpoints --from <dir>` to see each generation\'s hash status and restore an intact one; nothing was written to --out',
},
CHECKPOINT_OVERLAP: {
anchor: 'checkpoint-overlap',
summary: 'the checkpoint directory and the run directory (or the restore target) are the same directory or one contains the other, symlinks resolved',
fix: 'point GSTACK_AUTOPLAN_CHECKPOINT_DIR (or --from / --out) at a directory outside the run; nothing was moved or deleted',
},
CHECKPOINT_TARGET_EXISTS: {
anchor: 'checkpoint-target-exists',
+1 -1
View File
@@ -29,7 +29,7 @@ export const AUTOPLAN_DRIVER_COMMANDS: Array<{ cmd: string; does: string }> = [
{ cmd: 'gstack-autoplan revise --out <dir> --withdraw <decision id>', does: 'drop a pending amendment instead of applying it' },
{ cmd: 'gstack-autoplan export --out <dir>', does: 'write the artifact set; then `gstack-artifact validate <dir>/run.json`' },
{ cmd: 'gstack-autoplan status|resume --out <dir>', does: 'read the manifest (PENDING: lines name open attempts); take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts)' },
{ cmd: 'gstack-autoplan resume --out <new dir> --from <checkpoint>', does: 'restore a hash-verified checkpoint (GSTACK_AUTOPLAN_CHECKPOINT_DIR) into an empty directory and take ownership' },
{ cmd: 'gstack-autoplan resume --out <new dir> --from <checkpoint>', does: 'restore the newest valid checkpoint generation (GSTACK_AUTOPLAN_CHECKPOINT_DIR) into an empty directory and take ownership; `checkpoints --from <dir>` lists them' },
];
/** The reply grammar `gstack-gate` parses, one form per line. */
+1 -1
View File
@@ -33,7 +33,7 @@ function gatedRun(name: string): string {
const ids = attemptsOf(out, 'ceo');
expect(autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]).status).toBe(0);
const close = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', resultOf(out, 'ceo', 'outside'), '--model', 'gpt-5.5', '--attempt', ids.outside]);
expect(close.stdout).toMatch(/PHASE_CLOSED: ceo confirmed=1 disagree=1 new=1/);
expect(close.stdout).toMatch(/PHASE_CLOSED: ceo confirmed=0 unverified=1 disagree=1 new=1/);
const eng = runPhase(out, 'eng');
expect(eng.outside.stdout).toContain('status=gate_pending');
return out;
+142 -49
View File
@@ -1,90 +1,183 @@
/**
* Durable checkpoint (next-wave B12): with GSTACK_AUTOPLAN_CHECKPOINT_DIR set,
* a run at rest is copied, hash-listed, to <dir>/<run>/; journal paths are
* run-relative, so `resume --from` restores the run on a new directory (even
* with the owner's plan file gone), takes ownership, and the run answers and
* exports a directory `gstack-artifact validate` accepts. A tampered,
* escaping or misplaced restore writes nothing.
* Durable checkpoint (next-wave B12, integration fixes): with
* GSTACK_AUTOPLAN_CHECKPOINT_DIR set, a run at rest publishes an immutable,
* hash-listed generation under <dir>/<run>/generations/ and switches
* current.json to it; journal paths are run-relative, so `resume --from`
* restores the run on a new directory (even with the owner's plan file gone,
* twice over), takes ownership, and the run answers, revises and exports a
* directory `gstack-artifact validate` accepts. A checkpoint never replaces
* or deletes the live run, never replaces a newer generation, and a failure
* after a committed command is a warning. Restore picks the newest valid
* generation and refuses tampered, escaping (by path or symlink), invalid or
* overlapping input, writing nothing.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { ROOT, autoplan, env, journal, readRun, resultOf, review, runPhase, startSmallRun } from './helpers/autoplan-run-fixture';
import { ROOT, SMALL_PLAN, autoplan, env, journal, readRun, resultOf, review, runPhase, startSmallRun } from './helpers/autoplan-run-fixture';
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-checkpoint-'));
afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
const validate = (out: string) => spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-artifact'), 'validate', path.join(out, 'run.json')], { cwd: ROOT, encoding: 'utf8', timeout: 60_000 });
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const current = (collection: string) => JSON.parse(fs.readFileSync(path.join(collection, 'current.json'), 'utf8'));
const generations = (collection: string) => fs.readdirSync(path.join(collection, 'generations')).sort();
function checkpointed(name: string): { dir: string; out: string; cp: string; run: string; e: Record<string, string> } {
/** A small run brought to gate_pending with the checkpoint env on for the last submit. */
function checkpointed(name: string): { dir: string; out: string; root: string; collection: string; run: string; e: Record<string, string> } {
const dir = path.join(TMP, name);
const cpRoot = path.join(dir, 'drive');
const e = env({ GSTACK_AUTOPLAN_CHECKPOINT_DIR: cpRoot });
const root = path.join(dir, 'drive');
const e = env({ GSTACK_AUTOPLAN_CHECKPOINT_DIR: root });
const out = startSmallRun(dir);
runPhase(out, 'ceo');
const next = autoplan(['next', '--out', out], { env: e });
expect(next.stderr).not.toContain('CHECKPOINT:');
expect(autoplan(['next', '--out', out], { env: e }).stderr).not.toContain('CHECKPOINT:');
for (const voice of ['native', 'outside'] as const) review(path.join(out, `eng-${voice}-prompt.md`));
const run = readRun(out);
expect(autoplan(['submit', '--out', out, '--phase', 'eng', '--voice', 'native', '--result', resultOf(out, 'eng', 'native'), '--model', 'anthropic/claude-opus-4-7'], { env: e }).status).toBe(0);
const gated = autoplan(['submit', '--out', out, '--phase', 'eng', '--voice', 'outside', '--result', resultOf(out, 'eng', 'outside'), '--model', 'openai/gpt-6-astra'], { env: e });
expect(gated.stdout).toContain('status=gate_pending');
const cp = path.join(cpRoot, run.run);
expect(gated.stderr).toMatch(new RegExp(`^CHECKPOINT: ${cp.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} status=gate_pending gate_rev=1 files=\\d+$`, 'm'));
return { dir, out, cp, run: run.run, e };
const run = readRun(out).run;
const collection = path.join(root, run);
expect(gated.stderr).toMatch(new RegExp(`^CHECKPOINT: ${esc(path.join(collection, 'generations'))}/g\\d{6}-\\S+ status=gate_pending gate_rev=1 revision=\\d+ files=\\d+$`, 'm'));
return { dir, out, root, collection, run, e };
}
describe('checkpoint and restore', () => {
test('write at gate_pending, delete the run and the plan file, restore, resume, answer and export: validate passes', () => {
test('write at gate_pending, delete the run and the plan file, restore twice, answer, revise and export: validate passes', () => {
const c = checkpointed('roundtrip');
const manifest = JSON.parse(fs.readFileSync(path.join(c.cp, 'checkpoint.json'), 'utf8'));
expect(manifest).toMatchObject({ schema_version: 1, run: c.run, status: 'gate_pending', gate_rev: 1 });
const [gen] = generations(c.collection);
expect(current(c.collection)).toMatchObject({ generation: gen, run: c.run, status: 'gate_pending', gate_rev: 1 });
const manifest = JSON.parse(fs.readFileSync(path.join(c.collection, 'generations', gen!, 'checkpoint.json'), 'utf8'));
expect(Object.keys(manifest.files)).toEqual(expect.arrayContaining(['run.json', 'attempts.jsonl', 'gate.json', 'decisions.jsonl']));
expect(Object.keys(manifest.files)).not.toContain('run.lock');
expect(fs.readFileSync(path.join(c.cp, 'restore.md'), 'utf8')).toContain(`gstack-autoplan resume --out <new run dir> --from ${c.cp}`);
expect(fs.readFileSync(path.join(c.collection, 'restore.md'), 'utf8')).toContain(`gstack-autoplan resume --out <new run dir> --from ${c.collection}`);
for (const r of journal(c.out)) for (const k of ['prompt', 'result', 'staging']) if (typeof r[k] === 'string') expect(path.isAbsolute(r[k])).toBe(false);
fs.rmSync(c.out, { recursive: true, force: true });
fs.rmSync(path.join(c.dir, 'plan.md'));
const moved = path.join(c.dir, 'elsewhere', 'run');
const resumed = autoplan(['resume', '--out', moved, '--from', c.cp], { env: c.e });
const first = path.join(c.dir, 'first', 'run');
const resumed = autoplan(['resume', '--out', first, '--from', c.collection], { env: c.e });
expect(resumed.status, resumed.stderr).toBe(0);
expect(resumed.stdout).toMatch(new RegExp(`^RESTORED: run=${c.run} from=.* status=gate_pending gate_rev=1 files=\\d+ `, 'm'));
expect(resumed.stdout).toMatch(/^PLAN_INPUT: .*plan\.md is missing; restored the checkpoint copy to .*elsewhere\/run\/plan-input\/plan\.md$/m);
expect(resumed.stdout).toContain(`GSTACK_RESULT: skill=autoplan status=gate_pending run=${moved}`);
expect(journal(moved).find(r => r.event === 'restored')).toMatchObject({ host: os.hostname() });
const state = readRun(moved);
expect(state.plan_input).toBe(path.join(moved, 'plan-input', 'plan.md'));
expect(state.active_plan).toBe(path.join(moved, 'plan-active.md'));
const answered = autoplan(['answer', '--out', moved, '--gate-rev', '1', '--reply', 'as-is', '--by', 'human'], { env: c.e });
expect(resumed.stdout).toMatch(new RegExp(`^RESTORED: run=${c.run} from=.* status=gate_pending gate_rev=1 revision=\\d+ files=\\d+ `, 'm'));
expect(resumed.stdout).toMatch(/^PLAN_INPUT: .*plan\.md is missing; restored the checkpoint copy to .*first\/run\/plan-input\/plan\.md$/m);
expect(resumed.stdout).toContain(`GSTACK_RESULT: skill=autoplan status=gate_pending run=${first}`);
expect(journal(first).find(r => r.event === 'restored')).toMatchObject({ host: os.hostname() });
expect(resumed.stderr).toMatch(/^CHECKPOINT: .* status=gate_pending /m);
fs.rmSync(first, { recursive: true, force: true });
const second = path.join(c.dir, 'second', 'run');
expect(autoplan(['resume', '--out', second, '--from', c.collection]).status).toBe(0);
const state = readRun(second);
expect(state.plan_input).toBe(path.join(second, 'plan-input', 'plan.md'));
expect(state.plan_source.startsWith(second + path.sep)).toBe(true);
expect(state.active_plan).toBe(path.join(second, 'plan-active.md'));
const answered = autoplan(['answer', '--out', second, '--gate-rev', '1', '--reply', 'p1d', '--by', 'human'], { env: c.e });
expect(answered.status, answered.stderr).toBe(0);
expect(answered.stdout).toContain('status=complete');
expect(answered.stderr).toMatch(/^CHECKPOINT: .* status=complete gate_rev=1 /m);
expect(autoplan(['export', '--out', moved]).status).toBe(0);
const v = validate(moved);
fs.writeFileSync(state.plan_input, SMALL_PLAN.replace('Tests cover', 'Ship behind RETRY_BACKOFF=1. Tests cover'));
const revised = autoplan(['revise', '--out', second, '--plan', state.plan_input]);
expect(revised.status, revised.stderr).toBe(0);
runPhase(second, 'ceo');
runPhase(second, 'eng');
expect(autoplan(['answer', '--out', second, '--gate-rev', '2', '--reply', 'as-is', '--by', 'human']).stdout).toContain('status=complete');
expect(autoplan(['export', '--out', second]).status).toBe(0);
const v = validate(second);
expect(v.status, v.stdout + v.stderr).toBe(0);
});
test('a tampered file, an escaping path and a non-empty target are refused and write nothing', () => {
test('`checkpoints` lists every generation; a corrupt newest is skipped for the newest valid; an interrupted publish keeps the previous current', () => {
const c = checkpointed('generations');
const [older] = generations(c.collection);
const faulted = autoplan(['export', '--out', c.out], { env: { ...c.e, GSTACK_AUTOPLAN_FAULT: 'checkpoint-before-pointer' } });
expect(faulted.status, faulted.stderr).toBe(0);
expect(faulted.stderr).toMatch(/^CHECKPOINT: failed for .*fault injected: checkpoint interrupted before switching current\.json.*; the command's result stands, the previous checkpoint stays current$/m);
expect(current(c.collection).generation).toBe(older);
const newer = generations(c.collection).find(g => g !== older)!;
const listed = autoplan(['checkpoints', '--from', c.root]);
expect(listed.status, listed.stderr).toBe(0);
expect(listed.stdout.trim().split('\n')).toEqual([
expect.stringMatching(new RegExp(`^CHECKPOINT: \\S+/${newer} run=${c.run} gate_rev=1 status=gate_pending revision=\\d+ at=\\S+ hash=ok$`)),
expect.stringMatching(new RegExp(`^CHECKPOINT: \\S+/${older} run=${c.run} gate_rev=1 status=gate_pending revision=\\d+ at=\\S+ hash=ok current$`)),
]);
fs.appendFileSync(path.join(c.collection, 'generations', newer, 'run', 'gate.json'), ' ');
expect(autoplan(['checkpoints', '--from', c.collection]).stdout).toMatch(new RegExp(`${newer} .* hash=bad \\(gate\\.json does not match its sha256 in checkpoint\\.json\\)$`, 'm'));
const target = path.join(c.dir, 'restored');
const explicit = autoplan(['resume', '--out', target, '--from', path.join(c.collection, 'generations', newer)]);
expect(explicit.status).toBe(3);
expect(explicit.stderr).toMatch(/gate\.json does not match its sha256.*\(CHECKPOINT_INVALID\)/);
expect(fs.existsSync(target)).toBe(false);
const newest = autoplan(['resume', '--out', target, '--from', c.collection]);
expect(newest.status, newest.stderr).toBe(0);
expect(newest.stdout).toMatch(new RegExp(`^CHECKPOINT: skipped \\S+/${newer} \\(gate\\.json does not match`, 'm'));
expect(newest.stdout).toMatch(new RegExp(`^RESTORED: run=${c.run} from=\\S+/${older} `, 'm'));
});
test('a stale copy never replaces a newer generation', () => {
const c = checkpointed('newer');
const stale = path.join(c.dir, 'stale');
fs.cpSync(c.out, stale, { recursive: true });
for (let i = 0; i < 3; i++) expect(autoplan(['export', '--out', c.out], { env: c.e }).status).toBe(0);
const before = current(c.collection);
const r = autoplan(['export', '--out', stale], { env: c.e });
expect(r.status, r.stderr).toBe(0);
expect(r.stderr).toMatch(/^CHECKPOINT: skipped .*: it holds a newer checkpoint \(revision \d+ > \d+\); nothing replaced$/m);
expect(current(c.collection)).toEqual(before);
});
test('a checkpoint directory that is, contains or aliases the live run is refused before anything moves; the command still succeeds', () => {
const c = checkpointed('overlap');
const root = path.join(c.dir, 'root');
fs.mkdirSync(root);
const live = path.join(root, c.run);
fs.renameSync(c.out, live);
const same = autoplan(['export', '--out', live], { env: env({ GSTACK_AUTOPLAN_CHECKPOINT_DIR: root }) });
expect(same.status, same.stderr).toBe(0);
expect(same.stderr).toMatch(/^CHECKPOINT: failed for .*\(CHECKPOINT_OVERLAP\); the command's result stands/m);
expect(readRun(live).run).toBe(c.run);
const inner = autoplan(['export', '--out', live], { env: env({ GSTACK_AUTOPLAN_CHECKPOINT_DIR: path.join(live, 'ckpt') }) });
expect(inner.stderr).toMatch(/\(CHECKPOINT_OVERLAP\)/);
expect(fs.existsSync(path.join(live, 'ckpt'))).toBe(false);
const aliasRoot = path.join(c.dir, 'alias');
fs.mkdirSync(aliasRoot);
fs.symlinkSync(live, path.join(aliasRoot, c.run));
const alias = autoplan(['export', '--out', live], { env: env({ GSTACK_AUTOPLAN_CHECKPOINT_DIR: aliasRoot }) });
expect(alias.stderr).toMatch(/\(CHECKPOINT_OVERLAP\)/);
expect(readRun(live).run).toBe(c.run);
expect(fs.existsSync(path.join(live, 'run.json'))).toBe(true);
const restoreInto = autoplan(['resume', '--out', path.join(c.collection, 'inside'), '--from', c.collection]);
expect(restoreInto.status).toBe(3);
expect(restoreInto.stderr).toMatch(/\(CHECKPOINT_OVERLAP\)/);
});
test('tampered, escaping, symlink-escaping and non-validating checkpoints and a non-empty target are refused and write nothing', () => {
const c = checkpointed('refusals');
const gen = path.join(c.collection, 'generations', generations(c.collection)[0]!);
const target = path.join(c.dir, 'restored');
const refused = (pattern: RegExp, code = 'CHECKPOINT_INVALID') => {
const r = autoplan(['resume', '--out', target, '--from', gen]);
expect(r.status).toBe(3);
expect(r.stderr).toMatch(new RegExp(`${pattern.source}.*\\(${code}\\)`));
expect(fs.existsSync(target)).toBe(false);
};
const nonEmpty = path.join(c.dir, 'busy');
fs.mkdirSync(nonEmpty, { recursive: true });
fs.writeFileSync(path.join(nonEmpty, 'keep.txt'), 'x');
const busy = autoplan(['resume', '--out', nonEmpty, '--from', c.cp]);
expect(busy.status).toBe(3);
const busy = autoplan(['resume', '--out', nonEmpty, '--from', gen]);
expect(busy.stderr).toMatch(/\(CHECKPOINT_TARGET_EXISTS\)/);
expect(fs.readdirSync(nonEmpty)).toEqual(['keep.txt']);
fs.appendFileSync(path.join(c.cp, 'run', 'gate.json'), ' ');
const target = path.join(c.dir, 'restored');
const tampered = autoplan(['resume', '--out', target, '--from', c.cp]);
expect(tampered.status).toBe(3);
expect(tampered.stderr).toMatch(/gate\.json does not match its sha256.*\(CHECKPOINT_INVALID\)/);
expect(fs.existsSync(target)).toBe(false);
const m = JSON.parse(fs.readFileSync(path.join(c.cp, 'checkpoint.json'), 'utf8'));
fs.writeFileSync(path.join(c.cp, 'checkpoint.json'), JSON.stringify({ ...m, files: { ...m.files, '../../escape.md': m.files['run.json'] } }));
const escaping = autoplan(['resume', '--out', target, '--from', c.cp]);
expect(escaping.status).toBe(3);
expect(escaping.stderr).toMatch(/escapes the checkpoint.*\(CHECKPOINT_INVALID\)/);
expect(fs.existsSync(target)).toBe(false);
const manifestFile = path.join(gen, 'checkpoint.json');
const m = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
const snapDir = Object.keys(m.files).find(f => f.includes('/'))!.split('/')[0]!;
const outsideCopy = path.join(c.dir, 'outside-copy');
fs.cpSync(path.join(gen, 'run', snapDir), outsideCopy, { recursive: true });
fs.rmSync(path.join(gen, 'run', snapDir), { recursive: true });
fs.symlinkSync(outsideCopy, path.join(gen, 'run', snapDir));
refused(/escapes the checkpoint through a symlinked directory/);
fs.rmSync(path.join(gen, 'run', snapDir));
fs.cpSync(outsideCopy, path.join(gen, 'run', snapDir), { recursive: true });
fs.writeFileSync(manifestFile, JSON.stringify({ ...m, files: { ...m.files, '../../escape.md': m.files['run.json'] } }));
refused(/escapes the checkpoint/);
const gateFile = path.join(gen, 'run', 'gate.json');
fs.appendFileSync(gateFile, '\n');
fs.writeFileSync(manifestFile, JSON.stringify({ ...m, files: { ...m.files, 'gate.json': require('node:crypto').createHash('sha256').update(fs.readFileSync(gateFile)).digest('hex') } }));
refused(/the staged run does not validate: ARTIFACT_STALE gate\.json/);
});
});
+44
View File
@@ -204,6 +204,50 @@ describe('record-only changes settle without a plan edit', () => {
});
});
describe('mixed amendments: a scope change and a packaging change', () => {
const SCOPE_SAME = { native: [{ ...SAME.native[0], title: 'Ship the backoff behind a flag first', fix: 'gate the change behind RETRY_BACKOFF=1' }], outside: [{ ...SAME.outside[0], title: 'Ship the backoff behind a flag first', fix: 'gate the change behind RETRY_BACKOFF=1' }] };
const v2 = (dir: string) => v(dir, SMALL_PLAN.replace('cap 30 s', 'cap 30 s, behind RETRY_BACKOFF=1'));
const mixed = () => {
const g = gated(SCOPE_SAME, PACKAGING);
const items = gate(g.out).items.filter((i: any) => i.id.startsWith('uc'));
expect(items.map((i: any) => `${i.id}:${i.recommended}:${i.changes}`)).toEqual(['uc1:b:scope', 'uc2:b:packaging']);
const res = answer(g.out, 1, 'rec');
expect(res.status, res.stderr).toBe(0);
expect(res.stdout).toMatch(new RegExp(`^NEXT: gstack-autoplan revise --out ${esc(g.out)} --settle uc2; then edit the plan, then gstack-autoplan revise --out ${esc(g.out)} --plan ${esc(g.plan)} --incorporates ${esc(g.run)}-uc1$`, 'm'));
return g;
};
test('incorporate first, then settle: the settle binds to the revised plan, the reopened phases rerun, and the run completes', () => {
const g = mixed();
const inc = r.ap(['revise', '--out', g.out, '--plan', v2(g.dir), '--incorporates', 'uc1']);
expect(inc.status, inc.stderr).toBe(0);
expect(inc.stdout).toMatch(/^REVISED: plan=[0-9a-f]{12} reopened=ceo,eng$/m);
const settled = r.ap(['revise', '--out', g.out, '--settle', 'uc2']);
expect(settled.status, settled.stderr).toBe(0);
expect(settled.stdout).toMatch(new RegExp(`^SETTLED: ${esc(g.run)}-uc2 \\(packaging; plan [0-9a-f]{12} unchanged; nothing reopens\\)$`, 'm'));
expect(settled.stdout).not.toMatch(/^APPROVED:/m);
expect(settled.stdout).toMatch(new RegExp(`^NEXT: gstack-autoplan next --out ${esc(g.out)}$`, 'm'));
expect(readRunJson(g.out).status).toBe('running');
phaseVia(r, g.out, 'ceo');
expect(phaseVia(r, g.out, 'eng').outside.stdout).toContain('status=gate_pending');
expect(answer(g.out, 2, 'rec').stdout).toContain('status=complete');
expect(r.validate(g.out).status).toBe(0);
});
test('settle and incorporate in one revise; settling alone on an unreviewed plan edit is refused', () => {
const g = mixed();
fs.writeFileSync(g.plan, SMALL_PLAN.replace('cap 30 s', 'cap 30 s, behind RETRY_BACKOFF=1'));
const early = r.ap(['revise', '--out', g.out, '--settle', 'uc2']);
expect(early.status).toBe(3);
expect(early.stderr).toMatch(/the plan file has edits the run has not reviewed.*\(AMENDMENT_NOT_SETTLEABLE\)$/m);
const both = r.ap(['revise', '--out', g.out, '--plan', g.plan, '--incorporates', 'uc1', '--settle', 'uc2']);
expect(both.status, both.stderr).toBe(0);
expect(both.stdout).toMatch(/^AMENDMENTS: pending=none$/m);
const run = readRunJson(g.out);
expect(run.status).toBe('running');
expect(run.amendments.map((a: any) => `${a.item}:${a.status}`)).toEqual(['uc1:incorporated', 'uc2:settled']);
expect(r.ap(['next', '--out', g.out]).status).toBe(0);
});
});
describe('partial replies resolve against persisted decisions', () => {
test('a later `all` never overwrites an earlier override, and p1 alone with approvals unanswered keeps the gate', () => {
const g = gated({}, {});
+26 -2
View File
@@ -1,8 +1,9 @@
/**
* Pairing negative control (next-wave B2/B-a): finding pairs copied verbatim
* from a real autoplan run. File:line proximity associates findings but never
* confirms them; only a title match or the parent's override does, and `b`
* needs one shared proposal or the same remedy.
* confirms them, and neither does a title match alone: a title match with
* the same remedy confirms, opposite remedies disagree, different remedies stay
* unverified; the parent's override with one shared proposal also confirms.
*/
import { describe, expect, test } from 'bun:test';
import fixture from './fixtures/autoplan-pairing/run-20261014-pairs.json';
@@ -36,4 +37,27 @@ describe('pairing negative control from a real run', () => {
const [row] = reconcile(p.native, p.outside, [{ outside_id: p.outside[0].id, native_id: p.native[0].id, disposition: 'confirmed', resolution: 'both: a recommendation edit changes what a seen reply resolves to' }]).rows;
expect(challengeDefault(row!, challenge(p.native, 'scope')[0], challenge(p.outside, 'scope')[0])).toMatchObject({ recommended: 'a', basis: 'plan direction; remedies differ; no shared proposal' });
});
test('the same title with opposite remedies disagrees, as an ordinary finding and as a user challenge; never b', () => {
const p = pair('constructed same title, opposite');
const [row] = reconcile(p.native, p.outside).rows;
expect(row).toMatchObject({ disposition: 'disagree', basis: 'title' });
expect(row!.resolution).toStartWith('remedies conflict (same title): native "move attempts into a new retry_attempts table"');
const [n] = challenge(p.native, 'scope'), [o] = challenge(p.outside, 'scope');
const [crow] = reconcile([n], [o]).rows;
expect(crow!.disposition).toBe('disagree');
expect(challengeDefault(crow!, n, o)).toMatchObject({ recommended: 'a', basis: 'plan direction; voices disagree' });
});
test('the same title with different remedies is only associated; the same remedy confirms and recommends b', () => {
const differ = pair('constructed same title, different');
const [row] = reconcile(differ.native, differ.outside).rows;
expect(row).toMatchObject({ disposition: 'unverified' });
expect(row!.resolution).toStartWith('same title, remedies differ');
expect(challengeDefault(row!, challenge(differ.native, 'scope')[0], challenge(differ.outside, 'scope')[0])).toMatchObject({ recommended: 'a', basis: 'plan direction; remedies differ; no shared proposal' });
const same = pair('constructed same title, same remedy');
const [ok] = reconcile(same.native, same.outside).rows;
expect(ok).toMatchObject({ disposition: 'confirmed', basis: 'title' });
expect(challengeDefault(ok!, challenge(same.native, 'scope')[0], challenge(same.outside, 'scope')[0])).toMatchObject({ recommended: 'b', basis: 'both voices; same remedy' });
});
});
+25
View File
@@ -13,6 +13,8 @@
* required in an unattended run and lands on every decision row; an agent
* answer also carries the owner's verbatim reply (next-wave B-c), and a
* parseable owner reply that resolves differently from the relay is refused.
* A gate.json written before `rec` (no rec_rev) refuses alias replies
* until the owner sees a re-render (--rec-hash); tokens still answer.
* B-b `recommend` changes what `rec` resolves to, so it bumps rec_rev and a
* `rec` must carry the rec_hash of the render the owner saw.
*/
@@ -102,6 +104,29 @@ describe('R1/B1: `rec` (and its alias `all`) takes every recommendation, plan ch
});
});
describe('legacy gates: a gate.json written before `rec` (no rec_rev)', () => {
test('alias replies are refused until a re-render binds --rec-hash; explicit tokens still answer', () => {
const out = agreedRun('legacy');
const gateFile = path.join(out, 'gate.json');
const gate = JSON.parse(fs.readFileSync(gateFile, 'utf8'));
expect(gate.rec_rev).toBe(0);
delete gate.rec_rev;
fs.writeFileSync(gateFile, JSON.stringify(gate, null, 2) + '\n');
const before = fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8');
for (const reply of ['all', 'rec', 'approve all', 'all except uc1a']) {
const refused = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', reply, '--by', 'human']);
expect(refused.status, reply).toBe(3);
expect(refused.stderr).toMatch(/written before `rec`\/`all` took plan changes.*\(GATE_REC_STALE\)$/m);
}
expect(fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8')).toBe(before);
const tokens = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'uc1a', '--by', 'human']);
expect(tokens.status, tokens.stderr).toBe(0);
const hash = renderGate(JSON.parse(fs.readFileSync(gateFile, 'utf8'))).match(/rec_hash=([0-9a-f]{12})/)![1]!;
const bound = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'rec', '--rec-hash', hash, '--by', 'human']);
expect(bound.status, bound.stderr).toBe(0);
});
});
describe('R5: gate answers carry provenance', () => {
test('an unattended answer without --by is refused and writes nothing; --by lands on every answered row', () => {
const out = agreedRun('provenance');
+6 -5
View File
@@ -88,14 +88,14 @@ describe('next/submit loop', () => {
const ceo = runPhase(out, 'ceo');
expect(ceo.native.status, ceo.native.stderr).toBe(0);
expect(ceo.outside.status, ceo.outside.stderr).toBe(0);
expect(ceo.outside.stdout).toMatch(/^PHASE_CLOSED: ceo confirmed=2 disagree=0 new=1 native_only=1 coverage=both$/m);
expect(ceo.outside.stdout).toMatch(/^PHASE_CLOSED: ceo confirmed=0 unverified=2 disagree=0 new=1 native_only=1 coverage=both$/m);
expect(ceo.outside.stdout).toContain('GSTACK_RESULT: skill=autoplan status=running run=');
const consensus = JSON.parse(fs.readFileSync(path.join(out, 'ceo-consensus.json'), 'utf8'));
const run = readRun(out).run;
expect(consensus.rows.map((r: any) => [r.outside_id, r.native_id, r.disposition, r.basis])).toEqual([
[`${run}-ceo-outside-1`, `${run}-ceo-native-1`, 'confirmed', 'file_line'],
[`${run}-ceo-outside-1`, `${run}-ceo-native-1`, 'unverified', 'file_line'],
[`${run}-ceo-outside-2`, null, 'new', 'none'],
[`${run}-ceo-outside-3`, `${run}-ceo-native-3`, 'confirmed', 'title'],
[`${run}-ceo-outside-3`, `${run}-ceo-native-3`, 'unverified', 'title'],
]);
expect(consensus.native_only_ids).toEqual([`${run}-ceo-native-2`]);
const eng = runPhase(out, 'eng');
@@ -110,8 +110,9 @@ describe('next/submit loop', () => {
const decisions = fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8').trim().split('\n').map(l => JSON.parse(l));
expect(decisions.every((d: any) => d.status === 'pending' && d.gate_rev === 1)).toBe(true);
const tasks = fs.readFileSync(path.join(out, 'tasks.jsonl'), 'utf8').trim().split('\n').map(l => JSON.parse(l));
expect(tasks.length).toBe(4);
expect(tasks[0]).toMatchObject({ schema_version: 2, status: 'blocked_on_decision', blocked_by: [`${run}-p1`], findings: [`${run}-ceo-native-1`, `${run}-ceo-outside-1`] });
expect(tasks.length).toBe(6);
expect(tasks[0]).toMatchObject({ schema_version: 2, status: 'blocked_on_decision', blocked_by: [`${run}-p1`], findings: [`${run}-ceo-native-1`] });
expect(tasks.flatMap((t: any) => t.findings).filter((f: string) => f.endsWith('-ceo-outside-1'))).toHaveLength(1);
expect(fs.readFileSync(path.join(out, 'review-record.md'), 'utf8')).toContain('GUARD_NOT_INSTALLED');
expect(fs.readFileSync(path.join(out, 'plan.md'), 'utf8').startsWith('## URGENT, outside this plan')).toBe(true);
// a missing prior consensus refuses the Eng dispatch
+88 -1
View File
@@ -89,6 +89,93 @@
"resolution": "Hash the rendered recommendations into the relay (for example rec_hash in the render's resolution line). Make answer reject a rec whose hash no longer matches (re-render and re-relay). Make recommend refuse after a render has been relayed unless it bumps to a fresh render. Test recommend-after-relay, then rec."
}
]
},
{
"name": "constructed same title, opposite remedies",
"expect": "negative",
"source": "constructed (integration review, 2026-10-15): the same title associates two findings whose remedies are opposite",
"outside": [
{
"phase": "eng",
"severity": "High",
"title": "Persist retry attempts in their own table",
"file": "lib/worker.ts",
"id": "r-eng-outside-9",
"voice": "outside",
"line": 40,
"resolution": "keep attempts in the existing jobs column; do not add a table"
}
],
"native": [
{
"phase": "eng",
"severity": "High",
"title": "Persist retry attempts in their own table",
"file": "lib/worker.ts",
"id": "r-eng-native-9",
"voice": "native",
"line": 90,
"resolution": "move attempts into a new retry_attempts table"
}
]
},
{
"name": "constructed same title, different remedies",
"expect": "negative",
"source": "constructed: the same title with two different, non-opposite remedies is associated, not confirmed",
"outside": [
{
"phase": "eng",
"severity": "High",
"title": "Persist retry attempts in their own table",
"file": "lib/worker.ts",
"id": "r-eng-outside-10",
"voice": "outside",
"line": 40,
"resolution": "index the attempts by tenant"
}
],
"native": [
{
"phase": "eng",
"severity": "High",
"title": "Persist retry attempts in their own table",
"file": "lib/worker.ts",
"id": "r-eng-native-10",
"voice": "native",
"line": 90,
"resolution": "partition the attempts table by month"
}
]
},
{
"name": "constructed same title, same remedy",
"expect": "positive",
"source": "constructed: the same title and the same remedy (case and punctuation aside) confirm",
"outside": [
{
"phase": "eng",
"severity": "High",
"title": "Persist retry attempts in their own table",
"file": "lib/worker.ts",
"id": "r-eng-outside-11",
"voice": "outside",
"line": 40,
"resolution": "Move attempts into a new retry_attempts table."
}
],
"native": [
{
"phase": "eng",
"severity": "High",
"title": "Persist retry attempts in their own table",
"file": "lib/worker.ts",
"id": "r-eng-native-11",
"voice": "native",
"line": 90,
"resolution": "move attempts into a new retry_attempts table"
}
]
}
]
}
}