Merge origin/main (v1.65.0.0 fork port wave 2) into test-evals-ci-speedup

Second overlapping-wave merge; resolutions compose intent:

- TEST_ROOTS: ours is the superset (main also wired ios-qa/daemon/test;
  ours additionally has ios-qa/scripts + browser-skills). package.json
  'test' keeps routing through the canonical strict runner.
- gbrainAvailable: main fixed the same load-flake with a strictly better
  mechanism (memoized stat-based PATH scan, no subprocess at all) —
  theirs supersedes this branch's memoized-exec probe. Main also made
  the query timeout env-overridable (GSTACK_BRAIN_TIMEOUT_MS).
- Model defaults: adopted main's lib/eval-model.ts abstraction (one
  resolution point, env-overridable per kind) and applied decision D1a
  inside it: capture defaults to Sonnet (Opus opt-in via explicit arg or
  GSTACK_EVAL_MODEL_CAPTURE); test pins updated to follow.
- Parent watchdog: main's rewrite (named parameterized tick, driven
  deterministically by its test via __testInternals__, plus handoff
  suppression semantics from session persistence) supersedes this
  branch's env-tunable interval; adopted their server + test wholesale.
- windows-free-tests: ours (curated bun run test:windows) — main's
  hand-list grew by one more file, which the curated runner subsumes
  automatically; that drift is the reason for D11.
- context-skills 0-for-26 fix: both waves made the IDENTICAL fix; kept
  this branch's comment (carries the receipts).
- .gitignore: main's superset (also ignores Package.resolved — their
  never-commit call; untracked the copy this branch had committed).

