Files
gstack/lib/owner-brief.ts
Garry Tanandcapy-ai[bot] 295b32e2c4 v1.91.269.0 feat(autoplan): gates print their cost, approvals mirror to the Captain's drive, Eng asks for a perf budget (GSTA-114 lane F, PR 3) (#3496)
* feat(autoplan): gates print their cost, --owner-standing rec cites a standing approval, approvals mirror to the Captain's drive

E16: GATE_SPEND and GATE_REC_COST under every GATE line (lib/autoplan-gate-cost.ts), and a
Cost so far sentence in the owner brief; host-subagent attempts stay priced unknown.
E21: gstack-autoplan answer --owner-standing rec --by agent:<id> answers rec by citing the
newest standing approval covering autoplan recommendations from any branch's record; the
id lands on every decision row, in run.json and in the approvals import; refused with
STANDING_APPROVAL_ABSENT or STANDING_APPROVAL_INAPPLICABLE (urgent finding).
E22: the approvals mirror honours the approvals/ directory a Captain created in its drive,
between GSTACK_APPROVALS_DIR and the private project-user folder; gstack-approvals path
names the rule.
E23: the Eng phase asks for a performance budget and a bench step on hot-path changes.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(autoplan): review pass 1 — standing answers bind to the run's project and refuse recommend-edited gates, cost is an upper bound and reaches JSON and the Captain brief

Review pass 1 (6 specialists + red team) on the standing-approval and gate-cost change:
- the standing lookup resolves the approvals record of the repository the run's plan lives in,
  never the caller's cwd (red team: project A's row answered project B's gate)
- standingAnswer refuses a gate that `recommend` edited (rec_rev > 0 or recommended_by), a
  missing or unreadable findings.jsonl, and an outside-plan finding; the STANDING line flattens
  control characters so a crew-writable row cannot forge a protocol line
- coversAutoplanRec matches the recommendation words in the owner's words only and rejects
  negations; STANDING_APPROVAL_ABSENT's fix names every required gstack-approvals flag
- recReopens is the upper bound of the answer/revise rules (a plan change counts every phase,
  a round grant its phase plus Eng); gate_cost rides the JSON payloads, `run` prints the lines
  at its final gate, and `gstack-owner-brief render --captain` appends a ### Cost section
- locate keeps the other mirror roots as read_also so rows mirrored under an earlier rule stay
  visible; gstack-approvals path prints READ_ALSO
- the Eng phase edit is size-neutral: five nearby phrases shortened so every host's autoplan
  SKILL.md stays at its size-guard pin
- tests: isolated fixtures, the run's plan inside its own git repo, cross-project refusal,
  amend_pending vs complete, recommend-then-standing, injection, gate_cost JSON, Captain brief

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* 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>

* fix: pre-landing review fixes (pass 4, rule-23 exception)

Standing grammar fails closed: the newest standing row mentioning autoplan decides and grants only
with the canonical scope and affirmative owner words; same-id copies keep the newest recorded; id and
date are type-checked before the regexes. Gate cost prices the effective choices exactly as answer
does (p1d, carried overrides, non-recommended options). The run records project_slug beside
project_root so a restored run names its record on another machine, and project_root is a recorded
root for export --public.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* 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>

* RESTAMP: 1.91.268.0 -> 1.91.269.0 (stamped; queue_mode=stamp-at-merge base=origin/main@76aa68fc9e7a policy=base origin/main@76aa68fc9e7a:.gstack/ship-policy.json)
ship, next in queue at 1.91.269.0
STAMP_VERSION: 1.91.269.0
claims: ignored (stamp-at-merge)
stage version: done (wrote 1.91.269.0)
stage release_tool: done (bun scripts/gen-agents-digest.ts)
stage changelog: done (Unreleased -> 1.91.269.0)
stage stamp_paths: skipped (no stamp_paths in policy)
stage mirror: skipped (no mirror in policy)
stage apply: done (4 file(s))
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e4e28308c..566bb525c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,6 @@
 # Changelog

-## [Unreleased]
+## [1.91.269.0] - 2026-10-27

 **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.)

diff --git a/VERSION b/VERSION
index c31f84688..6437fa3e4 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.91.268.0
+1.91.269.0
diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md
index 3548eb690..4bdc2879f 100644
--- a/agents-digest/gstack-AGENTS.md
+++ b/agents-digest/gstack-AGENTS.md
@@ -1,4 +1,4 @@
-# gstack digest v1.91.268.0 — regenerate/re-copy after upgrading gstack
+# gstack digest v1.91.269.0 — regenerate/re-copy after upgrading gstack

 Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
 for agent hosts without a full skill install. The full skills add workflows,
diff --git a/node_modules b/node_modules
new file mode 120000
index 000000000..1de79460f
--- /dev/null
+++ b/node_modules
@@ -0,0 +1 @@
+/workspace/gstack-ap/node_modules
\ No newline at end of file
diff --git a/package.json b/package.json
index 7a672beb5..26a9702da 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "gstack",
-  "version": "1.91.268",
+  "version": "1.91.269",
   "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
   "license": "MIT",
   "type": "module",
