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