Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,38 @@
# Changelog

## [1.79.1.0] - 2026-09-04

**GBrain readiness is now a bounded, read-only check. Opening a session cannot
silently sync a repository, launch an embedding job, or spend provider money.**

GStack previously tried to repair a missing GBrain source pin automatically
from session and skill startup. On a new repository that could run before the
per-repository consent gate, send code to the configured brain, and leave a
foreground skill waiting indefinitely on a stuck GBrain subprocess. The repair
path is now explicit: automatic startup only reports readiness, while
`/sync-gbrain --full` owns synchronization and indexing after the existing
trust policy has been checked.

### What changed

- Every foreground GBrain probe has a 1–10 second timeout, with a three-second
default and a portable macOS fallback. A slow or dead brain degrades the
readiness signal instead of blocking `/review`, `/ship`, or another skill.
- Missing, malformed, unregistered, or path-mismatched source pins fail closed
and point to the explicit `/sync-gbrain --full` repair path.
- Session startup and skill startup never invoke sync, import, dream, embed,
edge backfill, source registration, or paid provider work.
- Candor-specific Claude hooks and locally patched generated skill names were
removed from the generic upstream branch. Canonical generated files are
fresh again.

### Verification

- 119 focused readiness, sync, dream, skill-start, team-mode, and generated-doc
tests passed with zero failures before release preparation.
- Independent exact-head review confirmed the prior privacy, timeout, source-ID,
generated-file, stale-base, and project-specific-hook blockers are closed.

## [1.79.0.0] - 2026-09-01

**/ship can no longer be stranded by a backgrounded subagent.**
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.79.0.0
1.79.1.0
2 changes: 1 addition & 1 deletion agents-digest/gstack-AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# gstack digest v1.79.0.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.79.1.0 — regenerate/re-copy after upgrading gstack

Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
Expand Down
191 changes: 191 additions & 0 deletions bin/gstack-gbrain-ready
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
#!/usr/bin/env bash
# Read-only proof that this exact worktree has a usable GBrain code graph.
#
# This command is called from the latency-sensitive skill preamble. It must
# never sync, index, run a dream cycle, spend provider credits, or write to an
# external database. `/sync-gbrain` is the only repair path. The only writes
# here are disposable local readiness receipts under GSTACK_HOME.

set +e

STATE_ROOT="${GSTACK_HOME:-$HOME/.gstack}/gbrain-readiness"
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
READINESS_SCHEMA=4
SESSION_KEY=""

PROBE_TIMEOUT_SECONDS="${GSTACK_GBRAIN_READY_TIMEOUT_SECONDS:-5}"
case "$PROBE_TIMEOUT_SECONDS" in
''|*[!0-9]*) PROBE_TIMEOUT_SECONDS=5 ;;
*)
if [ "$PROBE_TIMEOUT_SECONDS" -lt 1 ] || [ "$PROBE_TIMEOUT_SECONDS" -gt 10 ]; then
PROBE_TIMEOUT_SECONDS=5
fi
;;
esac

degraded() {
echo "GBRAIN_GRAPH: degraded | reason=$1"
exit 0
}

while [ "$#" -gt 0 ]; do
case "$1" in
--session-key)
[ "$#" -ge 2 ] || degraded "missing_session_key"
SESSION_KEY=$(printf '%s' "$2" | tr -cd 'A-Za-z0-9._-')
shift 2
;;
--check-only)
# Compatibility flag. Readiness is now always check-only.
shift
;;
*)
degraded "unknown_argument"
;;
esac
done

# Portable timeout with one lifecycle owner. Bash job control gives the probe
# its own process group on stock macOS; timeout tears down that whole group,
# first gracefully and then with SIGKILL. Polling a separate timer avoids the
# classic watchdog race where the parent dies on SIGTERM, the watchdog gets
# cancelled, and a TERM-resistant descendant keeps the caller's pipe open.
run_bounded() {
_duration="$1"
shift
_had_monitor=0
case "$-" in *m*) _had_monitor=1 ;; esac
set -m
# Keep pipeline input attached explicitly. Non-interactive shells may replace
# stdin for an asynchronous command with /dev/null unless it has a redirect.
"$@" <&0 &
_cmd_pid=$!
[ "$_had_monitor" -eq 1 ] || set +m

sleep "$_duration" &
_timer_pid=$!
while kill -0 "$_cmd_pid" 2>/dev/null && kill -0 "$_timer_pid" 2>/dev/null; do
sleep 0.05
done

