garrytan/gbrainmarkdown explorer
garrytan/gbrainmaster
docs / designs

BRAIN CURRENCY

docs/designs/BRAIN_CURRENCY.md

Brain currency — fix the incident, then build the ladder

Generated by /plan-ceo-review on 2026-08-10 Rev 3, after two adversarial spec-review rounds (6/10 → 7/10) and an independent outside voice. Branch: garrytan/gbrain-commit-indexing | Mode: SELECTIVE EXPANSION Repo: garrytan/gbrain

Citation convention: repo-relative paths. src/core/sync.ts (540 lines) and src/commands/sync.ts (5804 lines) are different files; both are cited.

Origin

An investigation into "how does gbrain pick up new commits from GitHub" found it never talks to GitHub. It diffs git diff last_commit..HEAD against a local checkout (src/core/sync-delta.ts:113). Getting remote commits into that checkout is a separate, opt-in concern.

It then found worse: on the founder's machine gbrain autopilot was installed, died 2026-05-31, and stayed dead 71 days while three surfaces reported healthy.

1. autopilot --status is an artifact-presence check. src/commands/autopilot.ts:1775-1786 — plist existsSync on darwin, crontab grep elsewhere. Never asks whether the job is loaded, the process alive, the baked --repo present, or the log fresh. Always exits 0.

2. doctor's sync_freshness computes the 71-day number and throws it away. src/core/source-health.ts:182-194:

const wallClockSeconds = Math.floor((nowMs - lastSyncMs) / 1000);   // ← the 71 days
if (wallClockSeconds < 0) return wallClockSeconds;
if (contentMs !== null && Number.isFinite(contentMs)) {
  return contentMs <= lastSyncMs ? 0 : wallClockSeconds;            // ← discarded
}

When the clone is unreachable, src/commands/doctor.ts:4306-4344 routes the verdict here. The function measures drain completeness, not staleness. "We caught up when we last looked" and "we have not looked in 71 days" both return 0.

3. gbrain status inherits it. src/commands/sync.ts:5440-5453'fresh' beside a 71-day-old date, exit 0. (gbrain sources status does report the real lag in its LAG column, but has no warn line for it and no exit contract.)

Root cause of the death: src/commands/migrate-engine.ts (22,733 bytes) contains zero autopilot references. The Supabase-to-PGLite migration rewrote ~/.gbrain/config.json while a daemon built on the old config kept running and died on config.database_url.

The key insight the reviews converged on

The content comparison in #2 is not a bug someone forgot. src/commands/doctor.ts:4288-4305 documents why it exists:

a container restart wipes local_path ... and since a no-op sync doesn't advance last_sync_at, every QUIET source read as stale/FAIL after a restart (score-sinking alert storm; observed live: 16-source brain, 12 clones gone after a config-update restart, doctor 70→30).

The premise in bold was invalidated after that code was written. v0.42.52.0 added a heartbeat at src/commands/sync.ts:2287-2298:

// bump last_sync_at as a heartbeat on every successful 0-changes sync...
if (opts.sourceId) {
  await engine.executeRaw(`UPDATE sources SET last_sync_at = now() WHERE id = $1`, [opts.sourceId]);
}

A no-op sync does advance last_sync_at now. So a quiet source that is being checked has a recent last_sync_at and survives a wall-clock ceiling; the 71-day case has an old one because no sync ran at all. The two cases are now distinguishable, and the fallback's justification has expired.

That is the whole incident: a wall-clock ceiling on the discard branch, in one pure function that doctor, gbrain status, and sources status all call. It fixes all three by construction, with no new table, no new command, and no migration.

It also means the heartbeat this plan originally proposed to build already ships. A separate live_ticks table would be a fourth status surface on a fifth data source, curing "three surfaces disagreed" by adding one more that can disagree.

Base branch

