Files
gstack/lib/gbrain-guards.ts
T
Garry Tan 45cc95d5f4 v1.57.5.0 feat: cross-session decision memory + gbrain dream-stage call graph (#1910)
* feat(gbrain-sync): add cycleCompleted() cycle-state probe

Reads `gbrain doctor` cycle_freshness to classify whether a source has
completed a full cycle (completed/never/unknown). A fail naming this source
-> never; a fail naming only other sources -> completed; an absent or
unparseable check -> unknown, so an unrelated doctor failure never masks a
real state. Gates the automatic call-graph build on --full.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(gbrain-sync): --dream call-graph stage with lock-free gate + honest outcome guard

Adds a source-scoped `gbrain dream --source <id>` stage that builds this
worktree's call graph (code-callers/code-callees). Runs lock-free after the
sync lock releases so it never blocks sibling worktrees; a .dream-in-progress
marker dedupes concurrent dreams. --full auto-runs it only when the cycle was
never built; explicit --dream always forces; --no-dream opts out.

The stage parses the cycle's own output and reports the truth, not a flat
"built": a WARN when the schema pack can't extract code symbols, when the
embed phase failed for a missing key, or when 0 edges resolved; OK with the
resolved-edge count otherwise. gbrain exits 0 even when it skips on a held
cycle lock (e.g. autopilot), so that case reports SKIP, not success.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: ignore gbrain .sources/ local staging dir

gbrain writes per-source staging and capability-check artifacts under
.sources/ in the repo root. It's machine-local runtime state, not source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(gbrain): honest call-graph guidance in /sync-gbrain + pin works on gbrain>=0.41.38

sync-gbrain frames the --dream offer honestly: building a call graph requires a
code-aware schema pack, and the dream stage reports a WARN when it can't. The
verdict's Call graph row mirrors the dream stage's real outcome instead of
assuming a completed cycle means edges exist. The ## GBrain Search Guidance
block written into CLAUDE.md drops the old code-callers --source caveat:
gbrain >=0.41.38.0 honors the .gbrain-source pin for code-callers/code-callees.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(jsonl-store): shared audited JSONL plumbing (injection-reject + atomic append + tolerant read)

Single source of truth extracted for D2A: gstack-learnings-* and the upcoming
gstack-decision-* bins share one injection-pattern list, one atomic single-line
appender, and one tolerant reader. No more drift between stores.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(learnings-log): use shared hasInjection from lib/jsonl-store (D2A)

Replace the inline injection-pattern copy with the shared list. One audited
write-path rejection across learnings + the upcoming decision store. Behavior
unchanged (35/35 learnings tests green); learnings-search keeps its inline copy
because a structural test pins its bash/bun shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(decision): event-sourced decision-memory model (lib/gstack-decision)

decide/supersede/redact events on lib/jsonl-store; active set is computed (no
mutable status), dangling refs tolerated. Free-text is injection-checked and
redact-scanned on write (HIGH secret -> reject). Scope filter (repo/branch/issue)
for relevant resurfacing. File-only + reliable; gbrain not required.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(decision): bounded active snapshot + compaction (redact expunges, supersede archives)

writeSnapshot/readSnapshot/rebuildSnapshot give an O(active) bounded read for the
session-start hot path (D1A). compact() rewrites the log to active, archives
superseded decisions for history, and EXPUNGES redacted ones (dropped, never
archived) so an accidentally-captured secret leaves the store for good.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(decision): gstack-decision-log + gstack-decision-search bins (non-interactive)

Two bins mirroring gstack-learnings-* (D3A). log writes decide/--supersede/--redact/
--compact events + refreshes the bounded snapshot + enqueues for cross-machine sync;
search reads the O(active) snapshot, scope-filtered to current branch, newest-first,
--all to include superseded, --json for machines. Empty store returns silently
(no snapshot write on an empty read).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(memory): surface active decisions at session start + capture nudge (Context Recovery)

Context Recovery now shows recent scope-relevant active decisions (bounded read of
decisions.active.json via gstack-decision-search) and instructs the agent to treat
them as settled calls and to log durable decisions/reversals. Closes the Phase-1
capture->curate->resurface loop, reliable + file-only. Regen across all hosts folded
in (squash-with-regen); parity 10/10, freshness green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: refresh ship golden baselines for the memory-loop preamble change

Context Recovery now emits the cross-session-decisions block, so ship's preamble
(all hosts) changed. Golden baselines are hand-maintained copies (gen does not
write them); refresh them from the fresh gen so golden-file regression passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(memory): document the cross-session decision-memory loop in CLAUDE.md

Adds a '## Cross-session decision memory' section: how to resurface
(gstack-decision-search) and capture (gstack-decision-log) durable decisions,
the supersede/redact/compact verbs, and a crisp durable-vs-trivial definition
so the store stays signal. Reliable file-only path; gbrain not required.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(memory): emit durable decisions from ship/ceo/eng/spec at structured points

Wires the four skills that finalize real decisions to capture them in the
cross-session decision store, from their STRUCTURED outputs (never free-text
scraping):
- ship: the version bump (level + why) at write time
- plan-ceo-review: accepted scope + verdict (branch-scoped)
- plan-eng-review: the architecture verdict + key call (branch-scoped)
- spec: the filed issue's core approach (issue-scoped)

All emits are non-interactive, schema-correct (content in decision/rationale,
source=skill, confidence 1-10), and best-effort (|| true) so a decision-log
failure never blocks the workflow. Includes regen across hosts + refreshed ship
golden baselines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(memory): optional gbrain --semantic recall for decision search

Adds gstack-decision-search --semantic (with --query): appends a 'Related from
memory' block from gbrain semantic search, scoped to the curated-memory source.
Pure enhancement, reliability-first: a new lib/gstack-decision-semantic.ts is the
ONLY decision module that touches gbrain and is imported lazily only on --semantic,
so the reliable file path never loads gbrain code. Every path degrades to the
reliable file results when gbrain is off, unconfigured, empty, or errors (never
throws, 10s timeout).

Built against the verified gbrain 0.42.x surface (text output [score] slug --
snippet, NOT JSON; curated-memory source resolved by worktree path, not a
gstack-brain-<user> id). Deterministic-contract tests only: parser units,
degrade-to-null when gbrain absent, and a fake-gbrain shim proving scope+search
end-to-end. find-contradictions deferred (no verifiable CLI surface yet + curated
memory not indexed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(gbrain-sync): self-heal stale autopilot lock (dead-pid)

detectAutopilot treated a lock FILE as proof of life, so a crashed gbrain daemon
left a stale lock that wedged every sync forever (observed: a dead pid refused
--full indefinitely). Now read the holder pid (bare or JSON body) and check
liveness via signal-0: ESRCH=dead → ignore the stale signal and keep checking;
EPERM=alive (other user) → active. A stale lock never masks a live autopilot
process. Pure decision function — does not delete the file; the caller may clean it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(review): drop stray trailing code fence in TODOS-format

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(test): align section-loading E2E testNames with their TOUCHFILES keys

Pre-existing on main (v1.56.x): the two section-loading E2E tests used
human-label testNames ('/ship section-loading') that don't match their slug
keys ('ship-section-loading') in E2E_TOUCHFILES/E2E_TIERS. Every other E2E test
uses the slug as its testName, and the TOUCHFILES completeness gate requires
testName to be a registered key — so the gate was red. Align both testNames to
their slug keys (also fixes tier lookup for these two periodic tests).

Verified failing on a clean origin/main checkout before the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: pre-landing review fixes (datamark, DRY, compact, coverage)

Addresses the pre-landing review findings (all INFORMATIONAL, no criticals):
- security: datamark resurfaced decision text at the render boundary
  (lib/gstack-decision.ts datamark() — neutralizes code fences, --- banners,
  <|role|>/</system> markers, control chars, newlines). Applied in
  gstack-decision-search human output so stored text can't masquerade as
  instructions in Context Recovery (codex hardening #3 / AC #7). --json stays raw.
- DRY: extract resolveSlug/gitBranch/flagValue to lib/bin-context.ts; both
  decision bins use it instead of duplicating the helpers.
- compact(): batch the archive append (one write, not N) and shrink the
  mid-compact crash window; simplify the opaque branch/issue ternary.
- coverage: learnings-log injection rejection (D2A wiring), search --recent/
  --scope + NaN-safe --recent, datamark-applied, unparseable lock body,
  compact-empty, corrupt-snapshot degrade.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(security): close adversarial-review findings in decision memory

Adversarial review (Claude subagent) found a CRITICAL the specialist pass missed:
- F1 (CRITICAL): 'Human:'/'Assistant:' turn-prefixes bypassed BOTH the write-time
  denylist AND datamark(), landing verbatim in agent context inside the trusted
  ACTIVE DECISIONS fence. Add 'human:' (+ 'disregard previous', 'from now on') to
  the shared denylist, and have datamark() neutralize Human:/Assistant:/System:/User:
  turn-prefixes (ZWSP) at the render boundary.
- F2: datamark() only stripped ASCII C0; extend to Unicode line terminators
  (U+0085/2028/2029) and U+007F so 'strip newlines' actually holds.
- F3: validateDecide blocked only HIGH secrets; MEDIUM-tier PII (e.g. SSN) persisted
  silently and synced cross-machine. The store is non-interactive (no confirm path),
  so fail closed on MEDIUM too.
- F4: compact() was a lock-free read-modify-rewrite that could clobber a concurrent
  append (lost decision). Add an O_EXCL compact lock + a pre-rename size recheck that
  aborts untouched (skipped=true) if an append landed; caller re-runs.
- F7: filterByScope unknown/garbage scope fell through to 'return true' (leaked into
  every context); fail conservative (false).

F5 (pid reuse) and F6 (pgrep over-match) are intentionally left as-is: both fail SAFE
(over-refuse sync); making them precise would introduce a fail-DANGEROUS path
(allowing sync during a real autopilot). True disambiguation needs gbrain to stamp the
lock with a start-time, which gstack doesn't own. F8 (compact moves history to archive)
is by design.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(security): close cross-model (Codex) adversarial findings

Codex adversarial review found a HIGH the Claude pass missed plus 3 mediums:
- C1 (HIGH): gstack-decision-search --all returned every decide and IGNORED redact
  events, so a redacted secret still resurfaced via --all until compact ran. --all
  now excludes redacted (redact = expunge from every read path), still showing
  superseded history.
- C-med: semantic (external gbrain) slug/snippet were printed raw — datamark them too
  so a gbrain hit can't spoof role markers / fences into agent context.
- C4: semanticRecall fell back to an UNSCOPED gbrain search when no curated-memory
  source resolved, pulling code/doc corpora mislabeled as 'related decisions'. Now
  returns null (degrade) when there's no worktree-backed memory source.
- C5: validateDecide scanned only decision/rationale/alternatives; branch and issue
  are stored + surfaced (raw via --json), so include them in the injection+secret scan.

C2 (snapshot staleness) / C3 (compact TOCTOU residual): accepted for a single-user
store — atomic appends never lose the event, rebuilds self-heal, and the compact
size-recheck leaves only a sub-ms window; full append-locking would break the
lock-free append design.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v1.57.5.0)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 06:20:58 -07:00

308 lines
12 KiB
TypeScript

/**
* gbrain-guards — defense-in-depth against gbrain's destructive code paths (#1734).
*
* gbrain (the separate CLI gstack shells out to) can rm-rf a user's working tree
* during an autopilot race (its own bug, upstream gbrain #1526). gstack can't fix
* that, but it MUST stop treating gbrain's destructive subcommands as safe. These
* guards gate the two ways the orchestrator can reach destruction:
*
* 1. `sources remove --confirm-destructive` → decideSourceRemove()
* 2. `sync --strategy code` (can auto-reclone) → decideCodeSync()
*
* plus an autopilot-active check (detectAutopilot) that refuses to run destructive
* ops concurrently with the daemon.
*
* Design notes grounded in the real gbrain 0.41.x surface:
* - There is NO `--keep-storage` flag and NO structured capability command, and
* subcommand `--help` is generic — so capability detection is best-effort and
* defaults to "unsupported". When we can't protect a user-managed source's
* files, we FAIL CLOSED (refuse the remove) rather than delete unprotected.
* - The autopilot lock filename isn't documented and (gbrain #1226) ignores
* GBRAIN_HOME, so the live `gbrain autopilot` process is the PRIMARY signal;
* known lock paths under both the configured home and ~/.gbrain are secondary.
* - We refuse only on an AFFIRMATIVE autopilot signal — inability to introspect
* never blocks a normal sync (that would brick the tool).
* - Path containment uses realpath so a symlink inside ~/.gbrain/clones can't
* smuggle a delete out to a user repo.
*
* Pure decision functions; the orchestrator logs the reasons (observability).
*/
import { spawnSync } from "child_process";
import { existsSync, realpathSync, readFileSync } from "fs";
import { homedir } from "os";
import { join, resolve, sep } from "path";
import { execGbrainJson, execGbrainText, NEEDS_SHELL_ON_WINDOWS } from "./gbrain-exec";
import { parseSourcesList, type GbrainSourceRow } from "./gbrain-sources";
export function gbrainHome(env: NodeJS.ProcessEnv = process.env): string {
return env.GBRAIN_HOME || join(homedir(), ".gbrain");
}
/**
* Directories gbrain owns and may delete safely. A source whose local_path
* resolves inside one of these is gbrain-managed; outside = user-managed and
* must be protected. Both the configured home and the default ~/.gbrain are
* checked because gbrain #1226 shows home-resolution is inconsistent.
*/
function clonesDirs(env: NodeJS.ProcessEnv = process.env): string[] {
return [...new Set([join(gbrainHome(env), "clones"), join(homedir(), ".gbrain", "clones")])];
}
/** True if `p` resolves (symlinks + `..` collapsed) to a location inside `dir`. */
export function isInside(p: string, dir: string): boolean {
let rp: string;
let rd: string;
try { rp = realpathSync(p); } catch { rp = resolve(p); }
try { rd = realpathSync(dir); } catch { rd = resolve(dir); }
const base = rd.endsWith(sep) ? rd : rd + sep;
return rp === rd || rp.startsWith(base);
}
// ── Autopilot detection (E1: multi-signal, affirmative-only) ────────────────
export interface AutopilotStatus {
active: boolean;
/** Which signal fired (lock path or "process"), or null when inactive. */
signal: string | null;
}
export interface AutopilotProbe {
/** Override the lock-path list (tests). */
lockPaths?: string[];
/** Override the live-process check (tests). */
processRunning?: () => boolean;
}
/**
* Detect a running gbrain autopilot. Refuse the caller's destructive op only on
* an affirmative signal; absence of a confirmable mechanism returns inactive so
* normal syncs are never bricked.
*/
export function detectAutopilot(
env: NodeJS.ProcessEnv = process.env,
probe: AutopilotProbe = {},
): AutopilotStatus {
// Secondary signal: known lock files. gbrain #1226 — the lock ignores
// GBRAIN_HOME, so check both the configured home and the default ~/.gbrain.
const lockPaths = probe.lockPaths ?? [
join(gbrainHome(env), "autopilot.lock"),
join(homedir(), ".gbrain", "autopilot.lock"),
join(gbrainHome(env), "autopilot.pid"),
join(homedir(), ".gbrain", "autopilot.pid"),
];
for (const lp of lockPaths) {
if (!existsSync(lp)) continue;
// A lock FILE alone is not proof of life — a crashed daemon leaves a stale
// lock that would otherwise wedge every sync forever (observed: a dead pid
// refused --full indefinitely). Read the holder pid and check liveness.
const pid = readLockPid(lp);
if (pid === null) {
// Can't introspect (no parseable pid) → stay conservative: treat as active.
return { active: true, signal: `lock:${lp}` };
}
if (isPidAlive(pid)) {
return { active: true, signal: `lock:${lp} (pid ${pid})` };
}
// Stale lock (holder pid is dead): ignore this signal, keep checking. Pure
// decision function — we do NOT delete the file here; the caller may clean it.
}
// Primary signal: a live `gbrain autopilot` process.
const running = (probe.processRunning ?? defaultProcessRunning)();
if (running) return { active: true, signal: "process:gbrain autopilot" };
return { active: false, signal: null };
}
/** Read the holder pid from a lock/pid file. Returns null if no integer pid is present. */
function readLockPid(lockPath: string): number | null {
try {
const raw = readFileSync(lockPath, "utf-8").trim();
// Files seen: a bare pid ("65495"), or JSON like {"pid":65495,...}.
const m = raw.match(/"pid"\s*:\s*(\d+)/) ?? raw.match(/^(\d+)$/);
if (!m) return null;
const pid = Number.parseInt(m[1], 10);
return Number.isFinite(pid) && pid > 0 ? pid : null;
} catch {
return null;
}
}
/**
* Liveness via signal 0: no signal sent, just an existence/permission check.
* ESRCH → dead; EPERM → alive but owned by another user. Cross-host pids are
* meaningless, but the autopilot lock is same-host by construction.
*/
function isPidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (err) {
return (err as NodeJS.ErrnoException).code === "EPERM";
}
}
function defaultProcessRunning(): boolean {
// No reliable pgrep on Windows; rely on the lock-file signal there.
if (process.platform === "win32") return false;
const r = spawnSync("pgrep", ["-f", "gbrain autopilot"], { encoding: "utf-8", timeout: 3_000 });
return r.status === 0 && (r.stdout || "").trim().length > 0;
}
// ── Capability detection (E4 + Codex: per-process memo, no persistent cache) ─
//
// No structured capability command exists and subcommand --help is generic, so
// --keep-storage support can't be probed reliably; default unsupported. Memoize
// per process (keyed to the resolved gbrain identity) rather than persisting a
// cross-run cache — Codex flagged stale persistent caches, and the probe is cheap.
let _keepStorageMemo: { key: string; value: boolean } | undefined;
function gbrainIdentity(env: NodeJS.ProcessEnv): string {
const r = spawnSync("gbrain", ["--version"], {
encoding: "utf-8",
timeout: 3_000,
shell: NEEDS_SHELL_ON_WINDOWS,
env,
});
return (r.stdout || "").trim() || "unknown";
}
export function gbrainSupportsKeepStorage(env: NodeJS.ProcessEnv = process.env): boolean {
const key = gbrainIdentity(env);
if (_keepStorageMemo && _keepStorageMemo.key === key) return _keepStorageMemo.value;
let value = false;
for (const args of [["sources", "remove", "--help"], ["--help"]]) {
try {
if (/--keep-storage/.test(execGbrainText(args, { baseEnv: env, timeout: 5_000 }))) {
value = true;
break;
}
} catch {
// generic/empty help or non-zero exit → treat as unsupported
}
}
_keepStorageMemo = { key, value };
return value;
}
/** Test-only: reset the per-process capability memo. */
export function _resetCapabilityMemo(): void {
_keepStorageMemo = undefined;
}
// ── Destructive-op decisions ────────────────────────────────────────────────
/**
* Fetch + normalize the source list. Throws on read/parse failure so callers can
* distinguish "couldn't read" (fail closed) from "empty list" (source absent).
* Injectable for hermetic tests.
*/
export function fetchSources(env: NodeJS.ProcessEnv = process.env): GbrainSourceRow[] {
const raw = execGbrainJson(["sources", "list", "--json"], { baseEnv: env });
if (raw === null) throw new Error("gbrain sources list returned no JSON");
return parseSourcesList(raw);
}
export interface RemoveDecision {
allow: boolean;
/** Extra args to append to `sources remove` (e.g. --keep-storage). */
extraArgs: string[];
reason: string;
}
/**
* Decide whether `sources remove <id>` is safe, and with what flags.
*
* Fail-closed cases (allow=false):
* - sources list unreadable/unparseable (can't prove the row is safe).
* - the row is user-managed (remote_url set AND local_path outside gbrain's
* clones) and gbrain has no --keep-storage to protect the files.
*
* Allowed: absent row (no-op), gbrain-managed (inside clones), or path-managed
* without a remote_url (gbrain's remove won't touch an outside-clones path that
* it didn't clone). --keep-storage is appended whenever supported, as extra armor.
*/
export interface DecideRemoveOpts {
/** Override capability detection (tests / cached caps). */
keepStorage?: boolean;
/** Override the source-list fetch (tests). Throwing simulates a read failure. */
fetchRows?: (env: NodeJS.ProcessEnv) => GbrainSourceRow[];
}
export function decideSourceRemove(
sourceId: string,
env: NodeJS.ProcessEnv = process.env,
opts: DecideRemoveOpts = {},
): RemoveDecision {
const keepStorage = opts.keepStorage ?? gbrainSupportsKeepStorage(env);
const extra = keepStorage ? ["--keep-storage"] : [];
let rows: GbrainSourceRow[];
try {
rows = (opts.fetchRows ?? fetchSources)(env);
} catch {
return { allow: false, extraArgs: [], reason: "could not read sources list; refusing remove (fail closed)" };
}
const row = rows.find((r) => r.id === sourceId);
if (!row) return { allow: true, extraArgs: extra, reason: "source absent (no-op)" };
const remoteUrl = row.config?.remote_url;
const userManaged =
!!remoteUrl && !!row.local_path && !clonesDirs(env).some((d) => isInside(row.local_path!, d));
if (userManaged) {
if (keepStorage) {
return { allow: true, extraArgs: ["--keep-storage"], reason: "user-managed; --keep-storage protects files" };
}
return {
allow: false,
extraArgs: [],
reason:
`refusing remove of user-managed source "${sourceId}" (remote_url set, local_path ` +
`${row.local_path} outside gbrain clones) — this gbrain has no --keep-storage to ` +
`protect the working tree. Upgrade gbrain or remove the source manually.`,
};
}
return { allow: true, extraArgs: extra, reason: "gbrain-managed or path-managed without remote_url" };
}
export interface SyncDecision {
allow: boolean;
reason: string;
}
/**
* Decide whether `sync --strategy code --source <id>` is safe to run.
*
* A source with a remote_url can trigger gbrain's auto-reclone, the ungated
* rm-rf path behind the data loss (gbrain #1526). Require an explicit
* --allow-reclone opt-in for URL-managed sources. Read failure here is NOT
* itself destructive, so it fails open (proceed) — the autopilot guard, checked
* first, is the primary protection against the race that caused the loss.
*/
export function decideCodeSync(
sourceId: string,
env: NodeJS.ProcessEnv = process.env,
allowReclone = false,
fetchRows: (env: NodeJS.ProcessEnv) => GbrainSourceRow[] = fetchSources,
): SyncDecision {
let rows: GbrainSourceRow[];
try {
rows = fetchRows(env);
} catch {
return { allow: true, reason: "sources unreadable; proceeding (sync read is non-destructive)" };
}
const row = rows.find((r) => r.id === sourceId);
if (row?.config?.remote_url && !allowReclone) {
return {
allow: false,
reason:
`source "${sourceId}" is URL-managed (remote_url set); sync may auto-reclone and ` +
`delete the working tree. Re-run /sync-gbrain with --allow-reclone to proceed.`,
};
}
return { allow: true, reason: "no remote_url, or reclone explicitly allowed" };
}