if kill -0 "$_cmd_pid" 2>/dev/null; then
# Timer won. The negative PID addresses the isolated process group, not
# just the direct wrapper process.
wait "$_timer_pid" 2>/dev/null
kill -TERM -- "-$_cmd_pid" 2>/dev/null || true
sleep 1
kill -KILL -- "-$_cmd_pid" 2>/dev/null || true
wait "$_cmd_pid" 2>/dev/null
return 124
fi

kill "$_timer_pid" 2>/dev/null
wait "$_timer_pid" 2>/dev/null
wait "$_cmd_pid"
_rc=$?
# A readiness command must never daemonize work. Reap any process that
# inherited its group even when the direct command exited successfully.
if kill -0 -- "-$_cmd_pid" 2>/dev/null; then
kill -TERM -- "-$_cmd_pid" 2>/dev/null || true
sleep 0.05
kill -KILL -- "-$_cmd_pid" 2>/dev/null || true
fi
return "$_rc"
}

valid_source_id() {
[ "${#1}" -le 32 ] || return 1
printf '%s\n' "$1" | grep -Eq '^[a-z0-9]$|^[a-z0-9][a-z0-9-]{0,30}[a-z0-9]$'
}

[ -n "$REPO_ROOT" ] || degraded "not_a_git_worktree"
command -v gbrain >/dev/null 2>&1 || degraded "gbrain_unavailable"
command -v jq >/dev/null 2>&1 || degraded "jq_unavailable"
[ -f "$REPO_ROOT/.gbrain-source" ] || degraded "source_pin_missing"

SOURCE_ID=$(tr -d '[:space:]' < "$REPO_ROOT/.gbrain-source" 2>/dev/null)
[ -n "$SOURCE_ID" ] || degraded "source_pin_empty"
valid_source_id "$SOURCE_ID" || degraded "source_pin_invalid"

HEAD_SHA=$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null)
TRACKED_DIFF=$(git -C "$REPO_ROOT" diff --no-ext-diff --binary HEAD 2>/dev/null | shasum | awk '{print $1}')
UNTRACKED_CODE_HASH=$(
git -C "$REPO_ROOT" ls-files --others --exclude-standard -z 2>/dev/null |
while IFS= read -r -d '' _path; do
if printf '%s\n' "$_path" | grep -Eq '\.(js|jsx|mjs|cjs|ts|tsx|py|rb|go|rs|java|kt|kts|swift|c|cc|cpp|h|hpp)$'; then
printf '%s\n' "$_path"
shasum < "$REPO_ROOT/$_path" 2>/dev/null
fi
done | shasum | awk '{print $1}'
)
FINGERPRINT=$(printf '%s\n%s\n%s\n%s\n%s\n' "$READINESS_SCHEMA" "$SOURCE_ID" "$HEAD_SHA" "$TRACKED_DIFF" "$UNTRACKED_CODE_HASH" | shasum | awk '{print $1}')

# Validate the source and its path before SOURCE_ID participates in any state
# path. `sources list`, like all GBrain probes below, is strictly bounded.
SOURCES_JSON=$(cd "$REPO_ROOT" && run_bounded "$PROBE_TIMEOUT_SECONDS" gbrain sources list --json 2>/dev/null)
[ $? -eq 0 ] && [ -n "$SOURCES_JSON" ] || degraded "sources_probe_failed_or_timed_out"
SOURCE_PATH=$(printf '%s' "$SOURCES_JSON" | jq -r --arg id "$SOURCE_ID" '.sources[]? | select(.id == $id) | .local_path // empty' 2>/dev/null)
[ -n "$SOURCE_PATH" ] || degraded "source_not_registered"
SOURCE_REAL=$(cd "$SOURCE_PATH" 2>/dev/null && pwd -P)
REPO_REAL=$(cd "$REPO_ROOT" 2>/dev/null && pwd -P)
[ "$SOURCE_REAL" = "$REPO_REAL" ] || degraded "source_path_mismatch"

mkdir -p "$STATE_ROOT" 2>/dev/null
CACHE_FILE="$STATE_ROOT/${SOURCE_ID}-${FINGERPRINT}.partial"
REPO_KEY=$(git -C "$REPO_ROOT" remote get-url origin 2>/dev/null | shasum | awk '{print $1}')
CANARY_FILE="$STATE_ROOT/repo-${REPO_KEY}.canary"
CANARY=$(head -1 "$CACHE_FILE" 2>/dev/null | tr -cd 'A-Za-z0-9_$')
[ -n "$CANARY" ] || CANARY=$(head -1 "$CANARY_FILE" 2>/dev/null | tr -cd 'A-Za-z0-9_$')