The whole wave (PR-A, PR-B, PR-C) is based on garrytan/codex-as-agent-default-install, not master. That branch carries the bootstrap surface (src/core/bootstrap/{host-specs,hooks}.ts, detectHarness()) that PR-B's harness tier needs, so PR-B is not blocked — an earlier revision of this doc assumed it was.

That branch moves frequently; re-fetch before comparing anything against it. A stale remote-tracking ref is an easy way to reach a confidently wrong conclusion here.

Sequencing (decided)

Three PRs. Nothing is cut; the order changed.

PR-A — close the incident (ships first)

  1. Wall-clock ceiling in lagFromContentMs (src/core/source-health.ts:189): return wallClockSeconds once it exceeds an absolute bound regardless of the content comparison. Bound is a named env knob per repo convention (GBRAIN_STALENESS_CEILING_HOURS, default 72, matching the existing GBRAIN_SYNC_FRESHNESS_FAIL_HOURS).
  2. Regression test (acceptance criterion 1 below).
  3. E3src/commands/migrate-engine.ts reconciles the running daemon.
  4. Wrapper self-disablesrc/commands/autopilot.ts:1314-1359. Adapted from src/core/brain-repo-durability.ts:509-512, NOT copied: two corrections the engineering review established.
    • Predicate is [ ! -d "$repo" ], not [ ! -d "$repo/.git" ]. --repo may be a subdirectory of the checkout (sync resolves the root itself by walking up), and .git is a FILE in worktrees and submodules — either shape would self-disable a healthy install.
    • exit 0 is correct for the durability wrapper because launchd fires it on StartInterval (one shot). Autopilot runs under KeepAlive=true + ThrottleInterval=60 and systemd Restart=always, where exiting disables nothing and instead produces a silent respawn-every-60s loop. The wrapper must launchctl bootout / systemctl --user disable --now itself on those targets and drop a marker that --status surfaces.
  5. Reconnect classifiersrc/commands/autopilot.ts:58-78; a JS TypeError must not substring-match as a config verdict.
  6. autopilot --status reads the heartbeat instead of existsSync, and exits nonzero when stale.
  7. E8 hygiene — test-run pollution of ~/.gbrain/sync-failures.jsonl; buildSyncManifest (src/core/sync.ts:105-140) dropping git T (typechange). Narrowed: src/core/sync-delta.ts:130 passes -M only, so C is unreachable without --find-copies and U needs a conflicted worktree. C/U handled defensively.

Not in PR-A: the lockfile-leak fix. Removing the leaked ~/.gbrain/autopilot.lock deletes the signal that distinguishes crashed from never installed (src/commands/status.ts:595-598) before its replacement exists. It lands in PR-C alongside live status.

PR-B — the harness tier alone

The harness tier is the only tier the modal gbrain user can actually run (PGLite default, desktop harness, behind NAT), so it ships alone and early rather than buried inside the cathedral. Reuses src/core/bootstrap/{host-specs,hooks}.ts, which the base branch already provides.

PR-C — the ladder

live command family, live.mode bundle, shape detection, cron/daemon/webhook tiers, advisor collector, init offer, watch tier, shared os-scheduler.ts, and the lockfile-leak fix. live_ticks is re-examined here against the shipped last_sync_at heartbeat rather than assumed — the burden is on the new table to justify itself.

The constraint being satisfied (quoted so it can be checked)

docs/designs/AGENT_BOOTSTRAP_PLAN.md on origin/garrytan/codex-as-agent-default-install, decision D9:

D9 Scheduling: almost nothing on by default. ON: SessionEnd push (event-driven, no daemon). OPT-IN: 15-min harden cron. Autopilot NOT default on PGLite (verified: its sync/embed children would contend with every live serve for the single-writer lock, and nothing handles LiveServeLockError politely today) — recommended on Postgres; any future scheduled job must treat lock-held as skip-silently-and-log.

Reconciliation: D9 says "15-min harden cron"; the shipped default is 1800s / 30 min (src/core/brain-repo-durability.ts:76, :659). D9's figure is stale. This plan uses 30.

This plan's decisions are labelled L1..L14 to avoid collision with that document.

