diff --git a/autoplan/DRIVER.md b/autoplan/DRIVER.md
index 1e8fc3571..48cad0438 100644
--- a/autoplan/DRIVER.md
+++ b/autoplan/DRIVER.md
@@ -74,7 +74,7 @@ line yourself; gstack never writes the plan.
- `gstack-autoplan status|resume --out
`
read the manifest (PENDING: lines name open attempts); take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts).
- `gstack-autoplan resume --out --from `
- 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 ` 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 ""`, `--owner-reply -` (stdin) or `--owner-reply-file `
- `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 --plan --incorporates `; settle a packaging or ordering change with `gstack-autoplan revise --out --settle `; or drop one with `gstack-autoplan revise --out --withdraw `
-- `AMENDMENT_NOT_SETTLEABLE`: edit the plan, then `gstack-autoplan revise --out --plan --incorporates `; settle only packaging or ordering changes on an unchanged plan
-- `CHECKPOINT_INVALID`: restore from an intact copy of `//` (its `restore.md` names the command); nothing was written to --out
+- `AMENDMENT_NOT_SETTLEABLE`: edit the plan, then `gstack-autoplan revise --out --plan --incorporates ` (add `--settle ` 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 ` 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)
diff --git a/bin/gstack-autoplan b/bin/gstack-autoplan
index e2b8766f4..becab9f1d 100755
--- a/bin/gstack-autoplan
+++ b/bin/gstack-autoplan
@@ -16,6 +16,7 @@
* gstack-autoplan status --out [--json] query: the manifest, no GSTACK_RESULT line
* gstack-autoplan export --out [--json] rewrite plan.md, review-record.md, findings/tasks.jsonl and run.json hashes
* gstack-autoplan release --out --attempt drop a reservation for an attempt that never ran
+ * gstack-autoplan checkpoints --from 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 --gate-rev --item --option --basis "" [--by ]
set one item's recommendation before relay (provenance recorded, p1 follows); re-render and relay afterwards
resume --out [--from ] [--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 // 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 // and prints CHECKPOINT: on stderr)
+ checkpoints --from list checkpoint generations, newest first: run, gate_rev, time, hash ok|bad, current
status --out [--json] export --out release --out --attempt
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): 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 {
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 ');
+ 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 `);
const out = path.resolve(outFlag);
@@ -256,8 +270,7 @@ async function main(argv: string[]): Promise {
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;
diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh
index cdff473ed..334148c1f 100644
--- a/bin/gstack-doctor-components.sh
+++ b/bin/gstack-doctor-components.sh
@@ -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' ;;
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index a4c323f7a..c0b00d065 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -2813,14 +2813,18 @@ pending, revise without `--incorporates`:
**Meaning.** `gstack-autoplan revise --settle ` 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 --plan --incorporates `.
-The `APPLY:` line for each change says which path it takes.
+`gstack-autoplan revise --out --plan --incorporates `,
+adding `--settle ` 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.
### `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=`; 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 /gate.json --all`), relay it,
-and answer with `--rec-hash ` from that render.
+and answer with `--rec-hash ` from that render. Explicit ``
+tokens are never refused.
### `CHECKPOINT_INVALID`
-**Meaning.** `gstack-autoplan resume --out --from ` 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 --from ` 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
-`//` (its `restore.md` names the exact command); a copy that was
-edited or partially synced fails here by design.
+**Fix.** `gstack-autoplan checkpoints --from ` lists every generation
+with `hash=ok` or `hash=bad ()`; 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.
+
+
+### `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.
### `CHECKPOINT_TARGET_EXISTS`
diff --git a/docs/unattended.md b/docs/unattended.md
index bf5919c9f..906257591 100644
--- a/docs/unattended.md
+++ b/docs/unattended.md
@@ -239,8 +239,14 @@ the plan.
consumption steps above apply unchanged. `$AP status --out ` 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 `//`; `$AP resume --out --from /` verifies and restores it (`CHECKPOINT_INVALID`).
+ at rest publishes an immutable, hash-listed generation under
+ `//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 ` lists generations with their hash status, and
+ `$AP resume --out --from /` 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
diff --git a/lib/autoplan-checkpoint.ts b/lib/autoplan-checkpoint.ts
index 687c4a28c..dff1b3ad0 100644
--- a/lib/autoplan-checkpoint.ts
+++ b/lib/autoplan-checkpoint.ts
@@ -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 `//`: `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 `//generations//`:
+ * `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 `.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;
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(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 --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//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 --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 = {};
- 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 = {};
+ 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 `: 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 /`, 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 });
+ }
+ }));
}
diff --git a/lib/autoplan-gate.ts b/lib/autoplan-gate.ts
index ad099e598..35db3f557 100644
--- a/lib/autoplan-gate.ts
+++ b/lib/autoplan-gate.ts
@@ -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 = { 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): 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, 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 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)}`);
diff --git a/lib/autoplan-reconcile.ts b/lib/autoplan-reconcile.ts
index 7dc47302b..48f5eb5e1 100644
--- a/lib/autoplan-reconcile.ts
+++ b/lib/autoplan-reconcile.ts
@@ -209,13 +209,26 @@ function jaccard(a: Set, b: Set): 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 };
}
diff --git a/lib/autoplan-revise.ts b/lib/autoplan-revise.ts
index 52cee58a6..777416cc8 100644
--- a/lib/autoplan-revise.ts
+++ b/lib/autoplan-revise.ts
@@ -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 } {
- 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 } {
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 = {};
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 --incorporates --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 });
diff --git a/lib/autoplan-run.ts b/lib/autoplan-run.ts
index bec1b801e..6346133b4 100644
--- a/lib/autoplan-run.ts
+++ b/lib/autoplan-run.ts
@@ -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 for a packaging/ordering change, --withdraw )`, 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 )`, 3);
}
const phase = currentPhase(state);
if (!phase) { finishGate(state); writeState(state); return result; }
diff --git a/lib/autoplan-state.ts b/lib/autoplan-state.ts
index c71d51e36..b7e48ca44 100644
--- a/lib/autoplan-state.ts
+++ b/lib/autoplan-state.ts
@@ -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) ps.prompts[k] = reroot(ps.prompts[k])!;
diff --git a/lib/result-codes.ts b/lib/result-codes.ts
index 1f4934a35..87251232e 100644
--- a/lib/result-codes.ts
+++ b/lib/result-codes.ts
@@ -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 --plan --incorporates `; 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 --plan --incorporates ` (add `--settle ` 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 /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 `//` (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 ` 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',
diff --git a/scripts/resolvers/autoplan-driver.ts b/scripts/resolvers/autoplan-driver.ts
index 75690fba4..867a80ca9 100644
--- a/scripts/resolvers/autoplan-driver.ts
+++ b/scripts/resolvers/autoplan-driver.ts
@@ -29,7 +29,7 @@ export const AUTOPLAN_DRIVER_COMMANDS: Array<{ cmd: string; does: string }> = [
{ cmd: 'gstack-autoplan revise --out --withdraw ', does: 'drop a pending amendment instead of applying it' },
{ cmd: 'gstack-autoplan export --out ', does: 'write the artifact set; then `gstack-artifact validate /run.json`' },
{ cmd: 'gstack-autoplan status|resume --out ', does: 'read the manifest (PENDING: lines name open attempts); take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts)' },
- { cmd: 'gstack-autoplan resume --out --from ', does: 'restore a hash-verified checkpoint (GSTACK_AUTOPLAN_CHECKPOINT_DIR) into an empty directory and take ownership' },
+ { cmd: 'gstack-autoplan resume --out --from ', does: 'restore the newest valid checkpoint generation (GSTACK_AUTOPLAN_CHECKPOINT_DIR) into an empty directory and take ownership; `checkpoints --from ` lists them' },
];
/** The reply grammar `gstack-gate` parses, one form per line. */
diff --git a/test/autoplan-answer-invalidation.test.ts b/test/autoplan-answer-invalidation.test.ts
index 9fd46a63d..48dd028bd 100644
--- a/test/autoplan-answer-invalidation.test.ts
+++ b/test/autoplan-answer-invalidation.test.ts
@@ -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;
diff --git a/test/autoplan-checkpoint.test.ts b/test/autoplan-checkpoint.test.ts
index 264b1a1c4..72aac6ab3 100644
--- a/test/autoplan-checkpoint.test.ts
+++ b/test/autoplan-checkpoint.test.ts
@@ -1,90 +1,183 @@
/**
- * Durable checkpoint (next-wave B12): with GSTACK_AUTOPLAN_CHECKPOINT_DIR set,
- * a run at rest is copied, hash-listed, to //; 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 //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 } {
+/** 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 } {
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 --from ${c.cp}`);
+ expect(fs.readFileSync(path.join(c.collection, 'restore.md'), 'utf8')).toContain(`gstack-autoplan resume --out --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/);
});
});
diff --git a/test/autoplan-gate-judgment.test.ts b/test/autoplan-gate-judgment.test.ts
index 9c147f7ea..02e841837 100644
--- a/test/autoplan-gate-judgment.test.ts
+++ b/test/autoplan-gate-judgment.test.ts
@@ -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({}, {});
diff --git a/test/autoplan-pairing.test.ts b/test/autoplan-pairing.test.ts
index 063636bce..71e9ab67f 100644
--- a/test/autoplan-pairing.test.ts
+++ b/test/autoplan-pairing.test.ts
@@ -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' });
+ });
});
diff --git a/test/autoplan-residuals.test.ts b/test/autoplan-residuals.test.ts
index 54d57769f..13260852b 100644
--- a/test/autoplan-residuals.test.ts
+++ b/test/autoplan-residuals.test.ts
@@ -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');
diff --git a/test/autoplan-run-recovery.test.ts b/test/autoplan-run-recovery.test.ts
index d2c0db080..74dbf1361 100644
--- a/test/autoplan-run-recovery.test.ts
+++ b/test/autoplan-run-recovery.test.ts
@@ -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
diff --git a/test/fixtures/autoplan-pairing/run-20261014-pairs.json b/test/fixtures/autoplan-pairing/run-20261014-pairs.json
index d0fac6775..e116d5a61 100644
--- a/test/fixtures/autoplan-pairing/run-20261014-pairs.json
+++ b/test/fixtures/autoplan-pairing/run-20261014-pairs.json
@@ -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"
+ }
+ ]
}
]
-}
\ No newline at end of file
+}