--stack b1,b2`** writes the same rows to every branch of a stack, or to none when one branch is unknown.
+- **`REVIEW_ESTIMATE:`** on the first `next`: phases, voices, rounds, attempts and the reservation total.
+
+### Changed
+- `lib/decision-trail.ts` holds the trail append rules shared by `gstack-decision-trail` and the carry.
+
+## [1.91.313.0] - 2026-10-29
+
+**The PII scanner stops flagging two kinds of ordinary text: a bare power-of-two or hash constant in code, and a package@version-style filename.** Eval artifacts and PR bodies that print a PRNG range or name a versioned results file no longer raise a MEDIUM phone or email finding, and real phone numbers and addresses next to them still report.
+
+### Fixed
+- **`lib/redact-patterns.ts`**: `pii.phone.e164` skips a bare number that is exactly a power of two from 2^30 through 2^49, one less than one, or one of eleven well-known 32-bit hash and PRNG constants. The same digits written with a `+`, spaces, dashes, dots or parentheses still report (#3577).
+- **`lib/redact-patterns.ts`**: `pii.email` skips a filename whose part after the `@` is a version or tag followed by file extensions no top-level domain uses, or a dotted version followed by an extension that doubles as one. A file named after a real address, an address at a numeric domain such as 163.com, and an address under a top-level domain that doubles as an extension (`.sh`, `.md`) still report (#3576).
+
+### Tests
+- `test/redact-constant-and-at-filename.test.ts`: pins both exemptions in both directions, including files named after a real address.
+
+## [1.91.312.0] - 2026-10-29
+
+**An install check run from inside /ship no longer upgrades gstack under it.** /ship's docs step runs `scripts/install.sh --for document-release`. That run fast-forwarded the checkout from 1.91.298 to 1.91.299 in the middle of a lane's /ship, so the receipt writer's rules changed partway through the run (#3571).
+
+### Fixed
+- **`bin/gstack-capy-session`**: in install mode, a live workflow lease under 120 minutes old held by the caller's own process chain now skips the self-upgrade: `SELF_UPGRADE: skipped (a gstack run in this session holds a workflow lease on this installation: pid (m ), so the upgrade waits until the lease is 120 minutes old; …(UPGRADE_BUSY))`. The install still certifies the installed revision and the trailer stays ok with `behind=`. `bin/gstack-upgrade` deliberately ignores the caller's own lease, and an install started from inside the run passed that check. Each `gstack-skill-start` renews the lease. The age bound matters on Capy, where the leased pid is the machine's long-lived `capy-server`, so a lease is never released by its process exiting. An older lease, or none, upgrades as before, and `GSTACK_UPGRADE_FORCE=1` or a hand-run `bin/gstack-upgrade` still moves the checkout.
+
+### Tests
+- `test/capy-skill-wrapper.test.ts`: an install run under a shell that holds a fresh lease leaves HEAD where it was and stays ok. Once that lease is 121 minutes old, with its holder still alive, the same install upgrades. Before this change the first run printed `SELF_UPGRADE: done 1.0.1 -> 1.0.2`.
+
+## [1.91.311.0] - 2026-10-29
+
+**A sealed policy can now refuse `sealed//tasks.json` while allowing the `MANIFEST.json` beside it, and can catch `HELDOUT/` with a `heldout` glob.** The sealed guard only had deny globs and one-commit waivers, so the gstack-evals layout, which keeps a public manifest of hashes and counts next to its sealed tasks, needed a fresh `.gstack/sealed-allow.tsv` line for every manifest commit (gstack-evals wiki/board/2026-10-28-gsta-9-sealed-manifest-glob-gap.md). Their first draft also wrote `sealed/**`, which never matched their nested `tasks//sealed/` directories, and garrytan/gstack-evals#3 (dc54796) hand-listed `Sealed/`, `HELDOUT/` and other case variants.
+
+### Added
+- **`sealed.allow_paths`** (`lib/sealed-guard.ts`): globs that exempt publishable files from `sealed.path` and `sealed.transcript`, applied after the deny globs and before sha waivers. They are read from the destination base branch only, so a push cannot widen its own exceptions; a pushed copy is still validated and refuses when malformed. Text rules still read an exempt file, so a manifest that quotes a sealed answer still refuses. An entry whose file name contains `**` or is only wildcards before its extension (`**`, `sealed/*`, `**/*.json`) is refused. Each exempt path prints `exempt commit (sealed.allow_paths )` on every push.
+- **`sealed.case_insensitive: true`**: `paths` and `transcripts` match regardless of case, so `**/heldout/**` also refuses `HELDOUT/` and `HeldOut/`. The value is the base branch's OR the pushed one, because it only tightens. `allow_paths` never fold case. Separator and spelling variants such as `hold-out/` still need their own globs.
+
+### Changed
+- `gstack-pregate sealed` prints `exempt=` after `waived=` on its `SEALED:` line (`policy=` stays last), and `--json` carries an `exempt` array. Both bins build their error report through one `sealedErrorReport`.
+- An unknown `sealed` key now says to upgrade gstack if the key comes from a newer one. A gstack older than this release refuses `allow_paths` and `case_insensitive` this way on every push and `gstack-pregate run`, so a repository raises `min_gstack_version` before landing either key.
+- `docs/pregate.md` § Sealed output explains glob anchoring: patterns match from the repository root, so `sealed/**` misses `tasks/x/sealed/a.json` and the docs say to write `**/sealed/**`. It also documents case handling, exceptions and the version skew.
+
+### Gate integrity
+- This is a gate edit: `allow_paths` lets the sealed guard pass files it refused before, but only from the reviewed base branch. No existing test, threshold or allowlist changed. New cases in `test/sealed-guard.test.ts` (10) and `test/pregate-guards.test.ts` cover an allowed exception, a non-matching one, an exception added on the pushed head, a text rule inside an exempt file, transcripts, the waiver order, the error report, case folding in both directions and the root-anchoring example.
+
## [1.91.310.0] - 2026-10-29
**The cookie workflow judge now asks for structured output, so a quote the model forgets to escape can no longer fail the gate.** Train 42 went red because both of its `setup-browser-cookies/SKILL.md workflow` samples came back as free text with a bare quote inside `reasoning` ("one --domain per run"), and neither parsed. The GSTA-91 comparison had measured that failure: 2 of 144 free-text samples failed to parse, against 0 of 72 with the schema.
diff --git a/VERSION b/VERSION
index 7a470c3f1..786e62c30 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.91.310.0
+1.91.316.0
diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md
index a91df72cb..5e410df79 100644
--- a/agents-digest/gstack-AGENTS.md
+++ b/agents-digest/gstack-AGENTS.md
@@ -1,4 +1,4 @@
-# gstack digest v1.91.310.0 — regenerate/re-copy after upgrading gstack
+# gstack digest v1.91.316.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/autoplan/DRIVER.md b/autoplan/DRIVER.md
index f1e1a3f96..875ea1068 100644
--- a/autoplan/DRIVER.md
+++ b/autoplan/DRIVER.md
@@ -26,7 +26,7 @@ line yourself; gstack never writes the plan.
1. `next` prints one `ATTEMPT: phase= voice=native|outside prompt= result=`
per voice. Dispatch the native prompt to a subagent on your model and the outside prompt to
- a subagent on another model family (or `gstack-outside-voice run`). Each writes `result=`.
+ a subagent on another model family (or `gstack-outside-voice run`). Each writes `result=`. The first `next` also prints `REVIEW_ESTIMATE:` (most attempts and reservations the review can take).
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=`); render it with `gstack-gate render /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.
@@ -38,6 +38,7 @@ line yourself; gstack never writes the plan.
5. `REOPENED: ` means loop `next` again (Eng last). `APPROVED:` means `export` (`DISPATCHES: est_input_tokens=`, relative).
6. Rounds: one by default; a second only for High or Critical findings; cap 2 (`--max-rounds`). A `revise` whose decisions all resolve below
High prints `APPLIED: without re-review (below High)`. At the cap with High open, `ESCALATE: evidence sufficient, decision needed`: relay it, never ask for another round.
+7. Carry-forward ([docs/unattended.md#carry-forward](../docs/unattended.md#carry-forward); `--no-carry-forward` on the first `next` opts out): a later gate that asks nothing new takes the owner's words from the gate before it (`CARRIED:`, then `APPROVED:`); `CARRY: no ()` leaves it for the owner; `CARRY_CHECK:` means one reviewer from another model family reads the printed delta prompt, then `check --out --attempt --result --model ` (or `--unavailable ""`).
### On Capy
@@ -51,9 +52,8 @@ line yourself; gstack never writes the plan.
with `gstack-config set autoplan_checkpoint drive`) so a run survives machine replacement: a
generation is published at rest, after each bound voice and after each phase transition; restore
with `resume --out --from /`, which never redispatches a bound voice.
-- Plans carry `## Before → after`, `## Why it matters`, `## What caused this work`, `## Big decisions`;
- relay the printed `RELAY: gstack-owner-brief render --captain` output to your Captain (or the person),
- then `gstack-approvals import --autoplan ` after `answer` (`capy/skill/gstack/captain.md`).
+- Plans carry `## ELI5` (5-10 plain sentences), `## Before → after`, `## Why it matters`, `## What caused this work`, `## Big decisions`;
+ relay the printed `RELAY: gstack-owner-brief render --captain` output to your Captain (or the person), then `gstack-approvals import --autoplan ` after `answer` (`--stack ` for a stack; `capy/skill/gstack/captain.md`).
### Commands
diff --git a/autoplan/DRIVER.md.tmpl b/autoplan/DRIVER.md.tmpl
index b2ff2b001..9b3ed3765 100644
--- a/autoplan/DRIVER.md.tmpl
+++ b/autoplan/DRIVER.md.tmpl
@@ -23,7 +23,7 @@ line yourself; gstack never writes the plan.
1. `next` prints one `ATTEMPT: phase= voice=native|outside prompt= result=`
per voice. Dispatch the native prompt to a subagent on your model and the outside prompt to
- a subagent on another model family (or `gstack-outside-voice run`). Each writes `result=`.
+ a subagent on another model family (or `gstack-outside-voice run`). Each writes `result=`. The first `next` also prints `REVIEW_ESTIMATE:` (most attempts and reservations the review can take).
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=`); render it with `gstack-gate render /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.
@@ -35,6 +35,7 @@ line yourself; gstack never writes the plan.
5. `REOPENED: ` means loop `next` again (Eng last). `APPROVED:` means `export` (`DISPATCHES: est_input_tokens=`, relative).
6. Rounds: one by default; a second only for High or Critical findings; cap 2 (`--max-rounds`). A `revise` whose decisions all resolve below
High prints `APPLIED: without re-review (below High)`. At the cap with High open, `ESCALATE: evidence sufficient, decision needed`: relay it, never ask for another round.
+7. Carry-forward ([docs/unattended.md#carry-forward](../docs/unattended.md#carry-forward); `--no-carry-forward` on the first `next` opts out): a later gate that asks nothing new takes the owner's words from the gate before it (`CARRIED:`, then `APPROVED:`); `CARRY: no ()` leaves it for the owner; `CARRY_CHECK:` means one reviewer from another model family reads the printed delta prompt, then `check --out --attempt --result --model ` (or `--unavailable ""`).
### On Capy
@@ -48,9 +49,8 @@ line yourself; gstack never writes the plan.
with `gstack-config set autoplan_checkpoint drive`) so a run survives machine replacement: a
generation is published at rest, after each bound voice and after each phase transition; restore
with `resume --out --from /`, which never redispatches a bound voice.
-- Plans carry `## Before → after`, `## Why it matters`, `## What caused this work`, `## Big decisions`;
- relay the printed `RELAY: gstack-owner-brief render --captain` output to your Captain (or the person),
- then `gstack-approvals import --autoplan ` after `answer` (`capy/skill/gstack/captain.md`).
+- Plans carry `## ELI5` (5-10 plain sentences), `## Before → after`, `## Why it matters`, `## What caused this work`, `## Big decisions`;
+ relay the printed `RELAY: gstack-owner-brief render --captain` output to your Captain (or the person), then `gstack-approvals import --autoplan ` after `answer` (`--stack ` for a stack; `capy/skill/gstack/captain.md`).
### Commands
diff --git a/bin/gstack-approvals b/bin/gstack-approvals
index da97642a9..3d3d5a9ef 100755
--- a/bin/gstack-approvals
+++ b/bin/gstack-approvals
@@ -5,7 +5,7 @@
* gstack-approvals add --ask --words --by --scope
* [--pending ] [--outcome approved|rejected|pending|standing]
* [--date YYYY-MM-DD] [--source [] [--branch ] [--json]
- * gstack-approvals import --autoplan [--by ] [--branch ] [--json]
+ * gstack-approvals import --autoplan [--by ] [--branch | --stack ] [--json]
* gstack-approvals render [--no-heading] [--branch ]
* gstack-approvals list [--json] [--branch ]
* gstack-approvals sync [--branch ]
@@ -20,14 +20,15 @@
*/
import * as path from 'node:path';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
-import { appendRows, buildRow, importAutoplan, locate, readRecord, renderRecord, syncRecord, type RecordError, type StoreLocation } from '../lib/approval-record';
+import { appendRows, branchExists, buildRow, importAutoplan, locate, readRecord, renderRecord, syncRecord, type RecordError, type StoreLocation } from '../lib/approval-record';
import { resultLine } from '../lib/result-codes';
const HELP = `gstack-approvals — the per-thread approval record (capy/skill/gstack/captain.md)
add --ask --words --by --scope
[--pending ] [--outcome approved|rejected|pending|standing] [--date YYYY-MM-DD] [--source ][]
- import --autoplan [--by ]
+ import --autoplan [--by ] [--stack ]
+ --stack writes the same rows to every branch of a stack; each branch is checked first, and one unknown branch writes none
render [--no-heading] the ## Approvals section for a PR body or report
list [--json]
sync re-mirror the record (GSTACK_APPROVALS_DIR, the Captain's drive approvals/, or the project-user Drive)
@@ -40,7 +41,7 @@ Example:
${exitTableHelp()}`;
-const VALUE_FLAGS = new Set(['--ask', '--words', '--by', '--scope', '--pending', '--outcome', '--date', '--source', '--branch', '--autoplan']);
+const VALUE_FLAGS = new Set(['--ask', '--words', '--by', '--scope', '--pending', '--outcome', '--date', '--source', '--branch', '--autoplan', '--stack']);
function parse(args: string[]): { flags: Record; bools: Set; error?: string } {
const flags: Record = {};
@@ -64,11 +65,45 @@ function fail(cmd: string, e: RecordError): number {
return EXIT.refused;
}
+/** `import --stack`: every branch checked first, then the same rows (ids are branch-independent) written to each branch's record. */
+function importStack(cmd: string, flags: Record, json: boolean): number {
+ if (cmd !== 'import' || !flags['--autoplan'] || flags['--branch'] !== undefined) { process.stderr.write(`gstack-approvals: --stack goes with import --autoplan , without --branch\n`); return EXIT.usage; }
+ const branches = [...new Set(flags['--stack']!.split(',').map(b => b.trim()).filter(Boolean))];
+ if (!branches.length) { process.stderr.write(`gstack-approvals: --stack needs \n`); return EXIT.usage; }
+ const cwd = process.cwd();
+ const missing = branches.filter(b => !branchExists(cwd, b));
+ if (missing.length) return fail(cmd, { code: 'APPROVAL_RECORD_NO_BRANCH', message: `no local or origin branch ${missing.join(', ')}; nothing was written` });
+ const runDir = path.resolve(flags['--autoplan']);
+ const results: Array<{ branch: string; added: string[]; skipped: number; file: string; mirror: string | null; error?: string }> = [];
+ let firstError: RecordError | undefined;
+ const plans: Array<{ branch: string; loc: StoreLocation; rows: ReturnType & unknown[] }> = [];
+ for (const branch of branches) {
+ const loc = locate({ cwd, env: process.env, branch });
+ if ('code' in loc) return fail(cmd, loc);
+ const rows = importAutoplan(runDir, branch, flags['--by']);
+ if ('code' in rows) return fail(cmd, rows);
+ plans.push({ branch, loc, rows });
+ }
+ for (const { branch, loc, rows } of plans) {
+ const res = appendRows(loc, rows);
+ if ('code' in res) { firstError ??= res; results.push({ branch, added: [], skipped: 0, file: loc.file, mirror: loc.mirror ?? null, error: res.message }); continue; }
+ if (res.mirrorError) firstError ??= res.mirrorError;
+ results.push({ branch, added: res.added.map(r => r.id), skipped: res.skipped, file: loc.file, mirror: loc.mirror ?? null, ...(res.mirrorError ? { error: res.mirrorError.message } : {}) });
+ }
+ if (json) process.stdout.write(JSON.stringify({ branches: results }) + '\n');
+ else {
+ for (const r of results) process.stdout.write(`APPROVAL_IMPORTED: branch=${r.branch} n=${r.added.length} skipped=${r.skipped} ${r.file}${r.mirror ? ` mirror=${r.mirror}` : ''}${r.error ? ` error=${r.error}` : ''}\n`);
+ process.stdout.write(`APPROVAL_IMPORTED_STACK: branches=${results.length} added=${results.reduce((n, r) => n + r.added.length, 0)}\n`);
+ }
+ return firstError ? fail(cmd, firstError) : EXIT.ok;
+}
+
function main(argv: string[]): number {
const [cmd, ...rest] = argv;
if (!cmd || cmd === '--help' || cmd === '-h') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; }
const { flags, bools, error } = parse(rest);
if (error) { process.stderr.write(`gstack-approvals: ${error}\n${HELP}\n`); return EXIT.usage; }
+ if (flags['--stack'] !== undefined) return importStack(cmd, flags, bools.has('--json'));
const loc = locate({ cwd: process.cwd(), env: process.env, branch: flags['--branch'] });
if ('code' in loc) return fail(cmd, loc);
const json = bools.has('--json');
diff --git a/bin/gstack-autoplan b/bin/gstack-autoplan
index 0b0a1c45b..c42062867 100755
--- a/bin/gstack-autoplan
+++ b/bin/gstack-autoplan
@@ -19,13 +19,14 @@
* gstack-autoplan release --out --attempt drop a reservation for an attempt that never ran
* gstack-autoplan close --out --phase ] --deferred "" --by > close a phase accepted-with-findings
* gstack-autoplan budget --out [--spend-cap ] [--max-rounds ] --by > the owner's journaled cap change
+ * gstack-autoplan check --out --attempt --result --model | --unavailable the delta check before a carry
* gstack-autoplan checkpoints --from list checkpoint generations, newest first, with hash ok|bad
* gstack-autoplan contract print the coordinator contract every lane prompt inherits (plan E2)
*
* Logic: lib/autoplan-run.ts (next/submit/resume), lib/autoplan-gate.ts (close, gate, answer),
* lib/autoplan-export.ts, lib/autoplan-prompts.ts, lib/spend-ledger.ts. Codes: lib/result-codes.ts.
* Workflow commands end with GSTACK_RESULT: skill=autoplan status= run=; the run is never approved here.
- * Mutating verbs (next, submit, answer, recommend, revise, close, budget, export, run) pass admission first (lib/autoplan-admission.ts)
+ * Mutating verbs (next, submit, answer, recommend, revise, close, budget, check, export, run) pass admission first (lib/autoplan-admission.ts)
* and bring an older run directory forward (lib/autoplan-migrate.ts) under the run lock.
*/
import * as fs from 'node:fs';
@@ -33,6 +34,7 @@ import * as path from 'node:path';
import { amendmentSteps, answerGate, closeDeferred, finishGate, recommendGate } from '../lib/autoplan-gate';
import { gateCost, gateCostPayload, renderGateCost, type GateCost } from '../lib/autoplan-gate-cost';
import { attemptEstimate, changeBudget, escalateLine, liftStop } from '../lib/autoplan-budget';
+import { DEFAULT_CARRY_CHECK_CAP_USD, carryLines, submitCarryCheck } from '../lib/autoplan-carry';
import { ANSWERED_BY_RE } from '../lib/gate-list';
import { checkpointLines, checkpointRun, restoreCheckpoint, type CheckpointMoment } from '../lib/autoplan-checkpoint';
import { admit, touchInstallLease } from '../lib/autoplan-admission';
@@ -60,6 +62,9 @@ DRIVER: ${driverFile()}
--estimate-usd: reserved per new attempt; unattended Capy runs reserve a default for host attempts so --spend-cap binds
--scaffold (first next): a plan missing one of its two sections is reviewed from /plan-source.md with the heading added
--light loads each phase's checklist plus the sections its scope needs (the unattended default); interactive kinds load everything
+ --no-carry-forward (first next): every later gate revision goes to the owner, even one that asks nothing new
+ --carry-check-cap (first next): the most one delta check may reserve before a carry (default ${DEFAULT_CARRY_CHECK_CAP_USD.toFixed(2)})
+ the first next prints REVIEW_ESTIMATE: the most the review can reserve at this scope and round cap
prepare the current phase; one ATTEMPT: line per voice; ends awaiting_result
submit --out --phase --voice native|outside --result | --record [--attest ]
--model [--attempt ] [--runner ] [--usage-usd ] [--reconcile ] [--json]
@@ -84,6 +89,9 @@ DRIVER: ${driverFile()}
resume --out [--from ] [--json] --from restores a hash-verified checkpoint into an empty --out first
(a collection or root restores its newest valid generation; GSTACK_AUTOPLAN_CHECKPOINT_DIR set: every command
leaving the run at rest publishes a generation under // and prints CHECKPOINT: on stderr)
+ check --out --attempt --result --model [--same-family ""] | --unavailable ""
+ bind the one-reviewer delta check a CARRY_CHECK: line asked for: nothing at High or above carries the owner's
+ last answer (CARRIED:); otherwise, or when unavailable, the gate goes to the owner as before
checkpoints --from list checkpoint generations, newest first: run, gate_rev, time, hash ok|bad, current
close --out --phase --deferred "" --by >
close the phase accepted-with-findings: open voices skipped (dispatched attempts settle unknown at their
@@ -180,7 +188,10 @@ function doNext(out: string, rest: string[], json: boolean): number {
const ui = rest.includes('--ui') ? true : rest.includes('--no-ui') ? false : undefined;
const maxRounds = num(rest, '--max-rounds');
if (maxRounds !== undefined && (!Number.isInteger(maxRounds) || maxRounds < 1)) throw new RunError('ARTIFACT_SCHEMA', '--max-rounds needs an integer >= 1', 1);
+ const carryCheckCapUsd = num(rest, '--carry-check-cap');
+ if (carryCheckCapUsd !== undefined && !(carryCheckCapUsd >= 0)) throw new RunError('ARTIFACT_SCHEMA', '--carry-check-cap needs a number >= 0', 1);
initRun({ plan, out, cwd, planHeading: flag(rest, '--plan-heading'), outsideEnabled: !rest.includes('--no-outside'), spendCap: num(rest, '--spend-cap') ?? null, maxRounds,
+ carryForward: !rest.includes('--no-carry-forward'), carryCheckCapUsd,
deadlineMin: num(rest, '--deadline'), light: rest.includes('--light'), scaffold: rest.includes('--scaffold'), scope: { ui, developerTool: rest.includes('--developer-tool'), agentPrimary: rest.includes('--agent-primary'), dx: rest.includes('--no-dx') ? false : undefined, ...(rest.includes('--docs-only') ? { docsOnly: true } : {}) } }, initLines);
}
const redispatch = rest.flatMap((a, i) => (a === '--redispatch' && rest[i + 1] ? [rest[i + 1]!] : []));
@@ -257,7 +268,7 @@ function doClose(out: string, rest: string[], json: boolean): number {
const r = closeDeferred(state, phase as Phase, { reason: reason.trim(), by, cause: 'owner' });
moment = 'transition';
const lines = [...r.lines, ...(stopped ? liftStop(state, `${phase} closed deferred by ${by}`) : [])];
- if (!currentPhase(state)) { finishGate(state); lines.push(...gateLines(state, out)); }
+ if (!currentPhase(state)) { finishGate(state); lines.push(...carryLines(state), ...(state.status === 'gate_pending' ? gateLines(state, out) : [])); }
else { state.status = 'running'; lines.push(`NEXT: gstack-autoplan next --out ${out}`); }
writeState(state);
lines.push(resultLineFor(state.status, out));
@@ -265,6 +276,19 @@ function doClose(out: string, rest: string[], json: boolean): number {
return EXIT.ok;
}
+/** `check`: the delta reviewer's verdict before a carry (lib/autoplan-carry.ts). */
+function doCheck(out: string, rest: string[], json: boolean): number {
+ const unavailable = flag(rest, '--unavailable');
+ const result = flag(rest, '--result');
+ if (unavailable === undefined && (!result || !flag(rest, '--model'))) return usage('check needs --attempt and --result --model , or --unavailable ""');
+ const r = submitCarryCheck({ out, attempt: flag(rest, '--attempt'), result: result ? path.resolve(result) : undefined, model: flag(rest, '--model'), unavailable, sameFamily: flag(rest, '--same-family') });
+ moment = 'transition';
+ const status = r.state.status;
+ const lines = [...r.lines, ...(status === 'gate_pending' ? gateLines(r.state, out) : []), ...(status === 'amend_pending' || status === 'running' ? [nextLine(r.state, out)] : []), resultLineFor(status, out)];
+ emit(json, lines, { run: r.state.run, status, gate_rev: r.state.gate_rev ?? null, carry: r.state.carry ?? null, out });
+ return EXIT.ok;
+}
+
function doBudget(out: string, rest: string[], json: boolean): number {
const state = readState(out);
const lines = changeBudget(state, { spendCap: num(rest, '--spend-cap'), maxRounds: num(rest, '--max-rounds'), by: flag(rest, '--by') });
@@ -338,6 +362,7 @@ async function main(argv: string[]): Promise {
if (cmd === 'revise') return mutating(out, json, () => doRevise(out, rest, json));
if (cmd === 'close') return mutating(out, json, () => doClose(out, rest, json));
if (cmd === 'budget') return mutating(out, json, () => doBudget(out, rest, json));
+ if (cmd === 'check') return mutating(out, json, () => doCheck(out, rest, json));
if (cmd === 'run') { const admission = admit(process.env); if (admission.lines.length) process.stdout.write(admission.lines.join('\n') + '\n'); return await doRun(out, rest, json); }
if (cmd === 'status') {
const state = readState(out);
@@ -416,7 +441,7 @@ async function main(argv: string[]): Promise {
if (e instanceof RunError) {
process.stderr.write(resultLine(`gstack-autoplan ${cmd}`, e.code, e.detail) + '\n');
if (e.after.lines?.length) process.stdout.write(e.after.lines.join('\n') + '\n');
- if (['next', 'submit', 'answer', 'resume', 'run', 'revise', 'recommend', 'close', 'budget'].includes(cmd)) process.stdout.write(resultLineFor(e.after.status ?? 'refused', out) + '\n');
+ if (['next', 'submit', 'answer', 'resume', 'run', 'revise', 'recommend', 'close', 'budget', 'check'].includes(cmd)) process.stdout.write(resultLineFor(e.after.status ?? 'refused', out) + '\n');
return e.exit === 3 ? EXIT.refused : EXIT.fail;
}
process.stderr.write(`gstack-autoplan ${cmd}: ${e?.message ?? e}\n`);
diff --git a/bin/gstack-capy-session b/bin/gstack-capy-session
index 4296bdec8..3df25b12e 100755
--- a/bin/gstack-capy-session
+++ b/bin/gstack-capy-session
@@ -23,7 +23,13 @@
# applies: dirty, diverged, pinned, Bun hold, a live workflow lease) and on
# success re-execs the NEW checkout's copy of this script with the same
# arguments (GSTACK_CAPY_SELF_UPGRADED=1 stops a second attempt) before the
-# installer runs. Lines: `SELF_UPGRADE: done -> ` | `skipped ()`
+# installer runs. A live lease under 120 minutes old (the lease prune window)
+# held by this session's own process chain also skips it (#3571):
+# gstack-upgrade never counts the caller's own session, but an install run from
+# inside a workflow (/ship's docs step) must not change gstack under it. The age
+# bound matters on Capy, where the leased harness pid is the machine's
+# long-lived capy-server and a live lease alone would never end the deferral;
+# each gstack-skill-start renews it (UPGRADE_BUSY; GSTACK_UPGRADE_FORCE=1 overrides). Lines: `SELF_UPGRADE: done -> ` | `skipped ()`
# | `none (current)` | `disabled ()`.
# --check never moves the checkout; behind origin it prints
@@ -107,6 +113,18 @@ elif [ "${GSTACK_CAPY_SELF_UPGRADED:-}" != 1 ]; then
elif ! behind; then echo "SELF_UPGRADE: none (current)"
elif [ ! -x "$ROOT/bin/gstack-upgrade" ]; then echo "SELF_UPGRADE: skipped ($ROOT/bin/gstack-upgrade is missing)"; report_behind "$RERUN"
export GSTACK_CAPY_BEHIND_BLOCKED="$ROOT/bin/gstack-upgrade is missing; fix: $RERUN (UPGRADE_REFUSED)"
+ elif [ "${GSTACK_UPGRADE_FORCE:-}" != 1 ] && [ -x "$ROOT/bin/gstack-lease" ] \
+ && OWN=$("$ROOT/bin/gstack-lease" live --root "$ROOT" 2>/dev/null | awk '{ a = $2; sub(/m$/, "", a); if (a + 0 < 120) print }') \
+ && [ -n "$OWN" ] \
+ && ! "$ROOT/bin/gstack-lease" live --root "$ROOT" --exclude-ancestry-of $$ >/dev/null 2>&1; then
+ HOLDERS=""
+ while read -r _pid _age; do HOLDERS="$HOLDERS${HOLDERS:+, }pid $_pid ($_age)"; done </dev/null)
UP_OUT=$("$ROOT/bin/gstack-upgrade" --ff-only-no-reinstall 2>&1); UP_RC=$?
diff --git a/bin/gstack-decision-trail b/bin/gstack-decision-trail
index 297c6aee4..750b00da8 100755
--- a/bin/gstack-decision-trail
+++ b/bin/gstack-decision-trail
@@ -17,7 +17,8 @@
* and a cell whose first byte a spreadsheet would run as a formula (=, +, -, @)
* gets a leading apostrophe, so evidence taken from PR titles, filenames or
* generated text cannot execute when a reviewer opens the file. License text:
- * licenses/MIT-pstack.txt; NOTICE.md carries the entry.
+ * licenses/MIT-pstack.txt; NOTICE.md carries the entry. The append rules live in
+ * lib/decision-trail.ts, shared with the autoplan runner.
*
* The start-row rule is gstack's mechanisation of the same skill's prose: a run
* (one agent conversation, with its later turns) that writes to a trail already
@@ -26,11 +27,8 @@
* id, and an append with `--run ` refuses until that run's start row is the
* newest start row in the file (DECISION_TRAIL_START_REQUIRED, exit 3).
*/
-import * as fs from "node:fs";
-import * as path from "node:path";
+import { appendTrailRow as appendRow, readTrailRows as readRows, type TrailRow as Row } from "../lib/decision-trail";
-const COLUMNS = ["ts", "phase", "decision", "why", "evidence", "result"] as const;
-const HEADER = COLUMNS.join("\t") + "\n";
const USAGE = `Usage:
gstack-decision-trail [--run ]
gstack-decision-trail start
@@ -56,58 +54,6 @@ letters, digits and . _ : @ / - (a Capy jam_ id, a session id, a PR number).
when the trail has rows and the newest start row is another run's.
`;
-type Row = { line: number; cells: string[] };
-
-function clean(cell: string): string {
- const flat = cell.replace(/[\t\n\r]/g, " ");
- return /^[=+\-@]/.test(flat) ? `'${flat}` : flat;
-}
-
-function readRows(file: string): Row[] {
- if (!fs.existsSync(file)) return [];
- const lines = fs.readFileSync(file, "utf8").split("\n");
- const rows: Row[] = [];
- lines.forEach((raw, i) => {
- let text = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
- if (i === 0 && text.startsWith("\uFEFF")) text = text.slice(1);
- // A repeated header (the append-on-flaky-mount case) is never a row.
- if (text === "" || text === HEADER.trimEnd()) return;
- rows.push({ line: i + 1, cells: text.split("\t") });
- });
- return rows;
-}
-
-function appendRow(file: string, cells: readonly string[]): number {
- const dir = path.dirname(file);
- if (dir && dir !== "." && !fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
- // Append the header too, never truncate: on a flaky mount the worst case is
- // one stray header line, not lost rows.
- // A file with no header line anywhere (empty, blank, or rows written by hand)
- // gets one appended once; readRows skips a header line wherever it sits.
- const size = fs.existsSync(file) ? fs.statSync(file).size : 0;
- if (size > 0 && !endsWithNewline(file, size)) fs.appendFileSync(file, "\n");
- if (size === 0 || !hasHeader(file)) fs.appendFileSync(file, HEADER);
- const ts = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
- fs.appendFileSync(file, [ts, ...cells.map(clean)].join("\t") + "\n");
- return readRows(file).length;
-}
-
-function hasHeader(file: string): boolean {
- return fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "").split("\n")
- .some(line => line.replace(/\r$/, "") === HEADER.trimEnd());
-}
-
-function endsWithNewline(file: string, size: number): boolean {
- const fd = fs.openSync(file, "r");
- try {
- const last = Buffer.alloc(1);
- fs.readSync(fd, last, 0, 1, size - 1);
- return last[0] === 0x0a;
- } finally {
- fs.closeSync(fd);
- }
-}
-
const RUN_ID = /^[A-Za-z0-9._:@/-]+$/;
function checkRunId(runId: string): void {
if (!RUN_ID.test(runId)) fail(`run id must match ${RUN_ID} (letters, digits, . _ : @ / -), got ${JSON.stringify(runId)}`, 2);
diff --git a/bin/gstack-pregate b/bin/gstack-pregate
index 8613150ed..446e4be7a 100755
--- a/bin/gstack-pregate
+++ b/bin/gstack-pregate
@@ -40,7 +40,7 @@ import { DEFAULT_STALL_TIMEOUT_S } from '../lib/pregate/context';
import { cancelActive } from '../lib/pregate/supervise';
import { defaultGh, evaluateRegate, renderRegate } from '../lib/pregate/regate';
import { GATE_AHEAD_REL } from '../lib/restamp';
-import { evaluateSealed, publishedCommits, rangeCommits, renderSealed } from '../lib/sealed-guard';
+import { evaluateSealed, publishedCommits, rangeCommits, renderSealed, sealedErrorReport } from '../lib/sealed-guard';
import { isUnavailable, loadPolicyFromBase, policyUnavailableLine, resolveBaseBranch } from '../lib/ship-policy';
import { fromGateAhead } from '../lib/tree-receipt';
@@ -244,13 +244,13 @@ function cmdSealed(root: string, rest: string[], json: boolean): number {
const baseRef = gitOut(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/${remote}/${base}^{commit}`]) ? `refs/remotes/${remote}/${base}` : null;
const remoteUrl = gitOut(root, ['remote', 'get-url', remote]);
const report = 'error' in listed
- ? { status: 'error' as const, error: listed.error, policyLabels: [], commits: 0, findings: [], waived: [], visibility: 'unknown' as const, visibilitySource: '', target: remoteUrl ?? remote }
+ ? sealedErrorReport(listed.error, remoteUrl ?? remote)
: evaluateSealed({ repoRoot: root, targets: [{ commits: listed.commits, pushedSha }], baseRef, baseLabel: `${remote}/${base}`, remoteUrl, target: remoteUrl ?? remote });
const blocks = report.status === 'refused' || report.status === 'error';
if (json) { process.stdout.write(JSON.stringify({ ...report, ok: !blocks }) + '\n'); return blocks ? EXIT.fail : EXIT.ok; }
process.stderr.write(renderSealed(report, 'gstack-pregate sealed'));
const policy = report.policyLabels.length ? report.policyLabels.join(' + ') : `no sealed key on ${baseRef ?? `${remote}/${base}`} or ${end}`;
- process.stdout.write(`SEALED: ${report.status} findings=${report.findings.length} commits=${report.commits} visibility=${report.visibility}${report.visibility === 'unknown' ? ' (counted public)' : ''} waived=${report.waived.length} policy=${policy}\n`);
+ process.stdout.write(`SEALED: ${report.status} findings=${report.findings.length} commits=${report.commits} visibility=${report.visibility}${report.visibility === 'unknown' ? ' (counted public)' : ''} waived=${report.waived.length} exempt=${report.exempt.length} policy=${policy}\n`);
return blocks ? EXIT.fail : EXIT.ok;
}
diff --git a/bin/gstack-redact-prepush b/bin/gstack-redact-prepush
index 252dc4389..80ebddeed 100755
--- a/bin/gstack-redact-prepush
+++ b/bin/gstack-redact-prepush
@@ -43,7 +43,7 @@ import * as path from "path";
import { normalizeWithMap, scan, type Finding, type ScanOptions } from "../lib/redact-engine";
import { mkdirpSync } from "../lib/fs-utils";
import { resolveStateRoot } from "../lib/state-root";
-import { evaluateSealed, publishedCommits, renderSealed, type SealedReport } from "../lib/sealed-guard";
+import { evaluateSealed, publishedCommits, renderSealed, sealedErrorReport, type SealedReport } from "../lib/sealed-guard";
const ZERO = /^0+$/;
let emptyTree: string | undefined;
@@ -608,7 +608,7 @@ function sealedForPush(refs: Array<{ localSha: string; remoteSha: string }>): Se
const targets: Array<{ commits: string[]; pushedSha: string }> = [];
for (const ref of refs) {
const published = publishedCommits(".", { localSha: ref.localSha, remoteSha: ref.remoteSha, remoteName });
- if ("error" in published) return { status: "error", error: published.error, policyLabels: [], commits: 0, findings: [], waived: [], visibility: "unknown", visibilitySource: "", target: remoteUrl ?? "the push target" };
+ if ("error" in published) return sealedErrorReport(published.error, remoteUrl ?? "the push target");
targets.push({ commits: published.commits, pushedSha: ref.localSha });
}
return evaluateSealed({ repoRoot: ".", targets, baseRef: defaultRemoteBranch(), remoteUrl, target: remoteUrl ?? remoteName ?? "the push target" });
diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt
index 6d50425b1..1b24fd1af 100755
--- a/bin/gstack-ship-receipt
+++ b/bin/gstack-ship-receipt
@@ -547,6 +547,8 @@ function cmdCarry(rest: string[], json: boolean): number {
if (!headTree) return refused('RECEIPT_CARRY_REFUSED', `git could not read the tree of HEAD ${head.slice(0, 12)}`);
const headPatch = productPatchAgainstBase(repoRoot, rules, baseRef, head);
if (typeof headPatch !== 'string') return refused('RECEIPT_CARRY_REFUSED', `git could not compute HEAD's product patch-id: ${headPatch.error}`);
+ const reviewedBase = receipt.base.replace(/^origin\//, '');
+ const retarget = reviewedBase !== base.replace(/^origin\//, '');
let plan: ReturnType;
if (!ancestor) {
// Rewritten history: the receipt follows only onto a tree it already covers, with the product diff it recorded.
@@ -565,11 +567,25 @@ function cmdCarry(rest: string[], json: boolean): number {
const testFrom = asId(patchId(repoRoot, fromBase, from, spec.tests));
const testHead = asId(patchId(repoRoot, headBase, head, spec.tests));
for (const r of [productFrom, productHead]) if (typeof r !== 'string') return refused('RECEIPT_CARRY_REFUSED', `git could not compute the product patch-id at one of the heads: ${r.error}`);
- // planCarry names at most CARRY_NAMED_PATHS differing paths, so the per-path comparison (two patch-ids each) stops there,
- // and it walks only the paths the branch itself touched against the base at either head: a base merge brings in every
- // path the base changed, none of which can differ between the two base diffs.
+ // A stacked child whose parent landed on the base (#3570): the receipt reviewed the child's own range against the
+ // parent branch, and the PR now targets the base. That reviewed range (the recorded product patch-id, else the one
+ // against origin/ when it still resolves) must be HEAD's whole product diff against the base, computed
+ // the way write records it (headPatch): the parent fully landed and the child is unchanged. Anything else fails closed.
+ let reviewed = productFrom;
+ if (retarget) {
+ const parentRef = `origin/${reviewedBase}`;
+ const recorded = receipt.product_patch_id !== undefined ? (receipt.product_patch_id === 'none' ? '' : receipt.product_patch_id)
+ : git(['rev-parse', '--verify', '--quiet', `${parentRef}^{commit}`], repoRoot) ? productPatchAgainstBase(repoRoot, rules, parentRef, from) : { error: `the receipt records no product patch-id and ${parentRef} no longer resolves` };
+ if (typeof recorded !== 'string') return refused('RECEIPT_CARRY_REFUSED', `the receipt was reviewed against ${reviewedBase}, not ${base}, and its reviewed product diff is unavailable: ${recorded.error}; run /ship on ${head.slice(0, 12)}`);
+ reviewed = recorded;
+ }
+ const current = retarget ? headPatch : productHead;
const changedProduct: string[] = [];
- if (productFrom !== productHead) {
+ if (retarget && reviewed !== current) changedProduct.push(`HEAD's product diff against ${base} is not the one reviewed against ${reviewedBase} (the parent has not fully landed on ${base}, or the branch changed since)`);
+ else if (productFrom !== productHead && !retarget) {
+ // planCarry names at most CARRY_NAMED_PATHS differing paths, so the per-path comparison (two patch-ids each) stops there,
+ // and it walks only the paths the branch itself touched against the base at either head: a base merge brings in every
+ // path the base changed, none of which can differ between the two base diffs.
const touched = new Set([...(git(['diff', '--name-only', fromBase, from], repoRoot) ?? '').split('\n'), ...(git(['diff', '--name-only', headBase, head], repoRoot) ?? '').split('\n')].filter(Boolean));
for (const p of [...touched].sort()) {
if (changedProduct.length >= CARRY_NAMED_PATHS) break;
@@ -583,7 +599,7 @@ function cmdCarry(rest: string[], json: boolean): number {
// Evidence for the record, never the decision: a slow range-diff is reported unavailable rather than waited for.
const rangeRaw = spawnSync('git', ['range-diff', '--no-color', `${fromBase}..${from}`, `${headBase}..${head}`], { cwd: repoRoot, encoding: 'utf8', timeout: 15_000, maxBuffer: 64 * 1024 * 1024 });
const rangeDiff = rangeRaw.status === 0 ? (rangeRaw.stdout.trim().split('\n').slice(0, 20).join('\n') || '(empty: identical ranges)') : `unavailable: ${(rangeRaw.stderr ?? '').trim().split('\n')[0] || (rangeRaw.signal ? `range-diff killed by ${rangeRaw.signal} after 15 s` : 'range-diff failed')}`;
- plan = planCarry({ from, head, changed, productPatchId: { from: productFrom, head: productHead }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff });
+ plan = planCarry({ from, head, changed, productPatchId: { from: reviewed as string, head: current as string }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff, ...(retarget ? { retargetedFrom: reviewedBase } : {}) });
}
if (!plan.ok) return refused(plan.code, plan.message);
// Eligibility is not publication: a receipt that recorded a pregate is rebound only with a pregate current for HEAD,
@@ -611,7 +627,9 @@ function cmdCarry(rest: string[], json: boolean): number {
if (depthBelow(receipt.review_depth.depth, now.depth)) return refused('RECEIPT_CARRY_REFUSED', `HEAD ${head.slice(0, 12)} needs ${now.depth} review depth (${now.reasons.join(', ')}), above the receipt's ${receipt.review_depth.depth}; run /ship on ${head.slice(0, 12)}`);
depthField = { review_depth: { ...now, depth: deeper(receipt.review_depth.depth, now.depth) } };
}
- const carried = carryReceipt({ ...receipt, ...pregateField, ...depthField }, head, plan.carry, { tree: headTree, fromTree, productPatchId: headPatch });
+ // The rebound product_patch_id is HEAD's diff against the carry's base, so a retargeted receipt names that base from now on.
+ const rebound = { ...receipt, ...(retarget ? { base: base.replace(/^origin\//, '') } : {}), ...(pregateField ?? {}), ...depthField };
+ const carried = carryReceipt(rebound, head, plan.carry, { tree: headTree, fromTree, productPatchId: headPatch });
const errors = validateReceipt(carried);
if (errors.length) return refused('RECEIPT_CARRY_REFUSED', `the carried receipt does not validate: ${errors[0]!.message} at ${errors[0]!.path}`);
const newBody = placeReceiptFirst(body, carried);
diff --git a/captain/templates/brief.md b/captain/templates/brief.md
index d185bc0bf..1f6b0decb 100644
--- a/captain/templates/brief.md
+++ b/captain/templates/brief.md
@@ -14,7 +14,7 @@ Spend cap:
1. Plan (when Plan: required). After the session-start install check, install for autoplan
and follow autoplan/DRIVER.md "On Capy" (launch file sourced, GSTACK_AUTOPLAN_CHECKPOINT_DIR
- set, run dir under ~/.capy/work). Write the plan with its ## Before → after,
+ set, run dir under ~/.capy/work). Write the plan with its ## ELI5, ## Before → after,
## Why it matters, ## What caused this work, ## Big decisions and ## Implementation plan
sections, then run gstack-autoplan next --out --plan
sized to this brief (a one-file fix: --no-ui --no-dx --max-rounds 1 --estimate-usd
@@ -25,7 +25,8 @@ Spend cap:
(gstack-autoplan answer --out --gate-rev --reply --rec-hash
--by agent: --owner-reply ""), incorporate every accepted plan
change (revise), finish every reopened phase, and start product edits only when the run
- reports APPROVED. Then gstack-approvals import --autoplan --by "".
+ reports APPROVED (a later gate that asks nothing new prints CARRIED: and needs no second
+ ask). Then gstack-approvals import --autoplan --by "" (--stack b1,b2 for a stack).
New scope you take on part-way goes back to step 1 with its own plan.
3. Ship. CHANGELOG entry under [Unreleased]; GUARD_CHECKS, pregate and /ship at the final
head; one unstamped READY block (captain/templates/READY.md) to me; the PR body carries
diff --git a/captain/templates/plan-ask.md b/captain/templates/plan-ask.md
index fb298017f..854998ffc 100644
--- a/captain/templates/plan-ask.md
+++ b/captain/templates/plan-ask.md
@@ -7,6 +7,9 @@ shape from an autoplan run; write it by hand for any other plan.
```text
Decision needed on : . Reply **rec** to take every recommendation.
+## ELI5
+<5-10 plain sentences: what changes, for whom, and what it costs>
+
## Before → after
diff --git a/capy/skill/gstack/captain.md b/capy/skill/gstack/captain.md
index a4e6ddccf..057573665 100644
--- a/capy/skill/gstack/captain.md
+++ b/capy/skill/gstack/captain.md
@@ -48,6 +48,7 @@ gstack-approvals add --ask "Spend up to \$40 on a paid eval rerun for the ship c
gstack-approvals add --outcome standing --ask "Merge #3330 when green" \
--words "yes merge when green" --by "Garry, 2026-10-16 (Captain rule)" --scope "every crew PR, through a train"
gstack-approvals import --autoplan --by Garry # after gstack-autoplan answer; idempotent
+gstack-approvals import --autoplan --by Garry --stack b1,b2 # a stacked PR set: every branch, or none
gstack-approvals render # the ## Approvals section
```
@@ -93,15 +94,15 @@ project can follow it:
the plan names (docs, trains, cleanups) included, and carry them through to done with no further ask.
Say in one line what it covers. For an autoplan gate, "do it" answers as `rec`; record the words.
-For an autoplan run, write parts 1 to 4 as `## Before → after`, `## Why it matters`,
-`## What caused this work` and `## Big decisions` sections in the plan. Then, at the gate:
+For an autoplan run, open the plan with `## ELI5` (5-10 plain sentences), then write parts 1 to 4 as
+`## Before → after`, `## Why it matters`, `## What caused this work` and `## Big decisions` sections. Then, at the gate:
```bash
gstack-owner-brief render --captain --out
```
-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
+It prints a header line ("Decision needed … Reply **rec** …"), the `### ELI5` summary (on a run whose gate
+carries `eli5`), 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, 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:
@@ -112,6 +113,11 @@ gstack-autoplan answer --out --gate-rev --reply rec --rec-hash
gstack-approvals import --autoplan --by
```
+A later gate that asks nothing new is answered for you with the words you recorded (`CARRIED:`, then `APPROVED:`);
+the brief says "Nothing pending. Carried forward", so import and build without a second ask. `CARRY_CHECK:` means
+one reviewer from another model family reads the unreviewed delta first (`gstack-autoplan check`). Anything
+else prints `CARRY: no ()` and goes to the person as today (`docs/unattended.md#carry-forward`).
+
If the person answers in words ("split it into two PRs"), map the words to tokens, answer, and confirm in one
line what you recorded ("Recorded: split into two PRs; everything else as recommended"). A plan missing a
section renders "Not ready for approval" with no reply invitation, and exits 1 (`CAPTAIN_BRIEF_INCOMPLETE`):
diff --git a/docs/captain.md b/docs/captain.md
index 7c0b7aadf..e955acfb7 100644
--- a/docs/captain.md
+++ b/docs/captain.md
@@ -138,8 +138,9 @@ this rule shipped cites this section by name.
**What the lane sends.** The plan and its gate in plain language a person outside the
project can follow: what changes, why now, what the change does, what it costs, the lane's
pick, and the recommended one-word reply. `gstack-owner-brief render --captain --out `
-prints that from the plan's `## Before → after`, `## Why it matters`,
-`## What caused this work` and `## Big decisions` sections, with the cost lines
+prints that from the plan's `## ELI5`, `## Before → after`, `## Why it matters`,
+`## What caused this work` and `## Big decisions` sections (also saved as `eli5` in
+`gate.json`, so `gstack-gate render` opens with them), with the cost lines
(`GATE_SPEND:`, `GATE_REC_COST:`) and a last line for the controller; a plan missing a
section exits `CAPTAIN_BRIEF_INCOMPLETE`, so the lane adds the section and renders again
([capy/skill/gstack/captain.md](../capy/skill/gstack/captain.md) § 3 lists them). The lane
@@ -156,10 +157,12 @@ controller and been answered into the run
every accepted plan change has been incorporated (`revise`), every reopened phase has
finished, and the run reports `APPROVED` (`status=complete` after `export`). An answered
gate that still prints `PLAN_AMEND_PENDING` or `REOPENED:` is not a completed plan, and an
-imported approval row alone is not completion.
+imported approval row alone is not completion. A later gate revision that asks nothing new
+is answered with the owner's recorded words and says `CARRIED:`; that is the owner's word,
+not a new ask ([carry-forward](unattended.md#carry-forward)).
-**The record.** `gstack-approvals import --autoplan --by ""` writes the
-approval row (date, the ask, the owner's exact words, the lane that answered as relayer,
+**The record.** `gstack-approvals import --autoplan --by ""` (with
+`--stack ` for a stacked PR set) writes the approval row (date, the ask, the owner's exact words, the lane that answered as relayer,
what it covers); `gstack-approvals render` puts it in the report and the PR body. Before
training the PR the controller compares that row's words and `gate_rev` with the ask it
relayed and the reply it carried back; a row that does not match is not READY (shape A).
diff --git a/docs/capy-install.md b/docs/capy-install.md
index f98f87d6e..7de24710e 100644
--- a/docs/capy-install.md
+++ b/docs/capy-install.md
@@ -155,7 +155,16 @@ and `/proc` start time; elsewhere `ps -o lstart`), because a pid is reused after
a reboot or a machine wake. `bin/gstack-upgrade`, and so the session-start
self-upgrade, refuses with [UPGRADE_BUSY](troubleshooting.md#upgrade-busy)
while a lease held by a live process outside its own chain exists, naming each
-holder as `pid (m )`. The team-mode SessionStart hook
+holder as `pid (m )`. The install-mode self-upgrade also
+defers while its own chain holds a live lease under 120 minutes old. An install
+run from inside a workflow (/ship's docs step running `install.sh --for
+document-release`, #3571) prints `SELF_UPGRADE: skipped (a gstack run in this
+session holds a workflow lease on this installation: ... (UPGRADE_BUSY))`,
+leaves the checkout where the run started and stays ok with `behind=`. Each
+`gstack-skill-start` renews the lease. On Capy the leased pid is the machine's
+long-lived `capy-server`, so the deferral ends when the lease turns 120
+minutes old, not when the process exits; `bin/gstack-upgrade` run by hand
+moves the checkout sooner. The team-mode SessionStart hook
(`bin/gstack-session-update`) honors the same leases: it logs
`SKIP leased pid=/m` to `analytics/session-update.log` and tries again at
the next session start, without writing its hourly throttle stamp. A lease whose
diff --git a/docs/pregate.md b/docs/pregate.md
index 11740fcea..1e8698852 100644
--- a/docs/pregate.md
+++ b/docs/pregate.md
@@ -768,11 +768,13 @@ with a timing flake: never raise the bound.
```json
{ "sealed": {
- "paths": ["heldout/**"],
+ "paths": ["heldout/**", "**/sealed/**"],
"transcripts": ["runs/**/transcript*.jsonl"],
+ "allow_paths": ["**/sealed/*/MANIFEST.json", "**/protected/MANIFEST.json"],
"answer_markers": ["SEALED ANSWER:"],
"seed_patterns": ["shuffle_seed=\\d+"],
- "local_paths": true } }
+ "local_paths": true,
+ "case_insensitive": true } }
```
A repo with a `sealed` key gets a configured local check against accidental
@@ -792,8 +794,38 @@ gstack-pregate sealed [--range ] [--remote origin] [--base ] [--js
in a later commit); `sealed.answer`, `sealed.seed` and `sealed.local_path`
(`/home//`, `/Users//`, `C:\Users\\`) check each commit's added
lines. Findings print rule, `file[:line]` and commit, never the text.
+- **Globs.** `paths`, `transcripts` and `allow_paths` match the whole
+ repo-relative path from the repository root. `*` and `?` stay inside one
+ path segment; `**/` spans zero or more directories; a `**` not followed by
+ `/` matches across directories (`heldout/**` covers every depth under
+ `heldout/`); `[` and `{` are literal. So `sealed/**` misses `tasks/x/sealed/a.json`:
+ to guard a `sealed/` directory wherever it sits, write `**/sealed/**`.
+- **Case.** Matching is case-sensitive. `case_insensitive: true` folds case
+ for `paths` and `transcripts` only, so `**/heldout/**` also catches
+ `HELDOUT/` and `HeldOut/`. Separator and spelling variants (`hold-out/`,
+ `held_out/`, `heldout-v2/`) are not folded: list each as its own glob.
+ The effective value is the base branch's OR the pushed one (it only tightens).
+- **Exceptions.** `allow_paths` exempts publishable files that sit next to
+ sealed data (a `MANIFEST.json` of hashes and counts) from `sealed.path` and
+ `sealed.transcript`, applied after the deny globs and before waivers. They
+ are read from the destination base branch only: an exception a push adds
+ takes effect once it lands on the base branch, so a branch can never widen
+ its own. They never fold case. An entry whose file name contains `**`, or
+ is only wildcards before its extension (`**`, `sealed/*`, `**/*.json`,
+ `sealed/**.json`), is refused; a partial wildcard such as `MANIFEST*.json`
+ is accepted and matches every file it spells, so name the file. An exempt file is checked by the
+ text rules only, so configure `answer_markers` when you exempt files next to
+ sealed data. Each exempt path is listed on every push (`exempt
+ commit (sealed.allow_paths )`) and counted as
+ `exempt=` on the `SEALED:` line.
+- **Version skew.** A gstack older than the release whose CHANGELOG adds
+ `sealed.allow_paths` refuses `allow_paths` and `case_insensitive` as
+ unknown keys, so every push and `gstack-pregate run` in the repository
+ fail on that machine. Raise `min_gstack_version` in
+ `.gstack/ship-policy.json` to that release before landing either key.
- **Policy.** The union of the destination base branch's `sealed` and the
- pushed commit's: a push can tighten it, never relax it. A malformed or
+ pushed commit's: a push can tighten it, never relax it (`allow_paths` come
+ from the base branch alone, and a pushed deny glob cannot override one). A malformed or
unreadable policy refuses. Waivers come only from the base branch's
`.gstack/sealed-allow.tsv` (`rulepathshareason`) and are
listed on every push.
diff --git a/docs/ship-policy.md b/docs/ship-policy.md
index c08973c92..168ccb678 100644
--- a/docs/ship-policy.md
+++ b/docs/ship-policy.md
@@ -953,6 +953,22 @@ receipt when the base moves:
| a merge of the base whose resolution changed product code | a fresh /ship (`RECEIPT_CARRY_PRODUCT_CHANGED`) |
| a rebase or force-push onto a tree the receipt covers, product diff against the new base unchanged | carries (`carry.ancestry: rewritten`) |
| any other rebase or force-push | a fresh /ship (`RECEIPT_CARRY_TREE_MISMATCH`) |
+| a stacked PR whose parent landed (squash or merge), retargeted to the base with the base merged in, HEAD's product diff exactly the one reviewed against the parent branch | carries (reason `stack parent landed`) |
+
+**Stacked PRs (#3570).** A child PR's receipt is written against its parent
+branch, so its `base` names that branch and its `product_patch_id` is the
+child's own range. After the parent lands, the PR is retargeted to the base, and
+/ship's carry check runs with `--base `. When that base differs from the
+receipt's, the carry compares the reviewed range (the recorded
+`product_patch_id`, or for an older receipt the range against
+`origin/` while that branch still resolves) with HEAD's whole product
+diff against the base, computed the way `write` records it, so a parent's
+release-metadata change that never reached the base counts too. Equal means
+the parent fully landed and the child is unchanged, so the receipt carries
+and from then on names the base it was carried to. A parent that has not landed, a child edited
+since, or a parent that landed with changes the child never merged all leave a
+different diff and refuse with `RECEIPT_CARRY_PRODUCT_CHANGED`. An older
+receipt whose parent branch is gone refuses with `RECEIPT_CARRY_REFUSED`.
Since the release that added `review_scope`. A `/ship` receipt names the head it
reviewed, so a conflict-only merge of the base or a test-only fix used to
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index b2fb05f09..e006d53d2 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -1566,11 +1566,19 @@ process has exited never blocks, and neither does a stale one: each lease
records its process's start identity, so a pid reused after a reboot or a
machine wake (GBRA-105's lease named pid 379, which had become dbus-daemon)
is reclaimed instead of blocking. The upgrader's own session (the pid chain it
-runs under) never counts.
+runs under) never counts for `bin/gstack-upgrade` run by hand. A Capy install
+in install mode (`bin/gstack-capy-session`) counts it while that lease is under
+120 minutes old: an install run from inside a workflow that holds the lease
+(/ship's docs step, #3571) prints `SELF_UPGRADE: skipped (a gstack run in this
+session holds a workflow lease on this installation: pid (m
+), so the upgrade waits until the lease is 120 minutes old; fix:
+/bin/gstack-upgrade once the run ends (UPGRADE_BUSY))` and leaves the
+checkout where the run started. Each `gstack-skill-start` renews the lease.
**Fix.** Wait for those sessions to finish (`bin/gstack-lease list` shows
every lease with its age and `alive|dead|stale`; a holder is named as
-`pid (m )`), then re-run `bin/gstack-upgrade`.
+`pid (m )`), then re-run `bin/gstack-upgrade`. For the
+session's own run, run the printed `bin/gstack-upgrade` after the run ends.
When you know they are idle, `GSTACK_UPGRADE_FORCE=1 bin/gstack-upgrade`
proceeds. At a Capy session start the same refusal appears as
`SELF_UPGRADE: skipped (live gstack sessions hold this installation: ...)`,
@@ -1729,8 +1737,11 @@ restate them. Rotate the secret if it was real.
**Meaning.** The record is kept per branch (one Capy thread ships one branch), and
HEAD is detached or the working directory is not a git repository. Exit 3.
+From `import --autoplan --stack ` it means a listed branch exists
+neither locally nor on origin; the line names it, and no branch's record was written.
-**Fix.** Check out the branch the work ships from, or pass `--branch `.
+**Fix.** Check out the branch the work ships from, or pass `--branch `. For
+`--stack`, correct or fetch the named branch and import again.
### `gstack-approvals: ... (APPROVAL_RECORD_UNAVAILABLE)`
@@ -1769,7 +1780,9 @@ import again. Imports are idempotent, so a retry never records an answer twice.
### `gstack-owner-brief render --captain: ... (CAPTAIN_BRIEF_INCOMPLETE)`
**Meaning.** The plan is missing at least one section the brief needs: `## Before → after`,
-`## Why it matters`, `## What caused this work`, `## Big decisions`. The brief still prints,
+`## Why it matters`, `## What caused this work`, `## Big decisions`, and `## ELI5` (alias
+`## In plain words`) on a run whose `gate.json` carries an `eli5` field (gstack 1.91.305 and
+later; older runs render as before). The brief still prints,
but it opens with "Not ready for approval" and has no reply invitation. Exit 1.
**Fix.** Add the named sections to the plan (`gstack-autoplan revise --plan ` if
@@ -2474,6 +2487,14 @@ and an E.164 `+14155551234` still report, so the finding means the id stands
without a run word (`the id is 40269992077`, `40269992077 failed`) or follows
a word that is not one (`see 40323989742`).
+Separately, the scanner skips a bare number that is exactly a power of two
+from 2^30 through 2^49, one less than such a power, or one of eleven 32-bit
+hash and PRNG constants (Knuth's multiplicative hash, the golden-ratio
+constant, the glibc and Numerical Recipes LCGs, FNV-1a, MurmurHash2 and 3, the
+MT19937 seeder and the largest 32-bit prime; `isNumericConstant` in
+`lib/redact-patterns.ts`). Only the exact digits with no separator are exempt:
+the same digits with a `+`, spaces, dashes, dots or parentheses still report.
+
**Fix.** Write the id next to a run word (`run 40269992077`) or as an
`/actions/runs/` link. For anything else, rerun with `--confirm `
using the `TOKEN:` line the refusal printed; MEDIUM findings do not block
@@ -2514,6 +2535,15 @@ An address only another remote knows still reports. If the hook also printed
`existing-email suppression was limited for this push`, it could not read the
history in 5 seconds, so known authors may be listed too.
+A filename with an `@` in the package@version style is not an address and
+never reports: the part after the `@` is a version or tag (one dotless label
+or a dotted version, never a domain name) followed by file extensions no
+top-level domain uses, or a dotted all-digit version followed by
+an extension that doubles as a top-level domain (`looksLikeAtFilename` in
+`lib/redact-patterns.ts`). A file named after a real address, an address at an
+all-digit domain and an address whose top-level domain doubles as a file
+extension still report.
+
**Fix.** Remove the address, or allow it for this repo:
`git config --add gstack.redact.allowEmail `. If it is your own
address, check `git config user.email`. The allowlist only covers `pii.email`;
@@ -3238,7 +3268,10 @@ cannot be downgraded; every other check id in `warn` is honoured only from the
base branch's copy or the user-owned file. For the user-owned file, edit it
directly at the path the message names. A user file inside the repository, or
reached through a symlink into it, is ignored rather than read (`--explain`
-names it).
+names it). An unknown `sealed` key that a newer gstack defines (such as
+`allow_paths` or `case_insensitive`) means this machine's gstack is older than
+the policy: upgrade gstack here, and raise `min_gstack_version` in the
+repository's `.gstack/ship-policy.json` before landing such a key.
### `PREGATE_SECRETS`
@@ -3744,7 +3777,11 @@ finding: rule, `file[:line]`, commit. They never print the matched text.
**Fix.** Remove the output from every published commit before the branch
reaches the public repository (a later commit that deletes it still publishes
-it). A reviewed false positive gets one line in the base branch's
+it). A publishable file kept next to sealed data (a `MANIFEST.json` of hashes
+and counts) can be exempted from the path rules with a `sealed.allow_paths`
+glob in the base branch's `.gstack/pregate.json`; the text rules still read it
+([docs/pregate.md#sealed](pregate.md#sealed)). A reviewed false positive gets
+one line in the base branch's
`.gstack/sealed-allow.tsv`: `rulepathshareason`. A private
destination sets `"visibility": "private"` under `sealed` in the base branch's
`.gstack/pregate.json`. Capy's pull-request tool may not run local hooks, so
@@ -4819,8 +4856,10 @@ to HEAD: the PR body has no valid receipt, the working tree has uncommitted or
untracked changes (a carry certifies a commit, never a tree that differs from
it), the receipt's head left HEAD's history and the receipt records no tree to
compare (a receipt written before `head_tree`, whose head is not in this
-clone), or git could not compare the two product diffs or read the working
-tree's status. It also refuses when an owner exception the receipt records
+clone), the PR was retargeted to the base after its stack parent landed but
+the receipt records no `product_patch_id` and `origin/` no longer
+resolves, so the reviewed range cannot be recomputed, or git could not
+compare the two product diffs or read the working tree's status. It also refuses when an owner exception the receipt records
no longer stands, and, for a receipt from a gstack that records review
depth, when HEAD needs a deeper review than the receipt records, `--head` is
not the checked-out HEAD, or the receipt records `gstack_version` without
@@ -4831,9 +4870,10 @@ ready` refuses `QUEUE_RECEIPT_STALE`.
**Fix.** Commit or stash every working-tree change, then rerun
`gstack-ship-receipt carry --pr ` at the new head. When the history is
already rewritten and the receipt predates `head_tree`, run /ship on the new
-head; next time merge the base in instead of rebasing. For a depth or
-exception refusal, run /ship on the new head (or check out `--head` and carry
-there).
+head; next time merge the base in instead of rebasing. For a retargeted stacked
+PR whose older receipt lost its parent branch, or a depth or exception
+refusal, run /ship on the new head. When `--head` is not the checked-out HEAD,
+check it out and carry there.
### `REVIEW_LOOP_EXCEPTION_INVALID`
@@ -4872,13 +4912,18 @@ registered output the pregate's regen check proved (listed under `proven`,
checked against the same base registry), nor part of the base merge has
a different patch than the reviewed head had. After a rebase it means HEAD has
the reviewed tree but the base moved under it, so the diff a reviewer would see
-is not the reviewed one. The reviewed verdict does not cover HEAD, so the
-receipt is not carried. The refusal names the changed product paths.
+is not the reviewed one. For a stacked PR retargeted to the base after its
+parent landed, it means HEAD's product diff against the base is not the range
+reviewed against the parent branch: the parent has not fully landed, or the
+branch changed since. The reviewed verdict does not cover HEAD, so the
+receipt is not carried. The refusal names the changed product paths, or for a
+retargeted stacked PR the parent branch and the base.
**Fix.** Run /ship on the new head. The receipt carries only across a
conflict-only base merge, a test-only change, release metadata, a registered
-output the pregate's regen check proved, or a rebase onto a covered tree with
-the recorded product diff, which the
+output the pregate's regen check proved, a rebase onto a covered tree with
+the recorded product diff, or a stacked PR's retarget once its parent fully
+landed on the base, which the
`carry` object in the carried receipt records with the product patch-id and
the range-diff evidence ([unattended.md](unattended.md#the-terminal-line)).
diff --git a/docs/unattended.md b/docs/unattended.md
index 293218a72..905de39cc 100644
--- a/docs/unattended.md
+++ b/docs/unattended.md
@@ -504,6 +504,53 @@ by its runner. Result codes: `EXECUTION_UNKNOWN`, `RUN_LOCKED`,
decision ids are renamed and journaled); one written by a newer gstack is
refused.
+### Carry-forward
+
+A gate revision after the first (`gate_rev` 2 and later, written after a
+`revise`) is answered with the owner's recorded words from the revision before
+it when nothing new needs their decision. `gstack-autoplan` prints
+`CARRIED: gate_rev= <- gate_rev= words="" by= reason=`,
+then the usual `APPROVED:` lines, and journals `gate_carried`. The carry needs
+all of: the earlier gate approved (`p1a` or `p1b`) with `rec` or "do it"
+(not item tokens), no item there left pending, and the owner's words
+recorded; that gate was not itself carried; every accepted change incorporated
+or settled; the plan file's bytes equal those recorded at the gate or at the
+`revise` that took the changes (any later edit refuses, an ELI5-only one
+included); no urgent or
+outside-plan finding open; and every new gate item one of the cases below.
+Anything else prints `CARRY: no ()` (journal `carry_refused`) and the gate
+goes to the owner as before. `--no-carry-forward` on the first `next` turns it
+off for the run.
+
+- **"do it".** When the owner's words were "do it" (their words, not a
+ `captain:` relay of them), a new `auto` item that changes no scope, cost or
+ direction, whose recommended option changes no plan text, and is no round
+ or budget grant takes its recommendation. The
+ decision row gets `carried_under: do it`, the output prints
+ `CARRIED_UNDER_DO_IT:` per item, and the approval record lists them as
+ "taken under "do it", not seen by the owner". `rec` carries no new item.
+- **Unreviewed post-cap delta.** A "plan changed after cap" item
+ recommending `a` (accept) is read by one reviewer before it carries. `CARRY_CHECK:` names the attempt and
+ the prompt (the delta against `plan-at-gate.r