check_canary() {
_symbol="$1"
[ -n "$_symbol" ] || return 1

_def=$(cd "$REPO_ROOT" && run_bounded "$PROBE_TIMEOUT_SECONDS" gbrain code-def "$_symbol" --json 2>/dev/null)
[ $? -eq 0 ] || return 1
[ "$(printf '%s' "$_def" | jq -r '.ready == true and .count == 1' 2>/dev/null)" = "true" ] || return 1

_callers=$(cd "$REPO_ROOT" && run_bounded "$PROBE_TIMEOUT_SECONDS" gbrain code-callers "$_symbol" --json 2>/dev/null)
[ $? -eq 0 ] || return 1
[ "$(printf '%s' "$_callers" | jq -r '.ready == true and .count > 0' 2>/dev/null)" = "true" ] || return 1

_init=$(jq -nc '{jsonrpc:"2.0",id:1,method:"initialize",params:{protocolVersion:"2025-03-26",capabilities:{},clientInfo:{name:"gstack-graph-canary",version:"1"}}}')
_ready=$(jq -nc '{jsonrpc:"2.0",method:"notifications/initialized",params:{}}')
_call=$(jq -nc --arg symbol "$_symbol" --arg source "$SOURCE_ID" '{jsonrpc:"2.0",id:2,method:"tools/call",params:{name:"code_blast",arguments:{symbol:$symbol,source_id:$source,depth:1,max_nodes:1}}}')
_blast_raw=$(cd "$REPO_ROOT" && { printf '%s\n' "$_init" "$_ready" "$_call"; } | run_bounded "$PROBE_TIMEOUT_SECONDS" gbrain serve --surface full 2>/dev/null)
[ $? -eq 0 ] || return 1
_blast=$(printf '%s' "$_blast_raw" | jq -r 'select(.id == 2) | .result.content[0].text' 2>/dev/null | jq -c '.' 2>/dev/null)
[ "$(printf '%s' "$_blast" | jq -r '.ready == true and .result == "ok" and ([.depth_groups[]?.nodes[]?] | length) > 0' 2>/dev/null)" = "true" ] || return 1
return 0
}

# A prior canary is a hint, never authority. Re-prove it against the live graph.
if [ -n "$CANARY" ] && check_canary "$CANARY"; then
printf '%s\n' "$CANARY" > "$CACHE_FILE"
if [ -n "$SESSION_KEY" ]; then
printf '%s\n' "$CANARY" > "$STATE_ROOT/session-${SESSION_KEY}-${FINGERPRINT}.partial"
fi
echo "GBRAIN_GRAPH: partial | source=$SOURCE_ID | canary=$CANARY | cache=hit"
exit 0
fi

# Cold discovery is local and deliberately limited to one likely symbol. That
# keeps the total worst-case foreground budget bounded (sources + one canary),
# while `/sync-gbrain --dream` remains the explicit repair when it cannot prove
# a graph.
CANARY=$(
git -C "$REPO_ROOT" grep -h -oE 'export (async )?function [A-Za-z_$][A-Za-z0-9_$]*' -- '*.ts' '*.tsx' 2>/dev/null |
awk '{print $NF}' | sort -u | head -1
)
if [ -n "$CANARY" ] && check_canary "$CANARY"; then
printf '%s\n' "$CANARY" > "$CANARY_FILE"
printf '%s\n' "$CANARY" > "$CACHE_FILE"
if [ -n "$SESSION_KEY" ]; then
printf '%s\n' "$CANARY" > "$STATE_ROOT/session-${SESSION_KEY}-${FINGERPRINT}.partial"
fi
echo "GBRAIN_GRAPH: partial | source=$SOURCE_ID | canary=$CANARY | cache=miss"
exit 0
fi

rm -f "$CACHE_FILE" 2>/dev/null
degraded "no_graph_canary_run_sync_gbrain"
49 changes: 36 additions & 13 deletions bin/gstack-gbrain-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -904,8 +904,8 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
ok: true,
duration_ms: 0,
summary: pinnedSourceId
? `would: gbrain sync --strategy code --source ${sourceId}; gbrain sources attach ${sourceId}`
: `would: gbrain sources add ${sourceId} --path ${root} --federated; gbrain sync --strategy code --source ${sourceId}; gbrain sources attach ${sourceId}`,
? `would: gbrain sources set-strategy ${sourceId} code; gbrain sync --strategy code --source ${sourceId}; gbrain sources attach ${sourceId}`
: `would: gbrain sources add ${sourceId} --path ${root} --federated; gbrain sources set-strategy ${sourceId} code; gbrain sync --strategy code --source ${sourceId}; gbrain sources attach ${sourceId}`,
detail: { source_id: sourceId, source_path: root, status: "skipped" },
};
}
Expand Down Expand Up @@ -984,6 +984,27 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
}
}

