Skip to content

Commit fa7ced7

Browse files
garrytanclaude
andcommitted
Merge origin/main into garrytan/trunk-land-skill
Reconcile VERSION (1.56.0.0 stays above main's 1.55.0.0), package.json, and CHANGELOG (1.56.0.0 entry on top of main's 1.54/1.55 entries). Regenerated all host SKILL.md against main's resolver changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 parents 24edb65 + 3bef43b commit fa7ced7

81 files changed

Lines changed: 5900 additions & 5220 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 167 additions & 78 deletions
Large diffs are not rendered by default.

CLAUDE.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -938,4 +938,10 @@ file globs. Run `/sync-gbrain` after meaningful code changes; for ongoing
938938
auto-sync across all worktrees, run `gbrain autopilot --install` once per
939939
machine — gbrain's daemon handles incremental refresh on a schedule.
940940

941+
Safety: don't run `/sync-gbrain` while `gbrain autopilot` is active — the
942+
orchestrator refuses destructive source ops when it detects a running autopilot
943+
to avoid racing it (#1734). Prefer registering user repos with `gbrain sources
944+
add --path <dir>` (no `--url`): URL-managed sources can auto-reclone, and the
945+
sync code walk for them requires an explicit `--allow-reclone` opt-in.
946+
941947
<!-- gstack-gbrain-search-guidance:end -->

USING_GBRAIN_WITH_GSTACK.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ The skill runs three stages — code, memory, brain-sync — independently. A fa
136136

137137
1. **Pre-flight.** Checks `gbrain_local_status` (the local engine's health). If the engine is `broken-db` or `broken-config`, the skill STOPs with a remediation menu — it refuses to silently degrade. If the local engine is missing and you're in remote-MCP mode (Path 4), the code stage SKIPs cleanly and only brain-sync runs.
138138
2. **Code stage.** Registers the cwd as a federated source via `gbrain sources add`, writes a `.gbrain-source` pin file in the repo root (kubectl-style context — every worktree gets its own pin, so Conductor sibling worktrees don't collide), runs `gbrain sync --strategy code`.
139-
3. **Memory stage.** Stages your `~/.gstack/` transcripts + curated memory. In local-stdio MCP mode, ingests into the local engine. In remote-http MCP mode, persists staged markdown to `~/.gstack/transcripts/run-<pid>-<ts>/` for the remote brain admin's pull pipeline.
139+
3. **Memory stage.** Stages your `~/.gstack/` transcripts + curated memory. In local-stdio MCP mode, ingests into the local engine. In remote-http MCP mode, persists staged markdown to `~/.gstack/transcripts/run-<pid>-<ts>/` for the remote brain admin's pull pipeline. The ingest timeout is 30 minutes by default; raise it for a big brain with `GSTACK_INGEST_TIMEOUT_MS` (accepts 1 min–24h). On timeout the gbrain import checkpoint is preserved, so the next `/sync-gbrain` resumes instead of starting over.
140140
4. **Brain-sync stage.** Pushes curated artifacts (plans, designs, retros) to your private artifacts repo if you have one configured.
141141
5. **CLAUDE.md guidance.** Capability-checks the round-trip (write a page → search → find it). If green, writes the `## GBrain Search Guidance` block to your project's CLAUDE.md. If red, REMOVES the block — the agent should never be told to use a tool that isn't installed.
142142

@@ -379,7 +379,7 @@ Another gstack session in a sibling Conductor workspace may be holding a lock on
379379
## Related skills + next steps
380380

381381
- `/health` — includes a GBrain dimension (doctor status, sync queue depth, last-push age) in its 0-10 composite score. The dimension is omitted when gbrain isn't installed; running `/health` on a non-gbrain machine doesn't penalize that choice.
382-
- `/gstack-upgrade` — keeps gstack itself up to date. Does NOT upgrade gbrain independently. To bump gbrain, update `PINNED_COMMIT` in `bin/gstack-gbrain-install` and re-run `/setup-gbrain`.
382+
- `/gstack-upgrade` — keeps gstack itself up to date. Does NOT upgrade gbrain independently. gbrain installs at the latest HEAD by default; to refresh it, `git pull` in your gbrain clone (default `~/gbrain`) and re-run `/setup-gbrain`. Pin a specific commit with `gstack-gbrain-install --pinned-commit <sha>` if you need reproducibility. Installs below the minimum tested version are refused.
383383
- `/retro` — weekly retrospective pulls learnings and plans from your gbrain when memory sync is on, letting the retro reference cross-machine history.
384384

385385
Run `/setup-gbrain` and see what sticks.

bin/gstack-gbrain-install

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,14 @@
1919
# - git
2020
# - network reachability to https://github.com
2121
#
22-
# The pinned commit is declared here rather than resolved dynamically so
23-
# upgrades are explicit and reviewable. Update PINNED_COMMIT when gstack
24-
# verifies compatibility with a new gbrain release.
22+
# gbrain installs at the latest default-branch HEAD by default — the hard pin
23+
# was removed in #1744 (it had drifted ~23 versions behind). Pass
24+
# --pinned-commit <sha> to install a specific commit for reproducibility. A
25+
# minimum-version floor (MIN_GBRAIN_VERSION) hard-fails the install when the
26+
# resulting gbrain is too old for gstack's sync integration, and a fast
27+
# `gbrain doctor` self-test hard-fails a broken install when gbrain is already
28+
# configured. This keeps the version gate that the pin used to provide without
29+
# freezing users 23 releases behind.
2530
#
2631
# Env:
2732
# GBRAIN_INSTALL_DIR — override default install path (~/gbrain)
@@ -33,8 +38,14 @@
3338
set -euo pipefail
3439

3540
# --- defaults ---
36-
PINNED_COMMIT="08b3698e90532b7b66c445e6b1d8cdfe71822802" # gbrain v0.18.2
37-
PINNED_TAG="v0.18.2"
41+
# No version pin by default — install the latest default-branch HEAD (#1744).
42+
# --pinned-commit <sha> overrides for reproducibility.
43+
PINNED_COMMIT=""
44+
PINNED_TAG=""
45+
# Minimum gbrain version gstack's integration is known to work with. The
46+
# `sources list --json` wrapped-object shape + federated sources landed by 0.20;
47+
# older predates the surface gstack drives. Hard-fail below this floor (#1744).
48+
MIN_GBRAIN_VERSION="0.20.0"
3849
GBRAIN_REPO_URL="https://github.com/garrytan/gbrain.git"
3950
DEFAULT_INSTALL_DIR="${GBRAIN_INSTALL_DIR:-$HOME/gbrain}"
4051
INSTALL_DIR="$DEFAULT_INSTALL_DIR"
@@ -113,16 +124,20 @@ elif [ -n "$DETECTED_CLONE" ]; then
113124
else
114125
# Fresh clone path.
115126
if $DRY_RUN; then
116-
log "DRY RUN: would clone $GBRAIN_REPO_URL @ $PINNED_COMMIT$INSTALL_DIR"
127+
log "DRY RUN: would clone $GBRAIN_REPO_URL ${PINNED_COMMIT:+@ $PINNED_COMMIT }$INSTALL_DIR (latest HEAD unless --pinned-commit)"
117128
exit 0
118129
fi
119130
if [ -d "$INSTALL_DIR" ]; then
120131
fail "install dir $INSTALL_DIR exists but is not a valid gbrain clone. Remove it or pass --install-dir <other>."
121132
fi
122133
log "cloning $GBRAIN_REPO_URL$INSTALL_DIR"
123134
git clone --quiet "$GBRAIN_REPO_URL" "$INSTALL_DIR"
124-
( cd "$INSTALL_DIR" && git checkout --quiet "$PINNED_COMMIT" )
125-
log "pinned to $PINNED_COMMIT${PINNED_TAG:+ ($PINNED_TAG)}"
135+
if [ -n "$PINNED_COMMIT" ]; then
136+
( cd "$INSTALL_DIR" && git checkout --quiet "$PINNED_COMMIT" )
137+
log "checked out pinned commit $PINNED_COMMIT${PINNED_TAG:+ ($PINNED_TAG)}"
138+
else
139+
log "installed latest gbrain (default-branch HEAD)"
140+
fi
126141
fi
127142

128143
if $DRY_RUN; then
@@ -195,6 +210,44 @@ fi
195210

196211
log "installed gbrain $actual_version from $INSTALL_DIR"
197212

213+
# --- minimum-version floor (#1744) ---
214+
# Unpinning means new installs track gbrain HEAD. Hard-fail if the resulting
215+
# version is below the floor gstack's sync integration needs — same exit-3 posture
216+
# as the PATH-shadow / version-mismatch failures above. A warning here is exactly
217+
# how the data-loss class slipped through, so this gate fails closed.
218+
version_lt() {
219+
# 0 (true) when $1 < $2 by version sort; equal versions are NOT less-than.
220+
[ "$1" = "$2" ] && return 1
221+
[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -1)" = "$1" ]
222+
}
223+
if version_lt "$actual_norm" "$MIN_GBRAIN_VERSION"; then
224+
echo "" >&2
225+
echo "gstack-gbrain-install: gbrain $actual_version is below the minimum gstack-tested version ($MIN_GBRAIN_VERSION)." >&2
226+
echo " gstack's sync integration needs the v0.20+ source/list surface." >&2
227+
echo " Fix: update the gbrain clone at $INSTALL_DIR to a newer release (git pull), then" >&2
228+
echo " re-run /setup-gbrain. Or pass --pinned-commit <sha> to install a specific newer commit." >&2
229+
echo "" >&2
230+
exit 3
231+
fi
232+
233+
# --- functional self-test when gbrain is already configured (#1744) ---
234+
# When a brain config exists (re-install / detected clone), run a fast doctor as
235+
# a hard gate so a broken gbrain is caught at setup, not at data-loss time.
236+
# Pre-init installs skip this (config not written yet); the full
237+
# `/sync-gbrain --dry-run` self-test runs from /setup-gbrain after `gbrain init`.
238+
_GBRAIN_HOME_CHECK="${GBRAIN_HOME:-$HOME/.gbrain}"
239+
if [ -f "$_GBRAIN_HOME_CHECK/config.json" ]; then
240+
if ! gbrain doctor --fast >/dev/null 2>&1; then
241+
echo "" >&2
242+
echo "gstack-gbrain-install: gbrain $actual_version installed but 'gbrain doctor --fast' failed." >&2
243+
echo " Refusing to leave a broken gbrain in place. Run 'gbrain doctor' to see what's wrong," >&2
244+
echo " fix it, then re-run /setup-gbrain." >&2
245+
echo "" >&2
246+
exit 3
247+
fi
248+
log "gbrain doctor --fast passed"
249+
fi
250+
198251
# v1.40.0.0 post-install validation (T6 / codex review #19): --ignore-scripts
199252
# may skip artifacts gbrain needs at runtime, especially on Windows
200253
# MSYS/MINGW where we DID pass --ignore-scripts. `gbrain --version` above

bin/gstack-gbrain-sync.ts

Lines changed: 85 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,10 @@ import { createHash } from "crypto";
3737

3838
import "../lib/conductor-env-shim";
3939
import { detectEngineTier, withErrorContext, canonicalizeRemote } from "../lib/gstack-memory-helpers";
40-
import { ensureSourceRegistered, sourcePageCount } from "../lib/gbrain-sources";
40+
import { ensureSourceRegistered, sourcePageCount, parseSourcesList } from "../lib/gbrain-sources";
41+
import { detectAutopilot, decideSourceRemove, decideCodeSync } from "../lib/gbrain-guards";
4142
import { localEngineStatus, type LocalEngineStatus } from "../lib/gbrain-local-status";
42-
import { buildGbrainEnv, spawnGbrain, execGbrainJson } from "../lib/gbrain-exec";
43+
import { buildGbrainEnv, spawnGbrain, execGbrainJson, NEEDS_SHELL_ON_WINDOWS } from "../lib/gbrain-exec";
4344

4445
// ── Types ──────────────────────────────────────────────────────────────────
4546

@@ -52,14 +53,16 @@ interface CliArgs {
5253
noMemory: boolean;
5354
noBrainSync: boolean;
5455
codeOnly: boolean;
56+
/** #1734: opt-in to sync a URL-managed source whose code walk may auto-reclone. */
57+
allowReclone: boolean;
5558
}
5659

5760
interface CodeStageDetail {
5861
source_id?: string;
5962
source_path?: string;
6063
page_count?: number | null;
6164
last_imported?: string;
62-
status?: "ok" | "skipped" | "failed";
65+
status?: "ok" | "skipped" | "failed" | "refused-autopilot" | "refused-reclone";
6366
}
6467

6568
interface StageResult {
@@ -205,6 +208,8 @@ Options:
205208
--no-memory Skip the gstack-memory-ingest stage (transcripts + artifacts).
206209
--no-brain-sync Skip the gstack-brain-sync git pipeline stage.
207210
--code-only Only run the code-import stage (alias for --no-memory --no-brain-sync).
211+
--allow-reclone Permit the code walk for URL-managed sources (remote_url set)
212+
even though gbrain may auto-reclone the working tree (#1734).
208213
--help This text.
209214
210215
Stages run in order: code → memory ingest → curated git push.
@@ -220,6 +225,7 @@ function parseArgs(): CliArgs {
220225
let noMemory = false;
221226
let noBrainSync = false;
222227
let codeOnly = false;
228+
let allowReclone = false;
223229

224230
for (let i = 0; i < args.length; i++) {
225231
const a = args[i];
@@ -231,6 +237,7 @@ function parseArgs(): CliArgs {
231237
case "--no-code": noCode = true; break;
232238
case "--no-memory": noMemory = true; break;
233239
case "--no-brain-sync": noBrainSync = true; break;
240+
case "--allow-reclone": allowReclone = true; break;
234241
case "--code-only":
235242
codeOnly = true;
236243
noMemory = true;
@@ -247,7 +254,7 @@ function parseArgs(): CliArgs {
247254
}
248255
}
249256

250-
return { mode, quiet, noCode, noMemory, noBrainSync, codeOnly };
257+
return { mode, quiet, noCode, noMemory, noBrainSync, codeOnly, allowReclone };
251258
}
252259

253260
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -407,10 +414,7 @@ export function sourceLocalPath(sourceId: string, env?: NodeJS.ProcessEnv): stri
407414
{ baseEnv: env },
408415
);
409416
if (!raw) return null;
410-
const list: Array<{ id?: string; local_path?: string }> = Array.isArray(raw)
411-
? (raw as Array<{ id?: string; local_path?: string }>)
412-
: ((raw as { sources?: Array<{ id?: string; local_path?: string }> }).sources ?? []);
413-
const found = list.find((s) => s.id === sourceId);
417+
const found = parseSourcesList(raw).find((s) => s.id === sourceId);
414418
return found?.local_path ?? null;
415419
}
416420

@@ -469,20 +473,50 @@ export function planHostnameFoldMigration(
469473
return { kind: "pending-cleanup", oldId: legacyPathHashId };
470474
}
471475

476+
export interface GuardedRemoveResult {
477+
removed: boolean;
478+
/** True when a guard refused the remove (autopilot active or unsafe source). */
479+
skipped: boolean;
480+
reason: string;
481+
}
482+
483+
/**
484+
* #1734: run `gbrain sources remove <id> --confirm-destructive` only behind the
485+
* data-loss guards. Checked immediately before the destructive op (E8: as late
486+
* as possible) so the autopilot window is as small as we can make it without a
487+
* gbrain-side lease. Refuses when autopilot is active or when the source is
488+
* user-managed and gbrain can't keep its storage. Pure side-effect helper; the
489+
* caller decides whether a skip is fatal (it never is today — removes are
490+
* best-effort cleanup).
491+
*/
492+
export function safeSourcesRemove(sourceId: string, env?: NodeJS.ProcessEnv): GuardedRemoveResult {
493+
const ap = detectAutopilot(env);
494+
if (ap.active) {
495+
return {
496+
removed: false,
497+
skipped: true,
498+
reason: `autopilot active (${ap.signal}); refusing destructive remove of ${sourceId}. ` +
499+
`Stop autopilot, then re-run /sync-gbrain.`,
500+
};
501+
}
502+
const decision = decideSourceRemove(sourceId, env);
503+
if (!decision.allow) {
504+
return { removed: false, skipped: true, reason: decision.reason };
505+
}
506+
const r = spawnGbrain(
507+
["sources", "remove", sourceId, "--confirm-destructive", ...decision.extraArgs],
508+
{ baseEnv: env },
509+
);
510+
return { removed: r.status === 0, skipped: false, reason: decision.reason };
511+
}
512+
472513
/**
473514
* Remove an orphaned source. Called only after new-source sync verifies pages
474-
* exist, so the old source is provably redundant before deletion.
475-
*
476-
* Flag note: existing call sites used `--confirm-destructive` here and
477-
* `--yes` in `lib/gbrain-sources.ts` — gbrain 0.35.0.0 accepts neither
478-
* deterministically (the subcommand surface help is generic). We pass
479-
* `--confirm-destructive` to match the existing call site convention; the
480-
* flag-helper centralization in commit 4 (lib/gbrain-exec.ts) will resolve
481-
* the inconsistency across the codebase.
515+
* exist, so the old source is provably redundant before deletion. Routed through
516+
* safeSourcesRemove for the #1734 guards.
482517
*/
483518
export function removeOrphanedSource(oldId: string, env?: NodeJS.ProcessEnv): boolean {
484-
const r = spawnGbrain(["sources", "remove", oldId, "--confirm-destructive"], { baseEnv: env });
485-
return r.status === 0;
519+
return safeSourcesRemove(oldId, env).removed;
486520
}
487521

488522
/**
@@ -661,13 +695,12 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
661695
const legacyId = deriveLegacyCodeSourceId(root);
662696
let legacyRemoved = false;
663697
if (legacyId !== sourceId) {
664-
const rm = spawnGbrain(["sources", "remove", legacyId, "--confirm-destructive"], {
665-
timeout: 30_000,
666-
baseEnv: gbrainEnv,
667-
});
668-
// Treat absent-source as success (clean state). gbrain emits "not found" on
669-
// missing id; treat any non-zero exit without "not found" as a soft fail.
670-
if (rm.status === 0) legacyRemoved = true;
698+
// #1734: route through the data-loss guards (autopilot + source-safety).
699+
const rm = safeSourcesRemove(legacyId, gbrainEnv);
700+
if (rm.skipped && !args.quiet) {
701+
console.error(`[sync:code] legacy-source cleanup skipped: ${rm.reason}`);
702+
}
703+
if (rm.removed) legacyRemoved = true;
671704
}
672705

673706
// Step 0b: Hostname-fold migration (#1414).
@@ -720,6 +753,29 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
720753
process.env.GSTACK_SYNC_CODE_TIMEOUT_MS,
721754
"GSTACK_SYNC_CODE_TIMEOUT_MS",
722755
);
756+
757+
// #1734 guards, checked immediately before the destructive walk (E8):
758+
// - autopilot active → refuse (the race that wiped a working tree).
759+
// - URL-managed source → the walk can auto-reclone (rm-rf); require
760+
// --allow-reclone. Both surface a visible reason and fail the stage so the
761+
// verdict shows ERR rather than silently skipping protection.
762+
const apBeforeWalk = detectAutopilot(gbrainEnv);
763+
if (apBeforeWalk.active) {
764+
return {
765+
name: "code", ran: true, ok: false, duration_ms: Date.now() - t0,
766+
summary: `refused: gbrain autopilot active (${apBeforeWalk.signal}). Stop autopilot, then re-run /sync-gbrain.`,
767+
detail: { source_id: sourceId, source_path: root, status: "refused-autopilot" },
768+
};
769+
}
770+
const reclone = decideCodeSync(sourceId, gbrainEnv, args.allowReclone);
771+
if (!reclone.allow) {
772+
return {
773+
name: "code", ran: true, ok: false, duration_ms: Date.now() - t0,
774+
summary: `refused: ${reclone.reason}`,
775+
detail: { source_id: sourceId, source_path: root, status: "refused-reclone" },
776+
};
777+
}
778+
723779
const walkResult = spawnGbrain(["sync", "--strategy", "code", "--source", sourceId], {
724780
stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"],
725781
timeout: codeTimeoutMs,
@@ -961,13 +1017,17 @@ function runBrainSyncPush(args: CliArgs): StageResult {
9611017
return { name: "brain-sync", ran: false, ok: true, duration_ms: 0, summary: "skipped (gstack-brain-sync not installed)" };
9621018
}
9631019

1020+
// #1731: gstack-brain-sync is a bash shebang script; Windows can't spawn it
1021+
// without a shell, which surfaced as "brain-sync exited undefined".
9641022
spawnSync(brainSyncPath, ["--discover-new"], {
9651023
stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"],
9661024
timeout: 60 * 1000,
1025+
shell: NEEDS_SHELL_ON_WINDOWS,
9671026
});
9681027
const result = spawnSync(brainSyncPath, ["--once"], {
9691028
stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"],
9701029
timeout: 60 * 1000,
1030+
shell: NEEDS_SHELL_ON_WINDOWS,
9711031
});
9721032

9731033
return {

bin/gstack-jsonl-merge

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,18 +53,25 @@ for path in paths:
5353
continue
5454
if line in seen:
5555
continue
56-
# Prefer ISO ts field for sort; fall back to SHA-256.
56+
# Prefer ISO ts field for sort; fall back to SHA-256. The line
57+
# content is the final tiebreaker so the order is total: two
58+
# entries sharing a ts must resolve identically regardless of
59+
# which side they arrive on. Without it, equal-ts entries fall
60+
# back to insertion order (base, ours, theirs), and since ours
61+
# and theirs are swapped depending on which machine runs the
62+
# merge, the two sides produce divergent files that never
63+
# converge.
5764
sort_key = None
5865
try:
5966
obj = json.loads(line)
6067
ts = obj.get('ts') or obj.get('timestamp')
6168
if isinstance(ts, str):
62-
sort_key = (0, ts)
69+
sort_key = (0, ts, line)
6370
except (json.JSONDecodeError, ValueError, TypeError):
6471
pass
6572
if sort_key is None:
6673
h = hashlib.sha256(line.encode('utf-8')).hexdigest()
67-
sort_key = (1, h)
74+
sort_key = (1, h, line)
6875
seen[line] = sort_key
6976
except FileNotFoundError:
7077
# Absent base / absent ours / absent theirs are all valid.

0 commit comments

Comments
 (0)