mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 06:24:41 +01:00
refactor(autoplan): drop --owner-standing rec (E21) from this PR
Pass-4 review (red team + Codex adversarial) still found that the keyword
grammar over the owner's standing words could read a refusal as a grant
("I won't take the recommendations", "Taking the recommendations is
forbidden" granted under the canonical scope) and that gstack-approvals add
deduped a same-day scope revocation as the grant. A free-text negation
parser cannot fail closed, so the standing answer leaves this change and
returns with a structured grant in its own PR.
Kept: GATE_SPEND/GATE_REC_COST cost lines and gate_cost (E16), the
Captain-drive approvals mirror with READ_ALSO (E22), the Eng perf-budget
question (E23). Removed: standingAnswer, standingRecApproval,
coversAutoplanRec, citableRow, project_root/project_slug in run.json,
STANDING_APPROVAL_* result codes and anchors, the standing DRIVER
paragraph and docs, test/autoplan-standing.test.ts (its E16/E22 cases move
to test/autoplan-gate-cost.test.ts).
Focused: 793 pass / 0 fail across 45 files; typecheck, typecheck:test,
format: cso:check clean; renders regenerated with gen:skill-docs --host all.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
4d49667ef9
commit
413bce8c94
20 files changed
+259
-684
No files matched your search
+4
-7
@@ -2,24 +2,21 @@
|
||||
|
||||
## [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.
|
||||
**An autoplan gate now arrives with its price, and the approvals record lands where the whole crew can read it.** Captains reported the same two frictions: review rounds and reviewer subagents had no credit figure at the gate, 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. (The standing "take the recommendations" answer planned beside these, `answer --owner-standing rec`, was cut from this release after review: a keyword grammar over the owner's words could still read a refusal as a grant, so it returns with a structured grant in its own change.)
|
||||
|
||||
### Added
|
||||
- **`GATE_SPEND:` and `GATE_REC_COST:`** (`lib/autoplan-gate-cost.ts`; printed by every gate-producing `gstack-autoplan` command under `GATE:`, by `run` when it stops at a gate, and as `gate_cost` in the `--json` payloads): 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` as an upper bound from the answer and revise rules (a plan change counts every phase `revise` can keep or add, since it reopens each phase whose snapshot the edit changes and re-detects scope, so a run that skipped Design or DX without `--no-ui`/`--no-dx` counts them too; a round grant counts its phase and Eng; the effective choice per item is parseReply's (an answer already recorded at this gate revision, else the carried choice, else the recommendation), a recorded plan rerun `p1d` prices every phase, a reject or stop nothing, and an override of the recommendation counts as `answer` counts it; 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). `gstack-owner-brief render` carries the same figures as one `Cost so far:` sentence under its Summary, and `render --captain` carries them as a `### Cost` section above the relaying-agent trailer, so the person approving sees it.
|
||||
- **`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 name the recommendations, from any branch's record of the project the run's plan belongs to (the repository its physical path was in at init, kept in `run.json` as `project_root` with its state-root `project_slug` so a run restored on another machine still names the record; never the caller's working directory or a symlink's; local, mirror and the other mirror roots), so a Captain records the owner's standing words once and every lane cites them. The grammar fails closed: the newest standing row that mentions autoplan anywhere decides, and it grants only when its scope is the canonical `autoplan gates: take the recommendations` and its words name the recommendations without a negation; any other newest row about autoplan (`never take the recommendations`, `Permission ... is revoked`, `all reviews other than autoplan`, `No, ask me each time`) is a refusal that blocks every older grant until a later grant supersedes it. Two copies of one id keep the newest recorded; a row whose id or date is not the recorded shape (type and form) is never cited. 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 (the recommendation words must be the owner's own, without a negation) and with `STANDING_APPROVAL_INAPPLICABLE` when the gate carries an urgent or outside-plan finding, when `recommend` changed a recommendation (the standing words cover the reviewers' recommendations, not an agent's), or when `findings.jsonl` is missing or unreadable; the `STANDING:` line flattens control characters from the cited row; `--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. The other roots are still read (`read_also`), so a row mirrored under the earlier rule stays visible to `list`, `render` and the standing lookup; `gstack-approvals path` prints which rule chose the mirror and the `READ_ALSO:` roots.
|
||||
- **Result codes** (`lib/result-codes-capy.ts`): `STANDING_APPROVAL_ABSENT`, `STANDING_APPROVAL_INAPPLICABLE`, each with a `docs/troubleshooting.md` anchor.
|
||||
- **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. The other roots are still read (`read_also`), so a row mirrored under the earlier rule stays visible to `list` and `render`; `gstack-approvals path` prints which rule chose the mirror and the `READ_ALSO:` roots.
|
||||
|
||||
### Changed
|
||||
- **Eng phase, Section 4 (Performance)** (`autoplan/sections/eng-phase.md.tmpl`): a hot path with no performance budget or bench step is a finding, and the consensus table's row 3 asks "Perf risks + budget/bench named?". Five nearby phrases were shortened by the same number of characters, so every host's generated autoplan SKILL.md stays at its size-guard pin (raising a pin is an owner decision).
|
||||
- **`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.
|
||||
- **`autoplan/DRIVER.md`, `docs/capy-install.md`, `docs/unattended.md`, `docs/recommended-harness.md`, `capy/skill/gstack/captain.md`, `docs/troubleshooting.md`**: the loop relays the two cost lines with every gate render; the Captain sections name the crew-readable mirror and the `READ_ALSO:` roots; the brief descriptions carry the `### Cost` section; 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 end-to-end runs through `bin/gstack-autoplan` with the plan in its own git repository: no covering row refuses and keeps the gate, a row recorded for the caller's project but not the run's refuses, a row from another branch answers `rec` with the citation on every row and in the import (plan-changing recommendations leave `amend_pending`; none, `complete`), an urgent or outside-plan finding, a `recommend`-edited gate and a missing `findings.jsonl` refuse, and a row with control characters cannot forge a protocol line (one with a line break in its id or date is not cited at all), a newer refusal blocks an older grant in each quoted phrasing (`never`, `is revoked`, `other than autoplan`, `No, ask me each time`), same-id copies keep the newest recorded, a torn `findings.jsonl` refuses, a run whose checkout is gone resolves its record by `project_slug`, `project_root` is a recorded root for `export --public`, a symlinked plans folder resolves the real repository and the identity survives the plan moving, `citableRow`, `priorChoices`; `gate_cost` in the closing submit's JSON, the `### Cost` section of `render --captain` above its trailer, and `run` at a pending gate printing the gate lines once (one `--json` payload). 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.
|
||||
- `test/autoplan-gate-cost.test.ts` (new): `recReopens` as the upper bound (plan change counts every phase revise can keep or add, a round grant its phase plus Eng, carried items only when changed, overrides, `p1d`/`p1c`/`p1e`), `priorChoices`, 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` with `READ_ALSO:`; end-to-end runs through `bin/gstack-autoplan`: the closing submit prints `GATE_SPEND:`/`GATE_REC_COST:` (text) or `gate_cost` (`--json`), the owner brief carries the `Cost so far:` sentence, `render --captain` a `### Cost` section above its trailer, and `run` at a pending gate prints the gate lines once (one `--json` payload).
|
||||
|
||||
### 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.
|
||||
|
||||
+2
-3
@@ -29,9 +29,8 @@ line yourself; gstack never writes the plan.
|
||||
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 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` can reopen and the estimate for them, an upper bound; `gate_cost` in `--json`).
|
||||
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 or a `recommend` changed it, which standing words do not cover. `APPLY: <id> <title> -> <change>` lines are
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -26,9 +26,8 @@ line yourself; gstack never writes the plan.
|
||||
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 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` can reopen and the estimate for them, an upper bound; `gate_cost` in `--json`).
|
||||
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 or a `recommend` changed it, which standing words do not cover. `APPLY: <id> <title> -> <change>` lines are
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -15,10 +15,6 @@
|
||||
* 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` with the scope `autoplan gates: take the recommendations`
|
||||
* and the owner's words naming the recommendations is what `gstack-autoplan
|
||||
* answer --owner-standing rec` cites; any newer standing row mentioning autoplan
|
||||
* that is not that grant refuses it.
|
||||
* `render` prints the `## Approvals` section /ship puts in the PR body. Logic:
|
||||
* lib/approval-record.ts.
|
||||
*/
|
||||
|
||||
+3
-31
@@ -11,7 +11,6 @@
|
||||
* 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
|
||||
@@ -31,9 +30,8 @@
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { amendmentSteps, answerGate, closeDeferred, finishGate, recommendGate, standingAnswer } from '../lib/autoplan-gate';
|
||||
import { amendmentSteps, answerGate, closeDeferred, finishGate, recommendGate } from '../lib/autoplan-gate';
|
||||
import { gateCost, gateCostPayload, renderGateCost, type GateCost } from '../lib/autoplan-gate-cost';
|
||||
import { locate, standingRecApproval, type ApprovalRow } 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';
|
||||
@@ -46,7 +44,6 @@ import { reviseRun } from '../lib/autoplan-revise';
|
||||
import { pendingAmendments, type RunState } from '../lib/autoplan-state';
|
||||
import { FILES, RunError, currentPhase, initRun, markOutside, nextStep, readState, resumeRun, statusLines, submitResult, withRunLock, writeState } from '../lib/autoplan-run';
|
||||
import { driverFile, type Phase } from '../lib/autoplan-prompts';
|
||||
import { repoRoot } from '../lib/autoplan-plan-text';
|
||||
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
|
||||
import { createRunner, familyConflict, type RunnerId } from '../lib/outside-voice-runner';
|
||||
import { resultLine } from '../lib/result-codes';
|
||||
@@ -80,9 +77,6 @@ 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
|
||||
@@ -360,36 +354,14 @@ 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');
|
||||
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 (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);
|
||||
let standing: ApprovalRow | undefined;
|
||||
if (standingFlag !== undefined) {
|
||||
// The record is the run's project's, never the caller's cwd: a lane answering project B's gate from
|
||||
// project A's checkout must not cite A's standing words. The project was fixed at init from the plan's
|
||||
// physical path (project_root); a run from before that field resolves it the same way now.
|
||||
const planFile = state.plan_input ?? state.plan_source;
|
||||
const runRepo = state.project_root ?? (planFile && fs.existsSync(planFile) ? repoRoot(path.dirname(fs.realpathSync(planFile))) : undefined);
|
||||
if (!runRepo) throw new RunError('STANDING_APPROVAL_ABSENT', `the run's plan (${planFile ?? 'unknown'}) is not inside a git repository, so no project approval record can be resolved for it`, 3);
|
||||
// A restored run on another machine has no checkout at project_root: its recorded slug still names the record.
|
||||
const rootHere = fs.existsSync(runRepo);
|
||||
if (!rootHere && !state.project_slug) throw new RunError('STANDING_APPROVAL_ABSENT', `the run's project checkout ${runRepo} is not on this machine and the run recorded no project slug, so no project approval record can be resolved for it`, 3);
|
||||
const loc = locate({ cwd: rootHere ? runRepo : process.cwd(), slug: state.project_slug, env: process.env, branch: flag(rest, '--branch') });
|
||||
if ('code' in loc) throw new RunError(loc.code, loc.message, 3);
|
||||
const found = standingRecApproval(loc);
|
||||
const where = `${[loc.file, loc.mirror, ...(loc.read_also ?? [])].flatMap(f => f ? [path.dirname(f)] : []).join(' or ')} (project ${loc.slug} at ${runRepo}, branch ${loc.branch})`;
|
||||
if (found.refusal) throw new RunError('STANDING_APPROVAL_ABSENT', `the newest standing instruction about autoplan recommendations refuses them: ${found.refusal.id} (${found.refusal.date}) ${JSON.stringify(found.refusal.words.replace(/[\u0000-\u001f\u007f\u2028\u2029]+/g, ' '))}; relay the gate instead (record in ${where})`, 3);
|
||||
standing = found.grant;
|
||||
if (!standing) throw new RunError('STANDING_APPROVAL_ABSENT', `no standing approval covering autoplan recommendations in ${where}`, 3);
|
||||
}
|
||||
const r = standing ? standingAnswer(state, Number(rev), by, standing) : answerGate(state, Number(rev), reply!, by, { recHash: flag(rest, '--rec-hash'), ownerReply });
|
||||
const r = 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,8 +301,6 @@ 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' ;;
|
||||
|
||||
@@ -47,9 +47,10 @@ gstack-approvals render # the ## Approvals se
|
||||
|
||||
- **Where it lives.** `<state root>/projects/<slug>/approvals/<branch key>.jsonl`, one file per branch,
|
||||
because one thread ships one branch. On Capy the state root is thrown away with the machine, so every write
|
||||
is also mirrored to the private project-user Drive folder (`<drive>/approvals/<slug>/`), or to
|
||||
`GSTACK_APPROVALS_DIR`. `GSTACK_APPROVALS_MIRROR=0` turns the mirror off. Reads merge both copies, and
|
||||
`gstack-approvals sync` restores the local copy.
|
||||
is also mirrored to `GSTACK_APPROVALS_DIR`, else to the `approvals/` directory in the mounted Captain
|
||||
drive (`<captain drive>/approvals/<slug>/`, readable by the whole crew), else to the private project-user
|
||||
Drive folder. `gstack-approvals path` prints the chosen mirror and `READ_ALSO:` roots, which reads still
|
||||
merge. `GSTACK_APPROVALS_MIRROR=0` turns the mirror off, and `gstack-approvals sync` restores the local copy.
|
||||
- **The durable copies** are the `## Approvals` sections in the thread's report and in the PR body. Paste
|
||||
`render` output into them; never type rows by hand.
|
||||
- **Secret-free.** Every field passes gstack's secret scanner before it is written. A hit refuses the row
|
||||
@@ -88,7 +89,8 @@ gstack-owner-brief render --captain --out <run dir>
|
||||
|
||||
It prints a header line ("Decision needed … Reply **rec** …"), the four plan sections (each capped, with a
|
||||
pointer to the plan for the rest), and "What we need from you", which lists every choice `rec` takes, in
|
||||
words, and closes with the "do it" reply. The last line, "For the relaying agent", carries the run, `gate_rev`, `rec_hash` and the exact
|
||||
words, and closes with the "do it" reply, then a `### Cost` section (spent so far, worst case, and the estimate for
|
||||
the phases `rec` can reopen). The last line, "For the relaying agent", carries the run, `gate_rev`, `rec_hash` and the exact
|
||||
`gstack-autoplan answer` command. Relay everything above that line. When the reply comes back:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -438,12 +438,8 @@ with `gstack-owner-brief render --captain`, answers are recorded with
|
||||
`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
|
||||
prints its cost (`GATE_SPEND:`, `GATE_REC_COST:`; `autoplan/DRIVER.md` says how to relay
|
||||
them). 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
|
||||
|
||||
@@ -312,7 +312,7 @@ closed) holds the longer history behind these numbers; from here on
|
||||
|---|---|---|
|
||||
| Install and health | `./setup`, `gstack-doctor`, `gstack-upgrade` | the user-scope skill folder, `scripts/install.sh --check` and its trailer, the project Setup snapshot |
|
||||
| Running a workflow | the SKILL.md files, `GSTACK_SESSION_KIND=unattended`, `GSTACK_RESULT:` lines | the `GSTACK_LAUNCH` assignments, `GSTACK_CAPY=1` and the `capy-asks` block, no enforced hooks (safety skills are advisory) |
|
||||
| Approvals | `gstack-approvals`, `gstack-owner-brief render --captain`, the `## Approvals` table | the Captain relay (`needs_you`), the private Drive mirror |
|
||||
| Approvals | `gstack-approvals`, `gstack-owner-brief render --captain`, the `## Approvals` table | the Captain relay (`needs_you`), the Captain drive `approvals/` mirror |
|
||||
| Pull requests | `/ship`, the ship receipt, `gstack-pr-shots` | `tools.pr.create` (callbacks), `tools.pr.uploadAsset` (before/after images) |
|
||||
| Merging | `gstack-ship-queue` (ready, train build, verdict, verify), `gstack-restamp`, `gh pr merge --match-head-commit` | the controller is a Captain thread; slot calls are thread messages |
|
||||
| Flaky pass | the rerun rule, `flake-records-a*` and `flake-ledger-*` artifacts, `test:health` | the daily automation and the ledger in the Captain's Drive |
|
||||
@@ -403,9 +403,11 @@ harness on it as of that date, with the workaround the crews use.
|
||||
|
||||
- An autoplan gate inside a crew thread costs a Captain round trip even when
|
||||
the owner's standing answer is "take every recommendation"; record the
|
||||
standing answer once with `gstack-approvals` and cite it. Approval rows
|
||||
mirrored to the project-user Drive are not readable by other crew threads,
|
||||
so the Captain carries the text into PR bodies.
|
||||
standing answer once with `gstack-approvals` and cite it. Rows mirror to the
|
||||
Captain drive's `approvals/` directory when the
|
||||
Captain created one, so other crew threads can read them; without it they
|
||||
stay in the private project-user Drive and the Captain carries the text into
|
||||
PR bodies.
|
||||
|
||||
**Harness gaps the same files name (fixed or owned in gstack, listed so the
|
||||
reader knows the date):** the pregate failing every touched file in a
|
||||
|
||||
@@ -1707,46 +1707,6 @@ 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 in this project's records (local and mirror, every branch) and found none that
|
||||
applies. The grammar fails closed: the newest standing row that mentions autoplan anywhere
|
||||
(ask, scope or words) decides, and it grants only when its scope is the canonical
|
||||
`autoplan gates: take the recommendations` and its words, the owner's own, name the
|
||||
recommendations without a negation. Any other newest row about autoplan is a refusal
|
||||
(`never take the recommendations`, `Permission ... is revoked`, `all reviews other than
|
||||
autoplan`, `No, ask me each time`): the line quotes it, and only a later grant supersedes it.
|
||||
Other causes: the run's plan is not inside a git repository (the project is the repository
|
||||
the plan's physical path was in at init, recorded as `project_root` and `project_slug`), or a
|
||||
restored run whose checkout is not on this machine recorded no slug. A row whose id or date is
|
||||
not the recorded shape is never cited. 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"`
|
||||
(that scope, exactly; a standing row recorded later about autoplan with any other scope
|
||||
revokes it); 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 is outside what standing words that say "take the recommendations"
|
||||
cover: it carries an urgent or outside-plan finding (`findings.jsonl` rows with `urgent` or
|
||||
`outside_plan`), which is news beyond the plan; or `gstack-autoplan recommend` changed a
|
||||
recommendation (`rec_rev` > 0 or an item with `recommended_by`), so `rec` would take an
|
||||
option the relaying agent chose rather than the reviewers'; or `findings.jsonl` is missing or
|
||||
has an unreadable line, so the urgent check cannot run. The line says which. 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`,
|
||||
`--owner-reply` and, after a `recommend`, `--rec-hash` from the render they saw. The standing
|
||||
row still applies to later gates the reviewers alone recommended.
|
||||
|
||||
<a id="captain-brief-incomplete"></a>
|
||||
### `gstack-owner-brief render --captain: ... (CAPTAIN_BRIEF_INCOMPLETE)`
|
||||
|
||||
|
||||
+6
-2
@@ -293,7 +293,9 @@ the plan.
|
||||
so a gate an agent closed is distinguishable from one the owner closed.
|
||||
`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`).
|
||||
the render the owner saw (`GATE_REC_STALE`). Every `GATE:` line is followed
|
||||
by `GATE_SPEND:` and `GATE_REC_COST:` (also `gate_cost` in `--json`; see
|
||||
`autoplan/DRIVER.md`).
|
||||
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
|
||||
@@ -425,7 +427,9 @@ replaces the generated sentence with the run's own), then the numbered
|
||||
decisions through the gate list: seven per page (`--page N`), stable ids,
|
||||
each `auto` item annotated with the default taken if unanswered, each
|
||||
`approval` item `pending until you answer` or `decided <id><letter> on <date>`.
|
||||
The last line names `review-record.md`, where the full review lives, and the
|
||||
It carries a `Cost so far:` sentence (spent, worst case, cap, and the estimate for
|
||||
the phases `rec` can reopen); `render --captain` puts it in a `### Cost` section above
|
||||
the relaying-agent trailer. The last line names `review-record.md`, where the full review lives, and the
|
||||
`gstack-autoplan answer` call that answers the page. `--write` also saves
|
||||
`brief.md` in the run directory. `/autoplan` Phase 4 presents this output
|
||||
verbatim and writes the former long report to `review-record.md`.
|
||||
|
||||
+5
-54
@@ -64,7 +64,7 @@ function contained(root: string, file: string): boolean {
|
||||
}
|
||||
|
||||
/** Where this branch's record lives, and its mirror. */
|
||||
export function locate(o: { cwd: string; env: Env; branch?: string; slug?: string }): StoreLocation | RecordError {
|
||||
export function locate(o: { cwd: string; env: Env; branch?: string }): StoreLocation | RecordError {
|
||||
let branch = o.branch?.trim();
|
||||
if (!branch) {
|
||||
const r = git(o.cwd, ['symbolic-ref', '--short', '-q', 'HEAD']);
|
||||
@@ -72,8 +72,7 @@ export function locate(o: { cwd: string; env: Env; branch?: string; slug?: strin
|
||||
}
|
||||
if (!branch) return { code: 'APPROVAL_RECORD_NO_BRANCH', message: `no branch at ${o.cwd} (detached HEAD or not a git repository)` };
|
||||
const stateRoot = resolveStateRoot(o.env);
|
||||
// `slug` is the project identity a run recorded at init (project_slug): a checkout that no longer exists on this machine still names its record.
|
||||
const slug = o.slug || slugFromEnvironment(stateRoot, o.cwd) || 'unknown';
|
||||
const slug = slugFromEnvironment(stateRoot, o.cwd) || 'unknown';
|
||||
const dir = path.join(stateRoot, 'projects', slug, 'approvals');
|
||||
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}` };
|
||||
@@ -112,54 +111,6 @@ export function captainApprovalsDir(env: Env): string | undefined {
|
||||
return found.length === 1 ? found[0] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A standing approval an agent may cite to answer an autoplan gate with `rec` (plan E21). The
|
||||
* grammar fails closed: a grant is a standing row whose scope is the canonical form the Captain
|
||||
* records (`autoplan gates: take the recommendations`, case and spacing aside) and whose words,
|
||||
* the owner's own, name the recommendations without a negation. Every other standing row that
|
||||
* mentions autoplan anywhere (ask, scope or words) is a refusal when it is the newest: `never take
|
||||
* the recommendations`, `all reviews other than autoplan`, `No, ask me each time`. Free text is
|
||||
* never parsed for permission; only the canonical scope grants.
|
||||
*/
|
||||
export const CANONICAL_AUTOPLAN_SCOPE = 'autoplan gates: take the recommendations';
|
||||
const NEGATION_RE = /\b(never|not|no|don[’']?t|do not|without|except|excluding|unless|reject(?:ed|s)?|refuse[ds]?|decline[ds]?|stop|revoke[ds]?|withdraw[ns]?|withdrew|rescind(?:ed|s)?|cancel(?:led|ed|s)?|other than|ask me)\b/i;
|
||||
const canonicalScope = (s: string) => s.trim().toLowerCase().replace(/\s+/g, ' ').replace(/\s*:\s*/, ': ') === CANONICAL_AUTOPLAN_SCOPE;
|
||||
/** A standing row about autoplan at all: a grant or a refusal, so the newest one decides. */
|
||||
export const mentionsAutoplan = (r: ApprovalRow): boolean =>
|
||||
r.outcome === 'standing' && /\bautoplan\b/i.test(`${r.ask} ${r.scope} ${r.words}`);
|
||||
export const coversAutoplanRec = (r: ApprovalRow): boolean =>
|
||||
mentionsAutoplan(r) && canonicalScope(r.scope) && /\b(rec|recommendations?|recommended)\b/i.test(r.words) && !NEGATION_RE.test(r.words);
|
||||
|
||||
/** A row an agent may cite: the generated id shape, a real calendar date and plain string fields, so no stored row can forge an output line or throw mid-answer. */
|
||||
const STANDING_ID_RE = /^[A-Za-z0-9._-]{1,64}$/;
|
||||
export const citableRow = (r: ApprovalRow): boolean =>
|
||||
typeof r.id === 'string' && STANDING_ID_RE.test(r.id) && typeof r.date === 'string' && validDate(r.date)
|
||||
&& ['ask', 'words', 'by', 'scope', 'source', 'branch', 'recorded_at'].every(k => typeof r[k as keyof ApprovalRow] === 'string');
|
||||
|
||||
export interface StandingLookup { grant?: ApprovalRow; refusal?: ApprovalRow }
|
||||
/**
|
||||
* The newest standing instruction about autoplan, from this branch's record and every other
|
||||
* branch's record of the same project (local, mirror and the other mirror roots): a Captain
|
||||
* records the owner's standing words once, on whatever branch it had checked out, and every lane
|
||||
* cites it. The newest citable row decides (date, then recorded_at; two copies of one id keep the
|
||||
* newest recorded): a grant only when `coversAutoplanRec`, otherwise a refusal that blocks every
|
||||
* older grant until a newer grant supersedes it.
|
||||
*/
|
||||
export function standingRecApproval(loc: StoreLocation): StandingLookup {
|
||||
const rows: ApprovalRow[] = [];
|
||||
for (const dir of [loc.file, loc.mirror, ...(loc.read_also ?? [])].flatMap(f => f ? [path.dirname(f)] : [])) {
|
||||
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>();
|
||||
const newest = rows.filter(r => citableRow(r) && mentionsAutoplan(r))
|
||||
.sort((a, b) => b.date.localeCompare(a.date) || b.recorded_at.localeCompare(a.recorded_at))
|
||||
.filter(r => !seen.has(r.id) && seen.add(r.id))[0];
|
||||
if (!newest) return {};
|
||||
return coversAutoplanRec(newest) ? { grant: newest } : { refusal: newest };
|
||||
}
|
||||
|
||||
function readRows(file: string | undefined): ApprovalRow[] {
|
||||
if (!file || !fs.existsSync(file)) return [];
|
||||
const rows: ApprovalRow[] = [];
|
||||
@@ -275,7 +226,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; standing?: 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; carried_from?: string; label?: string; chosen_text?: string }
|
||||
|
||||
const stripRef = (t: string) => t.replace(/\s*\[[^\]]*\]\s*$/, '').trim();
|
||||
|
||||
@@ -314,7 +265,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') : first.standing ? `${by ?? 'the owner'}, standing approval ${first.standing} cited by ${answerer}` : `${by ?? 'the owner'}, relayed by ${answerer}`;
|
||||
const who = answerer === 'human' ? (by ? `${by}, typed at the gate` : 'the owner, typed at the gate') : `${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),
|
||||
@@ -324,7 +275,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}${first.standing ? ` standing=${first.standing}` : ''}`,
|
||||
source: `autoplan:${runId}@gate_rev=${rev}`,
|
||||
}, branch, new Date(first.answered_at!));
|
||||
if ('code' in built) return built;
|
||||
out.push(built);
|
||||
|
||||
+1
-30
@@ -28,7 +28,6 @@ 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';
|
||||
@@ -322,34 +321,6 @@ 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 inapplicable = (why: string) => new RunError('STANDING_APPROVAL_INAPPLICABLE', `${why}; the standing approval ${standing.id} covers the reviewers' recommendations on the plan, not this`, 3);
|
||||
const findingsFile = path.join(state.out, 'findings.jsonl');
|
||||
if (!fs.existsSync(findingsFile)) throw inapplicable('findings.jsonl is missing, so the urgent-finding check cannot run');
|
||||
const findings = readJsonl(findingsFile);
|
||||
if (findings.errors.length) throw inapplicable(`findings.jsonl has ${findings.errors.length} unreadable line(s), so the urgent-finding check cannot run`);
|
||||
const urgent = (findings.rows as Array<Record<string, unknown>>).filter(r => r.urgent === true || r.outside_plan === true);
|
||||
if (urgent.length) throw inapplicable(`${urgent.length} urgent or outside-plan finding(s) at this gate (${urgent.slice(0, 3).map(r => String(r.id ?? r.title ?? '?')).join(', ')})`);
|
||||
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;
|
||||
const edited = list.items.filter(i => i.recommended_by);
|
||||
if ((list.rec_rev ?? 0) > 0 || edited.length) throw inapplicable(`\`recommend\` changed this gate (rec_rev=${list.rec_rev ?? 0}${edited.length ? `; ${edited.map(i => `${i.id} by ${i.recommended_by}`).join(', ')}` : ''}), so its recommendations are no longer the reviewers' alone; relay the render and answer with --reply and --rec-hash`);
|
||||
const r = answerGate(state, gateRev, 'rec', answeredBy, { recHash: recHash(list), ownerReply: standing.words, standing });
|
||||
const flat = (s: string) => s.replace(/[\u0000-\u001f\u007f\u2028\u2029]+/g, ' ');
|
||||
r.lines.unshift(`STANDING: ${flat(standing.id)} (${flat(standing.date)}, ${flat(standing.by)}; ${flat(standing.source)}) ${JSON.stringify(flat(standing.words))} — scope: ${flat(standing.scope)}`);
|
||||
return r;
|
||||
}
|
||||
|
||||
/** How an accepted plan change is discharged: a packaging/ordering change settles without a plan edit. */
|
||||
@@ -402,7 +373,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 } : {}), ...(o.standing ? { standing: o.standing.id } : {}) };
|
||||
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');
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface RecordedRoot { label: string; root: string }
|
||||
interface Provenance { private_run: string; private_manifest_sha256: string; exported_at: string; roots: string[]; files: Record<string, { private_sha256: string; public_sha256: string }> }
|
||||
|
||||
/** The machine roots a run recorded on this machine, longest first; the worktree root is labelled for the repo-relative rewrite. */
|
||||
export function recordedRoots(state: Pick<RunState, 'out' | 'plan_input' | 'plan_source' | 'project_root'>, env: NodeJS.ProcessEnv = process.env): RecordedRoot[] {
|
||||
export function recordedRoots(state: Pick<RunState, 'out' | 'plan_input' | 'plan_source'>, env: NodeJS.ProcessEnv = process.env): RecordedRoot[] {
|
||||
const input = state.plan_input ?? state.plan_source;
|
||||
const worktree = input ? repoRoot(path.dirname(input)) : undefined;
|
||||
const candidates: RecordedRoot[] = [
|
||||
@@ -39,7 +39,6 @@ export function recordedRoots(state: Pick<RunState, 'out' | 'plan_input' | 'plan
|
||||
...(input ? [{ label: 'plan_input', root: path.dirname(input) }] : []),
|
||||
...(checkpointRoot(env) ? [{ label: 'checkpoint', root: checkpointRoot(env)! }] : []),
|
||||
...(worktree ? [{ label: 'worktree', root: worktree }] : []),
|
||||
...(state.project_root ? [{ label: 'project_root', root: state.project_root }] : []),
|
||||
];
|
||||
const seen = new Set<string>();
|
||||
return candidates.map(c => ({ ...c, root: path.resolve(c.root) })).filter(c => c.root !== path.parse(c.root).root && !seen.has(c.root) && seen.add(c.root))
|
||||
|
||||
+2
-6
@@ -21,7 +21,7 @@ import * as path from 'node:path';
|
||||
import { canonicalRows, parseReceipt, parseRecordFindings, parseReviewResult, type CanonicalFinding } from './autoplan-reconcile';
|
||||
import { amendmentSteps, applyGuidance, closeDeferred, closePhase, finishGate } from './autoplan-gate';
|
||||
import { GSTACK_VERSION } from './autoplan-migrate';
|
||||
import { driftLine, normalizeFindingFile, repoRoot, reviewHash, scaffold } from './autoplan-plan-text';
|
||||
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';
|
||||
@@ -33,8 +33,6 @@ import { familyConflict, modelFamily } from './outside-voice-runner';
|
||||
import { renderSpend, reserve, settle, summarize, readLedger } from './spend-ledger';
|
||||
import { DEFAULT_MAX_ROUNDS, attemptEstimate, overCap, resumeStop, roundCap, roundOf, roundsLine, stopForBudget, stopLines } from './autoplan-budget';
|
||||
import { annotateConfirm, confirmBlock } from './autoplan-confirm';
|
||||
import { slugFromEnvironment } from './bin-context';
|
||||
import { resolveStateRoot } from './state-root';
|
||||
|
||||
/** An unreadable run.lock younger than this is a lock some other gstack is still writing, never a stale one. */
|
||||
const LOCK_WRITE_GRACE_MS = 30_000;
|
||||
@@ -81,8 +79,6 @@ export function initRun(o: InitOptions, lines: string[] = []): RunState {
|
||||
const input = path.resolve(o.plan);
|
||||
let source = input;
|
||||
if (!fs.existsSync(source)) throw new Error(`plan not found: ${source}`);
|
||||
// The plan's physical directory names the project: a symlinked plans/ folder must not borrow the link's repository.
|
||||
const projectRoot = repoRoot(path.dirname(fs.realpathSync(input)));
|
||||
let scaffolded = false;
|
||||
if (o.planHeading) {
|
||||
const section = extractHeading(fs.readFileSync(source, 'utf8'), o.planHeading);
|
||||
@@ -123,7 +119,7 @@ export function initRun(o: InitOptions, lines: string[] = []): RunState {
|
||||
deviations: ['autoplan guard: not enforced by this host; publication order is unverified (GUARD_NOT_INSTALLED)'],
|
||||
...(o.deadlineMin ? { deadline_at: new Date(Date.now() + o.deadlineMin * 60_000).toISOString() } : {}),
|
||||
...(o.light ? { light: true } : {}),
|
||||
gstack_version: GSTACK_VERSION, plan_input: input, scope_flags: { ...(o.scope ?? {}) }, ...(projectRoot ? { project_root: projectRoot, project_slug: slugFromEnvironment(resolveStateRoot(process.env), projectRoot) } : {}),
|
||||
gstack_version: GSTACK_VERSION, plan_input: input, scope_flags: { ...(o.scope ?? {}) },
|
||||
...(o.planHeading ? { plan_heading: o.planHeading } : {}), ...(scaffolded ? { scaffolded: true } : {}),
|
||||
};
|
||||
fs.writeFileSync(path.join(out, 'timing.json'), JSON.stringify({ schema_version: 1, run, phases: [], started_at: now(), session_kind: state.session_kind }, null, 2) + '\n');
|
||||
|
||||
@@ -59,8 +59,6 @@ export interface RunState extends RunManifest {
|
||||
gstack_version?: string;
|
||||
/** The --plan file the owner edits (drift hints, revise), the --plan-heading it was cut from, and the scope flags revise re-detects with. */
|
||||
plan_input?: string; plan_heading?: string; scope_flags?: Record<string, boolean | undefined>; scaffolded?: boolean;
|
||||
/** The repository the plan lived in at init (physical path) and its state-root slug, kept across checkpoint restores so the run's project never follows a relocated copy; the slug names the record when the path is gone (another machine). */
|
||||
project_root?: string; project_slug?: string;
|
||||
plan_revision?: number; amendments?: Amendment[];
|
||||
plan_reality?: { plan_sha256: string; checker: string; verdict: string; line: string };
|
||||
/** Review rounds per phase (null: uncapped); round_grants are the owner's per-phase raises at the gate. */
|
||||
|
||||
@@ -41,8 +41,6 @@ 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[];
|
||||
|
||||
@@ -39,16 +39,6 @@ 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 --ask "autoplan gates" --words "<the owner\'s exact words>" --by "<who, where>" --scope "autoplan gates: take the recommendations"',
|
||||
},
|
||||
STANDING_APPROVAL_INAPPLICABLE: {
|
||||
anchor: 'standing-approval-inapplicable',
|
||||
summary: 'the gate is outside what a standing "take the recommendations" approval covers (an urgent or outside-plan finding, a recommendation set by `recommend`, or an unreadable findings.jsonl), so nothing was applied',
|
||||
fix: 'relay the gate render (the RELAY line) to the owner and answer with their reply through --reply, --owner-reply and --rec-hash; the standing row still applies to later gates the reviewers alone recommended',
|
||||
},
|
||||
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,219 @@
|
||||
/**
|
||||
* Plan E16/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 (an upper bound priced on
|
||||
* the effective choices, as `answer` reads them); the owner brief carries
|
||||
* the same sentence. Host-subagent attempts are priced unknown, never zero.
|
||||
* 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, locate, readRecord, type ApprovalRow } from '../lib/approval-record';
|
||||
import { gateCost, priorChoices, 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-gate-cost-'));
|
||||
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;
|
||||
};
|
||||
|
||||
describe('E16: the gate prints what it cost and what rec adds', () => {
|
||||
test('recReopens is the upper bound of the answer and revise rules: a plan change counts every phase revise can keep or add, a recommended round grant its phase plus Eng, nothing otherwise', () => {
|
||||
const pinned = { ui: false, dx: false };
|
||||
expect(recReopens(list('b'), { phase_order: ['ceo', 'design', 'eng'], scope_flags: pinned })).toEqual(['ceo', 'design', 'eng']);
|
||||
expect(recReopens(list('a'), { phase_order: ['ceo', 'design', 'eng'], scope_flags: pinned })).toEqual([]);
|
||||
// revise re-detects scope: a run that skipped Design and DX without pinning them off can gain both.
|
||||
expect(recReopens(list('b'), { phase_order: ['ceo', 'eng'] })).toEqual(['ceo', 'design', 'dx', 'eng']);
|
||||
expect(recReopens(list('b'), { phase_order: ['ceo', 'eng'], scope_flags: { ui: false } })).toEqual(['ceo', 'dx', 'eng']);
|
||||
// an answer already recorded at this gate wins over the recommendation, as rec itself keeps it
|
||||
expect(recReopens(list('a'), { phase_order: ['ceo', 'eng'], scope_flags: pinned }, { uc1: 'b' })).toEqual(['ceo', 'eng']);
|
||||
const carried = list('b'); carried.items[0]!.carried = { from: 'x', chosen: 'b', gate_rev: 1 };
|
||||
expect(recReopens(carried, { phase_order: ['ceo', 'eng'], scope_flags: pinned })).toEqual([]);
|
||||
// a carried choice overridden at this gate is a change again; an override of a non-changing recommendation counts as answerGate counts it
|
||||
const carriedA = list('a'); carriedA.items[0]!.carried = { from: 'x', chosen: 'a', gate_rev: 1 };
|
||||
expect(recReopens(carriedA, { phase_order: ['ceo', 'eng'], scope_flags: pinned }, { uc1: 'b' })).toEqual(['ceo', 'eng']);
|
||||
const plain = list('a'); plain.items[0]!.options = [{ key: 'a', text: 'keep' }, { key: 'b', text: 'other' }];
|
||||
expect(recReopens(plain, { phase_order: ['ceo', 'eng'], scope_flags: pinned }, { uc1: 'b' })).toEqual(['ceo', 'eng']);
|
||||
// the plan approval: a recorded rerun reopens every phase; a reject or stop reopens nothing
|
||||
const rerun = list('a'); rerun.items[2]!.options.push({ key: 'd', text: 'rerun every phase' }, { key: 'e', text: 'reject' });
|
||||
expect(recReopens(rerun, { phase_order: ['ceo', 'eng'], scope_flags: pinned }, { p1: 'd' })).toEqual(['ceo', 'eng']);
|
||||
expect(recReopens(list('b'), { phase_order: ['ceo', 'eng'], scope_flags: pinned }, { p1: 'e' })).toEqual([]);
|
||||
const agent = list('b'); agent.items[0]!.reopens = false;
|
||||
expect(recReopens(agent, { phase_order: ['ceo', 'eng'], scope_flags: pinned })).toEqual([]);
|
||||
const grant = list('a');
|
||||
grant.items.splice(1, 0, { id: 'c1', title: 'design at its round cap', options: [{ key: 'a', text: 'accept as deferred', changes_plan: false }, { key: 'b', text: 'one more round', changes_plan: false }], recommended: 'b', kind: 'approval', phase: 'design', cap: { phase: 'design', kind: 'deferred' } });
|
||||
expect(recReopens(grant, { phase_order: ['ceo', 'design', 'dx', 'eng'] })).toEqual(['design', 'eng']);
|
||||
grant.items[1]!.recommended = 'a';
|
||||
expect(recReopens(grant, { phase_order: ['ceo', 'design', 'dx', 'eng'] })).toEqual([]);
|
||||
expect(recReopens(grant, { phase_order: ['ceo', 'design', 'dx', 'eng'] }, { c1: 'b' })).toEqual(['design', 'eng']);
|
||||
});
|
||||
|
||||
test('priorChoices reads the answers already recorded at this gate revision, as answerGate does; gateCost prices the partial answer, not the recommendation', () => {
|
||||
const out = path.join(TMP, 'prior'); fs.mkdirSync(out, { recursive: true });
|
||||
const rows = [
|
||||
{ schema_version: 1, run: 'r', id: 'r-UC1', label: 'UC1', kind: 'approval', status: 'approved', gate_rev: 1, chosen: 'b' },
|
||||
{ schema_version: 1, run: 'r', id: 'r-D1', label: 'D1', kind: 'auto', status: 'pending', gate_rev: 1, chosen: null },
|
||||
{ schema_version: 1, run: 'r', id: 'r-OLD', label: 'UC1', kind: 'approval', status: 'approved', gate_rev: 0, chosen: 'a' },
|
||||
];
|
||||
fs.writeFileSync(path.join(out, 'decisions.jsonl'), rows.map(r => JSON.stringify(r)).join('\n') + '\n');
|
||||
expect(priorChoices(out, 1)).toEqual({ uc1: 'b' });
|
||||
expect(priorChoices(path.join(TMP, 'no-decisions'), 1)).toEqual({});
|
||||
const c = gateCost(out, list('a'), { phase_order: ['ceo', 'eng'], outside_enabled: false, spend_cap_usd: null, scope_flags: { ui: false, dx: false } }, { defaultPerVoiceUsd: 2 });
|
||||
expect(c).toMatchObject({ reopens: ['ceo', 'eng'], voices: 2, rec_more_usd: 4 });
|
||||
});
|
||||
|
||||
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, scope_flags: { dx: false } });
|
||||
expect(c).toMatchObject({ attempts: 2, reopens: ['ceo', 'design', 'eng'], voices: 6, per_voice_usd: 5, rec_more_usd: 30 });
|
||||
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 up to ceo,design,eng ≈ 6 voice(s) × 5.00 = +30.00 at most (the run\'s per-voice reservation) → headroom 3.50');
|
||||
expect(gateCostSentence(c)).toContain('Taking every recommendation reopens up to ceo, design, eng: at most +30.00 more.');
|
||||
const variant = (name: string, cap: string) => { const d = path.join(TMP, name); fs.mkdirSync(d, { recursive: true }); fs.writeFileSync(path.join(d, 'spend.json'), fs.readFileSync(path.join(out, 'spend.json'), 'utf8').replace('"cap_usd":40', `"cap_usd":${cap}`)); return d; };
|
||||
const over = renderGateCost(gateCost(variant('cost-tight', '10'), list('b'), { phase_order: ['ceo', 'design', 'eng'], outside_enabled: true, spend_cap_usd: 10, scope_flags: { dx: false } }));
|
||||
expect(over[1]).toContain('(over the cap: raise it with gstack-autoplan budget before answering rec)');
|
||||
const uncapped = gateCost(variant('cost-uncapped', 'null'), list('a'), { phase_order: ['ceo', 'eng'], outside_enabled: false, spend_cap_usd: null });
|
||||
const none = renderGateCost(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');
|
||||
expect(gateCostSentence(uncapped)).toBe('Cost so far: spent 1.50, worst case 6.50 (no cap set, 1 host attempt(s) unknown and counted at reservation; 2 reviewer attempt(s)). Taking every recommendation reopens nothing: no further spend.');
|
||||
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: 40, reopens: ['ceo', 'design', 'dx', 'eng'] });
|
||||
});
|
||||
});
|
||||
|
||||
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('the other mirror roots are read: a row mirrored under project-user before the Captain created approvals/ stays visible, and path names both', () => {
|
||||
const h = fs.mkdtempSync(path.join(TMP, 'home-'));
|
||||
const d = path.join(h, '.capy', 'drive');
|
||||
fs.mkdirSync(path.join(d, 'project-user-gstack-garry', 'autoplan-checkpoints'), { recursive: true });
|
||||
const e = { ...base, HOME: h, GSTACK_STATE_ROOT: path.join(TMP, 'state-ra') } as NodeJS.ProcessEnv;
|
||||
const before = locate({ cwd: ROOT, env: e, branch: 'lane-x' }); if ('code' in before) throw new Error(before.message);
|
||||
expect(before.read_also).toEqual([]);
|
||||
fs.mkdirSync(path.dirname(before.mirror!), { recursive: true });
|
||||
fs.writeFileSync(before.mirror!, JSON.stringify(row({}, 'lane-x')) + '\n');
|
||||
fs.mkdirSync(path.join(d, 'captain-minder', 'approvals'), { recursive: true });
|
||||
const after = locate({ cwd: ROOT, env: e, branch: 'lane-x' }); if ('code' in after) throw new Error(after.message);
|
||||
expect(after.mirror_source).toBe('captain drive');
|
||||
expect(after.read_also).toEqual([before.mirror!]);
|
||||
expect(readRecord(after).rows.map(r => r.id)).toEqual([row({}, 'lane-x').id]);
|
||||
const r = spawnSync(process.execPath, [path.join(ROOT, 'bin', 'gstack-approvals'), 'path', '--branch', 'lane-x'], { cwd: ROOT, env: { ...process.env, ...e } as Record<string, string>, encoding: 'utf8', timeout: 30_000 });
|
||||
expect(r.stdout).toContain(`MIRROR: ${after.mirror} (captain drive)`);
|
||||
expect(r.stdout).toContain(`READ_ALSO: ${before.mirror}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('E16 end to end: every gate-producing command prints the cost, and the briefs carry it', () => {
|
||||
/** 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> = {}, o: { challenge?: boolean; json?: boolean } = {}): string {
|
||||
const repo = path.join(TMP, name);
|
||||
fs.mkdirSync(repo, { recursive: true });
|
||||
expect(spawnSync('git', ['init', '-q', '-b', 'lane-z', repo], { encoding: 'utf8', timeout: 30_000 }).status).toBe(0);
|
||||
const out = startSmallRun(repo);
|
||||
const challenge = o.challenge === false ? [] : [{ 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 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 }, ...challenge]);
|
||||
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 },
|
||||
...challenge.map(c => ({ ...c, id: 'O3', 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', ...(o.json && phase === 'eng' ? ['--json'] : [])]).stdout;
|
||||
}
|
||||
expect(readRun(out).status).toBe('gate_pending');
|
||||
if (o.json) {
|
||||
const payload = JSON.parse(last.trim().split('\n').pop()!);
|
||||
expect(payload.status).toBe('gate_pending');
|
||||
expect(payload.gate_cost).toMatchObject({ attempts: 4, unknown_count: 4, rec_voices: payload.gate_cost.rec_reopens.length * 2 });
|
||||
expect(typeof payload.gate_cost.spent_usd).toBe('number');
|
||||
} else {
|
||||
expect(last).toMatch(/^GATE_SPEND: spent=\d+\.\d\d worst_case=\d+\.\d\d cap=\S+ headroom=\S+ attempts=4; 4 host-subagent attempt/m);
|
||||
expect(last).toMatch(/^GATE_REC_COST: rec reopens /m);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
test('the closing submit prints GATE_SPEND/GATE_REC_COST (text) or gate_cost (--json); the owner brief carries the Cost so far sentence and render --captain a ### Cost section above the relaying trailer', () => {
|
||||
const out = gateRun('plain', {}, { challenge: false, json: true });
|
||||
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 captain = spawnSync(process.execPath, [path.join(ROOT, 'bin', 'gstack-owner-brief'), 'render', '--captain', '--out', out, '--allow-incomplete'], { cwd: ROOT, env: env(), encoding: 'utf8', timeout: 30_000 });
|
||||
expect(captain.stdout).toMatch(/### Cost\n\nCost so far: spent \d+\.\d\d, worst case \d+\.\d\d \(/);
|
||||
expect(captain.stdout).toContain('Taking every recommendation reopens nothing: no further spend.');
|
||||
// the relaying agent sends everything above the trailer, so the price must sit above it
|
||||
expect(captain.stdout.indexOf('### Cost')).toBeLessThan(captain.stdout.indexOf('_For the relaying agent, not the person:_'));
|
||||
expect(captain.stdout.indexOf('_For the relaying agent, not the person:_')).toBeGreaterThan(0);
|
||||
const changing = gateRun('changing');
|
||||
const rendered = spawnSync(process.execPath, [path.join(ROOT, 'bin', 'gstack-owner-brief'), 'render', '--out', changing], { cwd: ROOT, env: env(), encoding: 'utf8', timeout: 30_000 });
|
||||
expect(rendered.stdout).toMatch(/Taking every recommendation reopens up to [a-z, ]+: at most \+\d+\.\d\d more\./);
|
||||
});
|
||||
|
||||
test('run at a pending gate prints the gate lines once: text mode carries GATE_SPEND/GATE_REC_COST, --json exactly one payload with gate_cost', () => {
|
||||
const out = gateRun('run-gate', {}, { challenge: false });
|
||||
const models = ['--native-model', 'anthropic/claude-opus-4-7', '--outside-model', 'openai/gpt-6-astra'];
|
||||
const text = autoplan(['run', '--out', out, ...models]);
|
||||
expect(text.status).toBe(0);
|
||||
expect(text.stdout.match(/^GATE_SPEND: /gm)?.length).toBe(1);
|
||||
expect(text.stdout.match(/^GATE_REC_COST: /gm)?.length).toBe(1);
|
||||
const json = autoplan(['run', '--out', out, ...models, '--json']);
|
||||
expect(json.status).toBe(0);
|
||||
const objects = json.stdout.trim().split('\n').filter(l => l.startsWith('{'));
|
||||
expect(objects.length).toBe(1);
|
||||
expect(JSON.parse(objects[0]!)).toMatchObject({ status: 'gate_pending', gate_cost: { rec_reopens: [] } });
|
||||
expect(readRun(out).status).toBe('gate_pending');
|
||||
});
|
||||
});
|
||||
@@ -1,472 +0,0 @@
|
||||
/**
|
||||
* 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, citableRow, coversAutoplanRec, locate, mentionsAutoplan, readRecord, standingRecApproval, type ApprovalRow } from '../lib/approval-record';
|
||||
import { gateCost, priorChoices, recReopens, renderGateCost, gateCostSentence } from '../lib/autoplan-gate-cost';
|
||||
import type { GateList } from '../lib/gate-list';
|
||||
import { recordedRoots } from '../lib/autoplan-public';
|
||||
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 is the upper bound of the answer and revise rules: a plan change counts every phase revise can keep or add, a recommended round grant its phase plus Eng, nothing otherwise', () => {
|
||||
const pinned = { ui: false, dx: false };
|
||||
expect(recReopens(list('b'), { phase_order: ['ceo', 'design', 'eng'], scope_flags: pinned })).toEqual(['ceo', 'design', 'eng']);
|
||||
expect(recReopens(list('a'), { phase_order: ['ceo', 'design', 'eng'], scope_flags: pinned })).toEqual([]);
|
||||
// revise re-detects scope: a run that skipped Design and DX without pinning them off can gain both.
|
||||
expect(recReopens(list('b'), { phase_order: ['ceo', 'eng'] })).toEqual(['ceo', 'design', 'dx', 'eng']);
|
||||
expect(recReopens(list('b'), { phase_order: ['ceo', 'eng'], scope_flags: { ui: false } })).toEqual(['ceo', 'dx', 'eng']);
|
||||
// an answer already recorded at this gate wins over the recommendation, as rec itself keeps it
|
||||
expect(recReopens(list('a'), { phase_order: ['ceo', 'eng'], scope_flags: pinned }, { uc1: 'b' })).toEqual(['ceo', 'eng']);
|
||||
const carried = list('b'); carried.items[0]!.carried = { from: 'x', chosen: 'b', gate_rev: 1 };
|
||||
expect(recReopens(carried, { phase_order: ['ceo', 'eng'], scope_flags: pinned })).toEqual([]);
|
||||
// a carried choice overridden at this gate is a change again; an override of a non-changing recommendation counts as answerGate counts it
|
||||
const carriedA = list('a'); carriedA.items[0]!.carried = { from: 'x', chosen: 'a', gate_rev: 1 };
|
||||
expect(recReopens(carriedA, { phase_order: ['ceo', 'eng'], scope_flags: pinned }, { uc1: 'b' })).toEqual(['ceo', 'eng']);
|
||||
const plain = list('a'); plain.items[0]!.options = [{ key: 'a', text: 'keep' }, { key: 'b', text: 'other' }];
|
||||
expect(recReopens(plain, { phase_order: ['ceo', 'eng'], scope_flags: pinned }, { uc1: 'b' })).toEqual(['ceo', 'eng']);
|
||||
// the plan approval: a recorded rerun reopens every phase; a reject or stop reopens nothing
|
||||
const rerun = list('a'); rerun.items[2]!.options.push({ key: 'd', text: 'rerun every phase' }, { key: 'e', text: 'reject' });
|
||||
expect(recReopens(rerun, { phase_order: ['ceo', 'eng'], scope_flags: pinned }, { p1: 'd' })).toEqual(['ceo', 'eng']);
|
||||
expect(recReopens(list('b'), { phase_order: ['ceo', 'eng'], scope_flags: pinned }, { p1: 'e' })).toEqual([]);
|
||||
const agent = list('b'); agent.items[0]!.reopens = false;
|
||||
expect(recReopens(agent, { phase_order: ['ceo', 'eng'], scope_flags: pinned })).toEqual([]);
|
||||
const grant = list('a');
|
||||
grant.items.splice(1, 0, { id: 'c1', title: 'design at its round cap', options: [{ key: 'a', text: 'accept as deferred', changes_plan: false }, { key: 'b', text: 'one more round', changes_plan: false }], recommended: 'b', kind: 'approval', phase: 'design', cap: { phase: 'design', kind: 'deferred' } });
|
||||
expect(recReopens(grant, { phase_order: ['ceo', 'design', 'dx', 'eng'] })).toEqual(['design', 'eng']);
|
||||
grant.items[1]!.recommended = 'a';
|
||||
expect(recReopens(grant, { phase_order: ['ceo', 'design', 'dx', 'eng'] })).toEqual([]);
|
||||
expect(recReopens(grant, { phase_order: ['ceo', 'design', 'dx', 'eng'] }, { c1: 'b' })).toEqual(['design', 'eng']);
|
||||
});
|
||||
|
||||
test('priorChoices reads the answers already recorded at this gate revision, as answerGate does; gateCost prices the partial answer, not the recommendation', () => {
|
||||
const out = path.join(TMP, 'prior'); fs.mkdirSync(out, { recursive: true });
|
||||
const rows = [
|
||||
{ schema_version: 1, run: 'r', id: 'r-UC1', label: 'UC1', kind: 'approval', status: 'approved', gate_rev: 1, chosen: 'b' },
|
||||
{ schema_version: 1, run: 'r', id: 'r-D1', label: 'D1', kind: 'auto', status: 'pending', gate_rev: 1, chosen: null },
|
||||
{ schema_version: 1, run: 'r', id: 'r-OLD', label: 'UC1', kind: 'approval', status: 'approved', gate_rev: 0, chosen: 'a' },
|
||||
];
|
||||
fs.writeFileSync(path.join(out, 'decisions.jsonl'), rows.map(r => JSON.stringify(r)).join('\n') + '\n');
|
||||
expect(priorChoices(out, 1)).toEqual({ uc1: 'b' });
|
||||
expect(priorChoices(path.join(TMP, 'no-decisions'), 1)).toEqual({});
|
||||
const c = gateCost(out, list('a'), { phase_order: ['ceo', 'eng'], outside_enabled: false, spend_cap_usd: null, scope_flags: { ui: false, dx: false } }, { defaultPerVoiceUsd: 2 });
|
||||
expect(c).toMatchObject({ reopens: ['ceo', 'eng'], voices: 2, rec_more_usd: 4 });
|
||||
});
|
||||
|
||||
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, scope_flags: { dx: false } });
|
||||
expect(c).toMatchObject({ attempts: 2, reopens: ['ceo', 'design', 'eng'], voices: 6, per_voice_usd: 5, rec_more_usd: 30 });
|
||||
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 up to ceo,design,eng ≈ 6 voice(s) × 5.00 = +30.00 at most (the run\'s per-voice reservation) → headroom 3.50');
|
||||
expect(gateCostSentence(c)).toContain('Taking every recommendation reopens up to ceo, design, eng: at most +30.00 more.');
|
||||
const variant = (name: string, cap: string) => { const d = path.join(TMP, name); fs.mkdirSync(d, { recursive: true }); fs.writeFileSync(path.join(d, 'spend.json'), fs.readFileSync(path.join(out, 'spend.json'), 'utf8').replace('"cap_usd":40', `"cap_usd":${cap}`)); return d; };
|
||||
const over = renderGateCost(gateCost(variant('cost-tight', '10'), list('b'), { phase_order: ['ceo', 'design', 'eng'], outside_enabled: true, spend_cap_usd: 10, scope_flags: { dx: false } }));
|
||||
expect(over[1]).toContain('(over the cap: raise it with gstack-autoplan budget before answering rec)');
|
||||
const uncapped = gateCost(variant('cost-uncapped', 'null'), list('a'), { phase_order: ['ceo', 'eng'], outside_enabled: false, spend_cap_usd: null });
|
||||
const none = renderGateCost(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');
|
||||
expect(gateCostSentence(uncapped)).toBe('Cost so far: spent 1.50, worst case 6.50 (no cap set, 1 host attempt(s) unknown and counted at reservation; 2 reviewer attempt(s)). Taking every recommendation reopens nothing: no further spend.');
|
||||
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: 40, reopens: ['ceo', 'design', 'dx', 'eng'] });
|
||||
});
|
||||
});
|
||||
|
||||
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('the other mirror roots are read: a row mirrored under project-user before the Captain created approvals/ stays visible, and path names both', () => {
|
||||
const h = fs.mkdtempSync(path.join(TMP, 'home-'));
|
||||
const d = path.join(h, '.capy', 'drive');
|
||||
fs.mkdirSync(path.join(d, 'project-user-gstack-garry', 'autoplan-checkpoints'), { recursive: true });
|
||||
const e = { ...base, HOME: h, GSTACK_STATE_ROOT: path.join(TMP, 'state-ra') } as NodeJS.ProcessEnv;
|
||||
const before = locate({ cwd: ROOT, env: e, branch: 'lane-x' }); if ('code' in before) throw new Error(before.message);
|
||||
expect(before.read_also).toEqual([]);
|
||||
fs.mkdirSync(path.dirname(before.mirror!), { recursive: true });
|
||||
fs.writeFileSync(before.mirror!, JSON.stringify(row({}, 'lane-x')) + '\n');
|
||||
fs.mkdirSync(path.join(d, 'captain-minder', 'approvals'), { recursive: true });
|
||||
const after = locate({ cwd: ROOT, env: e, branch: 'lane-x' }); if ('code' in after) throw new Error(after.message);
|
||||
expect(after.mirror_source).toBe('captain drive');
|
||||
expect(after.read_also).toEqual([before.mirror!]);
|
||||
expect(readRecord(after).rows.map(r => r.id)).toEqual([row({}, 'lane-x').id]);
|
||||
expect(standingRecApproval(after).grant?.id).toBe(row({}, 'lane-x').id);
|
||||
const r = spawnSync(process.execPath, [path.join(ROOT, 'bin', 'gstack-approvals'), 'path', '--branch', 'lane-x'], { cwd: ROOT, env: { ...process.env, ...e } as Record<string, string>, encoding: 'utf8', timeout: 30_000 });
|
||||
expect(r.stdout).toContain(`MIRROR: ${after.mirror} (captain drive)`);
|
||||
expect(r.stdout).toContain(`READ_ALSO: ${before.mirror}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('E21: a standing approval answers rec without a round trip', () => {
|
||||
test('coversAutoplanRec fails closed: outcome standing, the canonical scope, and the owner\'s words naming the recommendations without a negation', () => {
|
||||
expect(coversAutoplanRec(row())).toBe(true);
|
||||
expect(coversAutoplanRec(row({ scope: ' Autoplan gates : take the recommendations ' }))).toBe(true);
|
||||
expect(coversAutoplanRec(row({ words: 'rec' }))).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' }))).toBe(false);
|
||||
// only the canonical scope grants: free text naming autoplan is never parsed for permission
|
||||
expect(coversAutoplanRec(row({ words: 'rec', scope: 'autoplan' }))).toBe(false);
|
||||
expect(coversAutoplanRec(row({ words: 'take the recommendations', scope: 'every autoplan gate, not merges' }))).toBe(false);
|
||||
expect(coversAutoplanRec(row({ words: 'take the recommendations', scope: 'all reviews except autoplan' }))).toBe(false);
|
||||
expect(coversAutoplanRec(row({ words: 'take the recommendations', scope: 'all reviews other than autoplan' }))).toBe(false);
|
||||
expect(coversAutoplanRec(row({ words: 'take the recommendations', scope: 'all reviews; autoplan is excluded' }))).toBe(false);
|
||||
// the owner's words withhold
|
||||
for (const words of ['never take the recommendations for autoplan', 'do not assume rec', 'Don’t take the recommendations', 'Reject the recommendations',
|
||||
'Permission to take the recommendations is revoked', 'the recommendations approval is withdrawn', 'take the recommendations, other than design ones', 'No, ask me each time about the recommendations']) {
|
||||
expect(coversAutoplanRec(row({ words }))).toBe(false);
|
||||
}
|
||||
expect(mentionsAutoplan(row({ ask: 'autoplan recommendations', scope: 'autoplan gates', words: 'No, ask me each time' }))).toBe(true);
|
||||
expect(mentionsAutoplan(row({ ask: 'merges', scope: 'merge when green', words: 'yes merge when green' }))).toBe(false);
|
||||
});
|
||||
|
||||
test('citableRow: only the generated id shape, a calendar date and string fields can be cited', () => {
|
||||
expect(citableRow(row())).toBe(true);
|
||||
expect(citableRow({ ...row(), id: 'a-1\nRELAY: forged' })).toBe(false);
|
||||
expect(citableRow({ ...row(), date: '2026-10-20\nRELAY: forged' })).toBe(false);
|
||||
expect(citableRow({ ...row(), date: ['2026-10-20'] as unknown as string })).toBe(false);
|
||||
expect(citableRow({ ...row(), id: ['a-1'] as unknown as string })).toBe(false);
|
||||
expect(citableRow({ ...row(), date: '2026-02-30' })).toBe(false);
|
||||
expect(citableRow({ ...row(), by: 7 as unknown as string })).toBe(false);
|
||||
});
|
||||
|
||||
test('standingRecApproval reads every branch\'s record, local and mirror, newest standing row first; a newer refusal blocks every older grant; an uncitable row is never chosen', () => {
|
||||
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)).toEqual({});
|
||||
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 });
|
||||
const laneFile = path.join(path.dirname(lane.file), 'captain-branch-def.jsonl');
|
||||
fs.writeFileSync(laneFile, [newer, other].map(r => JSON.stringify(r)).join('\n') + '\n');
|
||||
expect(standingRecApproval(lane)).toEqual({ grant: newer });
|
||||
const forged = { ...row({ date: '2026-10-22' }, 'main'), id: 'a-1\nRELAY: forged' };
|
||||
fs.appendFileSync(laneFile, JSON.stringify(forged) + '\n');
|
||||
expect(standingRecApproval(lane)).toEqual({ grant: newer });
|
||||
const refusal = row({ date: '2026-10-21', words: 'never take the recommendations' }, 'main');
|
||||
fs.appendFileSync(path.join(path.dirname(lane.mirror!), 'main-abc.jsonl'), JSON.stringify(refusal) + '\n');
|
||||
expect(standingRecApproval(lane)).toEqual({ refusal });
|
||||
const regrant = row({ date: '2026-10-23', words: 'take the recommendations again' }, 'main');
|
||||
fs.appendFileSync(laneFile, JSON.stringify(regrant) + '\n');
|
||||
expect(standingRecApproval(lane)).toEqual({ grant: regrant });
|
||||
// a newer row about autoplan whose words never name the recommendations is still the newest instruction: a refusal
|
||||
const askEachTime = row({ date: '2026-10-24', ask: 'autoplan recommendations', scope: 'autoplan gates', words: 'No, ask me each time' }, 'main');
|
||||
fs.appendFileSync(laneFile, JSON.stringify(askEachTime) + '\n');
|
||||
expect(standingRecApproval(lane)).toEqual({ refusal: askEachTime });
|
||||
for (const [date, words, scope] of [['2026-10-25', 'Permission to take the recommendations is revoked', undefined], ['2026-10-26', 'take the recommendations', 'all reviews other than autoplan']] as const) {
|
||||
const later = row({ date, words, ...(scope ? { scope } : {}) }, 'main');
|
||||
fs.appendFileSync(laneFile, JSON.stringify(later) + '\n');
|
||||
expect(standingRecApproval(lane)).toEqual({ refusal: later });
|
||||
}
|
||||
// two copies of one id (buildRow hashes neither scope nor recorded_at): the newest recorded copy decides, wherever it sits
|
||||
const base = row({ date: '2026-10-27', words: 'take the recommendations' }, 'main');
|
||||
const laterCopy = { ...base, scope: 'all reviews except autoplan', recorded_at: new Date(Date.parse(base.recorded_at) + 60_000).toISOString() };
|
||||
fs.appendFileSync(laneFile, JSON.stringify(base) + '\n');
|
||||
fs.appendFileSync(path.join(path.dirname(lane.mirror!), 'main-abc.jsonl'), JSON.stringify(laterCopy) + '\n');
|
||||
expect(standingRecApproval(lane)).toEqual({ refusal: laterCopy });
|
||||
});
|
||||
|
||||
/** Both voices challenge the same item: the gate recommends the plan change (b) and `rec` would reopen phases. */
|
||||
/** The run's plan lives in its own git repository, so the standing lookup resolves that project's record, never the caller's cwd. */
|
||||
function gateRun(name: string, extraFinding: Record<string, unknown> = {}, o: { challenge?: boolean; json?: boolean } = {}): string {
|
||||
const repo = path.join(TMP, name);
|
||||
fs.mkdirSync(repo, { recursive: true });
|
||||
expect(spawnSync('git', ['init', '-q', '-b', 'lane-z', repo], { encoding: 'utf8', timeout: 30_000 }).status).toBe(0);
|
||||
const out = startSmallRun(repo);
|
||||
const challenge = o.challenge === false ? [] : [{ 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 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 }, ...challenge]);
|
||||
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 },
|
||||
...challenge.map(c => ({ ...c, id: 'O3', 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', ...(o.json && phase === 'eng' ? ['--json'] : [])]).stdout;
|
||||
}
|
||||
expect(readRun(out).status).toBe('gate_pending');
|
||||
if (o.json) {
|
||||
const payload = JSON.parse(last.trim().split('\n').pop()!);
|
||||
expect(payload.status).toBe('gate_pending');
|
||||
expect(payload.gate_cost).toMatchObject({ attempts: 4, unknown_count: 4, rec_voices: payload.gate_cost.rec_reopens.length * 2 });
|
||||
expect(typeof payload.gate_cost.spent_usd).toBe('number');
|
||||
} else {
|
||||
expect(last).toMatch(/^GATE_SPEND: spent=\d+\.\d\d worst_case=\d+\.\d\d cap=\S+ headroom=\S+ attempts=4; 4 host-subagent attempt/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 standing = row({}, 'main');
|
||||
const foreign = seed('standing-foreign', undefined, ROOT);
|
||||
const fromCaller = autoplan(['answer', '--out', out, '--gate-rev', '1', '--owner-standing', 'rec', '--by', 'agent:lane', '--branch', 'lane-z'], { cwd: ROOT, env: approvalsEnv(foreign) });
|
||||
expect(fromCaller.status).toBe(3);
|
||||
expect(fromCaller.stderr).toContain('(STANDING_APPROVAL_ABSENT)');
|
||||
const dir = seed('standing');
|
||||
const l = locate({ cwd: path.join(TMP, 'standing'), 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);
|
||||
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 });
|
||||
const changing = JSON.parse(fs.readFileSync(path.join(out, 'gate.json'), 'utf8')).items.filter((i: any) => i.id !== 'p1' && i.options.find((o: any) => o.key === i.recommended)?.changes_plan).map((i: any) => i.id);
|
||||
expect(changing.length).toBeGreaterThan(0);
|
||||
for (const id of changing) expect(answered.find(d => d.label === id.toUpperCase())).toMatchObject({ chosen: 'b', status: 'approved' });
|
||||
expect(readRun(out).status).toBe('amend_pending');
|
||||
expect(readRun(out).amendments.filter((a: any) => a.status === 'pending').map((a: any) => a.item)).toEqual(changing);
|
||||
expect(r.stdout).toContain(`AMEND_PENDING: ${changing.length} accepted change(s)`);
|
||||
const imp = spawnSync(process.execPath, [path.join(ROOT, 'bin', 'gstack-approvals'), 'import', '--autoplan', out, '--by', 'Garry', '--branch', 'lane-z', '--json'], { cwd: path.join(TMP, 'standing'), 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`);
|
||||
});
|
||||
|
||||
/** A covering standing row in a fresh approvals dir, with a words override for the injection case. */
|
||||
function seed(name: string, words?: string, cwd = path.join(TMP, name), rows: Array<Partial<ApprovalRow>> = [{}]): string {
|
||||
const dir = path.join(TMP, `approvals-${name}`);
|
||||
const l = locate({ cwd, 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'), rows.map(over => JSON.stringify({ ...row({}, 'main'), ...(words ? { words } : {}), ...over })).join('\n') + '\n');
|
||||
return dir;
|
||||
}
|
||||
const standingAnswer = (out: string, dir: string) => autoplan(['answer', '--out', out, '--gate-rev', '1', '--owner-standing', 'rec', '--by', 'agent:lane', '--branch', 'lane-z'], { env: approvalsEnv(dir) });
|
||||
|
||||
test('no plan-changing recommendation: the standing answer completes the run with the citation on every row; the JSON payload and the Captain brief carry the cost', () => {
|
||||
const out = gateRun('plain', {}, { challenge: false, json: true });
|
||||
const captain = spawnSync(process.execPath, [path.join(ROOT, 'bin', 'gstack-owner-brief'), 'render', '--captain', '--out', out, '--allow-incomplete'], { cwd: ROOT, env: env(), encoding: 'utf8', timeout: 30_000 });
|
||||
expect(captain.stdout).toMatch(/### Cost\n\nCost so far: spent \d+\.\d\d, worst case \d+\.\d\d \(/);
|
||||
expect(captain.stdout).toContain('Taking every recommendation reopens nothing: no further spend.');
|
||||
// the relaying agent sends everything above the trailer, so the price must sit above it
|
||||
expect(captain.stdout.indexOf('### Cost')).toBeLessThan(captain.stdout.indexOf('_For the relaying agent, not the person:_'));
|
||||
expect(captain.stdout.indexOf('_For the relaying agent, not the person:_')).toBeGreaterThan(0);
|
||||
const r = standingAnswer(out, seed('plain'));
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('APPROVED: gate_rev=1 as-is');
|
||||
expect(readRun(out).status).toBe('complete');
|
||||
expect(readRun(out).amendments ?? []).toEqual([]);
|
||||
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.standing).toBe(row({}, 'main').id);
|
||||
});
|
||||
|
||||
test('an urgent or outside-plan finding at the gate makes the standing path refuse (STANDING_APPROVAL_INAPPLICABLE), as does a gate edited by recommend', () => {
|
||||
for (const name of ['urgent', 'outside']) {
|
||||
const out = gateRun(name, name === 'urgent' ? { urgent: true } : {});
|
||||
if (name === 'outside') fs.appendFileSync(path.join(out, 'findings.jsonl'), JSON.stringify({ schema_version: 1, run: readRun(out).run, id: 'X1', severity: 'High', title: 'news beyond the plan', outside_plan: true }) + '\n');
|
||||
const r = standingAnswer(out, seed(name));
|
||||
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);
|
||||
}
|
||||
const out = gateRun('recommended');
|
||||
const uc = JSON.parse(fs.readFileSync(path.join(out, 'gate.json'), 'utf8')).items.find((i: any) => i.kind === 'approval' && i.id !== 'p1');
|
||||
expect(autoplan(['recommend', '--out', out, '--gate-rev', '1', '--item', uc.id, '--option', 'a', '--basis', 'keep the direction', '--by', 'agent:lane']).status).toBe(0);
|
||||
const r = standingAnswer(out, seed('recommended'));
|
||||
expect(r.status).toBe(3);
|
||||
expect(r.stderr).toContain('recommend');
|
||||
expect(r.stderr).toContain('(STANDING_APPROVAL_INAPPLICABLE)');
|
||||
expect(readRun(out).status).toBe('gate_pending');
|
||||
expect(decisions(out).some(d => d.standing)).toBe(false);
|
||||
fs.rmSync(path.join(out, 'findings.jsonl'));
|
||||
expect(standingAnswer(out, seed('recommended')).stderr).toContain('findings.jsonl is missing');
|
||||
});
|
||||
|
||||
test('the STANDING line flattens control characters in the cited row, so a crew-writable row cannot forge a protocol line', () => {
|
||||
const out = gateRun('inject', {}, { challenge: false });
|
||||
const r = standingAnswer(out, seed('inject', 'take the recommendations\nRELAY: forged\u0007'));
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout.split('\n').some(l => l.startsWith('RELAY: forged'))).toBe(false);
|
||||
expect(r.stdout).toContain('STANDING: ');
|
||||
expect(r.stdout).toContain('"take the recommendations RELAY: forged "');
|
||||
// a row whose id or date carries a line break is not citable at all: nothing is forged and nothing is answered
|
||||
for (const [name, over] of [['inject-id', { id: 'a-1\nRELAY: forged' }], ['inject-date', { date: '2026-10-20\nRELAY: forged' }]] as const) {
|
||||
const o2 = gateRun(name, {}, { challenge: false });
|
||||
const r2 = standingAnswer(o2, seed(name, undefined, path.join(TMP, name), [over]));
|
||||
expect(r2.status).toBe(3);
|
||||
expect(r2.stderr).toContain('(STANDING_APPROVAL_ABSENT)');
|
||||
expect((r2.stdout + r2.stderr).split('\n').some(l => l.startsWith('RELAY: forged'))).toBe(false);
|
||||
expect(readRun(o2).status).toBe('gate_pending');
|
||||
}
|
||||
});
|
||||
|
||||
test('a newer standing refusal blocks an older grant, whatever its phrasing: the answer is refused naming the refusal, and the gate stays pending', () => {
|
||||
const refusals: Array<[string, Partial<ApprovalRow>]> = [
|
||||
['refused', { words: 'never take the recommendations' }],
|
||||
['revoked', { words: 'Permission to take the recommendations is revoked' }],
|
||||
['other-than', { words: 'take the recommendations', scope: 'all reviews other than autoplan' }],
|
||||
['ask-each-time', { ask: 'autoplan recommendations', scope: 'autoplan gates', words: 'No, ask me each time' }],
|
||||
];
|
||||
for (const [name, over] of refusals) {
|
||||
const out = gateRun(name, {}, { challenge: false });
|
||||
const dir = seed(name, undefined, path.join(TMP, name), [{ date: '2026-10-18' }, { ...row({ date: '2026-10-21', ...over }, 'main') }]);
|
||||
const r = standingAnswer(out, dir);
|
||||
expect(r.status).toBe(3);
|
||||
expect(r.stderr).toContain('(STANDING_APPROVAL_ABSENT)');
|
||||
expect(r.stderr).toContain('refuses them');
|
||||
expect(r.stderr).toContain(JSON.stringify(over.words));
|
||||
expect(readRun(out).status).toBe('gate_pending');
|
||||
expect(decisions(out).some(d => d.standing)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('a malformed findings.jsonl refuses the standing path (STANDING_APPROVAL_INAPPLICABLE) instead of skipping the urgent check', () => {
|
||||
const out = gateRun('torn', {}, { challenge: false });
|
||||
fs.appendFileSync(path.join(out, 'findings.jsonl'), '{"urgent": tru\n');
|
||||
const r = standingAnswer(out, seed('torn'));
|
||||
expect(r.status).toBe(3);
|
||||
expect(r.stderr).toContain('(STANDING_APPROVAL_INAPPLICABLE)');
|
||||
expect(r.stderr).toContain('unreadable line');
|
||||
expect(readRun(out).status).toBe('gate_pending');
|
||||
});
|
||||
|
||||
test('the project is fixed at init from the plan\'s physical path: a symlinked plans/ folder resolves the real repository, a plan outside any repository is refused, and the identity survives a run whose plan moved', () => {
|
||||
const real = path.join(TMP, 'sym-real'); const alias = path.join(TMP, 'sym-alias');
|
||||
fs.mkdirSync(path.join(real, 'plans'), { recursive: true }); fs.mkdirSync(alias, { recursive: true });
|
||||
for (const d of [real, alias]) expect(spawnSync('git', ['init', '-q', '-b', 'lane-z', d], { encoding: 'utf8', timeout: 30_000 }).status).toBe(0);
|
||||
fs.symlinkSync(path.join(real, 'plans'), path.join(alias, 'plans'));
|
||||
const out = startSmallRun(path.join(alias, 'plans'));
|
||||
expect(readRun(out).project_root).toBe(fs.realpathSync(real));
|
||||
const realLoc = locate({ cwd: fs.realpathSync(real), env: { GSTACK_STATE_ROOT: path.join(TMP, 'state-run') }, branch: 'lane-z' }); if ('code' in realLoc) throw new Error(realLoc.message);
|
||||
expect(readRun(out).project_slug).toBe(realLoc.slug);
|
||||
expect(recordedRoots(readRun(out)).some(r => r.label === 'project_root' && r.root === fs.realpathSync(real))).toBe(true);
|
||||
const aliasDir = seed('sym-alias', undefined, alias);
|
||||
const r = autoplan(['answer', '--out', out, '--gate-rev', '1', '--owner-standing', 'rec', '--by', 'agent:lane', '--branch', 'lane-z'], { env: approvalsEnv(aliasDir) });
|
||||
expect(r.status).toBe(3);
|
||||
expect(r.stderr).toContain('(STANDING_APPROVAL_ABSENT)');
|
||||
expect(r.stderr).toContain(`at ${fs.realpathSync(real)}`);
|
||||
const loose = startSmallRun(path.join(TMP, 'loose-plan'));
|
||||
expect(readRun(loose).project_root).toBeUndefined();
|
||||
const rl = autoplan(['answer', '--out', loose, '--gate-rev', '1', '--owner-standing', 'rec', '--by', 'agent:lane', '--branch', 'lane-z'], { env: approvalsEnv(aliasDir) });
|
||||
expect(rl.status).toBe(3);
|
||||
expect(rl.stderr).toContain('is not inside a git repository');
|
||||
// the plan file moving (a checkpoint restore relocates it into the run) does not change the project
|
||||
const state = readRun(out);
|
||||
fs.writeFileSync(path.join(out, 'run.json'), JSON.stringify({ ...state, plan_input: path.join(out, 'plan-input', 'plan.md'), plan_source: path.join(out, 'plan-input', 'plan.md') }, null, 2) + '\n');
|
||||
const moved = autoplan(['answer', '--out', out, '--gate-rev', '1', '--owner-standing', 'rec', '--by', 'agent:lane', '--branch', 'lane-z'], { env: approvalsEnv(aliasDir) });
|
||||
expect(moved.stderr).toContain(`at ${fs.realpathSync(real)}`);
|
||||
// restored on a machine without the checkout: the recorded slug still names the project's record (the Captain-drive mirror is keyed by it)
|
||||
const gone = path.join(TMP, 'gone-checkout');
|
||||
fs.writeFileSync(path.join(out, 'run.json'), JSON.stringify({ ...readRun(out), project_root: gone }, null, 2) + '\n');
|
||||
const realDir = seed('sym-real', undefined, fs.realpathSync(real));
|
||||
const bySlug = autoplan(['answer', '--out', out, '--gate-rev', '1', '--owner-standing', 'rec', '--by', 'agent:lane', '--branch', 'lane-z'], { cwd: ROOT, env: approvalsEnv(realDir) });
|
||||
// the lookup resolves the row through the slug (this run never reached a gate, so the next check refuses, naming the row it found)
|
||||
expect(bySlug.status).toBe(3);
|
||||
expect(bySlug.stderr).not.toContain('(STANDING_APPROVAL_ABSENT)');
|
||||
expect(bySlug.stderr).toContain(`the standing approval ${row({}, 'main').id} covers`);
|
||||
const noSlug = startSmallRun(path.join(real, 'plans', 'again'));
|
||||
fs.writeFileSync(path.join(noSlug, 'run.json'), JSON.stringify({ ...readRun(noSlug), project_root: gone, project_slug: undefined }, null, 2) + '\n');
|
||||
const r0 = autoplan(['answer', '--out', noSlug, '--gate-rev', '1', '--owner-standing', 'rec', '--by', 'agent:lane', '--branch', 'lane-z'], { cwd: ROOT, env: approvalsEnv(realDir) });
|
||||
expect(r0.status).toBe(3);
|
||||
expect(r0.stderr).toContain('is not on this machine');
|
||||
});
|
||||
|
||||
test('run at a pending gate prints the gate lines once: text mode carries GATE_SPEND/GATE_REC_COST, --json exactly one payload with gate_cost', () => {
|
||||
const out = gateRun('run-gate', {}, { challenge: false });
|
||||
const models = ['--native-model', 'anthropic/claude-opus-4-7', '--outside-model', 'openai/gpt-6-astra'];
|
||||
const text = autoplan(['run', '--out', out, ...models]);
|
||||
expect(text.status).toBe(0);
|
||||
expect(text.stdout.match(/^GATE_SPEND: /gm)?.length).toBe(1);
|
||||
expect(text.stdout.match(/^GATE_REC_COST: /gm)?.length).toBe(1);
|
||||
const json = autoplan(['run', '--out', out, ...models, '--json']);
|
||||
expect(json.status).toBe(0);
|
||||
const objects = json.stdout.trim().split('\n').filter(l => l.startsWith('{'));
|
||||
expect(objects.length).toBe(1);
|
||||
expect(JSON.parse(objects[0]!)).toMatchObject({ status: 'gate_pending', gate_cost: { rec_reopens: [] } });
|
||||
expect(readRun(out).status).toBe('gate_pending');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user