mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 22:45:02 +01:00
feat(autoplan): gates print their cost, --owner-standing rec cites a standing approval, approvals mirror to the Captain's drive
E16: GATE_SPEND and GATE_REC_COST under every GATE line (lib/autoplan-gate-cost.ts), and a Cost so far sentence in the owner brief; host-subagent attempts stay priced unknown. E21: gstack-autoplan answer --owner-standing rec --by agent:<id> answers rec by citing the newest standing approval covering autoplan recommendations from any branch's record; the id lands on every decision row, in run.json and in the approvals import; refused with STANDING_APPROVAL_ABSENT or STANDING_APPROVAL_INAPPLICABLE (urgent finding). E22: the approvals mirror honours the approvals/ directory a Captain created in its drive, between GSTACK_APPROVALS_DIR and the private project-user folder; gstack-approvals path names the rule. E23: the Eng phase asks for a performance budget and a bench step on hot-path changes. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
576839cb8c
commit
e6a094ed0e
17 files changed
+504
-47
No files matched your search
@@ -1,5 +1,25 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
**An autoplan gate now arrives with its price, a standing "take the recommendations" answers it without a round trip, and the approvals record lands where the whole crew can read it.** Three Captains reported the same three frictions: review rounds and reviewer subagents had no credit figure at the gate, every autoplan gate round-tripped through the Captain even though the owner had said once to take the recommendations (four gates, 87 minutes, on one plan), and the approvals mirror sat in a private project-user folder no sibling thread could read. The Eng phase also gains the performance-budget question, after a correctness pass doubled a consumer's p95 with no bench step to catch it.
|
||||
|
||||
### Added
|
||||
- **`GATE_SPEND:` and `GATE_REC_COST:`** (`lib/autoplan-gate-cost.ts`; printed by every gate-producing `gstack-autoplan` command under `GATE:`): the run's ledger at the gate (spent, worst case, cap, headroom, attempts; host-subagent attempts are priced `unknown` and counted at their reservation, with a pointer to the host's own cost view instead of an invented figure) and the price of `rec` (the phases its plan-changing recommendations reopen, Eng last, two voices each with the outside voice on, at the per-voice reservation the run has been making; a reopen past the cap says so). The owner brief (`gstack-owner-brief render`) carries the same figures as one `Cost so far:` sentence under its Summary.
|
||||
- **`gstack-autoplan answer --owner-standing rec --by agent:<id> [--branch <name>]`** (`lib/autoplan-gate.ts` `standingAnswer`): answers `rec` by citing the newest `gstack-approvals` row with `--outcome standing` whose ask or scope names autoplan and whose words or scope name the recommendations, from any branch's record of the project (local and mirror), so a Captain records the owner's standing words once and every lane cites them. The row's words are the owner reply, its id lands as `standing` on every answered decision row, in `run.json`'s gate and in the journal, a `STANDING:` line names it, and `gstack-approvals import --autoplan` records `standing=<id>` in the source and "standing approval <id> cited by agent:<id>" as the relay. Refused with `STANDING_APPROVAL_ABSENT` when no row covers it and with `STANDING_APPROVAL_INAPPLICABLE` when the gate carries an urgent or outside-plan finding, which standing words do not cover; `--owner-standing` takes only `rec` and excludes `--reply`, `--owner-reply` and `--rec-hash`.
|
||||
- **The Captain-named approvals directory** (`lib/approval-record.ts` `captainApprovalsDir`): on Capy the mirror is `GSTACK_APPROVALS_DIR` when set, else the `approvals/` directory in the one mounted Captain drive (`~/.capy/drive/captain-*/approvals/<slug>/<branch key>.jsonl`, readable by every crew thread), else the private project-user folder as before; two Captain drives with one each is ambiguous and falls through. `gstack-approvals path` prints which rule chose the mirror.
|
||||
- **Result codes** (`lib/result-codes-capy.ts`): `STANDING_APPROVAL_ABSENT`, `STANDING_APPROVAL_INAPPLICABLE`, each with a `docs/troubleshooting.md` anchor.
|
||||
|
||||
### Changed
|
||||
- **Eng phase, Section 4 (Performance)** (`autoplan/sections/eng-phase.md.tmpl`): after the slow-path review, the budget question: does the plan name a performance budget for the paths it changes (p95 latency, throughput, memory or bundle size) and a bench step that records the number before and after? A hot-path change with neither is a finding whose recommendation adds both to the Test plan.
|
||||
- **`autoplan/DRIVER.md`, `docs/capy-install.md`, `docs/troubleshooting.md`**: the loop relays the two cost lines with every gate render and names the standing path and its two refusals; the Captain section names the crew-readable mirror and the one-line `gstack-approvals add --outcome standing` a Captain records; the `APPROVAL_RECORD_UNAVAILABLE` fix names the three mirror sources. `capy/skill/gstack/SKILL.md` is untouched here (it is #3475's file) and points at `autoplan/DRIVER.md` already.
|
||||
|
||||
### Tests
|
||||
- `test/autoplan-standing.test.ts` (new): `recReopens` and the two cost lines from a ledger with an unknown host attempt (cap, no cap, over the cap, no ledger); the mirror chain (project-user, one Captain drive, two, `GSTACK_APPROVALS_DIR`, mirror off) and `gstack-approvals path`; `coversAutoplanRec`; `standingRecApproval` across branch files, local and mirror, newest first; and three end-to-end runs through `bin/gstack-autoplan`: no covering row refuses and keeps the gate, a row from another branch answers `rec` with the citation on every row and in the import, an urgent finding refuses. The `GATE_SPEND:`/`GATE_REC_COST:` lines are asserted on the submit that closes the last phase, and the brief's `Cost so far:` sentence on its render.
|
||||
|
||||
### Gate integrity
|
||||
- No threshold, rubric, trial count, case kind or judge prompt changed. The only prompt edit is the Eng phase's performance paragraph; the one paid autoplan case (`autoplan-dual-voice`) reads Phase 1, which is untouched.
|
||||
|
||||
## [1.91.256.0] - 2026-10-26
|
||||
|
||||
**The /ship review loop converges, and a receipt survives a base merge.** Five Captains' /ship runs hit the review cap on the same PR shape: every pass re-reviewed the whole diff, reviewers answered an empty range with an invented advisory, and each advisory earned another full pass; four more re-ran /ship on product code they had not changed because a conflict-only main merge or a test fix moved the head the receipt named. The loop now fixes blocking findings in the diff on pass 1, reads only the range since the previous pass afterwards, caps at three passes, and counts `passes=<n> blocking_found=<k> deferred=<m>` in the ship receipt; the receipt carries to the new head when the product patch-id is unchanged. The blocking floor is the owner's call (Garry, 2026-10-26: "Ok set it at that"), and the policy lives in gstack rather than in any one team's rules (Garry, 2026-10-26: "Gstack should be the actual holder of how to do things not one off Capy files"): `docs/review-policy.md` is the canonical statement, the skills carry it as their written policy, and a crew rule can be one line pointing at it.
|
||||
|
||||
+5
-6
@@ -27,12 +27,11 @@ line yourself; gstack never writes the plan.
|
||||
per voice. Dispatch the native prompt to a subagent on your model and the outside prompt to
|
||||
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 --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> --owner-reply "<their words>"` (you relayed them); without `--by` an
|
||||
unattended run refuses (`ANSWER_BY_REQUIRED`). `APPLY: <id> <title> -> <change>` lines are
|
||||
3. Repeat `next` until `status=gate_pending` (`GATE: gate_rev=<n>`); render it with `gstack-gate render <dir>/gate.json --all` and relay every page with the two cost lines printed under `GATE:`: `GATE_SPEND:` (spent, worst case, cap, headroom, attempts; host-subagent attempts are priced unknown and counted at their reservation, so quote their real figure from the host's cost view) and `GATE_REC_COST:` (the phases `rec` reopens and the estimate for them).
|
||||
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> --owner-reply "<their words>"` (you relayed them); without `--by` an unattended run refuses (`ANSWER_BY_REQUIRED`).
|
||||
When the owner's standing words for autoplan gates are on record (a `gstack-approvals` row with `--outcome standing` whose scope names autoplan and whose words name the recommendations, recorded once by the Captain on any branch of the project), `answer --gate-rev <n> --owner-standing rec --by agent:<id>` takes every recommendation citing that row (`STANDING:` line, `standing=<id>` on every decision row) with no round trip; still relay the render as information.
|
||||
It refuses with `STANDING_APPROVAL_ABSENT` when no such row exists and with `STANDING_APPROVAL_INAPPLICABLE` when the gate carries an urgent or outside-plan finding, which standing words do not cover. `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.
|
||||
|
||||
@@ -24,12 +24,11 @@ line yourself; gstack never writes the plan.
|
||||
per voice. Dispatch the native prompt to a subagent on your model and the outside prompt to
|
||||
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 --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> --owner-reply "<their words>"` (you relayed them); without `--by` an
|
||||
unattended run refuses (`ANSWER_BY_REQUIRED`). `APPLY: <id> <title> -> <change>` lines are
|
||||
3. Repeat `next` until `status=gate_pending` (`GATE: gate_rev=<n>`); render it with `gstack-gate render <dir>/gate.json --all` and relay every page with the two cost lines printed under `GATE:`: `GATE_SPEND:` (spent, worst case, cap, headroom, attempts; host-subagent attempts are priced unknown and counted at their reservation, so quote their real figure from the host's cost view) and `GATE_REC_COST:` (the phases `rec` reopens and the estimate for them).
|
||||
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> --owner-reply "<their words>"` (you relayed them); without `--by` an unattended run refuses (`ANSWER_BY_REQUIRED`).
|
||||
When the owner's standing words for autoplan gates are on record (a `gstack-approvals` row with `--outcome standing` whose scope names autoplan and whose words name the recommendations, recorded once by the Captain on any branch of the project), `answer --gate-rev <n> --owner-standing rec --by agent:<id>` takes every recommendation citing that row (`STANDING:` line, `standing=<id>` on every decision row) with no round trip; still relay the render as information.
|
||||
It refuses with `STANDING_APPROVAL_ABSENT` when no such row exists and with `STANDING_APPROVAL_INAPPLICABLE` when the gate carries an urgent or outside-plan finding, which standing words do not cover. `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.
|
||||
|
||||
@@ -142,7 +142,10 @@ Missing/disabled voice = N/A, never CONFIRMED. Flag any single-voice critical fi
|
||||
skipping the analysis.
|
||||
- Write the test plan artifact to disk
|
||||
|
||||
6. Section 4 (Performance): Evaluate N+1 queries, memory, caching, slow paths.
|
||||
6. Section 4 (Performance): Evaluate N+1 queries, memory, caching, slow paths. Then the
|
||||
budget question: does the plan name a performance budget for the paths it changes (p95,
|
||||
throughput, memory or bundle size) and a bench step that records it before and after?
|
||||
A hot-path change with neither is a finding: recommend both as Test plan additions.
|
||||
|
||||
**Mandatory outputs from Phase 3:**
|
||||
- "NOT in scope" section
|
||||
|
||||
@@ -95,7 +95,10 @@ Missing/disabled voice = N/A, never CONFIRMED. Flag any single-voice critical fi
|
||||
skipping the analysis.
|
||||
- Write the test plan artifact to disk
|
||||
|
||||
6. Section 4 (Performance): Evaluate N+1 queries, memory, caching, slow paths.
|
||||
6. Section 4 (Performance): Evaluate N+1 queries, memory, caching, slow paths. Then the
|
||||
budget question: does the plan name a performance budget for the paths it changes (p95,
|
||||
throughput, memory or bundle size) and a bench step that records it before and after?
|
||||
A hot-path change with neither is a finding: recommend both as Test plan additions.
|
||||
|
||||
**Mandatory outputs from Phase 3:**
|
||||
- "NOT in scope" section
|
||||
|
||||
@@ -11,8 +11,12 @@
|
||||
* gstack-approvals sync [--branch <name>]
|
||||
* gstack-approvals path [--branch <name>]
|
||||
*
|
||||
* Rows are kept per branch under the state root and, on Capy, mirrored to the
|
||||
* private Drive folder. Every field is secret-scanned before it is written.
|
||||
* Rows are kept per branch under the state root and, on Capy, mirrored to
|
||||
* GSTACK_APPROVALS_DIR, else the `approvals/` directory a Captain created in
|
||||
* its drive (readable by the whole crew), else the private project-user Drive
|
||||
* folder. Every field is secret-scanned before it is written. A row with
|
||||
* `--outcome standing` whose scope names autoplan and whose words or scope name
|
||||
* the recommendations is what `gstack-autoplan answer --owner-standing rec` cites.
|
||||
* `render` prints the `## Approvals` section /ship puts in the PR body. Logic:
|
||||
* lib/approval-record.ts.
|
||||
*/
|
||||
@@ -28,8 +32,8 @@ const HELP = `gstack-approvals — the per-thread approval record (capy/skill/gs
|
||||
import --autoplan <run dir> [--by <whose words, e.g. Garry>]
|
||||
render [--no-heading] the ## Approvals section for a PR body or report
|
||||
list [--json]
|
||||
sync re-mirror the record (Capy Drive or GSTACK_APPROVALS_DIR)
|
||||
path print the record and mirror paths
|
||||
sync re-mirror the record (GSTACK_APPROVALS_DIR, the Captain's drive approvals/, or the project-user Drive)
|
||||
path print the record and mirror paths, and which rule chose the mirror
|
||||
Every command takes [--branch <name>] (default: the checked-out branch).
|
||||
|
||||
Example:
|
||||
@@ -107,7 +111,7 @@ function main(argv: string[]): number {
|
||||
return s.error ? fail(cmd, s.error) : EXIT.ok;
|
||||
}
|
||||
if (cmd === 'path') {
|
||||
process.stdout.write(`RECORD: ${loc.file}\nMIRROR: ${loc.mirror ?? 'none'}\nBRANCH: ${loc.branch}\n`);
|
||||
process.stdout.write(`RECORD: ${loc.file}\nMIRROR: ${loc.mirror ? `${loc.mirror} (${loc.mirror_source})` : 'none'}\nBRANCH: ${loc.branch}\n`);
|
||||
return EXIT.ok;
|
||||
}
|
||||
process.stderr.write(`gstack-approvals: unknown command ${cmd}\n${HELP}\n`);
|
||||
|
||||
+28
-10
@@ -11,6 +11,7 @@
|
||||
* 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>" --by <human|agent:<id>> [--owner-reply <text>|-] [--rec-hash <h>] [--json]
|
||||
* gstack-autoplan answer --out <dir> --gate-rev <rev> --owner-standing rec --by agent:<id> [--branch <name>] [--json] cite a standing approval
|
||||
* 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
|
||||
@@ -30,8 +31,10 @@
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { amendmentSteps, answerGate, closeDeferred, finishGate, recommendGate } from '../lib/autoplan-gate';
|
||||
import { changeBudget, liftStop } from '../lib/autoplan-budget';
|
||||
import { amendmentSteps, answerGate, closeDeferred, finishGate, recommendGate, standingAnswer } from '../lib/autoplan-gate';
|
||||
import { gateCost, renderGateCost } from '../lib/autoplan-gate-cost';
|
||||
import { locate, standingRecApproval } from '../lib/approval-record';
|
||||
import { attemptEstimate, changeBudget, liftStop } from '../lib/autoplan-budget';
|
||||
import { ANSWERED_BY_RE } from '../lib/gate-list';
|
||||
import { checkpointLines, checkpointRun, restoreCheckpoint, type CheckpointMoment } from '../lib/autoplan-checkpoint';
|
||||
import { admit, touchInstallLease } from '../lib/autoplan-admission';
|
||||
@@ -76,6 +79,9 @@ DRIVER: ${driverFile()}
|
||||
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
|
||||
--owner-standing rec --by agent:<id> [--branch <name>]: answer rec by citing the newest gstack-approvals row with
|
||||
--outcome standing whose scope names autoplan and whose words name the recommendations (any branch of this project);
|
||||
refused when none exists (STANDING_APPROVAL_ABSENT) or the gate carries an urgent finding (STANDING_APPROVAL_INAPPLICABLE)
|
||||
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
|
||||
@@ -185,17 +191,19 @@ function doNext(out: string, rest: string[], json: boolean): number {
|
||||
moment = fresh ? 'init' : r.attempts.some(a => a.new) || r.lines.some(l => l.startsWith('PHASE_DEFERRED: ')) ? 'transition' : undefined;
|
||||
const status = r.state.status;
|
||||
const lines = [...initLines, ...r.lines];
|
||||
if (status === 'gate_pending') lines.push(...gateLines(r.state.gate_rev, out));
|
||||
if (status === 'gate_pending') lines.push(...gateLines(r.state, out));
|
||||
if (status === 'running') lines.push(`NEXT: gstack-autoplan next --out ${out}`);
|
||||
lines.push(resultLineFor(status, out));
|
||||
emit(json, lines, { run: r.state.run, status, phase: r.phase ?? null, attempts: r.attempts, gate_rev: r.state.gate_rev ?? null, out });
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
/** The gate pointer, plus the owner-facing render to relay under Capy (lib/captain-brief.ts). */
|
||||
function gateLines(gateRev: number | undefined, out: string): string[] {
|
||||
/** The gate pointer, its cost so far and the price of `rec` (lib/autoplan-gate-cost.ts), plus the owner-facing render to relay under Capy (lib/captain-brief.ts). */
|
||||
function gateLines(state: RunState, out: string): string[] {
|
||||
const relay = capyRelayLine(out);
|
||||
return [`GATE: gate_rev=${gateRev} render with: gstack-gate render ${path.join(out, 'gate.json')}`, ...(relay ? [relay] : [])];
|
||||
const gateFile = path.join(out, 'gate.json');
|
||||
const cost = fs.existsSync(gateFile) ? renderGateCost(gateCost(out, JSON.parse(fs.readFileSync(gateFile, 'utf8')), state, { defaultPerVoiceUsd: attemptEstimate(state, undefined, process.env).usd })) : [];
|
||||
return [`GATE: gate_rev=${state.gate_rev} render with: gstack-gate render ${gateFile}`, ...cost, ...(relay ? [relay] : [])];
|
||||
}
|
||||
|
||||
function doSubmit(out: string, rest: string[], json: boolean): number {
|
||||
@@ -211,7 +219,7 @@ function doSubmit(out: string, rest: string[], json: boolean): number {
|
||||
unavailable, failed, runner: flag(rest, '--runner'), usageUsd: num(rest, '--usage-usd'), reconcile: flag(rest, '--reconcile'), cwd: process.cwd() });
|
||||
const status = r.state.status;
|
||||
const lines = [...r.lines];
|
||||
if (status === 'gate_pending') lines.push(...gateLines(r.state.gate_rev, out));
|
||||
if (status === 'gate_pending') lines.push(...gateLines(r.state, out));
|
||||
if (status === 'running') lines.push(`NEXT: gstack-autoplan next --out ${out}`);
|
||||
lines.push(resultLineFor(status, out));
|
||||
emit(json, lines, { run: r.state.run, status, phase, voice, attempt: r.state.phases[phase]?.[voice]?.attempt ?? attempt ?? null, closed: r.closed, findings: r.findings, gate_rev: r.state.gate_rev ?? null, out });
|
||||
@@ -242,7 +250,7 @@ function doClose(out: string, rest: string[], json: boolean): number {
|
||||
const r = closeDeferred(state, phase as Phase, { reason: reason.trim(), by, cause: 'owner' });
|
||||
moment = 'transition';
|
||||
const lines = [...r.lines, ...(stopped ? liftStop(state, `${phase} closed deferred by ${by}`) : [])];
|
||||
if (!currentPhase(state)) { finishGate(state); lines.push(...gateLines(state.gate_rev, out)); }
|
||||
if (!currentPhase(state)) { finishGate(state); lines.push(...gateLines(state, out)); }
|
||||
else { state.status = 'running'; lines.push(`NEXT: gstack-autoplan next --out ${out}`); }
|
||||
writeState(state);
|
||||
lines.push(resultLineFor(state.status, out));
|
||||
@@ -339,14 +347,24 @@ async function main(argv: string[]): Promise<number> {
|
||||
}
|
||||
if (cmd === 'answer') {
|
||||
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>"');
|
||||
const standingFlag = flag(rest, '--owner-standing');
|
||||
if (standingFlag !== undefined && standingFlag !== 'rec') return usage('--owner-standing takes only rec: a standing approval covers the recommendations, never a token list');
|
||||
if (standingFlag !== undefined && (reply !== undefined || flag(rest, '--owner-reply') !== undefined || flag(rest, '--owner-reply-file') !== undefined || flag(rest, '--rec-hash') !== undefined)) return usage('--owner-standing rec replaces --reply, --owner-reply and --rec-hash: the cited row supplies the words');
|
||||
if (reply === undefined && standingFlag === undefined) return usage('answer needs --reply "<text>" (or --owner-standing rec --by agent:<id>)');
|
||||
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;
|
||||
let standing: ReturnType<typeof standingRecApproval>;
|
||||
if (standingFlag !== undefined) {
|
||||
const loc = locate({ cwd: process.cwd(), env: process.env, branch: flag(rest, '--branch') });
|
||||
if ('code' in loc) { process.stderr.write(resultLine('gstack-autoplan answer', loc.code, loc.message) + '\n'); return EXIT.refused; }
|
||||
standing = standingRecApproval(loc);
|
||||
if (!standing) { process.stderr.write(resultLine('gstack-autoplan answer', 'STANDING_APPROVAL_ABSENT', `no standing approval covering autoplan recommendations in ${path.dirname(loc.file)}${loc.mirror ? ` or ${path.dirname(loc.mirror)}` : ''} (branch ${loc.branch})`) + '\n'); return EXIT.refused; }
|
||||
}
|
||||
return mutating(out, json, () => {
|
||||
const state = readState(out);
|
||||
const r = answerGate(state, Number(rev), reply, by, { recHash: flag(rest, '--rec-hash'), ownerReply });
|
||||
const r = standing ? standingAnswer(state, Number(rev), by, standing) : answerGate(state, Number(rev), reply!, by, { recHash: flag(rest, '--rec-hash'), ownerReply });
|
||||
writeState(state);
|
||||
const lines = [...r.lines];
|
||||
if (r.outcome === 'reopened' || r.outcome === 'amend_pending') lines.push(nextLine(state, out));
|
||||
|
||||
@@ -301,6 +301,8 @@ gstack_result_anchor() {
|
||||
APPROVAL_RECORD_UNAVAILABLE) printf '%s' 'approval-record-unavailable' ;;
|
||||
APPROVAL_RECORD_WRITE_FAILED) printf '%s' 'approval-record-write-failed' ;;
|
||||
APPROVAL_IMPORT_UNANSWERED) printf '%s' 'approval-import-unanswered' ;;
|
||||
STANDING_APPROVAL_ABSENT) printf '%s' 'standing-approval-absent' ;;
|
||||
STANDING_APPROVAL_INAPPLICABLE) printf '%s' 'standing-approval-inapplicable' ;;
|
||||
CAPTAIN_BRIEF_INCOMPLETE) printf '%s' 'captain-brief-incomplete' ;;
|
||||
PR_SHOTS_INPUT_NOT_FOUND) printf '%s' 'pr-shots-input-not-found' ;;
|
||||
PR_SHOTS_INPUT_REFUSED) printf '%s' 'pr-shots-input-refused' ;;
|
||||
|
||||
@@ -435,8 +435,15 @@ In a Capy session (`GSTACK_CAPY=1`, set by the launch line) every workflow
|
||||
prints `CAPY: on` and a `capy-asks` block: decisions go through Capy (a crew
|
||||
thread asks its Captain, otherwise the person), plan approvals are rendered
|
||||
with `gstack-owner-brief render --captain`, answers are recorded with
|
||||
`gstack-approvals`, and /ship's PR body opens with before/after images
|
||||
(`gstack-pr-shots`), Why and Approvals. The guide is
|
||||
`gstack-approvals` (mirrored to the Captain's drive `approvals/` directory when the
|
||||
Captain created one, so the crew can read every lane's record), and /ship's PR body opens
|
||||
with before/after images (`gstack-pr-shots`), Why and Approvals. Every autoplan gate
|
||||
prints its cost (`GATE_SPEND:`, `GATE_REC_COST:`), and a lane whose Captain recorded the
|
||||
owner's standing words for autoplan gates once (`gstack-approvals add --outcome standing
|
||||
--ask "autoplan gates" --words "<the owner's exact words>" --by "<who, where>" --scope
|
||||
"autoplan gates: take the recommendations"`, on any branch of the project) answers with
|
||||
`gstack-autoplan answer --owner-standing rec --by agent:<id>` instead of a round trip;
|
||||
`autoplan/DRIVER.md` names the two refusals. The guide is
|
||||
[`capy/skill/gstack/captain.md`](../capy/skill/gstack/captain.md);
|
||||
`GSTACK_CAPY_ASKS=0` drops the block for one session. The controller's side (the
|
||||
READY contract, merge trains, the digest, the daily flaky pass, with templates for the
|
||||
|
||||
+31
-2
@@ -1683,8 +1683,9 @@ from "the record was lost with the old machine". `render` still prints the secti
|
||||
"Record unavailable on this machine" instead of "No approvals recorded". Exit 1.
|
||||
|
||||
**Fix.** Run where the record lives, copy the rows back from the thread's report, or make
|
||||
the private Drive mirror resolvable (one `~/.capy/drive/project-user-*` folder, or
|
||||
`GSTACK_APPROVALS_DIR`).
|
||||
a Drive mirror resolvable: `GSTACK_APPROVALS_DIR`, an `approvals/` directory in the one
|
||||
mounted Captain drive (`~/.capy/drive/captain-*/approvals`, readable by the whole crew), or one
|
||||
`~/.capy/drive/project-user-*` folder. `gstack-approvals path` prints which rule chose the mirror.
|
||||
|
||||
<a id="approval-record-write-failed"></a>
|
||||
### `gstack-approvals: ... (APPROVAL_RECORD_WRITE_FAILED)`
|
||||
@@ -1704,6 +1705,34 @@ so there is nothing to import. Exit 3.
|
||||
**Fix.** Answer the gate (`gstack-autoplan answer ... --by human|agent:<id>`), then
|
||||
import again. Imports are idempotent, so a retry never records an answer twice.
|
||||
|
||||
<a id="standing-approval-absent"></a>
|
||||
### `gstack-autoplan answer: ... (STANDING_APPROVAL_ABSENT)`
|
||||
|
||||
**Meaning.** `answer --owner-standing rec` looked for a standing approval covering autoplan
|
||||
recommendations (a `gstack-approvals` row with outcome `standing`, scope or ask naming
|
||||
`autoplan`, words or scope naming `rec` or the recommendations) in this project's records,
|
||||
local and mirror, every branch, and found none. The gate stays pending; nothing was applied.
|
||||
Exit 3.
|
||||
|
||||
**Fix.** Relay the gate to the owner (or Captain) and answer with their reply through
|
||||
`--reply` and `--owner-reply`. For the next gate, the Captain records the owner's standing
|
||||
words once: `gstack-approvals add --outcome standing --ask "autoplan gates" --words "<the
|
||||
owner's exact words>" --by "<who, where>" --scope "autoplan gates: take the recommendations"`;
|
||||
on Capy the row lands in the crew-readable mirror, so every lane's `--owner-standing rec`
|
||||
finds it.
|
||||
|
||||
<a id="standing-approval-inapplicable"></a>
|
||||
### `gstack-autoplan answer: ... (STANDING_APPROVAL_INAPPLICABLE)`
|
||||
|
||||
**Meaning.** The gate carries an urgent or outside-plan finding (`findings.jsonl` rows with
|
||||
`urgent` or `outside_plan`). Standing words that say "take the recommendations" cover the
|
||||
plan under review, not news beyond it, so the standing path refuses and nothing was applied.
|
||||
Exit 3.
|
||||
|
||||
**Fix.** Relay the gate render (the `RELAY:` line) to the owner, including the
|
||||
`## URGENT, outside this plan` block, and answer with their reply through `--reply` and
|
||||
`--owner-reply`. The standing row still applies to later gates that carry no such finding.
|
||||
|
||||
<a id="captain-brief-incomplete"></a>
|
||||
### `gstack-owner-brief render --captain: ... (CAPTAIN_BRIEF_INCOMPLETE)`
|
||||
|
||||
|
||||
+61
-13
@@ -15,14 +15,18 @@
|
||||
* branch key being a readable prefix plus a hash of the exact ref (no two
|
||||
* branches share a file, and the key is always one contained path segment).
|
||||
* On Capy (GSTACK_CAPY=1) the state root is thrown away with the machine, so
|
||||
* every write is mirrored to the private project-user Drive folder
|
||||
* (`<drive>/approvals/<slug>/<branch key>.jsonl`) or to GSTACK_APPROVALS_DIR;
|
||||
* GSTACK_APPROVALS_MIRROR=0 turns the mirror off. Reads take the union of both.
|
||||
* Every field passes the secret scanner before anything is written.
|
||||
* every write is mirrored: to GSTACK_APPROVALS_DIR when set, else to the
|
||||
* directory the Captain named by creating `approvals/` in its drive
|
||||
* (`~/.capy/drive/captain-<name>/approvals/<slug>/<branch key>.jsonl`, which every
|
||||
* crew thread can read), else to the private project-user Drive folder
|
||||
* (`<drive>/approvals/<slug>/<branch key>.jsonl`). GSTACK_APPROVALS_MIRROR=0
|
||||
* turns the mirror off. Reads take the union of both. Every field passes the
|
||||
* secret scanner before anything is written.
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
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 { capyDriveFolder } from './autoplan-checkpoint';
|
||||
import { reviewedPlan } from './captain-brief';
|
||||
@@ -40,7 +44,8 @@ export interface ApprovalRow {
|
||||
schema_version: 1; id: string; date: string; ask: string; words: string; by: string;
|
||||
scope: string; pending: string; outcome: Outcome; source: string; branch: string; recorded_at: string;
|
||||
}
|
||||
export interface StoreLocation { file: string; mirror?: string; branch: string; slug: string; ephemeral: boolean }
|
||||
export type MirrorSource = 'GSTACK_APPROVALS_DIR' | 'captain drive' | 'project-user drive';
|
||||
export interface StoreLocation { file: string; mirror?: string; mirror_source?: MirrorSource; branch: string; slug: string; ephemeral: boolean }
|
||||
export interface RecordError { code: ResultCodeName; message: string }
|
||||
export type Env = NodeJS.ProcessEnv;
|
||||
|
||||
@@ -71,15 +76,58 @@ export function locate(o: { cwd: string; env: Env; branch?: string }): StoreLoca
|
||||
const file = path.join(dir, `${branchKey(branch)}.jsonl`);
|
||||
if (!contained(stateRoot, file)) return { code: 'APPROVAL_RECORD_INVALID', message: `record path escapes the state root: ${file}` };
|
||||
let mirrorRoot: string | undefined;
|
||||
if (o.env.GSTACK_APPROVALS_DIR) mirrorRoot = o.env.GSTACK_APPROVALS_DIR;
|
||||
let mirror_source: MirrorSource | undefined;
|
||||
if (o.env.GSTACK_APPROVALS_DIR) { mirrorRoot = o.env.GSTACK_APPROVALS_DIR; mirror_source = 'GSTACK_APPROVALS_DIR'; }
|
||||
else if (o.env.GSTACK_CAPY === '1' && o.env.GSTACK_APPROVALS_MIRROR !== '0') {
|
||||
const top = git(o.cwd, ['rev-parse', '--show-toplevel']);
|
||||
const folder = capyDriveFolder(o.env, top.status === 0 ? path.basename(top.stdout.trim()) : undefined);
|
||||
if (folder) mirrorRoot = path.join(path.dirname(folder), 'approvals');
|
||||
const captain = captainApprovalsDir(o.env);
|
||||
if (captain) { mirrorRoot = captain; mirror_source = 'captain drive'; }
|
||||
else {
|
||||
const top = git(o.cwd, ['rev-parse', '--show-toplevel']);
|
||||
const folder = capyDriveFolder(o.env, top.status === 0 ? path.basename(top.stdout.trim()) : undefined);
|
||||
if (folder) { mirrorRoot = path.join(path.dirname(folder), 'approvals'); mirror_source = 'project-user drive'; }
|
||||
}
|
||||
}
|
||||
const mirror = mirrorRoot ? path.join(mirrorRoot, slug, `${branchKey(branch)}.jsonl`) : undefined;
|
||||
if (mirror && !contained(mirrorRoot!, mirror)) return { code: 'APPROVAL_RECORD_INVALID', message: `mirror path escapes ${mirrorRoot}` };
|
||||
return { file, mirror, branch, slug, ephemeral: o.env.GSTACK_EPHEMERAL === '1' };
|
||||
return { file, mirror, ...(mirror ? { mirror_source } : {}), branch, slug, ephemeral: o.env.GSTACK_EPHEMERAL === '1' };
|
||||
}
|
||||
|
||||
/**
|
||||
* The approvals directory a Captain named for its crew: exactly one mounted Captain drive
|
||||
* (`~/.capy/drive/captain-*`) holding an `approvals/` directory. Two Captains with one each
|
||||
* is ambiguous, so neither is taken and the private project-user folder stays the mirror.
|
||||
*/
|
||||
export function captainApprovalsDir(env: Env): string | undefined {
|
||||
if (env.GSTACK_CAPY !== '1') return undefined;
|
||||
const drive = path.join(env.HOME || os.homedir(), '.capy', 'drive');
|
||||
let found: string[];
|
||||
try {
|
||||
found = fs.readdirSync(drive).filter(d => d.startsWith('captain-')).map(d => path.join(drive, d, 'approvals')).filter(p => { try { return fs.statSync(p).isDirectory(); } catch { return false; } });
|
||||
} catch { return undefined; }
|
||||
return found.length === 1 ? found[0] : undefined;
|
||||
}
|
||||
|
||||
/** Standing approvals an agent may cite to answer an autoplan gate with `rec` (plan E21). */
|
||||
export const STANDING_REC_SCOPE_RE = /\bautoplan\b/i;
|
||||
export const STANDING_REC_WORDS_RE = /\b(rec|recommendations?|recommended)\b/i;
|
||||
export const coversAutoplanRec = (r: ApprovalRow): boolean =>
|
||||
r.outcome === 'standing' && STANDING_REC_SCOPE_RE.test(`${r.ask} ${r.scope}`) && STANDING_REC_WORDS_RE.test(`${r.words} ${r.scope}`);
|
||||
|
||||
/**
|
||||
* The newest standing approval covering autoplan recommendations, from this branch's record
|
||||
* and every other branch's record of the same project (local and mirror): a Captain records
|
||||
* the owner's standing words once, on whatever branch it had checked out, and every lane cites it.
|
||||
*/
|
||||
export function standingRecApproval(loc: StoreLocation): ApprovalRow | undefined {
|
||||
const rows: ApprovalRow[] = [];
|
||||
for (const dir of [path.dirname(loc.file), loc.mirror ? path.dirname(loc.mirror) : undefined]) {
|
||||
if (!dir) continue;
|
||||
let names: string[] = [];
|
||||
try { names = fs.readdirSync(dir).filter(n => n.endsWith('.jsonl')); } catch { continue; }
|
||||
for (const n of names) rows.push(...readRows(path.join(dir, n)));
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
return rows.filter(r => coversAutoplanRec(r) && !seen.has(r.id) && seen.add(r.id)).sort((a, b) => b.date.localeCompare(a.date) || b.recorded_at.localeCompare(a.recorded_at))[0];
|
||||
}
|
||||
|
||||
function readRows(file: string | undefined): ApprovalRow[] {
|
||||
@@ -197,7 +245,7 @@ export function renderRecord(read: ReadResult, loc: StoreLocation, o: { heading?
|
||||
return [...head, ...lines, '', `Source: gstack-approvals record for ${cell(loc.branch)} (display only; not an approval citation).`, ''].join('\n');
|
||||
}
|
||||
|
||||
interface DecisionLike { id?: string; title?: string; status?: string; chosen?: string | null; recommended?: string | null; gate_rev?: number; answered_at?: string; answered_by?: string; reply?: string; owner_reply?: string; carried_from?: string; label?: string; chosen_text?: string }
|
||||
interface DecisionLike { id?: string; title?: string; status?: string; chosen?: string | null; recommended?: string | null; gate_rev?: number; answered_at?: string; answered_by?: string; reply?: string; owner_reply?: string; standing?: string; carried_from?: string; label?: string; chosen_text?: string }
|
||||
|
||||
const stripRef = (t: string) => t.replace(/\s*\[[^\]]*\]\s*$/, '').trim();
|
||||
|
||||
@@ -236,7 +284,7 @@ export function importAutoplan(runDir: string, branch: string, by?: string): App
|
||||
const changed = items.filter(r => r.chosen && r.recommended && r.chosen !== r.recommended);
|
||||
const stillPending = rows.filter(r => r.gate_rev === rev && r.status === 'pending');
|
||||
const answerer = first.answered_by ?? 'unknown';
|
||||
const who = answerer === 'human' ? (by ? `${by}, typed at the gate` : 'the owner, typed at the gate') : `${by ?? 'the owner'}, relayed by ${answerer}`;
|
||||
const who = answerer === 'human' ? (by ? `${by}, typed at the gate` : 'the owner, typed at the gate') : first.standing ? `${by ?? 'the owner'}, standing approval ${first.standing} cited by ${answerer}` : `${by ?? 'the owner'}, relayed by ${answerer}`;
|
||||
const scope = `the reviewed plan (sha256 ${planSha}), ${changed.length ? `${changed.length} recommendation(s) changed: ${changed.map(r => stripRef(r.title ?? '')).join('; ')}` : 'every recommendation taken'}`;
|
||||
const built = buildRow({
|
||||
ask: `Approve the plan "${planTitle}" (autoplan run ${runId}, ${items.length + (p1 ? 1 : 0)} decisions)`.slice(0, CAPS.ask),
|
||||
@@ -246,7 +294,7 @@ export function importAutoplan(runDir: string, branch: string, by?: string): App
|
||||
pending: (stillPending.length ? `${stillPending.length} decision(s) unanswered: ${stillPending.map(r => stripRef(r.title ?? '')).join('; ')}` : outcome === 'pending' ? 'the plan approval itself' : 'none').slice(0, CAPS.pending),
|
||||
outcome,
|
||||
date: first.answered_at!.slice(0, 10),
|
||||
source: `autoplan:${runId}@gate_rev=${rev}`,
|
||||
source: `autoplan:${runId}@gate_rev=${rev}${first.standing ? ` standing=${first.standing}` : ''}`,
|
||||
}, branch, new Date(first.answered_at!));
|
||||
if ('code' in built) return built;
|
||||
out.push(built);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* autoplan-gate-cost — what a gate costs so far and what taking every
|
||||
* recommendation would add (plan E16). Every gate-producing command prints the
|
||||
* two lines, and the owner brief carries the same figures, so an approval is
|
||||
* never asked without its price. gstack prices what it reserved and settled in
|
||||
* `<out>/spend.json` (lib/spend-ledger.ts); a host subagent's actual spend is
|
||||
* `unknown` to it (kept at its reservation in worst_case), so the line says so
|
||||
* and points at the host's own cost view instead of inventing a credit figure.
|
||||
* The reopen estimate is an upper bound: the phases a plan-changing
|
||||
* recommendation names plus Eng (its prompt carries the earlier consensus),
|
||||
* two voices each when the outside voice is on, at the per-voice estimate the
|
||||
* run has been reserving.
|
||||
*/
|
||||
import * as path from 'node:path';
|
||||
import type { GateList } from './gate-list';
|
||||
import { readLedger, summarize, type SpendSummary } from './spend-ledger';
|
||||
|
||||
export interface GateCost {
|
||||
summary: SpendSummary;
|
||||
/** Attempts the ledger has seen: settled plus still reserved. */
|
||||
attempts: number;
|
||||
/** Phases a `rec` reply would reopen through its plan-changing recommendations, in run order; empty when none changes the plan. */
|
||||
reopens: string[];
|
||||
voices: number;
|
||||
per_voice_usd: number;
|
||||
/** The reopen estimate: voices × per-voice. */
|
||||
rec_more_usd: number;
|
||||
}
|
||||
|
||||
export interface GateCostRun { phase_order: string[]; outside_enabled: boolean; spend_cap_usd: number | null }
|
||||
|
||||
const usd = (n: number) => n.toFixed(2);
|
||||
|
||||
/** Phases a `rec` reopens: every plan-changing recommendation's phase, then Eng last when the run reviews it. */
|
||||
export function recReopens(list: GateList, phaseOrder: string[]): string[] {
|
||||
const named = new Set<string>();
|
||||
for (const item of list.items) {
|
||||
if (item.id === 'p1' || item.reopens === false || item.carried) continue;
|
||||
const option = item.options.find(o => o.key === item.recommended);
|
||||
if (option?.changes_plan && item.phase) named.add(item.phase);
|
||||
}
|
||||
if (named.size && phaseOrder.includes('eng')) named.add('eng');
|
||||
return phaseOrder.filter(p => named.has(p));
|
||||
}
|
||||
|
||||
export function gateCost(outDir: string, list: GateList, run: GateCostRun, o: { spendFile?: string; defaultPerVoiceUsd?: number } = {}): GateCost {
|
||||
const ledger = readLedger(o.spendFile ?? path.join(outDir, 'spend.json'), run.spend_cap_usd);
|
||||
const summary = summarize(ledger);
|
||||
const seen = [...ledger.settled.map(s => s.reserved_usd), ...ledger.reserved.map(r => r.estimate_usd)].filter(n => n > 0);
|
||||
const per_voice_usd = seen.length ? seen[seen.length - 1]! : o.defaultPerVoiceUsd ?? 0;
|
||||
const reopens = recReopens(list, run.phase_order);
|
||||
const voices = reopens.length * (run.outside_enabled ? 2 : 1);
|
||||
return { summary, attempts: ledger.settled.length + ledger.reserved.length, reopens, voices, per_voice_usd, rec_more_usd: Math.round(voices * per_voice_usd * 100) / 100 };
|
||||
}
|
||||
|
||||
/** The two printed lines: the ledger at this gate, and the price of `rec`. */
|
||||
export function renderGateCost(c: GateCost): string[] {
|
||||
const s = c.summary;
|
||||
const cap = s.cap_usd === null ? 'none' : usd(s.cap_usd);
|
||||
const headroom = s.headroom_usd === null ? 'none' : usd(s.headroom_usd);
|
||||
const unknown = s.unknown_count ? `; ${s.unknown_count} host-subagent attempt(s) priced unknown, counted at their reservation (read the actual figure in the host's cost view)` : '';
|
||||
const after = s.headroom_usd === null ? '' : ` → headroom ${usd(s.headroom_usd - c.rec_more_usd)}${s.headroom_usd - c.rec_more_usd < 0 ? ' (over the cap: raise it with gstack-autoplan budget before answering rec)' : ''}`;
|
||||
return [
|
||||
`GATE_SPEND: spent=${usd(s.spent_usd)} worst_case=${usd(s.worst_case_usd)} cap=${cap} headroom=${headroom} attempts=${c.attempts}${unknown}`,
|
||||
c.reopens.length
|
||||
? `GATE_REC_COST: rec reopens ${c.reopens.join(',')} ≈ ${c.voices} voice(s) × ${usd(c.per_voice_usd)} = +${usd(c.rec_more_usd)} (estimate at the run's per-voice reservation)${after}`
|
||||
: 'GATE_REC_COST: rec reopens nothing (no recommended option changes the plan): +0.00',
|
||||
];
|
||||
}
|
||||
|
||||
/** One sentence for the owner brief. */
|
||||
export function gateCostSentence(c: GateCost): string {
|
||||
const s = c.summary;
|
||||
const cap = s.cap_usd === null ? 'no cap set' : `cap ${usd(s.cap_usd)}, headroom ${usd(s.headroom_usd!)}`;
|
||||
const unknown = s.unknown_count ? `, ${s.unknown_count} host attempt(s) unknown and counted at reservation` : '';
|
||||
const rec = c.reopens.length ? `Taking every recommendation reopens ${c.reopens.join(', ')}: about +${usd(c.rec_more_usd)} more.` : 'Taking every recommendation reopens nothing: no further spend.';
|
||||
return `Cost so far: spent ${usd(s.spent_usd)}, worst case ${usd(s.worst_case_usd)} (${cap}${unknown}; ${c.attempts} reviewer attempt(s)). ${rec}`;
|
||||
}
|
||||
+22
-1
@@ -28,6 +28,7 @@ import { capyRelayLine } from './captain-brief';
|
||||
import { previousReview, reviewRows, settledIds } from './autoplan-confirm';
|
||||
import { phaseClose } from './autoplan-timing';
|
||||
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 { ApprovalRow } from './approval-record';
|
||||
import type { FindingRow } from './headless-artifacts';
|
||||
import { readJsonl, sha256File } from './headless-artifacts';
|
||||
import { closeDraftDirectionsFile, loadBriefInputs } from './owner-brief';
|
||||
@@ -321,6 +322,26 @@ export interface AnswerOptions {
|
||||
recHash?: string;
|
||||
/** The owner's verbatim words; required for every `--by agent:<id>` answer. */
|
||||
ownerReply?: string;
|
||||
/** A standing approval the agent cites instead of a relayed reply (`answer --owner-standing rec`, plan E21): its words are the owner reply, its id lands on every row. */
|
||||
standing?: Pick<ApprovalRow, 'id' | 'date' | 'by' | 'words' | 'scope' | 'source'>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The standing-approval path (plan E21): the reply is `rec`, the owner's words are the standing
|
||||
* row's, the rec_hash is the current one (nobody saw a render; the standing words pre-authorize
|
||||
* the recommendations). Refused when the gate carries an urgent or outside-plan finding: a
|
||||
* standing "take the recommendations" covers the plan under review, not news beyond it.
|
||||
*/
|
||||
export function standingAnswer(state: RunState, gateRev: number, answeredBy: string | undefined, standing: NonNullable<AnswerOptions['standing']>): AnswerResult {
|
||||
if (!answeredBy?.startsWith('agent:')) throw new RunError('ANSWER_BY_REQUIRED', 'a standing approval is cited by an agent: pass --by agent:<id>', 3);
|
||||
const findings = readJsonl(path.join(state.out, 'findings.jsonl')).rows as Array<Record<string, unknown>>;
|
||||
const urgent = findings.filter(r => r.urgent === true || r.outside_plan === true);
|
||||
if (urgent.length) throw new RunError('STANDING_APPROVAL_INAPPLICABLE', `${urgent.length} urgent or outside-plan finding(s) at this gate (${urgent.slice(0, 3).map(r => String(r.id ?? r.title ?? '?')).join(', ')}); the standing approval ${standing.id} covers the plan's recommendations, not these`, 3);
|
||||
const gateFile = path.join(state.out, 'gate.json');
|
||||
const list = fs.existsSync(gateFile) ? JSON.parse(fs.readFileSync(gateFile, 'utf8')) as GateList : undefined;
|
||||
const r = answerGate(state, gateRev, 'rec', answeredBy, { recHash: list ? recHash(list) : undefined, ownerReply: standing.words, standing });
|
||||
r.lines.unshift(`STANDING: ${standing.id} (${standing.date}, ${standing.by}; ${standing.source}) "${standing.words}" — scope: ${standing.scope}`);
|
||||
return r;
|
||||
}
|
||||
|
||||
/** How an accepted plan change is discharged: a packaging/ordering change settles without a plan edit. */
|
||||
@@ -373,7 +394,7 @@ export function answerGate(state: RunState, gateRev: number, reply: string, answ
|
||||
if (parsed.mode === 'rec' && list.rec_rev === undefined && o.recHash === undefined) throw new RunError('GATE_REC_STALE', `this gate was written before \`rec\`/\`all\` took plan changes (gate.json has no rec_rev), so the owner saw a different meaning; render it again (gstack-gate render ${gateFile} --all), relay it, and answer with --rec-hash ${currentHash}, or reply with explicit <id><option> tokens`, 3);
|
||||
if (parsed.mode === 'rec' && (list.rec_rev ?? 0) > 0 && o.recHash === undefined) throw new RunError('GATE_REC_STALE', `recommend changed this gate (rec_rev=${list.rec_rev}); pass --rec-hash from the render the owner saw (current rec_hash=${currentHash})`, 3);
|
||||
const ownerParsed = o.ownerReply !== undefined ? checkOwnerReply(list, gateRev, prior, parsed, o.ownerReply) : undefined;
|
||||
const provenance = o.ownerReply !== undefined ? { owner_reply: o.ownerReply, owner_reply_parsed: ownerParsed } : {};
|
||||
const provenance = { ...(o.ownerReply !== undefined ? { owner_reply: o.ownerReply, owner_reply_parsed: ownerParsed } : {}), ...(o.standing ? { standing: o.standing.id } : {}) };
|
||||
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');
|
||||
|
||||
@@ -41,6 +41,8 @@ export interface DecisionRow {
|
||||
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;
|
||||
/** The standing approval row (`gstack-approvals`, outcome standing) an agent cited to answer `rec` without a round trip. */
|
||||
standing?: string;
|
||||
}
|
||||
export interface TaskRow {
|
||||
schema_version: 2; run: string; id: string; title: string; status: TaskStatus; depends_on: string[];
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { gateCost, gateCostSentence } from './autoplan-gate-cost';
|
||||
import { decisionId, PAGE_SIZE, renderGate, type GateList } from './gate-list';
|
||||
import { readJsonl, renderUrgentBlock, type DecisionRow } from './headless-artifacts';
|
||||
import type { ResultCodeName } from './result-codes';
|
||||
@@ -85,6 +86,7 @@ export function renderBrief(inputs: BriefInputs, o: { page?: number; summary?: s
|
||||
'### Summary',
|
||||
'',
|
||||
plainSummary(inputs, o.summary),
|
||||
...(o.outDir && inputs.run ? ['', gateCostSentence(gateCost(o.outDir, inputs.gate, { phase_order: inputs.run.phase_order ?? [], outside_enabled: inputs.run.outside_enabled !== false, spend_cap_usd: inputs.run.spend_cap_usd ?? null }))] : []),
|
||||
'',
|
||||
'### Decisions',
|
||||
'',
|
||||
|
||||
@@ -39,6 +39,16 @@ export const CAPY_RESULT_CODES = {
|
||||
summary: 'the autoplan run has no answered gate to import',
|
||||
fix: 'answer the gate first (gstack-autoplan answer ... --by human|agent:<id>), then rerun the import; it is idempotent, so a retry imports nothing twice',
|
||||
},
|
||||
STANDING_APPROVAL_ABSENT: {
|
||||
anchor: 'standing-approval-absent',
|
||||
summary: 'gstack-autoplan answer --owner-standing rec found no standing approval covering autoplan recommendations for this project, so the gate stays pending',
|
||||
fix: 'relay the gate to the owner (or Captain) as usual, or have the Captain record the owner\'s standing words once with gstack-approvals add --outcome standing --scope "autoplan gates: take the recommendations" --words "<the owner\'s exact words>"',
|
||||
},
|
||||
STANDING_APPROVAL_INAPPLICABLE: {
|
||||
anchor: 'standing-approval-inapplicable',
|
||||
summary: 'the gate carries an urgent or outside-plan finding, which a standing "take the recommendations" approval does not cover, so nothing was applied',
|
||||
fix: 'relay the gate render (the RELAY line) to the owner and answer with their reply through --reply and --owner-reply; the standing row still applies to later gates without such findings',
|
||||
},
|
||||
CAPTAIN_BRIEF_INCOMPLETE: {
|
||||
anchor: 'captain-brief-incomplete',
|
||||
summary: 'the plan lacks one or more of the sections the Captain brief needs (Before → after, Why it matters, What caused this work, Big decisions), so the brief is not ready for approval',
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Plan E16/E21/E22 (lane F, autoplan gates):
|
||||
* E16 every gate prints its cost: `GATE_SPEND:` from the run's ledger and
|
||||
* `GATE_REC_COST:` for the phases a `rec` reopens; the owner brief carries
|
||||
* the same sentence. Host-subagent attempts are priced unknown, never zero.
|
||||
* E21 `answer --owner-standing rec --by agent:<id>` answers `rec` by citing
|
||||
* the newest standing approval covering autoplan recommendations (any
|
||||
* branch's record of the project), lands its id on every decision row and
|
||||
* in the import's source; it refuses with STANDING_APPROVAL_ABSENT when no
|
||||
* row covers it and STANDING_APPROVAL_INAPPLICABLE when the gate carries an
|
||||
* urgent finding.
|
||||
* E22 the approvals mirror honours the directory a Captain named by creating
|
||||
* `approvals/` in its drive, between GSTACK_APPROVALS_DIR and the private
|
||||
* project-user folder; `gstack-approvals path` names the rule.
|
||||
*/
|
||||
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 { buildRow, captainApprovalsDir, coversAutoplanRec, locate, standingRecApproval, type ApprovalRow } from '../lib/approval-record';
|
||||
import { gateCost, recReopens, renderGateCost, gateCostSentence } from '../lib/autoplan-gate-cost';
|
||||
import type { GateList } from '../lib/gate-list';
|
||||
import { attemptsOf, autoplan, env, readRun, resultOf, review, ROOT, startSmallRun } from './helpers/autoplan-run-fixture';
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-standing-'));
|
||||
afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
||||
|
||||
const keep = { key: 'a', text: 'keep your direction', changes_plan: false };
|
||||
const change = { key: 'b', text: 'accept the change', changes_plan: true };
|
||||
const list = (recommended: 'a' | 'b'): GateList => ({
|
||||
schema_version: 1, run: 'r', gate_rev: 1, items: [
|
||||
{ id: 'uc1', title: 'challenge', options: [keep, change], recommended, kind: 'approval', phase: 'ceo' },
|
||||
{ id: 'd1', title: 'auto', options: [keep, change], recommended: 'a', kind: 'auto', phase: 'design' },
|
||||
{ id: 'p1', title: 'Plan approval', options: [{ key: 'a', text: 'approve as-is' }], recommended: 'a', kind: 'approval' },
|
||||
],
|
||||
});
|
||||
const row = (over: Partial<Parameters<typeof buildRow>[0]> = {}, branch = 'main'): ApprovalRow => {
|
||||
const r = buildRow({ ask: 'autoplan gates', words: 'assume Garry will take the recommendations', by: 'Garry (user-scope AGENTS.md)', scope: 'autoplan gates: take the recommendations', outcome: 'standing', date: '2026-10-20', source: 'AGENTS.md', ...over }, branch);
|
||||
if ('code' in r) throw new Error(r.message);
|
||||
return r;
|
||||
};
|
||||
const decisions = (out: string) => fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8').trim().split('\n').map(l => JSON.parse(l));
|
||||
|
||||
describe('E16: the gate prints what it cost and what rec adds', () => {
|
||||
test('recReopens names each plan-changing recommendation\'s phase plus Eng last, and nothing when no recommendation changes the plan', () => {
|
||||
expect(recReopens(list('b'), ['ceo', 'design', 'eng'])).toEqual(['ceo', 'eng']);
|
||||
expect(recReopens(list('a'), ['ceo', 'design', 'eng'])).toEqual([]);
|
||||
expect(recReopens(list('b'), ['ceo', 'design'])).toEqual(['ceo']);
|
||||
const carried = list('b'); carried.items[0]!.carried = { from: 'x', chosen: 'b', gate_rev: 1 };
|
||||
expect(recReopens(carried, ['ceo', 'eng'])).toEqual([]);
|
||||
});
|
||||
|
||||
test('the lines price the reopen at the per-voice reservation, two voices each with the outside voice on, and name unknown host attempts', () => {
|
||||
const out = path.join(TMP, 'cost'); fs.mkdirSync(out, { recursive: true });
|
||||
fs.writeFileSync(path.join(out, 'spend.json'), JSON.stringify({ schema_version: 1, cap_usd: 40, spent_usd: 1.5, reserved: [], released: [],
|
||||
settled: [{ attempt: 'a1', usd: 1.5, reserved_usd: 5, at: 't' }, { attempt: 'a2', usd: 'unknown', reserved_usd: 5, at: 't' }] }));
|
||||
const c = gateCost(out, list('b'), { phase_order: ['ceo', 'design', 'eng'], outside_enabled: true, spend_cap_usd: 40 });
|
||||
expect(c).toMatchObject({ attempts: 2, reopens: ['ceo', 'eng'], voices: 4, per_voice_usd: 5, rec_more_usd: 20 });
|
||||
const lines = renderGateCost(c);
|
||||
expect(lines[0]).toBe('GATE_SPEND: spent=1.50 worst_case=6.50 cap=40.00 headroom=33.50 attempts=2; 1 host-subagent attempt(s) priced unknown, counted at their reservation (read the actual figure in the host\'s cost view)');
|
||||
expect(lines[1]).toBe('GATE_REC_COST: rec reopens ceo,eng ≈ 4 voice(s) × 5.00 = +20.00 (estimate at the run\'s per-voice reservation) → headroom 13.50');
|
||||
expect(gateCostSentence(c)).toContain('Taking every recommendation reopens ceo, eng: about +20.00 more.');
|
||||
const tight = path.join(out, 'tight.json');
|
||||
fs.writeFileSync(tight, fs.readFileSync(path.join(out, 'spend.json'), 'utf8').replace('"cap_usd":40', '"cap_usd":10'));
|
||||
const over = renderGateCost(gateCost(out, list('b'), { phase_order: ['ceo', 'design', 'eng'], outside_enabled: true, spend_cap_usd: 10 }, { spendFile: tight }));
|
||||
expect(over[1]).toContain('(over the cap: raise it with gstack-autoplan budget before answering rec)');
|
||||
const uncapped = path.join(out, 'uncapped.json');
|
||||
fs.writeFileSync(uncapped, fs.readFileSync(path.join(out, 'spend.json'), 'utf8').replace('"cap_usd":40', '"cap_usd":null'));
|
||||
const none = renderGateCost(gateCost(out, list('a'), { phase_order: ['ceo', 'eng'], outside_enabled: false, spend_cap_usd: null }, { spendFile: uncapped }));
|
||||
expect(none[0]).toContain('cap=none headroom=none');
|
||||
expect(none[1]).toBe('GATE_REC_COST: rec reopens nothing (no recommended option changes the plan): +0.00');
|
||||
const empty = gateCost(path.join(TMP, 'no-ledger'), list('b'), { phase_order: ['ceo', 'eng'], outside_enabled: true, spend_cap_usd: null }, { defaultPerVoiceUsd: 5 });
|
||||
expect(empty).toMatchObject({ attempts: 0, per_voice_usd: 5, rec_more_usd: 20 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('E22: the mirror honours the Captain-named approvals directory', () => {
|
||||
const home = path.join(TMP, 'home');
|
||||
const drive = path.join(home, '.capy', 'drive');
|
||||
fs.mkdirSync(path.join(drive, 'project-user-gstack-garry', 'autoplan-checkpoints'), { recursive: true });
|
||||
const base = { GSTACK_CAPY: '1', HOME: home, GSTACK_STATE_ROOT: path.join(TMP, 'state') } as NodeJS.ProcessEnv;
|
||||
const at = (e: NodeJS.ProcessEnv) => { const l = locate({ cwd: ROOT, env: e, branch: 'lane-x' }); if ('code' in l) throw new Error(l.message); return l; };
|
||||
|
||||
test('no Captain drive: the private project-user folder; one Captain drive with approvals/: that directory; two: back to project-user; GSTACK_APPROVALS_DIR wins over both', () => {
|
||||
expect(captainApprovalsDir(base)).toBeUndefined();
|
||||
expect(at(base)).toMatchObject({ mirror_source: 'project-user drive' });
|
||||
expect(at(base).mirror).toBe(path.join(drive, 'project-user-gstack-garry', 'approvals', at(base).slug, path.basename(at(base).file)));
|
||||
fs.mkdirSync(path.join(drive, 'captain-minder'), { recursive: true });
|
||||
expect(captainApprovalsDir(base)).toBeUndefined();
|
||||
fs.mkdirSync(path.join(drive, 'captain-minder', 'approvals'));
|
||||
expect(captainApprovalsDir(base)).toBe(path.join(drive, 'captain-minder', 'approvals'));
|
||||
expect(at(base)).toMatchObject({ mirror_source: 'captain drive', mirror: path.join(drive, 'captain-minder', 'approvals', at(base).slug, path.basename(at(base).file)) });
|
||||
fs.mkdirSync(path.join(drive, 'captain-other', 'approvals'), { recursive: true });
|
||||
expect(captainApprovalsDir(base)).toBeUndefined();
|
||||
expect(at(base)).toMatchObject({ mirror_source: 'project-user drive' });
|
||||
fs.rmSync(path.join(drive, 'captain-other'), { recursive: true });
|
||||
const explicit = path.join(TMP, 'explicit');
|
||||
expect(at({ ...base, GSTACK_APPROVALS_DIR: explicit })).toMatchObject({ mirror_source: 'GSTACK_APPROVALS_DIR', mirror: path.join(explicit, at(base).slug, path.basename(at(base).file)) });
|
||||
expect(at({ ...base, GSTACK_APPROVALS_MIRROR: '0' }).mirror).toBeUndefined();
|
||||
expect(captainApprovalsDir({ ...base, GSTACK_CAPY: undefined })).toBeUndefined();
|
||||
});
|
||||
|
||||
test('gstack-approvals path names the rule that chose the mirror', () => {
|
||||
const r = spawnSync(process.execPath, [path.join(ROOT, 'bin', 'gstack-approvals'), 'path', '--branch', 'lane-x'], { cwd: ROOT, env: { ...process.env, ...base } as Record<string, string>, encoding: 'utf8', timeout: 30_000 });
|
||||
expect(r.stdout).toContain('(captain drive)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('E21: a standing approval answers rec without a round trip', () => {
|
||||
test('coversAutoplanRec needs outcome standing, autoplan in the ask or scope, and the recommendations in the words or scope', () => {
|
||||
expect(coversAutoplanRec(row())).toBe(true);
|
||||
expect(coversAutoplanRec(row({ outcome: 'approved' }))).toBe(false);
|
||||
expect(coversAutoplanRec(row({ ask: 'merges', scope: 'merge when green' }))).toBe(false);
|
||||
expect(coversAutoplanRec(row({ words: 'do it', scope: 'autoplan gates' }))).toBe(false);
|
||||
expect(coversAutoplanRec(row({ words: 'rec', scope: 'autoplan' }))).toBe(true);
|
||||
});
|
||||
|
||||
test('standingRecApproval reads every branch\'s record, local and mirror, newest standing row first', () => {
|
||||
const state = path.join(TMP, 'state-s'); const mirror = path.join(TMP, 'mirror-s');
|
||||
const envS = { GSTACK_STATE_ROOT: state, GSTACK_APPROVALS_DIR: mirror } as NodeJS.ProcessEnv;
|
||||
const lane = locate({ cwd: ROOT, env: envS, branch: 'lane-y' }); if ('code' in lane) throw new Error(lane.message);
|
||||
expect(standingRecApproval(lane)).toBeUndefined();
|
||||
const older = row({ date: '2026-10-18', words: 'take the recommendations' }, 'main');
|
||||
const newer = row({ date: '2026-10-20' }, 'captain-branch');
|
||||
const other = row({ outcome: 'approved', ask: 'Approve the plan' }, 'captain-branch');
|
||||
fs.mkdirSync(path.dirname(lane.mirror!), { recursive: true });
|
||||
fs.writeFileSync(path.join(path.dirname(lane.mirror!), 'main-abc.jsonl'), JSON.stringify(older) + '\n');
|
||||
fs.mkdirSync(path.dirname(lane.file), { recursive: true });
|
||||
fs.writeFileSync(path.join(path.dirname(lane.file), 'captain-branch-def.jsonl'), [newer, other].map(r => JSON.stringify(r)).join('\n') + '\n');
|
||||
expect(standingRecApproval(lane)?.id).toBe(newer.id);
|
||||
});
|
||||
|
||||
/** Both voices challenge the same item: the gate recommends the plan change (b) and `rec` would reopen phases. */
|
||||
function gateRun(name: string, extraFinding: Record<string, unknown> = {}): string {
|
||||
const out = startSmallRun(path.join(TMP, name));
|
||||
const native = JSON.stringify([
|
||||
{ id: 'F1', severity: 'High', title: 'The install check lies by omission', file: 'bin/gstack-doctor', line: 12, fix: 'print PASS/FAIL per component', ...extraFinding },
|
||||
{ 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', ...extraFinding },
|
||||
{ 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.' }]);
|
||||
let last = '';
|
||||
for (const phase of ['ceo', 'eng']) {
|
||||
const n = autoplan(['next', '--out', out]);
|
||||
expect(n.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', resultOf(out, phase, 'native'), '--model', 'anthropic/claude-opus-4-7', '--attempt', ids.native, '--runner', 'host-subagent']).status).toBe(0);
|
||||
last = autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'outside', '--result', resultOf(out, phase, 'outside'), '--model', 'openai/gpt-6-astra', '--attempt', ids.outside, '--runner', 'host-subagent']).stdout;
|
||||
}
|
||||
expect(readRun(out).status).toBe('gate_pending');
|
||||
expect(last).toMatch(/^GATE_SPEND: spent=\d+\.\d\d worst_case=\d+\.\d\d cap=\S+ headroom=\S+ attempts=4/m);
|
||||
expect(last).toMatch(/^GATE_REC_COST: rec reopens /m);
|
||||
return out;
|
||||
}
|
||||
|
||||
const approvalsEnv = (dir: string) => env({ GSTACK_APPROVALS_DIR: dir, GSTACK_STATE_ROOT: path.join(TMP, 'state-run') });
|
||||
|
||||
test('with no covering row the answer is refused (STANDING_APPROVAL_ABSENT) and the gate stays pending; --owner-standing takes only rec', () => {
|
||||
const out = gateRun('absent');
|
||||
const dir = path.join(TMP, 'approvals-absent');
|
||||
const r = autoplan(['answer', '--out', out, '--gate-rev', '1', '--owner-standing', 'rec', '--by', 'agent:lane', '--branch', 'lane-z'], { env: approvalsEnv(dir) });
|
||||
expect(r.status).toBe(3);
|
||||
expect(r.stderr).toContain('(STANDING_APPROVAL_ABSENT)');
|
||||
expect(readRun(out).status).toBe('gate_pending');
|
||||
expect(autoplan(['answer', '--out', out, '--gate-rev', '1', '--owner-standing', 'd1a', '--by', 'agent:lane']).status).toBe(2);
|
||||
expect(autoplan(['answer', '--out', out, '--gate-rev', '1', '--owner-standing', 'rec', '--reply', 'rec', '--by', 'agent:lane']).status).toBe(2);
|
||||
});
|
||||
|
||||
test('a covering row from another branch answers rec: STANDING line, standing id on every answered row and in the import source; the brief carries the cost sentence', () => {
|
||||
const out = gateRun('standing');
|
||||
const dir = path.join(TMP, 'approvals-standing');
|
||||
const l = locate({ cwd: ROOT, env: { GSTACK_APPROVALS_DIR: dir, GSTACK_STATE_ROOT: path.join(TMP, 'state-run') }, branch: 'lane-z' }); if ('code' in l) throw new Error(l.message);
|
||||
fs.mkdirSync(path.dirname(l.mirror!), { recursive: true });
|
||||
const standing = row({}, 'main');
|
||||
fs.writeFileSync(path.join(path.dirname(l.mirror!), 'main-0000000000.jsonl'), JSON.stringify(standing) + '\n');
|
||||
const brief = spawnSync(process.execPath, [path.join(ROOT, 'bin', 'gstack-owner-brief'), 'render', '--out', out], { cwd: ROOT, env: env(), encoding: 'utf8', timeout: 30_000 });
|
||||
expect(brief.stdout).toMatch(/Cost so far: spent \d+\.\d\d, worst case \d+\.\d\d \(/);
|
||||
const human = autoplan(['answer', '--out', out, '--gate-rev', '1', '--owner-standing', 'rec', '--by', 'human'], { env: approvalsEnv(dir) });
|
||||
expect(human.status).toBe(3);
|
||||
expect(human.stderr).toContain('(ANSWER_BY_REQUIRED)');
|
||||
const r = autoplan(['answer', '--out', out, '--gate-rev', '1', '--owner-standing', 'rec', '--by', 'agent:lane', '--branch', 'lane-z'], { env: approvalsEnv(dir) });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain(`STANDING: ${standing.id} (2026-10-20, Garry (user-scope AGENTS.md); AGENTS.md) "assume Garry will take the recommendations" — scope: autoplan gates: take the recommendations`);
|
||||
expect(r.stdout).not.toContain('GATE_PENDING');
|
||||
const answered = decisions(out).filter(d => d.gate_rev === 1 && d.answered_at);
|
||||
expect(answered.length).toBeGreaterThan(0);
|
||||
for (const d of answered) expect(d).toMatchObject({ answered_by: 'agent:lane', reply: 'rec', owner_reply: 'assume Garry will take the recommendations', owner_reply_parsed: false, standing: standing.id });
|
||||
expect(readRun(out).gate).toMatchObject({ answered_by: 'agent:lane', standing: standing.id });
|
||||
expect(['amend_pending', 'complete', 'running']).toContain(readRun(out).status);
|
||||
const imp = spawnSync(process.execPath, [path.join(ROOT, 'bin', 'gstack-approvals'), 'import', '--autoplan', out, '--by', 'Garry', '--branch', 'lane-z', '--json'], { cwd: ROOT, env: approvalsEnv(dir) as Record<string, string>, encoding: 'utf8', timeout: 30_000 });
|
||||
expect(imp.status).toBe(0);
|
||||
const recorded = fs.readFileSync(l.mirror!, 'utf8').trim().split('\n').map(x => JSON.parse(x)).find((x: any) => x.source.startsWith('autoplan:'));
|
||||
expect(recorded.source).toContain(` standing=${standing.id}`);
|
||||
expect(recorded.by).toBe(`Garry, standing approval ${standing.id} cited by agent:lane`);
|
||||
});
|
||||
|
||||
test('an urgent finding at the gate makes the standing path refuse (STANDING_APPROVAL_INAPPLICABLE)', () => {
|
||||
const out = gateRun('urgent', { urgent: true });
|
||||
const dir = path.join(TMP, 'approvals-urgent');
|
||||
const l = locate({ cwd: ROOT, env: { GSTACK_APPROVALS_DIR: dir, GSTACK_STATE_ROOT: path.join(TMP, 'state-run') }, branch: 'lane-z' }); if ('code' in l) throw new Error(l.message);
|
||||
fs.mkdirSync(path.dirname(l.mirror!), { recursive: true });
|
||||
fs.writeFileSync(path.join(path.dirname(l.mirror!), 'main-0000000000.jsonl'), JSON.stringify(row({}, 'main')) + '\n');
|
||||
const r = autoplan(['answer', '--out', out, '--gate-rev', '1', '--owner-standing', 'rec', '--by', 'agent:lane', '--branch', 'lane-z'], { env: approvalsEnv(dir) });
|
||||
expect(r.status).toBe(3);
|
||||
expect(r.stderr).toContain('(STANDING_APPROVAL_INAPPLICABLE)');
|
||||
expect(readRun(out).status).toBe('gate_pending');
|
||||
expect(decisions(out).some(d => d.standing)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user