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

This commit is contained in:
garrytan committed 2026-10-15 00:49:05 +00:00
commit db9f098958
43 files changed
+1581 -415

No files matched your search

+35 -15
View File
@@ -28,15 +28,23 @@ line yourself; gstack never writes the plan.
a subagent on another model family (or `gstack-outside-voice run`). Each writes `result=`.
2. Submit each voice exactly once (`BOUND:`). Both voices in closes the phase (`PHASE_CLOSED:`).
3. Repeat `next` until `status=gate_pending` (`GATE: gate_rev=<n>`); render it with
`gstack-gate render <dir>/gate.json`. You never approve the gate for the owner: relay it.
`gstack-gate render <dir>/gate.json --all` and relay every page. You never approve the
gate for the owner. Offer `rec` (every recommendation, plan changes included) as the default.
4. `answer` with the owner's reply and `--by human` (the owner typed the tokens) or
`--by agent:<id>` (you relayed or decided them); without it an unattended run refuses
(`ANSWER_BY_REQUIRED`). `all` leaves every item recommended `b` pending: only the owner's
own token changes their stated direction. `APPLY: <id> <title> -> <change>` lines are
accepted plan changes: edit the plan, then `revise` (the printed `NEXT:` names it). Until
`--by agent:<id> --owner-reply "<their words>"` (you relayed them); without `--by` an
unattended run refuses (`ANSWER_BY_REQUIRED`). `APPLY: <id> <title> -> <change>` lines are
accepted plan changes (status `amend_pending`): edit the plan, then `revise` (the printed
`NEXT:` names it), or `revise --settle <id>` when the line says no plan edit is needed. Until
then `next` refuses with `PLAN_AMEND_PENDING` and `export` will not mark the run complete.
5. `REOPENED: <phases>` means loop `next` again (Eng last). `APPROVED:` means `export`.
### On Capy
- Always drive `gstack-autoplan`; never hand-adapt `autoplan/SKILL.md`. A plan you reviewed by
hand is not an autoplan run: do not present it as one.
- Set `GSTACK_AUTOPLAN_CHECKPOINT_DIR` to the private project-user Drive folder so a run at rest
survives machine replacement; restore with `resume --out <new dir> --from <dir>/<run>`.
### Commands
- `gstack-autoplan next --out <dir> --plan <plan.md> [scope flags] [--light] [--spend-cap <usd>] [--scaffold]`
@@ -51,16 +59,22 @@ line yourself; gstack never writes the plan.
bind a canonical result (the RESULT FORMAT below).
- `gstack-autoplan submit --out <dir> --phase <p> --voice outside --unavailable|--failed "<reason>"`
close the phase native-only when no outside voice can run.
- `gstack-autoplan answer --out <dir> --gate-rev <n> --reply "<reply>"`
answer the gate with the reply grammar below.
- `gstack-autoplan answer --out <dir> --gate-rev <n> --reply "<reply>" --by human|agent:<id> [--owner-reply <text>|-] [--rec-hash <h>]`
answer the gate with the reply grammar below; an agent answer stores the owner's words verbatim.
- `gstack-autoplan recommend --out <dir> --gate-rev <n> --item <id> --option <k> --basis "<why>"`
change one recommendation before relay; then render and relay again (a `rec` needs the new `--rec-hash`).
- `gstack-autoplan revise --out <dir> --plan <plan v2> --incorporates <decision ids>`
bind the amended plan; reopens every phase whose reviewed plan changed, Eng last.
- `gstack-autoplan revise --out <dir> --settle <decision id|d4>`
discharge a packaging or ordering change with no plan edit; reopens nothing.
- `gstack-autoplan revise --out <dir> --withdraw <decision id>`
drop a pending amendment instead of applying it.
- `gstack-autoplan export --out <dir>`
write the artifact set; then `gstack-artifact validate <dir>/run.json`.
- `gstack-autoplan status|resume --out <dir>`
read the manifest; take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts).
read the manifest (PENDING: lines name open attempts); take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts).
- `gstack-autoplan resume --out <new dir> --from <checkpoint>`
restore a hash-verified checkpoint (GSTACK_AUTOPLAN_CHECKPOINT_DIR) into an empty directory and take ownership.
## Reviewer result format
@@ -73,22 +87,23 @@ RESULT FORMAT (required by gstack-autoplan; the parent binds your file by this c
```gstack-findings
{"id":"F1","severity":"High","title":"<one line>","file":"<path or omit>","line":12,"fix":"<the fix>"}
```
severity is Critical | High | Medium | Low | Informational. Add "user_challenge": true only when you recommend changing the plan's stated direction AND the change alters its scope, cost or direction; then also add "changes": "scope" | "cost" | "direction". Packaging, ordering, naming and other reversible choices are ordinary findings, not user challenges. Add "urgent": true and "suggested_owner": "<who>" for a security or data-loss finding this plan does not own. An empty fence means no findings; a missing fence is refused.
severity is Critical | High | Medium | Low | Informational. Add "user_challenge": true only when you recommend changing the plan's stated direction AND the change alters its scope, cost or direction (then also add "changes": "scope" | "cost" | "direction") or only its delivery packaging or ordering, with no Implementation-plan edit (then "changes": "packaging" | "ordering"). Naming and other reversible choices are ordinary findings, not user challenges. Add "urgent": true and "suggested_owner": "<who>" for a security or data-loss finding this plan does not own. An empty fence means no findings; a missing fence is refused.
3. Write the whole result to this file: <result file>
## Gate replies
A reply binds to the `gate_rev` you read (`--gate-rev`). Bare `yes`/`no` is unparsed, never guessed.
- `all`: every item takes its recommendation (an agreed change prints APPLY: and needs revise).
- `all as-is`: keep every direction; no plan change.
- `rec`: every item takes its recommendation, plan changes included (each prints APPLY:); `all` is an alias.
- `as-is`: keep every direction; no plan change.
- `d3b uc1a`: one <id><option> token per item you set; unanswered auto items take their default.
- `all except d3b`: recommendations, with the listed overrides.
- `rec except d3b`: recommendations, with the listed overrides.
## Run directory
Runner state: `run.json`, `attempts.jsonl`, `run.lock`, `spend.json`, `plan-active.md`, `restore.md`, `plan-source.md`. Reviewer files: `<phase>-native-prompt.md`,
`<phase>-outside-prompt.md`, `<phase>-native.md`, `<phase>-outside.md`, `<phase>-consensus.{json,md}`,
`<phase>-outside-prompt.md`, `<phase>-<voice>.<attempt>.md` (the reviewer writes it), its sealed
`<phase>-<voice>.<attempt>.bound.md` (what submit bound), `<phase>-consensus.{json,md}`,
`gate.json`, `decisions.jsonl`. `export` writes `plan.md`, `review-record.md`, `findings.jsonl`,
`tasks.jsonl`, `timing.json` and rehashes `run.json`; `gstack-artifact validate <dir>/run.json`
is the completion check. Copy the whole directory, never single files.
@@ -107,8 +122,13 @@ is the completion check. Copy the whole directory, never single files.
- `MODEL_FAMILY_CONFLICT`: pass `--model <id>` of a different family (anthropic, openai, google, xai, deepseek, qwen, zai, moonshot, meta, mistral); a same-family result is not an outside voice
- `CONSENSUS_MISSING`: resume the run so the phase closes through reconciliation; the Eng prompt is never sent without every prior phase summary
- `GATE_REV_STALE`: render the current gate list, read it, and reply with its gate_rev
- `GATE_REPLY_UNPARSED`: reply with `all`, `all as-is`, `<id><option>` tokens (for example `d3b uc1a`), or `all except <tokens>`; bare yes/no is ambiguous
- `PLAN_AMEND_PENDING`: edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id,...>`; or drop one with `gstack-autoplan revise --out <dir> --withdraw <decision id>`
- `GATE_REPLY_UNPARSED`: reply with `rec`, `as-is`, `<id><option>` tokens (for example `d3b uc1a`), or `rec except <tokens>`; bare yes/no is ambiguous
- `GATE_REC_STALE`: render the gate again (`gstack-gate render <dir>/gate.json --all`), relay it, and answer with the printed `--rec-hash`
- `OWNER_REPLY_REQUIRED`: pass the owner's words exactly with `--owner-reply "<text>"`, `--owner-reply -` (stdin) or `--owner-reply-file <path>`
- `GATE_OWNER_REPLY_MISMATCH`: relay exactly what the owner chose (or their reply itself, e.g. `--reply rec`); ask the owner when their words and the tokens disagree
- `PLAN_AMEND_PENDING`: edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id,...>`; settle a packaging or ordering change with `gstack-autoplan revise --out <dir> --settle <id>`; or drop one with `gstack-autoplan revise --out <dir> --withdraw <decision id>`
- `AMENDMENT_NOT_SETTLEABLE`: edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id>`; settle only packaging or ordering changes on an unchanged plan
- `CHECKPOINT_INVALID`: restore from an intact copy of `<GSTACK_AUTOPLAN_CHECKPOINT_DIR>/<run>/` (its `restore.md` names the command); nothing was written to --out
- `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)
+15 -6
View File
@@ -25,15 +25,23 @@ line yourself; gstack never writes the plan.
a subagent on another model family (or `gstack-outside-voice run`). Each writes `result=`.
2. Submit each voice exactly once (`BOUND:`). Both voices in closes the phase (`PHASE_CLOSED:`).
3. Repeat `next` until `status=gate_pending` (`GATE: gate_rev=<n>`); render it with
`gstack-gate render <dir>/gate.json`. You never approve the gate for the owner: relay it.
`gstack-gate render <dir>/gate.json --all` and relay every page. You never approve the
gate for the owner. Offer `rec` (every recommendation, plan changes included) as the default.
4. `answer` with the owner's reply and `--by human` (the owner typed the tokens) or
`--by agent:<id>` (you relayed or decided them); without it an unattended run refuses
(`ANSWER_BY_REQUIRED`). `all` leaves every item recommended `b` pending: only the owner's
own token changes their stated direction. `APPLY: <id> <title> -> <change>` lines are
accepted plan changes: edit the plan, then `revise` (the printed `NEXT:` names it). Until
`--by agent:<id> --owner-reply "<their words>"` (you relayed them); without `--by` an
unattended run refuses (`ANSWER_BY_REQUIRED`). `APPLY: <id> <title> -> <change>` lines are
accepted plan changes (status `amend_pending`): edit the plan, then `revise` (the printed
`NEXT:` names it), or `revise --settle <id>` when the line says no plan edit is needed. Until
then `next` refuses with `PLAN_AMEND_PENDING` and `export` will not mark the run complete.
5. `REOPENED: <phases>` means loop `next` again (Eng last). `APPROVED:` means `export`.
### On Capy
- Always drive `gstack-autoplan`; never hand-adapt `autoplan/SKILL.md`. A plan you reviewed by
hand is not an autoplan run: do not present it as one.
- Set `GSTACK_AUTOPLAN_CHECKPOINT_DIR` to the private project-user Drive folder so a run at rest
survives machine replacement; restore with `resume --out <new dir> --from <dir>/<run>`.
### Commands
{{AUTOPLAN_DRIVER_COMMANDS}}
@@ -54,7 +62,8 @@ A reply binds to the `gate_rev` you read (`--gate-rev`). Bare `yes`/`no` is unpa
## Run directory
{{AUTOPLAN_DRIVER_FILES}} Reviewer files: `<phase>-native-prompt.md`,
`<phase>-outside-prompt.md`, `<phase>-native.md`, `<phase>-outside.md`, `<phase>-consensus.{json,md}`,
`<phase>-outside-prompt.md`, `<phase>-<voice>.<attempt>.md` (the reviewer writes it), its sealed
`<phase>-<voice>.<attempt>.bound.md` (what submit bound), `<phase>-consensus.{json,md}`,
`gate.json`, `decisions.jsonl`. `export` writes `plan.md`, `review-record.md`, `findings.jsonl`,
`tasks.jsonl`, `timing.json` and rehashes `run.json`; `gstack-artifact validate <dir>/run.json`
is the completion check. Copy the whole directory, never single files.
+1 -1
View File
@@ -176,7 +176,7 @@ Sections 1-11 — for EACH section, run the evaluation criteria from the loaded
**Mandatory outputs from Phase 1:**
- Reality rows: the loaded skill's `REALITY:` lines, one per applicable row;
`~/.claude/skills/gstack/bin/gstack-plan-reality check --phase ceo <review-file|->` prints `PLAN_REALITY: … verdict=complete` before the phase closes
`~/.claude/skills/gstack/bin/gstack-plan-reality check --phase ceo --plan <reviewed snapshot> <review-file|->` prints `PLAN_REALITY: … verdict=complete` before the phase closes
(unattended: `incomplete` keeps it open).
- "NOT in scope" section with deferred items and rationale
- "What already exists" section mapping sub-problems to existing code
+1 -1
View File
@@ -105,7 +105,7 @@ Retain the historical review-log skill ID; add `"host":"claude","outside_provide
1. Step 0 (Scope Challenge): Read actual code referenced by the plan. Map each
sub-problem to existing code. Run the complexity check. Produce concrete findings.
Reality rows: the loaded skill's `REALITY:` lines, one per applicable row;
`~/.claude/skills/gstack/bin/gstack-plan-reality check --phase eng <review-file|->` prints `PLAN_REALITY: … verdict=complete` before the phase closes
`~/.claude/skills/gstack/bin/gstack-plan-reality check --phase eng --plan <reviewed snapshot> <review-file|->` prints `PLAN_REALITY: … verdict=complete` before the phase closes
(unattended: `incomplete` keeps it open).
2. Step 0.5 (Dual Voices): Present the completed calls above under Codex SAYS
+53 -21
View File
@@ -7,11 +7,12 @@
* gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --result <file> | --record <file> [--attest <reason>]
* --model <id> [--attempt <id>] [--runner <r>] [--usage-usd <n>] [--reconcile <jsonl>] [--json]
* gstack-autoplan submit --out <dir> --phase <p> --voice outside --unavailable <reason> | --failed <reason> [--json]
* gstack-autoplan revise --out <dir> --plan <file> [--incorporates <id,...>] [--withdraw <id>] [--json] review a revised plan in this run
* gstack-autoplan revise --out <dir> --plan <file> [--incorporates <id,...>] [--withdraw <id>] [--settle <id>] [--json] review a revised plan in this run
* gstack-autoplan run --out <dir> --plan <file> --native-model <id> --outside-model <id> [--runner codex-cli|api] [--native-runner api]
* [--deadline <min>] [--spend-cap <usd>] [--estimate-usd <n>] [--json] next in a loop; needs runners for both voices
* gstack-autoplan answer --out <dir> --gate-rev <rev> --reply "<grammar>" [--json]
* gstack-autoplan resume --out <dir> [--json] take ownership; intent without a terminal record becomes EXECUTION_UNKNOWN
* gstack-autoplan answer --out <dir> --gate-rev <rev> --reply "<grammar>" --by <human|agent:<id>> [--owner-reply <text>|-] [--rec-hash <h>] [--json]
* gstack-autoplan recommend --out <dir> --gate-rev <n> --item <id> --option <k> --basis "<why>" [--by <who>]
* gstack-autoplan resume --out <dir> [--from <checkpoint>] [--json] take ownership; intent without a terminal record becomes EXECUTION_UNKNOWN
* gstack-autoplan status --out <dir> [--json] query: the manifest, no GSTACK_RESULT line
* gstack-autoplan export --out <dir> [--json] rewrite plan.md, review-record.md, findings/tasks.jsonl and run.json hashes
* gstack-autoplan release --out <dir> --attempt <id> drop a reservation for an attempt that never ran
@@ -20,12 +21,13 @@
* Logic: lib/autoplan-run.ts (next/submit/resume), lib/autoplan-gate.ts (close, gate, answer),
* lib/autoplan-export.ts, lib/autoplan-prompts.ts, lib/spend-ledger.ts. Codes: lib/result-codes.ts.
* Workflow commands end with GSTACK_RESULT: skill=autoplan status=<s> run=<dir>; the run is never approved here.
* Mutating verbs (next, submit, answer, revise, export, run) pass admission first (lib/autoplan-admission.ts)
* Mutating verbs (next, submit, answer, recommend, revise, export, run) pass admission first (lib/autoplan-admission.ts)
* and bring an older run directory forward (lib/autoplan-migrate.ts) under the run lock.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { answerGate } from '../lib/autoplan-gate';
import { answerGate, applyGuidance, recommendGate } from '../lib/autoplan-gate';
import { 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';
@@ -55,13 +57,20 @@ DRIVER: ${driverFile()}
without its INPUT line it binds only with --attest <reason> and --attempt (receipt: attested)
submit --out <dir> --phase <p> --voice outside --unavailable <reason> | --failed <reason>
the outside voice could not run: closes the phase native_only once native is bound
revise --out <dir> --plan <file> [--incorporates <decision id,...>] [--withdraw <decision id>]
review a revised plan in this run: every phase whose reviewed Implementation-plan hash differs reopens (plus Eng);
--incorporates discharges the named accepted changes (the plan must change); --withdraw (alone or with --plan) drops one
revise --out <dir> --plan <file> [--incorporates <id,...>] [--withdraw <id>] | --settle <id,...>
review a revised plan in this run: every phase whose reviewed Implementation-plan hash differs reopens (Eng last);
--incorporates discharges the named accepted changes (the plan must change); --withdraw (alone or with --plan) drops one;
--settle records a packaging/ordering change that needs no plan edit and reopens nothing (ids: decision id or d4)
run --out <dir> --plan <file> --native-model <id> --outside-model <id> [--runner codex-cli|api] [--native-runner api] ...
next in a loop, only when a runner executes both voices here; --deadline cancels the children it started
answer --out <dir> --gate-rev <rev> --reply "<all | d3b uc1a | all except d3b>" --by <human|agent:<id>> [--json]
resume --out <dir> [--json] status --out <dir> [--json] export --out <dir> release --out <dir> --attempt <id>
answer --out <dir> --gate-rev <rev> --reply "<rec | rec except d3a | d3b uc1a | as-is>" --by <human|agent:<id>> [--json]
--by agent:<id> needs --owner-reply "<owner's words>" (- reads stdin; --owner-reply-file <path>), stored verbatim;
--rec-hash <h> binds a rec to the render the owner saw (required once recommend ran); accepted changes leave amend_pending
recommend --out <dir> --gate-rev <n> --item <id> --option <k> --basis "<why>" [--by <who>]
set one item's recommendation before relay (provenance recorded, p1 follows); re-render and relay afterwards
resume --out <dir> [--from <checkpoint>] [--json] --from restores a hash-verified checkpoint into an empty --out first
(GSTACK_AUTOPLAN_CHECKPOINT_DIR set: every command leaving the run at rest writes <dir>/<run>/ and prints CHECKPOINT:)
status --out <dir> [--json] export --out <dir> release --out <dir> --attempt <id>
contract print lib/coordinator-contract.ts (the block every lane and reviewer prompt inherits word for word)
The first next needs --plan. The native voice is dispatched by the parent from the printed prompt file (a subagent on the
@@ -87,10 +96,13 @@ function num(args: string[], name: string): number | undefined {
}
const resultLineFor = (status: string, out: string) => `GSTACK_RESULT: skill=autoplan status=${status} run=${out}`;
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 a reopen or revise: revise while accepted changes are pending, else next. */
/** 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).map(a => a.decision);
return pending.length ? `NEXT: gstack-autoplan revise --out ${out} --plan ${state.plan_input ?? state.plan_source} --incorporates ${pending.join(',')}` : `NEXT: gstack-autoplan next --out ${out}`;
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('; ')}`;
}
/**
@@ -116,6 +128,8 @@ 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');
return code;
});
}
@@ -166,12 +180,12 @@ function doSubmit(out: string, rest: string[], json: boolean): number {
}
function doRevise(out: string, rest: string[], json: boolean): number {
const plan = flag(rest, '--plan'); const incorporates = listFlag(rest, '--incorporates'); const withdraw = listFlag(rest, '--withdraw');
if (!plan && !withdraw.length) return usage('revise needs --plan <file> (and optionally --incorporates <id,...>), or --withdraw <decision id>');
const plan = flag(rest, '--plan'); const incorporates = listFlag(rest, '--incorporates'); const withdraw = listFlag(rest, '--withdraw'); const settle = listFlag(rest, '--settle');
if (!plan && !withdraw.length && !settle.length) return usage('revise needs --plan <file> (and optionally --incorporates <id,...>), --withdraw <decision id> or --settle <id>');
if (!plan && incorporates.length) return usage('--incorporates binds accepted changes to a revised plan: pass --plan <file>');
const r = reviseRun({ out, plan: plan ? path.resolve(plan) : undefined, incorporates, withdraw, cwd: process.cwd() });
const r = reviseRun({ out, plan: plan ? path.resolve(plan) : undefined, incorporates, withdraw, settle, cwd: process.cwd() });
const lines = [...r.lines];
if (r.state.status === 'running') lines.push(nextLine(r.state, out));
if (r.state.status === 'running' || r.state.status === 'amend_pending') lines.push(nextLine(r.state, out));
lines.push(resultLineFor(r.state.status, out));
emit(json, lines, { run: r.state.run, status: r.state.status, reopened: r.reopened, amendments: pendingAmendments(r.state).map(a => a.decision), gate_rev: r.state.gate_rev ?? null, out });
return EXIT.ok;
@@ -238,9 +252,13 @@ async function main(argv: string[]): Promise<number> {
return EXIT.ok;
}
if (cmd === 'resume') {
const from = flag(rest, '--from');
const restored = from !== undefined ? restoreCheckpoint(from, out) : [];
return withRunLock(out, () => {
const r = resumeRun(out);
const lines = [...r.lines, resultLineFor(r.state.status, out)];
const checkpoint = checkpointRun(out);
if (checkpoint) process.stderr.write(checkpoint + '\n');
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;
}, { steal: true });
@@ -249,17 +267,31 @@ async function main(argv: string[]): Promise<number> {
const rev = flag(rest, '--gate-rev'); const reply = flag(rest, '--reply'); const by = flag(rest, '--by');
if (reply === undefined) return usage('answer needs --reply "<text>"');
if (rev === undefined) { const s = readState(out); process.stderr.write(resultLine('gstack-autoplan answer', 'GATE_REV_STALE', `no --gate-rev given; current gate_rev=${s.gate_rev ?? 0}`) + '\n'); return EXIT.refused; }
const replyFile = flag(rest, '--owner-reply-file'); const ownerFlag = flag(rest, '--owner-reply');
if (replyFile !== undefined && ownerFlag !== undefined) return usage('--owner-reply and --owner-reply-file are exclusive');
const ownerReply = replyFile !== undefined ? fs.readFileSync(replyFile, 'utf8') : ownerFlag === '-' ? fs.readFileSync(0, 'utf8') : ownerFlag;
return mutating(out, json, () => {
const state = readState(out);
const r = answerGate(state, Number(rev), reply, by);
const r = answerGate(state, Number(rev), reply, by, { recHash: flag(rest, '--rec-hash'), ownerReply });
writeState(state);
const lines = [...r.lines];
if (r.outcome === 'reopened') lines.push(nextLine(state, out));
if (r.outcome === 'reopened' || r.outcome === 'amend_pending') lines.push(nextLine(state, out));
lines.push(resultLineFor(state.status, out));
emit(json, lines, { run: state.run, status: state.status, outcome: r.outcome, pending: r.pending, reopened: r.reopened, amendments: pendingAmendments(state).map(a => a.decision), gate_rev: state.gate_rev, out });
return EXIT.ok;
});
}
if (cmd === 'recommend') {
const rev = flag(rest, '--gate-rev'); const item = flag(rest, '--item'); const option = flag(rest, '--option'); const basis = flag(rest, '--basis');
if (rev === undefined || !item || !option || !basis?.trim()) return usage('recommend needs --gate-rev <n> --item <id> --option <k> --basis "<why>"');
return mutating(out, json, () => {
const state = readState(out);
const lines = recommendGate(state, Number(rev), item.toLowerCase(), option.toLowerCase(), basis.trim(), flag(rest, '--by') ?? 'parent');
writeState(state);
emit(json, [...lines, resultLineFor(state.status, out)], { run: state.run, status: state.status, gate_rev: state.gate_rev, out });
return EXIT.ok;
});
}
if (cmd === 'export') {
return mutating(out, json, () => {
const state = readState(out);
@@ -283,7 +315,7 @@ async function main(argv: string[]): Promise<number> {
} catch (e: any) {
if (e instanceof RunError) {
process.stderr.write(resultLine(`gstack-autoplan ${cmd}`, e.code, e.detail) + '\n');
if (['next', 'submit', 'answer', 'resume', 'run', 'revise'].includes(cmd)) process.stdout.write(resultLineFor('refused', out) + '\n');
if (['next', 'submit', 'answer', 'resume', 'run', 'revise', 'recommend'].includes(cmd)) process.stdout.write(resultLineFor('refused', out) + '\n');
return e.exit === 3 ? EXIT.refused : EXIT.fail;
}
process.stderr.write(`gstack-autoplan ${cmd}: ${e?.message ?? e}\n`);
+6
View File
@@ -186,6 +186,12 @@ gstack_result_anchor() {
PLAN_SECTIONS_INVALID) printf '%s' 'plan-sections-invalid' ;;
PLAN_AMEND_PENDING) printf '%s' 'plan-amend-pending' ;;
AMENDMENT_UNKNOWN) printf '%s' 'amendment-unknown' ;;
AMENDMENT_NOT_SETTLEABLE) printf '%s' 'amendment-not-settleable' ;;
GATE_REC_STALE) printf '%s' 'gate-rec-stale' ;;
CHECKPOINT_INVALID) printf '%s' 'checkpoint-invalid' ;;
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' ;;
GSTACK_VERSION_CHANGED) printf '%s' 'gstack-version-changed' ;;
ARTIFACT_GATE_UNSETTLED) printf '%s' 'artifact-gate-unsettled' ;;
UPGRADE_REFUSED) printf '%s' 'upgrade-refused' ;;
+6 -4
View File
@@ -2,7 +2,7 @@
/**
* gstack-gate — render and parse the one gate list (plan B9).
*
* gstack-gate render <gate.json> [--page N]
* gstack-gate render <gate.json> [--page N | --all]
* gstack-gate parse <gate.json> --reply "<text>" --gate-rev N [--json]
* gstack-gate decisions <gate.json> [--reply "<text>" --gate-rev N] [--answered-at ISO]
*
@@ -15,7 +15,8 @@ import { resultLine } from '../lib/result-codes';
const HELP = `gstack-gate — one gate list, one reply grammar
render <gate.json> [--page N] numbered items with stable ids, auto/approval, the reply shape
render <gate.json> [--page N | --all] numbered items with stable ids, auto/approval, the reply shape;
--all prints every item on one page (relay every page)
parse <gate.json> --reply "<text>" --gate-rev N [--json]
resolved choices as JSON; unparsed tokens are listed, never guessed
decisions <gate.json> [--reply "<text>" --gate-rev N] [--answered-at ISO]
@@ -67,14 +68,15 @@ function parseWith(list: GateList, rest: string[], json: boolean) {
function main(argv: string[]): number {
const [cmd, ...rest] = argv;
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; }
const positional = rest.filter((a, i) => !a.startsWith('--') && !(i > 0 && rest[i - 1]!.startsWith('--') && rest[i - 1] !== '--json'));
const positional = rest.filter((a, i) => !a.startsWith('--') && !(i > 0 && rest[i - 1]!.startsWith('--') && !['--json', '--all'].includes(rest[i - 1]!)));
const list = loadList(positional[0]);
if (typeof list === 'number') return list;
if (cmd === 'render') {
const page = flag(rest, '--page');
if (page !== undefined && !/^[0-9]+$/.test(page)) return usage('--page needs a number');
process.stdout.write(renderGate(list, page ? Number(page) : 1));
if (page !== undefined && rest.includes('--all')) return usage('--page and --all are exclusive');
process.stdout.write(rest.includes('--all') ? renderGate(list, 1, list.items.length) : renderGate(list, page ? Number(page) : 1));
return EXIT.ok;
}
if (cmd === 'parse') {
+8 -4
View File
@@ -3,7 +3,7 @@
* gstack-plan-reality — the reality rows of a plan review (plan D1).
*
* gstack-plan-reality rows --phase <ceo|eng|dx|design> [--scope <csv>] [--deep] [--json]
* gstack-plan-reality check --phase <p> <review-file|-> [--scope <csv>] [--session-kind <kind>] [--json]
* gstack-plan-reality check --phase <p> <review-file|-> [--scope <csv>] [--session-kind <kind>] [--plan <reviewed plan>] [--json]
*
* Logic: lib/plan-reality.ts. Codes: lib/result-codes.ts (REALITY_ROW_MISSING, REALITY_RECEIPT_MISSING).
*/
@@ -15,8 +15,9 @@ import { resultLine } from '../lib/result-codes';
const HELP = `gstack-plan-reality — reality rows for plan reviews
rows --phase <p> [--scope <csv>] [--deep] [--json] the rows this review must carry (tier 1 always, tier 2 by scope)
check --phase <p> <file|-> [--scope <csv>] [--session-kind <kind>] [--json]
check --phase <p> <file|-> [--scope <csv>] [--session-kind <kind>] [--plan <reviewed plan>] [--json]
parse the review's REALITY: lines; exit 1 with the missing rows
--plan: a number inside a "quoted" span copied verbatim from it needs no receipt
Row line: \`${REALITY_LINE_SHAPE}\` (receipt: path:line, path:line-line or a commit sha).
Scopes: ${REALITY_SCOPES.filter(s => s !== 'always').join(', ')}. Session kind unattended → verdict=incomplete; else verdict=repair.
@@ -49,13 +50,16 @@ function main(argv: string[]): number {
return EXIT.ok;
}
if (cmd === 'check') {
const valueFlags = new Set(['--phase', '--scope', '--session-kind']);
const valueFlags = new Set(['--phase', '--scope', '--session-kind', '--plan']);
const file = rest.find((a, i) => !a.startsWith('--') && !(i > 0 && valueFlags.has(rest[i - 1]!)));
if (!file) return usage('a review file (or - for stdin) is required');
let text: string;
try { text = file === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(file, 'utf8'); } catch (e: any) { process.stderr.write(`gstack-plan-reality: ${file}: ${e.message}\n`); return EXIT.fail; }
const sessionKind = flag(rest, '--session-kind') ?? process.env.GSTACK_SESSION_KIND;
const result = checkRealityRows(text, { phase, scopes, sessionKind });
const planFile = flag(rest, '--plan');
let planText: string | undefined;
try { planText = planFile ? fs.readFileSync(planFile, 'utf8') : undefined; } catch (e: any) { process.stderr.write(`gstack-plan-reality: ${planFile}: ${e.message}\n`); return EXIT.fail; }
const result = checkRealityRows(text, { phase, scopes, sessionKind, planText });
if (json) process.stdout.write(JSON.stringify(result) + '\n');
else process.stdout.write([result.line, ...result.numberLines].join('\n') + '\n');
for (const id of result.unknownRows) process.stderr.write(`gstack-plan-reality: ${file}: REALITY row ${JSON.stringify(id)} is not a known row; it was ignored\n`);
+2
View File
@@ -38,6 +38,8 @@ If no specific skill was requested, use the router's task-to-skill mapping. Read
For `/autoplan` driven from Capy, read `autoplan/DRIVER.md` in the checkout (`~/.local/share/gstack/autoplan/DRIVER.md`): the compact phase loop, artifact contract and gate protocol, without the interactive preamble. A checkout without that file predates it; follow its `autoplan/SKILL.md` instead. Run every command with the `GSTACK_LAUNCH` assignments and read outputs as files, not prose: the run directory holds `run.json`, `decisions.jsonl`, `findings.jsonl`, `tasks.jsonl`, `timing.json`, `plan.md` and `review-record.md`, and the run ends with `GSTACK_RESULT: skill=autoplan status=gate_pending run=<dir>`. `~/.local/share/gstack/bin/gstack-artifact validate <dir>/run.json` is the completion check. The gate is answered through `gstack-autoplan answer` (or `gstack-gate parse`) with the printed `gate_rev`; an unattended run never approves its own gate. `docs/unattended.md` in the checkout is the contract.
Capy checklist for `/autoplan`: always drive `gstack-autoplan`; never hand-adapt `autoplan/SKILL.md`, and never present a plan you reviewed by hand as an autoplan run. Relay the gate with `gstack-gate render <dir>/gate.json --all` (every page), offer `rec` (take every recommendation, plan changes included) as the one-word default, and answer with `--by agent:<id> --owner-reply "<their words>"`. Launch every `gstack-autoplan` command with `GSTACK_AUTOPLAN_CHECKPOINT_DIR=/home/user/.capy/drive/<project-user scope>/autoplan-checkpoints` (the private project-user Drive folder) so a run at rest survives machine replacement; on a new machine, `gstack-autoplan resume --out <new dir> --from <that dir>/<run>` restores it (each checkpoint's `restore.md` names the command).
## Before any push or pull request
In a repository with a sealed configuration (a `sealed` key in its `.gstack/pregate.json`), run `~/.local/share/gstack/bin/gstack-pregate sealed` before every push and before creating a pull request: Capy's pull-request tool may not run local git hooks, so this is the check that keeps sealed or eval output out of a public repository. A refusal names the file, line and rule; do not push until it passes.
+74 -1
View File
@@ -2745,7 +2745,10 @@ incorporated or withdrawn; `export` refuses a `complete` run with one pending.
**Fix.** Edit the plan, then `gstack-autoplan revise --out <dir> --plan <file>
--incorporates <decision id,...>`. `revise` binds each named id to the old and
new Implementation-plan hashes and refuses when the Implementation plan did
not change; an id you do not name stays pending. To drop an accepted change
not change; an id you do not name stays pending. A packaging or ordering change
(its `APPLY:` line ends `no plan edit needed: revise --settle <id>`) is
discharged with `gstack-autoplan revise --out <dir> --settle <id>` and reopens
nothing. To drop an accepted change
instead, `gstack-autoplan revise --out <dir> --withdraw <decision id>` records
the withdrawal in the journal.
@@ -2761,6 +2764,76 @@ amendments), or it was already incorporated or withdrawn. Nothing was changed.
pending, revise without `--incorporates`:
`gstack-autoplan revise --out <dir> --plan <file>`.
<a id="amendment-not-settleable"></a>
### `AMENDMENT_NOT_SETTLEABLE`
**Meaning.** `gstack-autoplan revise --settle <id>` discharges an accepted change
without a plan edit, which is honest only for a packaging or ordering change
(the gate item's `changes` is `packaging` or `ordering`) on a plan nobody has
edited since it was reviewed. The named change alters scope, cost or direction,
names no `changes` value, or the plan file's Implementation plan no longer
matches the reviewed snapshot. Nothing was changed.
**Fix.** Edit the plan, then
`gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id>`.
The `APPLY:` line for each change says which path it takes.
<a id="gate-rec-stale"></a>
### `GATE_REC_STALE`
**Meaning.** `gstack-autoplan recommend` changed a recommendation after the gate
was rendered, so a `rec` (or `all`) reply the owner gave to the earlier render
would take recommendations they never saw. Every render prints
`rec_hash=<h>`; once `recommend` has run, a `rec` answer must name the hash of
the render the owner answered.
**Fix.** Render again (`gstack-gate render <dir>/gate.json --all`), relay it,
and answer with `--rec-hash <h>` from that render.
<a id="checkpoint-invalid"></a>
### `CHECKPOINT_INVALID`
**Meaning.** `gstack-autoplan resume --out <dir> --from <checkpoint>` refused
the checkpoint: `checkpoint.json` is unreadable or not a schema_version 1
checkpoint, or a listed file is missing, is not a regular file, escapes the
checkpoint directory, or does not match its recorded sha256. Nothing was
written to `--out`.
**Fix.** Restore from an intact copy. With `GSTACK_AUTOPLAN_CHECKPOINT_DIR`
set, every command that leaves the run at rest rewrites
`<dir>/<run>/` (its `restore.md` names the exact command); a copy that was
edited or partially synced fails here by design.
<a id="checkpoint-target-exists"></a>
### `CHECKPOINT_TARGET_EXISTS`
**Meaning.** `resume --from` restores only into a missing or empty `--out`
directory, and this one already holds files.
**Fix.** Pass a new `--out` directory, or run `gstack-autoplan resume --out
<dir>` without `--from` to resume the run already there.
<a id="owner-reply-required"></a>
### `OWNER_REPLY_REQUIRED`
**Meaning.** An agent answered the gate (`--by agent:<id>`) without recording
what the owner actually said. Nothing was applied.
**Fix.** Pass the owner's words verbatim: `--owner-reply "<text>"`,
`--owner-reply -` to read them from stdin, or `--owner-reply-file <path>`. They
are stored on every decision row; when they parse under the gate grammar they
must resolve to the same choices as `--reply`.
<a id="gate-owner-reply-mismatch"></a>
### `GATE_OWNER_REPLY_MISMATCH`
**Meaning.** The owner's verbatim reply is itself a gate reply (for example
`rec`), and it resolves to different choices than the tokens the agent relayed.
Nothing was applied.
**Fix.** Relay what the owner chose; passing their reply as `--reply` is always
consistent. When the owner's words and your tokens disagree, ask the owner.
<a id="gstack-version-changed"></a>
### `GSTACK_VERSION_CHANGED`
+44 -31
View File
@@ -111,14 +111,14 @@ JSON Schema for any of `findings`, `decisions`, `tasks`, `timing`, `run`,
3. **List what is pending.**
```bash
jq -c 'select(.status=="pending")' <dir>/decisions.jsonl
gstack-gate render <dir>/gate.json # the numbered list and reply grammar
gstack-gate render <dir>/gate.json --all # the numbered list and reply grammar, every page
```
4. **Answer the gate** by composing the reply grammar (`all`, `<id><option>`
tokens such as `d3b uc1a`, or `all except <tokens>`; bare yes/no is
rejected) and feeding it back with the gate revision you read:
4. **Answer the gate** by composing the reply grammar (`rec`, `as-is`,
`<id><option>` tokens such as `d3b uc1a`, or `rec except <tokens>`; bare
yes/no is rejected) and feeding it back with the gate revision you read:
```bash
gstack-gate parse <dir>/gate.json --reply "all except d3b" --gate-rev 1 --json
gstack-gate decisions <dir>/gate.json --reply "all except d3b" --gate-rev 1 >> <dir>/decisions.jsonl
gstack-gate parse <dir>/gate.json --reply "rec except d3b" --gate-rev 1 --json
gstack-gate decisions <dir>/gate.json --reply "rec except d3b" --gate-rev 1 >> <dir>/decisions.jsonl
```
A stale revision is `GATE_REV_STALE` and a reply with unparsed tokens is
`GATE_REPLY_UNPARSED`; both exit `1` and write nothing.
@@ -150,8 +150,8 @@ $AP next --out <dir> --plan <plan.md> [--ui|--no-ui] [--developer-tool] [--no-dx
# SCOPE: ui=yes (...) dx=yes (detected: ...) source=ui:detected,dx:detected
# DRIVER: <root>/autoplan/DRIVER.md
# Skipped sections: none (scope: ui=yes,dx=yes,source=ui:detected,dx:detected; checklist=loaded)
# ATTEMPT: <run>-a1 phase=ceo voice=native prompt=<dir>/ceo-native-prompt.md result=<dir>/ceo-native.md model_family=...
# ATTEMPT: <run>-a2 phase=ceo voice=outside prompt=<dir>/ceo-outside-prompt.md result=<dir>/ceo-outside.md model_family=differs from the native reviewer
# ATTEMPT: <run>-a1 phase=ceo voice=native prompt=<dir>/ceo-native-prompt.md result=<dir>/ceo-native.a1.md model_family=...
# ATTEMPT: <run>-a2 phase=ceo voice=outside prompt=<dir>/ceo-outside-prompt.md result=<dir>/ceo-outside.a2.md model_family=differs from the native reviewer
# SPEND: spent=0.00 reserved=0.00 unknown=0(0.00) worst_case=0.00 cap=none
# GSTACK_RESULT: skill=autoplan status=awaiting_result run=<dir>
```
@@ -173,18 +173,21 @@ the plan.
`release --attempt <id>` returns its reservation).
2. **Submit each result exactly once.**
```bash
$AP submit --out <dir> --phase ceo --voice native --result <dir>/ceo-native.md --model <id> --attempt <run>-a1
$AP submit --out <dir> --phase ceo --voice outside --result <dir>/ceo-outside.md --model <id> --attempt <run>-a2 [--runner host-subagent] [--usage-usd <n>]
$AP submit --out <dir> --phase ceo --voice native --result <dir>/ceo-native.a1.md --model <id> --attempt <run>-a1
$AP submit --out <dir> --phase ceo --voice outside --result <dir>/ceo-outside.a2.md --model <id> --attempt <run>-a2 [--runner host-subagent (default)] [--usage-usd <n>]
# BOUND: <run>-a1 phase=ceo voice=native model=<id> findings=7 sha256=<result hash prefix>
# PHASE_CLOSED: ceo confirmed=4 disagree=1 new=2 native_only=1 coverage=both
```
Submit seals the bytes it bound as `<phase>-<voice>.<attempt>.bound.md`
(read-only); a later write to the reviewer's file changes nothing bound,
and another attempt's result file is refused (`ATTEMPT_MISMATCH`).
Binding checks the receipt line against the phase snapshot
(`RESULT_RECEIPT_MISSING`), parses the canonical findings fence
(`RESULT_FINDINGS_MISSING`), refuses an outside model from the native
family (`MODEL_FAMILY_CONFLICT`), and refuses a second bind of the same
attempt or a bind while the phase is not awaiting it (`ATTEMPT_MISMATCH`,
`PHASE_NOT_AWAITING`, exit 3). Both voices in closes the phase: the
reconciliation (`confirmed` / `disagree` / `new` per outside finding, with
reconciliation (`confirmed` / `unverified` / `disagree` / `new` per outside finding, with
disagreement and resolution kept separate) is written to
`<phase>-consensus.{json,md}`, and every later phase's prompts (DX, design
and Eng alike) carry each closed predecessor's consensus as data; a phase
@@ -194,40 +197,50 @@ the plan.
disagreements as auto items and `p1` plan approval; nothing is approved.
4. **Answer the gate** with the reply grammar and the revision you read:
```bash
$AP answer --out <dir> --gate-rev 1 --reply "all" --by human # every recommendation the owner may delegate; agreeing plan changes stay pending
$AP answer --out <dir> --gate-rev 1 --reply "all as-is" --by human # APPROVED: keeps every direction, no new paid phase
$AP answer --out <dir> --gate-rev 1 --reply "all except d1b" --by human # APPLY: <id> ...; REOPENED: ceo,eng; NEXT: gstack-autoplan revise ...
$AP answer --out <dir> --gate-rev 1 --reply "rec" --by human # every recommendation, plan changes included (APPLY: ...; amend_pending)
$AP answer --out <dir> --gate-rev 1 --reply "as-is" --by human # APPROVED: keeps every direction, no new paid phase
$AP answer --out <dir> --gate-rev 1 --reply "rec except d1b" --by agent:<id> --owner-reply "<owner's words>"
```
`--by human` means the owner typed the tokens; `--by agent:<thread or id>`
means an agent relayed or decided them. An unattended run refuses an answer
without it (`ANSWER_BY_REQUIRED`, exit 3) and records the value as
means an agent relayed them, and then `--owner-reply` (or `-` for stdin,
`--owner-reply-file <path>`) stores the owner's words verbatim
(`OWNER_REPLY_REQUIRED`); owner words that parse to different choices are
refused (`GATE_OWNER_REPLY_MISMATCH`). An unattended run refuses an answer
without `--by` (`ANSWER_BY_REQUIRED`, exit 3) and records the value as
`answered_by` on every decision row and in the review record's gate line,
so a gate an agent closed is distinguishable from one the owner closed.
A user challenge both voices raised on a confirmed finding with the same
`changes` value is recommended as `b`, and option b shows both voices'
remedies (`recommended: b (both voices; remedies shown)`); a `--reconcile`
record naming one shared `proposal` shows that instead. Disagreeing or
missing remedies keep `a`. `all` never answers an item recommended `b`:
that would change the owner's stated direction on their behalf, so those
items stay pending (`GATE_PENDING: uc1 unanswered (reply with uc1a|uc1b; all
does not answer items recommended b)`) until their own token arrives, and
the rendered gate names them under the reply grammar. Every accepted plan change prints
`gstack-autoplan recommend` changes one recommendation before relay; it
bumps the gate's `rec_rev`, and a later `rec` needs the `--rec-hash` from
the render the owner saw (`GATE_REC_STALE`).
A user challenge both voices raised on a confirmed finding (a title match,
not file:line proximity alone, which is `unverified`) with the same
`changes` value is recommended as `b` only with one shared proposal: a
`--reconcile` record naming it, or the same remedy from both voices.
Different or missing remedies keep `a`, and option b shows both verbatim
with the basis. `rec` (alias `all`) takes every recommendation, so it
accepts the items recommended `b`. Every accepted plan change prints
`APPLY: <decision id> <title> -> <chosen text>` and becomes a pending
amendment: `next` refuses with `PLAN_AMEND_PENDING` and `export` refuses a
`complete` run until `revise --incorporates` names it (see below).
`complete` run until `revise --incorporates` names it, or `revise --settle
<id>` settles a packaging or ordering change that needs no plan edit.
A stale revision (`GATE_REV_STALE`, exit 3), bare `yes`/`no`, or a
conflicting reply (`GATE_REPLY_UNPARSED`) writes nothing. A reply that
leaves approval items pending records only the answered auto items and
keeps the gate; a later reply never overwrites an earlier answer. A choice
that changes the plan reopens that phase plus Eng, archives their bound
files as `*.r<rev>.*`, and the next gate is `gate_rev + 1`; `p1d` reruns
every phase on the current plan; `p1e` ends `incomplete`.
keeps the gate; a later reply never overwrites an earlier answer. An
accepted change reopens nothing at answer (status `amend_pending`); `revise`
reopens each phase whose reviewed plan changed plus Eng, archives their
prompts and consensus as `*.r<rev>.*` (sealed `*.bound.md` results stay in
place), and the next gate is `gate_rev + 1`; `p1d` reruns every phase on the
current plan; `p1e` ends `incomplete`.
5. **Export and validate.** `$AP export --out <dir>` writes `plan.md` (with
the `## URGENT, outside this plan` block first), `review-record.md`,
`tasks.jsonl`, `decisions.jsonl`, `findings.jsonl`, `timing.json` and
`run.json`; then `gstack-artifact validate <dir>/run.json` and the
consumption steps above apply unchanged. `$AP status --out <dir>` and
`resume` work after the run directory moves (paths are re-rooted).
`resume` work after the run directory moves (paths are re-rooted; journal
paths are run-relative). With `GSTACK_AUTOPLAN_CHECKPOINT_DIR` set, a run
at rest is copied, hash-listed, to `<dir>/<run>/`; `$AP resume --out <new
dir> --from <dir>/<run>` verifies and restores it (`CHECKPOINT_INVALID`).
Concurrency and recovery: every state write is a locked read-modify-write
(`run.lock`; a second writer sees `RUN_LOCKED`), attempts are journaled to
+115
View File
@@ -0,0 +1,115 @@
/**
* autoplan-checkpoint — an opt-in durable copy of a run directory (next-wave
* B12). With GSTACK_AUTOPLAN_CHECKPOINT_DIR set, every mutating command that
* leaves the run at rest (gate_pending, amend_pending, complete, incomplete)
* publishes `<dir>/<run>/`: `checkpoint.json` (every file's sha256), the run
* files under `run/`, the owner's plan file under `plan-input/` when it lives
* outside the run, and `restore.md`, the human index. Publication swaps a
* fully written sibling in, so a reader sees the old copy or the new one.
* `restoreCheckpoint` verifies containment and every hash before it publishes
* the run directory with one rename; the caller then resumes and owns it.
*/
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { sha256File } from './headless-artifacts';
import { FILES, RunError, journal, now, readState, type RunState } from './autoplan-state';
const MANIFEST = 'checkpoint.json';
const AT_REST = ['gate_pending', 'amend_pending', 'complete', 'incomplete'];
interface CheckpointManifest {
schema_version: 1; run: string; status: string; gate_rev: number; revision: number; at: string; host: string; gstack_version?: string;
files: Record<string, string>;
plan_input?: { original: string; file: string; sha256: string };
}
function runFiles(dir: string, rel = ''): string[] {
return fs.readdirSync(path.join(dir, rel), { withFileTypes: true }).flatMap(e => {
const r = rel ? `${rel}/${e.name}` : e.name;
if (e.isDirectory()) return /^\.revise-v\d+$/.test(e.name) ? [] : runFiles(dir, r);
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));
function restoreIndex(m: CheckpointManifest, target: string): string {
return [`# autoplan checkpoint: ${m.run}`, '',
`Written ${m.at} on ${m.host} at status=${m.status}, gate_rev=${m.gate_rev}; ${Object.keys(m.files).length} files, each listed with its sha256 in checkpoint.json.`, '',
'Restore on any machine (the target directory must not hold a run):', '',
` gstack-autoplan resume --out <new run dir> --from ${target}`, '',
'resume verifies every hash, publishes the run directory, takes ownership and prints the next step.',
...(m.plan_input ? ['', `The owner's plan file was ${m.plan_input.original}; a copy is in ${m.plan_input.file} and is restored into the run directory when the original is missing.`] : []), ''].join('\n');
}
/** Publish the checkpoint when the env opts in and the run is at rest; returns the CHECKPOINT line, or nothing. */
export function checkpointRun(out: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
const root = env.GSTACK_AUTOPLAN_CHECKPOINT_DIR;
if (!root || !fs.existsSync(path.join(out, FILES.run))) return undefined;
const state = readState(out);
if (!AT_REST.includes(state.status)) return undefined;
const target = path.join(path.resolve(root), state.run);
const tmp = `${target}.${process.pid}.tmp`;
fs.rmSync(tmp, { recursive: true, force: true });
const files: Record<string, string> = {};
for (const rel of runFiles(state.out)) { copy(path.join(state.out, rel), path.join(tmp, 'run', rel)); files[rel] = sha256File(path.join(tmp, 'run', rel)); }
const input = state.plan_input && !inside(state.out, state.plan_input) && fs.existsSync(state.plan_input) ? state.plan_input : undefined;
const planFile = input ? `plan-input/${path.basename(input)}` : undefined;
if (input) copy(input, path.join(tmp, planFile!));
const m: CheckpointManifest = { schema_version: 1, run: state.run, status: state.status, gate_rev: state.gate_rev ?? 0, revision: state.revision, at: now(), host: os.hostname(),
...(state.gstack_version ? { gstack_version: state.gstack_version } : {}), files, ...(input ? { plan_input: { original: input, file: planFile!, sha256: sha256File(path.join(tmp, planFile!)) } } : {}) };
fs.writeFileSync(path.join(tmp, 'restore.md'), restoreIndex(m, target));
fs.writeFileSync(path.join(tmp, MANIFEST), JSON.stringify(m, null, 2) + '\n');
const old = `${target}.${process.pid}.old`;
if (fs.existsSync(target)) fs.renameSync(target, old);
fs.renameSync(tmp, target);
fs.rmSync(old, { recursive: true, force: true });
return `CHECKPOINT: ${target} status=${m.status} gate_rev=${m.gate_rev} files=${Object.keys(files).length}`;
}
/** Verify a checkpoint and publish it as the run directory `out`. Nothing is written to `out` unless every hash matches. */
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 {
fs.rmSync(staging, { recursive: true, force: true });
}
}
+1 -1
View File
@@ -45,7 +45,7 @@ export function aggregateFindings(state: RunState): FindingRow[] {
for (const row of voiceRows(state, phase, 'native')) out.push({ ...row, native_counterpart: null });
for (const row of voiceRows(state, phase, 'outside')) {
const r = byOutside.get(row.id);
out.push({ ...row, native_counterpart: r?.native_id ?? null, ...(r ? { reconciliation: r.disposition, reconciliation_resolution: r.resolution } : {}) } as FindingRow);
out.push({ ...row, native_counterpart: r?.disposition === 'unverified' ? null : r?.native_id ?? null, ...(r ? { reconciliation: r.disposition, reconciliation_resolution: r.resolution } : {}) } as FindingRow);
}
}
return out;
+132 -49
View File
@@ -20,14 +20,15 @@ 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, type ConsensusRecord, type ReconciliationOverride, type ReconciliationRow } from './autoplan-reconcile';
import { consensusRecord, reconcile, 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 RunState, type VoiceState } from './autoplan-state';
import { FILES, RunError, journal as journalRow, now, readDecisions, voiceRows, writeDecisions, type Amendment, type RunState, type VoiceState } from './autoplan-state';
import { phaseClose } from './autoplan-timing';
import { ANSWERED_BY_RE, applyReply, decisionId, decisionRows, keepDirection, parseReply, pendingHint, validateGateList, type GateChoice, type GateItem, type GateList, type GateOption } from './gate-list';
import { ANSWERED_BY_RE, applyReply, decisionId, decisionRows, keepDirection, parseReply, pendingHint, recHash, validateGateList, type GateChoice, type GateItem, type GateList, type GateOption, type ParsedReply } from './gate-list';
import type { FindingRow } from './headless-artifacts';
import { readJsonl } from './headless-artifacts';
import { closeDraftDirectionsFile, loadBriefInputs } from './owner-brief';
import { vaguePlanTerms } from './plan-reality';
import { settle } from './spend-ledger';
export function loadOverrides(file: string | undefined): ReconciliationOverride[] {
@@ -56,7 +57,7 @@ export function closePhase(state: RunState, phase: Phase, o: { overridesFile?: s
const md = renderConsensus(consensus, [...native, ...outside]);
fs.writeFileSync(path.join(state.out, `${phase}-consensus.json`), JSON.stringify(consensus, null, 2) + '\n');
fs.writeFileSync(path.join(state.out, `${phase}-consensus.md`), md);
ps.consensus = { confirmed: consensus.confirmed, disagree: consensus.disagree, new: consensus.new, native_only: consensus.native_only, coverage: consensus.coverage };
ps.consensus = { confirmed: consensus.confirmed, unverified: consensus.unverified, disagree: consensus.disagree, new: consensus.new, native_only: consensus.native_only, coverage: consensus.coverage };
ps.closed_at = now();
const closed = phaseClose(path.join(state.out, 'timing.json'), state.run, phase, { outside_s: seconds(ps.outside), native_s: seconds(ps.native), session_kind: state.session_kind });
journalRow(state.out, { event: 'phase_closed', phase, consensus: ps.consensus, wall_s: closed.entry.wall_s, analytics: closed.analytics });
@@ -70,25 +71,31 @@ const CONTENT_SCOPE: Record<string, string> = { ceo: 'scope', design: 'design',
const optionHash = (options: GateOption[]) => createHash('sha256').update(JSON.stringify(options.map(o => [o.key, o.text]))).digest('hex').slice(0, 16);
const KEEP = (text: string): GateOption => ({ key: 'a', text, changes_plan: false });
const remedyKey = (text: string | undefined) => (text ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
/**
* A user challenge both voices raised recommends the change only when the
* finding reconciled `confirmed`, both name the same `changes` and the
* remedies are on the record (both voices', or the parent's one shared
* proposal from a reconcile override); option b shows them verbatim. Anything
* weaker keeps the plan's direction, and the basis says why.
* A user challenge both voices raised recommends the change only with an
* explicit, coherent shared proposal: the finding reconciled `confirmed`,
* both name the same `changes`, and either the parent's reconcile override
* states one proposal for both or both voices wrote the same remedy. Title
* similarity and file:line proximity only associate the findings; anything
* weaker keeps the plan's direction, and the basis says why. Option b shows
* the remedies verbatim.
*/
function challengeDefault(row: ReconciliationRow, n: FindingRow | undefined, o: FindingRow): { recommended: 'a' | 'b'; basis: string; text: string } {
export function challengeDefault(row: ReconciliationRow, n: FindingRow | undefined, o: FindingRow): { recommended: 'a' | 'b'; basis: string; text: string } {
const nf = n as (FindingRow & { changes?: string }) | undefined, of = o as FindingRow & { changes?: string };
const remedies = row.proposal ? ` (shared proposal: ${row.proposal})`
: 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 !== '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');
if (row.proposal) return { recommended: 'b', basis: 'both voices; shared proposal', text };
if (!nf.resolution || !of.resolution) return keep('a remedy is missing');
return { recommended: 'b', basis: 'both voices; remedies shown', text };
if (remedyKey(nf.resolution) !== remedyKey(of.resolution)) return keep('remedies differ; no shared proposal');
return { recommended: 'b', basis: 'both voices; same remedy', text };
}
/** A rerun challenge naming no scope, cost or direction: the agent's own decision, recorded, reopening nothing. */
@@ -139,17 +146,54 @@ export function buildGate(state: RunState, gateRev: number): GateList {
cost: `b reopens ${phase}${phase === 'eng' ? '' : ' and eng'}`, options: [KEEP('keep your direction'), { key: 'b', text: 'accept the change', changes_plan: true }] }, f.id);
}
}
const changes = items.filter(i => i.options.find(o => o.key === i.recommended)?.changes_plan).map(i => `${i.id}${i.recommended}`);
items.push({ id: 'p1', title: 'Plan approval', kind: 'approval', recommended: changes.length ? 'b' : 'a',
options: [{ key: 'a', text: 'approve as-is' }, { key: 'b', text: changes.length ? `approve with the recommended changes: ${changes.join(', ')}` : 'approve with the overrides above' },
{ key: 'c', text: 'interrogate (ask questions; stays pending)' },
{ key: 'd', text: `rerun every phase on the current plan, Eng last (to review an edited plan instead: gstack-autoplan revise --out ${state.out} --plan <file>)` }, { key: 'e', text: 'reject' }] });
items.push(planApproval(state, items));
const list: GateList = { schema_version: 1, run: state.run, gate_rev: gateRev, items, title: '/autoplan final approval gate' };
const problems = validateGateList(list);
if (problems.length) throw new Error(`gate list invalid: ${problems.join('; ')}`);
return list;
}
/** The p1 plan-approval item: recommends `b` when any item's recommendation changes the plan, with PLAN_REALITY flags. */
function planApproval(state: RunState, items: GateItem[]): GateItem {
const changes = items.filter(i => i.id !== 'p1' && i.options.find(o => o.key === i.recommended)?.changes_plan).map(i => `${i.id}${i.recommended}`);
const planFile = fs.existsSync(state.plan_source) ? state.plan_source : state.active_plan;
const flags = vaguePlanTerms(fs.readFileSync(planFile, 'utf8')).map(v => `PLAN_REALITY ${JSON.stringify(v.phrase)} has no number, arm list or rule body (${path.basename(state.plan_input ?? planFile)}:${v.line}: ${v.text.length > 120 ? `${v.text.slice(0, 117)}...` : v.text}) (report only)`);
return { id: 'p1', title: 'Plan approval', kind: 'approval', recommended: changes.length ? 'b' : 'a', ...(flags.length ? { flags } : {}),
options: [{ key: 'a', text: 'approve as-is' }, { key: 'b', text: changes.length ? `approve with the recommended changes: ${changes.join(', ')}` : 'approve with the overrides above' },
{ key: 'c', text: 'interrogate (ask questions; stays pending)' },
{ key: 'd', text: `rerun every phase on the current plan, Eng last (to review an edited plan instead: gstack-autoplan revise --out ${state.out} --plan <file>)` }, { key: 'e', text: 'reject' }] };
}
/**
* `gstack-autoplan recommend`: the relaying parent sets one item's recommendation before relay, with its basis and
* provenance. The plan-approval item and decisions.jsonl follow, and `rec_rev` bumps so a `rec` rendered earlier is
* refused (GATE_REC_STALE) until the gate is rendered and relayed again.
*/
export function recommendGate(state: RunState, gateRev: number, itemId: string, option: string, basis: string, by: string): string[] {
const gateFile = path.join(state.out, 'gate.json');
if (state.status !== 'gate_pending' || !fs.existsSync(gateFile)) throw new RunError('PHASE_NOT_AWAITING', `run is ${state.status}, not gate_pending`, 3);
const list = JSON.parse(fs.readFileSync(gateFile, 'utf8')) as GateList;
if (gateRev !== list.gate_rev) throw new RunError('GATE_REV_STALE', `--gate-rev ${gateRev}, current gate_rev=${list.gate_rev}`, 3);
const item = list.items.find(i => i.id === itemId);
if (!item || item.id === 'p1' || !item.options.some(o => o.key === option)) throw new RunError('GATE_REPLY_UNPARSED', `${itemId}${option} names no item option a recommendation can take (p1 follows the items)`);
Object.assign(item, { recommended: option, recommended_basis: basis, recommended_by: by });
const p1 = planApproval(state, list.items);
list.items = list.items.map(i => (i.id === 'p1' ? p1 : i));
list.rec_rev = (list.rec_rev ?? 0) + 1;
fs.writeFileSync(gateFile, JSON.stringify(list, null, 2) + '\n');
const updated = new Map([item, p1].map(i => [decisionId(list.run, gateRev, i.id), i]));
writeDecisions(state.out, readDecisions(state.out).map(r => {
const i = updated.get(r.id);
if (!i) return r;
return { ...r, recommended: i.recommended, ...(i.kind === 'auto' ? { default_if_unanswered: i.recommended } : {}), ...(i.recommended_basis ? { recommended_basis: i.recommended_basis } : {}), ...(i.recommended_by ? { recommended_by: i.recommended_by } : {}) };
}));
const hash = recHash(list);
journalRow(state.out, { event: 'recommended', gate_rev: gateRev, item: itemId, option, basis, by, rec_rev: list.rec_rev, rec_hash: hash });
exportRun(state);
return [`RECOMMENDED: ${itemId}${option} by=${by} basis=${JSON.stringify(basis)} p1=${p1.recommended} rec_hash=${hash}`,
`RELAY: gstack-gate render ${gateFile} --all (a rec rendered before this is refused; answer with --rec-hash ${hash})`];
}
/** Every phase closed: write the gate list, decisions, tasks and the exported files; the run ends gate_pending. Unchanged answered proposals carry forward. */
export function finishGate(state: RunState): GateList {
const gateRev = (state.gate_rev ?? 0) + 1;
@@ -180,32 +224,61 @@ export function finishGate(state: RunState): GateList {
// ---------------------------------------------------------------------------
// answer
// ---------------------------------------------------------------------------
export interface AnswerResult { state: RunState; applied: boolean; pending: string[]; reopened: Phase[]; outcome: 'complete' | 'gate_pending' | 'reopened' | 'rejected'; lines: string[] }
export interface AnswerResult { state: RunState; applied: boolean; pending: string[]; reopened: Phase[]; outcome: 'complete' | 'gate_pending' | 'reopened' | 'rejected' | 'amend_pending'; lines: string[] }
export interface AnswerOptions {
/** The rec_hash of the render the owner saw; required for a `rec` reply once `recommend` changed the gate. */
recHash?: string;
/** The owner's verbatim words; required for every `--by agent:<id>` answer. */
ownerReply?: string;
}
/** How an accepted plan change is discharged: a packaging/ordering change settles without a plan edit. */
export function applyGuidance(a: Pick<Amendment, 'changes' | 'item'>): string {
return a.changes && SETTLEABLE_CHANGES.includes(a.changes) ? `no plan edit needed: revise --settle ${a.item}` : 'edit the plan, then revise';
}
/** The owner's words must not contradict the relayed tokens: when they parse, both must resolve to the same choices. */
function checkOwnerReply(list: GateList, gateRev: number, prior: Record<string, string>, relayed: ParsedReply, ownerReply: string): boolean {
const owner = parseReply(ownerReply, list, gateRev, prior);
if (!owner.valid) return false;
const choices = (p: ParsedReply) => JSON.stringify(p.choices.map(c => [c.id, c.chosen]).sort());
if (choices(owner) !== choices(relayed) || owner.pending.join() !== relayed.pending.join()) {
throw new RunError('GATE_OWNER_REPLY_MISMATCH', `the owner's reply ${JSON.stringify(ownerReply)} resolves to ${owner.choices.map(c => `${c.id}${c.chosen}`).join(' ')}${owner.pending.length ? ` (pending ${owner.pending.join(',')})` : ''}; the relayed reply resolves to ${relayed.choices.map(c => `${c.id}${c.chosen}`).join(' ')}${relayed.pending.length ? ` (pending ${relayed.pending.join(',')})` : ''}`, 3);
}
return true;
}
/**
* Apply one reply. Earlier answers at this revision stand unless named again;
* the gate stays pending until every approval settles. A chosen option with
* `changes_plan` is an accepted plan change: it reopens its phase plus Eng,
* prints `APPLY:` and becomes a pending amendment (`next` refuses until
* `revise --incorporates` or `--withdraw`).
* `changes_plan` is an accepted plan change: it prints `APPLY:` and becomes a
* pending amendment (status `amend_pending`); nothing reopens here. `revise`
* reopens exactly the phases whose reviewed plan changed, and `revise
* --settle` discharges a packaging/ordering change without an edit.
*/
export function answerGate(state: RunState, gateRev: number, reply: string, answeredBy?: string): AnswerResult {
export function answerGate(state: RunState, gateRev: number, reply: string, answeredBy?: string, o: AnswerOptions = {}): AnswerResult {
const gateFile = path.join(state.out, 'gate.json');
if (state.status !== 'gate_pending' || !fs.existsSync(gateFile)) throw new RunError('PHASE_NOT_AWAITING', `run is ${state.status}, not gate_pending`, 3);
if (answeredBy !== undefined && !ANSWERED_BY_RE.test(answeredBy)) throw new RunError('ANSWER_BY_REQUIRED', `--by ${JSON.stringify(answeredBy)} is neither human nor agent:<id>`, 3);
if (answeredBy === undefined && state.session_kind === 'unattended') throw new RunError('ANSWER_BY_REQUIRED', 'an unattended run records who answered; pass --by human or --by agent:<id>', 3);
if (answeredBy?.startsWith('agent:') && o.ownerReply === undefined) throw new RunError('OWNER_REPLY_REQUIRED', `${answeredBy} relayed or decided this reply; pass the owner's words verbatim with --owner-reply "<text>" (or --owner-reply - / --owner-reply-file <path>)`, 3);
const list = JSON.parse(fs.readFileSync(gateFile, 'utf8')) as GateList;
const rows = readDecisions(state.out);
const prior = Object.fromEntries(rows.filter(r => r.gate_rev === list.gate_rev && r.chosen != null && r.status !== 'pending' && r.label).map(r => [r.label!.toLowerCase(), r.chosen!]));
const parsed = parseReply(reply, list, gateRev, prior);
if (parsed.error === 'GATE_REV_STALE') throw new RunError('GATE_REV_STALE', `reply names gate_rev=${gateRev}, current gate_rev=${list.gate_rev}`, 3);
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 ?? 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 } : {};
const itemOf = (id: string) => list.items.find(i => i.id === id)!;
const optionOf = (c: GateChoice) => itemOf(c.id).options.find(o => o.key === c.chosen)!;
const plan = parsed.choices.find(c => c.id === 'p1');
const lines: string[] = [];
const answeredAt = now();
const record = () => writeDecisions(state.out, applyReply(rows, parsed, answeredAt, answeredBy).map(r => {
const record = () => writeDecisions(state.out, applyReply(rows, parsed, answeredAt, answeredBy, provenance).map(r => {
const c = r.gate_rev === list.gate_rev && r.chosen && !r.chosen_text ? itemOf((r.label ?? '').toLowerCase())?.options.find(o => o.key === r.chosen) : undefined;
return c ? { ...r, chosen_text: c.text } : r;
}));
@@ -222,15 +295,10 @@ export function answerGate(state: RunState, gateRev: number, reply: string, answ
const changing = proceeds ? parsed.choices.filter(c => c.id !== 'p1' && !carried(c) && itemOf(c.id).reopens !== false && (optionOf(c).changes_plan ?? c.how === 'override')) : [];
const tokens = (cs: GateChoice[]) => cs.map(c => `${c.id}${c.chosen}`);
const overrides = parsed.choices.filter(c => c.how === 'override' && c.id !== 'p1');
const reopenSet = new Set<Phase>();
if (plan.chosen === 'd') state.phase_order.forEach(p => reopenSet.add(p));
for (const c of changing) {
const phase = itemOf(c.id).phase as Phase | undefined;
if (phase) { reopenSet.add(phase); if (state.phase_order.includes('eng')) reopenSet.add('eng'); }
}
const reopenSet = new Set<Phase>(plan.chosen === 'd' ? state.phase_order : []);
record();
state.gate = { ...(state.gate ?? {}), answered_at: answeredAt, reply, gate_rev: gateRev, plan: plan.chosen, overrides: tokens(overrides), ...(answeredBy ? { answered_by: answeredBy } : {}) };
journalRow(state.out, { event: 'answered', gate_rev: gateRev, reply, plan: plan.chosen, overrides: tokens(overrides), changes: tokens(changing), reopen: [...reopenSet], ...(answeredBy ? { answered_by: answeredBy } : {}) });
state.gate = { ...(state.gate ?? {}), answered_at: answeredAt, reply, gate_rev: gateRev, plan: plan.chosen, overrides: tokens(overrides), ...(answeredBy ? { answered_by: answeredBy } : {}), ...provenance };
journalRow(state.out, { event: 'answered', gate_rev: gateRev, reply, plan: plan.chosen, overrides: tokens(overrides), changes: tokens(changing), reopen: [...reopenSet], ...(answeredBy ? { answered_by: answeredBy } : {}), ...provenance });
if (plan.chosen === 'c') {
lines.push(`GATE_PENDING: interrogate recorded; the gate stays at gate_rev=${gateRev}`);
return { state, applied: true, pending: ['p1'], reopened: [], outcome: 'gate_pending', lines };
@@ -243,30 +311,44 @@ export function answerGate(state: RunState, gateRev: number, reply: string, answ
return { state, applied: true, pending: [], reopened: [], outcome: 'rejected', lines };
}
for (const c of changing) {
const decision = decisionId(state.run, gateRev, c.id);
(state.amendments ??= []).push({ decision, title: itemOf(c.id).title, chosen: c.chosen, text: optionOf(c).text, gate_rev: gateRev, status: 'pending', at: answeredAt });
journalRow(state.out, { event: 'amendment_pending', decision, gate_rev: gateRev });
lines.push(`APPLY: ${decision} ${itemOf(c.id).title} -> ${optionOf(c).text}`);
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 } : {}) };
(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)}`);
}
if (reopenSet.size === 0) {
state.status = 'complete';
lines.push(...closeDraftDirections(state, answeredAt.slice(0, 10)));
if (reopenSet.size) {
const reopened = state.phase_order.filter(p => reopenSet.has(p));
reopenPhases(state, reopened, gateRev, `gate_rev ${gateRev}: revise`);
if (changing.length) state.status = 'amend_pending';
lines.push(`REOPENED: ${reopened.join(',')} (Eng last) reason=revise`);
const drift = driftLine(state, 'PLAN_SOURCE_CHANGED');
if (drift) lines.push(drift);
return { state, applied: true, pending: [], reopened, outcome: 'reopened', lines };
}
if (changing.length) {
state.status = 'amend_pending';
exportRun(state);
const notes = overrides.filter(o => o.chosen !== keepDirection(itemOf(o.id)));
lines.push(`APPROVED: gate_rev=${gateRev} ${notes.length ? `overrides=${tokens(notes).join(',')} (none change reviewed content)` : 'as-is'}`);
return { state, applied: true, pending: [], reopened: [], outcome: 'complete', lines };
lines.push(`AMEND_PENDING: ${changing.length} accepted change(s) not yet in the plan; nothing reopens until revise`);
return { state, applied: true, pending: [], reopened: [], outcome: 'amend_pending', lines };
}
const reopened = state.phase_order.filter(p => reopenSet.has(p));
const reason = plan.chosen === 'd' ? 'revise' : tokens(changing).join(',');
reopenPhases(state, reopened, gateRev, `gate_rev ${gateRev}: ${reason}`);
lines.push(`REOPENED: ${reopened.join(',')} (Eng last) reason=${reason}`);
const drift = driftLine(state, 'PLAN_SOURCE_CHANGED');
if (drift) lines.push(drift);
return { state, applied: true, pending: [], reopened, outcome: 'reopened', lines };
lines.push(...approve(state, answeredAt));
const notes = overrides.filter(o => o.chosen !== keepDirection(itemOf(o.id)));
lines.push(`APPROVED: gate_rev=${gateRev} ${notes.length ? `overrides=${tokens(notes).join(',')} (none change reviewed content)` : 'as-is'}`);
return { state, applied: true, pending: [], reopened: [], outcome: 'complete', lines };
}
/** The run is approved: status complete, draft directions closed, files exported. */
export function approve(state: RunState, at: string): string[] {
state.status = 'complete';
const lines = closeDraftDirections(state, at.slice(0, 10));
exportRun(state);
return lines;
}
/** E3 phase-close operation: every `draft direction stands until the owner decides` line in the active plan becomes the decided option and date. */
function closeDraftDirections(state: RunState, date: string): string[] {
export function closeDraftDirections(state: RunState, date: string): string[] {
const loaded = loadBriefInputs(state.out);
if ('error' in loaded) return [];
const r = closeDraftDirectionsFile(state.active_plan, loaded.inputs, date);
@@ -305,7 +387,8 @@ export function planReopen(state: RunState, phases: Phase[], gateRev: number, re
journalRow(state.out, { event: 'superseded', attempt: a.attempt, phase, voice, reason });
}
const output = ps[voice]?.output;
if (output) { archive(`${output}.findings.jsonl`); archive(output); delete state.artifacts[output]; }
// A sealed copy is attempt-owned and immutable: it stays in place and bound; only a pre-seal output (bound at the result path) is archived.
if (output && !output.endsWith('.bound.md')) { archive(`${output}.findings.jsonl`); archive(output); delete state.artifacts[output]; }
archive(`${phase}-${voice}-prompt.md`);
}
archive(`${phase}-consensus.json`); archive(`${phase}-consensus.md`);
+37
View File
@@ -10,6 +10,7 @@
*/
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { extractImplementationPlan, implementationForReview, sectionHeadings } from '../bin/gstack-autoplan-snapshot';
import { extractHeading } from './autoplan-prompts';
import type { RunState } from './autoplan-state';
@@ -79,3 +80,39 @@ export function driftLine(state: RunState, code: 'PLAN_CHANGED' | 'PLAN_SOURCE_C
if (!current || current === reviewed) return undefined;
return `${code}: ${source} differs from the reviewed plan; run gstack-autoplan revise --out ${state.out} --plan ${source}`;
}
/** The nearest directory at or above `dir` holding `.git` (no git spawn), or undefined. */
export function repoRoot(dir: string): string | undefined {
for (let d = path.resolve(dir); ; d = path.dirname(d)) {
if (fs.existsSync(path.join(d, '.git'))) return d;
if (path.dirname(d) === d) return undefined;
}
}
/** Line offset of `inner` inside `outer` (0 when it starts on line 1), or undefined when it is not a verbatim block of it. */
function blockOffset(inner: string, outer: string): number | undefined {
const at = outer.indexOf(inner);
if (at >= 0) return outer.slice(0, at).split('\n').length - 1;
const restoreLine = /^<!-- \/autoplan restore point:[^\n]*\n/.exec(inner);
if (!restoreLine) return undefined;
const rest = blockOffset(inner.slice(restoreLine[0].length), outer);
return rest === undefined ? undefined : rest - 1;
}
/**
* A canonical row's `file`/`line` as the owner reads them: an absolute path under the repository root becomes
* repo-relative; a phase snapshot or the active plan becomes the plan's own file name, its line mapped to the plan
* source through the snapshot's verbatim position (dropped when it cannot be mapped). The reviewer's bytes are untouched.
*/
export function normalizeFindingFile(f: { file?: string; line?: number }, ctx: { cwd: string; planFiles: string[]; planSource: string; planName: string }): { file?: string; line?: number } {
if (!f.file) return f;
const planFile = ctx.planFiles.find(p => f.file === p || (!path.isAbsolute(f.file!) && path.basename(p) === f.file));
if (planFile) {
let offset: number | undefined;
try { offset = blockOffset(fs.readFileSync(planFile, 'utf8'), fs.readFileSync(ctx.planSource, 'utf8')); } catch { offset = undefined; }
return { file: ctx.planName, ...(f.line !== undefined && offset !== undefined ? { line: f.line + offset } : {}) };
}
const root = path.isAbsolute(f.file) ? repoRoot(ctx.cwd) : undefined;
if (root && f.file.startsWith(root + path.sep)) return { ...f, file: path.relative(root, f.file).split(path.sep).join('/') };
return f;
}
+10 -1
View File
@@ -112,7 +112,7 @@ RESULT FORMAT (required by gstack-autoplan; the parent binds your file by this c
\`\`\`gstack-findings
{"id":"F1","severity":"High","title":"<one line>","file":"<path or omit>","line":12,"fix":"<the fix>"}
\`\`\`
severity is Critical | High | Medium | Low | Informational. Add "user_challenge": true only when you recommend changing the plan's stated direction AND the change alters its scope, cost or direction; then also add "changes": "scope" | "cost" | "direction". Packaging, ordering, naming and other reversible choices are ordinary findings, not user challenges. Add "urgent": true and "suggested_owner": "<who>" for a security or data-loss finding this plan does not own. An empty fence means no findings; a missing fence is refused.
severity is Critical | High | Medium | Low | Informational. Add "user_challenge": true only when you recommend changing the plan's stated direction AND the change alters its scope, cost or direction (then also add "changes": "scope" | "cost" | "direction") or only its delivery packaging or ordering, with no Implementation-plan edit (then "changes": "packaging" | "ordering"). Naming and other reversible choices are ordinary findings, not user challenges. Add "urgent": true and "suggested_owner": "<who>" for a security or data-loss finding this plan does not own. An empty fence means no findings; a missing fence is refused.
3. Write the whole result to this file: ${resultPath}
`;
@@ -177,6 +177,15 @@ export function writePrompts(inp: PromptInputs): { native: string; outside: stri
return { native, outside, nativeResult, outsideResult };
}
/** An attempt-owned file in the run directory: `<phase>-<voice>.<attempt>.md` (the reviewer writes it) or `.bound.md` (the sealed copy). */
export const attemptFile = (phase: string, voice: string, shortAttempt: string, ext: 'md' | 'bound.md') => `${phase}-${voice}.${shortAttempt}.${ext}`;
/** Point a prompt's RESULT FORMAT at the attempt's own result path, so a superseded reviewer never writes where its successor does. */
export function bindPromptResult(promptFile: string, resultPath: string): void {
const text = fs.readFileSync(promptFile, 'utf8');
fs.writeFileSync(promptFile, text.replace(/^3\. Write the whole result to this file: .*$/m, `3. Write the whole result to this file: ${resultPath}`));
}
/** The installed review skill for a phase: this checkout's generated SKILL.md (same layout as ~/.claude/skills/gstack/<skill>/SKILL.md). */
export function reviewSkillFile(phase: Phase): string {
const file = path.join(ROOT, REVIEW_SKILL[phase], 'SKILL.md');
+37 -28
View File
@@ -26,10 +26,12 @@ const SEVERITY_ALIASES: Record<string, Severity> = {
export interface CanonicalFinding {
severity: Severity; title: string; file?: string; line?: number; fix?: string; local_id?: string;
user_challenge?: boolean; urgent?: boolean; suggested_owner?: string; plan_items?: string[]; dimension?: string;
/** What a user challenge changes; only these three make it a gate question (plan B2a). */
/** What a user challenge changes: scope, cost or direction (plan B2a), or packaging/ordering (settled without a plan edit). */
changes?: Changes;
}
export const CHANGES = ['scope', 'cost', 'direction'] as const;
export const CHANGES = ['scope', 'cost', 'direction', 'packaging', 'ordering'] as const;
/** Accepted changes of these kinds need no Implementation-plan edit: `revise --settle` records them. */
export const SETTLEABLE_CHANGES: readonly string[] = ['packaging', 'ordering'];
export type Changes = typeof CHANGES[number];
export interface ParsedReviewResult {
receipt: { ok: boolean; phase?: string; sha256?: string; line?: string };
@@ -67,9 +69,9 @@ export function parseReceipt(text: string, expected: { phase: string; sha256: st
function parseFences(text: string, errors: string[]): { findings: CanonicalFinding[]; fenceFound: boolean } {
const findings: CanonicalFinding[] = [];
let fenceFound = false;
for (const fence of text.matchAll(/^[ \t]*(`{3,}|~{3,})gstack-findings[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*\1[ \t]*$/gm)) {
for (const fence of text.matchAll(/^[ \t]*(`{3,}|~{3,})gstack-findings[ \t]*\r?\n(?:([\s\S]*?)\r?\n)??[ \t]*\1[ \t]*$/gm)) {
fenceFound = true;
fence[2]!.split('\n').forEach((raw, i) => {
(fence[2] ?? '').split('\n').forEach((raw, i) => {
const line = raw.trim();
if (!line) return;
let row: any;
@@ -186,7 +188,8 @@ export function canonicalRows(findings: CanonicalFinding[], ctx: { run: string;
} as FindingRow));
}
export type ReconciliationDisposition = 'confirmed' | 'disagree' | 'new';
/** `unverified`: the same file:line with no other shared evidence (titles unalike): associated, agreement unverified. */
export type ReconciliationDisposition = 'confirmed' | 'unverified' | 'disagree' | 'new';
export interface ReconciliationRow {
outside_id: string; native_id: string | null; disposition: ReconciliationDisposition;
/** Why this disposition: the matched location or title, or the parent's override reason. */
@@ -208,11 +211,14 @@ function jaccard(a: Set<string>, b: Set<string>): number {
const level = (s: Severity) => SEVERITIES.indexOf(s);
/**
* Structural reconciliation: a file:line match (±3 lines) is `confirmed`
* unless severities differ by two or more levels (`disagree`); otherwise a
* title overlap of at least half the words is `confirmed`; else `new`. Each
* native finding is matched at most once. Overrides replace the structural
* row for their outside id and must name ids from these two lists.
* 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`.
* 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.
*/
export function reconcile(native: FindingRow[], outside: FindingRow[], overrides: ReconciliationOverride[] = []): { rows: ReconciliationRow[]; native_only: string[]; errors: string[] } {
const errors: string[] = [];
@@ -222,7 +228,7 @@ export function reconcile(native: FindingRow[], outside: FindingRow[], overrides
for (const o of overrides) {
if (!outsideIds.has(o.outside_id)) { errors.push(`override names unknown outside id ${o.outside_id}`); continue; }
if (o.native_id && !nativeIds.has(o.native_id)) { errors.push(`override for ${o.outside_id} names unknown native id ${o.native_id}`); continue; }
if (!['confirmed', 'disagree', 'new'].includes(o.disposition)) { errors.push(`override for ${o.outside_id}: disposition ${JSON.stringify(o.disposition)} is not confirmed|disagree|new`); continue; }
if (!['confirmed', 'unverified', 'disagree', 'new'].includes(o.disposition)) { errors.push(`override for ${o.outside_id}: disposition ${JSON.stringify(o.disposition)} is not confirmed|unverified|disagree|new`); continue; }
if (o.disposition !== 'new' && !o.native_id) { errors.push(`override for ${o.outside_id}: ${o.disposition} needs a native_id`); continue; }
if (typeof o.resolution !== 'string' || !o.resolution.trim()) { errors.push(`override for ${o.outside_id}: resolution is required`); continue; }
byOutside.set(o.outside_id, o);
@@ -238,28 +244,29 @@ export function reconcile(native: FindingRow[], outside: FindingRow[], overrides
...(typeof override.proposal === 'string' && override.proposal.trim() ? { proposal: override.proposal.trim() } : {}) });
continue;
}
const near = (n: FindingRow) => !!o.file && o.line !== undefined && n.file === o.file && n.line !== undefined && Math.abs(n.line - o.line) <= 3;
const ot = tokens(o.title);
let match: FindingRow | undefined;
let basis: ReconciliationRow['basis'] = 'none';
if (o.file && o.line !== undefined) {
match = native.find(n => !used.has(n.id) && n.file === o.file && n.line !== undefined && Math.abs(n.line - o.line!) <= 3);
if (match) basis = 'file_line';
}
if (!match) {
const ot = tokens(o.title);
let best = 0;
for (const n of native) {
if (used.has(n.id)) continue;
const score = jaccard(ot, tokens(n.title));
if (score >= 0.5 && score > best) { best = score; match = n; }
}
if (match) basis = 'title';
let best = 0;
for (const n of native) {
if (used.has(n.id)) continue;
const score = jaccard(ot, tokens(n.title)) + (near(n) ? 0.01 : 0);
if (score >= 0.5 && score > best) { best = score; match = n; }
}
const titled = !!match;
match ??= native.find(n => !used.has(n.id) && near(n));
if (!match) { rows.push({ outside_id: o.id, native_id: null, disposition: 'new', resolution: 'not raised by the native voice', basis: 'none', severity: { outside: o.severity, native: null } }); continue; }
used.add(match.id);
const basis: ReconciliationRow['basis'] = near(match) ? 'file_line' : 'title';
const where = near(match) ? `${o.file}:${o.line}` : 'same title';
if (!titled) {
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} (${basis === 'file_line' ? `${o.file}:${o.line}` : 'same title'})` : `both voices (${basis === 'file_line' ? `${o.file}:${o.line}` : 'same title'}): ${match.title}`,
resolution: delta >= 2 ? `severity: native ${match.severity}, outside ${o.severity} (${where})` : `both voices (${where}): ${match.title}`,
basis, severity: { outside: o.severity, native: match.severity },
});
}
@@ -271,6 +278,8 @@ export interface ConsensusRecord {
native: { model: string | null; findings: number; by_severity: Record<Severity, number> } | null;
outside: { model: string | null; provider: string | null; findings: number; by_severity: Record<Severity, number> } | null;
confirmed: number; disagree: number; new: number; native_only: number;
/** Same location, agreement unverified (absent on records written before it existed). */
unverified?: number;
rows: ReconciliationRow[]; native_only_ids: string[];
/** CONFIRMED requires both voices completed. */
coverage: 'both' | 'native_only' | 'outside_only' | 'none';
@@ -288,7 +297,7 @@ export function consensusRecord(phase: string, native: { rows: FindingRow[]; mod
schema_version: 1, phase,
native: native ? { model: native.model ?? null, findings: native.rows.length, by_severity: bySeverity(native.rows) } : null,
outside: outside ? { model: outside.model ?? null, provider: outside.provider ?? null, findings: outside.rows.length, by_severity: bySeverity(outside.rows) } : null,
confirmed: rows.filter(r => r.disposition === 'confirmed').length, disagree: rows.filter(r => r.disposition === 'disagree').length,
confirmed: rows.filter(r => r.disposition === 'confirmed').length, unverified: rows.filter(r => r.disposition === 'unverified').length, disagree: rows.filter(r => r.disposition === 'disagree').length,
new: rows.filter(r => r.disposition === 'new').length, native_only: native && outside ? rec.native_only.length : (native?.rows.length ?? 0),
rows, native_only_ids: native && outside ? rec.native_only : (native?.rows.map(r => r.id) ?? []),
coverage: native && outside ? 'both' : native ? 'native_only' : outside ? 'outside_only' : 'none',
@@ -306,7 +315,7 @@ export function renderConsensus(c: ConsensusRecord, findings: FindingRow[]): str
...SEVERITIES.map(s => ` ${s.padEnd(15)} ${String(c.native?.by_severity[s] ?? '—').padEnd(28)} ${c.outside?.by_severity[s] ?? '—'}`),
` Findings ${String(c.native?.findings ?? '—').padEnd(28)} ${c.outside?.findings ?? '—'}`,
c.coverage === 'both'
? `CONFIRMED ${c.confirmed} · DISAGREE ${c.disagree} · NEW (outside only) ${c.new} · native only ${c.native_only}. Both completed.`
? `CONFIRMED ${c.confirmed} · SAME LOCATION, UNVERIFIED ${c.unverified ?? 0} · DISAGREE ${c.disagree} · NEW (outside only) ${c.new} · native only ${c.native_only}. Both completed.`
: `Consensus: N/A (voice coverage: ${c.coverage.replace('_', ' ')}); confirmed counts require both voices.`,
'',
'Reconciliation (each outside finding against the native result):',
+62 -16
View File
@@ -16,13 +16,14 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { exportRun } from './autoplan-export';
import { applyRenames, planReopen } from './autoplan-gate';
import { applyRenames, approve, closeDraftDirections, planReopen } from './autoplan-gate';
import { SETTLEABLE_CHANGES } from './autoplan-reconcile';
import { normalizeRevision, reviewHash, scaffold } from './autoplan-plan-text';
import { parseScopeTokens } from './autoplan-scope';
import { PHASES, detectScope, extractHeading, phaseOrder, type Phase, type ScopeFlags } from './autoplan-prompts';
import { FILES, RunError, journal, now, pendingAmendments, readJournal, readState, writeState, type RunState } from './autoplan-state';
export interface ReviseOptions { out: string; plan?: string; incorporates?: string[]; withdraw?: string[]; cwd: string }
export interface ReviseOptions { out: string; plan?: string; incorporates?: string[]; withdraw?: string[]; settle?: string[]; cwd: string }
export interface ReviseResult { state: RunState; reopened: Phase[]; lines: string[] }
const amendmentLine = (state: RunState) => `AMENDMENTS: pending=${pendingAmendments(state).map(a => a.decision).join(',') || 'none'}`;
@@ -41,37 +42,74 @@ function revisionText(state: RunState, file: string): string {
return diag.text ?? text;
}
function settleAmendments(state: RunState, ids: string[], status: 'incorporated' | 'withdrawn', at: string, extra: Record<string, unknown> = {}): Array<Record<string, unknown>> {
function settleAmendments(state: RunState, ids: string[], status: 'incorporated' | 'withdrawn' | 'settled', at: string, extra: Record<string, unknown> = {}): Array<Record<string, unknown>> {
return ids.map(id => {
const a = pendingAmendments(state).find(x => x.decision === id)!;
a.status = status; a.settled_at = at;
Object.assign(a, extra);
return { event: status === 'incorporated' ? 'amendment_incorporated' : 'amendment_withdrawn', decision: id, ...extra };
return { event: `amendment_${status}`, decision: id, ...extra };
});
}
/** A `--settle`/`--incorporates`/`--withdraw` id: the decision id, or the short item id (`d4`) at the current gate_rev. */
function resolveIds(state: RunState, ids: string[]): string[] {
return ids.map(id => pendingAmendments(state).find(a => a.decision === id || (a.item === id && a.gate_rev === state.gate_rev))?.decision ?? id);
}
/**
* `--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.
*/
function settleEvidence(state: RunState, ids: string[]): { plan_sha256: string; changes: Record<string, string> } {
const reviewed = state.phase_order.map(p => state.phases[p]?.snapshot).find(Boolean);
const active = reviewHash(fs.readFileSync(state.active_plan, 'utf8'));
let input = active;
try { if (state.plan_input && fs.existsSync(state.plan_input)) input = reviewHash(revisionText(state, state.plan_input)); } catch { input = ''; }
const changes: Record<string, string> = {};
for (const id of ids) {
const a = pendingAmendments(state).find(x => x.decision === id)!;
if (!a.changes || !SETTLEABLE_CHANGES.includes(a.changes)) throw new RunError('AMENDMENT_NOT_SETTLEABLE', `${id} changes ${a.changes ?? 'nothing named'}; only packaging or ordering changes settle without a plan edit`, 3);
changes[id] = a.changes;
}
if (!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 };
}
/** Every accepted change is discharged and nothing reopened: the answered gate stands and the run is complete. */
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 });
return [...approve(state, at), `APPROVED: gate_rev=${state.gate_rev} (every accepted change is in the plan or settled)`];
}
export function reviseRun(o: ReviseOptions): ReviseResult {
const out = path.resolve(o.out);
const state = readState(out);
if (['complete', 'incomplete', 'refused'].includes(state.status)) throw new RunError('PHASE_NOT_AWAITING', `run is ${state.status}; revise works on a running or gated run`, 3);
const amending = state.status === 'amend_pending';
const pending = pendingAmendments(state).map(a => a.decision);
for (const id of [...(o.incorporates ?? []), ...(o.withdraw ?? [])]) {
const incorporates = resolveIds(state, o.incorporates ?? []), withdraw = resolveIds(state, o.withdraw ?? []), settle = resolveIds(state, o.settle ?? []);
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) {
for (const row of settleAmendments(state, o.withdraw ?? [], 'withdrawn', at, { by: 'owner' })) journal(out, row);
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)];
writeState(state);
return { state, reopened: [], lines: [amendmentLine(state)] };
return { state, reopened: [], lines };
}
const file = path.resolve(o.plan);
const text = revisionText(state, file);
let newHash: string;
try { newHash = reviewHash(text); } catch (e: any) { throw new RunError('PLAN_SECTIONS_INVALID', `${file}: ${e.message}`, 3); }
const oldHash = reviewHash(fs.readFileSync(state.active_plan, 'utf8'));
if (o.incorporates?.length && newHash === oldHash) {
throw new RunError('PLAN_AMEND_PENDING', `the Implementation plan did not change (${oldHash.slice(0, 12)}); incorporating ${o.incorporates.join(', ')} needs the edit in ${file}`, 3);
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);
const k = (state.plan_revision ?? 0) + 1;
const staging = path.join(out, `.revise-v${k}`);
fs.rmSync(staging, { recursive: true, force: true });
@@ -87,8 +125,7 @@ export function reviseRun(o: ReviseOptions): ReviseResult {
const changed = opened.filter(p => state.phases[p]!.snapshot !== newHash);
const reopen = state.phase_order.filter(p => changed.includes(p) || (p === 'eng' && opened.includes('eng') && changed.length > 0));
const atGate = state.status === 'gate_pending' ? state.gate_rev : undefined;
const incorporated = o.incorporates ?? [];
journal(out, { event: 'revise_intent', k, staging, plan: file, old_plan_sha256: oldHash, new_plan_sha256: newHash, at_gate_rev: atGate ?? null, incorporates: incorporated, withdraw: o.withdraw ?? [] });
journal(out, { event: 'revise_intent', k, staging: path.relative(out, staging), plan: file, old_plan_sha256: oldHash, new_plan_sha256: newHash, at_gate_rev: atGate ?? null, incorporates, withdraw, settle });
const renames = reopen.length ? planReopen(state, reopen, state.gate_rev ?? 0, `revise v${k}`) : [];
for (const p of added) state.phases[p] = {};
@@ -97,11 +134,19 @@ export function reviseRun(o: ReviseOptions): ReviseResult {
if (reopen.length || added.length) state.status = 'running';
state.plan_input = file; state.plan_source = path.join(out, `plan-source.v${k}.md`); state.plan_revision = k;
delete state.plan_reality;
const settled = [...settleAmendments(state, incorporated, 'incorporated', at, { old_plan_sha256: oldHash, new_plan_sha256: newHash }), ...settleAmendments(state, o.withdraw ?? [], 'withdrawn', at, { by: 'owner' })];
const settled = [...settleAmendments(state, incorporates, 'incorporated', at, { old_plan_sha256: oldHash, new_plan_sha256: newHash }), ...settleAmendments(state, withdraw, 'withdrawn', at, { by: 'owner' }),
...settleAmendments(state, settle, 'settled', at, { plan_sha256: evidence?.plan_sha256 }).map(r => ({ ...r, changes: evidence!.changes[r.decision as string] }))];
if (amending) state.status = pendingAmendments(state).length ? 'amend_pending' : state.phase_order.some(p => !state.phases[p]?.closed_at) ? 'running' : 'complete';
const committed = { event: 'plan_revised', k, plan: file, old_plan_sha256: oldHash, new_plan_sha256: newHash, reopened: reopen, added, at_gate_rev: atGate ?? null };
fs.writeFileSync(path.join(staging, FILES.run), JSON.stringify({ state, renames, committed, settled }, null, 2) + '\n');
publish(state, staging, renames, committed, settled);
return { state, reopened: reopen, lines: [`REVISED: plan=${newHash.slice(0, 12)} reopened=${reopen.join(',') || 'none'}${added.length ? ` added=${added.join(',')}` : ''}`, amendmentLine(state)] };
const lines = [`REVISED: plan=${newHash.slice(0, 12)} reopened=${reopen.join(',') || 'none'}${added.length ? ` added=${added.join(',')}` : ''}`, amendmentLine(state)];
if (state.status === 'complete') {
lines.push(...closeDraftDirections(state, at.slice(0, 10)), `APPROVED: gate_rev=${state.gate_rev} (every accepted change is in the plan or settled)`);
exportRun(state);
writeState(state);
}
return { state, reopened: reopen, lines };
}
/** The commit half of the transaction; recoverRevise replays it from the stage. Every step is idempotent. */
@@ -124,15 +169,16 @@ export function recoverRevise(out: string): string[] {
const rows = readJournal(out);
const intent = rows.filter(r => r.event === 'revise_intent').pop();
if (!intent || rows.some(r => (r.event === 'plan_revised' || r.event === 'revise_abandoned') && r.k === intent.k)) return [];
const stagedRun = path.join(intent.staging, FILES.run);
const staging = path.resolve(out, intent.staging);
const stagedRun = path.join(staging, FILES.run);
if (!fs.existsSync(stagedRun)) {
journal(out, { event: 'revise_abandoned', k: intent.k });
fs.rmSync(intent.staging, { recursive: true, force: true });
fs.rmSync(staging, { recursive: true, force: true });
return [`REVISE_RECOVERED: v${intent.k} abandoned (nothing was published)`];
}
const stage = JSON.parse(fs.readFileSync(stagedRun, 'utf8')) as { state: RunState; renames: Array<[string, string]>; committed: Record<string, unknown>; settled: Array<Record<string, unknown>> };
fs.writeFileSync(path.join(out, FILES.run), JSON.stringify(stage.state, null, 2) + '\n');
const state = readState(out);
publish(state, intent.staging, stage.renames, { ...stage.committed, recovered: true }, stage.settled);
publish(state, staging, stage.renames, { ...stage.committed, recovered: true }, stage.settled);
return [`REVISE_RECOVERED: v${intent.k} completed (reopened=${(stage.committed.reopened as string[]).join(',') || 'none'})`];
}
+37 -20
View File
@@ -19,10 +19,10 @@ 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 { closePhase, finishGate } from './autoplan-gate';
import { applyGuidance, closePhase, finishGate } from './autoplan-gate';
import { GSTACK_VERSION } from './autoplan-migrate';
import { driftLine, reviewHash, scaffold } from './autoplan-plan-text';
import { PHASES, SNAPSHOT_TOOL, detectScope, driverFile, extractHeading, phaseOrder, reviewSkillFile, scopeLines, scopeSourceLabel, sectionLoadFor, snapshotTool, writePrompts, type Phase, type Scope, type ScopeFlags } from './autoplan-prompts';
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';
import { recoverRevise } from './autoplan-revise';
import { FILES, RunError, journal, now, pendingAmendments, readDecisions, readJournal, readState, voiceTerminal, writeState, type AttemptState, type PhaseState, type RunState, type Voice, type VoiceState } from './autoplan-state';
import { checkPlanReality, checkRealityRows, PLAN_REALITY_CHECKER } from './plan-reality';
@@ -167,7 +167,7 @@ function openPhase(state: RunState, phase: Phase, cwd: string): void {
ps.outside = state.outside_enabled ? { status: 'pending', attempts: [] }
: notRun ? { status: 'disabled', outcome: 'not_run', reason: notRun.reason, detail: notRun.detail } : { status: 'disabled' };
ps.prompts = prompts;
journal(state.out, { event: 'phase_opened', phase, snapshot: snap.sha256, prompts });
journal(state.out, { event: 'phase_opened', phase, snapshot: snap.sha256, prompts: Object.fromEntries(Object.entries(prompts).map(([k, p]) => [k, path.relative(state.out, p)])) });
}
export function nextStep(o: NextOptions): NextResult {
@@ -178,7 +178,8 @@ export function nextStep(o: NextOptions): NextResult {
if (state.deadline_at && Date.parse(state.deadline_at) < Date.now()) { state.status = 'interrupted'; writeState(state); lines.push(`DEADLINE: passed at ${state.deadline_at}`); return result; }
const pending = pendingAmendments(state).map(a => a.decision);
if (pending.length) {
throw new RunError('PLAN_AMEND_PENDING', `accepted at the gate but not in the plan: ${pending.join(', ')}; edit the plan, then gstack-autoplan revise --out ${state.out} --plan ${state.plan_input ?? state.plan_source} --incorporates ${pending.join(',')} (or --withdraw <id>)`, 3);
const each = pendingAmendments(state).map(a => `APPLY ${a.item ?? a.decision}: ${applyGuidance(a)}`).join('; ');
throw new RunError('PLAN_AMEND_PENDING', `accepted at the gate but not in the plan: ${pending.join(', ')} (${each}); edit the plan, then gstack-autoplan revise --out ${state.out} --plan ${state.plan_input ?? state.plan_source} --incorporates ${pending.join(',')} (or --settle <id> for a packaging/ordering change, --withdraw <id>)`, 3);
}
const phase = currentPhase(state);
if (!phase) { finishGate(state); writeState(state); return result; }
@@ -210,8 +211,9 @@ export function nextStep(o: NextOptions): NextResult {
const estimate = o.estimateUsd ?? 0;
try { reserve(path.join(state.out, FILES.spend), attempt, estimate, { cap: state.spend_cap_usd, label: `${phase}/${voice}` }); }
catch (e: any) { if (e.code === 'SPEND_CAP_EXCEEDED') { state.attempt_counter--; writeState(state); throw new RunError('SPEND_CAP_EXCEEDED', e.message, 3); } throw e; }
const row: AttemptState = { attempt, state: 'dispatched', prompt: prompts[voice], result: prompts[voice === 'native' ? 'nativeResult' : 'outsideResult'], estimate_usd: estimate, dispatched_at: now() };
journal(state.out, { event: 'dispatched', attempt, phase, voice, snapshot: ps.snapshot, prompt: row.prompt, prompt_sha256: sha256File(row.prompt), result: row.result, estimate_usd: estimate });
const row: AttemptState = { attempt, state: 'dispatched', prompt: prompts[voice], result: path.join(state.out, attemptFile(phase, voice, attempt.slice(state.run.length + 1), 'md')), estimate_usd: estimate, dispatched_at: now() };
bindPromptResult(row.prompt, row.result);
journal(state.out, { event: 'dispatched', attempt, phase, voice, snapshot: ps.snapshot, prompt: path.relative(state.out, row.prompt), prompt_sha256: sha256File(row.prompt), result: path.relative(state.out, row.result), estimate_usd: estimate });
(v.attempts ??= []).push(row);
v.attempt = attempt; v.status = 'running';
result.attempts.push({ attempt, voice, prompt: row.prompt, result: row.result, model_family: familyHint(state, voice), new: true });
@@ -292,6 +294,10 @@ export function submitResult(o: SubmitOptions): SubmitResult {
}
const open = resolveAttempt(v, o.attempt, phase, o.voice);
const file = (o.record ?? o.result)!;
const owner = new RegExp(`^${phase}-${o.voice}\\.(a\\d+)\\.md$`).exec(path.basename(file));
if (owner && path.dirname(path.resolve(file)) === state.out && `${state.run}-${owner[1]}` !== open.attempt) {
throw new RunError('ATTEMPT_MISMATCH', `${path.basename(file)} is the result path of ${state.run}-${owner[1]}, not of the open attempt ${open.attempt} (result=${open.result})`, 3);
}
if (!fs.existsSync(file)) throw new RunError('RESULT_RECEIPT_MISSING', `result file not found: ${file}`);
const text = fs.readFileSync(file, 'utf8');
let bind: ReturnType<typeof readRecord>;
@@ -309,19 +315,24 @@ export function submitResult(o: SubmitOptions): SubmitResult {
else if (modelFamily(model) === 'unknown') conflict = `native model ${JSON.stringify(model)} has no recognized family`;
if (conflict) throw new RunError('MODEL_FAMILY_CONFLICT', conflict, 3);
// Bind: immutable copy, per-voice canonical rows, journal, ledger, manifest. Nothing above this line wrote.
const bound = open.result;
// Bind: a sealed copy of the reviewer's bytes under an attempt-owned name the reviewer never writes, per-voice
// canonical rows, journal, ledger, manifest. Nothing above this line wrote; a later edit of `file` changes nothing bound.
const bound = path.join(state.out, attemptFile(phase, o.voice, open.attempt.slice(state.run.length + 1), 'bound.md'));
const tmp = `${bound}.${process.pid}.tmp`;
fs.writeFileSync(tmp, text);
fs.writeFileSync(tmp, text, process.platform === 'win32' ? {} : { mode: 0o444 });
fs.renameSync(tmp, bound);
const rows = canonicalRows(bind.findings, { run: state.run, phase, voice: o.voice, model });
if (process.env.GSTACK_AUTOPLAN_FAULT === 'submit-after-seal') throw new Error('fault injected: submit interrupted after sealing (GSTACK_AUTOPLAN_FAULT, tests only)');
const planFiles = [ps.snapshot_path, ps.snapshot_path && path.join(path.dirname(ps.snapshot_path), 'source-implementation.md'), state.active_plan].filter((p): p is string => !!p);
const pathCtx = { cwd: o.cwd, planFiles, planSource: state.plan_source, planName: path.basename(state.plan_input ?? state.plan_source) };
const rows = canonicalRows(bind.findings.map(f => { const { file, line, ...rest } = f; return { ...rest, ...normalizeFindingFile({ file, line }, pathCtx) }; }), { run: state.run, phase, voice: o.voice, model });
fs.writeFileSync(`${bound}.findings.jsonl`, rows.map(r => JSON.stringify(r)).join('\n') + (rows.length ? '\n' : ''));
const sha = sha256File(bound);
const provider = o.runner ?? 'host-subagent';
const receipt = { receipt: bind.receipt, findings_source: bind.source, reviewed_sha256: ps.snapshot, ...(bind.receipt === 'attested' ? { attest_reason: o.attest } : {}) };
journal(state.out, { event: 'submitted', attempt: open.attempt, phase, voice: o.voice, result: bound, result_sha256: sha, model, runner: o.runner ?? null, findings: rows.length, usage_usd: o.usageUsd ?? 'unknown', ...receipt });
journal(state.out, { event: 'submitted', attempt: open.attempt, phase, voice: o.voice, result: path.basename(bound), result_sha256: sha, model, runner: provider, findings: rows.length, usage_usd: o.usageUsd ?? 'unknown', ...receipt });
settle(path.join(state.out, FILES.spend), open.attempt, o.usageUsd ?? 'unknown');
open.state = 'submitted'; open.terminal_at = now();
Object.assign(v, { status: 'completed', model, provider: o.runner ?? (o.voice === 'native' ? 'host-subagent' : 'unknown'), output: path.basename(bound), result_sha256: sha, findings: rows.length, attempt: open.attempt, usage: { usd: o.usageUsd ?? 'unknown' }, ...receipt } satisfies Partial<VoiceState>);
Object.assign(v, { status: 'completed', model, provider, output: path.basename(bound), result_sha256: sha, findings: rows.length, attempt: open.attempt, usage: { usd: o.usageUsd ?? 'unknown' }, ...receipt } satisfies Partial<VoiceState>);
if (bind.receipt === 'attested') (state.deviations ??= []).push(`receipt attested: ${phase} ${o.voice} ${open.attempt} bound from a hand-run record without a reviewer receipt (snapshot ${ps.snapshot.slice(0, 12)}; reason: ${o.attest})`);
state.artifacts[path.basename(bound)] = { path: path.basename(bound), sha256: sha };
if (o.voice === 'native') state.native_model = model; else state.outside_model = model;
@@ -337,7 +348,7 @@ export function submitResult(o: SubmitOptions): SubmitResult {
function closeAndAdvance(state: RunState, phase: Phase, reconcile?: string): string[] {
const close = closePhase(state, phase, { overridesFile: reconcile });
if (!currentPhase(state)) finishGate(state); else state.status = 'running';
return [`PHASE_CLOSED: ${phase} confirmed=${close.consensus.confirmed} disagree=${close.consensus.disagree} new=${close.consensus.new} native_only=${close.consensus.native_only} coverage=${close.consensus.coverage}`];
return [`PHASE_CLOSED: ${phase} confirmed=${close.consensus.confirmed}${close.consensus.unverified ? ` unverified=${close.consensus.unverified}` : ''} disagree=${close.consensus.disagree} new=${close.consensus.new} native_only=${close.consensus.native_only} coverage=${close.consensus.coverage}`];
}
/**
@@ -395,17 +406,18 @@ export function resumeRun(out: string): ResumeResult {
if (terminal.has(r.attempt)) {
// A submit that journaled but died before run.json: re-bind from the journal and the file on disk.
const sub = rows.find(x => x.event === 'submitted' && x.attempt === r.attempt);
if (sub && a && a.state !== 'submitted' && fs.existsSync(sub.result) && sha256File(sub.result) === sub.result_sha256) {
const result = sub && path.resolve(out, sub.result);
if (sub && result && a && a.state !== 'submitted' && fs.existsSync(result) && sha256File(result) === sub.result_sha256) {
a.state = 'submitted'; a.terminal_at = sub.ts;
Object.assign(v!, { status: 'completed', model: sub.model, provider: sub.runner ?? 'unknown', output: path.basename(sub.result), result_sha256: sub.result_sha256, findings: sub.findings, attempt: sub.attempt,
Object.assign(v!, { status: 'completed', model: sub.model, provider: sub.runner ?? 'unknown', output: path.basename(result), result_sha256: sub.result_sha256, findings: sub.findings, attempt: sub.attempt,
...(sub.receipt ? { receipt: sub.receipt, findings_source: sub.findings_source, reviewed_sha256: sub.reviewed_sha256, ...(sub.attest_reason ? { attest_reason: sub.attest_reason } : {}) } : {}) });
state.artifacts[path.basename(sub.result)] = { path: path.basename(sub.result), sha256: sub.result_sha256 };
state.artifacts[path.basename(result)] = { path: path.basename(result), sha256: sub.result_sha256 };
if (r.voice === 'native') state.native_model = sub.model; else state.outside_model = sub.model;
rebound.push(r.attempt);
}
continue;
}
if (!a) { (v!.attempts ??= []).push({ attempt: r.attempt, state: 'execution_unknown', prompt: r.prompt, result: r.result, estimate_usd: r.estimate_usd ?? 0, dispatched_at: r.ts }); v!.status = 'running'; }
if (!a) { (v!.attempts ??= []).push({ attempt: r.attempt, state: 'execution_unknown', prompt: path.resolve(out, r.prompt), result: path.resolve(out, r.result), estimate_usd: r.estimate_usd ?? 0, dispatched_at: r.ts }); v!.status = 'running'; }
else if (a.state === 'dispatched') a.state = 'execution_unknown';
else continue;
journal(out, { event: 'execution_unknown', attempt: r.attempt, phase: r.phase, voice: r.voice, by: 'resume' });
@@ -416,7 +428,7 @@ export function resumeRun(out: string): ResumeResult {
if (!ps?.snapshot || ps.closed_at) continue;
if (ps.native?.status === 'completed' && voiceTerminal(ps.outside)) closePhase(state, phase, {});
}
if (!currentPhase(state) && !['gate_pending', 'complete', 'incomplete', 'refused'].includes(state.status)) finishGate(state);
if (!currentPhase(state) && !['gate_pending', 'amend_pending', 'complete', 'incomplete', 'refused'].includes(state.status)) finishGate(state);
else if (state.status === 'interrupted' || state.status === 'running' || state.status === 'awaiting_result') {
const anyOpen = state.phase_order.some(p => (['native', 'outside'] as const).some(v => openAttempt(voiceState(state, p, v))));
state.status = anyOpen ? 'awaiting_result' : 'running';
@@ -437,9 +449,14 @@ export function statusLines(state: RunState): string[] {
const v = (voice: Voice) => { const s = ps[voice]; return `${voice}=${s?.status ?? 'pending'}${s?.model ? `:${s.model}` : ''}${s?.attempt ? `@${s.attempt}` : ''}`; };
lines.push(`PHASE: ${p} ${ps.closed_at ? 'closed' : ps.snapshot ? 'open' : 'pending'} ${v('native')} ${v('outside')}${ps.consensus ? ` confirmed=${(ps.consensus as any).confirmed} disagree=${(ps.consensus as any).disagree} new=${(ps.consensus as any).new}` : ''}`);
}
for (const a of pendingAmendments(state)) lines.push(`AMEND_PENDING: ${a.decision} ${a.item ?? ''}${a.changes ? ` changes=${a.changes}` : ''} -> ${applyGuidance(a)}`);
for (const p of state.phase_order) for (const voice of ['native', 'outside'] as const) {
const open = state.phases[p]?.[voice] && openAttempt(state.phases[p]![voice]!);
if (open) lines.push(`PENDING: ${open.attempt} phase=${p} voice=${voice} result=${open.result}`);
}
if (fs.existsSync(path.join(state.out, FILES.spend))) lines.push(renderSpend(summarize(readLedger(path.join(state.out, FILES.spend)))));
return lines;
}
export const TERMINAL: readonly RunStatus[] = ['complete', 'gate_pending', 'incomplete', 'refused', 'interrupted'];
export const TERMINAL: readonly RunStatus[] = ['complete', 'gate_pending', 'amend_pending', 'incomplete', 'refused', 'interrupted'];
export { SNAPSHOT_TOOL, RunError, FILES, readState, writeState, readJournal, journal };
export type { RunState, Voice, VoiceState, AttemptState };
+5 -1
View File
@@ -20,7 +20,11 @@ export interface VoiceState extends VoiceOutcome {
receipt?: 'verified' | 'attested'; attest_reason?: string; findings_source?: 'fence' | 'table' | 'list' | 'none'; reviewed_sha256?: string;
}
/** An accepted plan change (gate option with changes_plan) the plan must absorb before the run continues. */
export interface Amendment { decision: string; title: string; chosen: string; text: string; gate_rev: number; status: 'pending' | 'incorporated' | 'withdrawn'; at: string; settled_at?: string }
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;
}
export interface PhaseState {
snapshot?: string; snapshot_dir?: string; snapshot_path?: string; methodology?: string; opened_at?: string; closed_at?: string;
native?: VoiceState; outside?: VoiceState; consensus?: Record<string, unknown>; skipped?: string;
+48 -41
View File
@@ -3,18 +3,21 @@
* A gate list is numbered items, each with a stable id, a recommended default
* and lettered options; `auto` items take their recommendation when
* unanswered, `approval` items stay pending until answered. One reply
* grammar: `all` (every item takes its recommendation), `<id><option>` tokens
* (`d3b uc1a`), or `all except <tokens>` (the same operation as listing the
* overrides, so there is no exclusion-vs-override ambiguity). Bare yes/no is
* ambiguous across multi-option items and is reported as unparsed, never
* guessed. A reply binds to the gate revision it answers (`gate_rev`).
* `all as-is` keeps every direction: each item takes its option that does not
* change the plan (`changes_plan: false`), so agreeing user challenges whose
* recommendation is the change can be declined in one token. Earlier answers
* at the same revision (`prior`) are kept unless a token names the item again:
* only unresolved items default.
* grammar: `rec` (every item takes its recommendation, plan changes
* included), `<id><option>` tokens (`d3b uc1a`), or `rec except <tokens>`
* (the same operation as listing the overrides). `all`, `approve all`,
* `accept all` and `take all recommendations` are silent aliases of `rec`;
* `as-is` (alias `all as-is`) keeps every direction: each item takes its
* option that does not change the plan (`changes_plan: false`). Bare yes/no
* is ambiguous across multi-option items and is reported as unparsed, never
* guessed. A reply binds to the gate revision it answers (`gate_rev`), and a
* `rec` reply to the recommendations it saw (`rec_hash`, bumped by
* `gstack-autoplan recommend`). Earlier answers at the same revision
* (`prior`) are kept unless a token names the item again: only unresolved
* items default.
* Rendered by bin/gstack-gate; rows land in decisions.jsonl (lib/headless-artifacts.ts).
*/
import { createHash } from 'node:crypto';
import type { DecisionRow } from './headless-artifacts';
/** `changes_plan`: choosing this option is an accepted plan change (reopens its phase plus Eng, prints APPLY:). */
@@ -24,22 +27,32 @@ export interface GateItem {
phase?: string; cost?: string; why?: string;
/** false: an agent decision; no option reopens a phase. */
reopens?: boolean;
/** Why `recommended` (e.g. `both voices; remedies shown`, `plan direction; voices disagree`). */
/** Why `recommended` (e.g. `both voices; shared proposal`, `plan direction; voices disagree`). */
recommended_basis?: string; changes?: string;
/** Set when the relaying parent chose this recommendation (`gstack-autoplan recommend`); shown in the render. */
recommended_by?: string;
/** Stable carry-forward identity (phase, finding ids, phase close, snapshot, option text). */
origin?: string;
/** Advisory lines rendered under the item (PLAN_REALITY: a plan term with no number, arm list or rule body). */
flags?: string[];
/** Answered at an earlier revision on an unchanged proposal from a phase that did not rerun. */
carried?: { from: string; chosen: string; gate_rev: number };
}
export interface GateList { schema_version: 1; run: string; gate_rev: number; items: GateItem[]; title?: string }
export interface GateList { schema_version: 1; run: string; gate_rev: number; items: GateItem[]; title?: string;
/** Bumped by every `recommend`, so a `rec` relayed before it no longer matches `recHash`. */
rec_rev?: number }
export interface GateChoice { id: string; chosen: string; kind: 'auto' | 'approval'; how: 'recommended' | 'override' | 'auto_default'; prior?: boolean }
export interface ParsedReply {
valid: boolean; gate_rev: number; reply: string; choices: GateChoice[]; pending: string[]; unparsed: string[];
/** `rec`: the reply took the recommendations (any alias, with or without `except`). */
mode?: 'rec' | 'as-is' | 'tokens';
error?: 'GATE_REV_STALE' | 'GATE_REPLY_UNPARSED' | 'GATE_EMPTY';
}
export const PAGE_SIZE = 7;
/** Reply openers that take every recommendation: `rec` and its silent aliases. */
const REC_ALIASES: string[][] = [['rec'], ['all'], ['approve', 'all'], ['accept', 'all'], ['take', 'all', 'recommendations']];
const ID_RE = /^[a-z]*[0-9]+$/;
export function validateGateList(list: unknown): string[] {
@@ -68,25 +81,17 @@ export function validateGateList(list: unknown): string[] {
return problems;
}
/**
* An approval item whose recommended option changes the plan (both voices
* recommend the change) is answered only by its own token: `all` must never
* flip the owner's stated direction on their behalf (field gate rev 1, d3).
*/
export function heldFromAll(item: GateItem): boolean {
return item.kind === 'approval' && item.options.some(o => o.key === item.recommended && o.changes_plan === true);
/** The recommendations a `rec` reply takes, as a short hash the render prints and `answer --rec-hash` checks. */
export function recHash(list: GateList): string {
const recs = list.items.map(i => [i.id, i.recommended, i.recommended_basis ?? '', i.recommended_by ?? '']);
return createHash('sha256').update(JSON.stringify([list.gate_rev, list.rec_rev ?? 0, recs])).digest('hex').slice(0, 12);
}
/** The `all` resolution: every item takes its recommendation, except the ones `heldFromAll` leaves to the owner; shown under the list so the reply shape is explicit. */
export function resolveAll(list: GateList): Record<string, string> {
return Object.fromEntries(list.items.filter(i => !heldFromAll(i)).map(i => [i.id, i.recommended]));
}
/** The `GATE_PENDING` hint: exact tokens per pending item, and the `all` rule when any pending item is held from it. */
/** The `GATE_PENDING` hint: exact tokens per pending item, or `rec`. */
export function pendingHint(list: GateList, pending: string[]): string {
const items = pending.map(id => list.items.find(i => i.id === id)).filter((i): i is GateItem => !!i);
const tokens = items.map(i => i.options.map(o => `${i.id}${o.key}`).join('|')).join(' ');
return `reply with ${tokens || 'p1a..p1e'}${items.some(heldFromAll) ? '; all does not answer items recommended b' : ' or all'}`;
return `reply with ${tokens || 'p1a..p1e'} or rec`;
}
export function renderGate(list: GateList, page = 1, pageSize = PAGE_SIZE): string {
@@ -98,18 +103,19 @@ export function renderGate(list: GateList, page = 1, pageSize = PAGE_SIZE): stri
lines.push('');
list.items.slice(start, start + pageSize).forEach((item, i) => {
const n = start + i + 1;
lines.push(`${n}. [${item.id}] ${item.title} (${item.kind}; recommended: ${item.recommended}${item.recommended_basis ? ` (${item.recommended_basis})` : ''}${item.phase ? `; from ${item.phase}` : ''})`);
lines.push(`${n}. [${item.id}] ${item.title} (${item.kind}; recommended: ${item.recommended}${item.recommended_basis ? ` (${item.recommended_basis}${item.recommended_by ? `; set by ${item.recommended_by}` : ''})` : ''}${item.phase ? `; from ${item.phase}` : ''})`);
if (item.why) lines.push(` ${item.why}`);
if (item.carried) lines.push(` decided at gate_rev ${item.carried.gate_rev}: ${item.id}${item.carried.chosen} (carried forward; name it again to change it)`);
for (const o of item.options) lines.push(` ${item.id}${o.key}) ${o.text}${o.key === item.recommended ? ' (recommended)' : ''}`);
if (item.cost) lines.push(` cost: ${item.cost}`);
for (const flag of item.flags ?? []) lines.push(` flagged: ${flag}`);
});
lines.push('');
if (pages > 1) lines.push(`Ids are stable across pages; render page N with \`gstack-gate render <file> --page N\`.`);
lines.push('Reply with exactly one of: `all` · `<id><option>` tokens, e.g. `' + exampleTokens(list) + '` · `all except <tokens>` · `all as-is` (keep every direction).');
if (pages > 1) lines.push(`Ids are stable across pages; render page N with \`gstack-gate render <file> --page N\`, or every page with \`--all\`. Relay every page.`);
lines.push('Reply with exactly one of: `rec` (default: take every recommendation, plan changes included) · `rec except <tokens>` · `<id><option>` tokens, e.g. `' + exampleTokens(list) + '` · `as-is` (the plan as written).');
lines.push('Items marked auto take their recommendation if unanswered; approval items stay pending until answered. Bare yes/no is not accepted.');
for (const held of list.items.filter(heldFromAll)) lines.push(`\`all\` does not answer ${held.id} (recommended ${held.recommended} changes the plan); reply ${held.options.map(o => `${held.id}${o.key}`).join(' or ')} yourself.`);
lines.push(`Reply binds to gate_rev=${list.gate_rev}. \`all\` resolves to: ${JSON.stringify({ gate_rev: list.gate_rev, choices: resolveAll(list) })}`);
const rec = parseReply('rec', list);
lines.push(`Reply binds to gate_rev=${list.gate_rev} rec_hash=${recHash(list)}. \`rec\` resolves to: ${JSON.stringify({ gate_rev: list.gate_rev, rec_hash: recHash(list), choices: Object.fromEntries(rec.choices.map(c => [c.id, c.chosen])) })}`);
return lines.join('\n') + '\n';
}
@@ -119,7 +125,7 @@ function exampleTokens(list: GateList): string {
/** Tokenize a reply: lowercase, strip separators, merge `<id> <option>` pairs into one token. */
export function tokenize(reply: string, list: GateList): string[] {
const raw = reply.toLowerCase().replace(/[,;:=)]/g, ' ').split(/\s+/).filter(Boolean);
const raw = reply.toLowerCase().replace(/[,;:=)`"'\u2018\u2019\u201c\u201d]/g, ' ').split(/\s+/).filter(Boolean);
const ids = new Set(list.items.map(i => i.id));
const out: string[] = [];
for (let i = 0; i < raw.length; i++) {
@@ -142,13 +148,14 @@ export function parseReply(reply: string, list: GateList, gateRev?: number, prio
const tokens = tokenize(reply, list);
if (tokens.length === 0) return { ...base, error: 'GATE_EMPTY' };
let all = false;
let rec = false;
let asIs = false;
let rest = tokens;
if (tokens[0] === 'all') {
all = true;
rest = tokens.slice(1);
if (rest[0] === 'as-is') { asIs = true; rest = rest.slice(1); }
const lead = REC_ALIASES.find(a => a.every((w, i) => tokens[i] === w));
if (lead && tokens[lead.length] === 'as-is' && lead.length === 1 && lead[0] === 'all') { asIs = true; rest = tokens.slice(2); }
else if (lead) { rec = true; rest = tokens.slice(lead.length); }
else if (tokens[0] === 'as-is') { asIs = true; rest = tokens.slice(1); }
if (rec || asIs) {
if (rest[0] === 'except') rest = rest.slice(1);
else if (rest.length > 0) return { ...base, unparsed: rest, error: 'GATE_REPLY_UNPARSED' };
}
@@ -173,11 +180,11 @@ export function parseReply(reply: string, list: GateList, gateRev?: number, prio
if (chosen !== undefined) choices.push({ id: item.id, chosen, kind: item.kind, how: how(item, chosen) });
else if (earlier !== undefined && item.options.some(o => o.key === earlier)) choices.push({ id: item.id, chosen: earlier, kind: item.kind, how: how(item, earlier), prior: true });
else if (asIs) { const keep = keepDirection(item); choices.push({ id: item.id, chosen: keep, kind: item.kind, how: how(item, keep) }); }
else if (all && !heldFromAll(item)) choices.push({ id: item.id, chosen: item.recommended, kind: item.kind, how: 'recommended' });
else if (rec) choices.push({ id: item.id, chosen: item.recommended, kind: item.kind, how: 'recommended' });
else if (item.kind === 'auto') choices.push({ id: item.id, chosen: item.recommended, kind: item.kind, how: 'auto_default' });
else pending.push(item.id);
}
return { ...base, valid: true, choices, pending };
return { ...base, valid: true, choices, pending, mode: rec ? 'rec' : asIs ? 'as-is' : 'tokens' };
}
/** The option that keeps the plan's direction: the first with `changes_plan: false`; plan approval `a`; else the recommendation. */
@@ -205,14 +212,14 @@ export function decisionRows(list: GateList, extra: Partial<DecisionRow> = {}):
/** `--by` values: the owner typing tokens, or an agent relaying or deciding them. */
export const ANSWERED_BY_RE = /^(human|agent:[A-Za-z0-9_.:-]+)$/;
export function applyReply(rows: DecisionRow[], parsed: ParsedReply, answeredAt: string, answeredBy?: string): DecisionRow[] {
export function applyReply(rows: DecisionRow[], parsed: ParsedReply, answeredAt: string, answeredBy?: string, extra: Partial<DecisionRow> = {}): DecisionRow[] {
if (!parsed.valid) return rows;
const byId = new Map(parsed.choices.map(c => [c.id, c]));
return rows.map(row => {
const choice = byId.get(row.label?.toLowerCase() ?? '') ?? byId.get(row.id.slice(row.run.length + 1).replace(/^r\d+-/, ''));
if (!choice || choice.prior || row.gate_rev !== parsed.gate_rev) return row;
return {
...row, chosen: choice.chosen, answered_at: answeredAt, reply: parsed.reply, ...(answeredBy ? { answered_by: answeredBy } : {}),
...row, chosen: choice.chosen, answered_at: answeredAt, reply: parsed.reply, ...(answeredBy ? { answered_by: answeredBy } : {}), ...extra,
status: choice.how === 'override' ? 'overridden' : 'approved',
};
});
+7 -5
View File
@@ -23,7 +23,7 @@ export type DecisionKind = 'auto' | 'approval' | 'user_challenge';
export type DecisionStatus = 'pending' | 'approved' | 'overridden' | 'rejected';
export type TaskStatus = 'pending' | 'ready' | 'blocked_on_dependency' | 'blocked_on_decision' | 'in_progress' | 'done';
export type VoiceStatus = 'completed' | 'unavailable' | 'disabled' | 'skipped' | 'failed' | 'pending' | 'running';
export type ResultStatus = 'complete' | 'gate_pending' | 'incomplete' | 'interrupted' | 'refused';
export type ResultStatus = 'complete' | 'gate_pending' | 'amend_pending' | 'incomplete' | 'interrupted' | 'refused';
export type RunStatus = ResultStatus | 'awaiting_result' | 'running';
export interface FindingRow {
@@ -39,6 +39,8 @@ export interface DecisionRow {
answered_by?: string; source?: string; cost?: string;
/** Why the recommendation (gate items built by gstack-autoplan), what a user challenge changes, and the carry-forward identity. */
recommended_basis?: string; changes?: string; origin?: string; carried_from?: string; chosen_text?: string;
/** An agent-relayed answer: the owner's verbatim words, and whether they parsed under the gate grammar. */
owner_reply?: string; owner_reply_parsed?: boolean; recommended_by?: string;
}
export interface TaskRow {
schema_version: 2; run: string; id: string; title: string; status: TaskStatus; depends_on: string[];
@@ -84,8 +86,8 @@ export interface ShipReceipt {
issues?: { fixes: number[]; refs: number[]; flakes: Array<{ issue: number; run: string }> };
}
export const RESULT_STATUSES: readonly ResultStatus[] = ['complete', 'gate_pending', 'incomplete', 'interrupted', 'refused'];
const TERMINAL_RUN: readonly string[] = ['complete', 'gate_pending', 'refused', 'incomplete'];
export const RESULT_STATUSES: readonly ResultStatus[] = ['complete', 'gate_pending', 'amend_pending', 'incomplete', 'interrupted', 'refused'];
const TERMINAL_RUN: readonly string[] = ['complete', 'gate_pending', 'amend_pending', 'refused', 'incomplete'];
const TERMINAL_VOICE: readonly string[] = ['completed', 'unavailable', 'disabled', 'skipped', 'failed'];
const RESOLVED: readonly string[] = ['accepted', 'partially_accepted', 'rejected', 'deferred', 'fixed'];
@@ -131,7 +133,7 @@ export const ARTIFACT_SCHEMAS = {
voice: { enum: ['native', 'outside'] }, model: str, severity: { enum: ['Critical', 'High', 'Medium', 'Low', 'Informational'] },
title: str, file: str, line: { type: 'integer' }, disposition: { enum: ['open', 'accepted', 'partially_accepted', 'rejected', 'deferred', 'fixed'] },
resolution: str, plan_items: strList, native_counterpart: nullableStr, source: str,
changes: { enum: ['scope', 'cost', 'direction'] },
changes: { enum: ['scope', 'cost', 'direction', 'packaging', 'ordering'] },
},
},
decisions: {
@@ -169,7 +171,7 @@ export const ARTIFACT_SCHEMAS = {
required: ['schema_version', 'run', 'status', 'required_phases', 'phases', 'artifacts'],
properties: {
schema_version: { const: 1 }, run: str,
status: { enum: ['complete', 'gate_pending', 'incomplete', 'interrupted', 'refused', 'awaiting_result', 'running'] },
status: { enum: ['complete', 'gate_pending', 'amend_pending', 'incomplete', 'interrupted', 'refused', 'awaiting_result', 'running'] },
base: str, host: str, session_kind: str, required_phases: strList,
phases: { type: 'object', description: 'phase id → { snapshot, native, outside }', properties: {} },
counts: { type: 'object', description: 'decisions, decisions_pending, findings, tasks: each must equal the rows in its file' },
+49 -12
View File
@@ -203,10 +203,11 @@ export interface RealityCheck {
* measured/costs/takes/took. Receipts, versions and ids are removed before matching, so they never count.
*/
const MEASURED_RE = /\$\s?\d|\b\d[\d,]*(?:\.\d+)?\s?[kKM]?\s?(?:ms|s|%|USD|tokens?|MB|GB)(?![\w])|\b\d[\d,]*(?:\.\d+)?\s?[×x](?![\w])|\b(?:measured|costs?|takes|take|took)\b\W+(?:[A-Za-z]+\W+){0,3}?\$?\d/i;
const NOT_A_QUANTITY_RE = new RegExp(`${RECEIPT_RE.source}|\\bv?\\d+(?:\\.\\d+){2,}\\b|#\\d+|\\b[A-Za-z]+\\d+[a-z]?\\b`, 'g');
const ITEM_REF_RE = /\b(?:items?|sections?)\s+\d+(?:\s*[–-]\s*\d+)?(?:\s*(?:,|and)\s*\d+(?:\s*[–-]\s*\d+)?)*|§\s?\d+(?:\.\d+)*/;
const NOT_A_QUANTITY_RE = new RegExp(`${RECEIPT_RE.source}|${ITEM_REF_RE.source}|\\bv?\\d+(?:\\.\\d+){2,}\\b|#\\d+|\\b[A-Za-z]+\\d+[a-z]?\\b`, 'g');
const EXEMPT_TAG_RE = /\[(?:estimated|re-measure before gate)\]/i;
const SCRIPT_RE = /(?:^|[\s(`"'])(?:\.?[\w-]+\/)*[\w.-]+\.(?:ts|js|mjs|cjs|sh|py|rb|go)\b|(?:^|[\s(`"'])(?:bin|scripts)\/[\w.-]+/;
const COMMAND_RE = /`[^`]*\s[^`]*`/;
const COMMAND_RE = /`(?!\s*\$?\d)[^`]*\s[^`]*`/;
const RULE_CLAIM_RE = /\b(?:decision rule|rules? (?:was |were )?written first|pre-?registered rule)\b/i;
const QUOTED_RULE_RE = /"[^"]{8,}"|\u201c[^\u201d]{8,}\u201d/;
const LINK_RE = /(?<![\w./-])\.?[A-Za-z~$][\w./~$@-]*:\d+/;
@@ -228,23 +229,32 @@ export function splitClauses(text: string): string[] {
return out.map(c => c.trim()).filter(Boolean);
}
/** Clauses of one line that state a measured number and carry no receipt of their own. */
export function unreceiptedNumbers(line: string): string[] {
return splitClauses(line).filter(c => MEASURED_RE.test(c.replace(NOT_A_QUANTITY_RE, ' '))
const QUOTED_SPAN_RE = /"([^"]+)"|\u201c([^\u201d]+)\u201d/g;
const BACKTICK_SPAN_RE = /`[^`]*`/g;
/**
* Clauses of one line that state a measured number and carry no receipt of their own. With `quotedFrom` (the
* reviewed plan, reviewer scope only), a span quoted verbatim from it is the plan's number, not the reviewer's claim.
*/
export function unreceiptedNumbers(line: string, quotedFrom?: string): string[] {
const text = quotedFrom ? line.replace(QUOTED_SPAN_RE, (span, a, b) => (quotedFrom.includes((a ?? b) as string) ? '""' : span)) : line;
return splitClauses(text).filter(c => MEASURED_RE.test(c.replace(NOT_A_QUANTITY_RE, ' '))
&& !RECEIPT_RE.test(c) && !SCRIPT_RE.test(c) && !COMMAND_RE.test(c) && !EXEMPT_TAG_RE.test(c));
}
/** A rule claim needs the rule: a quoted span, a file:line link on the line, or a fenced or quoted block right after it. */
function ruleUnquoted(lines: string[], i: number): boolean {
/** A rule claim needs the rule: a quoted span, a file:line link on the line, or a fenced or quoted block right after it. Plan scope ignores phrases inside backticks (a phrase named, not claimed). */
function ruleUnquoted(lines: string[], i: number, scope: 'plan' | 'review' = 'review'): boolean {
const line = lines[i]!;
if (!RULE_CLAIM_RE.test(line) || QUOTED_RULE_RE.test(line) || LINK_RE.test(line)) return false;
if (!RULE_CLAIM_RE.test(scope === 'plan' ? line.replace(BACKTICK_SPAN_RE, ' ') : line) || QUOTED_RULE_RE.test(line) || LINK_RE.test(line)) return false;
const after = lines.slice(i + 1).find(l => l.trim());
return !after || !/^\s*(?:```|~~~|>)/.test(after);
}
const verdictFor = (ok: boolean, sessionKind?: string): RealityVerdict => (ok ? 'complete' : sessionKind === 'unattended' ? 'incomplete' : 'repair');
export interface CheckOptions { phase: string; scopes?: readonly string[]; sessionKind?: string }
export interface CheckOptions { phase: string; scopes?: readonly string[]; sessionKind?: string;
/** The reviewed plan snapshot: a number inside a span quoted verbatim from it needs no receipt of its own. */
planText?: string }
/** Deterministic: which required rows the review carries. The verdict word depends on the session kind only. */
export function checkRealityRows(reviewText: string, o: CheckOptions): RealityCheck {
@@ -267,7 +277,7 @@ export function checkRealityRows(reviewText: string, o: CheckOptions): RealityCh
if (metric && l.metrics[metric] !== undefined) metrics[metric] = l.metrics[metric]!;
}
const rows = lines.filter(l => rowById(l.id) && l.status !== 'n/a');
const numbers = rows.flatMap(l => unreceiptedNumbers(l.rest));
const numbers = rows.flatMap(l => unreceiptedNumbers(l.rest, o.planText));
const rawLines = reviewText.split('\n');
const unquoted = rows.filter(l => ruleUnquoted(rawLines, l.line - 1)).map(l => rawLines[l.line - 1]!.trim());
const verdict = verdictFor(missing.length === 0 && unreceipted.length === 0 && numbers.length === 0 && unquoted.length === 0, o.sessionKind);
@@ -283,7 +293,7 @@ export function checkRealityRows(reviewText: string, o: CheckOptions): RealityCh
}
/** Bumped whenever checkPlanReality's rules change, so a cached verdict keyed on the plan hash is recomputed. */
export const PLAN_REALITY_CHECKER = 'plan-reality/2';
export const PLAN_REALITY_CHECKER = 'plan-reality/3';
export interface PlanRealityCheck { checker: string; unreceipted_numbers: string[]; unquoted: string[]; numberLines: string[]; verdict: RealityVerdict; line: string }
/**
@@ -299,13 +309,40 @@ export function checkPlanReality(planText: string, o: { sessionKind?: string } =
if (delimiter && (!fence || delimiter.startsWith(fence))) { fence = fence ? null : delimiter; return; }
if (fence || /^\s*<!--/.test(line)) return;
numbers.push(...unreceiptedNumbers(line));
if (ruleUnquoted(lines, i)) unquoted.push(line.trim());
if (ruleUnquoted(lines, i, 'plan')) unquoted.push(line.trim());
});
const verdict = verdictFor(numbers.length === 0 && unquoted.length === 0, o.sessionKind);
return { checker: PLAN_REALITY_CHECKER, unreceipted_numbers: numbers, unquoted, numberLines: numbers.map(c => `NUMBER_UNRECEIPTED: ${c}`), verdict,
line: `PLAN_REALITY: scope=plan unreceipted_numbers=${numbers.length} unquoted=${unquoted.length} verdict=${verdict}` };
}
const VAGUE_RE = /\b(minimum (?:detectable )?effect|margins?|decision rule|dollar cost|cost in dollars|USD cost)\b/i;
const DETAIL_AFTER_RE = /^\s*(?:[-*+]\s|\d+[.)]\s|\||```|~~~|>)/;
/**
* PLAN_REALITY flags for the gate (advisory, never blocking): a plan line naming a minimum effect, a margin, a
* decision rule or a dollar cost with no number on it (item references and phrases in backticks do not count)
* and no arm list, table, quoted rule or block right after it. `line` is 1-based in `planText`.
*/
export function vaguePlanTerms(planText: string): Array<{ phrase: string; line: number; text: string }> {
const lines = planText.split('\n');
const out: Array<{ phrase: string; line: number; text: string }> = [];
let fence: string | null = null;
lines.forEach((raw, i) => {
const delimiter = /^\s{0,3}(`{3,}|~{3,})/.exec(raw)?.[1];
if (delimiter && (!fence || delimiter.startsWith(fence))) { fence = fence ? null : delimiter; return; }
if (fence || /^\s*<!--/.test(raw)) return;
const prose = raw.replace(BACKTICK_SPAN_RE, ' ');
const m = VAGUE_RE.exec(prose);
if (!m) return;
if (/\d/.test(prose.replace(NOT_A_QUANTITY_RE, ' ')) || QUOTED_RULE_RE.test(prose)) return;
const after = lines.slice(i + 1).find(l => l.trim());
if (after && DETAIL_AFTER_RE.test(after)) return;
out.push({ phrase: m[1]!.toLowerCase(), line: i + 1, text: raw.trim() });
});
return out;
}
/** The example line every template quotes; one grammar, stated once. */
export const REALITY_LINE_SHAPE = 'REALITY: <row> pass|finding|n/a <summary> <file:line>';
+32 -2
View File
@@ -112,7 +112,7 @@ export const RESULT_CODES = {
GATE_REPLY_UNPARSED: {
anchor: 'gate-reply-unparsed',
summary: 'part of the reply matched no gate item or option, so nothing was applied',
fix: 'reply with `all`, `all as-is`, `<id><option>` tokens (for example `d3b uc1a`), or `all except <tokens>`; bare yes/no is ambiguous',
fix: 'reply with `rec`, `as-is`, `<id><option>` tokens (for example `d3b uc1a`), or `rec except <tokens>`; bare yes/no is ambiguous',
},
ANSWER_BY_REQUIRED: {
anchor: 'answer-by-required',
@@ -509,13 +509,43 @@ export const RESULT_CODES = {
PLAN_AMEND_PENDING: {
anchor: 'plan-amend-pending',
summary: 'the owner accepted plan changes at the gate that the plan has not absorbed yet',
fix: 'edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id,...>`; or drop one with `gstack-autoplan revise --out <dir> --withdraw <decision id>`',
fix: 'edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id,...>`; settle a packaging or ordering change with `gstack-autoplan revise --out <dir> --settle <id>`; or drop one with `gstack-autoplan revise --out <dir> --withdraw <decision id>`',
},
AMENDMENT_UNKNOWN: {
anchor: 'amendment-unknown',
summary: '`revise --incorporates` or `--withdraw` named a decision that is not a pending amendment (never accepted as a plan change, or already incorporated or withdrawn)',
fix: 'name only the ids the refusal lists after `pending:`; with none pending, drop --incorporates and run `gstack-autoplan revise --out <dir> --plan <file>`',
},
AMENDMENT_NOT_SETTLEABLE: {
anchor: 'amendment-not-settleable',
summary: '`revise --settle` named an accepted change that needs a plan edit (its item changes scope, cost or direction, or names no packaging/ordering change), or the reviewed plan changed since the gate',
fix: 'edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id>`; settle only packaging or ordering changes on an unchanged plan',
},
GATE_REC_STALE: {
anchor: 'gate-rec-stale',
summary: 'a `rec` reply was rendered before `gstack-autoplan recommend` changed the recommendations it takes',
fix: 'render the gate again (`gstack-gate render <dir>/gate.json --all`), relay it, and answer with the printed `--rec-hash`',
},
CHECKPOINT_INVALID: {
anchor: 'checkpoint-invalid',
summary: 'the autoplan checkpoint cannot be restored: its manifest is unreadable, a listed file is missing, escapes the checkpoint or does not match its sha256',
fix: 'restore from an intact copy of `<GSTACK_AUTOPLAN_CHECKPOINT_DIR>/<run>/` (its `restore.md` names the command); nothing was written to --out',
},
CHECKPOINT_TARGET_EXISTS: {
anchor: 'checkpoint-target-exists',
summary: '`gstack-autoplan resume --from` restores only into an empty or missing --out directory',
fix: 'pass a new --out directory, or resume the run already there without --from',
},
OWNER_REPLY_REQUIRED: {
anchor: 'owner-reply-required',
summary: 'an agent answered the gate (`--by agent:<id>`) without the owner\'s verbatim reply',
fix: 'pass the owner\'s words exactly with `--owner-reply "<text>"`, `--owner-reply -` (stdin) or `--owner-reply-file <path>`',
},
GATE_OWNER_REPLY_MISMATCH: {
anchor: 'gate-owner-reply-mismatch',
summary: 'the owner\'s verbatim reply parses under the gate grammar but resolves to different choices than the relayed tokens',
fix: 'relay exactly what the owner chose (or their reply itself, e.g. `--reply rec`); ask the owner when their words and the tokens disagree',
},
GSTACK_VERSION_CHANGED: {
anchor: 'gstack-version-changed',
summary: 'the run directory was written by a different gstack version',
+9 -6
View File
@@ -22,24 +22,27 @@ export const AUTOPLAN_DRIVER_COMMANDS: Array<{ cmd: string; does: string }> = [
{ cmd: 'gstack-autoplan submit ... --record <file.md> --attest "<reason>" --attempt <id>', does: 'bind a record with no INPUT: line as receipt: attested (a wrong INPUT: always refuses)' },
{ cmd: 'gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --result <file> --model <id> --attempt <id>', does: 'bind a canonical result (the RESULT FORMAT below)' },
{ cmd: 'gstack-autoplan submit --out <dir> --phase <p> --voice outside --unavailable|--failed "<reason>"', does: 'close the phase native-only when no outside voice can run' },
{ cmd: 'gstack-autoplan answer --out <dir> --gate-rev <n> --reply "<reply>"', does: 'answer the gate with the reply grammar below' },
{ cmd: 'gstack-autoplan answer --out <dir> --gate-rev <n> --reply "<reply>" --by human|agent:<id> [--owner-reply <text>|-] [--rec-hash <h>]', does: 'answer the gate with the reply grammar below; an agent answer stores the owner\'s words verbatim' },
{ cmd: 'gstack-autoplan recommend --out <dir> --gate-rev <n> --item <id> --option <k> --basis "<why>"', does: 'change one recommendation before relay; then render and relay again (a `rec` needs the new `--rec-hash`)' },
{ cmd: 'gstack-autoplan revise --out <dir> --plan <plan v2> --incorporates <decision ids>', does: 'bind the amended plan; reopens every phase whose reviewed plan changed, Eng last' },
{ cmd: 'gstack-autoplan revise --out <dir> --settle <decision id|d4>', does: 'discharge a packaging or ordering change with no plan edit; reopens nothing' },
{ cmd: 'gstack-autoplan revise --out <dir> --withdraw <decision id>', does: 'drop a pending amendment instead of applying it' },
{ cmd: 'gstack-autoplan export --out <dir>', does: 'write the artifact set; then `gstack-artifact validate <dir>/run.json`' },
{ cmd: 'gstack-autoplan status|resume --out <dir>', does: 'read the manifest; take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts)' },
{ cmd: 'gstack-autoplan status|resume --out <dir>', does: 'read the manifest (PENDING: lines name open attempts); take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts)' },
{ cmd: 'gstack-autoplan resume --out <new dir> --from <checkpoint>', does: 'restore a hash-verified checkpoint (GSTACK_AUTOPLAN_CHECKPOINT_DIR) into an empty directory and take ownership' },
];
/** The reply grammar `gstack-gate` parses, one form per line. */
export const GATE_REPLY_FORMS: Array<{ reply: string; means: string }> = [
{ reply: 'all', means: 'every item takes its recommendation (an agreed change prints APPLY: and needs revise)' },
{ reply: 'all as-is', means: 'keep every direction; no plan change' },
{ reply: 'rec', means: 'every item takes its recommendation, plan changes included (each prints APPLY:); `all` is an alias' },
{ reply: 'as-is', means: 'keep every direction; no plan change' },
{ reply: 'd3b uc1a', means: 'one <id><option> token per item you set; unanswered auto items take their default' },
{ reply: 'all except d3b', means: 'recommendations, with the listed overrides' },
{ reply: 'rec except d3b', means: 'recommendations, with the listed overrides' },
];
/** Result codes the loop can print, in the order a run meets them. */
export const DRIVER_CODES = ['RUN_NOT_INITIALIZED', 'PLAN_SECTIONS_INVALID', 'RUN_LOCKED', 'EXECUTION_UNKNOWN', 'SPEND_CAP_EXCEEDED', 'PHASE_NOT_AWAITING', 'ATTEMPT_MISMATCH',
'RESULT_RECEIPT_MISSING', 'RESULT_FINDINGS_MISSING', 'MODEL_FAMILY_CONFLICT', 'CONSENSUS_MISSING', 'GATE_REV_STALE', 'GATE_REPLY_UNPARSED', 'PLAN_AMEND_PENDING', 'GSTACK_VERSION_CHANGED'] as const;
'RESULT_RECEIPT_MISSING', 'RESULT_FINDINGS_MISSING', 'MODEL_FAMILY_CONFLICT', 'CONSENSUS_MISSING', 'GATE_REV_STALE', 'GATE_REPLY_UNPARSED', 'GATE_REC_STALE', 'OWNER_REPLY_REQUIRED', 'GATE_OWNER_REPLY_MISMATCH', 'PLAN_AMEND_PENDING', 'AMENDMENT_NOT_SETTLEABLE', 'CHECKPOINT_INVALID', 'GSTACK_VERSION_CHANGED'] as const;
/** Fix text for codes the plan specifies and lane B-core adds to lib/result-codes.ts; the table's own fix wins once it exists. */
const PENDING_FIX: Record<string, string> = {
PLAN_SECTIONS_INVALID: 'the plan needs one `## Implementation plan` and one `## Review record`; rerun `next --scaffold` to wrap a plan that has neither',
+1 -1
View File
@@ -52,7 +52,7 @@ in an unattended run, never a warning; interactive runs follow the repair rule a
}
if (mode === 'autoplan') {
return `Reality rows: the loaded skill's \`REALITY:\` lines, one per applicable row;
\`${BIN} check --phase ${args?.[1] ?? 'ceo'} <review-file|->\` prints \`PLAN_REALITY: … verdict=complete\` before the phase closes
\`${BIN} check --phase ${args?.[1] ?? 'ceo'} --plan <reviewed snapshot> <review-file|->\` prints \`PLAN_REALITY: … verdict=complete\` before the phase closes
(unattended: \`incomplete\` keeps it open).`;
}
const phase = phaseFor(ctx, mode);
+21 -16
View File
@@ -13,7 +13,7 @@ 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 { attemptsOf, autoplan, journal, readRun, review, ROOT, runPhase, SMALL_PLAN, startSmallRun } from './helpers/autoplan-run-fixture';
import { attemptsOf, autoplan, journal, readRun, resultOf, review, ROOT, runPhase, SMALL_PLAN, startSmallRun } from './helpers/autoplan-run-fixture';
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-answer-'));
afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
@@ -31,8 +31,8 @@ function gatedRun(name: string): string {
review(path.join(out, 'ceo-native-prompt.md'));
review(path.join(out, 'ceo-outside-prompt.md'), { FIXTURE_FINDINGS: disagree });
const ids = attemptsOf(out, 'ceo');
expect(autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]).status).toBe(0);
const close = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', path.join(out, 'ceo-outside.md'), '--model', 'gpt-5.5', '--attempt', ids.outside]);
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/);
const eng = runPhase(out, 'eng');
expect(eng.outside.stdout).toContain('status=gate_pending');
@@ -112,26 +112,31 @@ describe('answer', () => {
expect(readRun(out).attempt_counter).toBe(counter);
});
test('a non-recommended choice on a ceo item reopens ceo and eng only, archives their bound files, and the next gate is gate_rev 2', () => {
test('a non-recommended choice on a ceo item is recorded without reopening; revise reopens the changed phases, archives their bound files, and the next gate is gate_rev 2', () => {
const out = gatedRun('reopen');
const r = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '1', '--reply', 'all except d1b', '--json']);
expect(r.status, r.stderr).toBe(0);
const runId = readRun(out).run;
expect(JSON.parse(r.stdout)).toMatchObject({ status: 'running', outcome: 'reopened', reopened: ['ceo', 'eng'], amendments: [`${runId}-d1`] });
expect(JSON.parse(r.stdout)).toMatchObject({ status: 'amend_pending', outcome: 'amend_pending', reopened: [], amendments: [`${runId}-d1`] });
expect(readRun(out).phases.ceo.closed_at).toBeTruthy();
expect(journal(out).filter(j => j.event === 'reopened')).toEqual([]);
// the accepted change is applied to the plan and named; revise reopens the phases that reviewed the old plan, once
const revised = incorporate(out, [`${runId}-d1`], 'Gate reuse also checks the lockfile hash.');
expect(revised.stdout).toMatch(/^REVISED: plan=\S+ reopened=ceo,eng$/m);
const run = readRun(out);
expect(run.phases.ceo).toEqual({});
expect(run.phases.eng).toEqual({});
expect(run.phases.design.skipped).toBeTruthy();
expect(run.history.map((h: any) => [h.phase, h.gate_rev])).toEqual([['ceo', 1], ['eng', 1]]);
for (const f of ['ceo-native.r1.md', 'ceo-outside.r1.md', 'ceo-consensus.r1.json', 'eng-native.r1.md', 'gate.r1.json']) expect(fs.existsSync(path.join(out, f))).toBe(true);
for (const f of ['ceo-native.md', 'eng-consensus.md', 'gate.json']) expect(fs.existsSync(path.join(out, f))).toBe(false);
expect(Object.keys(run.artifacts)).not.toContain('ceo-native.md');
expect(run.status).toBe('running');
for (const f of ['ceo-native-prompt.r1.md', 'ceo-outside-prompt.r1.md', 'ceo-consensus.r1.json', 'eng-native-prompt.r1.md', 'gate.r1.json']) expect(fs.existsSync(path.join(out, f))).toBe(true);
for (const f of ['ceo-native-prompt.md', 'eng-consensus.md', 'gate.json']) expect(fs.existsSync(path.join(out, f))).toBe(false);
// sealed copies are attempt-owned: they stay in place and bound across the reopen
const sealed = run.history.flatMap((h: any) => ['native', 'outside'].map(v => h.previous[v].output));
expect(sealed.every((o: string) => /\.a\d+\.bound\.md$/.test(o) && fs.existsSync(path.join(out, o)) && run.artifacts[o])).toBe(true);
const d1 = decisions(out).find((d: any) => d.label === 'D1');
expect(d1).toMatchObject({ status: 'overridden', chosen: 'b', gate_rev: 1 });
expect(journal(out).filter(j => j.event === 'reopened').map(j => j.phase)).toEqual(['ceo', 'eng']);
// the accepted change is applied to the plan and named before any phase reruns; the reopened phases stay reopened once
incorporate(out, [`${runId}-d1`], 'Gate reuse also checks the lockfile hash.');
expect(journal(out).filter(j => j.event === 'reopened').map(j => j.phase)).toEqual(['ceo', 'eng']);
// the loop continues: ceo first, then eng, then a new gate at gate_rev 2 whose rows join the old ones
const ceo = runPhase(out, 'ceo');
expect(ceo.next.stdout).toMatch(/^PHASE: ceo /m);
@@ -160,16 +165,16 @@ describe('answer', () => {
expect(valid.status).toBe(0);
});
test('an eng-only override reopens eng alone; p1d reopens every phase; p1c keeps the gate; p1e ends incomplete', () => {
test('an eng-only override reopens nothing at answer; revise reruns every phase that reviewed the old plan; p1d reopens every phase; p1c keeps the gate; p1e ends incomplete', () => {
const out = gatedRun('eng-only');
const engOnly = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '1', '--reply', 'all except uc2b', '--json']);
const runId = readRun(out).run;
expect(JSON.parse(engOnly.stdout)).toMatchObject({ outcome: 'reopened', reopened: ['eng'], amendments: [`${runId}-uc2`] });
expect(JSON.parse(engOnly.stdout)).toMatchObject({ outcome: 'amend_pending', reopened: [], amendments: [`${runId}-uc2`] });
expect(readRun(out).phases.ceo.closed_at).toBeTruthy();
// the revised plan changes the hash ceo reviewed, so revise (not the answer) reruns ceo on v2 before eng
// the revised plan changes the hash both phases reviewed, so revise (not the answer) reruns ceo, then eng, on v2
const revised = incorporate(out, [`${runId}-uc2`], 'Retries stop at the dead-letter queue.');
expect(revised.stdout).toMatch(/^REVISED: plan=\S+ reopened=ceo$/m);
expect(journal(out).filter(j => j.event === 'reopened').map(j => [j.phase, j.reason ?? null])).toEqual([['eng', expect.anything()], ['ceo', expect.stringContaining('revise')]]);
expect(revised.stdout).toMatch(/^REVISED: plan=\S+ reopened=ceo,eng$/m);
expect(journal(out).filter(j => j.event === 'reopened').map(j => [j.phase, j.reason ?? null])).toEqual([['ceo', expect.stringContaining('revise')], ['eng', expect.stringContaining('revise')]]);
expect(runPhase(out, 'ceo').outside.stdout).toMatch(/PHASE_CLOSED: ceo/);
const eng = runPhase(out, 'eng');
expect(eng.outside.stdout).toContain('status=gate_pending');
+5 -5
View File
@@ -12,7 +12,7 @@ import { afterAll, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { RESULT_CODES } from '../lib/result-codes';
import { ROOT, SMALL_PLAN } from './helpers/autoplan-run-fixture';
import { ROOT, SMALL_PLAN, boundOf, resultOf } from './helpers/autoplan-run-fixture';
import { capyRunner, phaseVia, readJsonl, readRunJson, writePlan, type CapyRunner } from './helpers/autoplan-capy';
const r = capyRunner();
@@ -138,14 +138,14 @@ describe('conformance: one run through every mechanism', () => {
const run = readRunJson(s.out).run;
// ceo: native canonical (fixture), outside attested record
ok(c.review(path.join(s.out, 'ceo-native-prompt.md')));
ok(c.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(s.out, 'ceo-native.md'), '--model', 'anthropic/claude-opus-5-5']));
ok(c.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(s.out, 'ceo', 'native'), '--model', 'anthropic/claude-opus-5-5']));
const hand = path.join(s.dir, 'ceo-outside-hand.md');
fs.writeFileSync(hand, '| Severity | Title |\n|---|---|\n| Medium | Retry storage cost is unpriced |\n');
ok(c.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', 'outside', '--record', hand, '--model', 'openai/gpt-6.1-sol', '--attest', 'Capy subagent', '--attempt', readRunJson(s.out).phases.ceo.outside.attempt]));
// eng: native challenge with changes, outside unavailable
ok(c.ap(['next', '--out', s.out]));
ok(c.review(path.join(s.out, 'eng-native-prompt.md'), { FIXTURE_FINDINGS: JSON.stringify([{ id: 'F1', severity: 'High', title: 'Gate the backoff behind a flag', user_challenge: true, changes: 'scope', fix: 'RETRY_BACKOFF=1' }]) }));
ok(c.ap(['submit', '--out', s.out, '--phase', 'eng', '--voice', 'native', '--result', path.join(s.out, 'eng-native.md'), '--model', 'anthropic/claude-opus-5-5']));
ok(c.ap(['submit', '--out', s.out, '--phase', 'eng', '--voice', 'native', '--result', resultOf(s.out, 'eng', 'native'), '--model', 'anthropic/claude-opus-5-5']));
expect(ok(c.ap(['submit', '--out', s.out, '--phase', 'eng', '--voice', 'outside', '--unavailable', 'no outside runner'])).stdout).toContain('status=gate_pending');
const accept = JSON.parse(fs.readFileSync(path.join(s.out, 'gate.json'), 'utf8')).items.find((i: any) => i.title.startsWith('Gate the backoff')).id;
// partial, then full
@@ -166,7 +166,7 @@ describe('conformance: one run through every mechanism', () => {
const unknown = [...resumed.stdout.matchAll(/^EXECUTION_UNKNOWN: (\S+)$/gm)].map(m => m[1]!);
ok(c.ap(['next', '--out', s.out, ...unknown.flatMap(a => ['--redispatch', a])]));
for (const voice of ['native', 'outside']) ok(c.review(path.join(s.out, `ceo-${voice}-prompt.md`)));
for (const voice of ['native', 'outside']) ok(c.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', voice, '--result', path.join(s.out, `ceo-${voice}.md`), '--model', voice === 'native' ? 'anthropic/claude-opus-5-5' : 'openai/gpt-6.1-sol']));
for (const voice of ['native', 'outside']) ok(c.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', voice, '--result', resultOf(s.out, 'ceo', voice as 'native' | 'outside'), '--model', voice === 'native' ? 'anthropic/claude-opus-5-5' : 'openai/gpt-6.1-sol']));
phaseVia(c, s.out, 'eng');
expect(readRunJson(s.out).gate_rev).toBe(2);
ok(c.ap(['answer', '--by', 'human', '--out', s.out, '--gate-rev', '2', '--reply', 'all except p1d']));
@@ -181,6 +181,6 @@ describe('conformance: one run through every mechanism', () => {
expect(final.gate_rev).toBe(3);
expectUniqueIds(s.out);
expectReviewedHashes(s.out);
expect(fs.readFileSync(path.join(s.out, `ceo-native.md`), 'utf8')).toContain(final.phases.eng.snapshot);
expect(fs.readFileSync(boundOf(s.out, 'ceo', 'native'), 'utf8')).toContain(final.phases.eng.snapshot);
});
});
+90
View File
@@ -0,0 +1,90 @@
/**
* Durable checkpoint (next-wave B12): with GSTACK_AUTOPLAN_CHECKPOINT_DIR set,
* a run at rest is copied, hash-listed, to <dir>/<run>/; journal paths are
* run-relative, so `resume --from` restores the run on a new directory (even
* with the owner's plan file gone), takes ownership, and the run answers and
* exports a directory `gstack-artifact validate` accepts. A tampered,
* escaping or misplaced restore writes nothing.
*/
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';
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 });
function checkpointed(name: string): { dir: string; out: string; cp: string; run: string; e: Record<string, string> } {
const dir = path.join(TMP, name);
const cpRoot = path.join(dir, 'drive');
const e = env({ GSTACK_AUTOPLAN_CHECKPOINT_DIR: cpRoot });
const out = startSmallRun(dir);
runPhase(out, 'ceo');
const next = autoplan(['next', '--out', out], { env: e });
expect(next.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 };
}
describe('checkpoint and restore', () => {
test('write at gate_pending, delete the run and the plan file, restore, resume, answer 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 });
expect(Object.keys(manifest.files)).toEqual(expect.arrayContaining(['run.json', 'attempts.jsonl', 'gate.json', 'decisions.jsonl']));
expect(Object.keys(manifest.files)).not.toContain('run.lock');
expect(fs.readFileSync(path.join(c.cp, 'restore.md'), 'utf8')).toContain(`gstack-autoplan resume --out <new run dir> --from ${c.cp}`);
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 });
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(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);
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', () => {
const c = checkpointed('refusals');
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);
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);
});
});
+1 -1
View File
@@ -42,7 +42,7 @@ describe('autoplan/DRIVER.md content comes from the code', () => {
test('the plan-specified commands and the amendment interlock are present', () => {
for (const token of ['submit --out <dir> --phase <p> --voice native|outside --record', '--attest "<reason>"', '--unavailable|--failed',
'revise --out <dir> --plan <plan v2> --incorporates <decision ids>', '--withdraw <decision id>', '[--scaffold]', '`--no-dx`', '`all as-is`', 'PLAN_AMEND_PENDING', 'APPLY:']) {
'revise --out <dir> --plan <plan v2> --incorporates <decision ids>', '--withdraw <decision id>', '[--scaffold]', '`--no-dx`', '`as-is`', '`rec`', 'render <dir>/gate.json --all', '--settle <decision id|d4>', '--owner-reply', '--from <checkpoint>', 'PLAN_AMEND_PENDING', 'APPLY:']) {
expect(doc).toContain(token);
}
});
+139
View File
@@ -0,0 +1,139 @@
/**
* Next wave, Lane B artifacts (GBRA-40 items 7, 11, 14, 15, 18):
* B3 an empty gstack-findings fence (LF, CRLF, whitespace-only) is zero findings, not a missing fence;
* an opener without a closer and invalid rows are still refused.
* B7 canonical rows carry repo-relative paths; snapshot citations become the plan file and its source line.
* B8 `status` lists every open attempt with its result file.
* B9 `gstack-gate render --all` prints every item; paged output tells the relay to send every page.
* B6 a plan naming a minimum effect with no number gets a flagged PLAN_REALITY line under the plan approval item.
* B11 an outside voice submitted without --runner is labeled host-subagent in the consensus header.
*/
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 { normalizeFindingFile } from '../lib/autoplan-plan-text';
import { parseRecordFindings, parseReviewResult } from '../lib/autoplan-reconcile';
import { renderGate, type GateList } from '../lib/gate-list';
import { attemptsOf, autoplan, readRun, review, ROOT, runPhase, SMALL_PLAN, startSmallRun } from './helpers/autoplan-run-fixture';
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-gate-artifacts-'));
afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
const SHA = 'a'.repeat(64);
const GATE = path.join(ROOT, 'bin', 'gstack-gate');
describe('B3: empty findings fence', () => {
const head = `INPUT: ceo ${SHA}\n\n## review\n\n`;
test('LF, CRLF and whitespace-only empty fences parse as zero findings in both grammars', () => {
for (const text of [`${head}\`\`\`gstack-findings\n\`\`\`\n`, `${head}\`\`\`gstack-findings\n\`\`\`\n`.replace(/\n/g, '\r\n'), `${head}\`\`\`gstack-findings\n \n\`\`\`\n`]) {
const r = parseReviewResult(text, { phase: 'ceo', sha256: SHA });
expect(r.fenceFound).toBe(true);
expect(r.errors).toEqual([]);
expect(r.findings).toEqual([]);
expect(parseRecordFindings(text)).toEqual({ findings: [], source: 'fence', errors: [] });
}
});
test('an opener without a closer is no fence; an invalid row is refused; two fences still parse', () => {
expect(parseReviewResult(`${head}\`\`\`gstack-findings\n{"severity":"High","title":"x"}\n`, { phase: 'ceo', sha256: SHA }).errors).toContain('no ```gstack-findings fence');
expect(parseReviewResult(`${head}\`\`\`gstack-findings\n{"severity":"Bogus","title":"x"}\n\`\`\`\n`, { phase: 'ceo', sha256: SHA }).errors[0]).toMatch(/severity "Bogus"/);
const two = parseReviewResult(`${head}\`\`\`gstack-findings\n{"severity":"High","title":"a"}\n\`\`\`\n\n\`\`\`gstack-findings\n\`\`\`\n\n\`\`\`gstack-findings\n{"severity":"Low","title":"b"}\n\`\`\`\n`, { phase: 'ceo', sha256: SHA });
expect(two.errors).toEqual([]);
expect(two.findings.map(f => f.title)).toEqual(['a', 'b']);
});
});
describe('B7: repo-relative paths and plan lines', () => {
test('absolute repo paths become relative; snapshot and active-plan citations map to the plan source line or drop the line', () => {
const repo = path.join(TMP, 'repo');
fs.mkdirSync(path.join(repo, '.git'), { recursive: true });
fs.mkdirSync(path.join(repo, 'src'), { recursive: true });
const source = path.join(TMP, 'plan-source.md');
fs.writeFileSync(source, '# Title\n\n## Implementation plan\n\nline A\nline B\nline C\n\n## Review record\n');
const snapDir = path.join(TMP, 'autoplan-ceo-Xy12');
fs.mkdirSync(snapDir, { recursive: true });
const snapshot = path.join(snapDir, 'ceo-implementation.md');
fs.writeFileSync(snapshot, '\nline A\nline B\nline C\n\n');
const active = path.join(TMP, 'plan-active.md');
fs.writeFileSync(active, `<!-- /autoplan restore point: "${TMP}/restore.md" -->\n${fs.readFileSync(source, 'utf8')}`);
const stray = path.join(TMP, 'other-snapshot.md');
fs.writeFileSync(stray, 'unrelated\n');
const ctx = { cwd: path.join(repo, 'src'), planFiles: [snapshot, active, stray], planSource: source, planName: 'plan.md' };
expect(normalizeFindingFile({ file: path.join(repo, 'src', 'worker.ts'), line: 9 }, ctx)).toEqual({ file: 'src/worker.ts', line: 9 });
expect(normalizeFindingFile({ file: snapshot, line: 3 }, ctx)).toEqual({ file: 'plan.md', line: 6 });
expect(normalizeFindingFile({ file: 'ceo-implementation.md', line: 2 }, ctx)).toEqual({ file: 'plan.md', line: 5 });
expect(normalizeFindingFile({ file: active, line: 6 }, ctx)).toEqual({ file: 'plan.md', line: 5 });
expect(normalizeFindingFile({ file: stray, line: 1 }, ctx)).toEqual({ file: 'plan.md' });
expect(normalizeFindingFile({ file: '/elsewhere/x.ts', line: 1 }, ctx)).toEqual({ file: '/elsewhere/x.ts', line: 1 });
expect(normalizeFindingFile({ file: 'lib/x.ts', line: 4 }, ctx)).toEqual({ file: 'lib/x.ts', line: 4 });
});
test('end to end: a reviewer citing the snapshot and an absolute repo path binds repo-relative rows; the bound copy stays verbatim', () => {
const out = startSmallRun(path.join(TMP, 'paths'));
expect(autoplan(['next', '--out', out]).status).toBe(0);
const snap = readRun(out).phases.ceo.snapshot_path as string;
const findings = JSON.stringify([{ severity: 'High', title: 'cap unexplained', file: snap, line: 2 }, { severity: 'Low', title: 'abs path', file: path.join(ROOT, 'lib', 'gate-list.ts'), line: 3 }]);
review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_FINDINGS: findings });
const ids = attemptsOf(out, 'ceo');
const result = readRun(out).phases.ceo.native.attempts[0].result as string;
const bind = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', result, '--model', 'anthropic/claude-opus-5-5', '--attempt', ids.native]);
expect(bind.status, bind.stderr).toBe(0);
const output = readRun(out).phases.ceo.native.output as string;
const rows = fs.readFileSync(path.join(out, `${output}.findings.jsonl`), 'utf8').trim().split('\n').map(l => JSON.parse(l));
expect(rows.map((r: any) => [r.file, r.line])).toEqual([['plan.md', 5], ['lib/gate-list.ts', 3]]);
expect(fs.readFileSync(path.join(out, output), 'utf8')).toContain(snap);
});
});
describe('B8/B11: pending attempts in status; outside voice labeled host-subagent', () => {
test('status prints PENDING per open attempt; an outside submit without --runner reads model (host-subagent)', () => {
const out = startSmallRun(path.join(TMP, 'pending'));
expect(autoplan(['next', '--out', out]).status).toBe(0);
const ids = attemptsOf(out, 'ceo');
const run = readRun(out);
const status = autoplan(['status', '--out', out]);
expect(status.stdout).toContain(`PENDING: ${ids.native} phase=ceo voice=native result=${run.phases.ceo.native.attempts[0].result}`);
expect(status.stdout).toContain(`PENDING: ${ids.outside} phase=ceo voice=outside result=${run.phases.ceo.outside.attempts[0].result}`);
for (const voice of ['native', 'outside']) review(path.join(out, `ceo-${voice}-prompt.md`));
expect(autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', run.phases.ceo.native.attempts[0].result, '--model', 'anthropic/claude-opus-5-5']).status).toBe(0);
expect(autoplan(['status', '--out', out]).stdout).not.toContain(`PENDING: ${ids.native}`);
expect(autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', run.phases.ceo.outside.attempts[0].result, '--model', 'openai/gpt-6.1-sol']).status).toBe(0);
expect(readRun(out).phases.ceo.outside.provider).toBe('host-subagent');
expect(fs.readFileSync(path.join(out, 'ceo-consensus.md'), 'utf8')).toContain('openai/gpt-6.1-sol(host-subagent)');
expect(autoplan(['status', '--out', out]).stdout).not.toContain('PENDING:');
});
});
describe('B9: every gate page reaches the owner', () => {
const item = (i: number) => ({ id: `d${i}`, title: `finding ${i}`, kind: 'auto' as const, recommended: 'a', options: [{ key: 'a', text: 'keep' }, { key: 'b', text: 'change' }] });
const list: GateList = { schema_version: 1, run: 'r', gate_rev: 1, items: [...Array.from({ length: 14 }, (_, i) => item(i + 1)), { id: 'p1', title: 'Plan approval', kind: 'approval', recommended: 'a', options: [{ key: 'a', text: 'approve' }, { key: 'b', text: 'other' }] }] };
test('14 findings span three pages; --all prints every id once and paged output says to relay every page', () => {
expect(renderGate(list)).toContain('or every page with `--all`. Relay every page.');
const file = path.join(TMP, 'big-gate.json');
fs.writeFileSync(file, JSON.stringify(list));
const r = spawnSync('bun', [GATE, 'render', file, '--all'], { encoding: 'utf8', timeout: 30_000 });
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).toContain('page=1/1');
for (const i of list.items) expect(r.stdout.match(new RegExp(`\\[${i.id}\\]`, 'g'))?.length).toBe(1);
const pages = [1, 2, 3].map(p => renderGate(list, p)).join('\n');
for (const i of list.items) expect(pages).toContain(`[${i.id}]`);
expect(spawnSync('bun', [GATE, 'render', file, '--all', '--page', '2'], { encoding: 'utf8', timeout: 30_000 }).status).toBe(2);
});
});
describe('B6: PLAN_REALITY flag beside the approval item', () => {
test('the plan names a minimum effect with no number: gate.json p1 carries the flag and the render prints it; nothing blocks', () => {
const dir = path.join(TMP, 'vague');
fs.mkdirSync(dir, { recursive: true });
const plan = path.join(dir, 'plan.md');
fs.writeFileSync(plan, SMALL_PLAN.replace('Tests cover the cap', 'Ship once the minimum effect shows in the retry metrics. Tests cover the cap'));
const out = path.join(dir, 'run');
expect(autoplan(['next', '--out', out, '--plan', plan, '--no-ui']).status).toBe(0);
for (const phase of ['ceo', 'eng']) { const r = runPhase(out, phase); expect(r.outside.status, r.outside.stderr).toBe(0); }
const gate = JSON.parse(fs.readFileSync(path.join(out, 'gate.json'), 'utf8')) as GateList;
const p1 = gate.items.find(i => i.id === 'p1')!;
expect(p1.flags).toEqual([expect.stringMatching(/^PLAN_REALITY "minimum effect" has no number, arm list or rule body \(plan\.md:6: .*minimum effect.*\) \(report only\)$/)]);
expect(renderGate(gate)).toMatch(/\n flagged: PLAN_REALITY "minimum effect"/);
expect(readRun(out).status).toBe('gate_pending');
});
});
+100 -56
View File
@@ -1,24 +1,23 @@
/**
* Gate judgment (plan B2a/B2b, items 8 and 10, with the B-core amendments):
* findings carry `changes`; a user challenge both voices raised on a confirmed
* finding with the same `changes` and both remedies recommends `b`, rendering
* both remedies verbatim and the basis; anything weaker keeps `a` with its
* basis. Reopen and APPLY key on the chosen option's `changes_plan`; accepted
* changes are pending amendments that `next` refuses past (PLAN_AMEND_PENDING)
* until `revise --incorporates` binds them to a changed plan or `--withdraw`
* drops them. `all as-is` keeps every direction; `all` leaves an item
* recommended `b` pending (the owner's own token changes their direction;
* field gate rev 1, d3) and the GATE_PENDING line names the tokens. Partial
* replies resolve
* against persisted decisions. On reruns a challenge without `changes` is an
* agent decision that reopens nothing, rerun prompts carry the decided items,
* and answered items from phases that did not rerun carry forward.
* Gate judgment (plan B2a/B2b, items 8 and 10, with the B-core amendments and
* next-wave B1/B2/B5): findings carry `changes`; a user challenge both voices
* raised on a confirmed finding with the same `changes` recommends `b` only
* with one shared proposal (the parent's override, or the same remedy from
* both), rendering both remedies verbatim and the basis; anything weaker keeps
* `a` with its basis. APPLY keys on the chosen option's `changes_plan`;
* accepted changes are pending amendments (status amend_pending, nothing
* reopens at answer) that `next` refuses past (PLAN_AMEND_PENDING) until
* `revise --incorporates` binds them to a changed plan or `--withdraw` drops
* them. `as-is` keeps every direction; `rec` (alias `all`) takes every
* recommendation. Partial replies resolve against persisted decisions. On
* reruns a challenge without `changes` is an agent decision that reopens
* nothing, and rerun prompts carry the decided items.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { ROOT, SMALL_PLAN } from './helpers/autoplan-run-fixture';
import { ROOT, SMALL_PLAN, boundOf, resultOf } from './helpers/autoplan-run-fixture';
import { capyRunner, phaseVia, readJsonl, readRunJson, writePlan } from './helpers/autoplan-capy';
const r = capyRunner();
@@ -33,6 +32,14 @@ const DISAGREE = {
native: [{ id: 'F1', severity: 'Critical', title: 'Persist attempts in a separate table', file: 'lib/worker.ts', line: 10, user_challenge: true, changes: 'scope', fix: 'new table' }],
outside: [{ id: 'O1', severity: 'Low', title: 'Persist attempts in a separate table', file: 'lib/worker.ts', line: 11, user_challenge: true, changes: 'scope', fix: 'keep the column' }],
};
const SAME = {
native: [{ id: 'F4', severity: 'High', title: 'Cap the retry storage per tenant', user_challenge: true, changes: 'scope', fix: 'Cap stored attempts at 50 per tenant.' }],
outside: [{ id: 'O4', severity: 'High', title: 'Cap the retry storage per tenant', user_challenge: true, changes: 'scope', fix: 'cap stored attempts at 50 per tenant' }],
};
const PACKAGING = {
native: [{ id: 'F5', severity: 'Medium', title: 'Split the backoff into its own PR', user_challenge: true, changes: 'packaging', fix: 'land the schedule change as its own PR' }],
outside: [{ id: 'O5', severity: 'Medium', title: 'Split the backoff into its own PR', user_challenge: true, changes: 'packaging', fix: 'Land the schedule change as its own PR.' }],
};
const MISMATCH = {
native: [{ id: 'F1', severity: 'Medium', title: 'Drop the jitter requirement', user_challenge: true, changes: 'scope', fix: 'remove jitter' }],
outside: [{ id: 'O1', severity: 'Medium', title: 'Drop the jitter requirement', user_challenge: true, changes: 'cost', fix: 'remove jitter' }],
@@ -54,34 +61,37 @@ const v = (dir: string, text: string) => { const f = path.join(dir, `v${++n}.md`
describe('changes and user-challenge defaults', () => {
test('`changes` reaches findings.jsonl, the schema and decisions; an unknown value is refused', () => {
const g = gated(AGREE, {});
expect(readJsonl(path.join(g.out, 'ceo-native.md.findings.jsonl'))[0]).toMatchObject({ changes: 'scope', user_challenge: true });
expect(readJsonl(`${boundOf(g.out, 'ceo', 'native')}.findings.jsonl`)[0]).toMatchObject({ changes: 'scope', user_challenge: true });
const schema = spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-artifact'), 'schema', 'findings'], { cwd: ROOT, encoding: 'utf8', timeout: 60_000 });
expect(JSON.parse(schema.stdout).properties.changes).toEqual({ enum: ['scope', 'cost', 'direction'] });
expect(decisions(g.out).find(d => d.label === 'UC1')).toMatchObject({ changes: 'scope', recommended: 'b', recommended_basis: 'both voices; remedies shown' });
expect(JSON.parse(schema.stdout).properties.changes).toEqual({ enum: ['scope', 'cost', 'direction', 'packaging', 'ordering'] });
expect(decisions(g.out).find(d => d.label === 'UC1')).toMatchObject({ changes: 'scope', recommended: 'a', recommended_basis: 'plan direction; remedies differ; no shared proposal' });
const dir = path.join(r.host.base, 'badchanges');
const out = path.join(dir, 'run');
expect(r.ap(['next', '--out', out, '--plan', writePlan(dir), '--no-ui']).status).toBe(0);
expect(r.review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_FINDINGS: JSON.stringify([{ severity: 'Low', title: 'x', user_challenge: true, changes: 'packaging' }]) }).status).toBe(0);
const bad = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'anthropic/claude-opus-5-5']);
expect(r.review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_FINDINGS: JSON.stringify([{ severity: 'Low', title: 'x', user_challenge: true, changes: 'naming' }]) }).status).toBe(0);
const bad = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'anthropic/claude-opus-5-5']);
expect(bad.status).toBe(1);
expect(bad.stderr).toMatch(/changes "packaging" is not one of scope\|cost\|direction.*\(RESULT_FINDINGS_MISSING\)$/m);
expect(bad.stderr).toMatch(/changes "naming" is not one of scope\|cost\|direction\|packaging\|ordering.*\(RESULT_FINDINGS_MISSING\)$/m);
});
test('agreeing voices recommend b with both remedies verbatim; disagreement and mismatched changes keep a with the basis', () => {
const g = gated({ native: [...AGREE.native, ...MISMATCH.native.map(f => ({ ...f, id: 'F2' }))], outside: [...AGREE.outside, ...MISMATCH.outside.map(f => ({ ...f, id: 'O2' }))] }, DISAGREE);
test('one shared remedy recommends b; different remedies show both verbatim and keep a; disagreement and mismatched changes keep a with the basis', () => {
const g = gated({ native: [...AGREE.native, ...MISMATCH.native.map(f => ({ ...f, id: 'F2' })), ...SAME.native], outside: [...AGREE.outside, ...MISMATCH.outside.map(f => ({ ...f, id: 'O2' })), ...SAME.outside] }, DISAGREE);
const items = gate(g.out).items;
const same = items.find((i: any) => i.title.startsWith('Cap the retry storage'));
expect(same).toMatchObject({ kind: 'approval', recommended: 'b', recommended_basis: 'both voices; same remedy' });
const agree = items.find((i: any) => i.title.startsWith('Ship the backoff'));
expect(agree).toMatchObject({ kind: 'approval', recommended: 'b', recommended_basis: 'both voices; remedies shown' });
expect(agree).toMatchObject({ kind: 'approval', recommended: 'a', recommended_basis: 'plan direction; remedies differ; no shared proposal' });
expect(agree.options.find((o: any) => o.key === 'b')).toMatchObject({ changes_plan: true });
expect(agree.options.find((o: any) => o.key === 'b').text).toContain('native: gate the change behind RETRY_BACKOFF=1; outside: add a kill switch flag for the new schedule');
expect(agree.options.find((o: any) => o.key === 'a').changes_plan).toBe(false);
expect(items.find((i: any) => i.title.startsWith('Drop the jitter'))).toMatchObject({ recommended: 'a', recommended_basis: 'plan direction; voices name different changes' });
expect(items.find((i: any) => i.title.startsWith('Persist attempts'))).toMatchObject({ recommended: 'a', recommended_basis: 'plan direction; voices disagree' });
const render = spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-gate'), 'render', path.join(g.out, 'gate.json')], { cwd: ROOT, encoding: 'utf8', timeout: 60_000 });
expect(render.stdout).toMatch(new RegExp(`\\[${agree.id}\\] .*recommended: b \\(both voices; remedies shown\\)`));
expect(render.stdout).toMatch(new RegExp(`\\[${same.id}\\] .*recommended: b \\(both voices; same remedy\\)`));
expect(render.stdout).toMatch(/recommended: a \(plan direction; voices disagree\)/);
const p1 = items.find((i: any) => i.id === 'p1');
expect(p1.options.find((o: any) => o.key === p1.recommended).text).toContain(`${agree.id}b`);
expect(p1.options.find((o: any) => o.key === p1.recommended).text).toContain(`${same.id}b`);
expect(p1.options.find((o: any) => o.key === p1.recommended).text).not.toContain(`${agree.id}b`);
});
test('a parent reconcile override naming one shared proposal renders that proposal as option b', () => {
@@ -91,10 +101,10 @@ describe('changes and user-challenge defaults', () => {
r.review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_FINDINGS: JSON.stringify(AGREE.native) });
r.review(path.join(out, 'ceo-outside-prompt.md'), { FIXTURE_FINDINGS: JSON.stringify(AGREE.outside) });
const run = readRunJson(out);
expect(r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'anthropic/claude-opus-5-5']).status).toBe(0);
expect(r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'anthropic/claude-opus-5-5']).status).toBe(0);
const overrides = path.join(dir, 'reconcile.jsonl');
fs.writeFileSync(overrides, JSON.stringify({ outside_id: `${run.run}-ceo-outside-1`, native_id: `${run.run}-ceo-native-1`, disposition: 'confirmed', resolution: 'same change', proposal: 'ship behind RETRY_BACKOFF=1, default off' }) + '\n');
expect(r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', path.join(out, 'ceo-outside.md'), '--model', 'openai/gpt-6.1-sol', '--reconcile', overrides]).status).toBe(0);
expect(r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', resultOf(out, 'ceo', 'outside'), '--model', 'openai/gpt-6.1-sol', '--reconcile', overrides]).status).toBe(0);
phaseVia(r, out, 'eng', {});
const uc = gate(out).items.find((i: any) => i.title.startsWith('Ship the backoff'));
expect(uc).toMatchObject({ recommended: 'b', recommended_basis: 'both voices; shared proposal' });
@@ -103,23 +113,27 @@ describe('changes and user-challenge defaults', () => {
});
describe('accepted changes are amendments', () => {
test('`all` leaves the agreeing change pending; its own token accepts it: APPLY, REOPENED, NEXT revise; next refuses until revise --incorporates binds it', () => {
test('an accepted change prints APPLY and NEXT revise and leaves amend_pending without reopening; next refuses until revise --incorporates binds it; revise reports what reopened', () => {
const g = gated(AGREE, {});
const held = answer(g.out, 1, 'all');
const held = answer(g.out, 1, 'uc1b');
expect(held.status, held.stderr).toBe(0);
expect(held.stdout).toMatch(/^GATE_PENDING: uc1 unanswered \(reply with uc1a\|uc1b; all does not answer items recommended b\)$/m);
expect(held.stdout).toMatch(/^GATE_PENDING: uc2, p1 unanswered \(reply with uc2a\|uc2b p1a\|p1b\|p1c\|p1d\|p1e or rec\)$/m);
expect(held.stdout).not.toMatch(/^APPLY:/m);
expect(decisions(g.out).find(d => d.label === 'UC1')).toMatchObject({ status: 'pending' });
const res = answer(g.out, 1, 'uc1b');
const res = answer(g.out, 1, 'rec');
expect(res.status, res.stderr).toBe(0);
expect(res.stdout).toMatch(new RegExp(`^APPLY: ${esc(g.run)}-uc1 Ship the backoff behind a flag first .* -> accept the change both reviewers recommend`, 'm'));
expect(res.stdout).toMatch(/^REOPENED: ceo,eng \(Eng last\) reason=uc1b$/m);
expect(res.stdout).toMatch(new RegExp(`^NEXT: gstack-autoplan revise --out ${esc(g.out)} --plan ${esc(g.plan)} --incorporates ${esc(g.run)}-uc1$`, 'm'));
expect(res.stdout).not.toMatch(/^NEXT: gstack-autoplan next/m);
expect(readRunJson(g.out).amendments).toEqual([expect.objectContaining({ decision: `${g.run}-uc1`, status: 'pending' })]);
expect(res.stdout).toMatch(new RegExp(`^APPLY: ${esc(g.run)}-uc1 Ship the backoff behind a flag first .* -> accept the change both reviewers recommend.*; edit the plan, then revise$`, 'm'));
expect(res.stdout).not.toMatch(/^REOPENED:/m);
expect(res.stdout).toMatch(new RegExp(`^NEXT: edit the plan, then gstack-autoplan revise --out ${esc(g.out)} --plan ${esc(g.plan)} --incorporates ${esc(g.run)}-uc1$`, 'm'));
expect(res.stdout).toContain('status=amend_pending');
const before = readRunJson(g.out);
expect(before.status).toBe('amend_pending');
expect(before.phases.ceo.closed_at).toBeTruthy();
expect(before.amendments).toEqual([expect.objectContaining({ decision: `${g.run}-uc1`, status: 'pending', item: 'uc1', changes: 'scope' })]);
expect(r.ap(['status', '--out', g.out]).stdout).toMatch(new RegExp(`^AMEND_PENDING: ${esc(g.run)}-uc1 uc1 changes=scope -> edit the plan, then revise$`, 'm'));
expect(r.validate(g.out).status).toBe(0);
const blocked = r.ap(['next', '--out', g.out]);
expect(blocked.status).toBe(3);
expect(blocked.stderr).toMatch(new RegExp(`${esc(g.run)}-uc1.*\\(PLAN_AMEND_PENDING\\)$`, 'm'));
expect(blocked.stderr).toMatch(new RegExp(`${esc(g.run)}-uc1 \\(APPLY uc1: edit the plan, then revise\\).*\\(PLAN_AMEND_PENDING\\)$`, 'm'));
const runFile = path.join(g.out, 'run.json');
const saved = fs.readFileSync(runFile, 'utf8');
fs.writeFileSync(runFile, JSON.stringify({ ...JSON.parse(saved), status: 'complete' }, null, 2) + '\n');
@@ -127,35 +141,66 @@ describe('accepted changes are amendments', () => {
expect(exported.status).toBe(3);
expect(exported.stderr).toMatch(new RegExp(`${esc(g.run)}-uc1.*\\(PLAN_AMEND_PENDING\\)$`, 'm'));
fs.writeFileSync(runFile, saved);
const unrelated = r.ap(['revise', '--out', g.out, '--plan', v(g.dir, SMALL_PLAN.replace('cap 30 s', 'cap 45 s'))]);
expect(unrelated.status, unrelated.stderr).toBe(0);
expect(unrelated.stdout).toMatch(new RegExp(`^AMENDMENTS: pending=${esc(g.run)}-uc1$`, 'm'));
expect(r.ap(['next', '--out', g.out]).status).toBe(3);
const unchanged = r.ap(['revise', '--out', g.out, '--plan', v(g.dir, SMALL_PLAN.replace('cap 30 s', 'cap 45 s')), '--incorporates', `${g.run}-uc1`]);
const unchanged = r.ap(['revise', '--out', g.out, '--plan', g.plan, '--incorporates', `${g.run}-uc1`]);
expect(unchanged.status).toBe(3);
expect(unchanged.stderr).toMatch(/did not change.*\(PLAN_AMEND_PENDING\)$/m);
const bound = r.ap(['revise', '--out', g.out, '--plan', v(g.dir, SMALL_PLAN.replace('cap 30 s', 'cap 45 s, behind RETRY_BACKOFF=1')), '--incorporates', `${g.run}-uc1`]);
const bound = r.ap(['revise', '--out', g.out, '--plan', v(g.dir, SMALL_PLAN.replace('cap 30 s', 'cap 30 s, behind RETRY_BACKOFF=1')), '--incorporates', 'uc1']);
expect(bound.status, bound.stderr).toBe(0);
expect(bound.stdout).toMatch(/^REVISED: plan=[0-9a-f]{12} reopened=ceo,eng$/m);
expect(bound.stdout).toMatch(/^AMENDMENTS: pending=none$/m);
expect(bound.stdout).toMatch(new RegExp(`^NEXT: gstack-autoplan next --out ${esc(g.out)}$`, 'm'));
const j = readJsonl(path.join(g.out, 'attempts.jsonl')).find(e => e.event === 'amendment_incorporated');
expect(j).toMatchObject({ decision: `${g.run}-uc1` });
expect(j.old_plan_sha256).not.toBe(j.new_plan_sha256);
expect(readJsonl(path.join(g.out, 'attempts.jsonl')).find(e => e.event === 'plan_revised')).toMatchObject({ reopened: ['ceo', 'eng'] });
expect(readRunJson(g.out).status).toBe('running');
expect(r.ap(['next', '--out', g.out]).status).toBe(0);
});
test('`all as-is` keeps every direction; --withdraw is a journaled owner decision', () => {
test('`as-is` keeps every direction; --withdraw is a journaled owner decision that completes the run when nothing else is pending', () => {
const g = gated(AGREE, {});
const asIs = answer(g.out, 1, 'all as-is');
const asIs = answer(g.out, 1, 'as-is');
expect(asIs.status, asIs.stderr).toBe(0);
expect(asIs.stdout).toMatch(/^APPROVED: gate_rev=1 as-is$/m);
expect(asIs.stdout).not.toMatch(/^APPLY:/m);
expect(decisions(g.out).find(d => d.label === 'UC1')).toMatchObject({ chosen: 'a', status: 'overridden' });
expect(decisions(g.out).find(d => d.label === 'UC1')).toMatchObject({ chosen: 'a', status: 'approved' });
const w = gated(AGREE, {});
expect(answer(w.out, 1, 'all except uc1b').stdout).toMatch(/^APPLY:/m);
expect(answer(w.out, 1, 'rec except uc1b').stdout).toMatch(/^APPLY:/m);
const withdraw = r.ap(['revise', '--out', w.out, '--withdraw', `${w.run}-uc1`]);
expect(withdraw.status, withdraw.stderr).toBe(0);
expect(withdraw.stdout).toMatch(/^APPROVED: gate_rev=1 \(every accepted change is in the plan or settled\)$/m);
expect(readJsonl(path.join(w.out, 'attempts.jsonl')).find(e => e.event === 'amendment_withdrawn')).toMatchObject({ decision: `${w.run}-uc1` });
expect(r.ap(['next', '--out', w.out]).status).toBe(0);
expect(readRunJson(w.out).status).toBe('complete');
expect(r.validate(w.out).status).toBe(0);
});
});
describe('record-only changes settle without a plan edit', () => {
test('a packaging change: APPLY says settle, revise --settle reopens nothing and completes, export and validate pass; a scope change never settles', () => {
const g = gated(PACKAGING, {});
expect(gate(g.out).items.find((i: any) => i.id === 'uc1')).toMatchObject({ recommended: 'b', changes: 'packaging' });
const res = answer(g.out, 1, 'rec');
expect(res.status, res.stderr).toBe(0);
expect(res.stdout).toMatch(new RegExp(`^APPLY: ${esc(g.run)}-uc1 .*; no plan edit needed: revise --settle uc1$`, 'm'));
expect(readRunJson(g.out).status).toBe('amend_pending');
expect(r.ap(['next', '--out', g.out]).stderr).toMatch(/\(PLAN_AMEND_PENDING\)$/m);
const settled = r.ap(['revise', '--out', g.out, '--settle', 'uc1']);
expect(settled.status, settled.stderr).toBe(0);
expect(settled.stdout).toMatch(new RegExp(`^SETTLED: ${esc(g.run)}-uc1 \\(packaging; plan [0-9a-f]{12} unchanged; nothing reopens\\)$`, 'm'));
expect(settled.stdout).toMatch(/^APPROVED: gate_rev=1 \(every accepted change is in the plan or settled\)$/m);
expect(settled.stdout).not.toMatch(/^REOPENED:/m);
const run = readRunJson(g.out);
expect(run.status).toBe('complete');
expect(run.amendments).toEqual([expect.objectContaining({ decision: `${g.run}-uc1`, status: 'settled', changes: 'packaging' })]);
expect(readJsonl(path.join(g.out, 'attempts.jsonl')).find(e => e.event === 'amendment_settled')).toMatchObject({ decision: `${g.run}-uc1`, changes: 'packaging' });
expect(r.ap(['export', '--out', g.out]).status).toBe(0);
expect(r.validate(g.out).status).toBe(0);
const scope = gated(AGREE, {});
expect(answer(scope.out, 1, 'rec except uc1b').stdout).toMatch(/; edit the plan, then revise$/m);
const refused = r.ap(['revise', '--out', scope.out, '--settle', 'uc1']);
expect(refused.status).toBe(3);
expect(refused.stderr).toMatch(/changes scope; only packaging or ordering changes settle.*\(AMENDMENT_NOT_SETTLEABLE\)$/m);
expect(readRunJson(scope.out).status).toBe('amend_pending');
});
});
@@ -167,7 +212,7 @@ describe('partial replies resolve against persisted decisions', () => {
const first = answer(g.out, 1, 'uc1b', true);
expect(JSON.parse(first.stdout)).toMatchObject({ outcome: 'gate_pending', pending: ['uc2'] });
const second = answer(g.out, 1, 'all', true);
expect(JSON.parse(second.stdout)).toMatchObject({ outcome: 'reopened', reopened: ['ceo', 'eng'] });
expect(JSON.parse(second.stdout)).toMatchObject({ outcome: 'amend_pending', reopened: [], status: 'amend_pending' });
expect(decisions(g.out).find(d => d.label === 'UC1')).toMatchObject({ chosen: 'b', status: 'overridden' });
});
@@ -184,11 +229,12 @@ describe('partial replies resolve against persisted decisions', () => {
});
describe('reruns', () => {
test('rerun prompts carry the decided items, a ceo item answered at rev 1 carries forward, and a rerun challenge without `changes` reopens nothing', () => {
test('rerun prompts carry the decided items, and a rerun challenge without `changes` reopens nothing', () => {
const g = gated({}, AGREE);
const res = answer(g.out, 1, 'uc1a uc2b p1a');
expect(res.stdout).toMatch(/^REOPENED: eng \(Eng last\) reason=uc2b$/m);
expect(r.ap(['revise', '--out', g.out, '--withdraw', `${g.run}-uc2`]).status).toBe(0);
expect(res.stdout).not.toMatch(/^REOPENED:/m);
expect(r.ap(['revise', '--out', g.out, '--plan', v(g.dir, SMALL_PLAN.replace('cap 30 s', 'cap 30 s, behind RETRY_BACKOFF=1')), '--incorporates', 'uc2']).stdout).toMatch(/reopened=ceo,eng/);
phaseVia(r, g.out, 'ceo', {});
const eng = phaseVia(r, g.out, 'eng', {
native: [{ id: 'F9', severity: 'Low', title: 'Name the PR branch per lane', user_challenge: true }],
outside: [{ id: 'O9', severity: 'Medium', title: 'Price the retry storage before shipping', user_challenge: true, changes: 'cost' }],
@@ -198,8 +244,6 @@ describe('reruns', () => {
expect(prompt).toMatch(/uc2b .*Ship the backoff behind a flag first/);
expect(prompt).toMatch(/uc1a /);
expect(eng.outside.stdout).toContain('status=gate_pending');
const rev2 = decisions(g.out).filter(d => d.gate_rev === 2);
expect(rev2.find(d => d.label === 'UC1')).toMatchObject({ status: 'approved', chosen: 'a', carried_from: `${g.run}-uc1` });
const items = gate(g.out).items;
const demoted = items.find((i: any) => i.title.startsWith('Name the PR branch'));
expect(demoted).toMatchObject({ kind: 'auto', reopens: false });
+39
View File
@@ -0,0 +1,39 @@
/**
* 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.
*/
import { describe, expect, test } from 'bun:test';
import fixture from './fixtures/autoplan-pairing/run-20261014-pairs.json';
import { reconcile } from '../lib/autoplan-reconcile';
import { challengeDefault } from '../lib/autoplan-gate';
const pair = (name: string) => fixture.pairs.find(p => p.name.startsWith(name))! as any;
const challenge = (rows: any[], changes = 'direction') => rows.map(r => ({ ...r, user_challenge: true, changes }));
describe('pairing negative control from a real run', () => {
test('two findings three lines apart on one file are associated but never confirmed, and never recommend b', () => {
const p = pair('ceo r1 outside-1');
const [row] = reconcile(p.native, p.outside).rows;
expect(row).toMatchObject({ disposition: 'unverified', basis: 'file_line', native_id: p.native[0].id });
expect(row!.resolution).toStartWith('same location, agreement unverified (bin/gstack-capy-install:197)');
const [n] = challenge(p.native), [o] = challenge(p.outside);
expect(challengeDefault(row!, n, o)).toMatchObject({ recommended: 'a', basis: 'plan direction; same location, agreement unverified' });
});
test('a location match with different titles stays unverified until the parent confirms it with one proposal', () => {
const p = pair('dx r2 outside-2');
const [row] = reconcile(p.native, p.outside).rows;
expect(row!.disposition).toBe('unverified');
const [confirmed] = reconcile(p.native, p.outside, [{ outside_id: p.outside[0].id, native_id: p.native[0].id, disposition: 'confirmed', resolution: 'same trailer grammar break', proposal: 'keep `gstack: fail <components>` and specify the pins exit' }]).rows;
expect(challengeDefault(confirmed!, challenge(p.native, 'scope')[0], challenge(p.outside, 'scope')[0])).toMatchObject({ recommended: 'b', basis: 'both voices; shared proposal' });
});
test('the same problem on different files is new until the parent associates it; different remedies keep a', () => {
const p = pair('dx r2 native-2');
expect(reconcile(p.native, p.outside).rows[0]).toMatchObject({ disposition: 'new', native_id: null });
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' });
});
});
+6 -5
View File
@@ -14,6 +14,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { RESULT_CODES } from '../lib/result-codes';
import { capyRunner, readJsonl, readRunJson, writePlan } from './helpers/autoplan-capy';
import { boundOf, resultOf } from './helpers/autoplan-run-fixture';
const r = capyRunner();
afterAll(() => r.cleanup());
@@ -64,8 +65,8 @@ describe('submit --record', () => {
expect(ok.stdout).toMatch(new RegExp(`^BOUND: ${attempt} phase=ceo voice=native .*findings=2 .*receipt=attested`, 'm'));
const v = readRunJson(out).phases.ceo.native;
expect(v).toMatchObject({ status: 'completed', receipt: 'attested', attest_reason: 'subagent ran from the prompt file', findings_source: 'table' });
expect(fs.readFileSync(path.join(out, 'ceo-native.md'), 'utf8')).toBe(TABLE);
const rows = readJsonl(path.join(out, 'ceo-native.md.findings.jsonl'));
expect(fs.readFileSync(boundOf(out, 'ceo', 'native'), 'utf8')).toBe(TABLE);
const rows = readJsonl(`${boundOf(out, 'ceo', 'native')}.findings.jsonl`);
expect(rows.map(x => [x.severity, x.title, x.file ?? null, x.line ?? null])).toEqual([['High', 'Retry cap is unbounded on restart', 'lib/worker.ts', 10], ['Medium', 'Split persistence change into its own plan', null, null]]);
expect(readRunJson(out).deviations.some((d: string) => /attested.*ceo native/.test(d))).toBe(true);
});
@@ -107,7 +108,7 @@ describe('submit --record', () => {
test('the canonical --result path binds without --attempt when exactly one attempt is open', () => {
const out = start();
expect(r.review(path.join(out, 'ceo-native-prompt.md')).status).toBe(0);
const ok = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'anthropic/claude-opus-5-5']);
const ok = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'anthropic/claude-opus-5-5']);
expect(ok.status, ok.stderr).toBe(0);
expect(ok.stdout).toMatch(/^BOUND: \S+-a1 phase=ceo voice=native .*findings=3/m);
});
@@ -139,14 +140,14 @@ describe('submit --record', () => {
expect(resumed.stdout).not.toContain(outsideAttempt);
expect(readRunJson(out).phases.ceo.outside.status).toBe('unavailable');
expect(r.review(path.join(out, 'ceo-native-prompt.md')).status).toBe(0);
const close = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'anthropic/claude-opus-5-5']);
const close = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'anthropic/claude-opus-5-5']);
expect(close.stdout).toMatch(/^PHASE_CLOSED: ceo .*coverage=native_only/m);
});
test('--failed records a failed outside voice and closes the phase once native is bound', () => {
const out = start();
expect(r.review(path.join(out, 'ceo-native-prompt.md')).status).toBe(0);
expect(r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'anthropic/claude-opus-5-5']).status).toBe(0);
expect(r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'anthropic/claude-opus-5-5']).status).toBe(0);
const failed = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--failed', 'runner exited 1']);
expect(failed.status, failed.stderr).toBe(0);
expect(failed.stdout).toMatch(/^PHASE_CLOSED: ceo .*coverage=native_only/m);
+85 -34
View File
@@ -1,23 +1,27 @@
/**
* Field-wave residuals (gstack fix wave review, gate revs 1–2, 2026-10-12/13):
* R1 `all` never flips the owner's stated direction: a user-challenge item
* whose recommended option changes the plan stays pending under `all` and
* is answered only by its own token; the rendering says which ids `all`
* leaves open.
* R1 (superseded by next-wave B1, owner decision gate_rev 1): `rec` and its
* silent alias `all` take every recommendation, plan changes included;
* `as-is` keeps every direction; the render prints the `rec` resolution
* from the parser that applies it.
* R2 every later phase sees its predecessors' consensus: the DX prompt
* carries the CEO consensus, not only the Eng prompt.
* R3 lib code spawns the Bun that runs it (`process.execPath`), never `bun`
* by PATH name, so the snapshot tool and restamp cannot silently run the
* Capy image's 1.3.14 when a parent forgot the launch PATH.
* R5 gate answers carry provenance: `answer --by <human|agent:<id>>` is
* required in an unattended run and lands on every decision row.
* 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.
* 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.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { parseReply, renderGate, type GateList } from '../lib/gate-list';
import { attemptsOf, autoplan, readRun, review, ROOT, runPhase, startSmallRun } from './helpers/autoplan-run-fixture';
import { attemptsOf, autoplan, readRun, resultOf, review, ROOT, runPhase, startSmallRun } from './helpers/autoplan-run-fixture';
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-residuals-'));
afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
@@ -34,7 +38,7 @@ const agreed: GateList = {
};
const decisions = (out: string) => fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8').trim().split('\n').map(l => JSON.parse(l));
/** Both voices challenge the same item with the same `changes` and a remedy each: the gate recommends `b`. */
/** Both voices challenge the same item with the same `changes` and the same remedy: the gate recommends `b`. */
function agreedRun(name: string): string {
const out = startSmallRun(path.join(TMP, name));
const native = JSON.stringify([
@@ -42,51 +46,59 @@ function agreedRun(name: string): string {
{ id: 'F3', severity: 'High', title: 'Four lanes share result-codes.ts', file: 'lib/result-codes.ts', line: 1, user_challenge: true, changes: 'direction', fix: 'stack the lanes' }]);
const outside = JSON.stringify([
{ id: 'O1', severity: 'Critical', title: 'The install check lies by omission', file: 'bin/gstack-doctor', line: 14, fix: 'exit non-zero on any FAIL' },
{ id: 'O3', severity: 'High', title: 'Four lanes share result-codes.ts', file: 'lib/result-codes.ts', line: 1, user_challenge: true, changes: 'direction', fix: 'stack the lanes A then B' }]);
{ id: 'O3', severity: 'High', title: 'Four lanes share result-codes.ts', file: 'lib/result-codes.ts', line: 1, user_challenge: true, changes: 'direction', fix: 'Stack the lanes.' }]);
for (const phase of ['ceo', 'eng']) {
expect(autoplan(['next', '--out', out]).status).toBe(0);
review(path.join(out, `${phase}-native-prompt.md`), { FIXTURE_FINDINGS: native });
review(path.join(out, `${phase}-outside-prompt.md`), { FIXTURE_FINDINGS: outside });
const ids = attemptsOf(out, phase);
expect(autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'native', '--result', path.join(out, `${phase}-native.md`), '--model', 'anthropic/claude-opus-4-7', '--attempt', ids.native, '--runner', 'host-subagent']).status).toBe(0);
expect(autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'outside', '--result', path.join(out, `${phase}-outside.md`), '--model', 'openai/gpt-6-astra', '--attempt', ids.outside, '--runner', 'host-subagent']).status).toBe(0);
expect(autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'native', '--result', resultOf(out, phase, 'native'), '--model', 'anthropic/claude-opus-4-7', '--attempt', ids.native, '--runner', 'host-subagent']).status).toBe(0);
expect(autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'outside', '--result', resultOf(out, phase, 'outside'), '--model', 'openai/gpt-6-astra', '--attempt', ids.outside, '--runner', 'host-subagent']).status).toBe(0);
}
expect(readRun(out).status).toBe('gate_pending');
return out;
}
describe('R1: `all` never flips the owner\'s stated direction', () => {
test('an approval item recommended b (changes the plan) stays pending under `all`; its own token answers it', () => {
const r = parseReply('all', agreed);
expect(r.valid).toBe(true);
expect(r.pending).toEqual(['uc1']);
expect(r.choices.map(c => `${c.id}${c.chosen}`)).toEqual(['uc2a', 'd1a', 'p1a']);
const explicit = parseReply('all except uc1b', agreed);
expect(explicit.pending).toEqual([]);
expect(explicit.choices.find(c => c.id === 'uc1')).toMatchObject({ chosen: 'b', how: 'recommended' });
const kept = parseReply('all except uc1a', agreed);
expect(kept.choices.find(c => c.id === 'uc1')).toMatchObject({ chosen: 'a', how: 'override' });
expect(parseReply('all as-is', agreed).choices.find(c => c.id === 'uc1')).toMatchObject({ chosen: 'a' });
describe('R1/B1: `rec` (and its alias `all`) takes every recommendation, plan changes included', () => {
test('an approval item recommended b is answered by `rec`/`all`; `rec except uc1a` and `as-is` keep the direction', () => {
for (const reply of ['rec', 'all', 'approve all', 'Accept all', 'take all recommendations']) {
const r = parseReply(reply, agreed);
expect(r.valid).toBe(true);
expect(r.mode).toBe('rec');
expect(r.pending).toEqual([]);
expect(r.choices.map(c => `${c.id}${c.chosen}`)).toEqual(['uc1b', 'uc2a', 'd1a', 'p1a']);
}
expect(parseReply('rec except uc1a', agreed).choices.find(c => c.id === 'uc1')).toMatchObject({ chosen: 'a', how: 'override' });
expect(parseReply('all except uc1a', agreed).choices.find(c => c.id === 'uc1')).toMatchObject({ chosen: 'a', how: 'override' });
for (const reply of ['as-is', 'all as-is']) {
const r = parseReply(reply, agreed);
expect(r.mode).toBe('as-is');
expect(r.choices.find(c => c.id === 'uc1')).toMatchObject({ chosen: 'a' });
}
for (const bare of ['yes', 'no', 'rec d1', 'rec yes']) expect(parseReply(bare, agreed).valid).toBe(false);
});
test('the rendering names the ids `all` leaves open and their exact tokens', () => {
test('the rendering offers `rec` first and prints its resolution from the same parser, with the rec_hash', () => {
const text = renderGate(agreed);
expect(text).toMatch(/`all` does not answer uc1 \(recommended b changes the plan\); reply uc1a or uc1b/);
expect(text).toMatch(/"choices":\{"uc2":"a","d1":"a","p1":"a"\}/);
expect(text).toMatch(/Reply with exactly one of: `rec` \(default: take every recommendation, plan changes included\)/);
expect(text).toMatch(/"choices":\{"uc1":"b","uc2":"a","d1":"a","p1":"a"\}/);
expect(text).toMatch(/rec_hash=[0-9a-f]{12}/);
expect(text).not.toMatch(/does not answer/);
});
test('end to end: both voices agree, the gate recommends b, and `all` keeps the gate pending on that item', () => {
test('end to end: both voices agree with one remedy, the gate recommends b, and `rec` accepts it as a pending amendment', () => {
const out = agreedRun('agreed');
const gate = JSON.parse(fs.readFileSync(path.join(out, 'gate.json'), 'utf8')) as GateList;
const uc = gate.items.filter(i => i.id.startsWith('uc'));
expect(uc.length).toBeGreaterThan(0);
expect(uc.every(i => i.recommended === 'b')).toBe(true);
const all = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all', '--by', 'human']);
expect(all.status, all.stderr).toBe(0);
expect(all.stdout).toMatch(new RegExp(`GATE_PENDING: ${uc.map(i => i.id).join(', ')} unanswered \\(reply with ${uc.map(i => `${i.id}a|${i.id}b`).join(' ')}; all does not answer items recommended b\\)`));
expect(readRun(out).status).toBe('gate_pending');
expect(uc.every(i => i.recommended === 'b' && i.recommended_basis === 'both voices; same remedy')).toBe(true);
const rec = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'rec', '--by', 'human']);
expect(rec.status, rec.stderr).toBe(0);
for (const i of uc) expect(rec.stdout).toMatch(new RegExp(`^APPLY: ${readRun(out).run}-${i.id} .*; edit the plan, then revise$`, 'm'));
expect(rec.stdout).toContain('GSTACK_RESULT: skill=autoplan status=amend_pending');
expect(readRun(out).status).toBe('amend_pending');
const rows = decisions(out);
for (const i of uc) expect(rows.find(r => r.label === i.id.toUpperCase()).status).toBe('pending');
for (const i of uc) expect(rows.find(r => r.label === i.id.toUpperCase())).toMatchObject({ status: 'approved', chosen: 'b' });
});
});
@@ -101,15 +113,54 @@ describe('R5: gate answers carry provenance', () => {
const bad = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all as-is', '--by', 'me']);
expect(bad.status).toBe(3);
expect(bad.stderr).toMatch(/\(ANSWER_BY_REQUIRED\)/);
const ok = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all as-is', '--by', 'agent:GSTA-29']);
const noOwner = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all as-is', '--by', 'agent:GSTA-29']);
expect(noOwner.status).toBe(3);
expect(noOwner.stderr).toMatch(/\(OWNER_REPLY_REQUIRED\)/);
expect(fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8')).toBe(before);
const ok = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all as-is', '--by', 'agent:GSTA-29', '--owner-reply', 'keep the plan as written']);
expect(ok.status, ok.stderr).toBe(0);
const answered = decisions(out).filter(r => r.status !== 'pending');
expect(answered.length).toBeGreaterThan(0);
for (const r of answered) expect(r.answered_by).toBe('agent:GSTA-29');
for (const r of answered) expect(r).toMatchObject({ answered_by: 'agent:GSTA-29', owner_reply: 'keep the plan as written', owner_reply_parsed: false });
expect(fs.readFileSync(path.join(out, 'review-record.md'), 'utf8')).toMatch(/answered by agent:GSTA-29/);
});
});
describe('B-b/B-c: recommend binds rec to the render the owner saw; the owner reply must match the relay', () => {
test('recommend bumps rec_rev and rewrites p1 and decisions; a rec without the new rec_hash is refused; a mismatched owner reply is refused', () => {
const out = agreedRun('recommend');
const gateFile = path.join(out, 'gate.json');
const uc = (JSON.parse(fs.readFileSync(gateFile, 'utf8')) as GateList).items.find(i => i.id.startsWith('uc'))!;
const oldHash = renderGate(JSON.parse(fs.readFileSync(gateFile, 'utf8'))).match(/rec_hash=([0-9a-f]{12})/)![1]!;
const rec = autoplan(['recommend', '--out', out, '--gate-rev', '1', '--item', uc.id, '--option', 'a', '--basis', 'the lanes already split by file', '--by', 'parent']);
expect(rec.status, rec.stderr).toBe(0);
const hash = rec.stdout.match(/^RECOMMENDED: .* rec_hash=([0-9a-f]{12})$/m)![1]!;
expect(hash).not.toBe(oldHash);
const gate = JSON.parse(fs.readFileSync(gateFile, 'utf8')) as GateList;
expect(gate.rec_rev).toBe(1);
expect(gate.items.find(i => i.id === uc.id)).toMatchObject({ recommended: 'a', recommended_basis: 'the lanes already split by file', recommended_by: 'parent' });
expect(renderGate(gate)).toContain(`rec_hash=${hash}`);
expect(decisions(out).find(r => r.label === uc.id.toUpperCase())).toMatchObject({ recommended: 'a', recommended_by: 'parent' });
const before = fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8');
for (const extra of [[], ['--rec-hash', oldHash]]) {
const stale = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'rec', '--by', 'human', ...extra]);
expect(stale.status).toBe(3);
expect(stale.stderr).toMatch(/\(GATE_REC_STALE\)/);
}
const mismatch = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'rec', '--rec-hash', hash, '--by', 'agent:GSTA-29', '--owner-reply', `${uc.id}b`]);
expect(mismatch.status).toBe(3);
expect(mismatch.stderr).toMatch(/\(GATE_OWNER_REPLY_MISMATCH\)/);
expect(fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8')).toBe(before);
const ownerFile = path.join(TMP, 'owner-reply.txt');
fs.writeFileSync(ownerFile, 'rec');
const ok = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'rec', '--rec-hash', hash, '--by', 'agent:GSTA-29', '--owner-reply-file', ownerFile]);
expect(ok.status, ok.stderr).toBe(0);
expect(ok.stdout).not.toMatch(new RegExp(`^APPLY: \\S+-${uc.id} `, 'm'));
expect(ok.stdout).toMatch(/^APPLY: \S+-uc2 /m);
expect(decisions(out).find(r => r.label === uc.id.toUpperCase())).toMatchObject({ chosen: 'a', owner_reply: 'rec', owner_reply_parsed: true, recommended_by: 'parent' });
});
});
describe('R2: later phases see their predecessors\' consensus', () => {
test('the DX prompt carries the CEO consensus block', () => {
const out = startSmallRun(path.join(TMP, 'dx-consensus'), ['--developer-tool']);
+8 -4
View File
@@ -58,7 +58,11 @@ describe('revise', () => {
expect(new Set(['ceo', 'dx', 'eng'].map(p => run.phases[p].snapshot)).size).toBe(1);
expect(run.phases.ceo.snapshot).not.toBe(v1);
expect(readJsonl(path.join(s.out, 'attempts.jsonl')).filter(e => e.event === 'plan_revised')).toHaveLength(1);
expect(fs.existsSync(path.join(s.out, 'ceo-native.r0.md'))).toBe(true);
expect(fs.existsSync(path.join(s.out, 'ceo-native-prompt.r0.md'))).toBe(true);
const sealed = run.history.find((h: any) => h.phase === 'ceo').previous.native.output;
expect(sealed).toMatch(/^ceo-native\.a\d+\.bound\.md$/);
expect(fs.existsSync(path.join(s.out, sealed))).toBe(true);
expect(run.artifacts[sealed]).toBeTruthy();
});
test('a Review-record-only edit keeps every phase; an open phase whose hash changed has its attempts superseded', () => {
@@ -95,11 +99,11 @@ describe('revise', () => {
test('a second revise never overwrites the first archive', () => {
const s = gated();
expect(r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir)]).status).toBe(0);
const first = fs.readFileSync(path.join(s.out, 'ceo-native.r1.md'));
const first = fs.readFileSync(path.join(s.out, 'ceo-native-prompt.r1.md'));
phaseVia(r, s.out, 'ceo', { native: [], outside: [] });
expect(r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir, V2.replace('jitter 10%', 'jitter 20%'))]).status).toBe(0);
expect(fs.readFileSync(path.join(s.out, 'ceo-native.r1.md')).equals(first)).toBe(true);
expect(fs.existsSync(path.join(s.out, 'ceo-native.r1.v2.md'))).toBe(true);
expect(fs.readFileSync(path.join(s.out, 'ceo-native-prompt.r1.md')).equals(first)).toBe(true);
expect(fs.existsSync(path.join(s.out, 'ceo-native-prompt.r1.v2.md'))).toBe(true);
});
test('revise adds a newly in-scope phase and never removes one', () => {
+4 -4
View File
@@ -10,7 +10,7 @@ import { afterAll, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { AUTOPLAN, ROOT, attemptsOf, env, journal, readRun, review, runPhase, startSmallRun } from './helpers/autoplan-run-fixture';
import { AUTOPLAN, ROOT, attemptsOf, env, journal, readRun, resultOf, review, runPhase, startSmallRun } from './helpers/autoplan-run-fixture';
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-concurrency-'));
afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
@@ -44,7 +44,7 @@ describe('two writers', () => {
const out = startSmallRun(path.join(TMP, 'submit'), ['--estimate-usd', '1']);
const ids = attemptsOf(out, 'ceo');
review(path.join(out, 'ceo-native-prompt.md'));
const args = ['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native];
const args = ['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native];
const results = await parallel(Array.from({ length: 6 }, () => args));
const ok = results.filter(r => r.status === 0);
expect(ok).toHaveLength(1);
@@ -78,8 +78,8 @@ describe('two writers', () => {
review(path.join(out, 'ceo-outside-prompt.md'));
const before = readRun(out).revision;
const results = await parallel([
['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native],
['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', path.join(out, 'ceo-outside.md'), '--model', 'openai/gpt-6-astra', '--attempt', ids.outside],
['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native],
['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', resultOf(out, 'ceo', 'outside'), '--model', 'openai/gpt-6-astra', '--attempt', ids.outside],
['status', '--out', out],
]);
const bound = results.slice(0, 2).filter(r => r.status === 0).length;
+60 -11
View File
@@ -7,13 +7,16 @@
* refuses a wrong receipt, a missing findings fence, a same-family outside
* model and a stale attempt; the whole wave plan runs through the loop with a
* fixture reviewer and validates. Pins tokens, codes and exit codes.
* Next-wave B4: each attempt writes `<phase>-<voice>.<attempt>.md`, and submit
* binds a sealed `.bound.md` copy, so a late write or an interruption between
* sealing and the journal binds nothing twice.
*/
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 { AUTOPLAN, ROOT, WAVE_PLAN, attemptsOf, autoplan, env, journal, readRun, review, runPhase, startSmallRun } from './helpers/autoplan-run-fixture';
import { AUTOPLAN, ROOT, WAVE_PLAN, attemptsOf, autoplan, env, journal, readRun, resultOf, review, runPhase, startSmallRun } from './helpers/autoplan-run-fixture';
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-recovery-'));
afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
@@ -46,36 +49,36 @@ describe('next/submit loop', () => {
const ids = attemptsOf(out, 'ceo');
const before = fs.readFileSync(path.join(out, 'run.json'), 'utf8');
review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_BREAK: 'receipt' });
let r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]);
let r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]);
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/\(RESULT_RECEIPT_MISSING\)$/m);
review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_BREAK: 'fence' });
r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]);
r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]);
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/\(RESULT_FINDINGS_MISSING\)$/m);
review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_BREAK: 'row' });
r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]);
r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]);
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/\(RESULT_FINDINGS_MISSING\)$/m);
r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', 'nope-a9']);
r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', 'nope-a9']);
expect(r.status).toBe(3);
expect(r.stderr).toMatch(/\(ATTEMPT_MISMATCH\)$/m);
r = autoplan(['submit', '--out', out, '--phase', 'dx', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]);
r = autoplan(['submit', '--out', out, '--phase', 'dx', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]);
expect(r.status).toBe(3);
expect(r.stderr).toMatch(/\(PHASE_NOT_AWAITING\)$/m);
expect(fs.readFileSync(path.join(out, 'run.json'), 'utf8')).toBe(before);
expect(journal(out).filter(j => j.event === 'submitted')).toEqual([]);
// a good native, then an outside model from the same family
review(path.join(out, 'ceo-native-prompt.md'));
r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]);
r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]);
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).toMatch(/^BOUND: \S+ phase=ceo voice=native model=claude-opus-4-7 findings=3/m);
review(path.join(out, 'ceo-outside-prompt.md'));
r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', path.join(out, 'ceo-outside.md'), '--model', 'claude-sonnet-5', '--attempt', ids.outside]);
r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', resultOf(out, 'ceo', 'outside'), '--model', 'claude-sonnet-5', '--attempt', ids.outside]);
expect(r.status).toBe(3);
expect(r.stderr).toMatch(/both anthropic.*\(MODEL_FAMILY_CONFLICT\)$/m);
// exactly once: the bound native attempt cannot be submitted again
r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]);
r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]);
expect(r.status).toBe(3);
expect(r.stderr).toMatch(/\(PHASE_NOT_AWAITING\)$/m);
});
@@ -140,7 +143,7 @@ describe('recovery', () => {
expect(readRun(out).attempt_counter).toBe(2);
// the provider had finished after all: the late result binds under the same attempt id
review(path.join(out, 'ceo-native-prompt.md'));
const late = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]);
const late = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]);
expect(late.status, late.stderr).toBe(0);
// the outside attempt is redispatched only when named explicitly; its charge stays as unknown, never zero
const redo = autoplan(['next', '--out', out, '--redispatch', ids.outside, '--estimate-usd', '1']);
@@ -157,7 +160,7 @@ describe('recovery', () => {
const ids = attemptsOf(out, 'ceo');
review(path.join(out, 'ceo-native-prompt.md'));
const snapshotBefore = fs.readFileSync(path.join(out, 'run.json'), 'utf8');
const ok = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]);
const ok = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]);
expect(ok.status, ok.stderr).toBe(0);
// simulate the crash: the journal and the ledger have the submit, run.json does not
fs.writeFileSync(path.join(out, 'run.json'), snapshotBefore);
@@ -173,6 +176,52 @@ describe('recovery', () => {
expect(spend.reserved.map((r: any) => r.attempt)).toEqual([ids.outside]);
});
test('killed after sealing, before the journal: nothing is bound, resume reports the attempt unknown, and the same submit then binds once', () => {
const out = startSmallRun(path.join(TMP, 'kill-sealed'));
const ids = attemptsOf(out, 'ceo');
review(path.join(out, 'ceo-native-prompt.md'));
const args = ['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native];
const faulted = autoplan(args, { env: env({ GSTACK_AUTOPLAN_FAULT: 'submit-after-seal' }) });
expect(faulted.status).not.toBe(0);
const sealed = path.join(out, `ceo-native.${ids.native.split('-').pop()}.bound.md`);
expect(fs.existsSync(sealed)).toBe(true);
expect(journal(out).some(r => r.event === 'submitted')).toBe(false);
expect(readRun(out).phases.ceo.native.status).toBe('running');
const resumed = JSON.parse(autoplan(['resume', '--out', out, '--json']).stdout);
expect(resumed).toMatchObject({ rebound: [], execution_unknown: [ids.native, ids.outside] });
const ok = autoplan(args);
expect(ok.status, ok.stderr).toBe(0);
expect(journal(out).filter(r => r.event === 'submitted')).toHaveLength(1);
expect(readRun(out).phases.ceo.native).toMatchObject({ status: 'completed', output: path.basename(sealed), attempt: ids.native });
});
test('the bound result is a sealed attempt-owned copy: a late write to the reviewer file changes nothing bound, and another attempt\'s file is refused', () => {
const out = startSmallRun(path.join(TMP, 'sealed'));
runPhase(out, 'ceo');
const result = resultOf(out, 'ceo', 'native');
const run = readRun(out);
const bound = path.join(out, run.phases.ceo.native.output);
expect(path.basename(bound)).toMatch(/^ceo-native\.a\d+\.bound\.md$/);
expect(fs.readFileSync(bound, 'utf8')).toBe(fs.readFileSync(result, 'utf8'));
if (process.platform !== 'win32') expect(fs.statSync(bound).mode & 0o222).toBe(0);
fs.appendFileSync(result, '\nLate edit after submit.\n');
runPhase(out, 'eng');
expect(readRun(out).status).toBe('gate_pending');
const v = validate(out);
expect(v.status, v.stdout + v.stderr).toBe(0);
expect(fs.readFileSync(bound, 'utf8')).not.toContain('Late edit');
const eng = attemptsOf(out, 'eng');
expect(path.basename(resultOf(out, 'eng', 'native'))).toBe(`eng-native.${eng.native.split('-').pop()}.md`);
const other = startSmallRun(path.join(TMP, 'sealed-other'));
const otherIds = attemptsOf(other, 'ceo');
review(path.join(other, 'ceo-native-prompt.md'));
const foreign = path.join(other, `ceo-native.a999.md`);
fs.copyFileSync(resultOf(other, 'ceo', 'native'), foreign);
const refused = autoplan(['submit', '--out', other, '--phase', 'ceo', '--voice', 'native', '--result', foreign, '--model', 'claude-opus-4-7', '--attempt', otherIds.native]);
expect(refused.status).toBe(3);
expect(refused.stderr).toMatch(/\(ATTEMPT_MISMATCH\)/);
});
test('a runner process killed mid-command leaves no lock behind that a later command cannot reclaim; a live owner is never stolen', () => {
const out = startSmallRun(path.join(TMP, 'lock'));
fs.writeFileSync(path.join(out, 'run.lock'), JSON.stringify({ pid: 999999, host: os.hostname(), at: new Date().toISOString() }));
+94
View File
@@ -0,0 +1,94 @@
{
"source": "autoplan run run-20261014225954 (next-wave plan review, 2026-10-14): ceo gate_rev 1 findings (*.findings.r1.jsonl) and dx gate_rev 2 findings (*.findings.jsonl), copied verbatim",
"pairs": [
{
"name": "ceo r1 outside-1 vs native-11",
"expect": "negative",
"why": "same file, lines 3 apart; different problems and remedies (project-aware Bun launching vs a single verdict owner plus a fresh-machine job); reconciled confirmed on file:line alone at 1.91.101.0",
"outside": [
{
"id": "run-20261014225954-ceo-outside-1",
"phase": "ceo",
"voice": "outside",
"severity": "High",
"title": "A fixed home-wide Bun link cannot preserve per-project runtime ownership",
"file": "bin/gstack-capy-install",
"line": 197,
"resolution": "Replace A2's mutable fixed-version global link with project-aware launching; bind install verdicts to the calling project and resolved runtime, reconcile pregate's separate resolver, and exercise alternating exact-pin projects plus unrelated Bun commands.",
"user_challenge": true,
"changes": "direction"
}
],
"native": [
{
"id": "run-20261014225954-ceo-native-11",
"phase": "ceo",
"voice": "native",
"severity": "Medium",
"title": "Fourth install-honesty wave in about two weeks: add a single verdict owner and a pre-merge fresh-machine acceptance job instead of a post-ship manual rerun",
"file": "bin/gstack-capy-install",
"line": 200,
"resolution": "Route install, upgrade, repair and session-start through one final step: gstack-capy-install --check from ORIG_PWD, printing only its trailer. Make the GBRA-60 reproduction (image Bun on PATH, verified Bun off PATH, project pinned to 1.4.3, fresh shells) an automated job on a fresh Capy or Ubicloud machine that gates Lane A's merge."
}
]
},
{
"name": "dx r2 outside-2 vs native-1",
"expect": "positive",
"why": "both find that the revised trailer grammar breaks the existing fail head and leaves the pins exit unspecified",
"outside": [
{
"id": "run-20261014225954-dx-outside-2",
"phase": "dx",
"voice": "outside",
"severity": "Medium",
"title": "The revised failure trailer has conflicting shapes and an unspecified pins exit code",
"file": "bin/gstack-doctor-check.sh",
"line": 459,
"resolution": "Define one backward-compatible status grammar with failed-component information, specify A7's numeric pins-readiness exit and partial-upgrade state, and synchronize text/JSON/verdict/docs with success and multi-failure note-composition tests."
}
],
"native": [
{
"id": "run-20261014225954-dx-native-1",
"phase": "dx",
"voice": "native",
"severity": "High",
"title": "A9's trailer grammar would replace the existing 'gstack: fail <components>' head that the Capy skill (and its lagging Drive copies) dispatch repairs on",
"file": "bin/gstack-doctor-check.sh",
"line": 460,
"resolution": "Scope the grammar to `gstack: ok <rev> for=<skill>[ (<notes>)]`. Keep failures as `gstack: fail <component>[,<component>] \u2014 <detail>; fix: <cmd> (<CODE>; <url>#<anchor>)`, with notes appended in the same fixed order. Add a test that runs the current capy/skill/gstack/SKILL.md dispatch examples (fail revision, fail runtime,pins, fail pins) against the new output."
}
]
},
{
"name": "dx r2 native-2 vs outside-4",
"expect": "positive",
"why": "same problem by meaning: a recommendation edit after the owner saw the gate changes what the reply resolves to; different files and titles",
"outside": [
{
"id": "run-20261014225954-dx-outside-4",
"phase": "dx",
"voice": "outside",
"severity": "High",
"title": "Recommendation edits can change a displayed gate's defaults without invalidating its reply",
"file": "lib/autoplan-gate.ts",
"line": 154,
"resolution": "Version semantic recommendation changes, reject old replies, and refresh plan-approval summaries, decisions and artifact hashes; return the new gate identity and test concurrent/repeated edits, answered/carried choices and restore."
}
],
"native": [
{
"id": "run-20261014225954-dx-native-2",
"phase": "dx",
"voice": "native",
"severity": "High",
"title": "The new recommend verb can change what rec resolves to after the owner has seen the gate",
"file": "lib/gate-list.ts",
"line": 112,
"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."
}
]
}
]
}
+33 -6
View File
@@ -9,7 +9,7 @@ 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 { applyReply, decisionRows, parseReply, renderGate, resolveAll, validateGateList, type GateList } from '../lib/gate-list';
import { applyReply, decisionRows, parseReply, recHash, renderGate, validateGateList, type GateList } from '../lib/gate-list';
import { EXIT, validateFile } from '../lib/headless-artifacts';
const ROOT = path.resolve(import.meta.dir, '..');
@@ -116,7 +116,7 @@ describe('reply grammar', () => {
});
describe('rendering', () => {
test('ids and numbering are stable across pages; each page prints the reply shape and the `all` resolution', () => {
test('ids and numbering are stable across pages; each page prints the reply shape and the `rec` resolution with its rec_hash', () => {
const p1 = renderGate(big, 1);
const p5 = renderGate(big, 5);
expect(p1).toContain('page=1/5');
@@ -127,10 +127,10 @@ describe('rendering', () => {
expect(p5).toContain('29. [d29] decision 29');
expect(p5).toContain('33. [d33] decision 33');
for (const page of [p1, p5]) {
expect(page).toContain('`all`');
expect(page).toContain('`all except <tokens>`');
expect(page).toContain(`gate_rev=1`);
expect(page).toContain(JSON.stringify({ gate_rev: 1, choices: resolveAll(big) }));
expect(page).toContain('`rec`');
expect(page).toContain('`rec except <tokens>`');
expect(page).toContain(`gate_rev=1 rec_hash=${recHash(big)}`);
expect(page).toContain(JSON.stringify({ gate_rev: 1, rec_hash: recHash(big), choices: Object.fromEntries(big.items.map(i => [i.id, i.recommended])) }));
}
expect(renderGate(list)).toContain(' d1a) option A (recommended)');
expect(renderGate(list)).not.toContain('page=1/2');
@@ -145,6 +145,33 @@ describe('rendering', () => {
});
});
describe('next wave B1/B-b: rec, aliases, quotes and the rec_hash', () => {
const held: GateList = { ...list, items: [{ ...list.items[0]!, recommended: 'b', options: [{ key: 'a', text: 'keep', changes_plan: false }, { key: 'b', text: 'change', changes_plan: true }, { key: 'c', text: 'other' }] }, ...list.items.slice(1)] };
test('every alias resolves like `rec`, plan changes included; `except` and earlier answers combine; quotes, backticks and newlines are separators', () => {
const want = { uc1: 'b', d1: 'a', d2: 'a', d3: 'a' };
for (const reply of ['rec', 'REC', 'all', 'approve all', 'accept all', 'take all recommendations', '`rec`', '"rec"', '\u201crec\u201d', 'rec\n']) {
const p = parseReply(reply, held, 1);
expect(p.valid, reply).toBe(true);
expect(p.mode).toBe('rec');
expect(chosen(p)).toEqual(want);
}
expect(chosen(parseReply('rec except uc1a d3b', held, 1))).toEqual({ uc1: 'a', d1: 'a', d2: 'a', d3: 'b' });
expect(chosen(parseReply('rec', held, 1, { d1: 'c' }))).toEqual({ uc1: 'b', d1: 'c', d2: 'a', d3: 'a' });
expect(parseReply('rec', held, 1, { d1: 'c' }).choices.find(c => c.id === 'd1')!.prior).toBe(true);
expect(chosen(parseReply('as-is', held, 1))).toEqual({ uc1: 'a', d1: 'a', d2: 'a', d3: 'a' });
expect(parseReply('d3b', held, 1).pending).toEqual(['uc1', 'd1']);
for (const bad of ['approve', 'accept', 'take all', 'rec rec', 'rec as-is', 'yes']) expect(parseReply(bad, held, 1).valid, bad).toBe(false);
});
test('rec_hash changes with a recommendation, its basis or rec_rev, and the render shows who set it', () => {
const h = recHash(held);
expect(recHash({ ...held })).toBe(h);
expect(recHash({ ...held, rec_rev: 1 })).not.toBe(h);
const moved = { ...held, items: held.items.map(i => (i.id === 'd1' ? { ...i, recommended: 'b', recommended_basis: 'both reviewers name it', recommended_by: 'agent:relay' } : i)) };
expect(recHash(moved)).not.toBe(h);
expect(renderGate(moved)).toContain('[d1] Delivery shape (approval; recommended: b (both reviewers name it; set by agent:relay))');
});
});
describe('decisions.jsonl rows', () => {
test('pending rows carry gate_rev, kind, recommended and the auto default; applying a reply sets chosen/status', () => {
const rows = decisionRows(list);
+3 -3
View File
@@ -11,7 +11,7 @@ import * as path from 'node:path';
import { capyHost, type CapyHost } from './capy-host';
import { SKILL_REQUIREMENTS } from '../../lib/doctor-components';
import type { InstallVerdict } from '../../lib/install-verdict';
import { AUTOPLAN, REVIEWER, ROOT, SMALL_PLAN } from './autoplan-run-fixture';
import { AUTOPLAN, REVIEWER, ROOT, SMALL_PLAN, resultOf } from './autoplan-run-fixture';
const SESSION_KIND_VAR = ['GSTACK', 'SESSION', 'KIND'].join('_');
const RUNNER_KIND = 'un' + 'attended';
@@ -75,7 +75,7 @@ export function phaseVia(r: CapyRunner, out: string, phase: string, findings: {
if (rv.status !== 0) throw new Error(`fixture reviewer failed: ${rv.stderr}`);
}
const run = readRunJson(out);
const native = r.ap(['submit', '--out', out, '--phase', phase, '--voice', 'native', '--result', path.join(out, `${phase}-native.md`), '--model', 'anthropic/claude-opus-5-5', '--attempt', run.phases[phase].native.attempt, '--runner', 'host-subagent']);
const outside = r.ap(['submit', '--out', out, '--phase', phase, '--voice', 'outside', '--result', path.join(out, `${phase}-outside.md`), '--model', 'openai/gpt-6.1-sol', '--attempt', run.phases[phase].outside.attempt, '--runner', 'host-subagent']);
const native = r.ap(['submit', '--out', out, '--phase', phase, '--voice', 'native', '--result', resultOf(out, phase, 'native'), '--model', 'anthropic/claude-opus-5-5', '--attempt', run.phases[phase].native.attempt, '--runner', 'host-subagent']);
const outside = r.ap(['submit', '--out', out, '--phase', phase, '--voice', 'outside', '--result', resultOf(out, phase, 'outside'), '--model', 'openai/gpt-6.1-sol', '--attempt', run.phases[phase].outside.attempt, '--runner', 'host-subagent']);
return { next, native, outside };
}
+11 -2
View File
@@ -49,6 +49,15 @@ export function readRun(out: string): any {
export function journal(out: string): any[] {
return fs.readFileSync(path.join(out, 'attempts.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l));
}
/** The result path the open (else the latest) attempt of a voice owns: `<phase>-<voice>.<attempt>.md`. */
export function resultOf(out: string, phase: string, voice: 'native' | 'outside'): string {
const attempts: Array<{ state: string; result: string }> = readRun(out).phases[phase]?.[voice]?.attempts ?? [];
const attempt = attempts.find(a => a.state === 'dispatched' || a.state === 'execution_unknown') ?? attempts[attempts.length - 1];
if (!attempt) throw new Error(`${phase} ${voice} has no attempt in ${out}`);
return attempt.result;
}
/** The sealed copy a completed voice is bound to. */
export const boundOf = (out: string, phase: string, voice: 'native' | 'outside'): string => path.join(out, readRun(out).phases[phase][voice].output);
export function attemptsOf(out: string, phase: string): { native: string; outside: string } {
const run = readRun(out);
return { native: run.phases[phase].native.attempt, outside: run.phases[phase].outside.attempt };
@@ -65,8 +74,8 @@ export function runPhase(out: string, phase: string, o: PhaseRunOptions = {}): {
if (r.status !== 0) throw new Error(`fixture reviewer failed: ${r.stderr}`);
}
const ids = attemptsOf(out, phase);
const native = autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'native', '--result', path.join(out, `${phase}-native.md`), '--model', o.nativeModel ?? 'anthropic/claude-opus-4-7', '--attempt', ids.native, '--runner', 'host-subagent']);
const outside = autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'outside', '--result', path.join(out, `${phase}-outside.md`), '--model', o.outsideModel ?? 'openai/gpt-6-astra', '--attempt', ids.outside, '--runner', 'host-subagent', ...(o.reconcile ? ['--reconcile', o.reconcile] : [])]);
const native = autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'native', '--result', resultOf(out, phase, 'native'), '--model', o.nativeModel ?? 'anthropic/claude-opus-4-7', '--attempt', ids.native, '--runner', 'host-subagent']);
const outside = autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'outside', '--result', resultOf(out, phase, 'outside'), '--model', o.outsideModel ?? 'openai/gpt-6-astra', '--attempt', ids.outside, '--runner', 'host-subagent', ...(o.reconcile ? ['--reconcile', o.reconcile] : [])]);
return { next, native, outside };
}
+55 -1
View File
@@ -13,7 +13,7 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { REALITY_ROWS, REALITY_SCOPES, TIER1_ROW_IDS, checkPlanReality, checkRealityRows, parseRealityLines, parseScopes, renderIncidentTable, renderRows, requiredRows } from '../lib/plan-reality';
import { PLAN_REALITY_CHECKER, REALITY_ROWS, REALITY_SCOPES, TIER1_ROW_IDS, checkPlanReality, checkRealityRows, parseRealityLines, parseScopes, renderIncidentTable, renderRows, requiredRows, vaguePlanTerms } from '../lib/plan-reality';
import { extractHeading } from '../lib/autoplan-prompts';
import { RESULT_CODES } from '../lib/result-codes';
import { generatePlanRealityRows } from '../scripts/resolvers/plan-reality';
@@ -311,3 +311,57 @@ describe('field wave item 12: a receipt per measured number, the rule text for a
expect(r.unreceipted_numbers.length).toBeLessThanOrEqual(6);
});
});
describe('next wave B6/B10: PLAN_REALITY flags without false positives; plan quotes need no receipt', () => {
const others = (id: string) => Object.entries(FIXTURE.rows).filter(([k]) => k !== id).map(([, r]) => r.positive).join('\n');
test('B6: item and section references are not quantities; a phrase in backticks is named, not claimed (plan scope)', () => {
const plan = "B1. `rec`: one word takes every recommendation (item 5).\nB6. When the plan names `minimum effect`, `margin`, `decision rule` or a dollar cost, the gate flags it (items 10, 14–19; section 3).\n";
const r = checkPlanReality(plan);
expect(r.unreceipted_numbers).toEqual([]);
expect(r.unquoted).toEqual([]);
expect(checkPlanReality('Startup takes roughly 9 s on a cold machine.\n').unreceipted_numbers).toEqual(['Startup takes roughly 9 s on a cold machine.']);
expect(checkPlanReality('The decision rule was written first.\n').unquoted).toEqual(['The decision rule was written first.']);
expect(PLAN_REALITY_CHECKER).toBe('plan-reality/3');
});
test('B-f negative control: a measured number inside backticks in review scope stays flagged; a command in backticks still receipts', () => {
const quiet = 'REALITY: numbers pass the cold install took `86 s` on Capy';
expect(checkRealityRows(`${others('numbers')}\n${quiet}`, { phase: 'eng' }).unreceipted_numbers).toEqual(['the cold install took `86 s` on Capy']);
const cmd = 'REALITY: numbers pass the cold install took 86 s `bun run bench --json` on Capy';
expect(checkRealityRows(`${others('numbers')}\n${cmd}`, { phase: 'eng' }).unreceipted_numbers).toEqual([]);
});
test('B10: in reviewer scope a number quoted verbatim from the reviewed plan needs no receipt; a reworded or unknown quote does', () => {
const plan = 'Add exponential backoff: base 500 ms, factor 2, cap 30 s, jitter 10%.\n';
const quoted = 'REALITY: numbers pass the plan says "base 500 ms, factor 2, cap 30 s" and gives no reason for the cap (plan.md:1)';
expect(checkRealityRows(`${others('numbers')}\n${quoted}`, { phase: 'eng', planText: plan }).unreceipted_numbers).toEqual([]);
expect(checkRealityRows(`${others('numbers')}\n${quoted}`, { phase: 'eng' }).unreceipted_numbers.length).toBeGreaterThan(0);
const reworded = 'REALITY: numbers pass the plan says "a 45 s cap" and gives no reason';
expect(checkRealityRows(`${others('numbers')}\n${reworded}`, { phase: 'eng', planText: plan }).unreceipted_numbers.length).toBe(1);
const own = 'REALITY: numbers pass the plan says "cap 30 s"; retries took 900 ms in my run';
expect(checkRealityRows(`${others('numbers')}\n${own}`, { phase: 'eng', planText: plan }).unreceipted_numbers).toEqual(['retries took 900 ms in my run']);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-reality-quote-'));
fs.writeFileSync(path.join(dir, 'plan.md'), plan);
const bin = run(['check', '--phase', 'eng', '--plan', path.join(dir, 'plan.md'), '-'], `${others('numbers')}\n${quoted}`);
expect(bin.code, bin.err).toBe(EXIT.ok);
expect(run(['check', '--phase', 'eng', '-'], `${others('numbers')}\n${quoted}`).code).toBe(EXIT.fail);
fs.rmSync(dir, { recursive: true, force: true });
});
test('B6: vaguePlanTerms flags minimum effect, margin, decision rule and dollar cost only without a number, arm list or rule body', () => {
const plan = [
'Ship when the minimum effect is reached.',
'Keep a safety margin on the spend.',
'A margin of 15% over the pilot price.',
'The decision rule:',
'- arm A: keep the default',
'- arm B: switch',
'We price the dollar cost later (item 9).',
'The decision rule is "stop when recall drops below the base".',
'Name `minimum effect` in the template.',
'```', 'margin inside a fence', '```',
].join('\n');
expect(vaguePlanTerms(plan).map(v => [v.phrase, v.line])).toEqual([['minimum effect', 1], ['margin', 2], ['dollar cost', 7]]);
});
});