mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* feat(autoplan): gates print their cost, --owner-standing rec cites a standing approval, approvals mirror to the Captain's drive
E16: GATE_SPEND and GATE_REC_COST under every GATE line (lib/autoplan-gate-cost.ts), and a
Cost so far sentence in the owner brief; host-subagent attempts stay priced unknown.
E21: gstack-autoplan answer --owner-standing rec --by agent:<id> answers rec by citing the
newest standing approval covering autoplan recommendations from any branch's record; the
id lands on every decision row, in run.json and in the approvals import; refused with
STANDING_APPROVAL_ABSENT or STANDING_APPROVAL_INAPPLICABLE (urgent finding).
E22: the approvals mirror honours the approvals/ directory a Captain created in its drive,
between GSTACK_APPROVALS_DIR and the private project-user folder; gstack-approvals path
names the rule.
E23: the Eng phase asks for a performance budget and a bench step on hot-path changes.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* 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>
111 lines
7.3 KiB
TypeScript
111 lines
7.3 KiB
TypeScript
/**
|
||
* autoplan-gate-cost — what a gate costs so far and what taking every
|
||
* recommendation would add (plan E16). Every gate-producing command prints the
|
||
* two lines, and the owner brief carries the same figures, so an approval is
|
||
* never asked without its price. gstack prices what it reserved and settled in
|
||
* `<out>/spend.json` (lib/spend-ledger.ts); a host subagent's actual spend is
|
||
* `unknown` to it (kept at its reservation in worst_case), so the line says so
|
||
* and points at the host's own cost view instead of inventing a credit figure.
|
||
* The reopen estimate is an upper bound (`recReopens`): two voices per reopened
|
||
* 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';
|
||
|
||
export interface GateCost {
|
||
summary: SpendSummary;
|
||
/** Attempts the ledger has seen: settled plus still reserved. */
|
||
attempts: number;
|
||
/** Phases a `rec` reply would reopen through its plan-changing recommendations, in run order; empty when none changes the plan. */
|
||
reopens: string[];
|
||
voices: number;
|
||
per_voice_usd: number;
|
||
/** The reopen estimate: voices × per-voice. */
|
||
rec_more_usd: number;
|
||
}
|
||
|
||
export interface GateCostRun { phase_order: string[]; outside_enabled: boolean; spend_cap_usd: number | null; 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. The effective
|
||
* choice per item is what `rec` would record (parseReply's order): an answer already given at this
|
||
* gate revision (`prior`, as answerGate reads it from decisions.jsonl), else the carried choice,
|
||
* else the recommendation. A recorded plan rerun (`p1d`) reopens every phase; a reject or stop
|
||
* (`p1c`/`p1e`) reopens nothing; an override of the recommendation counts as answerGate counts it. 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, 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));
|
||
// The effective choice is parseReply's: an answer recorded at this gate, else the carried choice, else the recommendation.
|
||
const choiceOf = (item: GateList['items'][number]) => prior[item.id] ?? item.carried?.chosen ?? item.recommended;
|
||
const plan = list.items.find(i => i.id === 'p1');
|
||
const planChoice = plan ? choiceOf(plan) : undefined;
|
||
if (planChoice === 'c' || planChoice === 'e') return [];
|
||
const named = new Set<string>(planChoice === 'd' ? run.phase_order : []);
|
||
for (const item of list.items) {
|
||
if (item.id === 'p1') continue;
|
||
const choice = choiceOf(item);
|
||
if (item.carried && choice === item.carried.chosen) continue;
|
||
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 === choice)?.changes_plan ?? choice !== item.recommended) for (const p of bound) named.add(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 {
|
||
const ledger = readLedger(path.join(outDir, 'spend.json'), run.spend_cap_usd);
|
||
const summary = summarize(ledger);
|
||
const seen = [...ledger.settled.map(s => s.reserved_usd), ...ledger.reserved.map(r => r.estimate_usd)].filter(n => n > 0);
|
||
const per_voice_usd = seen.length ? seen[seen.length - 1]! : o.defaultPerVoiceUsd ?? 0;
|
||
const reopens = recReopens(list, run, 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 };
|
||
}
|
||
|
||
/** The two printed lines: the ledger at this gate, and the price of `rec`. */
|
||
export function renderGateCost(c: GateCost): string[] {
|
||
const s = c.summary;
|
||
const cap = s.cap_usd === null ? 'none' : usd(s.cap_usd);
|
||
const headroom = s.headroom_usd === null ? 'none' : usd(s.headroom_usd);
|
||
const unknown = s.unknown_count ? `; ${s.unknown_count} host-subagent attempt(s) priced unknown, counted at their reservation (read the actual figure in the host's cost view)` : '';
|
||
const after = s.headroom_usd === null ? '' : ` → headroom ${usd(s.headroom_usd - c.rec_more_usd)}${s.headroom_usd - c.rec_more_usd < 0 ? ' (over the cap: raise it with gstack-autoplan budget before answering rec)' : ''}`;
|
||
return [
|
||
`GATE_SPEND: spent=${usd(s.spent_usd)} worst_case=${usd(s.worst_case_usd)} cap=${cap} headroom=${headroom} attempts=${c.attempts}${unknown}`,
|
||
c.reopens.length
|
||
? `GATE_REC_COST: rec reopens up to ${c.reopens.join(',')} ≈ ${c.voices} voice(s) × ${usd(c.per_voice_usd)} = +${usd(c.rec_more_usd)} at most (the run's per-voice reservation)${after}`
|
||
: 'GATE_REC_COST: rec reopens nothing (no recommended option changes the plan): +0.00',
|
||
];
|
||
}
|
||
|
||
/** The JSON payload beside the printed lines. */
|
||
export function gateCostPayload(c: GateCost): Record<string, unknown> {
|
||
const s = c.summary;
|
||
return { spent_usd: s.spent_usd, worst_case_usd: s.worst_case_usd, cap_usd: s.cap_usd, headroom_usd: s.headroom_usd, attempts: c.attempts, unknown_count: s.unknown_count, rec_reopens: c.reopens, rec_voices: c.voices, per_voice_usd: c.per_voice_usd, rec_more_usd: c.rec_more_usd };
|
||
}
|
||
|
||
/** One sentence for the owner brief. */
|
||
export function gateCostSentence(c: GateCost): string {
|
||
const s = c.summary;
|
||
const cap = s.cap_usd === null ? 'no cap set' : `cap ${usd(s.cap_usd)}, headroom ${usd(s.headroom_usd!)}`;
|
||
const unknown = s.unknown_count ? `, ${s.unknown_count} host attempt(s) unknown and counted at reservation` : '';
|
||
const rec = c.reopens.length ? `Taking every recommendation reopens up to ${c.reopens.join(', ')}: at most +${usd(c.rec_more_usd)} more.` : 'Taking every recommendation reopens nothing: no further spend.';
|
||
return `Cost so far: spent ${usd(s.spent_usd)}, worst case ${usd(s.worst_case_usd)} (${cap}${unknown}; ${c.attempts} reviewer attempt(s)). ${rec}`;
|
||
}
|