// Persist the source's purpose, not just this invocation's override.
// Autopilot and `sync --all` read sources.config.strategy and otherwise
// fall back to markdown, which can silently replace a valid code index on
// their next full walk. A one-shot `--strategy code` override cannot make
// that durable, so failure here must fail the stage closed.
const strategyResult = spawnGbrain(["sources", "set-strategy", sourceId, "code"], {
stdio: ["ignore", "ignore", "pipe"],
timeout: 10_000,
baseEnv: gbrainEnv,
});
if (strategyResult.status !== 0) {
return {
name: "code",
ran: true,
ok: false,
duration_ms: Date.now() - t0,
summary: `gbrain sources set-strategy ${sourceId} code exited ${strategyResult.status}`,
detail: { source_id: sourceId, source_path: root, status: "failed" },
};
}

// Step 2: Always run the page-creating file walk first, then (for --full)
// a full re-embed.
//
Expand Down Expand Up @@ -1391,8 +1412,8 @@ export async function runDream(args: CliArgs): Promise<StageResult> {
ok: true,
duration_ms: 0,
summary: sourceId
? `would: gbrain dream --source ${sourceId} (build this source's call graph)`
: "would: gbrain dream (call-graph build)",
? `would: gbrain dream --source ${sourceId} --phase resolve_symbol_edges (build this source's call graph)`
: "would: gbrain dream --phase resolve_symbol_edges (call-graph build)",
};
}

Expand Down Expand Up @@ -1423,17 +1444,16 @@ export async function runDream(args: CliArgs): Promise<StageResult> {
DEFAULT_DREAM_TIMEOUT_MS,
);

// Scope the cycle to THIS worktree's code source: `gbrain dream --source <id>`.
// Verified empirically (not just from `gbrain --help`): plain `gbrain dream`
// cycles the brain's default source and never runs the source-scoped `extract`
// phase for our code source, so the call graph for the pinned source stays
// empty. `gbrain dream --source <id>` runs the per-source cycle (the form
// `gbrain doctor` recommends for stale sources) and is what actually populates
// code-callers/code-callees for this worktree. Falls back to plain `dream`
// only when we can't derive the source id (not in a git repo).
// Explicitly request the global resolver phase. Current gbrain intentionally
// excludes global phases from an implicit non-default source cycle, so bare
// `dream --source <id>` can exit 0 without ever resolving symbol edges.
// Keeping --source still binds the receipt to this worktree; --phase is the
// load-bearing proof that the call-graph phase actually ran.
const root = repoRoot();
const sourceId = root ? resolveCodeSourceId(root, gbrainEnv) : null;
const dreamArgs = sourceId ? ["dream", "--source", sourceId] : ["dream"];
const dreamArgs = sourceId
? ["dream", "--source", sourceId, "--phase", "resolve_symbol_edges"]
: ["dream", "--phase", "resolve_symbol_edges"];

// spawnGbrain seeds DATABASE_URL from gbrain's config via buildGbrainEnv.
//
Expand Down Expand Up @@ -1587,6 +1607,9 @@ export function classifyDreamOutcome(out: string): string | null {
if (parseResolvedEdges(out) === 0) {
return "dream ran but resolved 0 call-graph edges (no code symbols matched for this source yet).";
}
if (parseResolvedEdges(out) === null) {
return "dream exited successfully but did not prove resolve_symbol_edges completed; call-graph readiness is unknown.";
}
return null;
}

Expand Down
10 changes: 10 additions & 0 deletions bin/gstack-patch-names
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ set -euo pipefail
GSTACK_DIR="$1"
DO_PREFIX="$2"

# Canonical checkouts are generated source, not an install target. Refuse to
# rewrite tracked SKILL.md files even if a future caller accidentally points
# this legacy helper at the repository root; setup/relink create and patch
# consumer copies instead.
TRACKED_SKILLS=$(git -C "$GSTACK_DIR" ls-files 'SKILL.md' '*/SKILL.md' 2>/dev/null || true)
if [ -n "$TRACKED_SKILLS" ]; then
echo "Error: refusing to patch tracked canonical SKILL.md files; run gstack-relink to refresh installed copies." >&2
exit 2
fi

# Normalize prefix arg
case "$DO_PREFIX" in true|1) DO_PREFIX=1 ;; *) DO_PREFIX=0 ;; esac

Expand Down
Loading
Loading