v1.93.38.0 stamp: merge main 6f1dfd5b7c into #3894 (land)

This commit is contained in:
garrytan committed 2026-11-03 02:45:37 +00:00
commit bbbbd61de6
198 files changed
+6053 -760

No files matched your search

+27 -5
View File
@@ -2,7 +2,8 @@
/**
* gstack-autoplan — the unattended /autoplan runner as an explicit, durable state machine (plan B2).
*
* gstack-autoplan next --out <dir> [--plan <file>] [--plan-heading <text>] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--docs-only] [--ceo-only]
* gstack-autoplan next --kind fix a bug fix: no plan review (R65), status=skipped, nothing written (run --kind fix too)
* gstack-autoplan next --out <dir> [--plan <file>] [--kind feature|--full] [--base <ref>] [--plan-heading <text>] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--docs-only] [--ceo-only]
* [--no-outside] [--light] [--spend-cap <usd>] [--estimate-usd <n>] [--deadline <min>] [--redispatch <attempt>] [--json]
* gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --result <file> | --record <file> [--attest <reason>]
* --model <id> [--attempt <id>] [--runner <r>] [--usage-usd <n>] [--reconcile <jsonl>] [--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 <dir> [--plan <file>] [--plan-heading <text>] [--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 <dir> [--plan <file>] [--kind feature|--full] [--base <ref>] [--plan-heading <text>] [--scaffold] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--no-dx] [--docs-only] [--ceo-only]
[--no-outside] [--light] [--spend-cap <usd>] [--max-rounds <n>] [--estimate-usd <n>] [--deadline <min>] [--redispatch <attempt>] [--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 <out>/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 <dir> --plan <file> --native-model <id> --outside-model <id> [--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 <dir> --gate-rev <rev> --reply "<rec | rec except d3a | d3b uc1a | as-is>" --by <human|agent:<id>> [--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<number> {
async function doRun(out: string, args: string[], json: boolean): Promise<number> {
// 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<number> {
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 <dir>`);
const out = path.resolve(outFlag ?? '.');
+3 -1
View File
@@ -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).
#
+55 -10
View File
@@ -5,6 +5,9 @@
# Or: gstack-diff-scope <base> --head <sha> → the committed range <base>...<sha> 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 <base> --head <sha> --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/<base>, 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=()
+7 -1
View File
@@ -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);
+4
View File
@@ -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' ;;
+24 -5
View File
@@ -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=<ref> fix=<git command>` 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=<ref> fix=<git command>` as the only stdout line;
* `ref=<remote>/HEAD target=<remote>/<branch>` 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=<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} <base>\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 });
+29 -25
View File
@@ -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<string, McpServerEntry> }
| { mcpServers?: Record<string, GbrainMcpEntry> }
| 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) {
+26 -11
View File
@@ -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<ProbeReport> {
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;
}
+5 -1
View File
@@ -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 <check,...> restrict to these checks (${CHECK_IDS.join(', ')}; tier 2 later: ${PREGATE_TIER2.join(', ')})
--base <branch> a branch on origin, not a commit (default: origin/HEAD's branch, else main)
--base <branch> 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)
+1
View File
@@ -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"
+27
View File
@@ -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 <n>]
* gstack-review-bots record --dir <REVIEW_BOTS_DIR> --id <comment id> --type fix|fp|already-fixed --category <c>
*
* 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),
});
+78 -14
View File
@@ -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 <role> [--name <n>] --out <dir> [--ledger <f>] [slots]
roles: ${BRIEF_ROLES.join(', ')}
slots: --diff-base <sha> (every role but fix-check) --exclude '<DIFF_CMD tail>' --stack <s> --test-fw <s>
--keep '<DIFF_KEEP tail after <diff-base>>' --disclosure '<the line every brief carries>' (both only under review_exclude_paths)
--learnings-file <f> --checklist <path> (default <root>/review/specialists/<name>.md; red-team.md for red-team)
--checklists <a,b> (specialist-batched: the plan's checklists= names, or paths) --findings-file <f> --specialists <n> (red-team)
--fix-list <f> --hunks <f> --tests <f> [--callers <f>] (fix-check)
@@ -58,7 +60,9 @@ const HELP = `gstack-review-dispatch — verbatim reviewer briefs and loss-free
verify --ledger <f> [--targeted-out <file>]
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=<d> (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 <ref> --out <dir> --pass <id> [--ledger <f>] [--diff-base <sha>] [--learnings-file <f>] [--extras <json>] [--audit-only]
[--checklist-root <dir>] [--fix-list <f> --hunks <f> --tests <f> [--callers <f>]] [-- <gstack-review-plan flags>]
runs gstack-review-plan and writes <out>/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 <f> MANIFEST_JSON: <the manifest on one line>
stage --manifest <f> --stage <first|specialists|red-team|voices>
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 <f> --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<string, ReaderDiff>();
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<PassReader, 'exclude' | 'keep'>) => {
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<Pick<PassReader, 'reader' | 'exclude' | 'keep' | 'diff_base' | 'prompt_file'> & { 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<string, unknown> & { 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<string, unknown> & { state: string; excluded?: number }) | undefined): { reviewExclude?: Record<string, unknown>; 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<typeof disclosureLine>[0]) } : {}) };
}
function passStep(cmd: string): number {
const file = flag('--manifest');
if (!file) { process.stderr.write(`gstack-review-dispatch ${cmd}: --manifest <file> 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<string, Record<string, unknown>>();
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;
+41 -11
View File
@@ -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<string, string> => {
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<string> | 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 = <T,>(rec: Record<string, T>) => (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=)`);
+2 -2
View File
@@ -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 <branch> (default origin/HEAD, else main) and
* Every subcommand takes --base <branch> (default lib/default-base.ts) and
* ends with GSTACK_RESULT: skill=ship-queue status=complete|refused run=<path>.
* 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 <branch>: base branch (default: origin/HEAD, else main). Nothing here pushes, except land (the stamp commit and the
--base <branch>: 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()}`;
+20 -4
View File
@@ -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 <jso
"Review-loop exception: …" line verbatim; else REVIEW_LOOP_EXCEPTION_INVALID). Every receipt records gstack_version and
review_depth: the depth HEAD needs, recomputed from the committed range (--head must be HEAD; a dirty checkout or an
unreadable input records full).
--review-exclude <digest|none> (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 <json|@file> ({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 <ledger> and write with the --review-exclude <d> 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<typeof parseReviewTargeted> | 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 };
+23 -1
View File
@@ -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