Verified: 239-test merge battery green, watchdog 8/8, eval-model 5/5,
actionlint clean, eval:select works.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-15 11:56:34 -07:00
co-authored by Claude Fable 5
200 changed files with 11004 additions and 1207 deletions
-70
View File
@@ -1,70 +0,0 @@
#!/bin/bash
# Launch Chrome with CDP (remote debugging) enabled.
# Usage: chrome-cdp [port]
#
# Chrome refuses --remote-debugging-port on its default data directory.
# We create a separate data dir with a symlink to the user's real profile,
# so Chrome thinks it's non-default but uses the same cookies/extensions.
PORT="${1:-9222}"
CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
REAL_PROFILE="$HOME/Library/Application Support/Google/Chrome"
CDP_DATA_DIR="$HOME/.gstack/cdp-profile/chrome"
if ! [ -f "$CHROME" ]; then
echo "Chrome not found at $CHROME" >&2
exit 1
fi
# Check if Chrome is running
if pgrep -f "Google Chrome" >/dev/null 2>&1; then
echo "Chrome is still running. Quitting..."
osascript -e 'tell application "Google Chrome" to quit' 2>/dev/null
# Wait for it to fully exit
for i in $(seq 1 20); do
pgrep -f "Google Chrome" >/dev/null 2>&1 || break
sleep 0.5
done
if pgrep -f "Google Chrome" >/dev/null 2>&1; then
echo "Chrome won't quit. Force-killing..." >&2
pkill -f "Google Chrome"
sleep 1
fi
fi
# Set up CDP data dir with symlinked profile
# Chrome requires a "non-default" data dir for --remote-debugging-port.
# We symlink the real Default profile so cookies/extensions carry over.
mkdir -p "$CDP_DATA_DIR"
if [ -d "$REAL_PROFILE/Default" ] && ! [ -e "$CDP_DATA_DIR/Default" ]; then
ln -s "$REAL_PROFILE/Default" "$CDP_DATA_DIR/Default"
echo "Linked real Chrome profile into CDP data dir"
fi
# Also link Local State (contains crypto keys for cookie decryption, etc.)
if [ -f "$REAL_PROFILE/Local State" ] && ! [ -e "$CDP_DATA_DIR/Local State" ]; then
ln -s "$REAL_PROFILE/Local State" "$CDP_DATA_DIR/Local State"
fi
echo "Launching Chrome with CDP on port $PORT..."
"$CHROME" \
--remote-debugging-port="$PORT" \
--remote-debugging-address=127.0.0.1 \
--remote-allow-origins="http://127.0.0.1:$PORT" \
--user-data-dir="$CDP_DATA_DIR" \
--restore-last-session &
disown
# Wait for CDP to be available
for i in $(seq 1 30); do
if curl -s "http://127.0.0.1:$PORT/json/version" >/dev/null 2>&1; then
echo "CDP ready on port $PORT"
echo "Run: \$B connect chrome"
exit 0
fi
sleep 1
done
echo "CDP not available after 30s." >&2
exit 1
+27 -23
View File
@@ -34,9 +34,9 @@
* gstack-brain-context-load --quiet
*/
import { existsSync, readFileSync, statSync, readdirSync } from "fs";
import { join, dirname, basename, resolve } from "path";
import { execFileSync, spawnSync } from "child_process";
import { existsSync, readFileSync, statSync, readdirSync, accessSync, constants } from "fs";
import { join, dirname, basename, resolve, delimiter } from "path";
import { spawnSync } from "child_process";
import { homedir } from "os";
import { parseSkillManifest, type GbrainManifest, type GbrainManifestQuery, withErrorContext } from "../lib/gstack-memory-helpers";
@@ -68,7 +68,9 @@ interface QueryResult {
const HOME = homedir();
const GSTACK_HOME = process.env.GSTACK_HOME || join(HOME, ".gstack");
const MCP_TIMEOUT_MS = 500;
// 500ms hard cap per Section 1C; overridable for slow/loaded environments
// (test harnesses under CI load, cold CLI starts).
const MCP_TIMEOUT_MS = Math.max(1, parseInt(process.env.GSTACK_BRAIN_TIMEOUT_MS || "", 10) || 500);
const PAGE_SIZE_CAP = 10 * 1024; // 10KB per query result before truncation
// ── CLI ────────────────────────────────────────────────────────────────────
@@ -190,26 +192,28 @@ function resolveSkillFile(args: CliArgs): string | null {
// ── Dispatchers ────────────────────────────────────────────────────────────
// Memoized: availability can't change mid-invocation, and the per-query
// re-probe was both wasteful (N probes per run) and load-flaky — a cold
// `gbrain --version` on a saturated box can exceed the 500ms budget, branding
// gbrain "missing" for one query while its siblings succeed (observed under
// the parallel free-suite runner: SKIP at dur=505ms with two OKs after it).
let _gbrainAvailable: boolean | null = null;
let gbrainOnPath: boolean | null = null;
function gbrainAvailable(): boolean {
if (_gbrainAvailable !== null) return _gbrainAvailable;
try {
execFileSync("gbrain", ["--version"], {
stdio: "ignore",
// Generous first-probe budget: this runs ONCE, and a slow-to-start CLI
// is not a missing CLI. Query calls keep the tight MCP_TIMEOUT_MS.
timeout: 5_000,
});
_gbrainAvailable = true;
} catch {
_gbrainAvailable = false;
}
return _gbrainAvailable;
// Stat-based PATH scan, memoized. Spawning `gbrain --version` under the
// 500ms budget misreported gbrain as missing whenever a cold process spawn
// exceeded the timeout (loaded machine, node-based CLI cold start), and
// re-probing per query burned 3x the budget before any real work.
if (gbrainOnPath !== null) return gbrainOnPath;
const exts = process.platform === "win32"
? (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";")
: [""];
gbrainOnPath = (process.env.PATH || "").split(delimiter).some((dir) =>
dir !== "" && exts.some((ext) => {
try {
accessSync(join(dir, `gbrain${ext}`), constants.X_OK);
return true;
} catch {
return false;
}
})
);
return gbrainOnPath;
}
function dispatchVector(q: GbrainManifestQuery, args: CliArgs): QueryResult {
+284
View File
@@ -0,0 +1,284 @@
#!/usr/bin/env bun
/**
* gstack-code-intelligence — pick a code-intelligence provider and use it to
* index and search this repo. OPTIONAL: with nothing selected, gstack works
* fine and callers use grep / the file-only decision store.
*
* Portions copyright (c) 2026 Sina Matian, time-attack/gstack (GStack 2), MIT.
*
* Usage:
* gstack-code-intelligence suggest [repo] [--json] # should the one-time indexing offer be made here?
* gstack-code-intelligence options # list providers (GBrain first) + availability
* gstack-code-intelligence status # current selection + availability
* gstack-code-intelligence consent [repo-path] <yes|no> # record per-repo indexing consent (value REQUIRED)
* gstack-code-intelligence select <gbrain|sourcebot|graphify|none>
* gstack-code-intelligence index [repo-path] # index the repo with the selected provider
* gstack-code-intelligence search <query...> # search via the selected provider
*
* Non-local providers (GBrain, or a Sourcebot on a remote host) refuse to index
* until you consent for that repo. Graphify and a localhost Sourcebot are local:
* nothing leaves the machine, so no consent is needed. Graphify is never
* auto-installed.
*/
import { createHash } from "crypto";
import { realpathSync } from "fs";
import { hostname } from "os";
import { basename, resolve } from "path";
import {
CodeProviderError,
RECOMMENDED_ORDER,
detectAvailable,
getRoot,
hasConsent,
providerById,
readSelection,
resolveSelectedProvider,
setConsent,
setProvider,
setRoot,
shouldOfferIndexing,
type CodeProviderId,
} from "../lib/code-intelligence";
const PROVIDER_IDS = new Set<CodeProviderId>(["gbrain", "sourcebot", "graphify"]);
const LABEL: Record<CodeProviderId, string> = { gbrain: "GBrain", sourcebot: "Sourcebot", graphify: "Graphify" };
const NOTE: Record<CodeProviderId, string> = {
gbrain: "recommended; federated memory + code (sends content to your GBrain DB)",
sourcebot: "self-hosted whole-repo regex search (local when on localhost)",
graphify: "local tree-sitter code graph, nothing leaves the machine (install it yourself)",
};
function out(s: string): void {
process.stdout.write(`${s}\n`);
}
function fail(s: string): never {
process.stderr.write(`gstack-code-intelligence: ${s}\n`);
process.exit(1);
}
async function cmdOptions(): Promise<void> {
out("Code-intelligence providers (indexing is optional; GBrain recommended):\n");
const avail = await detectAvailable();
const byId = new Map(avail.map((a) => [a.id, a]));
for (const id of RECOMMENDED_ORDER) {
const a = byId.get(id);
const mark = a?.available ? "available" : "not available";
out(` ${id === "gbrain" ? "*" : " "} ${LABEL[id].padEnd(10)} [${mark}] — ${NOTE[id]}`);
if (a?.detail) out(` ${a.detail}`);
}
out("\nSelect one with: gstack-code-intelligence select <provider>");
}
async function cmdStatus(): Promise<void> {
const sel = readSelection();
out(`selected: ${sel.provider ?? "none (grep / file-only fallback)"}`);
const avail = await detectAvailable();
for (const a of avail) out(` ${LABEL[a.id]}: ${a.available ? "available" : "unavailable"} (${a.detail})`);
}
/**
* The one-time session-start offer gate. Prints (or emits as JSON) whether an
* agent should ask the user about indexing this repo, and when it should, the
* provider options with their reasons so the question is self-contained.
*/
async function cmdSuggest(rest: string[]): Promise<void> {
const json = rest.includes("--json");
const pathArg = rest.find((a) => !a.startsWith("--"));
const repoPath = resolve(pathArg ?? process.cwd());
const suggestion = shouldOfferIndexing(repoPath);
if (!suggestion.offer) {
if (json) {
out(JSON.stringify({ ...suggestion, repoPath }));
} else {
out(`no offer (${suggestion.reason}${suggestion.fileCount != null ? `, ${suggestion.fileCount} tracked files` : ""})`);
}
return;
}
const avail = await detectAvailable();
if (json) {
out(JSON.stringify({
...suggestion,
repoPath,
options: avail.map((a) => ({
id: a.id,
label: LABEL[a.id],
reason: NOTE[a.id],
local: providerById(a.id).local,
available: a.available,
detail: a.detail,
})),
}));
return;
}
out(`offer indexing: ${suggestion.fileCount} tracked files (threshold ${suggestion.threshold}) and no prior decision`);
await cmdOptions();
}
function cmdSelect(arg: string | undefined): void {
if (arg === "none") {
setProvider(null);
out("code-intelligence declined; gstack uses grep / file-only fallback and will not ask again");
return;
}
if (!arg || !PROVIDER_IDS.has(arg as CodeProviderId)) {
fail("Usage: select <gbrain|sourcebot|graphify|none>");
}
const id = arg as CodeProviderId;
setProvider(id);
out(`selected ${LABEL[id]}.`);
const provider = providerById(id);
if (!provider.local) out(`${LABEL[id]} sends repo content off this machine — run \`consent\` in a repo before indexing it.`);
}
/**
* Record per-repo indexing consent: `consent [repo-path] <yes|no>`.
*
* The yes|no value is REQUIRED (true/false also accepted). It is never
* defaulted: an agent recording a user's "no" must persist consent DENIED,
* and a missing/unknown value must record NOTHING — a consent gate that
* assumes "yes" is a consent gate that lies.
*/
function cmdConsent(rest: string[]): void {
const positional = rest.filter((a) => !a.startsWith("--"));
const CONSENT_USAGE = "Usage: consent [repo-path] <yes|no> — the yes/no value is required; consent is never assumed";
if (positional.length < 1 || positional.length > 2) fail(CONSENT_USAGE);
const value = positional[positional.length - 1].toLowerCase();
let consented: boolean;
if (value === "yes" || value === "true") consented = true;
else if (value === "no" || value === "false") consented = false;
else fail(CONSENT_USAGE);
const repoPath = resolve(positional.length === 2 ? positional[0] : process.cwd());
setConsent(repoPath, consented);
out(consented ? `indexing consent recorded for ${repoPath}` : `indexing consent DENIED for ${repoPath} (recorded)`);
}
/**
* Host+path-hashed source id for GBrain/Sourcebot — the same approach as
* deriveCodeSourceId in bin/gstack-gbrain-sync.ts. A bare basename collides:
* two repos both named "api" (or the same repo on two machines against a
* federated brain) would silently share one source. Suffix = first 8 hex of
* sha1(`${hostname}::${realpath}`); base sanitized to gbrain's source-id
* charset (lowercase alnum + interior hyphens) and capped so the whole id
* stays within gbrain's 32-char limit.
*/
function hashedSourceId(repoPath: string): string {
let real = repoPath;
try {
real = realpathSync(repoPath);
} catch {
// path may not exist yet at id-derivation time — hash the resolved form
}
const host = process.env.GSTACK_HOSTNAME || hostname();
const suffix = createHash("sha1").update(`${host}::${real}`).digest("hex").slice(0, 8);
const base =
basename(real)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 23)
.replace(/-+$/, "") || "repo";
return `${base}-${suffix}`;
}
async function cmdIndex(pathArg: string | undefined): Promise<void> {
const provider = resolveSelectedProvider();
if (!provider) fail("no provider selected; run `select <provider>` first");
const repoPath = resolve(pathArg ?? process.cwd());
// Indexing is write-class: hasConsent's default op class applies, so a
// `deny` OR `read-only` repo trust policy vetoes it (code indexing writes
// pages — same semantics as gstack-gbrain-sync's runCodeImport).
const consented = hasConsent(repoPath);
if (!provider!.local && !consented) {
const recorded = readSelection().consents[repoPath] === true;
fail(recorded
? `${provider!.label} indexing is blocked by the repo trust policy (deny or read-only — code indexing writes pages). Change with: gstack-gbrain-repo-policy set <origin-url> read-write`
: `${provider!.label} would send this repo's content off the machine. Run \`gstack-code-intelligence consent ${repoPath} yes\` first.`);
}
// Graphify keys sources on the repo path; GBrain/Sourcebot on a short
// host+path-hashed id (bare basenames collide across same-named repos).
const sourceId = provider!.id === "graphify" ? repoPath : hashedSourceId(repoPath);
const repo = { id: sourceId, path: repoPath };
try {
const registered = await provider!.registerSource(repo, { consented });
out(`registered ${repo.id} with ${provider!.label} (${registered.state})`);
const refreshed = await provider!.refresh({ id: registered.id }, { consented });
// Remember which repo this provider indexed so `search` reads the same graph.
setRoot(provider!.id, repoPath);
out(`indexed: ${refreshed.state}${refreshed.itemCount != null ? ` (${refreshed.itemCount} items)` : ""}`);
} catch (err) {
handleProviderError(err, provider!.label);
}
}
async function cmdSearch(terms: string[]): Promise<void> {
const query = terms.join(" ").trim();
if (!query) fail("Usage: search <query...>");
const provider = resolveSelectedProvider();
if (!provider) fail("no provider selected; run `select <provider>` first (or use grep)");
// Search is read-class: a read-only repo trust policy still allows it
// (mirrors gstack-gbrain-sync: search allowed, page writes never), but a
// deny tier — or no recorded consent at all — still refuses for non-local
// providers, because the query text itself is repo-derived content. The
// consent repo is the one this provider indexed (search reads that graph);
// loopback providers need no consent, so their path is unchanged.
const searchRoot = getRoot(provider!.id) ?? resolve(process.cwd());
const consented = hasConsent(searchRoot, undefined, "read");
// Honest pre-flight (mirrors cmdIndex): the adapter enforces the same gate
// (assertEgressConsent throws PROVIDER_NOT_CONSENTED before any bytes or
// receipt exist), but the CLI names WHY — missing consent vs a deny repo
// trust policy — instead of surfacing a generic provider error.
if (!provider!.local && !consented) {
const recorded = readSelection().consents[searchRoot] === true;
fail(recorded
? `${provider!.label} search is blocked by the repo trust policy (deny — the query text is repo-derived content). Change with: gstack-gbrain-repo-policy set <origin-url> read-only (search allowed) or read-write`
: `${provider!.label} would send the query text (repo-derived content) off this machine. Run \`gstack-code-intelligence consent ${searchRoot} yes\` first.`);
}
try {
const hits = await provider!.search(query, { limit: 10, consented });
if (!hits.length) {
out("(no results)");
return;
}
for (const h of hits) out(`${h.score != null ? `[${h.score.toFixed(2)}] ` : ""}${h.ref}${h.snippet ? ` — ${h.snippet}` : ""}`);
} catch (err) {
handleProviderError(err, provider!.label);
}
}
function handleProviderError(err: unknown, label: string): never {
if (err instanceof CodeProviderError) {
if (err.code === "PROVIDER_UNAVAILABLE") {
fail(`${label} is unavailable (${err.message}). gstack still works — fall back to grep / file-only.`);
}
if (err.code === "PROVIDER_NOT_CONSENTED") {
fail(`${label} ${err.code}: ${err.message} Run \`gstack-code-intelligence consent <repo-path> yes\` first (a deny repo trust policy overrides recorded consent).`);
}
fail(`${label} ${err.code}: ${err.message}`);
}
fail(err instanceof Error ? err.message : String(err));
}
async function main(): Promise<void> {
const [action, ...rest] = process.argv.slice(2);
switch (action) {
case "suggest":
return cmdSuggest(rest);
case "options":
return cmdOptions();
case "status":
return cmdStatus();
case "select":
return cmdSelect(rest[0]);
case "consent":
return cmdConsent(rest);
case "index":
return cmdIndex(rest[0]);
case "search":
return cmdSearch(rest);
default:
fail("Usage: suggest [path] [--json] | options | status | select <provider> | consent [path] <yes|no> | index [path] | search <query...>");
}
}
main().catch((err) => fail(err instanceof Error ? err.message : String(err)));
+10
View File
@@ -133,6 +133,8 @@ lookup_default() {
redact_repo_visibility) echo "" ;; # empty → fall through to gh/glab detection
redact_prepush_hook) echo "false" ;;
pair_agent) echo "off" ;; # remote tunnel consent — fail-closed until /pair-agent asks
founder_resources) echo "true" ;; # office-hours resource pitch — #538 permanent opt-out sets false
# Brain-aware planning (v1.48 / T5+T10+T16). Defaults documented inline:
# brain_trust_policy@<endpoint-id> — unset on fresh install; setup-gbrain
# writes 'personal' for local engines,
@@ -319,6 +321,14 @@ case "${1:-}" in
echo "Warning: redact_prepush_hook '$VALUE' not recognized. Valid values: true, false. Using false." >&2
VALUE="false"
fi
if [ "$KEY" = "pair_agent" ] && [ "$VALUE" != "on" ] && [ "$VALUE" != "off" ]; then
echo "Warning: pair_agent '$VALUE' not recognized. Valid values: on, off. Using off." >&2
VALUE="off"
fi
if [ "$KEY" = "founder_resources" ] && [ "$VALUE" != "true" ] && [ "$VALUE" != "false" ]; then
echo "Warning: founder_resources '$VALUE' not recognized. Valid values: true, false. Using true." >&2
VALUE="true"
fi
if [ "$KEY" = "plan_tune_hooks" ] && [ "$VALUE" != "prompt" ] && [ "$VALUE" != "yes" ] && [ "$VALUE" != "no" ]; then
echo "Warning: plan_tune_hooks '$VALUE' not recognized. Valid values: prompt, yes, no. Using prompt." >&2
VALUE="prompt"
+1
View File
@@ -2,6 +2,7 @@
// gstack-context-bill — token bill-of-materials for an installed skills tree.
// All behavior lives in lib/context-bill.ts; this is the CLI shim.
import '../lib/conductor-env-shim'; // --exact needs GSTACK_ANTHROPIC_API_KEY promotion inside Conductor
import { contextBillMain } from '../lib/context-bill';
process.exit(await contextBillMain(process.argv.slice(2)));
+42 -17
View File
@@ -51,39 +51,64 @@ if (args.includes("--compact")) {
process.exit(0);
}
// The payload is identified by its leading `{`, not by "first non-flag arg" — a
// `--supersede <id> '{...}'` call would otherwise mistake the target id for the payload.
const jsonArg = args.find((a) => a.trimStart().startsWith("{"));
/** Parse + validate a decision payload. Exits 1 (nothing persisted) when it's bad. */
function validPayload(raw: string): DecisionEvent {
let obj: Partial<DecisionEvent>;
try {
obj = JSON.parse(raw);
} catch {
process.stderr.write("gstack-decision-log: invalid JSON\n");
process.exit(1);
}
if (obj.scope === "branch" && !obj.branch) obj.branch = gitBranch();
const res = validateDecide(obj);
if (!res.ok) {
process.stderr.write(`gstack-decision-log: ${res.error}\n`);
process.exit(1);
}
return res.event;
}
const supersedeId = flagValue(args, "--supersede");
const redactId = flagValue(args, "--redact");
if (supersedeId || redactId) {
const kind = supersedeId ? "supersede" : "redact";
const targetId = (supersedeId || redactId) as string;
if (targetId.trimStart().startsWith("{")) {
process.stderr.write(`gstack-decision-log: --${kind} needs the target decision id before the replacement JSON\n`);
process.exit(1);
}
if (kind === "redact" && jsonArg) {
process.stderr.write(
"gstack-decision-log: --redact expunges and takes no replacement; log the replacement in its own call so it isn't dropped\n",
);
process.exit(1);
}
// Validate the replacement BEFORE anything is written, then append it FIRST and
// retire the old one SECOND. Appends are individually atomic, so the only visible
// interleaving is "both active" (recoverable); the reverse order could retire the
// old decision and lose the replacement the user was recording.
const replacement = jsonArg ? { ...validPayload(jsonArg), supersedes: targetId } : undefined;
if (replacement) appendEvent(paths, replacement);
appendEvent(paths, makeRefEvent(kind, targetId, { source: "agent" }));
rebuildSnapshot(paths);
enqueue();
console.log(`${kind}: ${targetId}`);
console.log(replacement ? `${kind}: ${targetId} -> ${replacement.id}` : `${kind}: ${targetId}`);
process.exit(0);
}
const jsonArg = args.find((a) => !a.startsWith("--"));
if (!jsonArg) {
process.stderr.write(
"gstack-decision-log: provide a JSON decision, or --supersede/--redact <id>, or --compact\n",
);
process.exit(1);
}
let obj: Partial<DecisionEvent>;
try {
obj = JSON.parse(jsonArg);
} catch {
process.stderr.write("gstack-decision-log: invalid JSON\n");
process.exit(1);
}
if (obj.scope === "branch" && !obj.branch) obj.branch = gitBranch();
const res = validateDecide(obj);
if (!res.ok) {
process.stderr.write(`gstack-decision-log: ${res.error}\n`);
process.exit(1);
}
appendEvent(paths, res.event);
const event = validPayload(jsonArg);
appendEvent(paths, event);
rebuildSnapshot(paths);
enqueue();
console.log(res.event.id);
console.log(event.id);
+9 -1
View File
@@ -4,7 +4,15 @@
# Or: gstack-diff-scope main → prints SCOPE_*=... lines
set -euo pipefail
BASE="${1:-main}"
# 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)}"
# Get changed file list
FILES=$(git diff "${BASE}...HEAD" --name-only 2>/dev/null || git diff "${BASE}" --name-only 2>/dev/null || echo "")
+6 -1
View File
@@ -197,8 +197,13 @@ RESULT=$(EVENTS_JSON="$EVENTS_JSON" DISTILL_PROMPT="$DISTILL_PROMPT" \
const INPUT_PER_TOKEN = 1e-6;
const OUTPUT_PER_TOKEN = 5e-6;
// Host-neutral model resolution (mirrors lib/eval-model.ts — this inline
// bun -e script cannot import repo-relative libs from an arbitrary cwd).
const distillModel = process.env.GSTACK_EVAL_MODEL_DISTILL
|| process.env.GSTACK_EVAL_MODEL
|| "claude-haiku-4-5-20251001";
const resp = await client.messages.create({
model: "claude-haiku-4-5-20251001",
model: distillModel,
max_tokens: 4096,
messages: [{ role: "user", content: prompt }],
});
+65
View File
@@ -42,6 +42,7 @@ import { detectAutopilot, decideSourceRemove, decideCodeSync } from "../lib/gbra
import { writeReceipt } from "../lib/egress-receipt";
import { localEngineStatus, type LocalEngineStatus } from "../lib/gbrain-local-status";
import { buildGbrainEnv, spawnGbrain, execGbrainJson, NEEDS_SHELL_ON_WINDOWS } from "../lib/gbrain-exec";
import { repoPolicyTier as sharedRepoPolicyTier } from "../lib/gbrain-repo-policy-client";
import { checkOwnedStagingDir } from "../lib/staging-guard";
// ── Types ──────────────────────────────────────────────────────────────────
@@ -778,6 +779,40 @@ function warnProbeTimeout(stage: "code" | "memory" | "dream"): void {
}
/**
* Per-repo trust tier from ~/.gstack/gbrain-repo-policy.json, read through
* the bin/gstack-gbrain-repo-policy CLI (which owns URL normalization and
* schema migration — do not reimplement either here).
*
* The tier was previously enforced only in /sync-gbrain skill prose, so a
* direct or cron invocation of this script ingested repo code regardless of
* a `deny`/`read-only` setting — and the egress receipt below cited this
* chokepoint as consent before it existed (#2140 sync path). This check
* closes both gaps.
*
* Fail-open ONLY when no policy store exists (nothing was ever set — same
* behavior as before for every non-policy user, and skips the subprocess).
* Fail-closed ("error") when a store exists but can't be read: a policy the
* user set must not be silently bypassed by a broken store or missing jq.
*
* Reads through the shared lib/gbrain-repo-policy-client.ts (same client as
* the code-intelligence consent veto — the two gates can never drift, and
* win32 gets the invoke-via-bash path). A spawn failure is still fail-closed
* but says so, instead of the misleading "store could not be read".
*/
export function repoPolicyTier(url: string | null): "read-write" | "read-only" | "deny" | "unset" | "error" {
const res = sharedRepoPolicyTier(url, process.env);
if (res.error === "spawn-failed") {
process.stderr.write(
"[gstack-gbrain-sync] the repo-policy helper could not be spawned (bash missing from PATH?) — " +
"refusing ingest rather than bypassing a possibly-set policy\n",
);
return "error";
}
if (res.error) return "error";
return res.tier === "none" ? "unset" : res.tier;
}
async function runCodeImport(args: CliArgs): Promise<StageResult> {
const t0 = Date.now();
const root = repoRoot();
@@ -787,6 +822,36 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
const sourceId = deriveCodeSourceId(root);
// Per-repo trust tier — checked BEFORE the dry-run branch so previews report
// the refusal honestly instead of claiming they would sync.
const policyUrl = originUrl();
const tier = repoPolicyTier(policyUrl);
if (tier === "read-only") {
// Honoring an explicit user setting (search allowed, page writes never) is
// a clean skip, not a stage failure — code ingest writes pages.
return {
name: "code",
ran: false,
ok: true,
duration_ms: Date.now() - t0,
summary: `skipped — repo policy is read-only for ${policyUrl} (code ingest writes pages). Change with: gstack-gbrain-repo-policy set ${policyUrl} read-write`,
detail: { source_id: sourceId, source_path: root, status: "skipped-policy-read-only" },
};
}
if (tier === "deny" || tier === "error") {
const why = tier === "deny"
? `repo policy is deny for ${policyUrl} — no gbrain ingest for this repo. Change with: gstack-gbrain-repo-policy set ${policyUrl} read-write`
: "repo policy store exists but could not be read (gstack-gbrain-repo-policy get failed) — refusing ingest rather than bypassing a set policy";
return {
name: "code",
ran: true,
ok: false,
duration_ms: Date.now() - t0,
summary: `refused: ${why}`,
detail: { source_id: sourceId, source_path: root, status: tier === "deny" ? "refused-policy-deny" : "refused-policy-unreadable" },
};
}
// dry-run preview always shows the would-do steps, regardless of local
// engine state. Useful for "what would /sync-gbrain do" without probing
// the engine.
+48 -8
View File
@@ -52,8 +52,9 @@ import {
readSync,
closeSync,
rmSync,
realpathSync,
} from "fs";
import { join, basename, dirname } from "path";
import { join, basename, dirname, delimiter } from "path";
import { execFileSync, spawnSync, spawn, type ChildProcess } from "child_process";
import { homedir } from "os";
import { createHash } from "crypto";
@@ -1458,13 +1459,40 @@ function runGbrainImportOnce(
// still reporting `written: N` from the staged count. Silent data loss
// on every run. A working run logs `import.collect_files done ... files=N`
// with N > 0 and takes minutes, not seconds.
const child = spawnGbrainAsync([
"import",
stagingDir,
"--no-embed",
...(includeGitignored ? ["--include-gitignored"] : []),
"--json",
]);
//
// GIT_CEILING_DIRECTORIES is the second layer of the same #2144 defense:
// it stops git's upward repo discovery at the staging dir's parent, so a
// git-enumerating collector fails cleanly out of the git fast path and
// falls back to its plain FS walk even on gbrain builds whose flag
// semantics drift. The ceiling must be the REAL path — git compares
// canonicalized directories during discovery, and a staging dir reached
// through a symlink (macOS /var -> /private/var, symlinked $GSTACK_HOME)
// otherwise never matches the ceiling entry. Scoped to this one child;
// no on-disk state, staging-guard/resume contracts untouched.
let ceiling: string;
try {
ceiling = realpathSync(dirname(stagingDir));
} catch {
ceiling = dirname(stagingDir); // staging parent vanished mid-run; spawn will fail loudly anyway
}
const baseEnv: NodeJS.ProcessEnv = {
...process.env,
// path.delimiter, not ':' — git splits this on ';' on Windows, and
// drive-letter paths contain ':' themselves.
GIT_CEILING_DIRECTORIES: process.env.GIT_CEILING_DIRECTORIES
? `${ceiling}${delimiter}${process.env.GIT_CEILING_DIRECTORIES}`
: ceiling,
};
const child = spawnGbrainAsync(
[
"import",
stagingDir,
"--no-embed",
...(includeGitignored ? ["--include-gitignored"] : []),
"--json",
],
{ baseEnv },
);
_activeImportChild = child;
let stdout = "";
let stderr = "";
@@ -1941,6 +1969,18 @@ async function ingestPass(args: CliArgs): Promise<BulkResult> {
: ""),
);
}
// Silent-zero pathology detector (#2144's other half): pages were staged
// but NOTHING imported or skipped-as-unchanged. That shape hid the dead
// ingest for months — it must be loud even under --quiet, because a run
// that indexes nothing is otherwise indistinguishable from a healthy one.
const importedCount = (importJson.imported ?? 0) + (importJson.skipped ?? 0);
if (prep.prepared.length > 0 && importedCount === 0 && (importJson.errors ?? 0) === 0) {
console.error(
`[memory-ingest] WARNING: ${prep.prepared.length} page(s) staged but gbrain collected ZERO ` +
`(no imports, no unchanged-skips, no errors). This is the #2144 silent-zero shape — ` +
`check gbrain's import.collect_files log line and your gbrain version.`,
);
}
} finally {
// #1802 D1: in remote-http mode `stagingDir` is the PERSISTENT transcript
// dir (makePersistentTranscriptDir, under ~/.gstack/transcripts/) that
+26 -1
View File
@@ -405,7 +405,32 @@ function parseArgs(argv: string[]): { base: string; bump: Bump; current: string;
else if (a === "-h" || a === "--help") help = true;
}
if (help) return { base: "", bump: "micro", current: "", excludePR: null, help: true };
if (!base) base = "main";
if (!base) {
// Detect the default branch instead of assuming main (local-only repos
// on trunk/master work like GitHub repos on main). Same probe order as
// the canonical chain in bin/gstack-diff-scope and {{BASE_BRANCH_DETECT}}
// (scripts/resolvers/utility.ts): origin/HEAD -> origin/main ->
// origin/master -> literal "main". origin/HEAD is unset on plain clones
// that never ran `git remote set-head`, so the rev-parse probes matter.
try {
const head = execFileSync("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
base = head.replace("refs/remotes/origin/", "");
} catch {
// fall through to the rev-parse probes
}
if (!base) {
for (const candidate of ["main", "master"]) {
try {
execFileSync("git", ["rev-parse", "--verify", "-q", `origin/${candidate}`], { stdio: ["ignore", "ignore", "ignore"] });
base = candidate;
break;
} catch {
// probe failed; try the next candidate
}
}
}
if (!base) base = "main";
}
if (!bump) {
console.error("Error: --bump is required (major|minor|patch|micro)");
process.exit(2);
+6
View File
@@ -61,6 +61,12 @@ elif [ -n "${TMP:-}" ]; then
else
_tmp_root=".gstack/tmp"
fi
# macOS exports TMPDIR with a trailing slash; mktemp templates built as
# "$TMP_ROOT/name-XXXXXX" would then carry "//", and any consumer comparing
# paths gets a spurious mismatch. Strip it (never strips a bare "/"). #2091
case "$_tmp_root" in
*/) [ "$_tmp_root" != "/" ] && _tmp_root="${_tmp_root%/}" ;;
esac
# Strip any trailing slash so consumers can safely concatenate "$TMP_ROOT/name"
# without producing a double slash. On macOS $TMPDIR ends in `/` by default
+7
View File
@@ -210,6 +210,13 @@ fi
STEP_FIELD="null"
[ -n "$FAILED_STEP" ] && STEP_FIELD="\"$(json_safe "$FAILED_STEP")\""
# Integrity first: a non-numeric duration would splice raw text into the
# JSON line ("duration_s":%s) and corrupt the whole JSONL stream — the range
# caps below silently no-op on non-integers because both test(1) comparisons
# fail. Reject anything that isn't a plain integer.
case "$DURATION" in
''|*[!0-9]*) DURATION="" ;;
esac
# Cap unreasonable durations
if [ -n "$DURATION" ] && [ "$DURATION" -gt 86400 ] 2>/dev/null; then
DURATION="" # null if > 24h
+21 -9
View File
@@ -81,15 +81,27 @@ while IFS= read -r LINE; do
[ -z "$LINE" ] && continue
echo "$LINE" | grep -q '^{' || continue
# Strip local-only fields (keep v, ts, sessions as-is for edge function)
CLEAN="$(echo "$LINE" | sed \
-e 's/,"_repo_slug":"[^"]*"//g' \
-e 's/,"_branch":"[^"]*"//g' \
-e 's/,"repo":"[^"]*"//g')"
# If anonymous tier, strip installation_id
if [ "$TIER" = "anonymous" ]; then
CLEAN="$(echo "$CLEAN" | sed 's/,"installation_id":"[^"]*"//g; s/,"installation_id":null//g')"
# Strip local-only fields (keep v, ts, sessions as-is for edge function).
# jq del() is structural — a value containing an escaped quote (repo names,
# branch names) can't smuggle the field past a regex or corrupt the strip.
# The sed path stays only as a jq-less fallback.
if command -v jq >/dev/null 2>&1; then
if [ "$TIER" = "anonymous" ]; then
CLEAN="$(printf '%s' "$LINE" | jq -c 'del(._repo_slug, ._branch, .repo, .installation_id)' 2>/dev/null)" || CLEAN=""
else
CLEAN="$(printf '%s' "$LINE" | jq -c 'del(._repo_slug, ._branch, .repo)' 2>/dev/null)" || CLEAN=""
fi
# A line jq can't parse is malformed telemetry — drop it rather than
# forwarding bytes the strip never touched.
[ -z "$CLEAN" ] && continue
else
CLEAN="$(echo "$LINE" | sed \
-e 's/,"_repo_slug":"[^"]*"//g' \
-e 's/,"_branch":"[^"]*"//g' \
-e 's/,"repo":"[^"]*"//g')"
if [ "$TIER" = "anonymous" ]; then
CLEAN="$(echo "$CLEAN" | sed 's/,"installation_id":"[^"]*"//g; s/,"installation_id":null//g')"
fi
fi
if [ "$FIRST" = "true" ]; then
+214
View File
@@ -0,0 +1,214 @@
#!/usr/bin/env bash
# gstack-verify-gate — Stop hook. Blocks the turn from ending until the
# project's declared verification command passes.
#
# Declare the command on one line in the project's CLAUDE.md:
# <!-- gstack:verify: bun test -->
#
# Read-or-ask: gstack never invents this command. No declaration, no gate.
# Fails open on every absence (no CLAUDE.md, no declaration, empty value).
#
# Trust boundary: hooks bypass the permission system, so a declared command
# NEVER runs until the user records it in the per-repo trust store:
# gstack-verify-gate --trust (run from inside the repo)
# The store maps realpath(repo root) -> sha256(command) at
# ${GSTACK_HOME:-$HOME/.gstack}/verify-gate-trust (flat "path<TAB>hash",
# 0600, atomic rewrite). Any edit to the declared command invalidates trust
# until --trust is run again. Untrusted commands never block the turn.
#
# Exit 0 = allow the turn to end, one-line reason on stdout.
# Exit 2 = block, Claude Code feeds stderr back to the agent.
#
# Remove with: gstack-settings-hook remove-source --source verify-gate
set -uo pipefail
TAB="$(printf '\t')"
STORE="${GSTACK_HOME:-$HOME/.gstack}/verify-gate-trust"
_sha256() {
if command -v shasum >/dev/null 2>&1; then
printf '%s' "$1" | shasum -a 256 | cut -d' ' -f1
elif command -v sha256sum >/dev/null 2>&1; then
printf '%s' "$1" | sha256sum | cut -d' ' -f1
else
printf '%s' "$1" | openssl dgst -sha256 | awk '{print $NF}'
fi
}
# Resolve the project root: CLAUDE_PROJECT_DIR, else walk up from $PWD to
# the first directory containing CLAUDE.md. Sets ROOT (may lack CLAUDE.md).
_resolve_root() {
ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
while [ ! -f "$ROOT/CLAUDE.md" ] && [ "$ROOT" != "/" ]; do
ROOT="$(dirname "$ROOT")"
done
}
# Extract the declared command from $ROOT/CLAUDE.md into CMD (may be empty).
# Accepts both `<!-- gstack:verify: cmd -->` and bare `gstack:verify: cmd`.
_extract_cmd() {
CMD="$(sed -n 's/^[[:space:]]*\(<!--[[:space:]]*\)\{0,1\}gstack:verify:[[:space:]]*\(.*\)$/\2/p' "$ROOT/CLAUDE.md" | head -1)"
CMD="${CMD%%-->*}"
CMD="$(printf '%s' "$CMD" | tr -d '`' | sed 's/[[:space:]]*$//')"
}
# Symlink-stable store key for the root.
_trust_key() {
(cd "$ROOT" 2>/dev/null && pwd -P) || printf '%s' "$ROOT"
}
# Print the stored hash for key $1, or return 1 when absent.
_trusted_hash() {
[ -f "$STORE" ] || return 1
local p h
while IFS="$TAB" read -r p h; do
if [ "$p" = "$1" ]; then
printf '%s' "$h"
return 0
fi
done <"$STORE"
return 1
}
# Record key $1 -> hash $2, replacing any prior entry. Atomic, 0600.
_record_trust() {
local store_dir tmp p h
store_dir="$(dirname "$STORE")"
mkdir -p "$store_dir"
tmp="$STORE.tmp.$$"
: >"$tmp"
chmod 600 "$tmp"
if [ -f "$STORE" ]; then
while IFS="$TAB" read -r p h; do
[ "$p" = "$1" ] || printf '%s\t%s\n' "$p" "$h" >>"$tmp"
done <"$STORE"
fi
printf '%s\t%s\n' "$1" "$2" >>"$tmp"
mv -f "$tmp" "$STORE"
}
# Minimal JSON string escaping (backslash + double quote). CMD and paths are
# single-line by construction, so control characters never appear.
_json_escape() {
printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
}
# Forensic audit of trust grants. --trust stays agent-runnable (guardrail
# posture: catch accidents, not determined actors — same as the redaction
# guard), but a grant is never invisible: append {ts, root, cmd_sha256,
# cmd verbatim, tty} to a 0600 JSONL under GSTACK_HOME/security/.
# Args: $1 = root key, $2 = cmd sha256, $3 = cmd verbatim.
_log_trust_grant() {
local sec_dir log tty ts
sec_dir="${GSTACK_HOME:-$HOME/.gstack}/security"
log="$sec_dir/verify-gate-trust-grants.jsonl"
tty=false
[ -t 0 ] && tty=true
ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
mkdir -p "$sec_dir"
[ -f "$log" ] || : >"$log"
chmod 600 "$log" 2>/dev/null || true
printf '{"ts":"%s","root":"%s","cmd_sha256":"%s","cmd":"%s","tty":%s}\n' \
"$ts" "$(_json_escape "$1")" "$2" "$(_json_escape "$3")" "$tty" >>"$log"
}
if [ "${1:-}" = "--trust" ]; then
_resolve_root
if [ ! -f "$ROOT/CLAUDE.md" ]; then
echo "verify-gate: no CLAUDE.md above $PWD, nothing to trust." >&2
exit 1
fi
_extract_cmd
if [ -z "$CMD" ]; then
echo "verify-gate: $ROOT/CLAUDE.md declares no 'gstack:verify:' command, nothing to trust." >&2
exit 1
fi
KEY="$(_trust_key)"
HASH="$(_sha256 "$CMD")"
_record_trust "$KEY" "$HASH"
_log_trust_grant "$KEY" "$HASH" "$CMD"
echo "verify-gate: trusted '$CMD' for $ROOT."
exit 0
fi
INPUT=""
[ -t 0 ] || INPUT="$(cat)"
# Claude Code re-runs Stop hooks after a block (stop_hook_active=true). A
# re-entry is NOT a free pass: the gate re-runs the trusted check so an agent
# can't clear a red verification by simply stopping again. Re-entry blocks are
# bounded per episode (MAX_REENTRY_BLOCKS) so a stuck check can't loop forever;
# at the bound the gate allows with a loud warning.
REENTRY=0
if printf '%s' "$INPUT" | grep -q '"stop_hook_active"[[:space:]]*:[[:space:]]*true'; then
REENTRY=1
fi
_resolve_root
if [ ! -f "$ROOT/CLAUDE.md" ]; then
echo "verify-gate: no CLAUDE.md above $PWD, no check declared, allowing."
exit 0
fi
_extract_cmd
if [ -z "$CMD" ]; then
echo "verify-gate: $ROOT/CLAUDE.md declares no 'gstack:verify:' command, allowing."
exit 0
fi
# Trust gate: never execute a declared command the user has not recorded.
# Applies on re-entry too — untrusted commands keep the exit-0-with-hint path.
if [ "$(_trusted_hash "$(_trust_key)" || true)" != "$(_sha256 "$CMD")" ]; then
echo "verify-gate: found '$CMD' in $ROOT/CLAUDE.md but it is not trusted yet, skipping; enable with: cd $ROOT && $0 --trust" >&2
echo "verify-gate: declared command not trusted, allowing."
exit 0
fi
# Episode-scoped re-entry attempt counter. Keyed by the hook-input session_id
# when present, else ppid+root — stale entries are fine to overwrite.
MAX_REENTRY_BLOCKS=3
_session_key() {
local sid
sid="$(printf '%s' "$INPUT" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)"
[ -n "$sid" ] || sid="ppid-$PPID"
_sha256 "$sid|$(_trust_key)"
}
ATTEMPTS_DIR="${GSTACK_HOME:-$HOME/.gstack}/verify-gate-attempts"
COUNTER="$ATTEMPTS_DIR/$(_session_key)"
# A first entry (stop_hook_active=false) starts a fresh blocking episode.
if [ "$REENTRY" -eq 0 ]; then
rm -f "$COUNTER" 2>/dev/null || true
fi
OUT="$(cd "$ROOT" && eval "$CMD" 2>&1)"
STATUS=$?
if [ "$STATUS" -eq 0 ]; then
rm -f "$COUNTER" 2>/dev/null || true
echo "verify-gate: declared check passed ($CMD)."
exit 0
fi
if [ "$REENTRY" -eq 1 ]; then
COUNT="$(cat "$COUNTER" 2>/dev/null || echo 0)"
case "$COUNT" in
''|*[!0-9]*) COUNT=0 ;;
esac
if [ "$COUNT" -ge "$MAX_REENTRY_BLOCKS" ]; then
rm -f "$COUNTER" 2>/dev/null || true
WARN="verify-gate: WARNING — allowing after $MAX_REENTRY_BLOCKS blocked re-entries but the declared check is still FAILING ($CMD). Verification is RED; do not treat this turn as verified."
echo "$WARN"
echo "$WARN" >&2
exit 0
fi
mkdir -p "$ATTEMPTS_DIR"
echo $((COUNT + 1)) >"$COUNTER"
fi
echo "verify-gate: declared check FAILED with exit $STATUS: $CMD" >&2
printf '%s\n' "$OUT" | tail -20 >&2
echo "Fix the failure, or drop the gstack:verify line from $ROOT/CLAUDE.md." >&2
exit 2