PR-C design (carried forward, not yet committed to a diff)

Tiers — five active plus off

tiermechanismexpected cadenceengine gate
offnothingn/a — live status exits 0
harnessagent hook / session boundaryevent-driven, age-exemptany (incl. Windows, containers)
webhookHMAC push from GitHubevent-driven, age-exempt; paired keepalive cron supplies the age signalany + reachable serve --http
cronOS schedulerdeclared expected_cadence_secondsany; PGLite floor 1800s + lock-aware skip
daemonresident autopilot, runCycle300sPostgres only (D9)
watchdaemon + chokidarfloor 300s for freshness purposes, not the ~1s event latencyPostgres only (D9)

Event-driven tiers are exempt from age-based failure; a webhook repo with no pushes for three days is healthy, not failed. watch's freshness cadence is decoupled from its event latency so a GC pause is not a FAIL.

off is a first-class bundle member with enabled: false, copied from src/core/pace-mode.ts:65-71.

L1 — Shape detection predicate

SignalSourceMeaning
engineconfig.enginepostgres required for daemon/watch
interactive desktop harnessCLAUDECODE, CLAUDE_CODE_ENTRYPOINT, CODEX_HOME, CODEX_SANDBOX, CODEX_CI (env only)any present → cap at harness
long-lived hostdetectInstallTarget() ∈ {macos, linux-systemd, ephemeral-container+injection point}a reboot-surviving scheduler exists
server postureserve --http configured, or minion_mode != 'off'corroborating, never sufficient alone

macos is in the long-lived row deliberately: detectInstallTarget() returns 'macos' unconditionally on darwin (src/commands/autopilot.ts:1277), and darwin is the platform of the origin incident. Omitting it would make the incident host permanently shape-ineligible.

No filesystem probes for harness identity. The ~/.claude/hooks/... class of probe (src/commands/autopilot.ts:1304) is what false-positives today. Env vars only.

Any inconclusive read falls to harness, never daemon.

L2 — live status exit codes

ConditionExit
fresh, or live.mode == off0
PGLite lock held by a live serve (blocked_by_serve)0
tier enabled + heartbeat missing or stale1
drifted install, or DB connect failure2

live.mode == off exiting 0 is load-bearing: otherwise every fresh install exits nonzero, which is the cycle_freshness #2540 lesson (never-configured must not turn the surface red). And lock-held is not an outage: src/core/pglite-engine.ts:444 acquires the file lock on every connect() and throws if it fails, so on the default engine with a resident serve, treating that as exit 2 would make FAIL the steady state.

L3 — skipped_locked semantics

A tick that cannot acquire the PGLite lock does not satisfy freshness and does not degrade it. It is neutral: logged, not recorded as work-done, and not counted toward staleness for a grace window of 3 consecutive skips, after which the surface reports blocked_by_serve with the remediation inline. Treating it as work-done rebuilds the 71-day false-green; treating it as failure makes the default engine permanently red.

L4 — Scheduler ownership

Ownership lives in a sidecar ~/.gbrain/live-ownership.json, not in an entry comment. On darwin both harden and autopilot install launchd plists (files, not comment-bearing crontab lines), so the # gbrain:autopilot v0.11.0 marker convention does not generalize. The sidecar covers all install targets uniformly.

Three enumerated cases:

  1. Harden cron exists + pull opted in → rewrite through os-scheduler.ts, ownership=live-adopted.
  2. Harden cron exists + pull declined → leave it entirely alone; install a separately labelled live entry. This is the default and lands first, so PR-C's live on never meets an existing harden cron without a rule.
  3. Neither exists → install a live entry, ownership=live.

live off removes only entries live created and reverts adopted ones to harden.

Pre-existing gbrain autopilot installs are migrated, not orphaned: first live status after upgrade reports tier: daemon (legacy autopilot) and offers one-time adoption.

L5 — Op scopes

