--phase --voice native|outside --result | --record [--attest ]
* --model [--attempt ] [--runner ] [--usage-usd ] [--reconcile ] [--json]
@@ -45,6 +46,7 @@ import { capyRelayLine } from '../lib/captain-brief';
import { exportPublic } from '../lib/autoplan-public';
import { migrateRun } from '../lib/autoplan-migrate';
import { reviseRun } from '../lib/autoplan-revise';
+import { FIX_LINES, KINDS, type AutoplanKind } from '../lib/autoplan-lean';
import { pendingAmendments, type RunState, type Voice } from '../lib/autoplan-state';
import { FILES, RunError, currentPhase, fitReservation, initRun, markOutside, markSent, nextStep, readState, resumeRun, retireAttempt, statusLines, submitResult, withRunLock, writeState, type NextOptions } from '../lib/autoplan-run';
import { driverFile, type Phase } from '../lib/autoplan-prompts';
@@ -61,9 +63,16 @@ import { exportShadow, renderShadowExport } from '../lib/autoplan-shadow-reader'
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] [--ceo-only]
+ next --kind fix a bug fix needs no plan review (R65): prints AUTOPLAN: not needed and status=skipped, writes nothing (no --out or --plan);
+ the PR still needs the bug's proof (a repro on current main or a failing test written first)
+ next --out [--plan ] [--kind feature|--full] [--base [] [--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
+ --kind feature (first next; the default for an unattended Capy run, GSTACK_CAPY=1): the lean run (R65): the eng phase alone with one
+ native reviewer whose brief merges the CEO and Eng lenses (Design/DX when scope detects them), reads the draft code (git diff of
+ --base, default origin/HEAD, hashed into the snapshot; DRAFT: none when there is no code yet) and always asks about outside terms;
+ no outside voice, one round, one gate revision (accepted changes apply without re-review), no carry check
+ --full (first next): every phase and both voices, as before R65; off Capy, or in an interactive session, the default stays full
+ --max-rounds (first next): review rounds per phase (default 2 unattended, 1 lean); 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
@@ -88,6 +97,7 @@ DRIVER: ${driverFile()}
--settle records a packaging/ordering change that needs no plan edit and reopens nothing (ids: decision id or d4)
run --out --plan --native-model --outside-model [--runner codex-cli|api] [--native-runner api] ...
next in a loop, only when a runner executes both voices here (hosts without subagents: --native-runner api --runner api);
+ the full run unless --kind feature (the lean run) or --kind fix (no review, nothing written);
--deadline cancels the children it started; --max-tokens caps each api call's output (default 16384; OpenAI counts reasoning tokens in it), and a
response cut off at that limit is reported truncated; submit's result checks judge every completed response
answer --out --gate-rev --reply "" --by > [--json]
@@ -247,7 +257,7 @@ function doNext(out: string, rest: string[], price?: NextOptions['price']): Repo
if (carryCheckCapUsd !== undefined && !(carryCheckCapUsd >= 0)) throw new RunError('ARTIFACT_SCHEMA', '--carry-check-cap needs a number >= 0', 1);
const shadow = shadowSwitch({ outsideEnabled: !rest.includes('--no-outside'), ceoOnly }, process.env);
if (shadow.line) initLines.push(shadow.line);
- initRun({ plan, out, cwd, planHeading: flag(rest, '--plan-heading'), outsideEnabled: !rest.includes('--no-outside'), spendCap: num(rest, '--spend-cap') ?? null, maxRounds,
+ initRun({ plan, out, cwd, kind: flag(rest, '--kind') as AutoplanKind | undefined, full: rest.includes('--full'), base: flag(rest, '--base'), planHeading: flag(rest, '--plan-heading'), outsideEnabled: !rest.includes('--no-outside'), spendCap: num(rest, '--spend-cap') ?? null, maxRounds,
carryForward: !rest.includes('--no-carry-forward'), carryCheckCapUsd, ceoOnly, shadow: shadow.shadow,
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);
}
@@ -342,7 +352,9 @@ function doBudget(out: string, rest: string[], json: boolean): number {
return EXIT.ok;
}
-async function doRun(out: string, rest: string[], json: boolean): Promise {
+async function doRun(out: string, args: string[], json: boolean): Promise {
+ // run pays for both voices, so it starts the full run unless --kind feature asks for the lean one (R65).
+ const rest = args.includes('--kind') ? args : [...args, '--full'];
const nativeModel = flag(rest, '--native-model'); const outsideModel = flag(rest, '--outside-model');
const outsideRunner = (flag(rest, '--runner') ?? 'codex-cli') as RunnerId; const nativeRunner = (flag(rest, '--native-runner') ?? 'api') as RunnerId;
if (!nativeModel || !outsideModel) return usage('run needs --native-model and --outside-model (a runner must execute both voices)');
@@ -444,6 +456,16 @@ async function main(argv: string[]): Promise {
try { process.stdout.write(checkpointLines(from).join('\n') + '\n'); return EXIT.ok; }
catch (e: any) { if (!(e instanceof RunError)) throw e; process.stderr.write(resultLine('gstack-autoplan checkpoints', e.code, e.detail) + '\n'); return EXIT.refused; }
}
+ if ((cmd === 'next' || cmd === 'run') && rest.includes('--kind')) {
+ const kind = flag(rest, '--kind');
+ if (!(KINDS as readonly string[]).includes(kind ?? '')) return usage(`--kind takes fix or feature (got ${JSON.stringify(kind ?? '')})`);
+ if (kind === 'fix') {
+ const live = flag(rest, '--out');
+ if (live && fs.existsSync(path.join(path.resolve(live), FILES.run))) return usage(`--kind fix starts no run, but ${live} already holds a run: continue it without --kind, or pass another --out`);
+ emit(json, FIX_LINES, { skill: 'autoplan', status: 'skipped', kind: 'fix', run: null, lines: FIX_LINES.slice(0, -1) });
+ return EXIT.ok;
+ }
+ }
const outFlag = flag(rest, '--out');
if (!outFlag && cmd !== 'shadow') return usage(`${cmd} needs --out `);
const out = path.resolve(outFlag ?? '.');
diff --git a/bin/gstack-config b/bin/gstack-config
index 17a907bc5..c9e4f72fc 100755
--- a/bin/gstack-config
+++ b/bin/gstack-config
@@ -300,7 +300,9 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne
# # bot owns logins starting with its name and
# # checks mentioning it).
# pr_wait_advisory_bots: coderabbit # review bots gstack-pr-wait reports but
-# # never waits for. Both lists: lowercase names,
+# # never waits for. /review and /ship triage the
+# # comments of every bot in both lists (e.g.
+# # coderabbit,copilot). Both lists: lowercase names,
# # letters, digits, . _ - only; none empties a list.
# # Invalid values are REJECTED (value kept).
#
diff --git a/bin/gstack-diff-scope b/bin/gstack-diff-scope
index 7609aefbf..4f0fb7d04 100755
--- a/bin/gstack-diff-scope
+++ b/bin/gstack-diff-scope
@@ -5,6 +5,9 @@
# Or: gstack-diff-scope --head → the committed range ... only
# (no working tree, no untracked files): what a receipt writer and the
# merge queue compute for one commit, from any checkout (GSTA-147).
+# Or: gstack-diff-scope --head --merged → the same for a head GitHub
+# already merged into the base (land resuming a true merge): the empty range
+# is the answer, so the base-contains-head refusal below does not apply.
#
# Output contract (#2526 — all-false must be distinguishable from "we could
# not look" and from "nothing matched"):
@@ -15,7 +18,11 @@
# trips loudly instead of silently disabling reviewers)
# exit 2 base ref unresolvable → all false + SCOPE_ERROR=no_base
# (shallow CI checkout / missing fetch — a green here would mean
-# "we could not look")
+# "we could not look"; also no default base when origin/HEAD
+# names the checked-out branch and nothing past it resolves)
+# exit 2 base is HEAD or contains it → all false + SCOPE_ERROR=base_contains_head
+# (the branch has commits its real base lacks, so it would be
+# compared with itself, GST-774; the reason as a comment line)
# Every line is shell-safe for `source <(...)` consumers: assignments or
# `#`-comments only.
#
@@ -26,22 +33,37 @@
set -euo pipefail
# Detect the repo's default branch when no arg is given (#703-class
-# platform-agnostic rule): origin/HEAD -> origin/main -> origin/master -> main.
-_default_base() {
- git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||' && return
- git rev-parse --verify -q origin/main >/dev/null 2>&1 && { echo "main"; return; }
- git rev-parse --verify -q origin/master >/dev/null 2>&1 && { echo "master"; return; }
- echo "main"
-}
-BASE="${1:-$(_default_base)}"
+# platform-agnostic rule, lib/default-base.ts): origin/HEAD unless it names the
+# checked-out branch, its upstream or HEAD's commit (a PR flow's clone can point
+# it at the PR branch, GST-511, GST-774) -> the PR's base (gh, 5 s) -> the
+# remote's live HEAD (ls-remote, 5 s) -> origin/main -> origin/master -> main.
+# An origin/HEAD skipped that way with nothing past it resolving leaves BASE
+# empty, and that fails closed; so does a default-base.ts that cannot run.
+GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac
+_default_base_ts() { bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." 2>/dev/null && pwd)/lib/default-base.ts" "$@"; }
+NO_DEFAULT=""
+BASE="${1:-}"
+if [ -z "$BASE" ]; then
+ _rc=0
+ _found=$(_default_base_ts default 2>/dev/null) || _rc=$?
+ case "$_rc" in
+ 0) BASE="$_found" ;;
+ 1) BASE="main" ;;
+ 3) NO_DEFAULT="$_found" ;;
+ *) NO_DEFAULT="lib/default-base.ts could not run (exit $_rc); pass the base explicitly" ;;
+ esac
+fi
HEAD_REF=""
+MERGED_HEAD=false
if [ "${2:-}" = "--head" ]; then HEAD_REF="${3:-}"; fi
+if [ -n "$HEAD_REF" ] && [ "${4:-}" = "--merged" ]; then MERGED_HEAD=true; fi
# A bare branch name reads origin's tracking branch, by its full ref, when that
# shares history with HEAD: /ship merges against origin/, and a linked
# worktree or a Capy clone never moves the local branch, so a stale one widens
# the scope (#3650). A remote-qualified name is taken as given.
_NOT_BRANCH='^[0-9a-f]{7,64}$|[~^]|@\{|^[A-Z_]*HEAD$|^refs/'
_QUALIFIED=false
+case "$BASE" in origin/*) _QUALIFIED=true ;; esac
for _remote in $(git remote 2>/dev/null); do
case "$BASE" in "$_remote"/*) _QUALIFIED=true ;; esac
done
@@ -118,9 +140,17 @@ _print_flags() {
echo "SCOPE_AUTH=$AUTH"
}
+if [ -z "$BASE" ]; then
+ _print_flags
+ echo "SCOPE_ERROR=no_base"
+ echo "# ${NO_DEFAULT}"
+ exit 2
+fi
+
# Base reachability (#2526): a shallow CI checkout or an unfetched ref makes
# `git diff` return an empty list — all-false with exit 0, a green that means
-# "we could not look". Distinguish it before diffing.
+# "we could not look". Distinguish it before diffing. A branch name that does
+# not resolve here reads its origin twin (_resolve_base; a PR checkout has no local main).
BASE_SHA=$(_resolve_base "$BASE" "${HEAD_REF:-HEAD}" || true)
if [ -z "$BASE_SHA" ]; then
_print_flags
@@ -129,6 +159,21 @@ if [ -z "$BASE_SHA" ]; then
exit 2
fi
+# A base that is the head or already contains it reads the branch against
+# itself, all false with exit 0 (GST-774). lib/default-base.ts decides whether
+# that is the base branch itself or a fresh branch (today's output) or a
+# refusal; it is asked only when git says the base contains the head.
+if [ "$MERGED_HEAD" = false ] && git merge-base --is-ancestor "${HEAD_REF:-HEAD}" "${BASE_SHA}" >/dev/null 2>&1; then
+ _rc=0
+ _why=$(_default_base_ts check "$BASE" "${HEAD_REF:-HEAD}" gstack-diff-scope 2>/dev/null) || _rc=$?
+ if [ "$_rc" -ne 0 ]; then
+ _print_flags
+ echo "SCOPE_ERROR=base_contains_head"
+ echo "# ${_why:-lib/default-base.ts could not check the base (exit $_rc) (BASE_CONTAINS_HEAD)}"
+ exit 2
+ fi
+fi
+
# Changed files, NUL-delimited (#2526 minor: `git diff --name-only` octal-quotes
# non-ASCII paths, and the trailing quote defeats extension globs; -z avoids it).
FILES_LIST=()
diff --git a/bin/gstack-docs-candidate b/bin/gstack-docs-candidate
index 517541019..a7e04e6ed 100755
--- a/bin/gstack-docs-candidate
+++ b/bin/gstack-docs-candidate
@@ -18,7 +18,9 @@
* untracked files). It prints a short summary; the JSON file is the record.
* --exception names the blocked .gstack/tmp/docs-audit.json an owner accepted
* (Step 14.5 Blocked recovery): the record then carries that exception, so
- * Step 16 can tell an accepted risk from a skipped audit.
+ * Step 16 can tell an accepted risk from a skipped audit. A --base that is HEAD
+ * or already contains it while the branch has commits its real base lacks is
+ * refused (BASE_CONTAINS_HEAD, lib/default-base.ts): it would select nothing.
*
* compare re-reads the same state and prints one JSON object: whether HEAD or
* the index changed, which recorded paths' content changed or disappeared, and
@@ -35,6 +37,8 @@
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
+import { baseContainsHead } from '../lib/default-base';
+import { resultLine } from '../lib/result-codes';
import { parseReceiptDocs, type ReceiptDocs } from '../lib/ship-receipt-docs';
function git(args: string[]): string {
@@ -135,6 +139,8 @@ function main(argv: string[]): void {
const mode = one(args, '--mode');
if (mode !== 'edit' && mode !== 'read-only') throw new Error('--mode must be edit or read-only');
const base = git(['rev-parse', '--verify', one(args, '--base') + '^{commit}']).trim();
+ const self = baseContainsHead(top, base);
+ if (self) throw new Error(resultLine('snapshot', 'BASE_CONTAINS_HEAD', self));
const exceptionFile = flag(args, '--exception')[0];
const exception = exceptionFile === undefined ? undefined : acceptedException(exceptionFile);
process.chdir(top);
diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh
index 6fe734001..23db8f80d 100644
--- a/bin/gstack-doctor-components.sh
+++ b/bin/gstack-doctor-components.sh
@@ -305,6 +305,7 @@ gstack_result_anchor() {
BASE_RED_REPRO_ERROR) printf '%s' 'base-red-repro-error' ;;
BASE_RED_PAUSE_EXPIRED) printf '%s' 'base-red-pause-expired' ;;
BASE_RED_WAIT_TIMEOUT) printf '%s' 'base-red-wait-timeout' ;;
+ BASE_CONTAINS_HEAD) printf '%s' 'base-contains-head' ;;
QUEUE_RECEIPT_DEPTH_RAISED) printf '%s' 'queue-receipt-depth-raised' ;;
QUEUE_RECEIPT_EXCEPTION_INVALID) printf '%s' 'queue-receipt-exception-invalid' ;;
QUEUE_RECEIPT_TARGETED_MISSING) printf '%s' 'queue-receipt-targeted-missing' ;;
@@ -348,6 +349,9 @@ gstack_result_anchor() {
PR_WAIT_BLOCKED) printf '%s' 'pr-wait-blocked' ;;
PR_WAIT_REFUSED) printf '%s' 'pr-wait-refused' ;;
PREGATE_HEAD_POLICY_LOOSENS) printf '%s' 'pregate-head-policy-loosens' ;;
+ REVIEW_DIFF_TOO_LARGE) printf '%s' 'review-diff-too-large' ;;
+ REVIEW_DIFF_UNREADABLE) printf '%s' 'review-diff-unreadable' ;;
+ REVIEW_EXCLUDE_REFERENCE) printf '%s' 'review-exclude-reference' ;;
APPROVAL_RECORD_INVALID) printf '%s' 'approval-record-invalid' ;;
APPROVAL_RECORD_SECRET) printf '%s' 'approval-record-secret' ;;
APPROVAL_RECORD_NO_BRANCH) printf '%s' 'approval-record-no-branch' ;;
diff --git a/bin/gstack-gate-diff b/bin/gstack-gate-diff
index 400a9a56d..6025ecaf9 100755
--- a/bin/gstack-gate-diff
+++ b/bin/gstack-gate-diff
@@ -19,9 +19,13 @@
* /review's review_binding and make /ship loop).
*
* Exit: 0 with or without hunks. 2 only when the base is unresolvable
- * (`GATE_ERROR=no_base ref=][ fix=` as the only stdout line)
- * or the tool itself failed (`GATE_ERROR=internal …`). Unmatched paths are
- * counted, never an exit 2.
+ * (`GATE_ERROR=no_base ref=][ fix=` as the only stdout line;
+ * `ref=/HEAD target=/` when no base is given, that
+ * remote's HEAD names the checked-out branch and no other default resolves),
+ * when the base is HEAD or already contains it while the branch has commits
+ * its real base lacks (`GATE_ERROR=base_contains_head ref=][`, the
+ * BASE_CONTAINS_HEAD line on stderr), or the tool itself failed
+ * (`GATE_ERROR=internal …`). Unmatched paths are counted, never an exit 2.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
@@ -31,6 +35,8 @@ import {
commitInfo, commitsInRange, defaultBase, diffCommit, diffWorkingTree, firstParent, headSha, mergeBase, noBaseFix, readClaudeMd, repoRoot,
resolveBase, resolvesToCommit, treePaths, untrackedAsAdded,
} from '../lib/gate-diff/git';
+import { baseContainsHead, noDefaultBaseLine } from '../lib/default-base';
+import { resultLine } from '../lib/result-codes';
import { renderDiagnostics, renderJsonl, renderJsonlBody, renderTable } from '../lib/gate-diff/render';
import { resolveStateRoot } from '../lib/state-root';
import { runScriptSync, wtreeFingerprint } from '../lib/script-invocation';
@@ -142,7 +148,14 @@ function main(argv: string[]): number {
candidate = sha.slice(0, 12);
specIsTestPath = specDirectoryHoldsSpecs(treePaths(root, sha));
} else {
- const baseRef = parsed.base ?? defaultBase(root);
+ const found = parsed.base !== undefined ? { ref: parsed.base } : defaultBase(root);
+ if ('skipped' in found) {
+ const { remote, branch } = found.skipped;
+ console.error(`gstack-gate-diff: ${noDefaultBaseLine(found.skipped)}`);
+ process.stdout.write(`GATE_ERROR=no_base ref=${remote}/HEAD target=${remote}/${branch} fix=git remote set-head ${remote} \n`);
+ return 2;
+ }
+ const baseRef = found.ref;
const head = headSha(root);
const base = resolveBase(baseRef, root, head ?? undefined);
if (!base || !head) {
@@ -150,12 +163,18 @@ function main(argv: string[]): number {
return 2;
}
if (base.local === 'fallback') console.error(`gstack-gate-diff: ${base.unrelatedRemote ? `${base.unrelatedRemote} shares no history with HEAD` : `no remote-tracking branch for ${baseRef}`}; base is the local ${baseRef}, which may lag its remote`);
- else if (base.local) console.error(`gstack-gate-diff: ${base.local === 'unrelated' ? `local ${baseRef} shares no history with HEAD` : base.local === 'differs' ? `local ${baseRef} is not ${base.ref}` : `no local branch ${baseRef}`}; base is ${base.ref}`);
+ else if (base.local) console.error(`gstack-gate-diff: ${{ unrelated: `local ${baseRef} shares no history with HEAD`, missing: `no local branch ${baseRef}`, behind: `local ${baseRef} is behind ${base.ref}`, ahead: `local ${baseRef} is ahead of ${base.ref}`, diverged: `local ${baseRef} has diverged from ${base.ref}` }[base.local]}; base is ${base.ref}`);
const mb = mergeBase(base.sha, head, root);
if (!mb) {
process.stdout.write(`GATE_ERROR=no_base ref=${baseRef} fix=${noBaseFix(baseRef, root)}\n`);
return 2;
}
+ const self = mb === head ? baseContainsHead(root, base.ref) : null;
+ if (self) {
+ console.error(resultLine('gstack-gate-diff', 'BASE_CONTAINS_HEAD', self));
+ process.stdout.write(`GATE_ERROR=base_contains_head ref=${baseRef}\n`);
+ return 2;
+ }
files = [...diffWorkingTree(mb, root), ...untrackedAsAdded(root, root)];
commits = commitsInRange(mb, head, root);
const fp = wtreeFingerprint({ cwd: root });
diff --git a/bin/gstack-gbrain-detect b/bin/gstack-gbrain-detect
index e79f4d558..0183f549d 100755
--- a/bin/gstack-gbrain-detect
+++ b/bin/gstack-gbrain-detect
@@ -45,6 +45,7 @@ import {
readGbrainVersion,
} from "../lib/gbrain-local-status";
import { gbrainConfigDir, isTransactionModePooler } from "../lib/gbrain-exec";
+import { classifyGbrainMcpEntry, classifyGbrainMcpListLine, type GbrainMcpEntry } from "../lib/gbrain-mcp-locality";
import { resolveStateRoot } from "../lib/state-root";
const STATE_DIR = resolveStateRoot();
@@ -154,7 +155,19 @@ function detectBrainGit(): boolean {
// 1. `claude mcp get gbrain --json` — public CLI surface, structured output
// 2. `claude mcp list` text-grep — older claude versions without --json
// 3. `~/.claude.json` jq read — last resort if `claude` isn't on PATH
-function detectMcpMode(): "local-stdio" | "remote-http" | "none" {
+//
+// #3539: an HTTP entry on a loopback host whose local gbrain config has an
+// engine and no remote_mcp marker is a resident `gbrain serve --http` of this
+// machine's own brain. It reports "local-stdio" (the local-engine mode every
+// consumer already understands: memory ingest imports, sync-gbrain skips the
+// remote trust-policy question) rather than a new value.
+type McpMode = "local-stdio" | "remote-http";
+function classify(entry: GbrainMcpEntry): McpMode | null {
+ const c = classifyGbrainMcpEntry(entry);
+ return c === "remote" ? "remote-http" : c === "local" ? "local-stdio" : null;
+}
+
+function detectMcpMode(): McpMode | "none" {
const claudeOnPath = tryExec("sh", ["-c", "command -v claude"], 1_000) !== null;
if (claudeOnPath) {
// Tier 1: `claude mcp get gbrain --json`
@@ -167,11 +180,8 @@ function detectMcpMode(): "local-stdio" | "remote-http" | "none" {
command?: string;
url?: string;
};
- const mtype = parsed.type || parsed.transport || "";
- if (mtype === "http" || mtype === "sse") return "remote-http";
- if (mtype === "stdio") return "local-stdio";
- if (parsed.url) return "remote-http";
- if (parsed.command) return "local-stdio";
+ const c = classify(parsed);
+ if (c) return c;
} catch {
// fall through
}
@@ -179,34 +189,28 @@ function detectMcpMode(): "local-stdio" | "remote-http" | "none" {
// Tier 2: `claude mcp list` text-grep. Name-pattern generalized (#2051):
// a gbrain server registered as e.g. "gbrain-remote" or "gbrain_work"
// still counts. Anchored to the gbrain token so unrelated servers can't
- // false-positive.
+ // false-positive. The exact `gbrain:` line wins over a variant, the same
+ // order as tier 3 (and memory-ingest, which reads mcpServers.gbrain):
+ // {gbrain-a: loopback, gbrain: remote} is remote-http.
const list = tryExec("claude", ["mcp", "list"], 3_000);
if (list) {
- const line = list.split("\n").find((l) => /^gbrain([-_][\w-]*)?:/.test(l));
+ const lines = list.split("\n");
+ const line =
+ lines.find((l) => l.startsWith("gbrain:")) ?? lines.find((l) => /^gbrain[-_][\w-]*:/.test(l));
if (line) {
- if (/\b(http|HTTP)\b/.test(line)) return "remote-http";
- return "local-stdio";
+ // The list line carries the URL for HTTP and SSE servers
+ // ("gbrain: http://127.0.0.1:3131/mcp (HTTP) - ✓ Connected"), so the
+ // loopback rule (#3539) applies here too. The whole URL token is
+ // classified, never cut at ")"; an HTTP or SSE line without a usable
+ // URL keeps the old remote-http answer.
+ return classifyGbrainMcpListLine(line) === "local" ? "local-stdio" : "remote-http";
}
}
}
// Tier 3: read ~/.claude.json directly
- interface McpServerEntry {
- type?: string;
- transport?: string;
- command?: string;
- url?: string;
- }
const cj = tryReadJSON(CLAUDE_JSON) as
- | { mcpServers?: Record }
+ | { mcpServers?: Record }
| null;
- const classify = (entry: McpServerEntry): "local-stdio" | "remote-http" | null => {
- const mtype = entry.type || entry.transport || "";
- if (mtype === "url" || mtype === "http" || mtype === "sse") return "remote-http";
- if (mtype === "stdio") return "local-stdio";
- if (entry.url) return "remote-http";
- if (entry.command) return "local-stdio";
- return null;
- };
const servers = cj?.mcpServers || {};
const exact = servers["gbrain"];
if (exact) {
diff --git a/bin/gstack-memory-ingest.ts b/bin/gstack-memory-ingest.ts
index b2a69fe1a..dba4605fe 100644
--- a/bin/gstack-memory-ingest.ts
+++ b/bin/gstack-memory-ingest.ts
@@ -69,6 +69,7 @@ import {
withErrorContext,
} from "../lib/gstack-memory-helpers";
import { execGbrainText, gbrainConfigDir, spawnGbrain, spawnGbrainAsync } from "../lib/gbrain-exec";
+import { classifyGbrainMcpEntry, type GbrainMcpEntry } from "../lib/gbrain-mcp-locality";
import { constrainSourceId, parseSourcesList } from "../lib/gbrain-sources";
import {
BISECT_AFTER_REFUSALS,
@@ -427,9 +428,19 @@ function fileSha256(path: string): string {
}
}
+/**
+ * #3539: a page staged for a remote brain's pull (`staged` / `remote-http`)
+ * was never imported here. Once the brain is local (a loopback serve, or the
+ * entry switched back to stdio), that page has not landed, so it must be
+ * prepared again instead of counting as unchanged.
+ */
+function stagedForRemotePull(entry: StateEntry | undefined): boolean {
+ return !!entry && entry.status === "staged" && entry.source_id === "remote-http" && !isRemoteHttpMcpMode();
+}
+
function fileChangedSinceState(path: string, state: IngestState, verifyHash = false): boolean {
const entry = state.sessions[path];
- if (isRetryable(entry)) return true;
+ if (isRetryable(entry) || stagedForRemotePull(entry)) return true;
try {
const st = statSync(path);
const mtimeNs = Math.floor(st.mtimeMs * 1e6);
@@ -1418,7 +1429,7 @@ async function probeMode(args: CliArgs): Promise {
totalBytes += size;
const entry = state.sessions[path];
- if (!entry) newCount++;
+ if (!entry || stagedForRemotePull(entry)) newCount++;
else if (fileChangedSinceState(path, state, args.scanSecrets)) updatedCount++;
else unchangedCount++;
}
@@ -1800,23 +1811,27 @@ function makePersistentTranscriptDir(): string {
*
* Reads ~/.claude.json directly (same fallback chain as gstack-gbrain-detect
* Tier 3). Cheap: one fs read, no fork-exec.
+ *
+ * #3539: an HTTP entry on a loopback host that reaches this machine's own
+ * gbrain engine (a resident `gbrain serve --http`) is not remote; see
+ * lib/gbrain-mcp-locality.ts. Memoized: the change check consults it per
+ * state entry, and ~/.claude.json can be megabytes.
*/
+let remoteHttpMcpMode: boolean | undefined;
function isRemoteHttpMcpMode(): boolean {
+ remoteHttpMcpMode ??= readRemoteHttpMcpMode();
+ return remoteHttpMcpMode;
+}
+
+function readRemoteHttpMcpMode(): boolean {
const home = process.env.HOME || homedir();
const claudeJsonPath = join(home, ".claude.json");
if (!existsSync(claudeJsonPath)) return false;
try {
const parsed = JSON.parse(readFileSync(claudeJsonPath, "utf-8")) as {
- mcpServers?: {
- gbrain?: { type?: string; transport?: string; url?: string };
- };
+ mcpServers?: { gbrain?: GbrainMcpEntry };
};
- const entry = parsed.mcpServers?.gbrain;
- if (!entry) return false;
- const mtype = entry.type || entry.transport || "";
- if (mtype === "url" || mtype === "http" || mtype === "sse") return true;
- if (entry.url) return true;
- return false;
+ return classifyGbrainMcpEntry(parsed.mcpServers?.gbrain) === "remote";
} catch {
return false;
}
diff --git a/bin/gstack-pregate b/bin/gstack-pregate
index 4a6671b46..3e31ae8ff 100755
--- a/bin/gstack-pregate
+++ b/bin/gstack-pregate
@@ -26,6 +26,7 @@
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
+import { baseContainsHead } from '../lib/default-base';
import { EXIT, exitTableHelp, formatResult, type PregateFile } from '../lib/headless-artifacts';
import { PREGATE_CONFIG_PATH, explainConfig, loadConfigFromHead } from '../lib/pregate/config';
import { describeDraft, draftConfig } from '../lib/pregate/init-draft';
@@ -71,7 +72,8 @@ const HELP = `gstack-pregate — cheap checks before anything paid or remote (do
--stage preflight|tests|all default all
--only restrict to these checks (${CHECK_IDS.join(', ')}; tier 2 later: ${PREGATE_TIER2.join(', ')})
- --base a branch on origin, not a commit (default: origin/HEAD's branch, else main)
+ --base a branch on origin, not a commit (default: origin/HEAD's branch unless it is the
+ checked-out one, else the PR's base, origin/main, origin/master; lib/default-base.ts)
--allow-repo-commands run pin-file commands and the test runner (what /ship passes); without it those checks are incomplete
--policy-from base|head base (default, reviewed) or head (unreviewed; needs --allow-repo-commands; the head file may
only add test lanes over the base copy, else PREGATE_HEAD_POLICY_LOOSENS)
@@ -268,6 +270,8 @@ function cmdSealed(root: string, rest: string[], json: boolean): number {
if (!pushedSha) { process.stderr.write(`gstack-pregate sealed: ${end} is not a commit\n`); return EXIT.usage; }
const listed = range ? rangeCommits(root, range) : publishedCommits(root, { localSha: pushedSha, remoteName: remote });
const baseRef = gitOut(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/${remote}/${base}^{commit}`]) ? `refs/remotes/${remote}/${base}` : null;
+ const self = baseRef && baseContainsHead(root, baseRef);
+ if (self) { process.stderr.write(`${resultLine('gstack-pregate sealed', 'BASE_CONTAINS_HEAD', self)}; the policy would be the branch's own\n`); return EXIT.refused; }
const remoteUrl = gitOut(root, ['remote', 'get-url', remote]);
const report = 'error' in listed
? sealedErrorReport(listed.error, remoteUrl ?? remote)
diff --git a/bin/gstack-retro-metrics b/bin/gstack-retro-metrics
index d49abd3cb..2d4155abd 100755
--- a/bin/gstack-retro-metrics
+++ b/bin/gstack-retro-metrics
@@ -448,6 +448,7 @@ fi
# ── Aux inputs (presence only — the model Reads what exists) ─────────────────
[ -f "$_GH/retro-context.md" ] && echo "RETRO_CONTEXT: present ($_GH/retro-context.md)" || echo "RETRO_CONTEXT: absent"
[ -f "$_GH/greptile-history.md" ] && echo "GREPTILE_HISTORY: present ($_GH/greptile-history.md)" || echo "GREPTILE_HISTORY: absent"
+[ -f "$_GH/review-bot-history.md" ] && echo "REVIEW_BOT_HISTORY: present ($_GH/review-bot-history.md)" || echo "REVIEW_BOT_HISTORY: absent"
[ -f "TODOS.md" ] && echo "TODOS_FILE: present (TODOS.md)" || echo "TODOS_FILE: absent"
[ -f "$_GH/analytics/skill-usage.jsonl" ] && echo "SKILL_USAGE_LOG: present ($_GH/analytics/skill-usage.jsonl)" || echo "SKILL_USAGE_LOG: absent"
[ -f "$_GH/analytics/eureka.jsonl" ] && echo "EUREKA_LOG: present ($_GH/analytics/eureka.jsonl)" || echo "EUREKA_LOG: absent"
diff --git a/bin/gstack-review-bots b/bin/gstack-review-bots
new file mode 100755
index 000000000..3adc6178e
--- /dev/null
+++ b/bin/gstack-review-bots
@@ -0,0 +1,27 @@
+#!/usr/bin/env -S bun --no-env-file --config=/dev/null
+/**
+ * gstack-review-bots — the PR review-bot comments /review and /ship triage (fetch, record).
+ *
+ * gstack-review-bots fetch [--pr ]
+ * gstack-review-bots record --dir --id --type fix|fp|already-fixed --category
+ *
+ * The bots are gstack-pr-wait's lists: gstack-config set pr_wait_gating_bots / pr_wait_advisory_bots.
+ * Logic: lib/review-bots.ts.
+ */
+import * as os from "os";
+import * as path from "path";
+import { spawnSync } from "child_process";
+import { runReviewBots } from "../lib/review-bots";
+
+process.exitCode = runReviewBots(process.argv.slice(2), {
+ run(cmd, args) {
+ const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 120_000, maxBuffer: 64 * 1024 * 1024 });
+ return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? (r.error ? String(r.error) : "") };
+ },
+ vars: process.env,
+ binDir: path.resolve(import.meta.dir),
+ tmpDir: process.env.TMPDIR || os.tmpdir(),
+ now: () => Date.now(),
+ out: (t) => process.stdout.write(t),
+ err: (t) => process.stderr.write(t),
+});
diff --git a/bin/gstack-review-dispatch b/bin/gstack-review-dispatch
index 6ca52ac1a..fd121faf1 100755
--- a/bin/gstack-review-dispatch
+++ b/bin/gstack-review-dispatch
@@ -26,12 +26,14 @@ import type { Dispatch } from '../lib/review-depth';
import { runScriptSync } from '../lib/script-invocation';
import { TARGETED_RULES, parseClassList, targetedBriefSha, targetedRead } from '../lib/review-targeted';
import { resolveBaseBranch } from '../lib/ship-policy';
+import { REVIEW_DIFF_MAX_BYTES, ceilingContext, disclosureDigest, disclosureLine, parseTail, readSelection, tooLargeLine, unreadableLine, type PlanExclude, type ReaderDiff } from '../lib/review-exclude';
const HELP = `gstack-review-dispatch — verbatim reviewer briefs and loss-free collection for /ship
render --role [--name ] --out [--ledger ] [slots]
roles: ${BRIEF_ROLES.join(', ')}
slots: --diff-base (every role but fix-check) --exclude '' --stack ] --test-fw
+ --keep '>' --disclosure '' (both only under review_exclude_paths)
--learnings-file --checklist (default /review/specialists/.md; red-team.md for red-team)
--checklists (specialist-batched: the plan's checklists= names, or paths) --findings-file --specialists (red-team)
--fix-list --hunks --tests [--callers ] (fix-check)
@@ -58,7 +60,9 @@ const HELP = `gstack-review-dispatch — verbatim reviewer briefs and loss-free
verify --ledger [--targeted-out ]
BRIEF, OUTPUT and JUDGE rows from the ledger alone, then VERIFY: clean (exit 0), VERIFY: dirty (exit 1) or
VERIFY: no brief records (exit 3: this run's ledger kept no prompts). A ledger with a targeted brief also prints
- TARGETED_RECORD: and --targeted-out writes it as the JSON gstack-ship-receipt write --review-targeted reads
+ TARGETED_RECORD: and --targeted-out writes it as the JSON gstack-ship-receipt write --review-targeted reads; under
+ review_exclude_paths prints REVIEW_EXCLUDE_RECORD: digest= (the selection every reader's newest brief names: the
+ value gstack-ship-receipt write --review-exclude takes), and a mixed set of digests makes it dirty
pass --base [ --out --pass [--ledger ] [--diff-base ] [--learnings-file ] [--extras ] [--audit-only]
[--checklist-root ] [--fix-list --hunks --tests [--callers ]] [-- ]
runs gstack-review-plan and writes /pass-manifest.json: stage first (audit readers marked first in --extras),
@@ -68,6 +72,11 @@ const HELP = `gstack-review-dispatch — verbatim reviewer briefs and loss-free
--diff-base sets every reader's base, a scoped pass's Red Team included (the targeted reader keeps the merge base).
An existing manifest for the same --pass is reused (resumed). --extras: [{"name","prompt_file","first"?,"model"?}]
--audit-only skips the plan: the --extras readers are the whole pass (Step 14.5's docs audit)
+ Before anything is written (and again before stage renders), each reviewer's diff is read once with a
+ ${REVIEW_DIFF_MAX_BYTES}-byte ceiling: over it prints REVIEW_DIFF_TOO_LARGE with the largest folders and the fix for where the
+ base policy's review_exclude_paths stands (exit 3); a git failure or timeout prints REVIEW_DIFF_UNREADABLE with the
+ stderr written under --out (exit 1). render applies the same ceiling to the selection --exclude and --keep name
+ whenever --diff-base is a commit here (always for the outside voice, whose brief embeds that selection).
show --manifest MANIFEST_JSON:
stage --manifest --stage
renders a stage; the Red Team only after every earlier reader is collected, its findings file being the
@@ -105,20 +114,25 @@ function render(): number {
const mb = mergeBaseWith(base);
const full = diffBase ? spawnSync('git', ['rev-parse', '--verify', '--quiet', `${diffBase}^{commit}`], { encoding: 'utf8', timeout: 30_000 }).stdout?.trim() : undefined;
if (!full || full !== mb) throw new BriefError(`the targeted reader reads the whole diff on every pass: --diff-base must be the merge base with ${base} (${mb ?? 'unresolved'}), got ${diffBase ?? 'none'}`);
+ if (!flag('--disclosure') && ceilingContext(process.cwd(), base).state === 'active') throw new BriefError(`${base}'s review_exclude_paths is active, so the targeted reader renders in the pass manifest's voices stage (gstack-review-dispatch stage --manifest --stage voices), which carries the exclusion and its disclosure line`);
}
const checklists = flag('--checklists')?.split(',').map(s => s.trim()).filter(Boolean).map(c => (c.includes('/') ? c : path.join(ROOT, 'review/specialists', `${c}.md`)));
for (const c of checklists ?? []) if (!fs.existsSync(c)) throw new BriefError(`checklist not found: ${c}`);
let diff: string | undefined;
- if (role === 'outside-voice' && diffBase) {
- const r = spawnSync('git', ['-c', 'core.quotePath=false', 'diff', '--no-color', '--no-ext-diff', diffBase], { encoding: 'utf8', timeout: 60_000, maxBuffer: 256 * 1024 * 1024 });
- if (r.status !== 0) throw new BriefError(`git diff ${diffBase} failed: ${(r.stderr ?? '').trim().split('\n')[0]}`);
- diff = r.stdout;
+ const keepItems = parseTail(flag('--keep')).filter(s => s !== '--');
+ const policyItems = parseTail(flag('--exclude')).filter(s => s.startsWith(':(top,exclude,glob)'));
+ const resolvable = !!diffBase && git(['rev-parse', '--verify', '--quiet', `${diffBase}^{commit}`]) !== null;
+ if (role !== 'fix-check' && diffBase && (resolvable || role === 'outside-voice')) {
+ const over = ceiling('gstack-review-dispatch render', resolveBaseBranch(process.cwd(), flag('--base')), out, [{ reader: readerKey(role, name), exclude: flag('--exclude'), keep: keepItems }], diffBase, undefined);
+ if (over) { process.stderr.write(over.line + '\n'); return over.exit; }
+ if (role === 'outside-voice') diff = branchDiff(diffBase, { exclude: flag('--exclude'), keep: keepItems });
}
const slots: Slots = {
name, diff_base: diffBase, exclude: flag('--exclude'), stack: flag('--stack'), test_fw: flag('--test-fw'),
learnings: readFile(flag('--learnings-file'), 'learnings'), checklist, checklists: checklists?.join(','),
findings: readFile(flag('--findings-file'), 'findings'), specialists: flag('--specialists'), diff,
classes: flag('--classes'), brief,
+ ...(flag('--disclosure') ? { disclosure: flag('--disclosure'), policy_exclude: policyItems.map(quote).join(' ') || undefined, keep: keepItems.map(quote).join(' ') || undefined } : {}),
fix_list: readFile(flag('--fix-list'), 'fix list'), hunks: readFile(flag('--hunks'), 'hunks'), tests: readFile(flag('--tests'), 'tests'), callers: readFile(flag('--callers'), 'callers'),
};
const workdir = checkoutRoot();
@@ -131,11 +145,12 @@ function render(): number {
const ledger = flag('--ledger') ?? path.join(out, 'review-ledger.jsonl');
const sha = promptHash(text);
const targeted = brief === undefined ? {} : { classes: parseClassList(flag('--classes')!), rules: TARGETED_RULES, brief_sha: targetedBriefSha(checklist), diff_base: diffBase ?? null, ...(diffBase ? targetedRead(process.cwd(), diffBase) : { patch_id: 'none', head: null }) };
- appendEvent(ledger, { event: 'rendered', reader: readerKey(role, name), role, name: name ?? null, file, sha256: sha, bytes: Buffer.byteLength(text), workdir, tree, ...targeted });
+ appendEvent(ledger, { event: 'rendered', reader: readerKey(role, name), role, name: name ?? null, file, sha256: sha, bytes: Buffer.byteLength(text), workdir, tree, ...targeted, ...(disclosureDigest(flag('--disclosure')) ? { exclude: disclosureDigest(flag('--disclosure')) } : {}) });
process.stdout.write(`BRIEF: reader=${readerKey(role, name)} sha256=${sha} bytes=${Buffer.byteLength(text)} file=${file}\n`);
return EXIT.ok;
}
+const quote = (s: string) => `'${s.replace(/'/g, "'\\''")}'`;
const wrongTree = (reader: string, t: TreeCheck) => `${t.moved ? 'COLLECT_TREE_MOVED' : 'COLLECT_WRONG_TREE'}: reader=${reader} reported=${JSON.stringify(t.reported)} expected=${JSON.stringify(t.expected)} (${t.moved ? 'the checkout moved after this brief was rendered' : 'the reply did not review the checkout its brief named'}; nothing merged; restore it with a fresh pass under a new --pass and --out)\n`;
const PROGRAM_FILE = path.join(ROOT, 'lib', 'capy-review-pass.js');
@@ -143,11 +158,30 @@ const PROGRAM_VERSION = 1;
const git = (a: string[]) => { const r = spawnSync('git', a, { encoding: 'utf8', timeout: 60_000, maxBuffer: 256 * 1024 * 1024 }); return r.status === 0 ? r.stdout : null; };
const mergeBaseWith = (base: string) => [`origin/${base}`, base].map(ref => git(['merge-base', ref, 'HEAD'])?.trim()).find(Boolean);
const readerLine = (m: Manifest, r: PassReader) => `READER: ${r.reader} stage=${r.stage} state=${r.state} title="${r.title}"${r.sha256 ? ` sha256=${r.sha256} bytes=${r.bytes} chunks=${r.chunks}` : ''}${r.path ? ` path=${r.path}` : ''}`;
-const branchDiff = (base: string) => {
- const d = git(['-c', 'core.quotePath=false', 'diff', '--no-color', '--no-ext-diff', base]);
- if (d === null) throw new BriefError(`git diff ${base} failed`);
- return d;
+/** Each selection is read once per process: the ceiling check and the outside voice's embedded diff share it. */
+const selections = new Map();
+const selectionOf = (base: string, exclude: string | undefined, keep: readonly string[] = []) => {
+ const key = JSON.stringify([base, exclude ?? '', keep]);
+ if (!selections.has(key)) selections.set(key, readSelection(process.cwd(), base, parseTail(exclude), keep));
+ return selections.get(key)!;
};
+const branchDiff = (base: string, r?: Pick) => {
+ const d = selectionOf(base, r?.exclude, r?.keep);
+ if (!d.ok) throw new BriefError(`git diff ${base} failed`);
+ return d.text;
+};
+
+/** The ceiling over every reader about to render: the first one over or unreadable stops the step before it writes anything else. */
+function ceiling(context: string, base: string, outDir: string, readers: ReadonlyArray & { diff_base?: string }>, diffBase: string, known: PlanExclude | undefined): { exit: number; line: string } | null {
+ for (const r of readers) {
+ if (r.prompt_file) continue;
+ const at = r.diff_base ?? diffBase;
+ const d = selectionOf(at, r.exclude, r.keep);
+ if (!d.ok) return { exit: EXIT.fail, line: unreadableLine(context, r.reader, d, at, base, outDir) };
+ if (d.bytes > REVIEW_DIFF_MAX_BYTES) return { exit: EXIT.refused, line: tooLargeLine(context, r.reader, d, ceilingContext(process.cwd(), base, known)).line };
+ }
+ return null;
+}
function pass(): number {
const base = flag('--base'), out = flag('--out'), id = flag('--pass');
@@ -179,7 +213,7 @@ function pass(): number {
if (text.status !== 0) { process.stdout.write(text.stdout ?? ''); process.stderr.write(text.stderr ?? ''); return EXIT.fail; }
const j = plan(true);
if (j.status !== 0) { process.stderr.write(j.stderr ?? ''); return EXIT.fail; }
- const p = JSON.parse(j.stdout) as { next: { step: string }; dispatches: Dispatch[]; targeted?: { classes: string[] }; fix_range?: { base: string } | null };
+ const p = JSON.parse(j.stdout) as { next: { step: string }; dispatches: Dispatch[]; targeted?: { classes: string[] }; fix_range?: { base: string } | null; review_exclude?: Record & { state: string; excluded?: number; kept_files?: unknown; references?: unknown } };
const step = p.next.step;
const fixChecks = step === 'fix-check' ? [] : p.dispatches.filter(d => d.role === 'fix-check');
const planLines = [...text.stdout.trimEnd().split('\n'), ...fixChecks.map(d => `FIX_CHECK: planned effort=${d.effort} (Step 3.5 runs it on its own; not in this manifest)`)];
@@ -195,7 +229,10 @@ function pass(): number {
const m = buildManifest({ pass: id, out, ledger: flag('--ledger') ?? path.join(out, 'review-ledger.jsonl'), base, diffBase, root, checklistRoot: flag('--checklist-root') ?? ROOT,
dispatches: step === 'fix-check' ? p.dispatches : p.dispatches.filter(d => d.role !== 'fix-check'), next: step, planLines, learnings: readFile(flag('--learnings-file'), 'learnings') ?? null, extras,
targetedClasses: p.targeted?.classes, mergeBase, rangeBase: flag('--diff-base') ? undefined : rangeBase,
- fixCheck: step === 'fix-check' && (flag('--fix-list') || flag('--hunks') || flag('--tests')) ? { fix_list: flag('--fix-list')!, hunks: flag('--hunks')!, tests: flag('--tests')!, callers: flag('--callers') } : undefined });
+ fixCheck: step === 'fix-check' && (flag('--fix-list') || flag('--hunks') || flag('--tests')) ? { fix_list: flag('--fix-list')!, hunks: flag('--hunks')!, tests: flag('--tests')!, callers: flag('--callers') } : undefined,
+ ...planExclude(p.review_exclude) });
+ const over = ceiling('gstack-review-dispatch pass', base, m.out, m.readers, m.diff_base, (m.review_exclude as PlanExclude) ?? null);
+ if (over) { process.stdout.write(planLines.join('\n') + '\n'); process.stderr.write(over.line + '\n'); return over.exit; }
fs.mkdirSync(m.out, { recursive: true, mode: 0o700 });
const t = sectionTexts(ROOT);
renderStage(m, 'first', t, branchDiff);
@@ -206,6 +243,13 @@ function pass(): number {
return EXIT.ok;
}
+/** The manifest's copy of the plan's review_exclude (no file lists) and the brief's disclosure line, when the plan had one. */
+function planExclude(x: (Record & { state: string; excluded?: number }) | undefined): { reviewExclude?: Record; disclosure?: string } {
+ if (!x) return {};
+ const { kept_files: _k, references: _r, ...rest } = x;
+ return { reviewExclude: rest, ...(x.state === 'active' ? { disclosure: disclosureLine(x as unknown as Parameters[0]) } : {}) };
+}
+
function passStep(cmd: string): number {
const file = flag('--manifest');
if (!file) { process.stderr.write(`gstack-review-dispatch ${cmd}: --manifest is required\n`); return EXIT.usage; }
@@ -214,6 +258,9 @@ function passStep(cmd: string): number {
if (cmd === 'stage') {
const stage = flag('--stage') as Stage | undefined;
if (!stage || !STAGES.includes(stage)) { process.stderr.write(`gstack-review-dispatch stage: --stage ${STAGES.join('|')}\n`); return EXIT.usage; }
+ const pending = m.readers.filter(r => r.stage === stage && r.state === 'planned');
+ const over = ceiling('gstack-review-dispatch stage', m.base, m.out, pending, m.diff_base, (m.review_exclude as PlanExclude) ?? null);
+ if (over) { process.stderr.write(over.line + '\n'); return over.exit; }
const done = renderStage(m, stage, sectionTexts(ROOT), branchDiff);
writeManifest(file, m);
process.stdout.write([`STAGE: ${stage} rendered=${done.length}`, ...done.map(r => readerLine(m, r))].join('\n') + '\n');
@@ -297,6 +344,19 @@ function targetedRecord(ledger: string, rows: VerifyRow[]): { classes: string[];
return { classes: (render.classes as string[]) ?? [], rules: String(render.rules), brief_sha: String(render.brief_sha), status: done ? 'done' : 'failed', findings: Array.isArray(ids) ? ids.length : 0, raised_by: 'targeted', head };
}
+/**
+ * The selection the verified briefs read under review_exclude_paths: the digest every reader's newest brief names (fix-check reads
+ * named hunks, not the selection); null when no brief names one, so a repository without the key verifies as before.
+ */
+function excludeRecord(ledger: string): { digest: string } | { mixed: string[] } | null {
+ const newest = new Map>();
+ for (const e of readLedger(ledger)) if (e.event === 'rendered' && e.role && e.role !== 'fix-check') newest.set(String(e.reader), e);
+ const named = [...newest.values()].map(e => (typeof e.exclude === 'string' ? e.exclude : 'none'));
+ if (!named.some(d => d !== 'none')) return null;
+ const digests = [...new Set(named)];
+ return digests.length === 1 ? { digest: digests[0]! } : { mixed: digests };
+}
+
function main(): number {
const cmd = args[0];
if (!cmd || cmd === '--help' || cmd === '-h') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; }
@@ -368,8 +428,12 @@ function main(): number {
process.stdout.write(`TARGETED_RECORD: classes=${record.classes.join(',')} status=${record.status} findings=${record.findings} brief=${record.brief_sha.slice(0, 12)} rules=${record.rules} read=${record.head?.slice(0, 12) ?? 'none'}\n`);
if (flag('--targeted-out')) fs.writeFileSync(flag('--targeted-out')!, JSON.stringify(record) + '\n');
}
- process.stdout.write(`VERIFY: ${v.clean ? 'clean' : 'dirty'} briefs=${v.briefs} outputs=${v.rows.filter(r => r.kind === 'OUTPUT').length} voided=${v.voided}${v.unavailable ? ` unavailable=${v.unavailable}` : ''}\n`);
- return v.clean ? EXIT.ok : EXIT.fail;
+ const sel = excludeRecord(ledger);
+ if (sel && 'digest' in sel) process.stdout.write(`REVIEW_EXCLUDE_RECORD: digest=${sel.digest} (gstack-ship-receipt write --review-exclude ${sel.digest})\n`);
+ if (sel && 'mixed' in sel) process.stdout.write(`REVIEW_EXCLUDE_RECORD: mixed digests=${sel.mixed.join(',')} (readers read different review_exclude_paths selections; rerun the review so every reader reads HEAD's)\n`);
+ const clean = v.clean && !(sel && 'mixed' in sel);
+ process.stdout.write(`VERIFY: ${clean ? 'clean' : 'dirty'} briefs=${v.briefs} outputs=${v.rows.filter(r => r.kind === 'OUTPUT').length} voided=${v.voided}${v.unavailable ? ` unavailable=${v.unavailable}` : ''}\n`);
+ return clean ? EXIT.ok : EXIT.fail;
}
process.stderr.write(`gstack-review-dispatch: unknown command ${cmd}\n${HELP}\n`);
return EXIT.usage;
diff --git a/bin/gstack-review-plan b/bin/gstack-review-plan
index 793a4d1f3..be34391f3 100755
--- a/bin/gstack-review-plan
+++ b/bin/gstack-review-plan
@@ -16,16 +16,23 @@
* FIX_RANGE: (with --range-base), REVIEW_PLAN:, one DEPTH_ERROR: per input that failed
* the depth closed, one DISPATCH: plus DIFF_CMD: (and DIFF_EXCLUDE: none) per
* reviewer and one SKIP: per reviewer left out. A plan that fails closed to full
- * exits 0; a diff git cannot list exits 1. Logic: lib/review-depth.ts.
+ * exits 0; a diff git cannot list exits 1, and so does a base that is HEAD or already
+ * contains it while the branch has commits its real base lacks (BASE_CONTAINS_HEAD,
+ * lib/default-base.ts). Logic: lib/review-depth.ts.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
+import { baseContainsHead } from '../lib/default-base';
import { locate, readRecord } from '../lib/approval-record';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
+import { resultLine } from '../lib/result-codes';
import { runScriptSync } from '../lib/script-invocation';
import { collectCommit, collectLive } from '../lib/review-collect';
-import { classifyDiff, diffCommand, planReview, reviewDepthLine, reviewPlanLine, scanNumbers, SCOPED_RANGE_LINES, type Depth, type History } from '../lib/review-depth';
+import { excludeLines, reviewExclude, type Exclusion } from '../lib/review-exclude';
+import { isUnavailable } from '../lib/ship-policy';
+import { loadPolicyAt } from '../lib/ship-policy-at';
+import { classifyDiff, diffCommand, keepCommand, planReview, reviewDepthLine, reviewPlanLine, scanNumbers, SCOPED_RANGE_LINES, type Depth, type History } from '../lib/review-depth';
import { classList, targetedBriefSha, targetedClasses, targetedPatchId } from '../lib/review-targeted';
import { renderFixCheckPrompt } from '../scripts/resolvers/fix-check';
@@ -120,6 +127,8 @@ function main(): number {
return EXIT.fail;
}
const head = git(['rev-parse', 'HEAD'])?.trim() ?? 'unknown';
+ const self = collected.mergeBase === head ? baseContainsHead(root, git(['rev-parse', '--verify', '-q', `origin/${base}^{commit}`]) ? `origin/${base}` : base) : null;
+ if (self) { process.stderr.write(`${resultLine('gstack-review-plan', 'BASE_CONTAINS_HEAD', self)}; no plan is safer than a plan for a diff compared with itself\n`); return EXIT.fail; }
const binDir = path.dirname(Bun.fileURLToPath(import.meta.url));
const scopeOf = (args: string[]): Record => {
const run = runScriptSync(path.join(binDir, 'gstack-diff-scope'), args, { encoding: 'utf8', timeout: 60_000 });
@@ -128,15 +137,32 @@ function main(): number {
if (run.status !== 0 && !s.SCOPE_ERROR) s.SCOPE_ERROR = `exit ${run.status}`;
return s;
};
- const gateTagsOf = (args: string[]): number | null => {
- const g = runScriptSync(path.join(binDir, 'gstack-gate-diff'), args, { encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
- const m = /GATE_SUMMARY: .*?tagged=\{([^}]*)\}/.exec(g.stdout ?? '');
- return g.status === 0 && m ? m[1]!.split(',').reduce((n, kv) => n + (Number(kv.split(':')[1]) || 0), 0) : null;
+ const runGateDiff = (args: string[]) => runScriptSync(path.join(binDir, 'gstack-gate-diff'), args, { encoding: 'utf8', timeout: 120_000, maxBuffer: 64 * 1024 * 1024 });
+ const tagCount = (stdout: string | undefined, status: number | null): number | null => {
+ const m = /GATE_SUMMARY: .*?tagged=\{([^}]*)\}/.exec(stdout ?? '');
+ return status === 0 && m ? m[1]!.split(',').reduce((n, kv) => n + (Number(kv.split(':')[1]) || 0), 0) : null;
};
+ const gateTagsOf = (args: string[]): number | null => { const g = runGateDiff(args); return tagCount(g.stdout, g.status); };
const diffBase = collected.mergeBase ?? base;
const scope = scopeOf([diffBase]);
- let gateTagged = flag('--gate-tagged') !== undefined ? Number(flag('--gate-tagged')) : gateTagsOf([diffBase]);
+ const ref = git(['rev-parse', '--verify', '-q', `origin/${base}^{commit}`]) !== null ? `origin/${base}` : base;
+ const baseSha = collected.registry.sha ?? git(['rev-parse', '--verify', '-q', `${ref}^{commit}`])?.trim() ?? '';
+ const policy = baseSha ? loadPolicyAt(root, baseSha, ref) : null;
+ const declares = !!policy && !isUnavailable(policy) && policy.policy.review_exclude_paths.length > 0;
+ let tagged: Set | null = null;
+ let gateTagged: number | null;
+ if (declares) {
+ const g = runGateDiff([diffBase, '--format', 'jsonl']);
+ gateTagged = tagCount(g.stdout, g.status);
+ tagged = gateTagged === null ? null : new Set((g.stdout ?? '').split('\n').slice(1).flatMap(l => { try { const h = JSON.parse(l) as { path?: string; tags?: unknown[] }; return h.path && h.tags?.length ? [h.path] : []; } catch { return []; } }));
+ if (flag('--gate-tagged') !== undefined) gateTagged = Number(flag('--gate-tagged'));
+ } else gateTagged = flag('--gate-tagged') !== undefined ? Number(flag('--gate-tagged')) : gateTagsOf([diffBase]);
if (gateTagged !== null && !Number.isFinite(gateTagged)) gateTagged = null;
+ const exclusion: Exclusion = !baseSha || !collected.mergeBase ? { state: 'none' } : reviewExclude({ root, baseSha, ref, mergeBase: collected.mergeBase, perPath: collected.perPath, added: collected.added, classRoot: root, gateTagged: tagged, context: 'gstack-review-plan' });
+ if (exclusion.state === 'refused') { for (const l of exclusion.lines) process.stderr.write(l + '\n'); return exclusion.code === 'REVIEW_DIFF_UNREADABLE' ? EXIT.fail : EXIT.refused; }
+ const active = exclusion.state === 'active' ? exclusion : null;
+ const kept = (rec: Record) => (active ? Object.fromEntries(Object.entries(rec).filter(([p]) => !active.excluded.has(p))) : rec);
+ const exclusionInput = active ? { excluded: active.excluded, exclusion: { specs: active.specs, keep: active.keep } } : {};
const force = flag('--force') === 'all' ? 'all' as const : list('--force');
const tiersSetting = runScriptSync(path.join(binDir, 'gstack-config'), ['get', 'review_effort_tiers'], { encoding: 'utf8', timeout: 15_000 });
const auditEffort = has('--no-tiers') || (tiersSetting.stdout ?? '').trim() === 'off' ? 'host' : 'light';
@@ -150,28 +176,30 @@ function main(): number {
else if (git(['diff', '--quiet', 'HEAD', '--']) === null || collected.untracked.length) fixRange = unread('uncommitted or untracked edits: commit the fixes first');
else {
const commit = collectCommit(root, resolved, 'HEAD');
- const range = classifyDiff({ ...commit, registry, scope: scopeOf([resolved, '--head', 'HEAD']), gateTagged: gateTagsOf([resolved]) });
+ const range = classifyDiff({ ...commit, registry, scope: scopeOf([resolved, '--head', 'HEAD']), gateTagged: gateTagsOf([resolved]), ...(active ? { excluded: active.excluded } : {}) });
fixRange = range.errors.length ? unread(range.errors.map(e => `${e.code} ${e.input}`).join('; '))
: !commit.paths.length ? unread('the fix range is empty: commit the fix after the range base')
: { base: rangeBase, runtime_lines: range.runtimeLines, ...(range.class === 'gate' ? { touched_gate: true as const } : {}) };
}
}
- const targeted = targetedClasses({ ...collected, scope, gateTagged });
+ const targeted = targetedClasses({ ...collected, ...(active ? { paths: paths.filter(p => !active.excluded.has(p)), added: kept(added), removed: kept(collected.removed) } : {}), scope, gateTagged });
const patch = collected.mergeBase ? targetedPatchId(process.cwd(), collected.mergeBase) : 'none';
const brief = targetedBriefSha();
const plan = planReview({
- ...collected, scope, gateTagged, targeted: { classes: targeted.classes, ran: !['uncommitted', 'none'].includes(patch) && flag('--targeted-ran') === patch }, numbers: scanNumbers(added), force, gated: list('--gated'), floor, tiers: auditEffort !== 'host',
+ ...collected, ...exclusionInput, scope, gateTagged, targeted: { classes: targeted.classes, ran: !['uncommitted', 'none'].includes(patch) && flag('--targeted-ran') === patch }, numbers: scanNumbers(kept(added)), force, gated: list('--gated'), floor, tiers: auditEffort !== 'host',
criticalFound: has('--critical-found'), adversarialRan: has('--adversarial-ran'), reviewedNumbers: flag('--reviewed-numbers'),
history: { fullPasses: pass, last, fixedBlocking: has('--fixed-blocking'), fixedBelow: has('--fixed-below') && last !== 'fix-check', blockingOpen: has('--blocking-open'), touchedGate: has('--touched-gate') || !!fixRange?.touched_gate, pass1Depth, rangeRuntimeLines: fixRange?.runtime_lines, granted: grant },
});
const gateScan = { tagged: gateTagged, head };
if (has('--json')) {
const { runtimeLines, stepDepth, depthReasons, ...rest } = plan;
- process.stdout.write(JSON.stringify({ ...rest, targeted: { ...targeted, brief, targeted_patch_id: patch }, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly, consumed: collected.consumed, drawings: collected.drawings }) + '\n');
+ process.stdout.write(JSON.stringify({ ...rest, targeted: { ...targeted, brief, targeted_patch_id: patch }, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly, consumed: collected.consumed, drawings: collected.drawings,
+ ...(exclusion.state === 'head-only' ? { review_exclude: exclusion } : active ? { review_exclude: { state: 'active', ...active.record, specs: active.specs, keep: active.keep, kept_files: active.kept, content: active.content, references: active.references } } : {}) }) + '\n');
return EXIT.ok;
}
const out = [`REVIEW_CLASS: ${plan.class} (${plan.reasons.slice(0, 3).join('; ') || 'no signals'})`, reviewDepthLine(plan), `GATE_SCAN: ${gateTagged === null ? 'unavailable' : `tagged=${gateTagged}`} head=${head}`,
`TARGETED: classes=${classList(targeted.classes)} rules=${targeted.rules} brief=${brief} targeted_patch_id=${patch}${targeted.reasons.length ? ` reasons=${targeted.reasons.join('; ')}` : ''}`];
+ out.push(...excludeLines(exclusion, base));
if (fixRange) out.push(`FIX_RANGE: ${fixRange.runtime_lines === null ? `unread (${fixRange.error})` : `runtime_lines=${fixRange.runtime_lines}`} base=${fixRange.base}`);
out.push(reviewPlanLine(plan, pass + (plan.next.step === 'full' || plan.next.step === 'scoped' ? 1 : 0)), `NEXT: ${plan.next.step} (${plan.next.reason})`);
for (const e of plan.errors) out.push(`DEPTH_ERROR: ${e.code} input=${e.input} detail=${e.detail}; fix: ${e.repair}${e.result ? ` (${e.result})` : ''}`);
@@ -179,6 +207,8 @@ function main(): number {
const key = d.name.split(' ')[0];
out.push(`DISPATCH: ${d.role} ${d.name} effort=${d.effort}${d.checklists ? ` checklists=${d.checklists.join(',')}` : ''}${d.reason ? ` reason=${d.reason}` : ''}`, `DIFF_CMD: ${key} ${diffCommand(d)}`);
if (!d.exclude.length) out.push(`DIFF_EXCLUDE: ${key} none`);
+ const keepCmd = keepCommand(d);
+ if (keepCmd) out.push(`DIFF_KEEP: ${key} ${keepCmd}`);
}
for (const s of plan.skipped) out.push(`SKIP: ${s}`);
out.push(`AUDIT_EFFORT: ${auditEffort} (coverage, plan-completion and docs audits, decision-trail reviewers, watchers; reviewers above run at their effort=)`);
diff --git a/bin/gstack-ship-queue b/bin/gstack-ship-queue
index 738acc9c2..2b503b0fe 100755
--- a/bin/gstack-ship-queue
+++ b/bin/gstack-ship-queue
@@ -20,7 +20,7 @@
* gstack-ship-queue train build|status|verdict|bisect|rebuild|watch|prune ... (merge trains; train --help)
* gstack-ship-queue wave plan|build|status|settle ... (wave PRs: 3-6 same-area PRs in one train slot; wave --help)
*
- * Every subcommand takes --base (default origin/HEAD, else main) and
+ * Every subcommand takes --base (default lib/default-base.ts) and
* ends with GSTACK_RESULT: skill=ship-queue status=complete|refused run=.
* gstack-restamp stays the primitive. Nothing here pushes, except land, ready
* under changelog_fragments (its READY receipt note), and under a
@@ -69,7 +69,7 @@ const HELP = `gstack-ship-queue — the shared merge queue's protocol steps (doc
wave plan|build|status|settle ... wave PRs under a train policy: 3-6 same-area PRs in one slot and one version
(wave --help; docs/ship-policy.md#wave-prs)
---base : base branch (default: origin/HEAD, else main). Nothing here pushes, except land (the stamp commit and the
+--base : base branch (default: origin/HEAD unless it names the checked-out branch, else the PR's base, origin/main). Nothing here pushes, except land (the stamp commit and the
refs/notes/gstack-land lock), ready under changelog_fragments (its READY receipt note) and merge trains.
${exitTableHelp()}`;
diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt
index 79c64da9e..9e417faa9 100755
--- a/bin/gstack-ship-receipt
+++ b/bin/gstack-ship-receipt
@@ -49,7 +49,7 @@ import {
} from '../lib/ship-receipt';
import { checkDocsCaps, docsCapLine, docsExceptionLine, parseReceiptDocs } from '../lib/ship-receipt-docs';
import { comparePath, contextPacket, PACKET_SECTION_CAP, readScopedReview, touchedPaths } from '../lib/ship-patch';
-import { deeper, depthBelow, exceptionProblems, parseReviewLoopException, parseReviewTargeted, receiptDepthAtHead, receiptTargetedAtHead, reviewLoopExceptionLine, targetedClassesSince } from '../lib/ship-receipt-depth';
+import { deeper, depthBelow, exceptionProblems, parseReviewLoopException, parseReviewTargeted, receiptExclude, receiptReviewAtHead, receiptTargetedAtHead, reviewLoopExceptionLine, targetedClassesSince } from '../lib/ship-receipt-depth';
import { checkReceiptProse, proseLine } from '../lib/ship-receipt-prose';
import { resolveStateRoot } from '../lib/state-root';
import { readPause, receiptBaseRed } from '../lib/base-red';
@@ -96,6 +96,9 @@ write flags: --pr --head --base --version --gated-tree --predecessor --gate (the digest= on gstack-review-dispatch verify's REVIEW_EXCLUDE_RECORD: line, which the reviewers' briefs
+ name; none without that line): must equal the digest HEAD has under the base policy's review_exclude_paths, else
+ RECEIPT_INVALID field=review_exclude and nothing is written (rerun the review; write alone cannot clear it); recorded as review_exclude. Carry and the queue compare that digest.
--review-targeted ({classes, rules, brief_sha, status, findings, raised_by, head}: gstack-review-dispatch verify
--targeted-out); recorded as review_targeted bound to the receipt's product_patch_id, and refused (RECEIPT_INVALID) when the
commit the reader read (head) is not an ancestor or a change since it carries a targeted class (gate scan included). write and carry
@@ -220,7 +223,15 @@ function cmdWrite(rest: string[], json: boolean): number {
const line = reviewLoopExceptionLine(loopException.exception);
if (bodyFile && !bodyText.split(/\r?\n/).some(l => l.trim() === line)) return refuse(`${bodyFile} does not carry the exception line verbatim: ${JSON.stringify(line)}`);
}
- const reviewDepth = receiptDepthAtHead(repoRoot, base, path.dirname(Bun.fileURLToPath(import.meta.url)), headSha ?? head);
+ const review = receiptReviewAtHead(repoRoot, base, path.dirname(Bun.fileURLToPath(import.meta.url)), headSha ?? head);
+ const reviewDepth = review.depth;
+ if (review.exclude.state === 'refused') { for (const l of review.exclude.lines) process.stderr.write(`${l}; nothing written\n`); return EXIT.refused; }
+ const excludeNow = review.exclude.state === 'active' ? review.exclude.record : null;
+ const excludeGiven = flag(rest, '--review-exclude') ?? 'none';
+ if ((excludeNow?.digest ?? 'none') !== excludeGiven) {
+ process.stderr.write(`gstack-ship-receipt write: field=review_exclude expected=${excludeNow?.digest ?? 'none'} recorded=${excludeGiven} source=${excludeNow?.source ?? `origin/${base} (no review_exclude_paths)`}: the review --review-exclude names read another selection than ${base}'s review_exclude_paths gives ${head.slice(0, 12)}; fix: rerun /ship's review on ${head.slice(0, 12)} (gstack-review-dispatch pass --base ${base}), run gstack-review-dispatch verify --ledger and write with the --review-exclude its REVIEW_EXCLUDE_RECORD: line prints (none when it prints no such line); nothing written (RECEIPT_INVALID)\n`);
+ return EXIT.fail;
+ }
const product = reviewedProduct(repoRoot, base, head, pregateFile);
let targeted: ReturnType | undefined;
if (flag(rest, '--review-targeted') !== undefined) {
@@ -259,6 +270,7 @@ function cmdWrite(rest: string[], json: boolean): number {
review_loop: reviewLoop, ...(docs?.ok ? { docs: docs.docs } : {}), ...product,
gstack_version: runningGstackVersion(), review_depth: reviewDepth, review_loop_exception: loopException?.ok ? loopException.exception : undefined, review_targeted: targeted?.ok ? targeted.targeted : undefined,
base_red: pause ? receiptBaseRed(pause) : undefined,
+ review_exclude: receiptExclude(review.exclude),
};
} catch (e: any) { process.stderr.write(`gstack-ship-receipt write: ${e.message}\n`); return EXIT.usage; }
if (input.pr !== undefined && !Number.isInteger(input.pr)) { process.stderr.write('gstack-ship-receipt write: --pr must be an integer\n'); return EXIT.usage; }
@@ -733,9 +745,13 @@ function cmdCarry(rest: string[], json: boolean): number {
if (!receipt.review_depth) return refused('RECEIPT_CARRY_REFUSED', `the receipt records gstack_version but no review_depth, so the queue would refuse it; run /ship on ${head.slice(0, 12)}`);
const checkedOut = git(['rev-parse', 'HEAD'], repoRoot)?.toLowerCase();
if (checkedOut !== head) return refused('RECEIPT_CARRY_REFUSED', `--head ${head.slice(0, 12)} is not the checked-out HEAD ${(checkedOut ?? 'none').slice(0, 12)}; the gate scan reads the checkout, so check out ${head.slice(0, 12)} and carry there`);
- const now = receiptDepthAtHead(repoRoot, base, path.dirname(Bun.fileURLToPath(import.meta.url)), head);
+ const review = receiptReviewAtHead(repoRoot, base, path.dirname(Bun.fileURLToPath(import.meta.url)), head);
+ const now = review.depth;
+ if (review.exclude.state === 'refused') return refused('RECEIPT_CARRY_REFUSED', `${review.exclude.lines.join('; ')}; run /ship on ${head.slice(0, 12)} once the base policy reads`);
+ const digest = review.exclude.state === 'active' ? review.exclude.record.digest : 'none';
+ if (digest !== (receipt.review_exclude?.digest ?? 'none')) return refused('RECEIPT_CARRY_REFUSED', `review_exclude changed: recorded=${receipt.review_exclude?.digest ?? 'none'} now=${digest} source=${review.exclude.state === 'active' ? review.exclude.record.source : `${base} (no review_exclude_paths)`}; the reviewers read another selection than HEAD ${head.slice(0, 12)} has under the base policy, so carry does not apply; run /ship on ${head.slice(0, 12)}`);
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) } };
+ depthField = { review_depth: { ...now, depth: deeper(receipt.review_depth.depth, now.depth) }, review_exclude: receiptExclude(review.exclude) };
}
// 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 };
diff --git a/bin/gstack-skill-start b/bin/gstack-skill-start
index fb26ee8e5..16b1c9133 100755
--- a/bin/gstack-skill-start
+++ b/bin/gstack-skill-start
@@ -426,7 +426,29 @@ if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ] && grep -q '"gbr
_GBRAIN_MCP_ENTRY=$(jq -c --arg cwd "$PWD" '((.projects // {}) | to_entries | map(select((.key as $k | $cwd == $k or ($cwd | startswith($k + "/")) or ($cwd | startswith($k + "\\"))) and ((try .value.mcpServers.gbrain catch null) != null))) | sort_by(.key | length) | last | .value.mcpServers.gbrain) // .mcpServers.gbrain // empty' "$HOME/.claude.json" 2>/dev/null)
_GBRAIN_MCP_TYPE=$(printf '%s' "$_GBRAIN_MCP_ENTRY" | jq -r '.type // .transport // empty' 2>/dev/null)
case "$_GBRAIN_MCP_TYPE" in
- url|http|sse) _GBRAIN_MCP_MODE="remote-http" ;;
+ url|http|sse)
+ _GBRAIN_MCP_MODE="remote-http"
+ # #3539: a loopback URL whose local gbrain config names a Postgres
+ # engine (a database_url, engine not pglite) and no remote_mcp marker is
+ # this machine's own `gbrain serve --http`, not a remote brain. A conservative subset of lib/gbrain-mcp-locality.ts, not
+ # an exact mirror: the URL test accepts only canonical loopback
+ # spellings (localhost, 127.a.b.c with valid decimal octets, [::1], a
+ # valid port), so other spellings stay remote here. It lowercases with
+ # ascii_downcase, not the "i" flag, which case-folds U+017F to "s". As in
+ # TS, a URL containing "${" is remote (Claude Code expands it), and the
+ # regex rejects userinfo. The config test
+ # matches the TS one: remote_mcp is a marker when JS-truthy, as in
+ # gbrain's isThinClient (null, false, 0 and "" are not), and
+ # database_url counts when non-blank after JS trim() and engine is not
+ # pglite. GBRAIN_HOME is the
+ # parent of .gbrain, as in gbrainConfigDir. test/gbrain-mcp-locality.test.ts
+ # runs both programs below against the TS rule.
+ if printf '%s' "$_GBRAIN_MCP_ENTRY" | jq -e '(.url // "") | ascii_downcase | (contains("${") | not) and test("\\Ahttps?://(localhost|127(\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|\\[::1\\])(:([0-9]{0,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?([/?#]|\\z)")' >/dev/null 2>&1 \
+ && jq -e '((.remote_mcp // false) | . == false or . == 0 or . == "") and .engine != "pglite" and ((.database_url // "") | (type == "string" and test("[^\\s\\x{feff}]|\\x{0085}")))' \
+ "${GBRAIN_HOME:-$HOME}/.gbrain/config.json" >/dev/null 2>&1; then
+ _GBRAIN_MCP_MODE="local-stdio"
+ fi
+ ;;
stdio) _GBRAIN_MCP_MODE="local-stdio" ;;
esac
fi
diff --git a/browse/src/cli.ts b/browse/src/cli.ts
index e884952b0..208889ec2 100644
--- a/browse/src/cli.ts
+++ b/browse/src/cli.ts
@@ -523,12 +523,16 @@ export function proxyMismatchLines(flags: GlobalFlags, state: ServerState, env:
* The server env for this invocation's resolved proxy (#915); empty when
* direct, except BROWSE_PROXY_URL=direct where that bypasses the system proxy
* settings (#3440), so a daemon start or auto-restart carries the opt-out the
- * session's hash was computed from.
+ * session's hash was computed from. The resolved URL already carries any
+ * BROWSE_PROXY_USER/PASS, so those are blanked for the daemon, which inherits
+ * this environment and would otherwise see the creds twice (#3457).
*/
function proxyServerEnv(flags: Partial> | null | undefined): Record {
if (!flags?.proxyUrl) return flags?.explicitDirect ? { BROWSE_PROXY_URL: 'direct' } : {};
return {
BROWSE_PROXY_URL: flags.proxyUrl,
+ BROWSE_PROXY_USER: '',
+ BROWSE_PROXY_PASS: '',
...(flags.proxySource ? { BROWSE_PROXY_SOURCE: flags.proxySource } : {}),
...(flags.proxyBypass ? { BROWSE_PROXY_BYPASS: flags.proxyBypass } : {}),
};
diff --git a/browse/test/cli-supervisor.test.ts b/browse/test/cli-supervisor.test.ts
index eed733983..3e9e27809 100644
--- a/browse/test/cli-supervisor.test.ts
+++ b/browse/test/cli-supervisor.test.ts
@@ -124,7 +124,7 @@ describe('runHeadedSupervisor (behavior)', () => {
expect(h.calls.startEnv).toHaveLength(1);
expect(h.calls.startEnv[0]).toEqual({
BROWSE_HEADED: '1', BROWSE_PORT: '34567', BROWSE_PARENT_PID: '0',
- BROWSE_PROXY_URL: 'socks5://127.0.0.1:9050', BROWSE_CONFIG_HASH: 'abc123',
+ BROWSE_PROXY_URL: 'socks5://127.0.0.1:9050', BROWSE_PROXY_USER: '', BROWSE_PROXY_PASS: '', BROWSE_CONFIG_HASH: 'abc123',
});
expect(h.calls.startEnv[0]).toBe(h.deps.env);
expect(h.calls.agents).toEqual([5000]);
diff --git a/browse/test/proxy-env-daemon.test.ts b/browse/test/proxy-env-daemon.test.ts
index 785eef9bd..68ab854da 100644
--- a/browse/test/proxy-env-daemon.test.ts
+++ b/browse/test/proxy-env-daemon.test.ts
@@ -14,6 +14,8 @@ import * as path from 'node:path';
const CLI = path.resolve(import.meta.dir, '../src/cli.ts');
const PROXIED_HOST = 'proxied.gstack-915.test';
+const AUTH_HOST = 'auth.gstack-3457.test';
+const AUTH_HEADER = `Basic ${Buffer.from('proxy-user:proxy-secret').toString('base64')}`;
let scratch = '';
let stateFile = '';
@@ -27,6 +29,11 @@ beforeAll(async () => {
stateFile = path.join(scratch, 'state', 'browse.json');
proxy = http.createServer((req, res) => {
const host = new URL(req.url ?? '/', 'http://unknown').hostname;
+ if (host === AUTH_HOST && req.headers['proxy-authorization'] !== AUTH_HEADER) {
+ res.writeHead(407, { 'Proxy-Authenticate': 'Basic realm="gstack-3457"' });
+ res.end();
+ return;
+ }
proxiedHosts.push(host);
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`]via proxy for ${host}
`);
@@ -177,4 +184,15 @@ describe.skipIf(process.platform === 'win32')('#915 proxy from the environment,
}
expect(daemonPid()).toBe(pid);
}, 120_000);
+
+ test('BROWSE_PROXY_USER/PASS with HTTP_PROXY: the daemon starts and Chromium answers the proxy auth challenge (#3457)', async () => {
+ expect((await browse(['stop'], {})).code).toBe(0);
+ const env = { HTTP_PROXY: proxyUrl, BROWSE_PROXY_USER: 'proxy-user', BROWSE_PROXY_PASS: 'proxy-secret' };
+ const r = await browse(['goto', `http://${AUTH_HOST}/`], env);
+ expect(r.code, r.out).toBe(0);
+ expect(r.stderr).toContain(`Starting server with proxy ${proxyUrl.replace('http://', 'http://***:***@')}/ from HTTP_PROXY...`);
+ expect(r.out).not.toContain('proxy-secret');
+ expect(proxiedHosts).toContain(AUTH_HOST);
+ expect((await browse(['text'], env)).stdout).toContain(`via proxy for ${AUTH_HOST}`);
+ }, 120_000);
});
diff --git a/browse/test/restart-env.test.ts b/browse/test/restart-env.test.ts
index a48eb1a32..6a9aea842 100644
--- a/browse/test/restart-env.test.ts
+++ b/browse/test/restart-env.test.ts
@@ -1,5 +1,6 @@
import { describe, test, expect } from "bun:test";
-import { buildRestartEnv } from "../src/cli";
+import { buildHeadedServerEnv, buildRestartEnv, extractGlobalFlags } from "../src/cli";
+import { parseProxyConfig, serverProxyEnv } from "../src/proxy-config";
// #1781: an auto-restart triggered by a plain command (no --headed flag) must
// NOT silently downgrade a headed session to headless. buildRestartEnv reapplies
@@ -48,3 +49,36 @@ describe("buildRestartEnv (#1781 headed persistence)", () => {
expect(env.BROWSE_CONFIG_HASH).toBe("fromstate");
});
});
+
+// #3457: the CLI folds BROWSE_PROXY_USER/PASS into the BROWSE_PROXY_URL it hands
+// the daemon, and the daemon inherits the CLI's environment. The daemon parses
+// that URL with the env creds exactly as server.ts does, so it must see one source.
+describe("daemon env carries one proxy credential source (#3457)", () => {
+ const userEnv = { BROWSE_PROXY_USER: "a", BROWSE_PROXY_PASS: "b" };
+ const daemonParse = (env: Record) => {
+ const { url, source } = serverProxyEnv(env, "linux");
+ return parseProxyConfig({ proxyUrl: url!, envUser: env.BROWSE_PROXY_USER, envPass: env.BROWSE_PROXY_PASS, source });
+ };
+ const spellings: Array<[string, string[], Record]> = [
+ ["--proxy", ["--proxy", "http://127.0.0.1:3128", "status"], {}],
+ ["BROWSE_PROXY_URL", ["status"], { BROWSE_PROXY_URL: "http://127.0.0.1:3128" }],
+ ["HTTPS_PROXY", ["status"], { HTTPS_PROXY: "http://127.0.0.1:3128" }],
+ ];
+
+ for (const [name, args, proxyEnv] of spellings) {
+ test(`${name} with BROWSE_PROXY_USER/PASS: start, restart and headed daemons parse the creds once`, () => {
+ const env = { ...userEnv, ...proxyEnv };
+ const flags = extractGlobalFlags(args, env, "linux");
+ for (const childEnv of [buildRestartEnv(flags, null), buildHeadedServerEnv(flags)]) {
+ const parsed = daemonParse({ ...env, ...childEnv });
+ expect(parsed).toMatchObject({ host: "127.0.0.1", port: 3128, userId: "a", password: "b" });
+ }
+ });
+ }
+
+ test("without a resolved proxy the daemon env leaves BROWSE_PROXY_USER/PASS alone", () => {
+ const env = buildRestartEnv(extractGlobalFlags(["status"], userEnv, "linux"), null);
+ expect(env.BROWSE_PROXY_USER).toBeUndefined();
+ expect(env.BROWSE_PROXY_PASS).toBeUndefined();
+ });
+});
diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md
index 1e3263109..ad8c801bf 100644
--- a/capy/skill/gstack/SKILL.md
+++ b/capy/skill/gstack/SKILL.md
@@ -52,7 +52,7 @@ Token caps on /ship: pass a brief's cap as `--token-cap `. /ship reads `cost.
Every Capy thread that uses gstack follows these for new work, under a Captain or on its own. The rule in full is `docs/captain.md`, "Plan before build"; the lane side is section 3 of captain.md in this folder.
-- **Plan first.** Before building new functionality, run `/autoplan` on it as the first act after the install check, sized to the work (scope flags and `--max-rounds`; a one-file fix gets `--no-ui --no-dx --max-rounds 1` with `--estimate-usd` and a small `--spend-cap`, never no run).
+- **Plan first.** Run `/autoplan` on new functionality right after the install check. A bug fix needs none (`gstack-autoplan next --kind fix`), just a repro or failing test in the PR. A feature gets the lean run (the Capy default), one reviewer over plan and draft code; `--full` adds every phase.
- **ELI5, then the word.** Send the plan and gate to the person, or the Captain, in plain language: `gstack-owner-brief render --captain --out --captain-name ""`, its choices as lettered lines in the ask format (`docs/captain.md`) and "do it" taking every recommendation at full scope. Build only after their word is answered into the run and it reports `APPROVED`; then `gstack-approvals import --autoplan --by ""`.
- **/ship before ready.** Run /ship at the final pushed head before a PR is called ready; `SHIP_RECEIPT: pr= head=` must name that head, and a later push needs a new /ship. The review inside /ship, like the autoplan, is sized to the change.
- **Stop a finished /ship subagent.** When a /ship subagent opens its PR, the parent subscribes to that PR with Capy's subscribe tool and keeps that one subscription until the PR merges or closes, re-subscribing if the watch expires, then cancels it. Once the subscription is in place and READY arrives, the parent stops the subagent and owns the PR from then on, slot stamp, re-READY and receipt carry included. A stopped /ship subagent woken by a check, review or merge notice, not by its parent, reports one line naming what woke it, its pin and the PR head (`gh pr view --json headRefOid --jq .headRefOid`) and ends the turn without acting, whether or not the head moved. A message from its parent is an instruction it carries out.
diff --git a/changelog.d/gsta214-a.md b/changelog.d/gsta214-a.md
deleted file mode 100644
index 216ddb5bd..000000000
--- a/changelog.d/gsta214-a.md
+++ /dev/null
@@ -1,9 +0,0 @@
-**Eval planning prices Claude Haiku 5.5, GPT-6 Luna and Gemini 4 Argon, and the Codex docs name the current OpenAI tiers (#3640, #2943).** Before, `gstack-eval-plan arms --arms claude-haiku-5-5` stopped with EVAL_PRICE_MISSING because `lib/pricing.ts` had no row for it. docs/hosts.md and the /codex example still named gpt-5.6-sol as the current Sol model.
-
-### Added
-- **Price rows** for `claude-haiku-5-5` ($0.10 / $0.50, cached $0.01, standard tier up to 100,000 tokens), `gpt-6-luna` ($0.10 / $0.50, cached $0.01) and `gemini-4-argon` ($4.00 / $20.00, cached $0.20). Each row cites its provider page. Argon uses the rates that start on 2026-12-01, so estimates never come in low after that date. A repository's model-policy document that names one of these models now gets it as an eval arm.
-
-### Changed
-- **docs/hosts.md and /codex** name `gpt-6-astra`, `gpt-6.1-sol` and `gpt-6-luna` as the current tiers. They also say the bounded-scope overlay still applies only to `gpt-5.6-sol` until it has its own eval.
-
-Thanks to @exGeni (#3640) and @DanCanadian (#2943) for the reports. The TypeSafe Jev rows from #3640 are not included, because a $0 output price breaks the price table's rule that every row has an output price.
diff --git a/docs/CONTRIBUTOR_REFERENCE.md b/docs/CONTRIBUTOR_REFERENCE.md
index caec81fd3..d429ffcb9 100644
--- a/docs/CONTRIBUTOR_REFERENCE.md
+++ b/docs/CONTRIBUTOR_REFERENCE.md
@@ -131,8 +131,8 @@ determined leaker (a CHANGELOG line that does would fail a hostile screenshotter
→ glab → unknown(=public-strict). Public repos get STERNER per-finding
confirmation (no batch-acknowledge, no silent-proceed); MEDIUM is never
auto-promoted to HIGH.
-- **Tool-attributed fences:** wrap Codex/Greptile/eval output in ` ```codex-review `
- / ` ```greptile ` fences so example credentials those tools quote WARN-degrade
+- **Tool-attributed fences:** wrap Codex/review-bot/Greptile/eval output in ` ```codex-review `
+ / ` ```review-bot ` / ` ```greptile ` fences so example credentials those tools quote WARN-degrade
instead of blocking. A live-format credential inside the fence still blocks.
- **Config keys:** `redact_repo_visibility` (public|private|unknown, local-only
override for repos gh/glab can't read), `redact_prepush_hook` (true|false).
diff --git a/docs/pregate.md b/docs/pregate.md
index 9128fa093..4abfcd628 100644
--- a/docs/pregate.md
+++ b/docs/pregate.md
@@ -85,7 +85,7 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`).
| `serial_patterns` | test-file globs the `lanes` check runs one process per file, apart from the unit files (default `**/*.serial.test.*`) |
| `sealed` | sealed or eval output that must never reach a public destination; read by the pre-push hook and `gstack-pregate sealed` (see [sealed output](#sealed)) |
| `price_file` | repo-relative path of the paid gate's call-plan data file (`GSTACK_EVAL_CALLS v1`); `gstack-eval-plan price-gate` prices from it and never executes anything ([docs/eval-calls.md](eval-calls.md)) |
-| `stall_timeout_s` | seconds a test command may print nothing before its process group is killed and the check is `incomplete` (`PREGATE_STALLED`); default 300, `0` disables; `--stall-timeout` overrides; the `--remote` gate is exempt (see [supervision](#supervision)) |
+| `stall_timeout_s` | seconds a test command may print nothing while using under a tenth of one CPU before its process group is killed and the check is `incomplete` (`PREGATE_STALLED`); default 300, `0` disables; `--stall-timeout` overrides; the `--remote` gate is exempt (see [supervision](#supervision)) |
| `timeout_s` | seconds each runner process may take (the unit-file group, each serial file, each guard without its own `timeout_s`, and the remote gate's deadline) when `--timeout` is not passed; 1 to 3600, default 600. `gstack-ship-preflight` and the `finish` fallback run pregate without `--timeout`, so this is how a repo with long lanes sets theirs |
| `remote_gate` | the repo's remote test gate command; under 8 cores the local test tier becomes a `requires-remote` obligation naming it, and `--remote` runs it (see [remote gate](#remote-gate)) |
| `run_profiles` | name → `{ workflow, events, jobs, artifact, max_age_hours?, os? }`: the GitHub Actions runs `clear --run` verifies as evidence on the identical tree; a profile named after a lane (`free-tests.yml/macos-named-regressions`) clears that lane (see [run evidence](#run-evidence)). `--from-run` and the main-push reuse job verify runs against them too ([from-run](#from-run), [CI reuse](#ci-reuse)) |
@@ -228,6 +228,18 @@ Two stages with separate clocks, both printed:
elsewhere in the file is not a consumer. A call-site name such as `attempts`
or `limit` is an ordinary word, so for it the file must also name the changed
module.
+ A test that keeps the old value on purpose, such as the refused input after a
+ minimum version goes up, marks that one line with a comment on it or on the
+ comment line directly above it:
+ `// pregate-literal: MIN_RUNTIME='1.4.0' refused input below the minimum`
+ (`#` and `/*` comments work too). The marker waives only that line, and only
+ when it names the changed constant and its old value and gives a reason (a
+ reason cannot start with `*`, so a bare `*/`, `**/` or `* */` closer is not
+ one); text inside a string is not a marker. Every other line in the file is still
+ checked. The check lists each waived line as
+ `waived: :: ()`, adding `marker added on
+ this branch` when the merge-base file lacks the marker. The policy has no
+ waiver key, so neither a base nor a pushed `pregate.json` can widen it.
- `timing`: added or changed test lines that compare an elapsed wall-clock or
CPU time against a bound, or sleep and then assert, print
@@ -447,10 +459,17 @@ test-tier command:
a one-file run (each serial file) reports its file from the launch for any
runner. Other runners (node --test, vitest, jest, pytest, playwright test, flutter test, dart test, cargo test) get heartbeats
only. A heartbeat follows each 60 s with no progress line;
-- is killed as **stalled** when its log does not grow for the stall timeout
- (`--stall-timeout `, else `stall_timeout_s`, else 300; `0` disables): the
- check is `incomplete` with `PREGATE_STALLED` and names the last file
- started; the next serial file still runs. The `--remote` gate is exempt: a
+- is killed as **stalled** when its log does not grow and its process group
+ stays under a tenth of one CPU for the stall timeout (`--stall-timeout `, else
+ `stall_timeout_s`, else 300; `0` disables): the check is `incomplete` with
+ `PREGATE_STALLED` and names the last file started; the next serial file
+ still runs. The group counts as busy when its CPU time, sampled each second,
+ grew by at least a tenth of the wall time, so a quiet suite that is working
+ (FOUN-63: a parallel `check.sh` silent for 9 minutes) is not killed, while
+ a hang that waits on nothing is. A hang that keeps burning CPU is left to
+ the timeout. Linux reads `/proc` (exited children's time included once
+ reaped); macOS and other BSDs read `ps -S`; a failed read skips that
+ sample. Windows reads no CPU time here and uses log growth alone. The `--remote` gate is exempt: a
gate that waits on CI is quiet by design, so only its timeout bounds it, and
its heartbeats add `; last output: `;
- is killed as **timed out** when that runner process reaches its timeout
diff --git a/docs/review-policy.md b/docs/review-policy.md
index 3362090f0..b886d927b 100644
--- a/docs/review-policy.md
+++ b/docs/review-policy.md
@@ -207,7 +207,14 @@ function but a same-file `url(#id)`, which closes `@import`, `image-set()`,
entity declaration, and no DOCTYPE but a bare `` or W3C's own SVG DTD, read after character references are decoded; anything else, a
script or `foreignObject` included, keeps it code. More than
2,000 data paths keep every one code; a `git grep` failure is `consumer_scan_failed`
-and fails the depth closed. Only `classifyDiff` applies the rule (`lib/review-collect.ts`
+and fails the depth closed. The scan first lists the files that contain a candidate's
+extension and then searches only those, docs files left out, so a large data tree
+costs one cheap pass. Each scan gets one 120-second deadline, set in milliseconds with
+`GSTACK_CONSUMER_SCAN_TIMEOUT_MS`; a scan that runs past it, or whose matches pass
+256 MB, is also `consumer_scan_failed`, and its detail says what stopped it and names
+the deadline. One scan fits a minute inside the 180 seconds /ship gives
+`gstack-review-plan`; a plan that also scans a fix range runs two scans, and if they
+pass that limit the plan fails, which also leads to a full review. Only `classifyDiff` applies the rule (`lib/review-collect.ts`
finds the consumers, `gstack-ship-preflight`'s `RUNTIME_LINES` reads the same
function); every path-only caller (the receipt's gate-scan decision, the Step 16
post-review route, /autoplan's docs-only scope) keeps data files code. A numeric claim
@@ -424,6 +431,24 @@ Over the last 100 non-release commits on main (8663c3d2..8be7f213), money hit
which hit 43 of 100; those paths already get full depth, so the gate class was
narrowed to the gate scan's tagged hunks.
+
+## What readers read under `review_exclude_paths`
+
+A repository can list content folders its reviewers leave out, on its base
+branch only ([ship-policy.md](ship-policy.md#review-exclude-paths)). Under it,
+every reader (specialists, the Red Team, the native and outside voices, the
+targeted reader) reads the same selection: its `DIFF_CMD:` with one
+`:(top,exclude,glob)` item per applied pattern, the fixture command with the
+same items, and the `DIFF_KEEP:` command for the kept files under those
+patterns. The outside voice's embedded diff is that selection. Each brief says
+how many files and bytes were left out and that the reader must not report on
+their absence. Excluded paths count as docs for the depth and never trigger the
+numeric-claim scan or a targeted class; kept files keep their own class.
+
+Independently of the key, no reader is handed more than 2,000,000 bytes of
+diff: `gstack-review-dispatch` stops with `REVIEW_DIFF_TOO_LARGE` before it writes
+anything, instead of a review of the part a reader happened to see.
+
## What the receipt records
`gstack-review-scope summary --since --run ` reads this invocation's
@@ -674,6 +699,7 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends:
- `scripts/resolvers/review-army.ts`: the floor and `NO FINDINGS` in every specialist and Red Team prompt (rendered into `review/sections/review-army.md` and `ship/sections/review-army.md`), and the lead judgment's "On record" line that turns a [ruled class](#rulings-below-the-floor) into a note; in `/ship` every specialist and Red Team prompt it renders carries its diff base as a literal sha the parent writes in (`DIFF_BASE=`: the merge base on pass 1, `RANGE_BASE` from pass 2 on) beside its plan's `DIFF_CMD` excludes, because a reviewer is its own process and never sees the parent's shell variables; the fan-out batches above.
- `ship/sections/review-army.md` Step 9: the plan's `REVIEW_DEPTH`, `GATE_SCAN` and `--depth-floor` on every run, the depth fields of the review-log row, passes, `RANGE_BASE`, classify, defer, render, the cap check before any edit, the Step 11 late-defer row, scratch files under `.gstack/tmp/`, the cap decisions; `ship/sections/adversarial.md` Step 11: the outside voice reads the same range and scopes queued fixes the same way.
+- `lib/review-exclude.ts`: the base policy's `review_exclude_paths` (matched set, kept reasons, the kept-file argument bound, the digest the receipt, carry and the queue compare) and the 2,000,000-byte reader ceiling (`test/review-exclude.test.ts`).
- `lib/review-depth.ts`, `bin/gstack-review-plan`: the diff class, the depth (`depthFor`, `DEPTH_RULES`, `depthOfCommitInput`), each dispatch's `exclude` pathspecs and `DIFF_CMD`, the transition table, the numeric-claim scan and each role's effort tier (`test/review-depth.test.ts`, `test/review-plan-cli.test.ts`).
- `lib/review-collect.ts`: `collectCommit` (git objects only, from any checkout) and `collectLive` (the working tree), per-path lines and the base commit's registry (`test/review-collect.test.ts`).
- `lib/review-scope.ts`, `bin/gstack-review-scope`: `classify`, `defer`, `render`, `summary` (with `fix_checks`, `dispatches`, `est_input_tokens`, `unrecorded`, `ledgers_read`); refusals are `REVIEW_SCOPE_INPUT`, `REVIEW_SCOPE_RANGE` and `REVIEW_SCOPE_LEDGER` ([troubleshooting.md](troubleshooting.md#review-scope-input)).
diff --git a/docs/ship-policy.md b/docs/ship-policy.md
index 7fd896a28..d76edb603 100644
--- a/docs/ship-policy.md
+++ b/docs/ship-policy.md
@@ -58,6 +58,7 @@ refuses the file (`POLICY_GSTACK_TOO_OLD`). `validate` ends with
| `gate_command` | command or `null` | `null` | The repo's full pre-merge gate. `gstack-ship-queue gate-ahead` and `ready` print it for the caller to run (in the background or in CI); gstack never executes it. Needs `min_gstack_version`. |
| `full_e2e` | `true` \| `false` | `false` | `true`: every merge needs a passing full-gate result bound to a tree equal to the merging one modulo stamps; `gstack-ship-queue ready`, `stamp` and `verify` refuse without one (`QUEUE_GATE_MISSING`): a gate-ahead record, or the lane's `--gate-exit 0 --gate-log ` from its run on the gated head. Needs `min_gstack_version`. |
| `review_scope` | `frozen` \| `diff-plus-blocking` \| `all` | `frozen` | The finding scope of `/ship`'s review loop; see [review_scope](#review-scope). `frozen` fixes the diff on pass 1, files blocking findings in unchanged code as issues, and later passes read only the range since the previous pass. `diff-plus-blocking` is the same but fixes a blocking finding in unchanged code in this PR instead of filing it. `all` is the earlier loop: every finding is fixed on every pass. Needs `min_gstack_version` when not `frozen`. |
+| `review_exclude_paths` | git glob pathspecs | `[]` | Content files (data, docs, binary, `.log`) `/ship`'s reviewers leave out; read from the base only, with code, gate, config, executable and named-by-code files kept. See [review_exclude_paths](#review-exclude-paths). Needs `min_gstack_version`. |
| `min_gstack_version` | version or `null` | `null` | The oldest gstack that may read the file; see the compatibility rule above. |
| `docs` | `{ caps: [{ path, max_bytes }] }` or `null` | `null` | Documentation files with a byte cap. `/ship` Step 14.5 runs `gstack-ship-receipt docs-caps --base ` (the caps come from `origin/`'s policy, never the branch's copy; `--policy ` is for a repository bootstrapping its policy) before the verification gate and prints one `DOCS_CAP: / ok\|over` line per cap; any `over` (an absent file counts) exits 1 and blocks the ship until the file is trimmed or the cap raised on the base branch (`DOCS_AUDIT_BLOCKED`). At most 64 caps, each path contained. See [the ship receipt](#ship-receipt). Needs `min_gstack_version`. |
| `train` | `{ gate_workflow, free_workflow, full_gate_check, record_artifact, max_size, fill_hours, screen_selection, trial_reservation_usd, land? }`, `{ gates, land, max_size, fill_hours }` or `null` | `null` | Turns on [merge trains](#run-a-merge-train), read from the base revision only. Every field is required and an unknown field is `POLICY_INVALID`: `max_size` 1 to 6, `fill_hours` above 0 and at most 24, `screen_selection` `gstack-paid` \| `none`, `trial_reservation_usd` above 0 and at most 100. A block with `gates` ([any repository](#train-gates)) takes `max_size` 1 to 5 and needs `min_gstack_version` at least `TRAIN_GATES_KEY_VERSION`. `null` keeps the per-PR queue exactly as it is. Needs `min_gstack_version`. |
@@ -1252,6 +1253,127 @@ opens no fence, and a fence that never closes stays prose, along with the rest
of the body. Data PRs (evals,
measurements, sweeps) run it before READY so the body's numbers are the run's.
+
+## `review_exclude_paths`: what reviewers leave out
+
+Since the release that added it (`REVIEW_EXCLUDE_KEY_VERSION` in
+`lib/ship-policy.ts`). A results PR can carry thousands of receipt files that
+no reviewer needs to read. `review_exclude_paths` lists git glob pathspecs
+whose content files `/ship`'s reviewers leave out:
+
+```json
+{ "review_exclude_paths": ["receipts/**"], "min_gstack_version": "" }
+```
+
+**Read from the base only.** `gstack-review-plan` reads the policy committed on
+the base commit it already resolved (`origin/`, or ``), never
+the branch's copy, so a branch cannot exclude its own files. A branch that adds
+the key gets `REVIEW_EXCLUDE: none (head sets review_exclude_paths; it applies
+after it lands on )` and a normal review. A base policy that does not
+validate stops the plan (`POLICY_INVALID`), and a base git cannot read stops it
+(`POLICY_SOURCE_UNAVAILABLE`); neither turns exclusion off quietly.
+
+**Git's glob dialect.** Each pattern is a `:(top,glob)` pathspec: root-anchored
+and case-sensitive. The matched set comes from git itself (`git diff --raw -z`),
+so the plan, the receipt and what readers see are one set. Untracked files are never part of it: no reader's `git diff` shows them, and the receipt reads the committed head.
+
+| Pattern | gitignore would match | `review_exclude_paths` matches |
+|---|---|---|
+| `receipts/**` | everything under `receipts/` at any depth | everything under the top-level `receipts/` |
+| `receipts/*` | files directly in `receipts/` | files directly in `receipts/` only (`*` never crosses `/`) |
+| `data/` | any `data/` directory at any depth | nothing: write `data/**` (and `**/data/**` for any depth) |
+
+A pattern that matches no changed file is listed as `unmatched=` on the plan line.
+
+**What is excluded, and what is kept.** A matched file is excluded only when it
+is content: a data file (JSON, JSONL, CSV, TSV, images), a docs-class file, a
+`.log` file, or a file git reports as binary (`-\t-` in `--numstat`; readers would only see `Binary files differ`) whose bytes hold a NUL in their first 8,000, so a branch's `.gitattributes` cannot make a text file binary content. A binary file is content even under
+`config/` or in a skill directory; only `policy-dir`, `gate-path`, `executable`, `link`, `gate-tagged`, or a source or script extension (`code`) keep it.
+Everything else stays in every reader's diff with one reason code:
+
+| Reason | Kept because |
+|---|---|
+| `policy-dir` | it is under `.gstack/` or `.github/` |
+| `gate-path` | it is a gate path (lib/review-depth.ts) |
+| `executable` | its old or new mode is executable |
+| `link` | its old or new mode is a symlink or a submodule commit |
+| `agent-prose` | it is a skill, section, resolver, template or rule file |
+| `config` | it is a data-shaped config file (`package.json`, `tsconfig*.json`, `config/`, dotfiles) |
+| `gate-tagged` | the gate scan tagged one of its hunks |
+| `code` | it is code or a test, not content; a binary file with a source or script extension (`.js`, `.py`, `.sh`, `.wasm`) counts as code |
+| `secret-line` | an added line reads a credential (the targeted pass's secret rule) |
+| `named-by-code` | a code or test file that stays in review (not docs, a data file, a `.log` or binary, and not itself excluded) names its full repository path, at the head or the merge base |
+| `consumer-scan-unavailable` | the `git grep` for those names failed, so every candidate is kept; also a live binary file whose bytes could not be read |
+| `gate-scan-unavailable` | the gate scan failed or did not run at the head, so no hunk's tag is known and every candidate is kept |
+
+Only a full path counts as a name. A basename such as `result.json` would keep
+every receipt in the tree, and a directory reference such as `receipts/` (the
+harness that writes the receipts names it) keeps nothing: the plan prints an
+informational `REVIEW_EXCLUDE_REFERENCE:` line naming the code that reads the
+folder. A data file never keeps another file: a results manifest such as
+`docs/receipts-manifest.json` lists every receipt by full path, and it is
+content, not a program that reads them. The name scan is one uncapped `git grep -F -f` over the candidates'
+paths, not the collector's capped consumer scan.
+
+**What readers run.** Every reader's `DIFF_CMD:` gains one
+`':(top,exclude,glob)'` item per applied pattern, and the fixture
+command the security specialist, the Red Team and the native adversarial pass
+read through gets the same items. A git exclude beats any positive pathspec, so
+kept files under an applied pattern come back through a second command,
+`DIFF_KEEP: git diff -- ':(top,literal)' …`, which every
+brief names. That command is bounded by its argument bytes (24,000 across all
+patterns, under Windows' command-line limit): a pattern whose kept files would
+pass the bound is not applied, and the plan line says so as
+`skipped=:kept=:bytes=`. Every brief also gets one line:
+`Excluded by base policy [@ (selection ): files, bytes
+on this branch matching . They are not in your diff; do not report on
+their absence.`
+
+**Depth, numbers and the targeted pass.** Excluded paths count as docs for the
+review depth everywhere it is computed (`gstack-review-plan`, the receipt
+writer, carry and the queue), and their lines never trigger the numeric-claim
+scan or the targeted money, gate, auth and secret classes. Defects inside a
+correctly excluded data file are coverage the owner gives up by policy.
+
+**The plan line.** `REVIEW_EXCLUDE: patterns=/ excluded=
+bytes= kept= content=:,… source=][@ digest=`
+(`content=` counts the excluded files by kind: `binary`, `data`, `docs`, `log`), then up to 20
+`REVIEW_EXCLUDE_KEPT: ` lines (`--json` lists every one). A
+repository with no key anywhere prints none of these lines, and its plan, briefs
+and diff commands are byte for byte what they were.
+
+**The receipt, carry and the queue.** Each rendered brief records the digest it
+names in the review ledger, and `gstack-review-dispatch verify` prints
+`REVIEW_EXCLUDE_RECORD: digest=` when every reader's newest brief names the
+same one (a mixed set makes it `VERIFY: dirty`). `gstack-ship-receipt write
+--review-exclude `, with that digest, records `review_exclude` (source, patterns, counts, the gate-tagged kept
+paths and three hashes). The `digest` hashes the declared patterns and the
+excluded and kept lists; `source` is provenance only. Write, carry and the queue
+each recompute the digest at the current base and compare it, so a base merge
+or a moved base that leaves the patterns and the lists unchanged still carries
+and binds. A different digest means the reviewers read another selection:
+write refuses with `RECEIPT_INVALID field=review_exclude`, carry with
+`RECEIPT_CARRY_REFUSED review_exclude changed`, and the queue with
+`RECEIPT_INVALID field=review_exclude expected= recorded=`. The
+fix is to rerun the review, never a receipt write alone.
+
+**Adopting it.** (1) Open a PR on the base that adds the patterns and
+`min_gstack_version` at least `REVIEW_EXCLUDE_KEY_VERSION`, and ship it like any
+policy change. (2) Once it merges, bring the base into the results branch (a
+crew needs its coordinator's word for that merge) and rerun `/ship`; the plan
+prints `REVIEW_EXCLUDE:`. (3) Check the kept list: a file you expected to be
+excluded names its reason.
+
+
+**The review ceiling.** Independently of the key, `gstack-review-dispatch` reads
+each reviewer's diff once before it writes a manifest or a brief (`pass`, `stage`
+and `render`). A diff over 2,000,000 bytes (about 500K tokens) stops with
+[`REVIEW_DIFF_TOO_LARGE`](troubleshooting.md#review-diff-too-large), exit 3, and a
+fix chosen by where the policy stands; a `git diff` that fails or runs past
+120 seconds stops with
+[`REVIEW_DIFF_UNREADABLE`](troubleshooting.md#review-diff-unreadable), exit 1.
+There is no override flag: the recovery is a policy PR on the base, or a split.
+
## `review_scope`: how the review loop converges
diff --git a/docs/skills.md b/docs/skills.md
index d5315e262..18dadd110 100644
--- a/docs/skills.md
+++ b/docs/skills.md
@@ -631,6 +631,8 @@ One exception: a shortcut you took deliberately and logged. A `gstack-shortcut(d
**Design pass.** When the diff touches frontend files, the Design specialist reads `review/design-checklist.md`, which is generated from `lib/design-catalog.ts`, so `/review`, `/ship`, and `/design-review` flag the same patterns under the same rule ids. If you have [impeccable](https://impeccable.style) installed, its engine scans the changed frontend files first (`.dart` files get the checklist pass only): its rows bucket by tier (auto-fix, ask, possible), a detector hit and a checklist hit at the same file:line collapse into one row, and your repo's `.impeccable/config*.json` ignores are read as settled decisions. Without it, the checklist pass runs alone.
+**PR type.** The checklist starts by naming the kind of PR from the changed paths: docs-only, CI/infra, scripts/tooling, application or mixed, and the review's first line says which (`PR type: `). Application and mixed diffs run every category as before. A CI/infra, scripts/tooling or docs-only diff always gets Gate Integrity, Shell Injection, Completeness Gaps, Distribution & CI/CD Pipeline and Repo Conventions & Platform Fit; every other category with nothing to check is listed once, in one line, as not applicable, and never counts as passed. Repo Conventions & Platform Fit flags new files that don't match the languages and tools the repo already uses or the platforms its CI runs on, such as a `.bat` build script in a bash-and-Makefile repo.
+
### Example
Suppose the smart listing flow is implemented and the tests are green.
@@ -1538,7 +1540,7 @@ Install Greptile on your GitHub repo at [greptile.com](https://greptile.com) —
The problem with any automated reviewer is triage. Greptile is good, but not every comment is a real issue. Some are false positives. Some flag things you already fixed three commits ago. Without a triage layer, the comments pile up and you start ignoring them — which defeats the purpose.
-gstack solves this. `/review` and `/ship` are now Greptile-aware. They read Greptile's comments, classify each one, and take action:
+gstack solves this. `/review` and `/ship` read your review bots' comments (Greptile, CodeRabbit, Macroscope, or any bot you name), classify each one, and take action:
- **Valid issues** get added to the critical findings and fixed before shipping
- **Already-fixed issues** get an auto-reply acknowledging the catch
@@ -1548,7 +1550,11 @@ The result is a two-layer review: Greptile catches things asynchronously on the
### Learning from history
-Every false positive you confirm gets saved to `~/.gstack/greptile-history.md`. Future runs auto-skip known FP patterns for your codebase. And `/retro` tracks Greptile's batting average over time — so you can see whether the signal-to-noise ratio is improving.
+Every false positive you confirm gets saved to `~/.gstack/review-bot-history.md`, tagged with the bot that made it. Future runs auto-skip that bot's known FP patterns for your codebase. And `/retro` tracks each bot's batting average over time — so you can see whether the signal-to-noise ratio is improving.
+
+### Choosing your bots
+
+The bots are the two lists `gstack-pr-wait` uses: `gstack-config set pr_wait_gating_bots greptile,macroscope` (waited for, then triaged) and `gstack-config set pr_wait_advisory_bots coderabbit,copilot` (triaged, never waited for). Set both to `none` to turn bot triage off. Greptile's early review (`/ship` opening the PR early) runs only while `greptile` is in one of the lists.
### Example
diff --git a/docs/standalone-binaries.md b/docs/standalone-binaries.md
index 8518318d6..71f2ef47c 100644
--- a/docs/standalone-binaries.md
+++ b/docs/standalone-binaries.md
@@ -21,6 +21,7 @@ Beyond the slash-command skills, gstack ships standalone CLIs for workflows that
| `gstack-evidence` | **Verification-evidence ledger** — `run --label -- ` transparently wraps any test command (the child's exit code always passes through) and records what ran against which working-tree fingerprint (tracked and untracked files that are not gitignored, minus an untracked `.bun/` folder at the repo root, which Bun can create during a run); a run whose files changed while it ran is recorded without a fingerprint and with up to five of the changed paths, which `check` names in its STALE reason. `check` grades each label FRESH/STALE/MISSING with `--expect-cmd`, `--max-age`, and `--allow-paths` binding. /ship and /land-and-deploy cite fresh evidence instead of re-running suites. Per-run logs are 0600, capped at 2MB, pruned after 30 days; the ledger and logs stay machine-local by design. |
| `gstack-flaky` | **Flaky-test rerun for any JUnit runner** — `run --junit -- ` runs your test command (Playwright, `bun test`, jest, vitest, pytest) and, when at most `--max-reruns` (3) tests failed and the runner made no retries of its own, reruns the whole command once (`--rerun-scope test` opts in to rerunning only the failed tests). A test that fails and then passes on that rerun is a flaky pass: the run stays green and one redacted line per flaky pass or failed rerun goes on a ledger (`GSTACK_FLAKE_LEDGER_JUNIT`, default `/flake-ledger-junit.jsonl`). A missing or malformed report, a crash or a killed runner never passes. `quarantine add|renew|release|check|report` keeps `.gstack/quarantine.json`: a quarantined test still runs, gets up to 3 attempts of the whole command, passes only on a full green attempt and is never counted as a pass (`run --base ][` reads the registry from the base branch). `ids --junit ` prints a report's test ids. Exit 0 pass, 1 a test failed, 2 usage, 3 no usable report. [Guide](flaky.md). |
| `gstack-pr-wait` | **PR event watcher** — `gstack-pr-wait [--repo owner/name] [--until green\|ready\|any-change] [--interval 20] [--timeout 60] [--exclude CHECK,...]` polls one pull request through `gh` and prints one stdout line per real event (a check failed with its link, all checks finished, a comment or review from someone other than the author, a review bot finished on the head, the base moved and its new commits touch this PR's files, BEHIND or DIRTY, merged or closed), with progress on stderr, so a monitor wakes the agent once per event instead of on a timer. `green` gates on every check on the head (an advisory bot's review is never waited for, its checks gate like any other), as `gstack-ci-gate` does; a check the base branch requires (rulesets and classic branch protection) is pending until it appears, never green by omission; two identical polls confirm a check set is complete; `--exclude` leaves a named non-required check out of the verdict as `gstack-ci-gate --exclude` does (a required name is refused and gates). Exit 0 when the condition is met, 1 when it needs action or `--timeout` passes (`PR_WAIT_BLOCKED`), 2 on bad input, a second watcher for the same PR or a local error (`PR_WAIT_REFUSED`). State under `/pr-wait/`, baseline discarded after 6 hours. Review bots are configuration: `gstack-config set pr_wait_gating_bots greptile,macroscope`, `pr_wait_advisory_bots coderabbit` (advisory bots are reported, never waited on). `/land-and-deploy` Step 3 is one call of it. Design ported from tstack's pr-wait (MIT, Theo Browne; NOTICE.md). [Exit codes](troubleshooting.md#gstack-pr-wait). |
+| `gstack-review-bots` | **Review-bot comments** — `fetch [--pr ]` reads every root line comment, conversation comment and newest review body from each bot in `pr_wait_gating_bots` and `pr_wait_advisory_bots` (Bot accounts only, all pages, through `gh`) into a private directory with per-bot counts, or prints `REVIEW_BOTS: skip` or `REVIEW_BOTS: error` (never a false zero). `record --dir --id --type fix\|fp\|already-fixed --category ` appends one triage outcome to the project and global `review-bot-history.md`, taking the bot and file from the fetched comment. `/review` Step 2.5 and `/ship` Step 10 run it. [Triage reference](../review/greptile-triage.md). |
| `gstack-issue-guard` | **Tracker-text trust envelope** — fetches GitHub issue/PR text (`issue `, `pr-body`, `pr-comments`, or `--stdin`) and wraps it in a labeled envelope so agents treat it as data: injection-shaped lines get labeled even through fullwidth and invisible-character evasion, and forged envelope banners are defused. Every tracker-text ingress in gstack routes through it, enforced by a CI scanner. |
| `gstack-ios-qa-daemon` | **iOS QA daemon** — Mac-side broker between an agent and a connected iPhone or iPad over USB CoreDevice. Loopback by default; `--tailnet` opens a Tailscale-facing listener with identity-gated capability tiers. Single-instance via flock on `~/.gstack/ios-qa-daemon.pid`. See [docs/howto-ios-testing-with-gstack.md](howto-ios-testing-with-gstack.md). |
| `gstack-ios-qa-mint` | **iOS allowlist manager** — owner-grant CLI for the tailnet allowlist. `grant`/`revoke`/`list` against `~/.gstack/ios-qa-allowlist.json` (mode 0600). Remote agents never auto-allowlist; this is the explicit-intent path. |
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index d78dfb536..56add0826 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -2434,7 +2434,10 @@ default `fetch-depth: 1`), a remote branch never fetched
first parent is missing from a shallow history. The tool never guesses a
base, because an empty diff against a wrong or absent base would read as "no
gate edits" and pass review. `gstack-diff-scope` prints `SCOPE_ERROR=no_base`
-(all flags false, exit 2) for the same cause.
+(all flags false, exit 2) for the same cause, and also when no base was given
+and none resolves because `origin/HEAD` names the checked-out branch (or its
+upstream, or HEAD's own commit) and no PR base, live remote HEAD, `origin/main`
+or `origin/master` resolves (its comment line says so).
**Fix.** Run the command the line prints. It is built for this checkout:
`git fetch origin +:refs/remotes/origin/` for an unfetched
@@ -3330,6 +3333,72 @@ block. For the `--head` refusal, check out that commit and write there. For a
targeted refusal, run the targeted reader on HEAD, `gstack-review-dispatch verify
--targeted-out .gstack/tmp/targeted.json`, and write again with
`--review-targeted @.gstack/tmp/targeted.json`.
+
+**`field=review_exclude`.** `gstack-ship-receipt write` and the queue's receipt check also
+use this code when the receipt's review_exclude digest (or the
+`--review-exclude ` write was given) is not the digest HEAD has under the
+base policy's [`review_exclude_paths`](ship-policy.md#review-exclude-paths). The line
+names `expected=` (now), `recorded=` (what the review read, `none` when it read
+without exclusion) and `source=` (the base commit). The reviewers read another
+selection than the one the head now has, so a receipt write alone cannot clear
+it: rerun `/ship`'s review on the head (`gstack-review-dispatch pass --base `),
+run `gstack-review-dispatch verify --ledger `, which prints
+`REVIEW_EXCLUDE_RECORD: digest=]