mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
fix(autoplan): one-word rec, verified pairing, sealed attempt results, deferred reopen (Lane B: B1 B2 B4 B5)
B1/B-b/B-c `rec` (aliases: all, approve all, accept all, take all recommendations)
takes every recommendation, plan changes included; `as-is` keeps every
direction. The render offers `rec` first and prints what it resolves to
with gate_rev and rec_hash. `gstack-autoplan recommend` changes one
recommendation before relay (provenance recorded, p1 and decisions follow)
and bumps rec_rev, so a `rec` without the new --rec-hash is refused
(GATE_REC_STALE). An agent answer needs the owner's words verbatim
(--owner-reply <text>|-, --owner-reply-file; OWNER_REPLY_REQUIRED); a
parseable owner reply that resolves differently from the relay is refused
(GATE_OWNER_REPLY_MISMATCH). Supersedes field-wave R1 per the owner's
gate_rev 1 decision.
B2/B-a reconcile associates by title first; a file:line-only match is
`unverified` (associated, never confirmed). `b` is recommended only for a
confirmed pair with the same `changes` and one shared proposal (override)
or the same normalized remedy; the basis says which. Negative control and
positives copied from run-20261014225954 (test/fixtures/autoplan-pairing).
B4/B-e each attempt writes <phase>-<voice>.<attempt>.md; submit refuses another
attempt's file (ATTEMPT_MISMATCH) and binds a sealed 0444
<phase>-<voice>.<attempt>.bound.md copy, so a late write changes nothing
bound; reopen keeps sealed copies in place.
B5/B-d answer records accepted changes as amendments (status amend_pending) and
reopens nothing; revise reopens what changed. `revise --settle <id|d4>`
discharges a packaging/ordering change on an unchanged plan
(AMENDMENT_NOT_SETTLEABLE otherwise). APPLY/NEXT/status lines say which.
DRIVER.md and the reply table move to the rec grammar.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
78ee23240c
commit
77ab139675
30 files changed
+937
-341
No files matched your search
+24
-14
@@ -28,12 +28,13 @@ 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`.
|
||||
|
||||
@@ -51,16 +52,20 @@ 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).
|
||||
|
||||
## Reviewer result format
|
||||
|
||||
@@ -73,22 +78,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.
|
||||
@@ -108,7 +114,11 @@ is the completion check. Copy the whole directory, never single files.
|
||||
- `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_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
|
||||
- `GSTACK_VERSION_CHANGED`: nothing to do for an older run (journaled forward migrations ran); a run written by a newer gstack needs that gstack: upgrade with bin/gstack-upgrade
|
||||
|
||||
## Coordinator contract (inherited by every lane)
|
||||
|
||||
@@ -25,12 +25,13 @@ 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`.
|
||||
|
||||
@@ -54,7 +55,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.
|
||||
|
||||
+41
-18
@@ -7,10 +7,11 @@
|
||||
* 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 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> [--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
|
||||
@@ -20,12 +21,12 @@
|
||||
* 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 { admit, touchInstallLease } from '../lib/autoplan-admission';
|
||||
import { exportRun } from '../lib/autoplan-export';
|
||||
import { migrateRun } from '../lib/autoplan-migrate';
|
||||
@@ -55,12 +56,17 @@ 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]
|
||||
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> [--json] 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)
|
||||
|
||||
@@ -87,10 +93,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('; ')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,12 +175,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;
|
||||
@@ -249,17 +258,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 +306,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`);
|
||||
|
||||
@@ -184,6 +184,10 @@ 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' ;;
|
||||
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' ;;
|
||||
|
||||
+51
-1
@@ -2700,7 +2700,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.
|
||||
|
||||
@@ -2716,6 +2719,53 @@ 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="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`
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+131
-51
@@ -20,11 +20,11 @@ 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';
|
||||
@@ -57,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 });
|
||||
@@ -71,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. */
|
||||
@@ -140,19 +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}`);
|
||||
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)`);
|
||||
items.push({ 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' }] });
|
||||
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;
|
||||
@@ -183,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;
|
||||
}));
|
||||
@@ -225,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 };
|
||||
@@ -246,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);
|
||||
@@ -308,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`);
|
||||
|
||||
+10
-1
@@ -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');
|
||||
|
||||
+35
-26
@@ -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 };
|
||||
@@ -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):',
|
||||
|
||||
+58
-13
@@ -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, 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. */
|
||||
|
||||
+19
-10
@@ -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, normalizeFindingFile, 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 { 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';
|
||||
@@ -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,7 +211,8 @@ 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() };
|
||||
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: row.prompt, prompt_sha256: sha256File(row.prompt), result: row.result, estimate_usd: estimate });
|
||||
(v.attempts ??= []).push(row);
|
||||
v.attempt = attempt; v.status = 'running';
|
||||
@@ -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,11 +315,13 @@ 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);
|
||||
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 });
|
||||
@@ -340,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}`];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -419,7 +427,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';
|
||||
@@ -440,6 +448,7 @@ 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}`);
|
||||
@@ -447,6 +456,6 @@ export function statusLines(state: RunState): string[] {
|
||||
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 };
|
||||
@@ -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;
|
||||
|
||||
+44
-40
@@ -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,8 +27,10 @@ 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). */
|
||||
@@ -33,15 +38,21 @@ export interface GateItem {
|
||||
/** 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[] {
|
||||
@@ -70,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 {
|
||||
@@ -100,7 +103,7 @@ 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)' : ''}`);
|
||||
@@ -109,10 +112,10 @@ export function renderGate(list: GateList, page = 1, pageSize = PAGE_SIZE): stri
|
||||
});
|
||||
lines.push('');
|
||||
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: `all` · `<id><option>` tokens, e.g. `' + exampleTokens(list) + '` · `all except <tokens>` · `all as-is` (keep every direction).');
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -122,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++) {
|
||||
@@ -145,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' };
|
||||
}
|
||||
@@ -176,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. */
|
||||
@@ -208,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',
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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' },
|
||||
|
||||
+21
-1
@@ -509,13 +509,33 @@ 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`',
|
||||
},
|
||||
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',
|
||||
|
||||
@@ -22,24 +22,26 @@ 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)' },
|
||||
];
|
||||
|
||||
/** 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', '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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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', 'PLAN_AMEND_PENDING', 'APPLY:']) {
|
||||
expect(doc).toContain(token);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() }));
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user