FILES: CHANGELOG.md VERSION agents-digest/gstack-AGENTS.md package.json
MERGE_CONDITION: sha=624f0eedec7c3aebdd0cecd70be4e4ebd10eb4af
next: NEW_VERSION=1.91.269.0; commit and push only the stamp diff above (FILES), with no edit of your own: the train's verify accepts the CHANGELOG only as the base's plus this PR's entry re-headed; merge with the expected head 624f0eedec and recompute on a stale queue.
GSTACK_RESULT: skill=restamp status=complete run=/workspace/gstack-ap/.gstack/tmp/restamp-journal.json

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-27 06:54:56 +00:00

146 lines
9.3 KiB
TypeScript

/**
* owner-brief — what the owner sees at the autoplan gate (plan E3). One page:
* a plain-language summary, the `## URGENT, outside this plan` block (B12),
* and the numbered decisions rendered through the gate list (B9): seven per
* page with stable ids, `auto` items showing the default taken if unanswered,
* `approval` items pending until answered. The full review stays in
* `review-record.md`; the brief points at it. `closeDraftDirections` is the
* phase-close operation that rewrites every `draft direction stands until the
* owner decides` line in the plan to the decided option and date once the
* gate is answered; a line whose decision is still pending is left as is and
* reported (DRAFT_DIRECTION_UNRESOLVED).
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { gateCost, gateCostSentence } from './autoplan-gate-cost';
import { decisionId, PAGE_SIZE, renderGate, type GateList } from './gate-list';
import { readJsonl, renderUrgentBlock, type DecisionRow } from './headless-artifacts';
import type { ResultCodeName } from './result-codes';
export const DRAFT_DIRECTION_PHRASE = 'draft direction stands until the owner decides';
export const BRIEF_FILE = 'brief.md';
export interface BriefInputs { gate: GateList; decisions: DecisionRow[]; findings: Record<string, unknown>[]; run?: Record<string, any>; timing?: Record<string, any> }
export interface BriefError { code: ResultCodeName; message: string }
export function loadBriefInputs(outDir: string): { inputs: BriefInputs } | { error: BriefError } {
const need = (name: string) => path.join(outDir, name);
for (const name of ['gate.json', 'decisions.jsonl', 'findings.jsonl']) if (!fs.existsSync(need(name))) return { error: { code: 'BRIEF_INPUT_MISSING', message: `${name} not in ${outDir}` } };
let gate: GateList;
try { gate = JSON.parse(fs.readFileSync(need('gate.json'), 'utf8')) as GateList; } catch (e: any) { return { error: { code: 'ARTIFACT_INVALID_JSON', message: `gate.json: ${e.message}` } }; }
const decisions = readJsonl(need('decisions.jsonl'));
if (decisions.errors.length) return { error: { code: 'ARTIFACT_MALFORMED_JSONL', message: decisions.errors.map(e => e.message).join('; ') } };
const findings = readJsonl(need('findings.jsonl'));
if (findings.errors.length) return { error: { code: 'ARTIFACT_MALFORMED_JSONL', message: findings.errors.map(e => e.message).join('; ') } };
const optional = (name: string) => { try { return fs.existsSync(need(name)) ? JSON.parse(fs.readFileSync(need(name), 'utf8')) : undefined; } catch { return undefined; } };
return { inputs: { gate, decisions: decisions.rows as unknown as DecisionRow[], findings: findings.rows, run: optional('run.json'), timing: optional('timing.json') } };
}
/** The decision row for a gate item, at the gate's revision (the label is the item id in upper case). */
function rowFor(inputs: BriefInputs, id: string): DecisionRow | undefined {
return inputs.decisions.filter(d => d.gate_rev === inputs.gate.gate_rev).find(d => (d.label ?? '').toLowerCase() === id || d.id === decisionId(inputs.gate.run, inputs.gate.gate_rev, id));
}
/** Plain words, counted from the artifacts; `summary` (a file the run or the parent wrote) replaces the generated sentence. */
export function plainSummary(inputs: BriefInputs, summary?: string): string {
if (summary?.trim()) return summary.trim().replace(/^#{1,6}[^\n]*\n+/, '').trim() || summary.trim();
const phases = inputs.run?.required_phases ?? Object.keys(inputs.run?.phases ?? {});
const closed = phases.filter((p: string) => inputs.run?.phases?.[p]?.closed_at);
const f = inputs.findings;
const accepted = f.filter(r => ['accepted', 'partially_accepted', 'fixed'].includes(String(r.resolution ?? r.disposition ?? ''))).length;
const auto = inputs.gate.items.filter(i => i.kind === 'auto').length;
const approval = inputs.gate.items.filter(i => i.kind === 'approval');
const pending = approval.filter(i => (rowFor(inputs, i.id)?.status ?? 'pending') === 'pending').length;
const cycle = inputs.timing?.total_s ?? inputs.timing?.cycle_s;
return [
`${closed.length ? `${closed.length} review phase${closed.length === 1 ? '' : 's'} closed (${closed.join(', ')})` : `${phases.length} review phases`}; ${f.length} finding${f.length === 1 ? '' : 's'}${f.length ? ` (${accepted} accepted into the plan)` : ''}.`,
`${inputs.gate.items.length} decision${inputs.gate.items.length === 1 ? '' : 's'} below: ${auto} take their default if you do not answer; ${approval.length} need your answer (${pending} still pending).`,
cycle ? `Whole cycle: ${Math.round(Number(cycle) / 60)} min.` : '',
].filter(Boolean).join(' ');
}
/** The gate list through B9's renderer, each item line annotated with its status from decisions.jsonl. */
export function renderDecisions(inputs: BriefInputs, page = 1): string {
const rendered = renderGate(inputs.gate, page, PAGE_SIZE);
return rendered.split('\n').map(line => {
const m = /^(\d+)\. \[([a-z]*[0-9]+)\] /.exec(line);
if (!m) return line;
const item = inputs.gate.items.find(i => i.id === m[2]);
const row = rowFor(inputs, m[2]!);
if (!item) return line;
if (row && row.status !== 'pending' && row.chosen) return `${line} → decided ${item.id}${row.chosen}${row.answered_at ? ` on ${String(row.answered_at).slice(0, 10)}` : ''}`;
return item.kind === 'auto' ? `${line} → default ${item.id}${item.recommended} taken if unanswered` : `${line} → pending until you answer`;
}).join('\n');
}
export function renderBrief(inputs: BriefInputs, o: { page?: number; summary?: string; outDir?: string } = {}): string {
const pages = Math.max(1, Math.ceil(inputs.gate.items.length / PAGE_SIZE));
const page = Math.min(Math.max(1, o.page ?? 1), pages);
const record = o.outDir ? path.join(o.outDir, 'review-record.md') : 'review-record.md';
const recordNote = o.outDir && fs.existsSync(record) ? ` (${fs.statSync(record).size} bytes)` : '';
return [
`# /autoplan owner brief — run ${inputs.gate.run} (gate_rev ${inputs.gate.gate_rev}, page ${page}/${pages})`,
'',
renderUrgentBlock(inputs.findings).trimEnd(),
'',
'### 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, scope_flags: inputs.run.scope_flags }))] : []),
'',
'### Decisions',
'',
renderDecisions(inputs, page).trimEnd(),
'',
`Full review: ${record}${recordNote}. Answer with \`gstack-autoplan answer --out <dir> --gate-rev ${inputs.gate.gate_rev} --reply "<rec | rec except d3b | d3b uc1a | as-is>" --by human\`.`,
'',
].join('\n');
}
// ---------------------------------------------------------------------------
// phase close: rewrite the draft-direction lines
// ---------------------------------------------------------------------------
export interface DraftRewrite { line: number; id: string | null; status: 'rewritten' | 'pending' | 'unknown_id'; text: string }
export interface DraftCloseResult { text: string; rewrites: DraftRewrite[]; unresolved: DraftRewrite[] }
function decisionIdIn(line: string, ids: Set<string>): string | null {
for (const m of line.matchAll(/\b([a-z]{0,3}[0-9]{1,3})\b/gi)) { const id = m[1]!.toLowerCase(); if (ids.has(id)) return id; }
return null;
}
/**
* Every line containing the phrase is rewritten to `decided: <id><option> — <option text> (<date>)`
* when its decision (an id on the line, else the plan approval `p1`) is answered; pending lines stay.
*/
export function closeDraftDirections(planText: string, inputs: BriefInputs, date: string): DraftCloseResult {
const ids = new Set(inputs.gate.items.map(i => i.id));
const rewrites: DraftRewrite[] = [];
const lines = planText.split('\n').map((line, i) => {
if (!line.toLowerCase().includes(DRAFT_DIRECTION_PHRASE)) return line;
const id = decisionIdIn(line, ids) ?? (ids.has('p1') ? 'p1' : null);
if (!id) { rewrites.push({ line: i + 1, id: null, status: 'unknown_id', text: line }); return line; }
const item = inputs.gate.items.find(x => x.id === id)!;
const row = rowFor(inputs, id);
if (!row || row.status === 'pending' || !row.chosen) { rewrites.push({ line: i + 1, id, status: 'pending', text: line }); return line; }
const option = item.options.find(o => o.key === row.chosen);
const replacement = `decided ${id}${row.chosen} (${date}): ${option?.text ?? row.chosen}`;
const re = new RegExp(DRAFT_DIRECTION_PHRASE.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
const out = line.replace(re, replacement);
rewrites.push({ line: i + 1, id, status: 'rewritten', text: out });
return out;
});
return { text: lines.join('\n'), rewrites, unresolved: rewrites.filter(r => r.status !== 'rewritten') };
}
/** Rewrite the plan file in place; returns what changed. */
export function closeDraftDirectionsFile(planFile: string, inputs: BriefInputs, date = new Date().toISOString().slice(0, 10)): DraftCloseResult {
const text = fs.readFileSync(planFile, 'utf8');
const result = closeDraftDirections(text, inputs, date);
if (result.text !== text) {
const tmp = `${planFile}.${process.pid}.tmp`;
fs.writeFileSync(tmp, result.text);
fs.renameSync(tmp, planFile);
}
return result;
}