--phase --voice native|outside --result | --record [--attest ]
* --model [--attempt ] [--runner ] [--usage-usd ] [--reconcile ] [--json]
@@ -31,10 +31,11 @@
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
-import { amendmentSteps, answerGate, closeDeferred, finishGate, recommendGate } from '../lib/autoplan-gate';
+import { amendmentSteps, answerGate, closeDeferred, recommendGate } from '../lib/autoplan-gate';
+import { checkCeoOnlyFlags, finishRun, refuseCeoOnly } from '../lib/autoplan-ceo-only';
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 { DEFAULT_CARRY_CHECK_CAP_USD, 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';
@@ -57,10 +58,11 @@ import { COORDINATOR_CONTRACT } from '../lib/coordinator-contract';
const HELP = `gstack-autoplan — unattended /autoplan: next/submit loop, answer, resume (docs/unattended.md)
DRIVER: ${driverFile()}
- next --out [--plan ] [--plan-heading ] [--scaffold] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--no-dx] [--docs-only]
+ next --out [--plan ] [--plan-heading ] [--scaffold] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--no-dx] [--docs-only] [--ceo-only]
[--no-outside] [--light] [--spend-cap ] [--max-rounds ] [--estimate-usd ] [--deadline ] [--redispatch ] [--json]
--max-rounds (first next): review rounds per phase (default 2 unattended); at the cap a phase closes accepted-with-findings
--docs-only (first next; also detected when every Implementation-plan path is docs-class): CEO (native voice only) + Eng
+ --ceo-only (first next, command line only): the CEO phase alone, no gate; ends status=reviewed with /ceo-review.md, never approved
--estimate-usd: reserved per new attempt, kept for the rest of the run; with --spend-cap any run (or an unattended run on Capy) reserves a default so the 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
@@ -187,16 +189,18 @@ function doNext(out: string, rest: string[], json: boolean, price?: NextOptions[
const cwd = process.cwd();
const initLines: string[] = [];
const fresh = !fs.existsSync(path.join(out, FILES.run));
+ if (!fresh) checkCeoOnlyFlags(rest, undefined, readState(out));
if (fresh) {
const plan = flag(rest, '--plan');
if (!plan) throw new RunError('RUN_NOT_INITIALIZED', `${out} has no run.json; pass --plan on the first next`, 3);
+ const ceoOnly = checkCeoOnlyFlags(rest, path.resolve(plan), undefined, out);
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,
+ carryForward: !rest.includes('--no-carry-forward'), carryCheckCapUsd, ceoOnly,
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]!] : []));
@@ -253,6 +257,7 @@ function doRevise(out: string, rest: string[], json: boolean): number {
const plan = flag(rest, '--plan'); const incorporates = listFlag(rest, '--incorporates'); const withdraw = listFlag(rest, '--withdraw'); const settle = listFlag(rest, '--settle');
if (!plan && !withdraw.length && !settle.length) return usage('revise needs --plan (and optionally --incorporates ), --withdraw or --settle ');
if (!plan && incorporates.length) return usage('--incorporates binds accepted changes to a revised plan: pass --plan ');
+ refuseCeoOnly(readState(out), 'revise');
const r = reviseRun({ out, plan: plan ? path.resolve(plan) : undefined, incorporates, withdraw, settle, cwd: process.cwd() });
const lines = [...r.lines];
if (r.state.status === 'running' || r.state.status === 'amend_pending') lines.push(nextLine(r.state, out));
@@ -273,7 +278,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(...carryLines(state), ...(state.status === 'gate_pending' ? gateLines(state, out) : [])); }
+ if (!currentPhase(state)) lines.push(...finishRun(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));
@@ -415,7 +420,7 @@ async function main(argv: string[]): Promise {
if (cmd === 'answer') {
const rev = flag(rest, '--gate-rev'); const reply = flag(rest, '--reply'); const by = flag(rest, '--by');
if (reply === undefined) return usage('answer needs --reply ""');
- if (rev === undefined) { const s = readState(out); process.stderr.write(resultLine('gstack-autoplan answer', 'GATE_REV_STALE', `no --gate-rev given; current gate_rev=${s.gate_rev ?? 0}`) + '\n'); return EXIT.refused; }
+ if (rev === undefined) { const s = readState(out); refuseCeoOnly(s, 'answer'); process.stderr.write(resultLine('gstack-autoplan answer', 'GATE_REV_STALE', `no --gate-rev given; current gate_rev=${s.gate_rev ?? 0}`) + '\n'); return EXIT.refused; }
const replyFile = flag(rest, '--owner-reply-file'); const ownerFlag = flag(rest, '--owner-reply');
if (replyFile !== undefined && ownerFlag !== undefined) return usage('--owner-reply and --owner-reply-file are exclusive');
const wordsBy = flag(rest, '--words-by') ?? 'owner';
@@ -423,6 +428,7 @@ async function main(argv: string[]): Promise {
const ownerReply = replyFile !== undefined ? fs.readFileSync(replyFile, 'utf8') : ownerFlag === '-' ? fs.readFileSync(0, 'utf8') : ownerFlag;
return mutating(out, json, () => {
const state = readState(out);
+ refuseCeoOnly(state, 'answer');
const r = answerGate(state, Number(rev), reply, by, { recHash: flag(rest, '--rec-hash'), ownerReply, ...(wordsBy === 'owner' ? {} : { wordsBy }) });
writeState(state);
const lines = [...r.lines];
@@ -437,6 +443,7 @@ async function main(argv: string[]): Promise {
if (rev === undefined || !item || !option || !basis?.trim()) return usage('recommend needs --gate-rev --item --option --basis ""');
return mutating(out, json, () => {
const state = readState(out);
+ refuseCeoOnly(state, 'recommend');
const lines = recommendGate(state, Number(rev), item.toLowerCase(), option.toLowerCase(), basis.trim(), flag(rest, '--by') ?? 'parent');
writeState(state);
emit(json, [...lines, resultLineFor(state.status, out)], { run: state.run, status: state.status, gate_rev: state.gate_rev, out });
diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh
index 33f77842f..960129724 100644
--- a/bin/gstack-doctor-components.sh
+++ b/bin/gstack-doctor-components.sh
@@ -338,6 +338,9 @@ gstack_result_anchor() {
RECEIPT_GIT_TOO_OLD) printf '%s' 'receipt-git-too-old' ;;
CHANGELOG_FRAGMENT_INVALID) printf '%s' 'changelog-fragment-invalid' ;;
QUEUE_LAND_LOCKED) printf '%s' 'queue-land-locked' ;;
+ QUEUE_TRAIN_UNSTAMPED) printf '%s' 'queue-train-unstamped' ;;
+ RESTAMP_NOT_STAGED) printf '%s' 'restamp-not-staged' ;;
+ RESTAMP_UNSTAGED_EDITS) printf '%s' 'restamp-unstaged-edits' ;;
QUEUE_LAND_CONFLICT) printf '%s' 'queue-land-conflict' ;;
QUEUE_LAND_DRIFT) printf '%s' 'queue-land-drift' ;;
QUEUE_LAND_BASE_BUSY) printf '%s' 'queue-land-base-busy' ;;
@@ -375,6 +378,9 @@ gstack_result_anchor() {
OFFICE_HOURS_PROBE_REFUSED) printf '%s' 'office-hours-probe-refused' ;;
TASKS_SUPERSEDE_REFUSED) printf '%s' 'tasks-supersede-refused' ;;
REALITY_RECEIPT_UNRESOLVED) printf '%s' 'reality-receipt-unresolved' ;;
+ CEO_ONLY_CONFLICT) printf '%s' 'ceo-only-conflict' ;;
+ CEO_ONLY_AFTER_INIT) printf '%s' 'ceo-only-after-init' ;;
+ CEO_ONLY_NO_GATE) printf '%s' 'ceo-only-no-gate' ;;
*) return 1 ;;
esac
}
diff --git a/bin/gstack-restamp b/bin/gstack-restamp
index b4922171b..ef6e44c7b 100755
--- a/bin/gstack-restamp
+++ b/bin/gstack-restamp
@@ -10,11 +10,18 @@
* Runs gstack-version-bump write, the policy's release_tool, the CHANGELOG
* re-heading and the stamp_paths rewrites in an isolated staging worktree,
* shows the diff limited to stamp files, and writes the branch only after
- * every stage validated (journal: .gstack/tmp/restamp-journal.json).
- * Idempotent; --dry-run writes nothing. Workflow command: ends with the
+ * every stage validated (journal: .gstack/tmp/restamp-journal.json), then
+ * stages what it wrote (`git add -A -- `, STAGED:), so a bare commit after
+ * a --no-commit base merge cannot drop the stamp (#3531). A stamp file holding
+ * unstaged edits of the caller's own refuses before anything is written
+ * (RESTAMP_UNSTAGED_EDITS); a staging failure leaves the files written and
+ * .gstack/tmp/restamp-staging.json, which the noop rerun stages, or drops when a
+ * file changed since (RESTAMP_NOT_STAGED). Idempotent; --dry-run writes nothing. Workflow command: ends with the
* GSTACK_RESULT line. Logic: lib/restamp.ts; policy: lib/ship-policy.ts.
*/
import { spawnSync } from 'node:child_process';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
import { EXIT, exitTableHelp, formatResult } from '../lib/headless-artifacts';
import { GATE_AHEAD_REL, JOURNAL_REL, restamp, type RestampResult } from '../lib/restamp';
import { RESULT_CODES, type ResultCodeName } from '../lib/result-codes';
@@ -39,7 +46,7 @@ const HELP = `gstack-restamp — stamp the version, release tool, CHANGELOG and
--dry-run print the full diff, write nothing
--json JSON only on stdout
-Prints STAMP_VERSION: , the stage table, the stamp diff and
+Prints STAMP_VERSION: , the stage table, the stamp diff, STAGED: and
GSTACK_RESULT: skill=restamp status=complete|refused|incomplete run=${JOURNAL_REL}.
${exitTableHelp()}`;
@@ -48,6 +55,49 @@ function flag(args: string[], name: string): string | undefined {
return i >= 0 ? args[i + 1] : undefined;
}
+const STAGING_REL = '.gstack/tmp/restamp-staging.json';
+interface Pending { files: string[]; blobs: Record }
+interface Staging { staged?: string[]; dropped?: string[]; failure?: string }
+
+const gitIn = (root: string, args: string[]) => spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 60_000 });
+/** Each file's blob sha in the worktree (one git call), null for a file that does not exist. */
+function blobsOf(root: string, files: string[]): Record {
+ const present = files.filter(f => fs.existsSync(path.join(root, f)));
+ const shas = present.length ? gitIn(root, ['hash-object', '--', ...present]).stdout.trim().split('\n') : [];
+ return Object.fromEntries(files.map(f => [f, present.includes(f) ? shas[present.indexOf(f)] ?? '' : null]));
+}
+function readPending(root: string): Pending | null {
+ try { return JSON.parse(fs.readFileSync(path.join(root, STAGING_REL), 'utf8')) as Pending; } catch { return null; }
+}
+
+/** Files with unstaged edits that are the caller's own (not bytes a pending restamp wrote and could not stage). */
+function ownUnstaged(root: string): string[] {
+ const pending = readPending(root);
+ const unstaged = gitIn(root, ['diff', '--name-only', '-z']).stdout.split('\0').filter(Boolean);
+ const now = pending ? blobsOf(root, unstaged.filter(f => f in pending.blobs)) : {};
+ return unstaged.filter(f => !(pending && f in now && pending.blobs[f] === now[f]));
+}
+
+/**
+ * Stage what restamp wrote (`written`), or what a pending marker says is still unstaged; a marker file whose bytes
+ * changed since (a hand commit, another branch, a base merge) is stale for that file and dropped, never a failure.
+ * Null when there is nothing to stage.
+ */
+function stage(root: string, written: string[] | null): Staging | null {
+ const marker = path.join(root, STAGING_REL);
+ const pending = written ? { files: written, blobs: blobsOf(root, written) } : readPending(root);
+ if (!pending?.files.length) return null;
+ if (written) { fs.mkdirSync(path.dirname(marker), { recursive: true }); fs.writeFileSync(marker, JSON.stringify(pending, null, 2) + '\n'); }
+ const now = written ? pending.blobs : blobsOf(root, pending.files);
+ const dropped = pending.files.filter(f => now[f] !== pending.blobs[f]);
+ const files = pending.files.filter(f => !dropped.includes(f));
+ if (!files.length) { fs.rmSync(marker, { force: true }); return { staged: [], dropped }; }
+ const add = gitIn(root, ['add', '-A', '--', ...files]);
+ if (add.status !== 0) return { failure: `git add of ${files.join(' ')} failed (${(add.stderr || add.stdout).trim().split('\n')[0]}); the stamp stays written and ${STAGING_REL} names it` };
+ fs.rmSync(marker, { force: true });
+ return { staged: gitIn(root, ['diff', '--cached', '--name-only', '-z', '--', ...files]).stdout.split('\0').filter(Boolean), dropped };
+}
+
function main(argv: string[]): number {
if (argv.includes('--help') || argv.includes('-h')) { process.stdout.write(HELP + '\n'); return EXIT.ok; }
const json = argv.includes('--json');
@@ -65,23 +115,32 @@ function main(argv: string[]): number {
const after = flag(argv, '--after');
const base = resolveBaseBranch(repoRoot, flag(argv, '--base'));
- const result = restamp({
+ const options = {
repoRoot, base, version: flag(argv, '--version'), next: argv.includes('--next'), bump, queueMode,
after: after ? { pr: after, ref: flag(argv, '--predecessor-ref') } : undefined, pr: flag(argv, '--pr'),
expectBase: flag(argv, '--expect-base'), was: flag(argv, '--was'), expectOrder: flag(argv, '--expect-order')?.split(',').map(s => s.trim()).filter(Boolean),
policyFrom, allowRepoCommands: argv.includes('--allow-repo-commands'), dryRun: argv.includes('--dry-run'),
failAfter: process.env.GSTACK_RESTAMP_FAIL_AFTER,
- });
- return report(result, json);
+ };
+ const writes = !after && !options.dryRun;
+ const own = writes ? ownUnstaged(repoRoot) : [];
+ const dry = own.length ? restamp({ ...options, dryRun: true }) : null;
+ if (dry && (dry.status === 'refused' || dry.status === 'failed')) return report(dry, json);
+ const clash = (dry?.files ?? []).filter(f => own.includes(f));
+ if (clash.length) return report({ status: 'refused', code: 'RESTAMP_UNSTAGED_EDITS', message: `${clash.join(', ')} ${clash.length === 1 ? 'holds' : 'hold'} unstaged edits of your own, which staging the stamp would sweep into the stamp commit; nothing written`, stages: [] }, json);
+ const result = restamp(options);
+ const staging = writes && (result.status === 'stamped' || result.status === 'noop') ? stage(repoRoot, result.status === 'stamped' ? result.files ?? [] : null) : null;
+ return report(result, json, staging);
}
-function report(r: RestampResult, json: boolean): number {
- const status = r.status === 'refused' ? 'refused' : r.status === 'failed' ? 'incomplete' : 'complete';
- const code = r.status === 'refused' ? EXIT.refused : r.status === 'failed' ? EXIT.fail : EXIT.ok;
+function report(r: RestampResult, json: boolean, staging: Staging | null = null): number {
+ const status = r.status === 'refused' ? 'refused' : r.status === 'failed' || staging?.failure ? 'incomplete' : 'complete';
+ const code = r.status === 'refused' ? EXIT.refused : r.status === 'failed' || staging?.failure ? EXIT.fail : EXIT.ok;
if (json) {
- process.stdout.write(JSON.stringify({ ...r, result: status }) + '\n');
+ process.stdout.write(JSON.stringify({ ...r, ...(staging ?? {}), ...(staging?.failure && !r.code ? { code: 'RESTAMP_NOT_STAGED' } : {}), result: status }) + '\n');
return code;
}
+ if (staging?.failure) process.stderr.write(`gstack-restamp: ${staging.failure}; fix: ${RESULT_CODES.RESTAMP_NOT_STAGED.fix} (RESTAMP_NOT_STAGED)\n`);
if (r.code) {
const row = RESULT_CODES[r.code as ResultCodeName];
process.stderr.write(`gstack-restamp: ${r.message}; fix: ${r.fix ?? row.fix} (${r.code})\n`);
@@ -95,6 +154,8 @@ function report(r: RestampResult, json: boolean): number {
if (r.gateAhead) process.stdout.write(`GATE_AHEAD: head=${r.gateAhead.gated_head} tree=${r.gateAhead.gated_tree} predecessor=#${r.gateAhead.predecessor_pr}@${r.gateAhead.predecessor_head.slice(0, 12)} worktree=${r.gateAhead.worktree} evidence_branch=${r.gateAhead.evidence_branch_key}\n`);
if (r.diff) process.stdout.write(r.diff + (r.diff.endsWith('\n') ? '' : '\n'));
process.stdout.write(`FILES: ${(r.files ?? []).join(' ') || '(none)'}\n`);
+ if (staging?.staged) process.stdout.write(`STAGED: ${staging.staged.join(' ') || '(none)'}\n`);
+ if (staging?.dropped?.length) process.stdout.write(`NOTE: ${staging.dropped.join(' ')} changed since an earlier restamp wrote ${staging.dropped.length === 1 ? 'it' : 'them'}; not staged, and that run's staging marker is dropped\n`);
if (r.mergeCondition) process.stdout.write(`MERGE_CONDITION: sha=${r.mergeCondition}\n`);
if (r.status === 'gate-ahead' && r.gateAhead) process.stdout.write(`next: run the verification gate in ${r.gateAhead.worktree}, then \`gstack-evidence bundle --out \` there; the branch is untouched. At this PR's turn rerun \`gstack-restamp --next\` here and read \`gstack-tree-receipt --from ${GATE_AHEAD_REL}\` before deciding what to rerun.\n`);
else if ((r.status === 'stamped' || r.status === 'noop') && r.queueMode === 'stamp-at-merge') process.stdout.write(`next: NEW_VERSION=${r.newVersion}; 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 ${r.mergeCondition ?? 'sha'} and recompute on a stale queue.\n`);
diff --git a/bin/gstack-ship-queue b/bin/gstack-ship-queue
index f72bd0e8f..738acc9c2 100755
--- a/bin/gstack-ship-queue
+++ b/bin/gstack-ship-queue
@@ -14,7 +14,7 @@
* gstack-ship-queue gate-ahead --list --pr
* gstack-ship-queue verify --pr --screen (train policy: the head's screen verdict from its run)
* gstack-ship-queue verify --post-merge [--timeout ] (after a merge: main's push workflows at that commit until they settle)
- * gstack-ship-queue land --pr [--dry-run] [--no-wait] [--break-lock] (the merge step; lib/ship-queue-land.ts)
+ * gstack-ship-queue land --pr [--dry-run] [--no-wait] [--break-lock [--break-train-lock]] (the merge step; lib/ship-queue-land.ts)
* gstack-ship-queue land --pr --repair (after QUEUE_LAND_DRIFT: open the outside merge's release PR)
* gstack-ship-queue land --census
* gstack-ship-queue train build|status|verdict|bisect|rebuild|watch|prune ... (merge trains; train --help)
@@ -57,7 +57,7 @@ const HELP = `gstack-ship-queue — the shared merge queue's protocol steps (doc
verify --pr --screen (train policy) the PR head's screen verdict read from its run (SCREEN_VERDICT)
verify --post-merge [--timeout ] wait for every check run at a merged commit to settle: POST_MERGE: green|red|pending
and one CHECK: line per workflow; resumable, default timeout 2700 s
- land --pr [--dry-run] [--no-wait] [--timeout ] [--break-lock] [--allow-repo-commands]
+ land --pr [--dry-run] [--no-wait] [--timeout ] [--break-lock [--break-train-lock]] [--allow-repo-commands]
the merge step: lock, merge the base, stamp (fragments folded), push the stamp commit,
wait for its checks, squash with --match-head-commit, confirm the base tree; resumable
land --pr --repair after QUEUE_LAND_DRIFT: open the PR that gives the merge outside land its own
@@ -172,7 +172,7 @@ async function main(argv: string[]): Promise {
if (args.includes('--census')) return finish('land', l.census(ctx));
if (!pr || !/^\d+$/.test(pr)) return usage('land needs --pr (or --census)');
const timeout = flag(args, '--timeout');
- return finish('land', l.land(ctx, { pr, repair: args.includes('--repair'), dryRun: args.includes('--dry-run'), noWait: args.includes('--no-wait'), breakLock: args.includes('--break-lock'), allowRepoCommands: args.includes('--allow-repo-commands'), waitTimeoutMin: timeout && /^\d+$/.test(timeout) ? Number(timeout) : undefined }));
+ return finish('land', l.land(ctx, { pr, repair: args.includes('--repair'), dryRun: args.includes('--dry-run'), noWait: args.includes('--no-wait'), breakLock: args.includes('--break-lock'), breakTrainLock: args.includes('--break-train-lock'), allowRepoCommands: args.includes('--allow-repo-commands'), waitTimeoutMin: timeout && /^\d+$/.test(timeout) ? Number(timeout) : undefined }));
}
if (!pr || !/^\d+$/.test(pr)) return usage(`${cmd} needs --pr `);
const gateExit = flag(args, '--gate-exit');
diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt
index b2d902b65..3de38d032 100755
--- a/bin/gstack-ship-receipt
+++ b/bin/gstack-ship-receipt
@@ -37,6 +37,7 @@ import * as path from 'node:path';
import { EXIT, exitTableHelp, type ShipReceipt } from '../lib/headless-artifacts';
import { resultLine, resultLineWithDocs } from '../lib/result-codes';
import { versionAtRef } from '../lib/restamp';
+import { runScriptSync } from '../lib/script-invocation';
import { readPregateFile, verifyPregate } from '../lib/pregate/index';
import { isUnavailable, loadPolicyFromBase, matchesAny, policyUnavailableLine, resolveBaseBranch, runningGstackVersion, stampFilesInTree, validatePolicy, type ShipPolicy } from '../lib/ship-policy';
import { approvalForReceipt } from '../lib/approval-citations';
@@ -179,7 +180,7 @@ function cmdWrite(rest: string[], json: boolean): number {
const tree = receiptFile ? JSON.parse(fs.readFileSync(receiptFile, 'utf8')) : null;
let sessionKind = flag(rest, '--session-kind') ?? process.env.GSTACK_SESSION_KIND;
if (!sessionKind) {
- const r = spawnSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-session-kind'), [], { encoding: 'utf8', timeout: 15_000 });
+ const r = runScriptSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-session-kind'), [], { encoding: 'utf8', timeout: 15_000 });
sessionKind = r.status === 0 ? r.stdout.trim() || undefined : undefined;
}
let pregate: PregateField | undefined;
diff --git a/captain/templates/slot-call.md b/captain/templates/slot-call.md
index f1908884a..36d70f1db 100644
--- a/captain/templates/slot-call.md
+++ b/captain/templates/slot-call.md
@@ -12,10 +12,12 @@ Run: gstack-restamp --next --base --pr --expect-base --expect-o
Slot 2 and later (and slot 1 when BASE moved): merge BASE into your pin first (expect a
CHANGELOG-only conflict; resolve it with mergeChangelogs from lib/changelog-check.ts, the
one-liner in docs/ship-policy.md's merge-train section, never by hand; grep for conflict
-markers before you stamp). Commit only the BASE merge and the stamp files this call names:
- (plus the file carrying a `*_KEY_VERSION` your PR introduced, on your own
-line only, when this call names it), never a fifth: a file the restamp lists that this call
-does not name is not a stamp, leave it out and say so.
+markers before you stamp). The restamp stages what it writes (STAGED:); commit what it
+staged, which must be only the BASE merge and the stamp files this call names:
+(plus the file carrying a `*_KEY_VERSION` your PR introduced, on your own line only, when this
+call names it), never a fifth: a file the call does not name is not a stamp, `git restore
+--staged --worktree` it and say so. verify reads the head's own STAMP_VERSION and refuses an unstamped
+head (QUEUE_TRAIN_UNSTAMPED).
A pin stacked on a PR that merged earlier conflicts beyond the CHANGELOG where the parent's
squash touched your lines: take your pin's bytes there (verify checks the tree against
T); mergeChangelogs carries your edits to the parent's released entry as Gate integrity
diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md
index 7c87a2786..c6d85c010 100644
--- a/capy/skill/gstack/SKILL.md
+++ b/capy/skill/gstack/SKILL.md
@@ -46,7 +46,7 @@ Capy checklist for `/autoplan`: drive `gstack-autoplan`, never a hand adaptation
Token caps on /ship: pass a brief's cap as `--token-cap `. /ship reads `cost.tokens` and `cost.credits` from `tools.thread.get({ thread_id: "" })` (its own jam; a subagent reading root `"this"` starts `--subagent --reading-from this --meter-scope tree`) inside the same execute program as the `gstack-ship-budget` command that takes them as `--reading --credits `, never alone. `ship/DRIVER.md` starts `--mode observe`: each program ends with `note`, and only a note past the cap stops (`docs/ship-budget.md`).
-/ship on Capy is about a dozen calls, each one execute program (`ship/DRIVER.md`). Each review pass and Step 11 is one call to the checked-in pass program (fill only `input`): `const t0 = await now(); const p = (await tools.capy.bash({ command: ". '' && gstack-review-dispatch program", description: "Pass" })).output.split("\n"); return p[0] + "\n" + (await new (Object.getPrototypeOf(async () => {}).constructor)("tools", "sleep", "now", "input", JSON.parse(p[1]))(tools, sleep, now, { startedAt: t0, launch: "", cwd: "", base: "", out: "/p", pass: "-p", laneModel: "", planFlags: [], logStart: ["review"], jam: "", record: "", step: "9.2" }));` It plans, logs `logStart` (`reservation` mode from `RESERVATION_ID:`), sends each brief on `machine: "shared"`, then polls: one `tools.subagent.list` per ~20 s tick, returning 200 s after the program's first line, where a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run); it starts readers under `rejected` with `tools.subagent.start` as slots free and stops on one stuck 20 minutes. It ends on `NEXT:` (`poll`: rerun) or `STOP:`. `models` maps roles to models (`outside-voice`: another family); Step 11 adds `stage: "voices"`; a full repeat pass, `diffBase: ""`. Steps 7, 8, 9.2.1 and 14.5 (`step: "14.5"`) use `extras`, JSON `{name, prompt_file, first, model}` (prompts verbatim from their sections, Step 7 `first`); they cannot block, so `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (`anthropic/claude-haiku-5-5` also fits). Waits: never let 5 minutes pass between calls. While readers run, never end the turn on `wait`; rerun the loader. Otherwise re-issue `wait` with `duration_seconds: 200` and operation ids. At Step 22 report the `CALLS:` line of `gstack-ship-budget calls --events ` on your jam's transcript since /ship began. A small waiting child saves nothing.
+/ship on Capy is about a dozen calls, each one execute program (`ship/DRIVER.md`). Each review pass and Step 11 is one call to the checked-in pass program (fill only `input`): `const t0 = await now(); const p = (await tools.capy.bash({ command: ". '' && gstack-review-dispatch program", description: "Pass" })).output.split("\n"); return p[0] + "\n" + (await new (Object.getPrototypeOf(async () => {}).constructor)("tools", "sleep", "now", "input", JSON.parse(p[1]))(tools, sleep, now, { startedAt: t0, launch: "", cwd: "", base: "", out: "/p", pass: "-p", laneModel: "", planFlags: [], logStart: ["review"], jam: "", record: "", step: "9.2" }));` It plans, logs `logStart` (`reservation` mode from `RESERVATION_ID:`), sends each brief on `machine: "shared"`, then polls: one `tools.subagent.list` per ~20 s tick, returning 200 s after the program's first line, where a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run); it starts readers under `rejected` with `tools.subagent.start` as slots free and stops on one stuck 20 minutes. It ends on `NEXT:` (`poll`: rerun) or `STOP:`. `models` maps roles to models (`outside-voice`: another family); Step 11 adds `stage: "voices"`; a full repeat pass, `diffBase: ""`. Steps 7, 8 and 14.5 (`step: "14.5"`) use `extras`, JSON `{name, prompt_file, first, model}` (prompts verbatim from their sections, Step 7 `first`); they cannot block, so `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (`anthropic/claude-haiku-5-5` also fits). Waits: never let 5 minutes pass between calls. While readers run, never end the turn on `wait`; rerun the loader. Otherwise re-issue `wait` with `duration_seconds: 200` and operation ids. At Step 22 report the `CALLS:` line of `gstack-ship-budget calls --events ` on your jam's transcript since /ship began. A small waiting child saves nothing.
## Plan, approve, ship
diff --git a/changelog.d/capy-gsta156-flake-window.md b/changelog.d/capy-gsta156-flake-window.md
deleted file mode 100644
index 2beedc310..000000000
--- a/changelog.d/capy-gsta156-flake-window.md
+++ /dev/null
@@ -1,9 +0,0 @@
-**`test:health` and the factory scorecard leave reused main pushes out of their free-suite flake counts (#3725).** Since #3709, a main push whose merged pull request already passed on the same tree reuses that run. It finishes green, runs no shards and uploads nothing, so both readers counted it as a run with zero flakes. As reuse grows, that pulls real flake rates under the 5% bar, and `bun run test:health --enforce` stops flagging files without any error.
-
-### Fixed
-- **One rule for both readers** (`reuseChecker` in `scripts/lib/ci-history.ts`). A run with no artifacts and a green `reuse` job is a reused main push. `readCiFlakeLedgers` leaves it out of the window and its denominator and fills the window with older runs, so it still holds 20 runs that ran their shards. `readFreeRetries` in `lib/factory-scorecard.ts` leaves it out of the free-retry count. Both reports name how many runs they left out.
-- **Bounded and visible.** A run with any artifact costs no extra call. A run with none costs one job listing, at most 40 per read. Past that cap, or when a listing fails, the run counts as it did before and the report says so.
-
-### Tests
-- `test/test-health-report.test.ts`: reused runs among real flaky runs (2 flaky of 20 real runs, with 30 reused) failed on the old code with 2/45 = 4.4%, which passed the 5% bar; now 2/20 = 10% fails it. The lookup cap and a failed lookup both count the run as before, and the report says so.
-- `test/factory-scorecard.test.ts`: two reused runs in the window failed on the old code ("in 1 of 5 runs"); now the free-retry line matches the real runs only ("in 1 of 3 runs") and names the 2 runs it left out.
diff --git a/docs/TESTING_INTERNALS.md b/docs/TESTING_INTERNALS.md
index 2aff20533..ca7c3002c 100644
--- a/docs/TESTING_INTERNALS.md
+++ b/docs/TESTING_INTERNALS.md
@@ -1002,7 +1002,7 @@ with `UBICLOUD: left=<0|1|unknown> owner= vm= others=`, checked
against the project's VM list after teardown; anything but `left=0` turns a
passing command into exit 1. pregate-remote.sh reads that line from the file
named by `UBI_LEFT_FILE`, never from the command's output. No owner-age sweep runs by default (the TTL reaper
-below covers every owner); set
+below covers this owner's expired VMs); set
`UBI_GC_HOURS` to a positive number (or run `gc HOURS`; `gc 0` is a no-op) to
sweep only this owner's VMs older than that. For other commands, use the runner
directly: `scripts/ubicloud/ubi-runner.sh run --setup