OpscopelocalOnlyremote
live_statusreadnoallowed; omits local_path, scheduler artifact paths, and log tail
live_tickwriteyesreject
live_on / live_offadminyesreject
live_self_healadminyesreject

Self-heal walks a DB-supplied local_path and then writes a scheduler entry. src/commands/doctor.ts already gates its git short-circuit on localOnly === true ("a remote-callable code path must NOT walk DB-supplied local_path values with subprocess calls"). Self-heal honors that and additionally requires a realpath match against the anchor via isAnchorOwnedSyncPath (src/commands/sync.ts:1296).

Bootstrap paradox, acknowledged: if the broken thing is the scheduler entry, a scheduled self-heal never runs. Non-scheduled triggers are the harness tier (PR-B) and an explicit gbrain live doctor. PR-C ships self-heal with both, not with a scheduled trigger alone.

L6 — Revert

A code revert leaves plists, crontab lines, systemd units, and (E1) a GitHub webhook installed and unowned. Therefore:

  • Revert requires gbrain live off first on any enabled host. Stated in the PR body.
  • The generated wrapper self-disables on a marker file written by live on and removed by live off. Not a gbrain live --help probe: that adds a process spawn per tick and assumes an exit code the CLI does not guarantee.
  • The migration, if live_ticks survives PR-C's re-examination, is additive and uses the next free version at implementation time (125 is the current max; two waves may land first).

L7 — E5 must not use nag-state.ts

src/core/skillpack/nag-state.ts is skillpack-scoped (schema gbrain-skillpack-nag-v1, entries keyed on pack_version, DEFAULT_NAG_CEILING = 3, suppressed thereafter). Wiring a dead-sync alarm through it means a genuinely broken brain goes silent after three notices, which is a suppression mechanism for the exact failure mode whose defining property was 71 days of silence.

E5 instead uses a rate limit, not a ceiling: at most once per session, never suppressed permanently, escalating in terseness rather than disappearing.

L8 — E1 webhook dependencies (previously unpriced)

Creating a GitHub webhook programmatically needs an admin:repo_hook token. No acquisition, storage, scope, or rotation story existed. Therefore E1 ships in manual mode only: live on --tier webhook generates the secret, resolves and prints the payload URL, and the user pastes it into GitHub, matching what gbrain sources webhook set (src/commands/sources.ts:909-916) already does. No token, no remote hook creation, no live off remote deletion problem.

The "verified test ping" must originate from GitHub, not locally. A local ping proves nothing through NAT and would be an artifact-presence check, the precise anti-pattern in the Origin section.

L9 — live_ticks retention

If the table survives PR-C, the sweep runs inside live tick (bounded best-effort DELETE on a TTL), not only in the cycle's purge phase. purge is a runCycle phase (src/core/cycle.ts:1434), and runCycle runs only on daemon/watch — the cron, webhook, and harness tiers would accumulate forever.

Scope decisions (all accepted; PR assignment added)

#ItemPRNote
L10Approach C: full ladderA/B/Cuser chose the cathedral; resequenced, not cut
L11Tier default keys on deployment shape, not vendorCHermes has zero detectable signal
E1Webhook tier, manual mode (L8)C
E2Self-heal with .tmp+rename+.bak rollbackCbootstrap paradox handled per L5
E3migrate-engine reconciles the daemonAthe literal root cause
E4Pull cron adoption per L4, separate opt-in per L12C
E5Agent-facing staleness, rate-limited not nag-ceilinged (L7)C
E6Windows hard error naming --tier harnessCdetectInstallTarget() has no win32 branch
E7live_ticksre-examined, not assumedCthe shipped last_sync_at heartbeat may suffice
E8Hygiene, narrowed to git TA

L12 — E4's pull cron is an autonomy question

docs/guides/upgrades-auto-update.md:41-43 states "auto is deliberately NOT a default anywhere — it's an explicit autonomy grant, because applying code from GitHub unattended is, by design, remote code execution." This plan does not flip self_upgrade.mode.

