mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
fix: pre-landing review fixes (pass 2)
Standing approvals: the newest row about autoplan recommendations decides, so a later refusal blocks older grants; rows whose id or date is not the recorded shape are never cited; the project is fixed at init from the plan's physical path (run.json project_root) so a symlinked plans folder or a checkpoint restore cannot borrow another repository's record. Gate cost: recReopens counts every phase revise can keep or add and prices answers already recorded at the gate; run --json emits one payload; the Captain brief's ### Cost sits above the relaying trailer. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
ff2bcd8686
commit
ee2ce02e58
12 files changed
+215
-61
No files matched your search
+3
-3
@@ -5,8 +5,8 @@
|
||||
**An autoplan gate now arrives with its price, a standing "take the recommendations" answers it without a round trip, and the approvals record lands where the whole crew can read it.** Three Captains reported the same three frictions: review rounds and reviewer subagents had no credit figure at the gate, every autoplan gate round-tripped through the Captain even though the owner had said once to take the recommendations (four gates, 87 minutes, on one plan), and the approvals mirror sat in a private project-user folder no sibling thread could read. The Eng phase also gains the performance-budget question, after a correctness pass doubled a consumer's p95 with no bench step to catch it.
|
||||
|
||||
### Added
|
||||
- **`GATE_SPEND:` and `GATE_REC_COST:`** (`lib/autoplan-gate-cost.ts`; printed by every gate-producing `gstack-autoplan` command under `GATE:`, 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 recommended plan change counts every phase, since `revise` reopens each phase whose snapshot the edit changes; a recommended round grant counts its phase and Eng; 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` appends them as a `### Cost` section.
|
||||
- **`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 (its repository, never the caller's working directory; local, mirror and the other mirror roots), so a Captain records the owner's standing words once and every lane cites them. The row's words are the owner reply, its id lands as `standing` on every answered decision row, in `run.json`'s gate and in the journal, a `STANDING:` line names it, and `gstack-approvals import --autoplan` records `standing=<id>` in the source and "standing approval <id> cited by agent:<id>" as the relay. Refused with `STANDING_APPROVAL_ABSENT` when no row covers it (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`.
|
||||
- **`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; an answer already recorded at this gate revision is priced as given, since `rec` keeps 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`, 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 newest row about autoplan recommendations decides: a later refusal (`never take the recommendations`, `reject`, a scope that carves autoplan out) blocks every older grant until a later grant supersedes it, and a row whose id or date is not the recorded shape 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.
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
- **`autoplan/DRIVER.md`, `docs/capy-install.md`, `docs/troubleshooting.md`**: the loop relays the two cost lines with every gate render and names the standing path and its two refusals; the Captain section names the crew-readable mirror and the one-line `gstack-approvals add --outcome standing` a Captain records; the `APPROVAL_RECORD_UNAVAILABLE` fix names the three mirror sources. `capy/skill/gstack/SKILL.md` is untouched here (it is #3475's file) and points at `autoplan/DRIVER.md` already.
|
||||
|
||||
### Tests
|
||||
- `test/autoplan-standing.test.ts` (new): `recReopens` and the two cost lines from a ledger with an unknown host attempt (cap, no cap, over the cap, no ledger); the mirror chain (project-user, one Captain drive, two, `GSTACK_APPROVALS_DIR`, mirror off) and `gstack-approvals path`; `coversAutoplanRec`; `standingRecApproval` across branch files, local and mirror, newest first; and 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; `gate_cost` in the closing submit's JSON and the `### Cost` section of `render --captain`. 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-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, a torn `findings.jsonl` refuses, 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.
|
||||
|
||||
### 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.
|
||||
|
||||
+10
-7
@@ -33,7 +33,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { amendmentSteps, answerGate, closeDeferred, finishGate, recommendGate, standingAnswer } from '../lib/autoplan-gate';
|
||||
import { gateCost, gateCostPayload, renderGateCost, type GateCost } from '../lib/autoplan-gate-cost';
|
||||
import { locate, standingRecApproval } from '../lib/approval-record';
|
||||
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';
|
||||
@@ -292,7 +292,6 @@ async function doRun(out: string, rest: string[], json: boolean): Promise<number
|
||||
const state = readState(out);
|
||||
if (state.status !== 'awaiting_result') {
|
||||
if (!json) process.stdout.write([...statusLines(state), ...(state.status === 'gate_pending' ? gateLines(state, out) : [])].join('\n') + '\n');
|
||||
else if (state.status === 'gate_pending') process.stdout.write(JSON.stringify({ run: state.run, status: state.status, gate_rev: state.gate_rev ?? null, ...gateCostField(state, out), out }) + '\n');
|
||||
process.stdout.write(resultLineFor(state.status, out) + '\n'); return EXIT.ok;
|
||||
}
|
||||
const phase = state.phase_order.find(p => state.phases[p]?.snapshot && !state.phases[p]?.closed_at)!;
|
||||
@@ -371,17 +370,21 @@ async function main(argv: string[]): Promise<number> {
|
||||
const ownerReply = replyFile !== undefined ? fs.readFileSync(replyFile, 'utf8') : ownerFlag === '-' ? fs.readFileSync(0, 'utf8') : ownerFlag;
|
||||
return mutating(out, json, () => {
|
||||
const state = readState(out);
|
||||
let standing: ReturnType<typeof standingRecApproval>;
|
||||
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 plan under review names the repository.
|
||||
// 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 = planFile ? repoRoot(path.dirname(planFile)) : undefined;
|
||||
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);
|
||||
const loc = locate({ cwd: runRepo, env: process.env, branch: flag(rest, '--branch') });
|
||||
if ('code' in loc) throw new RunError(loc.code, loc.message, 3);
|
||||
standing = standingRecApproval(loc);
|
||||
if (!standing) throw new RunError('STANDING_APPROVAL_ABSENT', `no standing approval covering autoplan recommendations in ${[loc.file, loc.mirror, ...(loc.read_also ?? [])].flatMap(f => f ? [path.dirname(f)] : []).join(' or ')} (project ${loc.slug} at ${runRepo}, branch ${loc.branch})`, 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 });
|
||||
writeState(state);
|
||||
|
||||
@@ -23,7 +23,6 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
|
||||
import { renderCaptainBrief } from '../lib/captain-brief';
|
||||
import { gateCost, gateCostSentence } from '../lib/autoplan-gate-cost';
|
||||
import { BRIEF_FILE, closeDraftDirectionsFile, loadBriefInputs, renderBrief } from '../lib/owner-brief';
|
||||
import { resultLine } from '../lib/result-codes';
|
||||
|
||||
@@ -51,8 +50,6 @@ function main(argv: string[]): number {
|
||||
|
||||
if (cmd === 'render' && rest.includes('--captain')) {
|
||||
const brief = renderCaptainBrief(loaded.inputs, { outDir: path.resolve(out) });
|
||||
const run = loaded.inputs.run;
|
||||
if (run) brief.text = brief.text.trimEnd() + `\n\n### Cost\n\n${gateCostSentence(gateCost(path.resolve(out), loaded.inputs.gate, { phase_order: run.phase_order ?? [], outside_enabled: run.outside_enabled !== false, spend_cap_usd: run.spend_cap_usd ?? null }))}\n`;
|
||||
if (rest.includes('--write')) fs.writeFileSync(path.join(path.resolve(out), 'captain-brief.md'), brief.text);
|
||||
if (json) process.stdout.write(JSON.stringify({ run: loaded.inputs.gate.run, gate_rev: loaded.inputs.gate.gate_rev, rec_hash: brief.recHash, pending: brief.pending, missing: brief.missing, brief: brief.text }) + '\n');
|
||||
else process.stdout.write(brief.text);
|
||||
|
||||
@@ -1713,8 +1713,12 @@ import again. Imports are idempotent, so a retry never records an answer twice.
|
||||
**Meaning.** `answer --owner-standing rec` looked for a standing approval covering autoplan
|
||||
recommendations (a `gstack-approvals` row with outcome `standing`, scope or ask naming
|
||||
`autoplan`, words or scope naming `rec` or the recommendations) in this project's records,
|
||||
local and mirror, every branch, and found none. The gate stays pending; nothing was applied.
|
||||
Exit 3.
|
||||
local and mirror, every branch, and found none that applies: either no such row exists, the
|
||||
newest row about autoplan recommendations refuses them (`never take the recommendations`, a
|
||||
scope that carves autoplan out; the line quotes it, and a later grant supersedes it), or the
|
||||
run's plan is not inside a git repository, so no project record can be named (the project is
|
||||
the repository the plan's physical path was in at init). 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
|
||||
|
||||
+24
-9
@@ -116,17 +116,30 @@ export function captainApprovalsDir(env: Env): string | undefined {
|
||||
* standing, the ask or scope naming autoplan, and the owner's own words naming the recommendations
|
||||
* without a negation (`never take the recommendations` is a standing refusal, not an approval).
|
||||
*/
|
||||
export const coversAutoplanRec = (r: ApprovalRow): boolean =>
|
||||
r.outcome === 'standing' && /\bautoplan\b/i.test(`${r.ask} ${r.scope}`)
|
||||
&& /\b(rec|recommendations?|recommended)\b/i.test(r.words) && !/\b(never|not|no|don'?t|do not|without|except)\b/i.test(r.words);
|
||||
const NEGATION_RE = /\b(never|not|no|don[’']?t|do not|without|except|excluding|unless|reject|refuse|decline|stop|revoke|withdraw)\b/i;
|
||||
/** A standing row that names autoplan and the recommendations at all: a grant or a refusal, so the newest one decides. */
|
||||
export const mentionsAutoplanRec = (r: ApprovalRow): boolean =>
|
||||
r.outcome === 'standing' && /\bautoplan\b/i.test(`${r.ask} ${r.scope}`) && /\b(rec|recommendations?|recommended)\b/i.test(r.words);
|
||||
/** The words withhold or the scope carves autoplan out (`all reviews except autoplan`). */
|
||||
export const refusesAutoplanRec = (r: ApprovalRow): boolean =>
|
||||
NEGATION_RE.test(r.words) || /\b(except|excluding|not|never|unless|but not)\b[^.;]*\bautoplan\b/i.test(r.scope);
|
||||
export const coversAutoplanRec = (r: ApprovalRow): boolean => mentionsAutoplanRec(r) && !refusesAutoplanRec(r);
|
||||
|
||||
/** A row an agent may cite: the generated id shape, a calendar date and plain string fields, so no stored row can forge an output line. */
|
||||
const STANDING_ID_RE = /^[A-Za-z0-9._-]{1,64}$/;
|
||||
export const citableRow = (r: ApprovalRow): boolean =>
|
||||
STANDING_ID_RE.test(r.id) && DATE_RE.test(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 approval covering autoplan recommendations, 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 standing instruction about autoplan recommendations, 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 row decides: a later refusal (`never take the recommendations`)
|
||||
* blocks every older grant instead of being filtered away, and a row that fails `citableRow` is
|
||||
* never cited.
|
||||
*/
|
||||
export function standingRecApproval(loc: StoreLocation): ApprovalRow | undefined {
|
||||
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[] = [];
|
||||
@@ -134,7 +147,9 @@ export function standingRecApproval(loc: StoreLocation): ApprovalRow | undefined
|
||||
for (const n of names) rows.push(...readRows(path.join(dir, n)));
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
return rows.filter(r => coversAutoplanRec(r) && !seen.has(r.id) && seen.add(r.id)).sort((a, b) => b.date.localeCompare(a.date) || b.recorded_at.localeCompare(a.recorded_at))[0];
|
||||
const newest = rows.filter(r => mentionsAutoplanRec(r) && citableRow(r) && !seen.has(r.id) && seen.add(r.id)).sort((a, b) => b.date.localeCompare(a.date) || b.recorded_at.localeCompare(a.recorded_at))[0];
|
||||
if (!newest) return {};
|
||||
return refusesAutoplanRec(newest) ? { refusal: newest } : { grant: newest };
|
||||
}
|
||||
|
||||
function readRows(file: string | undefined): ApprovalRow[] {
|
||||
|
||||
+26
-13
@@ -7,10 +7,12 @@
|
||||
* `unknown` to it (kept at its reservation in worst_case), so the line says so
|
||||
* and points at the host's own cost view instead of inventing a credit figure.
|
||||
* The reopen estimate is an upper bound (`recReopens`): two voices per reopened
|
||||
* phase when the outside voice is on, at the per-voice estimate the run has
|
||||
* been reserving.
|
||||
* or newly added phase when the outside voice is on, at the per-voice estimate
|
||||
* the run has been reserving.
|
||||
*/
|
||||
import * as path from 'node:path';
|
||||
import { PHASES } from './autoplan-prompts';
|
||||
import { readDecisions } from './autoplan-state';
|
||||
import type { GateList } from './gate-list';
|
||||
import { readLedger, summarize, type SpendSummary } from './spend-ledger';
|
||||
|
||||
@@ -26,26 +28,37 @@ export interface GateCost {
|
||||
rec_more_usd: number;
|
||||
}
|
||||
|
||||
export interface GateCostRun { phase_order: string[]; outside_enabled: boolean; spend_cap_usd: number | null }
|
||||
export interface GateCostRun { phase_order: string[]; outside_enabled: boolean; spend_cap_usd: number | null; scope_flags?: Record<string, boolean | undefined> }
|
||||
|
||||
const usd = (n: number) => n.toFixed(2);
|
||||
|
||||
/**
|
||||
* The phases a `rec` can reopen, as an upper bound from the answer and revise rules: a recommended
|
||||
* plan change is absorbed by `revise`, which reopens every phase whose snapshot the edited plan
|
||||
* changes (any of them, so all of them are counted); a recommended round grant (`cap` item, option
|
||||
* `b`) reopens its phase and Eng. Phases already at their cap are not reopened by revise, but the
|
||||
* bound does not subtract them: the gate lists their delta as its own item instead.
|
||||
* The phases a `rec` can reopen, as an upper bound from the answer and revise rules. The effective
|
||||
* choice per item is what `rec` would record: an answer already given at this gate revision
|
||||
* (`prior`, as answerGate reads it from decisions.jsonl) wins over the recommendation. A plan
|
||||
* change is absorbed by `revise`, which reopens every phase whose snapshot the edited plan changes
|
||||
* and re-detects scope, so it can add a phase the run skipped: every phase the scope flags do not
|
||||
* pin off is counted (`--no-ui` keeps Design out, `--no-dx` keeps DX out). A round grant (`cap`
|
||||
* item, option `b`) reopens its phase and Eng. Phases already at their cap are not reopened by
|
||||
* revise, but the bound does not subtract them: the gate lists their delta as its own item instead.
|
||||
*/
|
||||
export function recReopens(list: GateList, phaseOrder: string[]): string[] {
|
||||
export function recReopens(list: GateList, run: Pick<GateCostRun, 'phase_order' | 'scope_flags'>, prior: Record<string, string> = {}): string[] {
|
||||
const flags = run.scope_flags ?? {};
|
||||
const bound = PHASES.filter(p => run.phase_order.includes(p) || (p === 'design' ? flags.ui !== false : p === 'dx' ? flags.dx !== false : true));
|
||||
const named = new Set<string>();
|
||||
for (const item of list.items) {
|
||||
if (item.id === 'p1' || item.carried) continue;
|
||||
if (item.cap && item.recommended === 'b') { named.add(item.cap.phase); if (phaseOrder.includes('eng')) named.add('eng'); continue; }
|
||||
const choice = prior[item.id] ?? item.recommended;
|
||||
if (item.cap && choice === 'b') { named.add(item.cap.phase); if (run.phase_order.includes('eng')) named.add('eng'); continue; }
|
||||
if (item.reopens === false) continue;
|
||||
if (item.options.find(o => o.key === item.recommended)?.changes_plan) for (const p of phaseOrder) named.add(p);
|
||||
if (item.options.find(o => o.key === choice)?.changes_plan) for (const p of bound) named.add(p);
|
||||
}
|
||||
return phaseOrder.filter(p => named.has(p));
|
||||
return PHASES.filter(p => named.has(p));
|
||||
}
|
||||
|
||||
/** The answers already recorded at this gate revision, keyed as parseReply's `prior` is (item id → option). */
|
||||
export function priorChoices(outDir: string, gateRev: number): Record<string, string> {
|
||||
return Object.fromEntries(readDecisions(outDir).filter(r => r.gate_rev === gateRev && r.chosen != null && r.status !== 'pending' && r.label).map(r => [r.label!.toLowerCase(), r.chosen!]));
|
||||
}
|
||||
|
||||
export function gateCost(outDir: string, list: GateList, run: GateCostRun, o: { defaultPerVoiceUsd?: number } = {}): GateCost {
|
||||
@@ -53,7 +66,7 @@ export function gateCost(outDir: string, list: GateList, run: GateCostRun, o: {
|
||||
const summary = summarize(ledger);
|
||||
const seen = [...ledger.settled.map(s => s.reserved_usd), ...ledger.reserved.map(r => r.estimate_usd)].filter(n => n > 0);
|
||||
const per_voice_usd = seen.length ? seen[seen.length - 1]! : o.defaultPerVoiceUsd ?? 0;
|
||||
const reopens = recReopens(list, run.phase_order);
|
||||
const reopens = recReopens(list, run, priorChoices(outDir, list.gate_rev));
|
||||
const voices = reopens.length * (run.outside_enabled ? 2 : 1);
|
||||
return { summary, attempts: ledger.settled.length + ledger.reserved.length, reopens, voices, per_voice_usd, rec_more_usd: Math.round(voices * per_voice_usd * 100) / 100 };
|
||||
}
|
||||
|
||||
@@ -348,7 +348,7 @@ export function standingAnswer(state: RunState, gateRev: number, answeredBy: str
|
||||
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: ${standing.id} (${standing.date}, ${flat(standing.by)}; ${flat(standing.source)}) ${JSON.stringify(flat(standing.words))} — scope: ${flat(standing.scope)}`);
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -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, reviewHash, scaffold } from './autoplan-plan-text';
|
||||
import { driftLine, normalizeFindingFile, repoRoot, 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';
|
||||
@@ -79,6 +79,8 @@ 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);
|
||||
@@ -119,7 +121,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 ?? {}) },
|
||||
gstack_version: GSTACK_VERSION, plan_input: input, scope_flags: { ...(o.scope ?? {}) }, ...(projectRoot ? { project_root: projectRoot } : {}),
|
||||
...(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,6 +59,8 @@ 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), kept across checkpoint restores so the run's project never follows a relocated copy. */
|
||||
project_root?: 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. */
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { gateCost, gateCostSentence } from './autoplan-gate-cost';
|
||||
import { recHash, type GateItem } from './gate-list';
|
||||
import { renderUrgentBlock } from './headless-artifacts';
|
||||
import { findSection, firstTitle } from './markdown-sections';
|
||||
@@ -104,7 +105,9 @@ export function renderCaptainBrief(inputs: BriefInputs, o: { outDir: string }):
|
||||
const tokens = list.items.map(i => `${i.id}${i.recommended}`).join(' ');
|
||||
const trailer = `_For the relaying agent, not the person:_ run ${list.run}, gate_rev=${list.gate_rev}, rec_hash=${hash}; rec resolves to ${tokens}; plan ${plan.file ?? '(missing)'} (sha256 ${plan.sha}). Answer with ` +
|
||||
`\`gstack-autoplan answer --out ${o.outDir} --gate-rev ${list.gate_rev} --reply rec --rec-hash ${hash} --by agent:<id> --owner-reply "<their exact words>"\`, then \`gstack-approvals import --autoplan ${o.outDir} --by <name>\`.`;
|
||||
const text = [...urgent, ...header, '', ...body, ...need, '---', '', trailer, ''].join('\n');
|
||||
// The price sits above the trailer: the relaying agent sends everything above that line, so the person approving sees it.
|
||||
const cost = inputs.run ? ['### Cost', '', gateCostSentence(gateCost(o.outDir, list, { phase_order: inputs.run.phase_order ?? [], outside_enabled: inputs.run.outside_enabled !== false, spend_cap_usd: inputs.run.spend_cap_usd ?? null, scope_flags: inputs.run.scope_flags })), ''] : [];
|
||||
const text = [...urgent, ...header, '', ...body, ...need, ...cost, '---', '', trailer, ''].join('\n');
|
||||
return { text, missing, pending, recHash: hash };
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -86,7 +86,7 @@ export function renderBrief(inputs: BriefInputs, o: { page?: number; summary?: s
|
||||
'### Summary',
|
||||
'',
|
||||
plainSummary(inputs, o.summary),
|
||||
...(o.outDir && inputs.run ? ['', gateCostSentence(gateCost(o.outDir, inputs.gate, { phase_order: inputs.run.phase_order ?? [], outside_enabled: inputs.run.outside_enabled !== false, spend_cap_usd: inputs.run.spend_cap_usd ?? null }))] : []),
|
||||
...(o.outDir && inputs.run ? ['', gateCostSentence(gateCost(o.outDir, inputs.gate, { phase_order: inputs.run.phase_order ?? [], outside_enabled: inputs.run.outside_enabled !== false, spend_cap_usd: inputs.run.spend_cap_usd ?? null, scope_flags: inputs.run.scope_flags }))] : []),
|
||||
'',
|
||||
'### Decisions',
|
||||
'',
|
||||
|
||||
+134
-19
@@ -18,8 +18,8 @@ import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { buildRow, captainApprovalsDir, coversAutoplanRec, locate, readRecord, standingRecApproval, type ApprovalRow } from '../lib/approval-record';
|
||||
import { gateCost, recReopens, renderGateCost, gateCostSentence } from '../lib/autoplan-gate-cost';
|
||||
import { buildRow, captainApprovalsDir, citableRow, coversAutoplanRec, locate, 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 { attemptsOf, autoplan, env, readRun, resultOf, review, ROOT, startSmallRun } from './helpers/autoplan-run-fixture';
|
||||
|
||||
@@ -43,32 +43,53 @@ const row = (over: Partial<Parameters<typeof buildRow>[0]> = {}, branch = 'main'
|
||||
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, a recommended round grant its phase plus Eng, nothing otherwise', () => {
|
||||
expect(recReopens(list('b'), ['ceo', 'design', 'eng'])).toEqual(['ceo', 'design', 'eng']);
|
||||
expect(recReopens(list('a'), ['ceo', 'design', 'eng'])).toEqual([]);
|
||||
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, ['ceo', 'eng'])).toEqual([]);
|
||||
expect(recReopens(carried, { phase_order: ['ceo', 'eng'], scope_flags: pinned })).toEqual([]);
|
||||
const agent = list('b'); agent.items[0]!.reopens = false;
|
||||
expect(recReopens(agent, ['ceo', 'eng'])).toEqual([]);
|
||||
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, ['ceo', 'design', 'dx', 'eng'])).toEqual(['design', 'eng']);
|
||||
expect(recReopens(grant, { phase_order: ['ceo', 'design', 'dx', 'eng'] })).toEqual(['design', 'eng']);
|
||||
grant.items[1]!.recommended = 'a';
|
||||
expect(recReopens(grant, ['ceo', 'design', 'dx', 'eng'])).toEqual([]);
|
||||
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 });
|
||||
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 }));
|
||||
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);
|
||||
@@ -76,7 +97,7 @@ describe('E16: the gate prints what it cost and what rec adds', () => {
|
||||
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: 20, reopens: ['ceo', 'eng'] });
|
||||
expect(empty).toMatchObject({ attempts: 0, per_voice_usd: 5, rec_more_usd: 40, reopens: ['ceo', 'design', 'dx', 'eng'] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -120,7 +141,7 @@ describe('E22: the mirror honours the Captain-named approvals directory', () =>
|
||||
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)?.id).toBe(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}`);
|
||||
@@ -136,21 +157,42 @@ describe('E21: a standing approval answers rec without a round trip', () => {
|
||||
expect(coversAutoplanRec(row({ words: 'rec', scope: 'autoplan' }))).toBe(true);
|
||||
expect(coversAutoplanRec(row({ words: 'never take the recommendations for autoplan' }))).toBe(false);
|
||||
expect(coversAutoplanRec(row({ words: 'do not assume rec' }))).toBe(false);
|
||||
expect(coversAutoplanRec(row({ words: 'Don’t take the recommendations' }))).toBe(false);
|
||||
expect(coversAutoplanRec(row({ words: 'Reject the recommendations' }))).toBe(false);
|
||||
expect(coversAutoplanRec(row({ words: 'take the recommendations', scope: 'all reviews except autoplan' }))).toBe(false);
|
||||
expect(coversAutoplanRec(row({ words: 'take the recommendations', scope: 'every autoplan gate, not merges' }))).toBe(true);
|
||||
});
|
||||
|
||||
test('standingRecApproval reads every branch\'s record, local and mirror, newest standing row first', () => {
|
||||
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(), 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)).toBeUndefined();
|
||||
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 });
|
||||
fs.writeFileSync(path.join(path.dirname(lane.file), 'captain-branch-def.jsonl'), [newer, other].map(r => JSON.stringify(r)).join('\n') + '\n');
|
||||
expect(standingRecApproval(lane)?.id).toBe(newer.id);
|
||||
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 });
|
||||
});
|
||||
|
||||
/** Both voices challenge the same item: the gate recommends the plan change (b) and `rec` would reopen phases. */
|
||||
@@ -238,11 +280,11 @@ describe('E21: a standing approval answers rec without a round trip', () => {
|
||||
});
|
||||
|
||||
/** 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)): string {
|
||||
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'), JSON.stringify(words ? { ...row({}, 'main'), words } : row({}, 'main')) + '\n');
|
||||
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) });
|
||||
@@ -252,6 +294,9 @@ describe('E21: a standing approval answers rec without a round trip', () => {
|
||||
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');
|
||||
@@ -292,5 +337,75 @@ describe('E21: a standing approval answers rec without a round trip', () => {
|
||||
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: the answer is refused naming the refusal, and the gate stays pending', () => {
|
||||
const out = gateRun('refused', {}, { challenge: false });
|
||||
const dir = seed('refused', undefined, path.join(TMP, 'refused'), [{ date: '2026-10-18' }, { ...row({ date: '2026-10-21', words: 'never take the recommendations' }, '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('"never take the recommendations"');
|
||||
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 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)}`);
|
||||
});
|
||||
|
||||
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