E4 schedules git pull every 30 minutes. That is content, not code, and durability keeps gbrain's hooks local and untracked so a pulled commit cannot rewrite executable hook code. But it is still unattended network fetch into a directory gbrain runs tooling against. Therefore the pull cron is a separate opt-in from the tier, proposed and explained by live on, never silently bundled.

L13 — The directive's internal tension, stated

"OpenClaw and Hermes default to always-up-to-date" sits against L1's "shape detection recommends, never installs" and D9's "almost nothing on by default." These are reconciled by scope: shape detection sets the recommended tier and pre-selects it in the init consent prompt, so a shape-matching host is one keystroke from always-on rather than silently converted. Whether that consent is required on upgrade as well as fresh install is open decision F1 below.

L14 — Acceptance criteria

  1. Three-surface honesty. A source whose local_path is deleted, whose last_sync_at is 71 days old, whose newest_content_at is non-NULL, and whose chunker_version matches must report stale/fail from doctor and gbrain status, and must surface the lag in sources status. Both fixture preconditions are required: a NULL newest_content_at already falls through to wall-clock (src/commands/doctor.ts:4335-4342) and a chunker mismatch already disables the fallback (:4318), so a naive fixture passes against unfixed code. sources status is held to output, not exit code — it has no exit contract today and adding one is an undeclared breaking change to a read-only dashboard.
  2. Quiet-source non-regression. A source with a recent last_sync_at, an unreachable clone, and no new content must still report OK. This is the 16-source / doctor 70→30 incident; the ceiling must not re-light it.
  3. Install honesty (PR-C). live on --tier cron verifies the job loaded and exits nonzero if not; deleting the repo makes live status exit nonzero and name the path; live off leaves nothing.
  4. Concurrency (PR-C, Postgres only). Two tiers ticking produce one import and one neutral skip record. On PGLite the second process cannot open the DB at all, so the defined outcome is a log line and no row.
  5. Watch tier (PR-C). E2E expects queued-job-failure, not synchronous rejection — ingest_capture enqueues and returns.
  6. Engine parity (PR-C, if live_ticks survives). DDL identical in both engines, pinned by test/e2e/engine-parity.test.ts; bootstrap probe-set entry pinned by test/schema-bootstrap-coverage.test.ts.

Open decisions (unanswered — do not silently default)

  • F1. Does shape-detected always-on apply on upgrade as well as fresh install? Codebase precedent (src/commands/upgrade.ts:513-516, mcp.publish_skills) is new-installs-only with a one-time prompt for existing. Gates PR-C only.
  • F2. Command noun and config key: gbrain live + live.mode (requires renaming the existing liveSyncStatus helper at src/core/db-lock.ts:749 to syncInProgress, two call sites) vs gbrain sync live + sync.live.mode. Gates PR-C only.

Deferred to TODOS.md

  • Full Windows schtasks tier — no test machine; harness covers it
  • Per-tier cost meter for daemon / watch
  • Cross-OS scheduler probing as a live status diagnostic (TODO-V19-D stays open; the heartbeat makes it optional rather than load-bearing)
  • Centralize the three freshness call sites onto one freshnessVerdict() helper (existing filed P3, now partially satisfied by PR-A's single-function fix)

Dream state delta

PR-A leaves brain currency honest. PR-B leaves it workable for the modal user. PR-C leaves it a product feature. Remaining gap to the 12-month ideal: currency is still something the user turns on, not something simply true of a configured brain. F1 is the decision that closes or preserves that gap.

Reviewer concerns (unresolved after 3 iterations)

  • Scope, from both reviewers: PR-C remains large (command family, mode bundle, shape detector, three tiers, advisor collector, init prompt, webhook, watch tier, scheduler extraction). The PR-A/B/C split answers the sequencing objection but not the size of C itself. Revisit at PR-C planning with the incident already fixed.
  • live_ticks necessity is explicitly unresolved and assigned to PR-C rather than decided here.
Continue exploring589 Markdown documents in the local repository