mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 08:53:22 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04bd97ab75 | ||
|
|
2817dd47e4 | ||
|
|
a34935a312 | ||
|
|
b329158e49 | ||
|
|
d7ce020782 | ||
|
|
5cdf865a56 | ||
|
|
f04312def2 | ||
|
|
cc3c66fcd1 | ||
|
|
62bbd3c077 | ||
|
|
4ab5cf329d | ||
|
|
1d5b85b51a | ||
|
|
c0eddce479 |
+31
-8
@@ -1,4 +1,4 @@
|
||||
<!-- gbrain-runbook-stamp: 0.45.10.0 -->
|
||||
<!-- gbrain-runbook-stamp: 0.45.11.0 -->
|
||||
<!-- This stamp must equal the VERSION file at every release; CI enforces it
|
||||
(scripts/check-bootstrap-tag.sh). `gbrain bootstrap status` compares it to
|
||||
the installed binary and warns on skew. -->
|
||||
@@ -82,8 +82,11 @@ you needed; report the count at the end (it feeds the install-time measurement).
|
||||
`gh auth login -h github.com -p https -w` (you run it; they click Authorize).
|
||||
Then `gbrain bootstrap status` — it is idempotent and resume-aware; after any
|
||||
partial failure, re-run it and continue where it points.
|
||||
2. **Engine.** `gbrain init --pglite` (2 seconds, no server). Search mode defaults
|
||||
to balanced silently — do NOT ask; the human can change it any time with
|
||||
2. **Engine.** `gbrain init --pglite` (2 seconds, no server). Search mode is
|
||||
auto-selected silently (conservative when keyless, tokenmax with an
|
||||
expansion key) and printed with an `[AGENT]` cost matrix — surface that
|
||||
matrix to the human and confirm before running high-volume queries (see
|
||||
INSTALL_FOR_AGENTS.md Step 3.5); they can change it any time with
|
||||
`gbrain search modes`. The one thing to raise here is the OPTIONAL provider
|
||||
key — with no key you run keyless: keyword search plus memory you author
|
||||
yourself through the write tools; everything works, one key upgrades search to
|
||||
@@ -147,7 +150,9 @@ you needed; report the count at the end (it feeds the install-time measurement).
|
||||
through the real write path, graph floor, token sweep, secret scan, repo
|
||||
privacy, hooks smoke, capability report (keyless or keyed). Exit 0 or it is not
|
||||
done. Paste the report. Then relay the first-run tour it prints (three prompts
|
||||
the human should try, starting with restarting the session).
|
||||
the human should try, starting with restarting the session) AND the hand-off
|
||||
block below it — the ownership line and the cold-start offer are the two
|
||||
things the human must actually understand, not fine print.
|
||||
|
||||
## Machine two
|
||||
|
||||
@@ -209,7 +214,25 @@ placeholder). Trust the CLI's detection over your own guesses.
|
||||
|
||||
## Hand off
|
||||
|
||||
Finish by telling the human: the private repo URL (or the local-only status), the
|
||||
capability mode (keyless vs keyed), the three commands they will actually reuse
|
||||
(`gbrain doctor`, `gbrain bootstrap verify`, `gbrain sources push`), and the
|
||||
first-run tour. Then delete nothing — this runbook was fetched, not installed.
|
||||
Two things the human must UNDERSTAND before you finish — say them plainly, in
|
||||
this order, and confirm they landed:
|
||||
|
||||
1. **They own the brain.** Every memory you keep is a markdown file in THEIR
|
||||
private GitHub repo — name the URL. Owning it means: they can read it any
|
||||
time, take it to a second machine (`gbrain bootstrap attach`), or delete the
|
||||
repo and the brain is gone. If they went local-only, say that instead, with
|
||||
`gbrain bootstrap repo` as the any-time upgrade.
|
||||
2. **The first skill to run is cold-start.** An empty brain is a database; a
|
||||
filled one is a memory — and every flagship skill (book-mirror, briefings,
|
||||
meeting prep) only becomes magical once the brain holds their real life.
|
||||
OFFER to run the cold-start skill now: it imports Gmail, calendar, and
|
||||
contacts through ClawVisor (clawvisor.com — an OAuth vault; you never hold
|
||||
raw tokens), or offline archives (Google Takeout, a notes folder) if they
|
||||
prefer no third-party gateway. Every phase is consent-gated and
|
||||
independently valuable — they can stop after any one. If they say "later",
|
||||
that is a complete install; they can say "fill my brain" any time.
|
||||
|
||||
Then the routine facts: the capability mode (keyless vs keyed), and the three
|
||||
commands they will actually reuse (`gbrain doctor`, `gbrain bootstrap verify`,
|
||||
`gbrain sources push`). Then delete nothing — this runbook was fetched, not
|
||||
installed.
|
||||
|
||||
+42
-1
@@ -2,6 +2,47 @@
|
||||
|
||||
All notable changes to GBrain will be documented in this file.
|
||||
|
||||
## [0.45.11.0] - 2026-08-12
|
||||
|
||||
**The install now ends by telling you the two things that matter: you own the brain, and here's the first skill to run.** A working install used to finish on a health report and three tour prompts — technically complete, but a new user walked away without the two facts that make gbrain worth trusting and worth using. Now `gbrain bootstrap verify` ends with a hand-off: **what you own** (every memory is a markdown file in YOUR private GitHub repo — read it, take it to a second machine, delete it and the brain is gone; or the local-only variant with the one command that gives it a durable home) and **what to do next** (run the `cold-start` skill — say "fill my brain" and your agent imports your Gmail, calendar, and contacts through ClawVisor, an OAuth vault so the agent never holds raw tokens, or offline archives like Google Takeout, one consented phase at a time).
|
||||
|
||||
The structural fix underneath: `cold-start` — the skill designed exactly for "I just installed this, now what?" — was excluded from the downstream skill bundle, so the paste-in install audience it was written for could never scaffold it. It's now bundled, it's the #1 recommended skill (ahead of the book-mirror flagship, because every flagship skill only becomes magical once the brain holds your real life), and a new drift guard fails CI if any recommended skill ever becomes unscaffoldable again.
|
||||
|
||||
To take advantage of v0.45.11.0: existing installs can run `gbrain skillpack scaffold cold-start` and say "fill my brain"; fresh installs get the full hand-off automatically.
|
||||
|
||||
### Added
|
||||
- **The verify hand-off block.** On PASS, `gbrain bootstrap verify` prints (and returns in `--json` as `handoff`) the ownership statement — with the actual repo URL, or the local-only variant pointing at `gbrain bootstrap repo` — followed by the cold-start next action. The runbook's Hand off section now instructs the installing agent to make both land ("say them plainly, confirm they landed") and to OFFER running cold-start on the spot.
|
||||
- **`cold-start` ships in the downstream bundle** (61 skills) and leads the recommended set, so the post-install advisory, `gbrain advisor`, and `gbrain skillpack scaffold --all` all surface it. Its prior bundle exclusion ("host onboarding flow") predated the personal-agent bootstrap and was reversed deliberately.
|
||||
- **Recommended-set drift guard**: every recommended slug must be scaffoldable from the plugin bundle — recommended-but-unscaffoldable is a dead-end call-to-action and now fails the suite.
|
||||
|
||||
### Changed
|
||||
- README's Codex and Claude Code paths spell out the same two follow-ups after the click moment: ownership (markdown in a repo you own) and cold-start as the first skill, with ClawVisor named as the credential path and offline archives as the no-gateway alternative.
|
||||
|
||||
**Also in this release — the first-five-minutes DX wave** (re-versioned from an unpublished 0.45.9.0 after the release queue moved):
|
||||
|
||||
**The first five minutes stop making you think.** We built a real-terminal harness that drives the actual install the way a new user does — every picker, prompt, silence window, and line of copy — and then fixed what it surfaced. Keyless `gbrain init` used to dead-end at an error before it created anything; now it just works, keyless, and says so. A fresh brain used to scroll ~240 lines of internal migration names; now it prints one line. The success screen used to bury the one thing to do next under eight competing calls to action; now the copy-paste memory demo is the last, obvious thing on screen. And the "here's the magic" moment in the README now points at the trick that only a brain can do — tell it something, restart, ask for it back — instead of a question your identity files answer for free.
|
||||
|
||||
Under the hood: the upgrade nudge now compares the version you're actually running (a stale or foreign cache can't tell you to upgrade to something you already have), a broken settings file makes the installer stop and tell you rather than quietly replace it, and `gbrain init --supabase` fails loudly in a script instead of pretending it worked. Every fix landed with a test, and a two-model adversarial review pass (Claude + Codex) caught a cluster of follow-on issues in the fixes themselves — a keyless upgrade command that pointed at a rejected path, a compiled-binary detection that broke for renamed binaries — which are fixed here too.
|
||||
|
||||
To take advantage of v0.45.11.0: nothing to do — `gbrain self-upgrade` (or your next `gbrain` invocation's upgrade nudge) brings you current, and the improvements are all in the install/first-run path a new brain hits automatically.
|
||||
|
||||
### Added
|
||||
- **A real-PTY DX exploration harness** (`test/helpers/tty-harness.ts` + `scripts/dx-explore.ts`). It spawns any CLI — gbrain, `claude`, `codex` — under a true pseudo-terminal, timestamps every output burst, and turns silence windows into a measurable stall report, so "the user stared at a frozen screen for nine seconds" is an artifact, not a hunch. A `drive` mode lets an agent steer a live TUI across separate tool calls. Developer instrument only; transcripts are gitignored and nothing in the shipped product depends on it.
|
||||
|
||||
### Changed
|
||||
- **Keyless is now the default when you have no embedding key**, on both the interactive and scripted paths: `gbrain init` completes with a loud, honest "keyless mode — keyword search plus memory your agent writes; everything works" notice instead of exiting with an error. A near-miss key typo still fails loudly (so a fat-fingered `OPENAPI_API_KEY` isn't silently buried). Multiple keys auto-pick the canonical default rather than refusing.
|
||||
- **Fresh-brain init prints one schema-setup line** instead of the full migration replay; upgrades keep the per-migration detail where it has diagnostic value (`GBRAIN_MIGRATE_VERBOSE=1` restores it).
|
||||
- **The init success screen leads with one action** — the three-command memory demo, last on screen — with import/scale-up/health collapsed into a single terse footer and the recommended-skills advisory reduced to a human-voiced pointer.
|
||||
- **The provider picker offers "continue keyless" explicitly** and probe-gates a local Ollama daemon (a running daemon that hasn't pulled the model is annotated, not silently selected); a bare Enter never picks a broken local provider.
|
||||
- **The upgrade nudge tells the truth about your binary**: it compares the running version to the latest and prints the running version, so a stale or foreign-written cache can't nag about an upgrade you already have. The raw machine marker stays off an interactive human's screen (override with `GBRAIN_FORCE_UPGRADE_MARKER=1` for PTY-based agent harnesses that parse it).
|
||||
- **Copy honesty pass**: provider capabilities are attributed per provider (OpenAI unlocks semantic search + fact extraction; Voyage semantic search; Anthropic fact extraction — it has no embeddings API); the install-time estimate reads ~15 minutes for the personal-agent path (~30 for the always-on setup); the first-run tour says to restart first and frames the genuine cross-session round-trip.
|
||||
|
||||
### Fixed
|
||||
- **A parse-broken `.claude/settings.local.json` aborts the hooks write** with a fix-and-re-run message instead of being replaced — your permissions and allowlist are never silently dropped.
|
||||
- **`gbrain init --supabase` in a non-interactive shell fails loudly** (exit 1, names the `--url` escape hatch) instead of the old silent exit-0 that wrote no config.
|
||||
- **`gbrain bootstrap hooks` with a missing harness CLI** now still installs per-turn hooks and reports the phase as partial (so a resuming agent re-runs it once the CLI is on PATH) instead of leaving a false "wire complete".
|
||||
- **`gbrain bootstrap interview --set/--skip` after a confirmation** warns that it voided the read-back instead of failing silently later at render.
|
||||
- Review-pass self-fixes: the keyless upgrade hint now names the re-init command that actually works (not the schema-sizing field `config set` rejects); compiled-binary detection for the detached update refresh no longer breaks for a renamed/official-named binary; the DX harness scrubs copied credentials even on interrupt and reaps the child's whole process tree.
|
||||
## [0.45.10.0] - 2026-08-13
|
||||
|
||||
**21 more community and maintainer bug fixes. Search answers get more complete, sync gets safer, and doctor learns to warn you before a provider dies.**
|
||||
@@ -413,7 +454,7 @@ answers. Ask before anything destructive. You are not done until
|
||||
`gbrain bootstrap verify` exits 0.
|
||||
```
|
||||
|
||||
The agent runs `gbrain bootstrap` — a new command family (`status`, `interview`, `render`, `repo`, `hooks`, `verify`, `attach`, `uninstall`) that drives the whole install. It works with **zero API keys**: your harness's model is the LLM, so the agent authors memory directly and search runs keyword-only; add one optional key (OpenAI, Anthropic, or Voyage) to unlock semantic search and automatic fact extraction. Everything is consent-gated — hooks, background push, MCP scope — and nothing runs while your harness is closed (the honest desktop contract; true 24/7 is what a hosted brain adds).
|
||||
The agent runs `gbrain bootstrap` — a new command family (`status`, `interview`, `render`, `repo`, `hooks`, `verify`, `attach`, `uninstall`) that drives the whole install. It works with **zero API keys**: your harness's model is the LLM, so the agent authors memory directly and search runs keyword-only; add one optional key to upgrade capabilities (OpenAI: semantic search + automatic fact extraction; Voyage: semantic search; Anthropic: fact extraction). Everything is consent-gated — hooks, background push, MCP scope — and nothing runs while your harness is closed (the honest desktop contract; true 24/7 is what a hosted brain adds).
|
||||
|
||||
### What you get
|
||||
|
||||
|
||||
@@ -90,9 +90,10 @@ NOT inside ~/gbrain.
|
||||
## Step 3.5: Confirm search mode with the user (DO NOT SKIP)
|
||||
|
||||
`gbrain init` auto-applied a default search mode (`tokenmax` unless your subagent
|
||||
tier is Haiku-class or no OpenAI key is configured). The init output included the
|
||||
cost matrix below preceded by `[AGENT]` markers. You must NOT silently accept the
|
||||
default. Stop and ask the operator.
|
||||
tier is Haiku-class or no expansion-capable API key — Anthropic, OpenAI, or
|
||||
Google — is configured). The init output included the cost matrix below preceded
|
||||
by `[AGENT]` markers. You must NOT silently accept the default. Stop and ask the
|
||||
operator.
|
||||
|
||||
**Present this matrix verbatim:**
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ The point of building a 150K-page brain is to use it as a strategic moat. To nev
|
||||
|
||||
It's easier to ship a daemon that runs 24/7 to ingest, enrich, and consolidate than it is to keep an agent in chat working hard. GBrain is that daemon, generalized. Install in 30 minutes. Your agent does the work. As my personal agent gets smarter, so does yours.
|
||||
|
||||
> **~30 minutes to a fully working brain.** Database ready in 2 seconds (PGLite, no server). You just answer questions about API keys.
|
||||
> **~15 minutes to a working personal agent** on the recommended Codex / Claude Code path (mostly a short interview); ~30 minutes for the always-on OpenClaw / Hermes setup. Database ready in 2 seconds either way (PGLite, no server).
|
||||
|
||||
> **LLMs:** fetch [`llms.txt`](llms.txt) for the documentation map, or [`llms-full.txt`](llms-full.txt) for the same map with core docs inlined in one fetch. **Agents:** start with [`AGENTS.md`](AGENTS.md) (or [`CLAUDE.md`](CLAUDE.md) if you're Claude Code).
|
||||
|
||||
@@ -90,7 +90,9 @@ answers. Ask before anything destructive. You are not done until
|
||||
`gbrain bootstrap verify` exits 0.
|
||||
```
|
||||
|
||||
Codex will ask for command approvals during the install — approving them is the sandbox working as intended. What you get, in about 15 minutes: a short interview (6 required questions) → your agent's identity (SOUL.md, USER.md, MEMORY.md) rendered from your own answers, never invented → a local PGLite brain (2 seconds, no server, no Docker) → MCP wired so every session can search and write memory → a **private** GitHub repo, created and privacy-verified, as your agent's durable body. Works with **zero API keys** — keyword search plus memory your agent writes itself; one optional key (OpenAI, Anthropic, or Voyage) upgrades to semantic search and automatic fact extraction. Codex reads brain context through its tools each turn (pull-based).
|
||||
Codex will ask for command approvals during the install — approving them is the sandbox working as intended. What you get, in about 15 minutes: a short interview (6 required questions) → your agent's identity (SOUL.md, USER.md, MEMORY.md) rendered from your own answers, never invented → a local PGLite brain (2 seconds, no server, no Docker) → MCP wired so every session can search and write memory → a **private** GitHub repo, created and privacy-verified, as your agent's durable body. Works with **zero API keys** — keyword search plus memory your agent writes itself; one optional key upgrades capabilities (OpenAI: semantic search + automatic fact extraction; Voyage: semantic search; Anthropic: fact extraction). Codex reads brain context through its tools each turn (pull-based). The click moment: tell it one small thing to remember, restart Codex, then ask for it back — the answer comes from the brain, not from this chat's context (which the restart cleared). That cross-session round-trip is the whole product; "what's my name / my top jobs?" is answered from your identity files, which is nice but not the same trick.
|
||||
|
||||
Two things worth understanding once it's running: **you own the brain** — every memory is a markdown file in that private repo (read it, clone it to a second machine, delete it and the brain is gone) — and **the first skill to run is `cold-start`**: say "fill my brain" and your agent imports your Gmail, calendar, and contacts (via [ClawVisor](https://clawvisor.com), an OAuth vault so the agent never holds raw tokens) or offline archives like Google Takeout, one consented step at a time. An empty brain is a database; a filled one is a memory.
|
||||
|
||||
> **Prefer to make the repo yourself?** Create a new **empty** private repo **under your own GitHub account** (no README/.gitignore/license), clone it, open the clone in Codex, and paste the same block — bootstrap detects your empty repo and adopts it instead of creating one. The repo must be empty and personal-account-owned; org-owned repos are refused (create one under your account, or let bootstrap make it).
|
||||
|
||||
@@ -107,7 +109,7 @@ answers. Ask before anything destructive. You are not done until
|
||||
`gbrain bootstrap verify` exits 0.
|
||||
```
|
||||
|
||||
Everything from the Codex path applies — interview, identity from your own answers, local brain, private repo, keyless mode — plus Claude Code gets **per-turn context hooks**: your brain loads automatically into every prompt, and your work persists to your private repo on a per-turn cadence (debounced ~5 min locally, every turn in a cloud sandbox — this covers the `/exit` case the harness never fires a session-end hook on), with a notice on your next turn if a push ever fails. This works in a **Claude Code cloud session** too, not just on your laptop: verification falls back to pure git protocol when the sandbox blocks the GitHub API, and `gbrain bootstrap cloud-setup-script` prints the environment setup recipe. Restart the session after install and ask "what did I tell you my top jobs were?" — that's the moment it clicks. Full contract, security posture, cloud sandboxes, and uninstall: [docs/guides/bootstrap.md](docs/guides/bootstrap.md).
|
||||
Everything from the Codex path applies — interview, identity from your own answers, local brain, private repo, keyless mode — plus Claude Code gets **per-turn context hooks** (on by default, with an opt-out): your brain loads automatically into every prompt, and your work persists to your private repo on a per-turn cadence (debounced ~5 min locally, every turn in a cloud sandbox — this covers the `/exit` case the harness never fires a session-end hook on), with a notice on your next turn if a push ever fails. This works in a **Claude Code cloud session** too, not just on your laptop: verification falls back to pure git protocol when the sandbox blocks the GitHub API, and `gbrain bootstrap cloud-setup-script` prints the environment setup recipe. The click moment: tell it one small thing to remember, restart the session, then ask for it back — a fresh session has no chat context, so the answer can only come from the brain. That cross-session round-trip is the whole product ("what's my name?" is answered from your identity files — nice, but not the same trick). Same two follow-ups as the Codex path: you own the brain (markdown in your private repo), and `cold-start` is the first skill to run — "fill my brain" imports your email, calendar, and contacts (ClawVisor) or offline archives, one consented step at a time. Full contract, security posture, cloud sandboxes, and uninstall: [docs/guides/bootstrap.md](docs/guides/bootstrap.md).
|
||||
|
||||
> **Prefer to make the repo yourself?** Create a new **empty** private repo **under your own GitHub account** (no README/.gitignore/license), clone it, open the clone in Claude Code (CLI or the desktop app's open-a-repo flow), and paste the same block — bootstrap adopts your empty repo instead of creating one. The repo must be empty and personal-account-owned; org-owned repos are refused.
|
||||
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# TODOS
|
||||
|
||||
## Onboarding DX follow-ups (filed v0.45.9.0)
|
||||
|
||||
- [ ] **Retire the `config set embedding_model` dead-end across ALL surfaces.** v0.45.9.0 fixed the keyless-init notice to point at `gbrain init --force --pglite --embedding-model <id>`, but `src/core/embed-preflight.ts` (lines ~73/83/90/115) and `src/core/embedding-dim-check.ts:78` still advertise `gbrain config set embedding_model <...>`, which `src/commands/config.ts:142` hard-refuses as a schema-sizing no-op. Same dead-end class, different surfaces. Sweep them to the re-init recipe. Priority: P2.
|
||||
- [ ] **`gbrain init --supabase` migrate-model dead-end doc.** The Postgres branch of config.ts points at `docs/embedding-migrations.md`; confirm that doc exists and describes a working switch, or write it. Priority: P3.
|
||||
- [ ] **DX harness binary cache keyed on nothing.** `scripts/dx-explore.ts` reuses `.context/dx-runs/bin/gbrain` unless `--rebuild` is passed, so a second run after code changes can produce transcripts from a stale binary. Key the cache by a source hash (or rebuild when any `src/` file is newer). Dev instrument only. Priority: P3.
|
||||
- [ ] **`verify` has no MCP-registration check.** v0.45.9.0 made `bootstrap status` report the wire phase `partial` when only hooks landed (host CLI missing), but `bootstrap verify` still exits 0 in that state. Add an MCP-registration probe to verify so the "done when verify exits 0" contract also covers MCP. Priority: P2.
|
||||
- [ ] **`hasExpansionKey` misses config-plane keys + init-before-key sequencing.** The mode picker reads `process.env` only; a key routed to the 0600 config by the interview (which runs AFTER init) never influences the auto-selected search mode, and the picker never re-fires. Resolve keys through the capability/gateway fold and consider re-running the recommendation when a key is first configured. Priority: P3.
|
||||
- [ ] **`findEnvKeyTypos` KEY_SHAPE misses no-underscore typos.** `OPENAI_APIKEY` (no `_` before `KEY`) escapes the near-miss net, so that typo class now completes keyless silently instead of failing loud. Widen the regex. Priority: P3.
|
||||
- [ ] **`init-nudge` stale "4 checks" comment + 6-probe accounting.** The header still says "4 onboard checks" but six probes now run; the partial-checks message counts the page-count probe. Cosmetic. Priority: P3.
|
||||
- [ ] **FIRST LIGHT (the real first-magical-moment feature).** The v0.45.9.0 tour rewrite is the ship-now slice; the full seed-phase → compendium → scout design is PR-A (seed phase + Mirror + baton) / PR-B (compendium + scout) with one-way-door decisions (new bootstrap phase, consent key, `skills/first-light/`, a one-time Gate-3 narration exemption). Priority: P2.
|
||||
|
||||
## Ambient recall follow-ups (filed v0.45.7.0, issue #1)
|
||||
|
||||
Deferred from the ambient-recall wave (`context_pack` + `delta` frozen verbs +
|
||||
|
||||
@@ -100,9 +100,11 @@ With zero API keys, everything works: the agent authors memory explicitly throug
|
||||
the brain's write tools (`put_page`, timeline entries, `## Facts` fences — your
|
||||
harness's model is the LLM, already paid for), and search runs keyword-only
|
||||
(BM25). `bootstrap verify` prints the capability report honestly. One optional key
|
||||
(OpenAI, Anthropic, or Voyage) unlocks semantic search and automatic fact
|
||||
extraction; the key goes to the 0600 config file, never into the repo or the
|
||||
interview answers. API spend is metered separately from your subscription and is
|
||||
upgrades capabilities per provider — OpenAI unlocks semantic search and
|
||||
automatic fact extraction; Voyage unlocks semantic search; Anthropic unlocks
|
||||
fact extraction (Anthropic has no embeddings API, so it does not enable
|
||||
semantic search). The key goes to the 0600 config file, never into the repo or
|
||||
the interview answers. API spend is metered separately from your subscription and is
|
||||
zero in keyless mode; with a key, the standard spend gates apply
|
||||
([spend-controls](../operations/spend-controls.md)).
|
||||
|
||||
@@ -214,3 +216,32 @@ Run locally (where both are installed + authed):
|
||||
```bash
|
||||
bun test test/e2e/bootstrap-real-codex.serial.test.ts
|
||||
```
|
||||
|
||||
## DX exploration harness (developer instrument, not a test)
|
||||
|
||||
The door tests prove the install WORKS; they say nothing about how it FEELS.
|
||||
`test/helpers/tty-harness.ts` spawns any CLI (gbrain, `claude`, `codex`) under a
|
||||
real pseudo-terminal (Bun's `terminal:` spawn option) and records every output
|
||||
burst with a millisecond timestamp, so unnecessary pauses become a measurable
|
||||
artifact (`computeStalls` → `stalls.md`) instead of a vibe. Same hermetic env as
|
||||
`agent-harness.ts`; pure helpers are unit-tested in `test/tty-harness.test.ts`
|
||||
(zero subprocesses, PTY smokes self-skip where `terminal:` is unavailable).
|
||||
|
||||
`scripts/dx-explore.ts` drives it to capture the fresh-user funnel as timestamped
|
||||
transcripts under `.context/dx-runs/` (gitignored — nothing asserts, no CI):
|
||||
|
||||
```bash
|
||||
bun run scripts/dx-explore.ts help # comprehension surfaces (no keys)
|
||||
bun run scripts/dx-explore.ts init [--keyless] # interactive init, naive-user autopilot
|
||||
bun run scripts/dx-explore.ts claude-install # REAL claude running the paste-in bootstrap
|
||||
bun run scripts/dx-explore.ts codex-install # REAL codex, same
|
||||
bun run scripts/dx-explore.ts drive -- gbrain init # manual: steer a live TUI via a file channel
|
||||
```
|
||||
|
||||
`drive` mode is how an agent in a Conductor workspace explores a live TUI across
|
||||
separate tool calls: `cat <dir>/session/screen.txt` to watch, append
|
||||
`{"line":"..."}` / `{"key":"Down"}` / `{"stop":true}` to `<dir>/session/input.jsonl`
|
||||
to steer. Each run writes `meta.json`, `visible.txt`, `frames.jsonl`, and
|
||||
`stalls.md`. `--keyless` strips provider keys so the true no-key first-touch path
|
||||
is exercised (a Conductor session's ambient `ANTHROPIC_API_KEY` would otherwise
|
||||
leak in). Install scenarios pay real API cost — launch them as background tasks.
|
||||
|
||||
@@ -20,7 +20,12 @@ schema. The user gets new capabilities automatically.
|
||||
|
||||
gbrain stays current the way gstack does: it rides invocation frequency. A
|
||||
throttled, cache-read-only check runs at the start of every `gbrain` invocation
|
||||
(CLI and MCP) and emits an `UPGRADE_AVAILABLE <old> <new>` marker on stderr. No
|
||||
(CLI and MCP) and emits an `UPGRADE_AVAILABLE <old> <new>` marker on stderr. The
|
||||
raw marker line is suppressed when stderr is an interactive TTY (a human sees
|
||||
only the plain `gbrain X -> Y available` sentence, not the machine token); set
|
||||
`GBRAIN_FORCE_UPGRADE_MARKER=1` if an agent harness parses the token but runs
|
||||
under a PTY. `<old>` is always the RUNNING binary's version, so a stale or
|
||||
foreign-written cache never nags about an upgrade this binary already has. No
|
||||
host cron required — every agent kind (Claude Code, Codex, OpenClaw, Hermes, the
|
||||
`gbrain serve` host behind a Perplexity thin client) converges to current by
|
||||
construction. The behavior is governed by one file-plane config key,
|
||||
|
||||
+9
-6
@@ -1106,9 +1106,10 @@ NOT inside ~/gbrain.
|
||||
## Step 3.5: Confirm search mode with the user (DO NOT SKIP)
|
||||
|
||||
`gbrain init` auto-applied a default search mode (`tokenmax` unless your subagent
|
||||
tier is Haiku-class or no OpenAI key is configured). The init output included the
|
||||
cost matrix below preceded by `[AGENT]` markers. You must NOT silently accept the
|
||||
default. Stop and ask the operator.
|
||||
tier is Haiku-class or no expansion-capable API key — Anthropic, OpenAI, or
|
||||
Google — is configured). The init output included the cost matrix below preceded
|
||||
by `[AGENT]` markers. You must NOT silently accept the default. Stop and ask the
|
||||
operator.
|
||||
|
||||
**Present this matrix verbatim:**
|
||||
|
||||
@@ -1596,7 +1597,7 @@ The point of building a 150K-page brain is to use it as a strategic moat. To nev
|
||||
|
||||
It's easier to ship a daemon that runs 24/7 to ingest, enrich, and consolidate than it is to keep an agent in chat working hard. GBrain is that daemon, generalized. Install in 30 minutes. Your agent does the work. As my personal agent gets smarter, so does yours.
|
||||
|
||||
> **~30 minutes to a fully working brain.** Database ready in 2 seconds (PGLite, no server). You just answer questions about API keys.
|
||||
> **~15 minutes to a working personal agent** on the recommended Codex / Claude Code path (mostly a short interview); ~30 minutes for the always-on OpenClaw / Hermes setup. Database ready in 2 seconds either way (PGLite, no server).
|
||||
|
||||
> **LLMs:** fetch [`llms.txt`](llms.txt) for the documentation map, or [`llms-full.txt`](llms-full.txt) for the same map with core docs inlined in one fetch. **Agents:** start with [`AGENTS.md`](AGENTS.md) (or [`CLAUDE.md`](CLAUDE.md) if you're Claude Code).
|
||||
|
||||
@@ -1671,7 +1672,9 @@ answers. Ask before anything destructive. You are not done until
|
||||
`gbrain bootstrap verify` exits 0.
|
||||
```
|
||||
|
||||
Codex will ask for command approvals during the install — approving them is the sandbox working as intended. What you get, in about 15 minutes: a short interview (6 required questions) → your agent's identity (SOUL.md, USER.md, MEMORY.md) rendered from your own answers, never invented → a local PGLite brain (2 seconds, no server, no Docker) → MCP wired so every session can search and write memory → a **private** GitHub repo, created and privacy-verified, as your agent's durable body. Works with **zero API keys** — keyword search plus memory your agent writes itself; one optional key (OpenAI, Anthropic, or Voyage) upgrades to semantic search and automatic fact extraction. Codex reads brain context through its tools each turn (pull-based).
|
||||
Codex will ask for command approvals during the install — approving them is the sandbox working as intended. What you get, in about 15 minutes: a short interview (6 required questions) → your agent's identity (SOUL.md, USER.md, MEMORY.md) rendered from your own answers, never invented → a local PGLite brain (2 seconds, no server, no Docker) → MCP wired so every session can search and write memory → a **private** GitHub repo, created and privacy-verified, as your agent's durable body. Works with **zero API keys** — keyword search plus memory your agent writes itself; one optional key upgrades capabilities (OpenAI: semantic search + automatic fact extraction; Voyage: semantic search; Anthropic: fact extraction). Codex reads brain context through its tools each turn (pull-based). The click moment: tell it one small thing to remember, restart Codex, then ask for it back — the answer comes from the brain, not from this chat's context (which the restart cleared). That cross-session round-trip is the whole product; "what's my name / my top jobs?" is answered from your identity files, which is nice but not the same trick.
|
||||
|
||||
Two things worth understanding once it's running: **you own the brain** — every memory is a markdown file in that private repo (read it, clone it to a second machine, delete it and the brain is gone) — and **the first skill to run is `cold-start`**: say "fill my brain" and your agent imports your Gmail, calendar, and contacts (via [ClawVisor](https://clawvisor.com), an OAuth vault so the agent never holds raw tokens) or offline archives like Google Takeout, one consented step at a time. An empty brain is a database; a filled one is a memory.
|
||||
|
||||
> **Prefer to make the repo yourself?** Create a new **empty** private repo **under your own GitHub account** (no README/.gitignore/license), clone it, open the clone in Codex, and paste the same block — bootstrap detects your empty repo and adopts it instead of creating one. The repo must be empty and personal-account-owned; org-owned repos are refused (create one under your account, or let bootstrap make it).
|
||||
|
||||
@@ -1688,7 +1691,7 @@ answers. Ask before anything destructive. You are not done until
|
||||
`gbrain bootstrap verify` exits 0.
|
||||
```
|
||||
|
||||
Everything from the Codex path applies — interview, identity from your own answers, local brain, private repo, keyless mode — plus Claude Code gets **per-turn context hooks**: your brain loads automatically into every prompt, and your work persists to your private repo on a per-turn cadence (debounced ~5 min locally, every turn in a cloud sandbox — this covers the `/exit` case the harness never fires a session-end hook on), with a notice on your next turn if a push ever fails. This works in a **Claude Code cloud session** too, not just on your laptop: verification falls back to pure git protocol when the sandbox blocks the GitHub API, and `gbrain bootstrap cloud-setup-script` prints the environment setup recipe. Restart the session after install and ask "what did I tell you my top jobs were?" — that's the moment it clicks. Full contract, security posture, cloud sandboxes, and uninstall: [docs/guides/bootstrap.md](docs/guides/bootstrap.md).
|
||||
Everything from the Codex path applies — interview, identity from your own answers, local brain, private repo, keyless mode — plus Claude Code gets **per-turn context hooks** (on by default, with an opt-out): your brain loads automatically into every prompt, and your work persists to your private repo on a per-turn cadence (debounced ~5 min locally, every turn in a cloud sandbox — this covers the `/exit` case the harness never fires a session-end hook on), with a notice on your next turn if a push ever fails. This works in a **Claude Code cloud session** too, not just on your laptop: verification falls back to pure git protocol when the sandbox blocks the GitHub API, and `gbrain bootstrap cloud-setup-script` prints the environment setup recipe. The click moment: tell it one small thing to remember, restart the session, then ask for it back — a fresh session has no chat context, so the answer can only come from the brain. That cross-session round-trip is the whole product ("what's my name?" is answered from your identity files — nice, but not the same trick). Same two follow-ups as the Codex path: you own the brain (markdown in your private repo), and `cold-start` is the first skill to run — "fill my brain" imports your email, calendar, and contacts (ClawVisor) or offline archives, one consented step at a time. Full contract, security posture, cloud sandboxes, and uninstall: [docs/guides/bootstrap.md](docs/guides/bootstrap.md).
|
||||
|
||||
> **Prefer to make the repo yourself?** Create a new **empty** private repo **under your own GitHub account** (no README/.gitignore/license), clone it, open the clone in Claude Code (CLI or the desktop app's open-a-repo flow), and paste the same block — bootstrap adopts your empty repo instead of creating one. The repo must be empty and personal-account-owned; org-owned repos are refused.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "gbrain-context-engine",
|
||||
"name": "gbrain",
|
||||
"version": "0.45.10.0",
|
||||
"version": "0.45.11.0",
|
||||
"description": "Personal knowledge brain with Postgres + pgvector hybrid search",
|
||||
"family": "bundle-plugin",
|
||||
"configSchema": {
|
||||
@@ -47,6 +47,7 @@
|
||||
"skills/capture",
|
||||
"skills/citation-fixer",
|
||||
"skills/citation-graph-ingest",
|
||||
"skills/cold-start",
|
||||
"skills/company-brainify",
|
||||
"skills/concept-synthesis",
|
||||
"skills/context-audit",
|
||||
|
||||
+1
-1
@@ -154,7 +154,7 @@
|
||||
"bun": ">=1.3.10"
|
||||
},
|
||||
"license": "MIT",
|
||||
"version": "0.45.10.0",
|
||||
"version": "0.45.11.0",
|
||||
"overrides": {
|
||||
"@hono/node-server": "^2.0.5",
|
||||
"fast-uri": "^3.1.5",
|
||||
|
||||
@@ -0,0 +1,723 @@
|
||||
/**
|
||||
* dx-explore — drive the REAL fresh-user experience under a PTY and record it.
|
||||
*
|
||||
* The e2e door tests (test/e2e/bootstrap-real-{claude,codex}.serial.test.ts)
|
||||
* prove the install WORKS headlessly. This script captures what installing
|
||||
* FEELS like: every picker, prompt, spinner, silence window, and line of copy
|
||||
* a fresh user sees, as timestamped transcripts ready for a
|
||||
* Don't-Make-Me-Think DX audit. It is a developer instrument, not a test —
|
||||
* transcripts land in .context/dx-runs/ (gitignored) and nothing asserts.
|
||||
*
|
||||
* Scenarios (all hermetic — temp HOME/GBRAIN_HOME/CLAUDE_CONFIG_DIR/CODEX_HOME;
|
||||
* the operator's real config is never WRITTEN. Two narrow reads exist for
|
||||
* auth: codex-install copies ~/.codex/auth.json into the temp CODEX_HOME, and
|
||||
* the claude seed records the API key's last 20 chars — both copies are
|
||||
* scrubbed at cleanup even under --keep, so no credential material outlives
|
||||
* the run):
|
||||
*
|
||||
* help First-touch comprehension surfaces: bare `gbrain`,
|
||||
* `gbrain --help`, `gbrain init --help`, `gbrain bootstrap
|
||||
* --help`, `gbrain bootstrap` bare. Cheap, no keys.
|
||||
* init Interactive `gbrain init` (keyless) with a naive-user
|
||||
* autopilot: wait for each screen to settle, snapshot it,
|
||||
* press Enter (accept the default), repeat. What a user who
|
||||
* "just hits Enter" experiences, with stall timing.
|
||||
* claude-install REAL interactive `claude` in a fresh empty workspace,
|
||||
* driven by the README paste block pointed at THIS repo's
|
||||
* BOOTSTRAP_FOR_AGENTS.md, with a scripted persona appendix
|
||||
* so the interview completes unattended. Pays real API cost;
|
||||
* takes 10-25 min. Run in background and watch session/screen.txt.
|
||||
* codex-install Same for REAL `codex` (interactive TUI).
|
||||
* drive -- <cmd> Manual mode: spawn ANY command under the PTY and steer it
|
||||
* across separate shell calls via a file control channel:
|
||||
* watch: cat <dir>/session/screen.txt
|
||||
* type: echo '{"line":"hello"}' >> <dir>/session/input.jsonl
|
||||
* keys: echo '{"key":"Down"}' >> <dir>/session/input.jsonl
|
||||
* note: echo '{"note":"picker confuses me"}' >> ...
|
||||
* stop: echo '{"stop":true}' >> ...
|
||||
* {"line": ...} sends text + Enter; {"send": ...} sends raw
|
||||
* bytes (mind that zsh `echo` mangles \r — prefer "line").
|
||||
* Launch as a background task; this is how an agent in
|
||||
* Conductor explores a live TUI across tool calls.
|
||||
*
|
||||
* Usage:
|
||||
* bun run scripts/dx-explore.ts help
|
||||
* bun run scripts/dx-explore.ts init
|
||||
* bun run scripts/dx-explore.ts claude-install
|
||||
* bun run scripts/dx-explore.ts codex-install
|
||||
* bun run scripts/dx-explore.ts drive [--no-hermetic-home] -- gbrain init
|
||||
* Options: --dir <out> transcript dir (default .context/dx-runs/<scenario>-<ts>)
|
||||
* --gbrain <bin> use an existing gbrain binary (default: compile+cache)
|
||||
* --rebuild force recompile of the cached binary
|
||||
* --keep keep hermetic temp homes for forensics
|
||||
*
|
||||
* Output bundle per scenario dir: meta.json, raw.txt, visible.txt,
|
||||
* frames.jsonl, stalls.md, events.jsonl (inputs/notes timeline), steps.md
|
||||
* (autopilot screen-by-screen), session/ (live: screen.txt, status.json).
|
||||
*
|
||||
* Progress prints to stderr; the transcript dir path is the only stdout line
|
||||
* (pipe-friendly), matching the repo's progress discipline.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import {
|
||||
launchTty,
|
||||
saveTranscript,
|
||||
seedClaudeTuiConfig,
|
||||
parseDriveCommand,
|
||||
type TtySession,
|
||||
} from '../test/helpers/tty-harness.ts';
|
||||
|
||||
const REPO_ROOT = path.resolve(import.meta.dir, '..');
|
||||
|
||||
/** Screen patterns that mean the paste-in install reached a passing verify —
|
||||
* ONE list shared by the claude-install and codex-install scenarios so the
|
||||
* two can't drift when the bootstrap's success copy changes. */
|
||||
const VERIFY_SUCCESS_PATTERNS: Array<RegExp | string> = [
|
||||
/bootstrap verify.*exit(?:ed|s)? 0/i,
|
||||
/verify\b.*\b(passed|0\b)/i,
|
||||
/All checks passed/i,
|
||||
];
|
||||
|
||||
// Same synthetic persona the door tests use — the interview can complete
|
||||
// unattended and nothing real about the operator ever enters a transcript.
|
||||
const PERSONA = {
|
||||
AGENT_NAME: 'Lighthouse',
|
||||
PRINCIPAL_NAME: 'Pat Example',
|
||||
AGENT_PURPOSE: 'Maintain the research corpus and draft the weekly memo without re-briefing.',
|
||||
AGENT_TOP_JOBS: 'corpus upkeep; weekly memo; meeting prep',
|
||||
PRINCIPAL_CONTEXT: 'Runs a small research group; builds internal tooling; values signal over noise.',
|
||||
VOICE_REGISTER: 'Direct: three options, the second one wins.',
|
||||
};
|
||||
|
||||
function log(msg: string): void {
|
||||
process.stderr.write(`[dx-explore] ${msg}\n`);
|
||||
}
|
||||
|
||||
function nowStamp(): string {
|
||||
return new Date().toISOString().replace(/[:.]/g, '-').replace('T', '-').slice(0, 19);
|
||||
}
|
||||
|
||||
// ── arg parsing ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface CliArgs {
|
||||
scenario: string;
|
||||
dir?: string;
|
||||
gbrainBin?: string;
|
||||
rebuild: boolean;
|
||||
keep: boolean;
|
||||
/** Strip provider API keys from the child env — the TRUE keyless posture.
|
||||
* Without this, a Conductor session's ANTHROPIC_API_KEY leaks into the
|
||||
* hermetic run and the keyless first-touch path is never exercised. */
|
||||
keyless: boolean;
|
||||
hermeticHome: boolean;
|
||||
driveArgv: string[];
|
||||
}
|
||||
|
||||
/** Provider keys the hermetic base allows through; --keyless drops them. */
|
||||
const PROVIDER_KEY_NAMES = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'OPENAI_API_KEY',
|
||||
'GSTACK_ANTHROPIC_API_KEY',
|
||||
'GSTACK_OPENAI_API_KEY',
|
||||
];
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
const out: CliArgs = {
|
||||
scenario: '',
|
||||
rebuild: false,
|
||||
keep: false,
|
||||
keyless: false,
|
||||
hermeticHome: true,
|
||||
driveArgv: [],
|
||||
};
|
||||
let i = 0;
|
||||
const sep = argv.indexOf('--');
|
||||
const own = sep >= 0 ? argv.slice(0, sep) : argv;
|
||||
out.driveArgv = sep >= 0 ? argv.slice(sep + 1) : [];
|
||||
while (i < own.length) {
|
||||
const a = own[i]!;
|
||||
if (a === '--dir') out.dir = own[++i];
|
||||
else if (a === '--gbrain') out.gbrainBin = own[++i];
|
||||
else if (a === '--rebuild') out.rebuild = true;
|
||||
else if (a === '--keep') out.keep = true;
|
||||
else if (a === '--keyless') out.keyless = true;
|
||||
else if (a === '--no-hermetic-home') out.hermeticHome = false;
|
||||
else if (!out.scenario && !a.startsWith('--')) out.scenario = a;
|
||||
else {
|
||||
log(`unknown argument: ${a}`);
|
||||
process.exit(2);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── compiled gbrain binary (what a real user runs) ───────────────────────────
|
||||
|
||||
/** Compile (or reuse) a standalone gbrain binary. `bun run src/cli.ts` adds a
|
||||
* multi-second transpile stall to EVERY invocation that a real install never
|
||||
* has — a compiled binary keeps the timing honest. Cached under
|
||||
* .context/dx-runs/bin/ keyed on nothing (use --rebuild after code changes). */
|
||||
function ensureGbrainBinary(explicit: string | undefined, rebuild: boolean): string {
|
||||
if (explicit) {
|
||||
fs.accessSync(explicit, fs.constants.X_OK);
|
||||
return path.resolve(explicit);
|
||||
}
|
||||
const binDir = path.join(REPO_ROOT, '.context', 'dx-runs', 'bin');
|
||||
const binPath = path.join(binDir, 'gbrain');
|
||||
if (!rebuild && fs.existsSync(binPath)) {
|
||||
log(`reusing compiled gbrain at ${binPath} (--rebuild to refresh)`);
|
||||
return binPath;
|
||||
}
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
log('compiling gbrain (bun build --compile)…');
|
||||
const res = spawnSync('bun', ['build', '--compile', '--outfile', binPath, 'src/cli.ts'], {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: 'utf8',
|
||||
timeout: 300_000,
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
if (res.status !== 0 || !fs.existsSync(binPath)) {
|
||||
throw new Error(`bun build --compile failed (exit ${res.status}):\n${(res.stderr ?? '').slice(-2000)}`);
|
||||
}
|
||||
log(`compiled ${binPath}`);
|
||||
return binPath;
|
||||
}
|
||||
|
||||
// ── scenario plumbing ────────────────────────────────────────────────────────
|
||||
|
||||
interface ScenarioCtx {
|
||||
outDir: string;
|
||||
gbrainBin: string;
|
||||
keep: boolean;
|
||||
/** temp dirs to remove on completion unless --keep */
|
||||
cleanups: string[];
|
||||
/** Files carrying credential material (copied auth.json, seeded key
|
||||
* suffixes). ALWAYS deleted at cleanup — --keep keeps transcripts and
|
||||
* hermetic dirs for forensics, never credentials. */
|
||||
secretPaths: string[];
|
||||
events: Array<{ tMs: number; kind: 'input' | 'note' | 'screen'; data: string }>;
|
||||
t0: number;
|
||||
}
|
||||
|
||||
function newCtx(args: CliArgs, needsGbrain: boolean): ScenarioCtx {
|
||||
const outDir = path.resolve(
|
||||
args.dir ?? path.join(REPO_ROOT, '.context', 'dx-runs', `${args.scenario}-${nowStamp()}`),
|
||||
);
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const ctx: ScenarioCtx = {
|
||||
outDir,
|
||||
gbrainBin: needsGbrain ? ensureGbrainBinary(args.gbrainBin, args.rebuild) : '',
|
||||
keep: args.keep,
|
||||
cleanups: [],
|
||||
secretPaths: [],
|
||||
events: [],
|
||||
t0: Date.now(),
|
||||
};
|
||||
installSignalScrub(ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function tmp(ctx: ScenarioCtx, prefix: string): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
ctx.cleanups.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function event(ctx: ScenarioCtx, kind: 'input' | 'note' | 'screen', data: string): void {
|
||||
ctx.events.push({ tMs: Date.now() - ctx.t0, kind, data });
|
||||
}
|
||||
|
||||
/** Delete every credential copy. Idempotent; safe to call from a signal
|
||||
* handler AND from finishCtx (a second call is a no-op). This is the
|
||||
* "no credential outlives the run" guarantee — it must run even when a
|
||||
* 10-25min install is Ctrl-C'd (finally does NOT run on SIGINT default). */
|
||||
function scrubSecrets(ctx: ScenarioCtx): void {
|
||||
for (const p of ctx.secretPaths) {
|
||||
try {
|
||||
fs.rmSync(p, { force: true });
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Wire SIGINT/SIGTERM so an interrupted run still scrubs credentials before
|
||||
* the process dies. Registered once per scenario ctx. */
|
||||
function installSignalScrub(ctx: ScenarioCtx): void {
|
||||
const handler = (sig: NodeJS.Signals) => {
|
||||
scrubSecrets(ctx);
|
||||
process.stderr.write(`\n[dx-explore] ${sig}: scrubbed credential copies, exiting.\n`);
|
||||
process.exit(130);
|
||||
};
|
||||
process.once('SIGINT', handler);
|
||||
process.once('SIGTERM', handler);
|
||||
}
|
||||
|
||||
function finishCtx(ctx: ScenarioCtx): void {
|
||||
// Scrub credentials FIRST — before any other I/O that could throw (an
|
||||
// events.jsonl write failure must not strand auth files).
|
||||
scrubSecrets(ctx);
|
||||
fs.writeFileSync(
|
||||
path.join(ctx.outDir, 'events.jsonl'),
|
||||
ctx.events.map((e) => JSON.stringify(e)).join('\n') + (ctx.events.length ? '\n' : ''),
|
||||
);
|
||||
if (ctx.keep && ctx.secretPaths.length > 0) {
|
||||
log(`--keep: retained hermetic dirs, but scrubbed ${ctx.secretPaths.length} credential file(s)`);
|
||||
}
|
||||
if (!ctx.keep) {
|
||||
for (const d of ctx.cleanups) {
|
||||
try {
|
||||
fs.rmSync(d, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fs.writeFileSync(
|
||||
path.join(ctx.outDir, 'hermetic-dirs.json'),
|
||||
JSON.stringify(ctx.cleanups, null, 2),
|
||||
);
|
||||
}
|
||||
// The one stdout line: where the transcript landed.
|
||||
console.log(ctx.outDir);
|
||||
}
|
||||
|
||||
/** Live session mirror so a watcher (or a Conductor agent) can follow along:
|
||||
* session/screen.txt (latest visible tail) + session/status.json. */
|
||||
function mirrorSession(dir: string, session: TtySession): () => void {
|
||||
const sessDir = path.join(dir, 'session');
|
||||
fs.mkdirSync(sessDir, { recursive: true });
|
||||
const timer = setInterval(() => {
|
||||
try {
|
||||
fs.writeFileSync(path.join(sessDir, 'screen.txt'), session.visible().slice(-8000));
|
||||
fs.writeFileSync(
|
||||
path.join(sessDir, 'status.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
running: !session.exited(),
|
||||
exitCode: session.exitCode(),
|
||||
elapsedMs: Date.now() - session.startedAtMs,
|
||||
frames: session.frames().length,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}, 500);
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
|
||||
function saveSession(ctx: ScenarioCtx, name: string, session: TtySession, extraMeta: Record<string, unknown> = {}): void {
|
||||
const dir = name ? path.join(ctx.outDir, name) : ctx.outDir;
|
||||
saveTranscript(dir, {
|
||||
frames: session.frames(),
|
||||
raw: session.raw(),
|
||||
meta: {
|
||||
scenario: name || path.basename(ctx.outDir),
|
||||
argv: session.argv,
|
||||
startedAtIso: new Date(session.startedAtMs).toISOString(),
|
||||
exitCode: session.exitCode(),
|
||||
durationMs: Date.now() - session.startedAtMs,
|
||||
...extraMeta,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── scenario: help ───────────────────────────────────────────────────────────
|
||||
|
||||
async function scenarioHelp(ctx: ScenarioCtx, args: CliArgs): Promise<void> {
|
||||
const home = tmp(ctx, 'gb-dx-home-');
|
||||
const ws = tmp(ctx, 'gb-dx-ws-');
|
||||
const dropEnv = args.keyless ? PROVIDER_KEY_NAMES : undefined;
|
||||
const surfaces: Array<{ name: string; argv: string[] }> = [
|
||||
{ name: 'step-01-bare', argv: [ctx.gbrainBin] },
|
||||
{ name: 'step-02-help', argv: [ctx.gbrainBin, '--help'] },
|
||||
{ name: 'step-03-init-help', argv: [ctx.gbrainBin, 'init', '--help'] },
|
||||
{ name: 'step-04-bootstrap-help', argv: [ctx.gbrainBin, 'bootstrap', '--help'] },
|
||||
{ name: 'step-05-bootstrap-bare', argv: [ctx.gbrainBin, 'bootstrap'] },
|
||||
{ name: 'step-06-status-fresh', argv: [ctx.gbrainBin, 'status'] },
|
||||
];
|
||||
for (const s of surfaces) {
|
||||
log(`running ${s.name}: ${s.argv.join(' ')}`);
|
||||
const session = launchTty(s.argv, {
|
||||
cwd: ws,
|
||||
env: { HOME: home, GBRAIN_HOME: home },
|
||||
dropEnv,
|
||||
timeoutMs: 120_000,
|
||||
});
|
||||
await session.waitForExit(110_000);
|
||||
await session.close();
|
||||
saveSession(ctx, s.name, session);
|
||||
}
|
||||
}
|
||||
|
||||
// ── scenario: init (naive-user autopilot) ────────────────────────────────────
|
||||
|
||||
async function scenarioInit(ctx: ScenarioCtx, args: CliArgs): Promise<void> {
|
||||
const home = tmp(ctx, 'gb-dx-home-');
|
||||
const ws = tmp(ctx, 'gb-dx-ws-');
|
||||
log(
|
||||
`interactive \`gbrain init\` (${args.keyless ? 'TRUE keyless — provider keys stripped' : 'ambient keys allowed'}), ` +
|
||||
'naive-user autopilot: Enter accepts every default',
|
||||
);
|
||||
const session = launchTty([ctx.gbrainBin, 'init'], {
|
||||
cwd: ws,
|
||||
env: { HOME: home, GBRAIN_HOME: home },
|
||||
dropEnv: args.keyless ? PROVIDER_KEY_NAMES : undefined,
|
||||
timeoutMs: 600_000,
|
||||
});
|
||||
const stopMirror = mirrorSession(ctx.outDir, session);
|
||||
|
||||
const steps: string[] = [];
|
||||
let lastMarkPos = 0;
|
||||
const MAX_STEPS = 15;
|
||||
try {
|
||||
for (let step = 1; step <= MAX_STEPS && !session.exited(); step++) {
|
||||
const settled = await session.waitForQuiet({ quietMs: 2000, timeoutMs: 180_000 });
|
||||
const shot = session.visibleSince(lastMarkPos);
|
||||
lastMarkPos = session.mark();
|
||||
const tSec = ((Date.now() - session.startedAtMs) / 1000).toFixed(1);
|
||||
steps.push(
|
||||
`## Step ${step} (t+${tSec}s${settled ? '' : ', NEVER SETTLED within 180s'})\n\n` +
|
||||
'```\n' + shot.trim().slice(-3000) + '\n```\n',
|
||||
);
|
||||
event(ctx, 'screen', shot.slice(-2000));
|
||||
if (session.exited()) break;
|
||||
log(`step ${step}: screen settled at t+${tSec}s — pressing Enter (default)`);
|
||||
event(ctx, 'input', 'Enter');
|
||||
session.sendKey('Enter');
|
||||
await Bun.sleep(300);
|
||||
}
|
||||
await session.waitForExit(60_000);
|
||||
} finally {
|
||||
stopMirror();
|
||||
await session.close();
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(ctx.outDir, 'steps.md'),
|
||||
`# gbrain init — naive-user autopilot (Enter through every prompt)\n\n${steps.join('\n')}`,
|
||||
);
|
||||
saveSession(ctx, '', session, { autopilot: 'enter-through-defaults', keyless: args.keyless });
|
||||
}
|
||||
|
||||
// ── scenarios: claude-install / codex-install ────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handle the harness's own first-run chrome dialogs (Claude Code: workspace
|
||||
* trust, bypass-permissions warning) so an unattended run reaches the input
|
||||
* prompt. Each handled dialog is recorded as a note — the dialogs ARE part of
|
||||
* the real first-run friction, just not gbrain's copy. Returns once the
|
||||
* screen has been quiet with no dialog visible, or at the deadline.
|
||||
*/
|
||||
async function settlePastBootDialogs(
|
||||
ctx: ScenarioCtx,
|
||||
session: TtySession,
|
||||
opts: { deadlineMs?: number } = {},
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + (opts.deadlineMs ?? 90_000);
|
||||
const handled = new Set<string>();
|
||||
while (Date.now() < deadline) {
|
||||
await session.waitForQuiet({ quietMs: 2000, timeoutMs: 30_000 });
|
||||
if (session.exited()) return;
|
||||
const tail = session.visible().slice(-2500);
|
||||
if (!handled.has('trust') && /trust this ?folder/i.test(tail.replace(/\s+/g, ' '))) {
|
||||
handled.add('trust');
|
||||
event(ctx, 'note', 'boot dialog: workspace trust — accepted (option 1)');
|
||||
session.send('1');
|
||||
await Bun.sleep(300);
|
||||
session.sendKey('Enter');
|
||||
continue;
|
||||
}
|
||||
if (!handled.has('bypass') && /Bypass ?Permissions ?mode/i.test(tail.replace(/\s+/g, ''))) {
|
||||
handled.add('bypass');
|
||||
event(ctx, 'note', 'boot dialog: bypass-permissions warning — accepted (option 2)');
|
||||
session.send('2');
|
||||
await Bun.sleep(300);
|
||||
session.sendKey('Enter');
|
||||
continue;
|
||||
}
|
||||
// Codex directory-trust dialog: "Do you trust the contents of this
|
||||
// directory? › 1. Yes, continue 2. No, quit".
|
||||
if (!handled.has('codex-trust') && /trust ?the ?contents ?of ?this ?directory/i.test(tail.replace(/\s+/g, ''))) {
|
||||
handled.add('codex-trust');
|
||||
event(ctx, 'note', 'boot dialog: codex directory trust — accepted (option 1)');
|
||||
session.send('1');
|
||||
await Bun.sleep(300);
|
||||
session.sendKey('Enter');
|
||||
continue;
|
||||
}
|
||||
return; // quiet + no dialog = at the input prompt
|
||||
}
|
||||
}
|
||||
|
||||
/** The README paste block, pointed at THIS repo's runbook, plus a persona
|
||||
* appendix so the interview completes unattended. The appendix is the ONLY
|
||||
* deviation from the shipped block — flagged in meta so the audit discounts it. */
|
||||
function installPrompt(): string {
|
||||
const runbook = path.join(REPO_ROOT, 'BOOTSTRAP_FOR_AGENTS.md');
|
||||
return (
|
||||
`Read and follow every step of: ${runbook}\n` +
|
||||
`Goal: set yourself up as my persistent personal agent in this folder, with gbrain ` +
|
||||
`as your memory. Interview me before writing any identity file — never invent ` +
|
||||
`answers. Ask before anything destructive. You are not done until ` +
|
||||
`\`gbrain bootstrap verify\` exits 0.\n\n` +
|
||||
`[Unattended-run appendix — I am stepping away; use these interview answers instead ` +
|
||||
`of asking me, and do not wait for my input: ` +
|
||||
`agent name: ${PERSONA.AGENT_NAME}; my name: ${PERSONA.PRINCIPAL_NAME}; ` +
|
||||
`purpose: ${PERSONA.AGENT_PURPOSE}; top jobs: ${PERSONA.AGENT_TOP_JOBS}; ` +
|
||||
`about me: ${PERSONA.PRINCIPAL_CONTEXT}; voice: ${PERSONA.VOICE_REGISTER}. ` +
|
||||
`gbrain is already installed and on PATH. If a step needs GitHub auth or an API key ` +
|
||||
`that is unavailable, take the documented keyless/local fallback and continue.]`
|
||||
);
|
||||
}
|
||||
|
||||
async function scenarioClaudeInstall(ctx: ScenarioCtx): Promise<void> {
|
||||
const home = tmp(ctx, 'gb-dx-home-');
|
||||
const cfg = tmp(ctx, 'gb-dx-ccfg-');
|
||||
const gbHome = tmp(ctx, 'gb-dx-gbhome-');
|
||||
const ws = tmp(ctx, 'gb-dx-ws-');
|
||||
const binDir = tmp(ctx, 'gb-dx-bin-');
|
||||
fs.copyFileSync(ctx.gbrainBin, path.join(binDir, 'gbrain'));
|
||||
fs.chmodSync(path.join(binDir, 'gbrain'), 0o755);
|
||||
|
||||
seedClaudeTuiConfig(cfg, {
|
||||
apiKey: process.env.ANTHROPIC_API_KEY ?? process.env.GSTACK_ANTHROPIC_API_KEY,
|
||||
// realpath: macOS tmpdirs live under /var → /private/var; claude compares
|
||||
// against the resolved path, so an unresolved seed misses.
|
||||
trustedDirs: [ws, fs.realpathSync(ws)],
|
||||
});
|
||||
// The seed records the key's last 20 chars — credential-adjacent, so it is
|
||||
// scrubbed at cleanup even with --keep.
|
||||
ctx.secretPaths.push(path.join(cfg, '.claude.json'));
|
||||
|
||||
log('REAL interactive claude running the paste-in bootstrap (10-25 min, real API cost)');
|
||||
log(`watch live: cat ${path.join(ctx.outDir, 'session', 'screen.txt')}`);
|
||||
const session = launchTty(
|
||||
// --dangerously-skip-permissions: v1 measures flow + copy + stalls without
|
||||
// permission-dialog babysitting. Permission-prompt COUNT is a separate
|
||||
// drive-mode pass (the dialogs are Claude Code's chrome, not gbrain copy).
|
||||
['claude', '--dangerously-skip-permissions'],
|
||||
{
|
||||
cwd: ws,
|
||||
env: {
|
||||
HOME: home,
|
||||
CLAUDE_CONFIG_DIR: cfg,
|
||||
GBRAIN_HOME: gbHome,
|
||||
PATH: `${binDir}:${process.env.PATH ?? ''}`,
|
||||
},
|
||||
timeoutMs: 1_800_000,
|
||||
},
|
||||
);
|
||||
const stopMirror = mirrorSession(ctx.outDir, session);
|
||||
try {
|
||||
// Get past first-run chrome (trust dialog, bypass warning), then paste.
|
||||
await settlePastBootDialogs(ctx, session);
|
||||
event(ctx, 'input', 'paste install prompt');
|
||||
session.send(installPrompt());
|
||||
await Bun.sleep(1500);
|
||||
session.sendKey('Enter');
|
||||
// Run until verify-success copy or exit or wall clock.
|
||||
const done = await Promise.race([
|
||||
session
|
||||
.waitForAny(VERIFY_SUCCESS_PATTERNS, {
|
||||
timeoutMs: 1_500_000,
|
||||
})
|
||||
.then(() => 'verify-signal')
|
||||
.catch(() => 'no-signal'),
|
||||
session.waitForExit(1_500_000).then(() => 'exited'),
|
||||
]);
|
||||
event(ctx, 'note', `terminal condition: ${done}`);
|
||||
// Let trailing output land.
|
||||
await session.waitForQuiet({ quietMs: 5000, timeoutMs: 60_000 });
|
||||
} finally {
|
||||
stopMirror();
|
||||
await session.close();
|
||||
}
|
||||
saveSession(ctx, '', session, {
|
||||
promptDeviation: 'unattended persona appendix + local runbook path + preinstalled binary',
|
||||
runbook: 'BOOTSTRAP_FOR_AGENTS.md (local)',
|
||||
});
|
||||
}
|
||||
|
||||
async function scenarioCodexInstall(ctx: ScenarioCtx): Promise<void> {
|
||||
const home = tmp(ctx, 'gb-dx-home-');
|
||||
const gbHome = tmp(ctx, 'gb-dx-gbhome-');
|
||||
const ws = tmp(ctx, 'gb-dx-ws-');
|
||||
const binDir = tmp(ctx, 'gb-dx-bin-');
|
||||
fs.copyFileSync(ctx.gbrainBin, path.join(binDir, 'gbrain'));
|
||||
fs.chmodSync(path.join(binDir, 'gbrain'), 0o755);
|
||||
|
||||
// Hermetic ~/.codex with ONLY the operator's auth (same posture as the
|
||||
// codex door test). codex refuses untrusted cwds — a git repo satisfies it.
|
||||
const codexHome = path.join(home, '.codex');
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
const realAuth = path.join(os.homedir(), '.codex', 'auth.json');
|
||||
if (fs.existsSync(realAuth)) {
|
||||
const authCopy = path.join(codexHome, 'auth.json');
|
||||
fs.copyFileSync(realAuth, authCopy);
|
||||
fs.chmodSync(authCopy, 0o600); // copyFileSync doesn't preserve source mode
|
||||
ctx.secretPaths.push(authCopy); // scrubbed at cleanup, even with --keep
|
||||
}
|
||||
spawnSync('git', ['init', '-q', ws]);
|
||||
spawnSync('git', ['-C', ws, 'config', 'user.email', 'dx@example.com']);
|
||||
spawnSync('git', ['-C', ws, 'config', 'user.name', 'DX Explore']);
|
||||
|
||||
log('REAL interactive codex running the paste-in bootstrap (10-25 min, real API cost)');
|
||||
log(`watch live: cat ${path.join(ctx.outDir, 'session', 'screen.txt')}`);
|
||||
const session = launchTty(
|
||||
['codex', '--sandbox', 'workspace-write', '--ask-for-approval', 'never'],
|
||||
{
|
||||
cwd: ws,
|
||||
env: {
|
||||
HOME: home,
|
||||
CODEX_HOME: codexHome,
|
||||
GBRAIN_HOME: gbHome,
|
||||
PATH: `${binDir}:${process.env.PATH ?? ''}`,
|
||||
},
|
||||
extraAllow: ['OPENAI_API_KEY', 'CODEX_*'],
|
||||
timeoutMs: 1_800_000,
|
||||
},
|
||||
);
|
||||
const stopMirror = mirrorSession(ctx.outDir, session);
|
||||
try {
|
||||
await settlePastBootDialogs(ctx, session);
|
||||
event(ctx, 'input', 'paste install prompt');
|
||||
session.send(installPrompt());
|
||||
await Bun.sleep(1500);
|
||||
session.sendKey('Enter');
|
||||
const done = await Promise.race([
|
||||
session
|
||||
.waitForAny(VERIFY_SUCCESS_PATTERNS, {
|
||||
timeoutMs: 1_500_000,
|
||||
})
|
||||
.then(() => 'verify-signal')
|
||||
.catch(() => 'no-signal'),
|
||||
session.waitForExit(1_500_000).then(() => 'exited'),
|
||||
]);
|
||||
event(ctx, 'note', `terminal condition: ${done}`);
|
||||
await session.waitForQuiet({ quietMs: 5000, timeoutMs: 60_000 });
|
||||
} finally {
|
||||
stopMirror();
|
||||
await session.close();
|
||||
}
|
||||
saveSession(ctx, '', session, {
|
||||
promptDeviation: 'unattended persona appendix + local runbook path + preinstalled binary',
|
||||
runbook: 'BOOTSTRAP_FOR_AGENTS.md (local)',
|
||||
});
|
||||
}
|
||||
|
||||
// ── scenario: drive (manual control channel) ─────────────────────────────────
|
||||
|
||||
async function scenarioDrive(ctx: ScenarioCtx, args: CliArgs): Promise<void> {
|
||||
if (args.driveArgv.length === 0) {
|
||||
log('drive mode needs a command: dx-explore.ts drive -- gbrain init');
|
||||
process.exit(2);
|
||||
}
|
||||
// `gbrain` as argv[0] resolves to the compiled binary.
|
||||
const argv = [...args.driveArgv];
|
||||
if (argv[0] === 'gbrain') argv[0] = ctx.gbrainBin;
|
||||
|
||||
const sessDir = path.join(ctx.outDir, 'session');
|
||||
fs.mkdirSync(sessDir, { recursive: true });
|
||||
const inputPath = path.join(sessDir, 'input.jsonl');
|
||||
fs.writeFileSync(inputPath, '');
|
||||
|
||||
const env: Record<string, string | undefined> = {};
|
||||
if (args.hermeticHome) {
|
||||
const home = tmp(ctx, 'gb-dx-home-');
|
||||
env.HOME = home;
|
||||
env.GBRAIN_HOME = home;
|
||||
}
|
||||
|
||||
log(`driving: ${argv.join(' ')}`);
|
||||
log(`watch: cat ${path.join(sessDir, 'screen.txt')}`);
|
||||
log(`input: echo '{"line":"some text"}' >> ${inputPath} (sends text + Enter)`);
|
||||
log(` echo '{"key":"Down"}' >> ${inputPath}`);
|
||||
log(`stop: echo '{"stop":true}' >> ${inputPath}`);
|
||||
|
||||
const session = launchTty(argv, {
|
||||
cwd: process.cwd(),
|
||||
env,
|
||||
timeoutMs: 3_600_000,
|
||||
});
|
||||
const stopMirror = mirrorSession(ctx.outDir, session);
|
||||
|
||||
let offset = 0;
|
||||
let stopping = false;
|
||||
try {
|
||||
while (!session.exited() && !stopping) {
|
||||
await Bun.sleep(200);
|
||||
let content = '';
|
||||
try {
|
||||
content = fs.readFileSync(inputPath, 'utf8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (content.length <= offset) continue;
|
||||
const fresh = content.slice(offset);
|
||||
offset = content.length;
|
||||
for (const line of fresh.split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
const cmd = parseDriveCommand(line);
|
||||
if (!cmd) {
|
||||
log(`skipping malformed drive command: ${line.slice(0, 120)}`);
|
||||
continue;
|
||||
}
|
||||
if (cmd.kind === 'send') {
|
||||
event(ctx, 'input', cmd.data);
|
||||
session.send(cmd.data);
|
||||
} else if (cmd.kind === 'key') {
|
||||
event(ctx, 'input', `<${cmd.key}>`);
|
||||
session.sendKey(cmd.key);
|
||||
} else if (cmd.kind === 'note') {
|
||||
event(ctx, 'note', cmd.text);
|
||||
} else if (cmd.kind === 'stop') {
|
||||
stopping = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
stopMirror();
|
||||
await session.close();
|
||||
}
|
||||
saveSession(ctx, '', session, { mode: 'drive', command: argv.join(' ') });
|
||||
}
|
||||
|
||||
// ── main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const SCENARIOS: Record<string, { needsGbrain: boolean; run: (ctx: ScenarioCtx, args: CliArgs) => Promise<void> }> = {
|
||||
help: { needsGbrain: true, run: scenarioHelp },
|
||||
init: { needsGbrain: true, run: scenarioInit },
|
||||
'claude-install': { needsGbrain: true, run: scenarioClaudeInstall },
|
||||
'codex-install': { needsGbrain: true, run: scenarioCodexInstall },
|
||||
drive: { needsGbrain: true, run: scenarioDrive },
|
||||
};
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const scenario = SCENARIOS[args.scenario];
|
||||
if (!scenario) {
|
||||
log(`usage: bun run scripts/dx-explore.ts <${Object.keys(SCENARIOS).join('|')}> [options] [-- cmd...]`);
|
||||
process.exit(2);
|
||||
}
|
||||
const ctx = newCtx(args, scenario.needsGbrain);
|
||||
log(`transcripts → ${ctx.outDir}`);
|
||||
try {
|
||||
await scenario.run(ctx, args);
|
||||
} finally {
|
||||
finishCtx(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -7,7 +7,6 @@
|
||||
"schema-author": "schema-pack authoring is a brain-owner activity, not a client activity",
|
||||
"smoke-test": "host-runtime health checks (container/daemon assumptions)",
|
||||
"gbrain-upgrade": "host binary upgrade flow",
|
||||
"cold-start": "host onboarding flow",
|
||||
"schema-unify": "schema unification is a brain-owner migration activity",
|
||||
"skill-optimizer": "requires host-side skillopt engine access and benchmark files"
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@
|
||||
"minion-orchestrator/routing-eval.jsonl": "501ed2e19cb16847ff8425219d246b7a774de1accd42cb28fd44edbb64204992",
|
||||
"perplexity-research/SKILL.md": "c25f5c471cbe3c6e0f975d8397e8382b00a85f8aa75302231d53c52855369e97",
|
||||
"perplexity-research/routing-eval.jsonl": "f1a40d87e710d5d2acd602a372d83f46c95da022b6e635228fffeaacb3bb2b27",
|
||||
"plugin-exclusions.json": "585486aaaf9a87ec4b13bea5d03f5e9af34a9ac64283c878c234ba094126f793",
|
||||
"plugin-exclusions.json": "e8070da59bb4444304eb354c7421f0998383454d96d5e66e38484aae792c4775",
|
||||
"publish/SKILL.md": "e06b609db780a3cc93a1755a87b30ff08ffdc0fdbc834c1422b2ad2489b57497",
|
||||
"query/SKILL.md": "b12aae4e86b893038b1d9e97a977bd6a7939db9f5c57dde12c11d8d7451e0762",
|
||||
"query/routing-eval.jsonl": "74f5a91e52fabc54e0e9403fa17db87ee26bb7ebb8ae8005148c51142abc62fe",
|
||||
|
||||
+37
-9
@@ -14,6 +14,7 @@ import { spawn } from 'child_process';
|
||||
import {
|
||||
readUpdateCache,
|
||||
isCacheFresh,
|
||||
pendingUpgradeVersion,
|
||||
readSnooze,
|
||||
isSnoozeActive,
|
||||
resolveSelfUpgradeMode,
|
||||
@@ -259,12 +260,26 @@ function maybeEmitUpdateMarker(command: string): void {
|
||||
const now = Date.now();
|
||||
const entry = readUpdateCache();
|
||||
if (entry && isCacheFresh(entry, now)) {
|
||||
if (entry.marker.kind === 'upgrade_available' && entry.marker.latest) {
|
||||
// Shared stale/foreign-cache guard (pendingUpgradeVersion): only nag when
|
||||
// the cached latest is strictly newer than the RUNNING binary, and print
|
||||
// the running version — the cache records whatever binary WROTE it.
|
||||
const latest = pendingUpgradeVersion(VERSION, now);
|
||||
if (latest) {
|
||||
// notify mode honors a per-version snooze; auto mode ignores it.
|
||||
if (mode === 'notify' && isSnoozeActive(readSnooze(), entry.marker.latest, now)) return;
|
||||
process.stderr.write(`UPGRADE_AVAILABLE ${entry.marker.current} ${entry.marker.latest}\n`);
|
||||
if (mode === 'notify' && isSnoozeActive(readSnooze(), latest, now)) return;
|
||||
// The raw `UPGRADE_AVAILABLE <cur> <latest>` line is a MACHINE marker
|
||||
// (parsed by the self-upgrade skill / MCP via parseMarker). A human at
|
||||
// an interactive terminal should never see the token as the literal
|
||||
// first line of output — so emit it only when stderr is NOT a TTY
|
||||
// (agent harnesses capture stderr non-interactively and still get it).
|
||||
// GBRAIN_FORCE_UPGRADE_MARKER=1 forces it for the rarer agent harness
|
||||
// that allocates a PTY yet still parses the token. The human sentence
|
||||
// prints on both.
|
||||
if (!process.stderr.isTTY || process.env.GBRAIN_FORCE_UPGRADE_MARKER === '1') {
|
||||
process.stderr.write(`UPGRADE_AVAILABLE ${VERSION} ${latest}\n`);
|
||||
}
|
||||
process.stderr.write(
|
||||
`gbrain ${entry.marker.current} -> ${entry.marker.latest} available. Run: gbrain self-upgrade\n`,
|
||||
`gbrain ${VERSION} -> ${latest} available. Run: gbrain self-upgrade\n`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -273,19 +288,32 @@ function maybeEmitUpdateMarker(command: string): void {
|
||||
// Stale/missing cache → kick a detached, single-flighted refresh. The child
|
||||
// (`check-update --refresh-cache`) single-flights via the refresh lock and
|
||||
// writes the cache for the NEXT invocation. We never wait on it.
|
||||
// Spawn OURSELVES (hook.ts spawnDetachedPush pattern), not `gbrain` from
|
||||
// PATH — a different (older) binary on PATH would write ITS version into
|
||||
// the cache and make the marker lie about what is installed here.
|
||||
try {
|
||||
const child = spawn('gbrain', ['check-update', '--refresh-cache'], {
|
||||
const exec = process.execPath ?? '';
|
||||
const refreshArgs = ['check-update', '--refresh-cache'];
|
||||
// Detect compiled-vs-dev by the RUNTIME's basename, not our own — a
|
||||
// published binary keeps its official name (`gbrain-darwin-arm64`, a
|
||||
// `gb` shim), so matching `/gbrain$/` on execPath would misfire and
|
||||
// prepend the `/$bunfs/root/...` virtual entrypoint (process.argv[1] in
|
||||
// a compiled Bun binary), producing an unknown-command child that never
|
||||
// refreshes. Dev mode runs under `bun`/`node`; anything else IS the
|
||||
// compiled binary and re-execs itself directly.
|
||||
const isDevRuntime = /[/\\](bun|node)(\.exe)?$/.test(exec);
|
||||
const argv = isDevRuntime ? [process.argv[1], ...refreshArgs] : refreshArgs;
|
||||
const child = spawn(exec, argv, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: { ...process.env, GBRAIN_SKIP_STARTUP_HOOKS: '1' },
|
||||
});
|
||||
// ChildProcess is an EventEmitter — an unhandled 'error' (e.g. ENOENT when
|
||||
// gbrain isn't on PATH) would throw uncaught. Swallow it; the refresh is
|
||||
// best-effort.
|
||||
// ChildProcess is an EventEmitter — an unhandled 'error' would throw
|
||||
// uncaught. Swallow it; the refresh is best-effort.
|
||||
child.on('error', () => {});
|
||||
child.unref();
|
||||
} catch {
|
||||
/* gbrain not on PATH / spawn failed — fail-open, no refresh this run */
|
||||
/* spawn failed — fail-open, no refresh this run */
|
||||
}
|
||||
} catch {
|
||||
/* the update marker must never break a command */
|
||||
|
||||
+83
-17
@@ -429,7 +429,7 @@ async function runStatus(ws: string, rest: string[], home: string): Promise<numb
|
||||
if (report.next) {
|
||||
console.log(`\nNext: ${report.next}`);
|
||||
} else {
|
||||
console.log('\nAll phases done. Weekly self-check: `gbrain bootstrap verify`.');
|
||||
console.log('\nAll phases done. Weekly self-check: `gbrain bootstrap verify` (close agent sessions first — PGLite is single-writer).');
|
||||
}
|
||||
if (report.runbookSkew) {
|
||||
console.log(
|
||||
@@ -456,6 +456,15 @@ async function runStatus(ws: string, rest: string[], home: string): Promise<numb
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** One copy of the A8 invalidation warning — shared by --set and --skip so
|
||||
* the operator-facing instructions cannot drift between the two branches. */
|
||||
function warnInvalidatedConfirmation(): void {
|
||||
console.error(
|
||||
'note: this change voided the prior confirmation — read the full answer set back ' +
|
||||
'to the human, then `gbrain bootstrap interview --confirm <hash>` again before render.',
|
||||
);
|
||||
}
|
||||
|
||||
async function runInterview(ws: string, rest: string[]): Promise<number> {
|
||||
if (rest.includes('--init')) {
|
||||
const r = initState(ws);
|
||||
@@ -497,6 +506,7 @@ async function runInterview(ws: string, rest: string[]): Promise<number> {
|
||||
console.log(`${key}: routed to the 0600 config file (${routed.configKey}). Not recorded in interview state.`);
|
||||
return 0;
|
||||
}
|
||||
if (r.invalidatedConfirmation) warnInvalidatedConfirmation();
|
||||
console.log(`${key} recorded.`);
|
||||
return 0;
|
||||
}
|
||||
@@ -512,6 +522,7 @@ async function runInterview(ws: string, rest: string[]): Promise<number> {
|
||||
console.error(r.message);
|
||||
return 1;
|
||||
}
|
||||
if (r.invalidatedConfirmation) warnInvalidatedConfirmation();
|
||||
console.log(`${key} skipped.`);
|
||||
return 0;
|
||||
}
|
||||
@@ -839,6 +850,11 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
|
||||
|
||||
// 1. MCP registration — argv built by the host-format module, executed
|
||||
// through the runner seam, recorded on the receipt.
|
||||
// A missing host binary (exit 127) skips MCP registration but NOT the
|
||||
// hooks below — hooks only write .claude/settings.local.json and need no
|
||||
// binary. The old early-return silently dropped hooks while the copy said
|
||||
// only "MCP registration skipped".
|
||||
let mcpSkipped = false;
|
||||
const argvs =
|
||||
harness === 'claude-code'
|
||||
? registerClaudeMcp({ gbrainBin, scope: mcpScope, sourceId, ...(gbrainHome ? { gbrainHome } : {}) })
|
||||
@@ -848,10 +864,12 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
|
||||
const res = await runner(argv);
|
||||
if (res.code === 127) {
|
||||
console.error(
|
||||
`\`${argv[0]}\` is not on PATH — is ${harness} installed? MCP registration skipped; ` +
|
||||
`re-run \`gbrain bootstrap hooks --harness ${harness}\` once it is.`,
|
||||
`\`${argv[0]}\` is not on PATH — is ${harness} installed? MCP registration skipped ` +
|
||||
`(per-turn hooks still install below); re-run ` +
|
||||
`\`gbrain bootstrap hooks --harness ${harness}\` once it is.`,
|
||||
);
|
||||
return 2;
|
||||
mcpSkipped = true;
|
||||
break;
|
||||
}
|
||||
if (res.code !== 0) {
|
||||
const already = /already exists|already registered/i.test(res.stderr + res.stdout);
|
||||
@@ -869,12 +887,45 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
|
||||
console.error(
|
||||
`existing '${mcpName}' MCP registration targets a DIFFERENT workspace/binary — replacing it.`,
|
||||
);
|
||||
await runner([argv[0], 'mcp', 'remove', mcpName]);
|
||||
// The add above failed "already exists" in the CURRENT scope, so the
|
||||
// blocker lives there — target the remove at that scope on Claude
|
||||
// Code (a scope-less remove can resolve to a different scope's
|
||||
// registration and leave the blocker in place). Codex has no scope
|
||||
// flag. Fail loud if the remove doesn't land: the silent no-op loop
|
||||
// used to re-fail the add and report nothing actionable.
|
||||
const rmArgv =
|
||||
harness === 'claude-code'
|
||||
? [argv[0], 'mcp', 'remove', mcpName, '--scope', mcpScope]
|
||||
: [argv[0], 'mcp', 'remove', mcpName];
|
||||
const rm = await runner(rmArgv);
|
||||
if (rm.code !== 0) {
|
||||
console.error(
|
||||
`\`${rmArgv.join(' ')}\` failed (${rm.stderr.trim() || `exit ${rm.code}`}) — remove the stale ` +
|
||||
`registration by hand (\`${argv[0]} mcp get ${mcpName}\` shows where it lives), then re-run ` +
|
||||
`\`gbrain bootstrap hooks --harness ${harness} --repair\`.`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
const re = await runner(argv);
|
||||
if (re.code !== 0 && !/already exists|already registered/i.test(re.stderr + re.stdout)) {
|
||||
console.error(`MCP re-registration failed (${argv.join(' ')}): ${re.stderr.trim() || `exit ${re.code}`}`);
|
||||
return 1;
|
||||
}
|
||||
// Re-add can itself return "already exists" if a racing writer
|
||||
// re-claimed the name between our remove and add — that registration
|
||||
// is NOT ours. Re-verify and abort rather than bless a foreign
|
||||
// endpoint that would intercept memory ops. (Only the recorded
|
||||
// warn-then-continue step-2 smoke did this before; here it's fatal.)
|
||||
const post = await verifyMcpTargetsWorkspace(runner, harness, mcpName, gbrainBin, sourceId);
|
||||
if (post === 'mismatch') {
|
||||
console.error(
|
||||
`after replacing '${mcpName}', it STILL targets a different workspace/binary — ` +
|
||||
`refusing to continue (a racing registration may have re-claimed the name). ` +
|
||||
`Inspect \`${argv[0]} mcp get ${mcpName}\`, remove it by hand, then re-run ` +
|
||||
`\`gbrain bootstrap hooks --harness ${harness} --repair\`.`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`MCP server '${mcpName}' already registered — could not confirm it targets this workspace ` +
|
||||
@@ -887,7 +938,7 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
|
||||
// 2. Registration smoke [FIX7]: confirm the EXPECTED server (binary path +
|
||||
// GBRAIN_SOURCE), not merely a 'gbrain' substring in `mcp list`. Falls back
|
||||
// to the list probe only when the host has no `mcp get`.
|
||||
try {
|
||||
if (!mcpSkipped) try {
|
||||
const listBin = harness === 'claude-code' ? 'claude' : 'codex';
|
||||
const scopeLabel = harness === 'claude-code' ? mcpScope : 'user-global';
|
||||
const verdict = await verifyMcpTargetsWorkspace(runner, harness, 'gbrain', gbrainBin, sourceId);
|
||||
@@ -926,9 +977,21 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
|
||||
// both files.
|
||||
const hookEnv = { GBRAIN_SOURCE: sourceId, ...(gbrainHome ? { GBRAIN_HOME: gbrainHome } : {}) };
|
||||
const cloudCarrier = detectExecutionEnvironment() === 'cloud-sandbox';
|
||||
const r = cloudCarrier
|
||||
? writeCommittedClaudeHooks(ws, { env: hookEnv })
|
||||
: writeClaudeHooks(ws, { gbrainBin, env: hookEnv });
|
||||
let r: ReturnType<typeof writeClaudeHooks> | ReturnType<typeof writeCommittedClaudeHooks>;
|
||||
try {
|
||||
r = cloudCarrier
|
||||
? writeCommittedClaudeHooks(ws, { env: hookEnv })
|
||||
: writeClaudeHooks(ws, { gbrainBin, env: hookEnv });
|
||||
} catch (e) {
|
||||
// Fail-closed on an unparseable settings file (either carrier): MCP
|
||||
// (step 1) still landed; record that, surface the fix, and exit
|
||||
// nonzero so the paste-in flow knows hooks are NOT installed.
|
||||
console.error((e as Error).message);
|
||||
if (!mcpSkipped) {
|
||||
appendReceiptRegistration(home, ws, { host: harness, scope: mcpScope, detail: 'mcp' });
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
hooksWritten = true;
|
||||
console.log(
|
||||
`hooks installed (${r.installed.length} event(s)) in ${r.settingsPath}${repair ? ' [repair]' : ''} — your brain now loads every turn. Turn off any time with GBRAIN_HOOKS=0, or re-run with --no-hooks.`,
|
||||
@@ -951,15 +1014,18 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
|
||||
console.log('Codex has no hook system — per-turn context is the AGENTS.md pull protocol (stated plainly, not a bug).');
|
||||
}
|
||||
|
||||
// 4. Receipt registration record [CX2-12].
|
||||
appendReceiptRegistration(home, ws, {
|
||||
host: harness,
|
||||
scope: harness === 'claude-code' ? mcpScope : 'user',
|
||||
detail: hooksWritten ? 'mcp+hooks' : 'mcp',
|
||||
});
|
||||
// 4. Receipt registration record [CX2-12]. Detail records what actually
|
||||
// landed; nothing landed at all (127 + no hooks) → no receipt entry.
|
||||
if (!mcpSkipped || hooksWritten) {
|
||||
appendReceiptRegistration(home, ws, {
|
||||
host: harness,
|
||||
scope: harness === 'claude-code' ? mcpScope : 'user',
|
||||
detail: hooksWritten ? (mcpSkipped ? 'hooks' : 'mcp+hooks') : 'mcp',
|
||||
});
|
||||
}
|
||||
|
||||
abortIfInjected('wire');
|
||||
return 0;
|
||||
return mcpSkipped ? 2 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -981,7 +1047,7 @@ async function runVerify(ws: string, rest: string[], home: string): Promise<numb
|
||||
const sourceId = state.state === 'initialized' ? state.manifest.source_id : 'workspace';
|
||||
const result = await verifyWorkspace(engine, ws, { sourceId, gbrainHomeDir: home });
|
||||
if (jsonMode) {
|
||||
console.log(JSON.stringify({ ok: result.ok, checks: result.checks, capability: result.capability, tour: result.tour }, null, 2));
|
||||
console.log(JSON.stringify({ ok: result.ok, checks: result.checks, capability: result.capability, tour: result.tour, handoff: result.handoff }, null, 2));
|
||||
} else {
|
||||
console.log(result.report);
|
||||
}
|
||||
|
||||
@@ -1285,8 +1285,7 @@ export function checkSelfUpgradeHealth(): Check {
|
||||
const { loadConfig } = require('../core/config.ts');
|
||||
const {
|
||||
resolveSelfUpgradeMode,
|
||||
readUpdateCache,
|
||||
isCacheFresh,
|
||||
pendingUpgradeVersion,
|
||||
} = require('../core/self-upgrade.ts');
|
||||
const { readRecentSelfUpgrades } = require('../core/audit/self-upgrade-audit.ts');
|
||||
|
||||
@@ -1301,9 +1300,11 @@ export function checkSelfUpgradeHealth(): Check {
|
||||
}
|
||||
|
||||
const parts: string[] = [`mode=${mode}`];
|
||||
const entry = readUpdateCache();
|
||||
if (entry && isCacheFresh(entry, Date.now()) && entry.marker.kind === 'upgrade_available') {
|
||||
parts.push(`update available: ${entry.marker.current} -> ${entry.marker.latest} (run: gbrain self-upgrade)`);
|
||||
// Shared stale/foreign-cache guard: only report an upgrade strictly newer
|
||||
// than the RUNNING binary (pendingUpgradeVersion owns the rule).
|
||||
const pendingLatest = pendingUpgradeVersion(GBRAIN_BINARY_VERSION, Date.now());
|
||||
if (pendingLatest) {
|
||||
parts.push(`update available: ${GBRAIN_BINARY_VERSION} -> ${pendingLatest} (run: gbrain self-upgrade)`);
|
||||
}
|
||||
const failedVersions: string[] = cfg?.self_upgrade?.failed_versions ?? [];
|
||||
if (failedVersions.length > 0) {
|
||||
|
||||
@@ -5,15 +5,17 @@
|
||||
* config writes work [CDX-7]. Idempotent: if `search.mode` is already set
|
||||
* (re-init / second run), the picker is skipped entirely.
|
||||
*
|
||||
* TTY flow shows the menu. Non-TTY (CI, scripted init, --mcp-only) writes
|
||||
* `balanced` and prints the one-line hint pointing at `gbrain config set
|
||||
* search.mode`. The mode picker NEVER blocks an init run — readLineSafe
|
||||
* caps at 60s and falls back to `balanced` on timeout / EOF.
|
||||
* TTY flow shows the menu. Non-TTY (CI, scripted init, --mcp-only) applies
|
||||
* the auto-recommendation, prints the cost matrix + an [AGENT] directive to
|
||||
* confirm with the operator, and points at `gbrain config set search.mode`.
|
||||
* The mode picker NEVER blocks an init run — readLineSafe caps at 60s and
|
||||
* falls back to the recommendation on timeout / EOF.
|
||||
*
|
||||
* Smart auto-suggestion: reads models.tier.subagent / models.default /
|
||||
* OPENAI_API_KEY presence + brain size hint to RECOMMEND a mode. The
|
||||
* recommendation is informational only — the user picks. This is the
|
||||
* "agents perfectly tune for user needs" piece at install time.
|
||||
* expansion-capable key presence (Anthropic/OpenAI/Google) + brain size hint
|
||||
* to RECOMMEND a mode. The recommendation is informational only — the user
|
||||
* picks. This is the "agents perfectly tune for user needs" piece at
|
||||
* install time.
|
||||
*/
|
||||
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
@@ -36,8 +38,11 @@ export interface ModePickerInputs {
|
||||
subagentModel?: string | null;
|
||||
/** Configured default model id. */
|
||||
defaultModel?: string | null;
|
||||
/** True iff an OpenAI API key is configured. */
|
||||
hasOpenAIKey?: boolean;
|
||||
/** True iff an expansion-capable API key (Anthropic / OpenAI / Google) is
|
||||
* configured. LLM query expansion routes through the gateway's chat lane,
|
||||
* not the embedding lane — an OpenAI-only gate wrongly told Anthropic-keyed
|
||||
* installs "no LLM expansion possible". */
|
||||
hasExpansionKey?: boolean;
|
||||
/** Approximate page count of the brain (after initSchema, before bulk import). */
|
||||
pageCount?: number;
|
||||
}
|
||||
@@ -50,8 +55,8 @@ export interface ModePickerInputs {
|
||||
* shape per the v0.32.3 install-picker directive):
|
||||
* - Opus / Frontier model OR Sonnet / unknown → tokenmax (max-quality default)
|
||||
* - Haiku subagent → conservative (cost-sensitive setups)
|
||||
* - No OpenAI key configured → conservative (LLM expansion not possible
|
||||
* anyway, so tight budget makes more sense)
|
||||
* - No expansion-capable key (Anthropic/OpenAI/Google) → conservative
|
||||
* (LLM expansion cannot run anyway, so tight budget makes more sense)
|
||||
*
|
||||
* Rationale: the previous "default to balanced unless Opus detected" logic
|
||||
* silently downgraded users who were running Sonnet-tier work and expected
|
||||
@@ -67,10 +72,10 @@ export function recommendModeFor(inputs: ModePickerInputs): { mode: SearchMode;
|
||||
reason: 'Haiku subagent tier detected — tight 4K budget keeps per-call cost down.',
|
||||
};
|
||||
}
|
||||
if (inputs.hasOpenAIKey === false) {
|
||||
if (inputs.hasExpansionKey === false) {
|
||||
return {
|
||||
mode: 'conservative',
|
||||
reason: 'No OpenAI key configured — semantic cache still works, but no LLM expansion possible.',
|
||||
reason: 'No expansion-capable API key (Anthropic/OpenAI/Google) — semantic cache still works, but LLM query expansion cannot run.',
|
||||
};
|
||||
}
|
||||
const opus = /opus/i.test(inputs.defaultModel ?? '') || /opus/i.test(inputs.subagentModel ?? '');
|
||||
@@ -109,7 +114,12 @@ async function resolveInputs(engine: BrainEngine): Promise<ModePickerInputs> {
|
||||
return {
|
||||
subagentModel,
|
||||
defaultModel,
|
||||
hasOpenAIKey: Boolean(process.env.OPENAI_API_KEY),
|
||||
hasExpansionKey: Boolean(
|
||||
process.env.ANTHROPIC_API_KEY ||
|
||||
process.env.OPENAI_API_KEY ||
|
||||
process.env.GOOGLE_GENERATIVE_AI_API_KEY ||
|
||||
process.env.GEMINI_API_KEY, // gateway accepts GEMINI_API_KEY as a first-class alias
|
||||
),
|
||||
pageCount,
|
||||
};
|
||||
}
|
||||
@@ -214,7 +224,9 @@ export async function runModePicker(
|
||||
// 2 stderr lines that agents typically miss / don't relay to the human.
|
||||
// Now: the agent sees the same matrix the human-TTY picker shows, plus
|
||||
// a directive saying "show this to your operator and confirm before
|
||||
// moving on." Default-applied mode is tokenmax (preserves v0.31.x shape).
|
||||
// moving on." The applied default is the recommendation above — the
|
||||
// banner interpolates it (a static "preserves the v0.31.x shape" line
|
||||
// used to claim tokenmax semantics even when conservative was applied).
|
||||
if (!process.stdin.isTTY) {
|
||||
try { await engine.setConfig(SEARCH_MODE_KEY, rec.mode); } catch { /* swallow */ }
|
||||
console.log('');
|
||||
@@ -224,10 +236,10 @@ export async function runModePicker(
|
||||
console.log('═══════════════════════════════════════════════════════════════');
|
||||
console.log('');
|
||||
console.log('[AGENT] Before continuing, SHOW this matrix to your operator and');
|
||||
console.log('[AGENT] ask which mode they want. The default above preserves the');
|
||||
console.log('[AGENT] v0.31.x retrieval shape. Run `gbrain config set search.mode');
|
||||
console.log('[AGENT] <conservative|balanced|tokenmax>` if the operator picks');
|
||||
console.log('[AGENT] differently. See INSTALL_FOR_AGENTS.md Step 3.5 for the');
|
||||
console.log(`[AGENT] ask which mode they want. The default above (${rec.mode}) was`);
|
||||
console.log('[AGENT] auto-selected from the Reason line. Run `gbrain config set');
|
||||
console.log('[AGENT] search.mode <conservative|balanced|tokenmax>` if the operator');
|
||||
console.log('[AGENT] picks differently. See INSTALL_FOR_AGENTS.md Step 3.5 for the');
|
||||
console.log('[AGENT] full protocol.');
|
||||
console.log('');
|
||||
console.log('Per-query cost @ 10K queries/mo (search payload only, no cache savings):');
|
||||
|
||||
@@ -8,13 +8,22 @@
|
||||
* picker's UI and `gbrain providers list` can't drift.
|
||||
*
|
||||
* Trust contract:
|
||||
* - TTY-only. Callers must not invoke this in non-TTY contexts; D3 says
|
||||
* non-TTY with zero keys exits 1 from `resolveAIOptions` before we
|
||||
* reach here. A defensive guard returns null if no TTY anyway.
|
||||
* - Filters candidates to env-ready recipes (codex finding #3). The
|
||||
* picker is for choosing among providers the user CAN run, not for
|
||||
* walking them through key setup.
|
||||
* - On Ctrl-D / EOF / timeout: returns null, caller treats as exit 1.
|
||||
* - TTY-only. Callers must not invoke this in non-TTY contexts (non-TTY
|
||||
* zero-key resolves keyless in `resolveEmbeddingByEnv` before reaching
|
||||
* here). A defensive guard returns null if no TTY anyway.
|
||||
* - Filters candidates to env-ready recipes (codex finding #3), and
|
||||
* probe-gates LOCAL daemons (ollama): daemon-up ≠ model-pulled, so an
|
||||
* unreachable daemon is dropped and a missing model is annotated with
|
||||
* its `ollama pull` fix inline.
|
||||
* - Embedding pickers always offer `0) none — continue keyless`. When no
|
||||
* KEYED provider is ready, keyless is the DEFAULT (bare Enter / 60s
|
||||
* timeout / EOF all resolve to 0 → null), so a local daemon is never
|
||||
* auto-selected. When a keyed provider IS ready the default is `1`, so an
|
||||
* unattended timeout picks that first keyed provider — NOT null; explicit
|
||||
* `0` is still keyless.
|
||||
* - Returns null on the keyless choice (and on invalid input); the embedding
|
||||
* caller continues keyless with a loud notice on BOTH the zero-key and the
|
||||
* multi-key paths (other touchpoints treat null as no-pick).
|
||||
* - When the user picks a non-Anthropic chat-capable recipe AND
|
||||
* `ANTHROPIC_API_KEY` is missing, prints the subagent caveat from D7
|
||||
* BEFORE returning the choice so the user sees the implication.
|
||||
@@ -23,6 +32,7 @@
|
||||
import { listRecipes } from '../core/ai/recipes/index.ts';
|
||||
import { envReady, formatRecipeTable } from './providers.ts';
|
||||
import { readLineSafe } from './init.ts';
|
||||
import { probeOllama, type ProbeResult } from '../core/ai/probes.ts';
|
||||
import type { Recipe } from '../core/ai/types.ts';
|
||||
|
||||
export interface PickedProvider {
|
||||
@@ -46,6 +56,10 @@ export interface PickProviderOpts {
|
||||
isTTY?: boolean;
|
||||
/** Stderr override for tests (capturing prompts). Defaults to process.stderr.write. */
|
||||
writeStderr?: (s: string) => void;
|
||||
/** Local-daemon probe seam (injected for tests; defaults to probeOllama).
|
||||
* Keeps the unit suite off the network — and off any REAL ollama that
|
||||
* happens to be running on the test machine. */
|
||||
probeLocal?: () => Promise<ProbeResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,19 +121,57 @@ export async function pickProvider(opts: PickProviderOpts): Promise<PickedProvid
|
||||
}
|
||||
|
||||
const all = listRecipes();
|
||||
const ready = readyRecipesForTouchpoint(all, opts.touchpoint, env);
|
||||
let ready = readyRecipesForTouchpoint(all, opts.touchpoint, env);
|
||||
|
||||
if (ready.length === 0) {
|
||||
// Probe-gate the ollama daemon: `envReady` treats no-key-required as
|
||||
// ready, but daemon-up ≠ model-pulled — the exact trap that let a keyless
|
||||
// Enter "choose" a broken ollama config and continue silently degraded.
|
||||
// Drop ollama when its daemon doesn't answer; annotate it when the daemon
|
||||
// answers but hasn't pulled the recipe's model. Scoped to ollama — other
|
||||
// local recipes (claude-cli) have no daemon to probe.
|
||||
const localHints = new Map<string, string>();
|
||||
const localRecipes = ready.filter((r) => r.id === 'ollama');
|
||||
if (localRecipes.length > 0) {
|
||||
const probe = opts.probeLocal ?? probeOllama;
|
||||
let probeResult: ProbeResult;
|
||||
try {
|
||||
probeResult = await probe();
|
||||
} catch {
|
||||
probeResult = { reachable: false };
|
||||
}
|
||||
if (!probeResult.models_endpoint_valid) {
|
||||
ready = ready.filter((r) => r.id !== 'ollama');
|
||||
} else {
|
||||
for (const r of localRecipes) {
|
||||
const tp = r.touchpoints[opts.touchpoint];
|
||||
const wanted = tp && 'models' in tp && Array.isArray(tp.models) ? tp.models[0] : undefined;
|
||||
const served = probeResult.models ?? [];
|
||||
if (wanted && !served.some((m) => m === wanted || m.startsWith(`${wanted}:`))) {
|
||||
localHints.set(r.id, `model not pulled — run: ollama pull ${wanted}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keyless is always a valid embedding choice — the brain works with
|
||||
// keyword search + agent-authored memory. Offer it explicitly instead of
|
||||
// forcing a keypress through a provider menu.
|
||||
const keylessOption = opts.touchpoint === 'embedding';
|
||||
|
||||
if (ready.length === 0 && !keylessOption) {
|
||||
writeStderr(`\nNo ${opts.touchpoint}-capable providers are env-ready.\n`);
|
||||
writeStderr('Set one of the env vars below and re-run init:\n\n');
|
||||
writeStderr(formatRecipeTable(all, env) + '\n\n');
|
||||
return null;
|
||||
}
|
||||
|
||||
writeStderr(`\nPick a ${opts.touchpoint} provider (env-ready providers shown):\n\n`);
|
||||
writeStderr(formatRecipeTable(ready, env) + '\n\n');
|
||||
// Article-aware: touchpoint is 'embedding' | 'expansion' | 'chat' — a
|
||||
// hardcoded article renders "an chat provider".
|
||||
const article = /^[aeiou]/i.test(opts.touchpoint) ? 'an' : 'a';
|
||||
writeStderr(`\nPick ${article} ${opts.touchpoint} provider (env-ready providers shown):\n\n`);
|
||||
if (ready.length > 0) writeStderr(formatRecipeTable(ready, env) + '\n\n');
|
||||
|
||||
// Build numbered options
|
||||
// Build numbered options (0 = keyless skip for embedding).
|
||||
const lines = ready.map((r, i) => {
|
||||
const tp = r.touchpoints[opts.touchpoint];
|
||||
let label = ` ${i + 1}) ${r.id}`;
|
||||
@@ -129,21 +181,35 @@ export async function pickProvider(opts: PickProviderOpts): Promise<PickedProvid
|
||||
if (tp && 'models' in tp && Array.isArray(tp.models) && tp.models.length > 0) {
|
||||
label += ` ${tp.models[0]}`;
|
||||
}
|
||||
const hint = localHints.get(r.id);
|
||||
if (hint) label += ` [${hint}]`;
|
||||
return label;
|
||||
});
|
||||
if (keylessOption) {
|
||||
lines.unshift(' 0) none — continue keyless (keyword search; add a key later)');
|
||||
}
|
||||
writeStderr(lines.join('\n') + '\n\n');
|
||||
|
||||
// Default: keyless when no remote (keyed) provider is ready — a bare Enter
|
||||
// must never select a local daemon the user didn't ask for.
|
||||
const hasKeyedReady = ready.some((r) => (r.auth_env?.required ?? []).length > 0);
|
||||
const defaultChoice = keylessOption && !hasKeyedReady ? '0' : '1';
|
||||
const low = keylessOption ? 0 : 1;
|
||||
|
||||
const answer = await readLineSafe(
|
||||
`Choice [1-${ready.length}, default 1]: `,
|
||||
'1',
|
||||
`Choice [${low}-${ready.length}, default ${defaultChoice}]: `,
|
||||
defaultChoice,
|
||||
/* timeoutMs */ 60_000,
|
||||
);
|
||||
|
||||
const choice = parseInt(answer.trim(), 10);
|
||||
if (!Number.isFinite(choice) || choice < 1 || choice > ready.length) {
|
||||
writeStderr(`\nInvalid choice "${answer}". Aborting.\n`);
|
||||
if (!Number.isFinite(choice) || choice < low || choice > ready.length) {
|
||||
writeStderr(`\nInvalid choice "${answer}".\n`);
|
||||
return null;
|
||||
}
|
||||
if (keylessOption && choice === 0) {
|
||||
return null; // caller continues keyless with its own notice
|
||||
}
|
||||
|
||||
const picked = ready[choice - 1];
|
||||
const tp = picked.touchpoints[opts.touchpoint];
|
||||
|
||||
+126
-51
@@ -491,9 +491,16 @@ export async function findEnvKeyTypos(
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Emit the fail-loud "no embedding provider" message + paste-ready setup. */
|
||||
/** Emit the "no embedding provider" message + paste-ready setup. Keyless
|
||||
* continue leads (it always works); key setup follows for the upgrade. */
|
||||
function printNoEmbeddingProviderHint(typos: Array<{ userSet: string; suggested: string }>): void {
|
||||
console.error('\nNo embedding provider configured. Set one of:');
|
||||
console.error('\nNo embedding provider configured.');
|
||||
console.error('Continue without one (keyless — keyword search + memory your agent writes):');
|
||||
console.error(' gbrain init --pglite --no-embedding');
|
||||
console.error(' (enable semantic search later by re-running with a key:');
|
||||
console.error(' gbrain init --force --pglite --embedding-model <id>)');
|
||||
console.error('');
|
||||
console.error('Or set a key for semantic search:');
|
||||
console.error(' export OPENAI_API_KEY=sk-… # openai:text-embedding-3-large (1536d)');
|
||||
console.error(' export ZEROENTROPY_API_KEY=ze-… # zeroentropyai:zembed-1 (2560d, Matryoshka)');
|
||||
console.error(' export VOYAGE_API_KEY=pa-… # voyage:voyage-3-large (1024d)');
|
||||
@@ -501,9 +508,6 @@ function printNoEmbeddingProviderHint(typos: Array<{ userSet: string; suggested:
|
||||
console.error('');
|
||||
console.error('Or pick explicitly:');
|
||||
console.error(' gbrain init --pglite --embedding-model openai:text-embedding-3-large');
|
||||
console.error('');
|
||||
console.error('Or defer setup: gbrain init --pglite --no-embedding');
|
||||
console.error(' (you can configure later with `gbrain config set embedding_model <id>`)');
|
||||
// D13: surface near-miss env vars (e.g. OPENAPI_API_KEY → OPENAI_API_KEY).
|
||||
if (typos.length > 0) {
|
||||
console.error('');
|
||||
@@ -513,6 +517,19 @@ function printNoEmbeddingProviderHint(typos: Array<{ userSet: string; suggested:
|
||||
}
|
||||
}
|
||||
|
||||
/** Loud keyless-continue notice for the no-keys default path. The upgrade
|
||||
* command is `init --force` re-init, NOT `config set embedding_model` — that
|
||||
* key is a schema-sizing file-plane field that `gbrain config set` refuses
|
||||
* (it would be a silent no-op), so pointing users there is a dead end. */
|
||||
function printKeylessContinueNotice(): void {
|
||||
console.error(
|
||||
'No embedding provider keys detected — continuing in keyless mode:\n' +
|
||||
' keyword search + memory your agent writes down itself. Everything works.\n' +
|
||||
' One optional key upgrades search to semantic — set the key, then re-run\n' +
|
||||
' `gbrain init --force --pglite --embedding-model <id>` (re-imports via `gbrain sync`).',
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveEmbeddingByEnv(out: ResolvedAIOptions, nonInteractive: boolean): Promise<void> {
|
||||
const ready = await groupReadyByProvider('embedding');
|
||||
const isTTY = !nonInteractive && !!process.stdin.isTTY;
|
||||
@@ -548,28 +565,56 @@ async function resolveEmbeddingByEnv(out: ResolvedAIOptions, nonInteractive: boo
|
||||
}
|
||||
}
|
||||
|
||||
// Zero or multi — pick or fail loud.
|
||||
// Zero keys — keyless is a first-class posture (the whole paste-in
|
||||
// bootstrap runs on it), so the DEFAULT is to continue keyless with a loud
|
||||
// notice, not exit 1. Fail-loud survives in exactly one zero-key case: a
|
||||
// near-miss env var (OPENAPI_API_KEY → OPENAI_API_KEY) signals the user
|
||||
// MEANT to configure a key — completing keyless there would silently bury
|
||||
// their typo.
|
||||
if (ready.length === 0) {
|
||||
if (!isTTY) {
|
||||
const typos = await findEnvKeyTypos();
|
||||
const typos = await findEnvKeyTypos();
|
||||
if (typos.length > 0) {
|
||||
printNoEmbeddingProviderHint(typos);
|
||||
process.exit(1);
|
||||
}
|
||||
// TTY → picker; on null (user aborted) still fail loud.
|
||||
if (!isTTY) {
|
||||
printKeylessContinueNotice();
|
||||
out.noEmbedding = true;
|
||||
return;
|
||||
}
|
||||
// TTY → picker (local providers like ollama may be selectable); a null
|
||||
// pick (nothing offered, user skipped, or EOF) continues keyless.
|
||||
const { pickProvider } = await import('./init-provider-picker.ts');
|
||||
const picked = await pickProvider({ touchpoint: 'embedding', env: process.env, isTTY: true });
|
||||
if (!picked) {
|
||||
const typos = await findEnvKeyTypos();
|
||||
printNoEmbeddingProviderHint(typos);
|
||||
process.exit(1);
|
||||
printKeylessContinueNotice();
|
||||
out.noEmbedding = true;
|
||||
return;
|
||||
}
|
||||
out.embedding_model = picked.fullModel;
|
||||
out.embedding_dimensions = picked.dim;
|
||||
return;
|
||||
}
|
||||
|
||||
// ready.length > 1 — picker (TTY) or fail-loud (non-TTY) per D2/D3.
|
||||
// ready.length > 1 — picker (TTY); non-TTY auto-picks the canonical default
|
||||
// when its key is present (the most common agent/dev setup is 2+ provider
|
||||
// keys — failing there blocked scripted installs), else fail-loud per D2/D3
|
||||
// (a genuinely ambiguous set with no canonical candidate stays explicit).
|
||||
if (!isTTY) {
|
||||
const { DEFAULT_EMBEDDING_MODEL, DEFAULT_EMBEDDING_DIMENSIONS } =
|
||||
await import('../core/ai/defaults.ts');
|
||||
const canonicalProvider = DEFAULT_EMBEDDING_MODEL.split(':')[0];
|
||||
const canonical = ready.find((p) => p.recipeId === canonicalProvider);
|
||||
if (canonical) {
|
||||
out.embedding_model = DEFAULT_EMBEDDING_MODEL;
|
||||
out.embedding_dimensions = DEFAULT_EMBEDDING_DIMENSIONS;
|
||||
console.error(
|
||||
`Multiple embedding providers env-ready (${ready.map(p => p.recipeId).join(', ')}). ` +
|
||||
`Using the default ${DEFAULT_EMBEDDING_MODEL} (${DEFAULT_EMBEDDING_DIMENSIONS}d). ` +
|
||||
`Override with --embedding-model.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.error(`Multiple embedding providers env-ready: ${ready.map(p => p.recipeId).join(', ')}.`);
|
||||
console.error(`Disambiguate by passing --embedding-model <provider>:<model>, or unset extra env vars.`);
|
||||
process.exit(1);
|
||||
@@ -577,8 +622,13 @@ async function resolveEmbeddingByEnv(out: ResolvedAIOptions, nonInteractive: boo
|
||||
const { pickProvider } = await import('./init-provider-picker.ts');
|
||||
const picked = await pickProvider({ touchpoint: 'embedding', env: process.env, isTTY: true });
|
||||
if (!picked) {
|
||||
console.error('Init aborted: no embedding provider picked.');
|
||||
process.exit(1);
|
||||
// The embedding picker offers an explicit "0) none — continue keyless"
|
||||
// option (and returns null on it). Honor that instead of aborting: a user
|
||||
// with multiple keys who deliberately chose keyless gets keyless, matching
|
||||
// the zero-key path. (Ctrl-D / EOF / invalid also land here → keyless.)
|
||||
printKeylessContinueNotice();
|
||||
out.noEmbedding = true;
|
||||
return;
|
||||
}
|
||||
out.embedding_model = picked.fullModel;
|
||||
out.embedding_dimensions = picked.dim;
|
||||
@@ -1080,18 +1130,16 @@ async function initPGLite(opts: {
|
||||
} else {
|
||||
console.log(`\nBrain ready at ${dbPath}`);
|
||||
console.log(`${stats.page_count} pages. Engine: PGLite (local Postgres).`);
|
||||
// Reference/status blocks print FIRST and terse; the ONE primary action
|
||||
// (the memory demo) prints LAST so it is the final, unmistakable thing on
|
||||
// screen. Krug: one obvious next action, everything else subordinate.
|
||||
if (stats.page_count > 0) {
|
||||
console.log('');
|
||||
console.log('Existing brain detected. To wire up the v0.10.3 knowledge graph:');
|
||||
console.log('Existing brain detected. Wire up the knowledge graph:');
|
||||
console.log(' gbrain extract links --source db (typed link backfill)');
|
||||
console.log(' gbrain extract timeline --source db (structured timeline backfill)');
|
||||
console.log(' gbrain stats (verify links > 0)');
|
||||
} else {
|
||||
console.log('Next: gbrain import <dir>');
|
||||
}
|
||||
printMemoryVerbsQuickstart();
|
||||
console.log('');
|
||||
console.log('When you outgrow local: gbrain migrate --to supabase');
|
||||
reportModStatus();
|
||||
const { printAdvisoryIfRecommended } = await import('../core/skillpack/post-install-advisory.ts');
|
||||
const { VERSION } = await import('../version.ts');
|
||||
@@ -1101,6 +1149,9 @@ async function initPGLite(opts: {
|
||||
// Fail-open; 3s wallclock cap. Skipped silently in non-TTY contexts.
|
||||
const { runInitNudge } = await import('../core/onboard/init-nudge.ts');
|
||||
await runInitNudge(engine);
|
||||
|
||||
// The single primary action, last-on-screen.
|
||||
printMemoryVerbsQuickstart({ emptyBrain: stats.page_count === 0, onPglite: true });
|
||||
}
|
||||
} finally {
|
||||
try { await engine.disconnect(); } catch { /* best-effort */ }
|
||||
@@ -1108,23 +1159,33 @@ async function initPGLite(opts: {
|
||||
}
|
||||
|
||||
/**
|
||||
* MEMORY_VERBS v1 quickstart funnel (E3 + D4B + T1 consent). Printed at the
|
||||
* end of both init epilogues. The copy-next block is EXACTLY three commands
|
||||
* (codex DX 9): wire the harness, write a memory, prove the resurrection.
|
||||
* The demo uses the facts arm only, so it works with NO embedding key [F-B].
|
||||
* MEMORY_VERBS v1 quickstart funnel (E3 + D4B + T1 consent). Printed LAST in
|
||||
* both init epilogues as the ONE primary action. The copy-next block is
|
||||
* EXACTLY three commands (codex DX 9): wire the harness, write a memory, prove
|
||||
* the resurrection. The demo uses the facts arm only, so it works with NO
|
||||
* embedding key [F-B]. Secondary paths (import, migrate) ride a single terse
|
||||
* "More:" footer so they never compete with the primary action.
|
||||
*/
|
||||
function printMemoryVerbsQuickstart(): void {
|
||||
function printMemoryVerbsQuickstart(opts: { emptyBrain?: boolean; onPglite?: boolean } = {}): void {
|
||||
console.log('');
|
||||
console.log('Give your agent memory (copy these three commands):');
|
||||
console.log('→ Do this next — give your agent memory (copy these three commands):');
|
||||
console.log(' claude mcp add gbrain -- gbrain serve --surface verbs');
|
||||
console.log(' gbrain remember "I prefer dark mode in every editor" --provenance demo --entity people/me');
|
||||
console.log(' gbrain recall --entity people/me');
|
||||
console.log('Now ask your agent in a NEW session — it remembers.');
|
||||
console.log('Then ask your agent in a NEW session — it remembers.');
|
||||
console.log('');
|
||||
console.log('Note: memories agents save are readable by every agent connected to');
|
||||
console.log('this brain; use visibility:"private" for local-only facts.');
|
||||
console.log('Other harnesses (Codex, OpenClaw): docs/protocol/MEMORY_VERBS_v1.md');
|
||||
console.log('If `claude` is not found: install Claude Code first, or use the per-harness blocks in that doc.');
|
||||
// Secondary paths, one line, clearly subordinate to the action above.
|
||||
console.log('');
|
||||
console.log(
|
||||
'More: ' +
|
||||
(opts.emptyBrain ? 'bulk-load notes `gbrain import <dir>` · ' : '') +
|
||||
(opts.onPglite ? 'scale up `gbrain migrate --to supabase` · ' : '') +
|
||||
'health `gbrain doctor`',
|
||||
);
|
||||
}
|
||||
|
||||
async function initPostgres(opts: {
|
||||
@@ -1350,14 +1411,11 @@ async function initPostgres(opts: {
|
||||
console.log(`\nBrain ready. ${stats.page_count} pages. Engine: Postgres (Supabase).`);
|
||||
if (stats.page_count > 0) {
|
||||
console.log('');
|
||||
console.log('Existing brain detected. To wire up the v0.10.3 knowledge graph:');
|
||||
console.log('Existing brain detected. Wire up the knowledge graph:');
|
||||
console.log(' gbrain extract links --source db (typed link backfill)');
|
||||
console.log(' gbrain extract timeline --source db (structured timeline backfill)');
|
||||
console.log(' gbrain stats (verify links > 0)');
|
||||
} else {
|
||||
console.log('Next: gbrain import <dir>');
|
||||
}
|
||||
printMemoryVerbsQuickstart();
|
||||
reportModStatus();
|
||||
const { printAdvisoryIfRecommended } = await import('../core/skillpack/post-install-advisory.ts');
|
||||
const { VERSION } = await import('../version.ts');
|
||||
@@ -1367,6 +1425,9 @@ async function initPostgres(opts: {
|
||||
// Fail-open; 3s wallclock cap. Skipped silently in non-TTY contexts.
|
||||
const { runInitNudge } = await import('../core/onboard/init-nudge.ts');
|
||||
await runInitNudge(engine);
|
||||
|
||||
// The single primary action, last-on-screen.
|
||||
printMemoryVerbsQuickstart({ emptyBrain: stats.page_count === 0 });
|
||||
}
|
||||
} finally {
|
||||
try { await engine.disconnect(); } catch { /* best-effort */ }
|
||||
@@ -1402,6 +1463,15 @@ function countMarkdownFiles(dir: string, maxScan = 1500): number {
|
||||
}
|
||||
|
||||
async function supabaseWizard(): Promise<string> {
|
||||
// Non-TTY guard: without a terminal the URL prompt below can never be
|
||||
// answered — the legacy behavior was a silent exit-0 no-op (stdin closed →
|
||||
// readLine never resolved data → process ended with NO config written), the
|
||||
// worst failure shape for a scripted/agent caller. Fail loud with the fix.
|
||||
if (!process.stdin.isTTY) {
|
||||
console.error('gbrain init --supabase needs an interactive terminal to prompt for the connection URL.');
|
||||
console.error('Non-interactive: pass --url <connection_string>, or set GBRAIN_DATABASE_URL and use --non-interactive.');
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
execSync('bunx supabase --version', { stdio: 'pipe' });
|
||||
console.log('Supabase CLI detected.');
|
||||
@@ -1427,12 +1497,21 @@ function readLine(prompt: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
process.stdout.write(prompt);
|
||||
let data = '';
|
||||
let settled = false;
|
||||
const settle = (value: string) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
process.stdin.pause();
|
||||
resolve(value);
|
||||
};
|
||||
process.stdin.setEncoding('utf-8');
|
||||
process.stdin.once('data', (chunk) => {
|
||||
data = chunk.toString().trim();
|
||||
process.stdin.pause();
|
||||
resolve(data);
|
||||
settle(data);
|
||||
});
|
||||
// EOF (Ctrl-D mid-prompt) resolves empty instead of hanging — the caller's
|
||||
// "No URL provided." guard then fails loud.
|
||||
process.stdin.once('end', () => settle(''));
|
||||
process.stdin.resume();
|
||||
});
|
||||
}
|
||||
@@ -1578,23 +1657,18 @@ export function reportModStatus(): void {
|
||||
skillCount = manifest.skills?.length || 0;
|
||||
} catch { /* manifest not found */ }
|
||||
|
||||
// One line per fact, one pointer per optional extra — this block sits on
|
||||
// the init success screen, where every extra call-to-action competes with
|
||||
// the memory-verbs funnel (the one action that matters). Krug: one screen,
|
||||
// one primary action.
|
||||
console.log('');
|
||||
console.log('--- GBrain Mod Status ---');
|
||||
console.log(`Skills: ${skillCount} loaded`);
|
||||
console.log(`GStack: ${gstack.found ? `found (${gstack.host})` : 'not found'}`);
|
||||
if (!gstack.found) {
|
||||
console.log(' Install GStack for coding skills:');
|
||||
console.log(' git clone https://github.com/garrytan/gstack.git ~/.claude/skills/gstack');
|
||||
console.log(' cd ~/.claude/skills/gstack && ./setup');
|
||||
}
|
||||
console.log('Resolver: skills/RESOLVER.md');
|
||||
console.log('Soul audit: ask your agent to "run a soul audit" to customize its identity (see skills/soul-audit)');
|
||||
console.log(`Skills: ${skillCount} loaded (router: skills/RESOLVER.md)`);
|
||||
console.log(`GStack: ${gstack.found ? `found (${gstack.host})` : 'not found (coding skills — see github.com/garrytan/gstack)'}`);
|
||||
// Retrieval Reflex (#1981): the deterministic pointer layer is ON by default
|
||||
// (no action needed). The policy skill is installed into the HOST repo on
|
||||
// request — we PRINT the command rather than silently mutating the host repo.
|
||||
console.log('Retrieval reflex: on by default (entity pointers injected per turn)');
|
||||
console.log(' Install the policy skill into your agent repo:');
|
||||
console.log(' gbrain integrations install retrieval-reflex --target <host-repo>');
|
||||
// request — we PRINT the pointer rather than silently mutating the host repo.
|
||||
console.log('Retrieval reflex: on by default. More: `gbrain integrations` (policy skill), skills/soul-audit (identity).');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
@@ -1606,7 +1680,7 @@ USAGE
|
||||
gbrain init [flags]
|
||||
|
||||
ENGINE SELECTION (mutually exclusive)
|
||||
--pglite Use embedded PGLite (zero-config, default for <1000 .md files)
|
||||
--pglite Use embedded PGLite (zero-config, the default)
|
||||
--supabase Use Supabase Postgres (recommended for 1000+ files)
|
||||
--url <URL> Use a manual Postgres connection string
|
||||
--mcp-only Thin-client mode: connect to a remote gbrain MCP, no local engine
|
||||
@@ -1639,9 +1713,10 @@ EXAMPLES
|
||||
gbrain init --mcp-only --url https://... # Thin-client mode
|
||||
|
||||
NOTES
|
||||
- Bare \`gbrain init\` in a directory with 1000+ .md files defaults to Supabase
|
||||
interactive setup. With <1000 files (or with --pglite explicitly), defaults
|
||||
to PGLite at ~/.gbrain/brain.pglite.
|
||||
- Bare \`gbrain init\` always defaults to PGLite at ~/.gbrain/brain.pglite.
|
||||
In a directory with 1000+ .md files it prints a suggestion to use
|
||||
\`gbrain init --supabase\` (faster search at scale) but still proceeds
|
||||
with PGLite.
|
||||
- Existing config is preserved unless --force is passed.
|
||||
`.trim());
|
||||
}
|
||||
|
||||
@@ -6,21 +6,16 @@
|
||||
* the self-upgrade refresh path.
|
||||
*/
|
||||
|
||||
import { readUpdateCache } from '../self-upgrade.ts';
|
||||
import { pendingUpgradeVersion } from '../self-upgrade.ts';
|
||||
import type { AdvisorCollector } from './types.ts';
|
||||
|
||||
export const collectVersion: AdvisorCollector = {
|
||||
id: 'version',
|
||||
collect: async (ctx) => {
|
||||
let latest: string | undefined;
|
||||
try {
|
||||
const entry = readUpdateCache();
|
||||
if (entry && entry.marker.kind === 'upgrade_available' && entry.marker.latest) {
|
||||
latest = entry.marker.latest;
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
// Shared stale/foreign-cache guard: fresh cache only, and only an upgrade
|
||||
// strictly newer than the RUNNING version (pendingUpgradeVersion owns the
|
||||
// rule; never throws).
|
||||
const latest = pendingUpgradeVersion(ctx.version, Date.now());
|
||||
if (!latest) return [];
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -16,6 +16,14 @@ export interface RecommendedSkill {
|
||||
}
|
||||
|
||||
export const RECOMMENDED: RecommendedSkill[] = [
|
||||
{
|
||||
// First on purpose: the day-one "now what?" answer. Every other skill in
|
||||
// this list gets dramatically better once the brain holds the user's real
|
||||
// life — cold-start is what fills it.
|
||||
slug: 'cold-start',
|
||||
description:
|
||||
'START HERE. Day-one brain filling: imports your Gmail, calendar, and contacts (via ClawVisor — an OAuth vault, the agent never holds raw tokens) or offline archives, one consented phase at a time. Run it right after install.',
|
||||
},
|
||||
{
|
||||
slug: 'book-mirror',
|
||||
description:
|
||||
|
||||
+18
-3
@@ -6,6 +6,10 @@
|
||||
export interface ProbeResult {
|
||||
reachable: boolean;
|
||||
models_endpoint_valid?: boolean;
|
||||
/** Model ids the endpoint reports as served/pulled (when the models
|
||||
* endpoint is valid). Lets callers check "is the recipe's model actually
|
||||
* available" instead of treating daemon-up as model-ready. */
|
||||
models?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -22,14 +26,25 @@ export async function probeOpenAICompat(baseUrl: string, timeoutMs: number = 100
|
||||
signal: controller.signal,
|
||||
headers: { accept: 'application/json' },
|
||||
});
|
||||
clearTimeout(timer);
|
||||
if (!res.ok) return { reachable: true, models_endpoint_valid: false, error: `HTTP ${res.status}` };
|
||||
if (!res.ok) {
|
||||
clearTimeout(timer);
|
||||
return { reachable: true, models_endpoint_valid: false, error: `HTTP ${res.status}` };
|
||||
}
|
||||
// Keep the abort timer live through the BODY read — a daemon that accepts,
|
||||
// returns headers, then stalls the body would otherwise hang past the
|
||||
// advertised timeout (the probe sits on init's interactive critical path).
|
||||
const body = await res.json().catch(() => null);
|
||||
clearTimeout(timer);
|
||||
if (!body || typeof body !== 'object') {
|
||||
return { reachable: true, models_endpoint_valid: false, error: 'non-JSON response' };
|
||||
}
|
||||
const isList = (body as any).object === 'list' && Array.isArray((body as any).data);
|
||||
return { reachable: true, models_endpoint_valid: isList };
|
||||
const models = isList
|
||||
? ((body as any).data as Array<{ id?: unknown }>)
|
||||
.map((m) => (typeof m?.id === 'string' ? m.id : ''))
|
||||
.filter(Boolean)
|
||||
: undefined;
|
||||
return { reachable: true, models_endpoint_valid: isList, models };
|
||||
} catch (e) {
|
||||
clearTimeout(timer);
|
||||
return { reachable: false, error: e instanceof Error ? e.message : String(e) };
|
||||
|
||||
+19
-15
@@ -10,8 +10,9 @@
|
||||
* and dedupe match on the marker (surviving reordering and command-string
|
||||
* drift), and foreign hooks / permissions / every other settings key are
|
||||
* never touched. Writes are atomic (tmp + rename) with a `.bak` of the
|
||||
* previous file; a parse-broken existing file is backed up aside and the
|
||||
* write starts clean with a loud note in the result [G5].
|
||||
* previous file; a parse-broken existing file ABORTS the write with
|
||||
* fix-and-re-run instructions (fail-closed, matching removal's stance — a
|
||||
* rewrite could drop permissions/allowlist entries gbrain cannot parse) [G5].
|
||||
*
|
||||
* MCP registration helpers BUILD ARGV ONLY — the bootstrap dispatcher execs
|
||||
* them (and records the registration in the install receipt). Precedent:
|
||||
@@ -70,7 +71,9 @@ export interface WriteClaudeHooksResult {
|
||||
removedPrior: number;
|
||||
/** `.bak` of the pre-write file (null when no file existed). */
|
||||
backupPath: string | null;
|
||||
/** Where a parse-broken original was moved (null when parse succeeded). */
|
||||
/** Always null since the fail-closed change (a parse-broken file now
|
||||
* aborts the write instead of being moved aside). Kept for result-shape
|
||||
* stability. */
|
||||
brokenBackupPath: string | null;
|
||||
notes: string[];
|
||||
}
|
||||
@@ -247,10 +250,13 @@ interface LoadedSettings {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the existing settings file. Absent/empty → `{}`. Parse error → the
|
||||
* broken file is MOVED to a timestamped `.broken-*` backup and the caller
|
||||
* starts clean, with a loud note (the user's broken-by-hand file is never
|
||||
* silently destroyed, and never silently half-merged) [G5].
|
||||
* Parse the existing settings file. Absent/empty → `{}`. Parse error →
|
||||
* THROW, fail-closed [G5]: the file may carry permissions/allowlist entries
|
||||
* gbrain cannot see, so replacing it with a fresh file (the old behavior —
|
||||
* backup + start clean) silently dropped the user's live settings. Removal
|
||||
* (`removeClaudeHooks`) already refuses to touch what it cannot parse; the
|
||||
* write path now matches that stance. The user fixes the JSON, re-runs, and
|
||||
* the structural merge preserves everything.
|
||||
*/
|
||||
function loadSettings(path: string): LoadedSettings {
|
||||
const notes: string[] = [];
|
||||
@@ -273,14 +279,12 @@ function loadSettings(path: string): LoadedSettings {
|
||||
}
|
||||
return { settings: parsed as SettingsObject, existed: true, brokenBackupPath: null, notes };
|
||||
} catch (e) {
|
||||
const broken = `${path}.broken-${Date.now()}`;
|
||||
copyFileSync(path, broken);
|
||||
notes.push(
|
||||
`WARNING: ${path} was not valid JSON (${(e as Error).message}); ` +
|
||||
`the original was backed up to ${broken} and hooks were written to a fresh file. ` +
|
||||
`Restore any hand-made settings from the backup.`,
|
||||
throw new Error(
|
||||
`${path} is not valid JSON (${(e as Error).message}) — refusing to rewrite a settings file ` +
|
||||
`gbrain cannot parse (it may carry your permissions/allowlist entries). Fix the JSON by ` +
|
||||
`hand, then re-run \`gbrain bootstrap hooks --harness claude-code --repair\` ` +
|
||||
`(the structural merge preserves your settings).`,
|
||||
);
|
||||
return { settings: {}, existed: true, brokenBackupPath: broken, notes };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,7 +370,7 @@ export function writeClaudeHooks(
|
||||
settings.hooks = hooks;
|
||||
|
||||
let backupPath: string | null = null;
|
||||
if (existed && brokenBackupPath === null) {
|
||||
if (existed) {
|
||||
backupPath = `${settingsPath}.bak`;
|
||||
copyFileSync(settingsPath, backupPath);
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ function requiredKeys(bank: QuestionBank): string[] {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SetAnswerResult =
|
||||
| { ok: true; sink: 'state'; key: string; value: string }
|
||||
| { ok: true; sink: 'state'; key: string; value: string; invalidatedConfirmation?: boolean }
|
||||
/** Config-sink keys [CX2-13]: nothing persisted here; the caller routes the
|
||||
* value via `routeProviderKeyToConfig`. The value is deliberately NOT
|
||||
* echoed back in this result. */
|
||||
@@ -305,20 +305,23 @@ export function setAnswer(workspaceDir: string, key: string, rawValue: string):
|
||||
if (!read.ok) return read;
|
||||
const state = read.state;
|
||||
state.answers[key] = { value: stored, set_at: new Date().toISOString() };
|
||||
// Any change invalidates a prior read-back confirmation [A8].
|
||||
// Any change invalidates a prior read-back confirmation [A8]. Surfaced to
|
||||
// the caller so the CLI can WARN — silently voiding the confirmation used
|
||||
// to fail much later, at render, with no pointer back to this --set.
|
||||
const invalidatedConfirmation = state.confirmed !== undefined;
|
||||
delete state.confirmed;
|
||||
try {
|
||||
writeInterviewState(workspaceDir, state);
|
||||
} catch (e) {
|
||||
return { ok: false, code: 'io_error', message: `could not write interview state: ${(e as Error).message}` };
|
||||
}
|
||||
return { ok: true, sink: 'state', key, value: stored };
|
||||
return { ok: true, sink: 'state', key, value: stored, invalidatedConfirmation };
|
||||
}
|
||||
|
||||
export function skipAnswer(
|
||||
workspaceDir: string,
|
||||
key: string
|
||||
): { ok: true; sink: 'state' | 'config'; key: string; skipped: true } | InterviewError {
|
||||
): { ok: true; sink: 'state' | 'config'; key: string; skipped: true; invalidatedConfirmation?: boolean } | InterviewError {
|
||||
const bank = loadQuestionBank();
|
||||
const spec = bankSpec(bank, key);
|
||||
if (!spec) {
|
||||
@@ -339,13 +342,15 @@ export function skipAnswer(
|
||||
if (!read.ok) return read;
|
||||
const state = read.state;
|
||||
state.answers[key] = { value: '', set_at: new Date().toISOString(), skipped: true };
|
||||
// Same [A8] invalidation-surfacing as setAnswer.
|
||||
const invalidatedConfirmation = state.confirmed !== undefined;
|
||||
delete state.confirmed;
|
||||
try {
|
||||
writeInterviewState(workspaceDir, state);
|
||||
} catch (e) {
|
||||
return { ok: false, code: 'io_error', message: `could not write interview state: ${(e as Error).message}` };
|
||||
}
|
||||
return { ok: true, sink: 'state', key, skipped: true };
|
||||
return { ok: true, sink: 'state', key, skipped: true, invalidatedConfirmation };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -270,7 +270,20 @@ export const PHASES: PhaseSpec[] = [
|
||||
detect: (ws, ctx) => {
|
||||
const regs = ctx.receipt?.registrations ?? [];
|
||||
if (regs.length > 0) {
|
||||
return { state: 'done', detail: regs.map((r) => `${r.host} (${r.scope})`).join(', ') };
|
||||
// A registration whose detail carries 'mcp' ('mcp' or 'mcp+hooks')
|
||||
// means MCP actually registered. A 'hooks'-only detail means the host
|
||||
// binary was missing at wire time (hooks landed, MCP did not) — the
|
||||
// phase is PARTIAL, not done, so a resuming agent re-runs it once the
|
||||
// CLI is on PATH instead of trusting a false "done".
|
||||
const mcpRegistered = regs.some((r) => (r.detail ?? '').includes('mcp'));
|
||||
if (mcpRegistered) {
|
||||
return { state: 'done', detail: regs.map((r) => `${r.host} (${r.scope})`).join(', ') };
|
||||
}
|
||||
return {
|
||||
state: 'partial',
|
||||
detail: 'hooks installed but MCP not registered (the harness CLI was not on PATH) — ' +
|
||||
're-run `gbrain bootstrap hooks --harness <claude-code|codex>` once it is',
|
||||
};
|
||||
}
|
||||
if (hooksInstalled(ws)) return { state: 'done', detail: 'hooks present in .claude/settings.local.json' };
|
||||
return { state: 'pending' };
|
||||
|
||||
@@ -75,6 +75,9 @@ export interface VerifyReport {
|
||||
capability: CapabilityReport;
|
||||
/** The three scripted first-run prompts [D3.6/A4]. */
|
||||
tour: string[];
|
||||
/** The OOBE hand-off lines (ownership + the cold-start next action) —
|
||||
* unconditional in the shape like `tour`; printed in the report on PASS. */
|
||||
handoff: string[];
|
||||
}
|
||||
|
||||
export interface VerifyOpts {
|
||||
@@ -97,11 +100,15 @@ export const VERIFY_PROBE_ENTITY_SLUG = 'wiki/bootstrap-verify-probe-entity';
|
||||
/** Deterministic magic-moment token the fence fact carries [CX-P0.5]. */
|
||||
export const VERIFY_MAGIC_TOKEN = 'verify-lighthouse-passphrase';
|
||||
|
||||
/** The three scripted first-run prompts [D3.6] — pinned by the A4 snapshot test. */
|
||||
/** The three scripted first-run prompts [D3.6] — pinned by the A4 snapshot test.
|
||||
* Exactly three (the count is copy-pinned here, in BOOTSTRAP_FOR_AGENTS.md,
|
||||
* and the A4 plan). Prompt 3 must be TRUE on day one — the brain is empty at
|
||||
* install, so "everything ingested so far" would be an anticlimax; the
|
||||
* round-trip fact from prompt 2 is the honest day-one payoff. */
|
||||
export const FIRST_RUN_TOUR: readonly string[] = [
|
||||
'"Who am I to you?" — identity from SOUL.md/USER.md, no lookup needed.',
|
||||
'"Remember that <one small true fact>." Then restart the session and ask me about it — that round-trip is the whole product.',
|
||||
'"What do you know about this project?" — brain recall over everything ingested so far.',
|
||||
'"Remember that <one small true fact>." — it lands in the brain, not this chat.',
|
||||
'"What do you remember about me?" — asked in the NEW session: on day one that is the fact from prompt 2, recalled from the brain. Every session after this adds more. That round-trip is the whole product.',
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -948,9 +955,11 @@ export async function verifyWorkspace(
|
||||
|
||||
checks.push(checkPushProbe(ws));
|
||||
checks.push(checkInertSkills(ws, caps));
|
||||
checks.push({ id: 'first_run_tour', ok: true, detail: 'three scripted prompts appended to the report [D3.6]' });
|
||||
const tourCheck = { id: 'first_run_tour', ok: true, detail: 'three scripted prompts appended to the report' };
|
||||
checks.push(tourCheck);
|
||||
|
||||
const ok = checks.every((c) => c.ok || c.warn === true);
|
||||
if (!ok) tourCheck.detail = 'tour withheld — prints on PASS';
|
||||
|
||||
const ts = new Date().toISOString();
|
||||
persistVerifyRun(gbrainHomeDir, { ts, ok, checks });
|
||||
@@ -964,8 +973,57 @@ export async function verifyWorkspace(
|
||||
lines.push('');
|
||||
lines.push(renderCapabilityReport(caps));
|
||||
lines.push('');
|
||||
lines.push('First-run tour — hand these three prompts to your human, in order:');
|
||||
FIRST_RUN_TOUR.forEach((p, i) => lines.push(` ${i + 1}. ${p}`));
|
||||
// The tour celebrates a WORKING install — under a FAIL banner it reads as
|
||||
// a mixed signal ("broken, but go enjoy it"). Gate the report lines on ok;
|
||||
// the returned `tour` array (and --json field) stays unconditional so
|
||||
// machine consumers keep a stable shape.
|
||||
const handoff = buildHandoff(ws);
|
||||
if (ok) {
|
||||
lines.push('First-run tour — have your human RESTART the session first');
|
||||
lines.push('(a fresh session proves the files and the brain, not this chat),');
|
||||
lines.push('then try these three prompts in order:');
|
||||
FIRST_RUN_TOUR.forEach((p, i) => lines.push(` ${i + 1}. ${p}`));
|
||||
lines.push('');
|
||||
for (const h of handoff) lines.push(h);
|
||||
} else {
|
||||
lines.push('Fix the FAIL checks above and re-run — the first-run tour prints on PASS.');
|
||||
}
|
||||
|
||||
return { ok, checks, report: lines.join('\n'), capability: caps, tour: [...FIRST_RUN_TOUR] };
|
||||
return { ok, checks, report: lines.join('\n'), capability: caps, tour: [...FIRST_RUN_TOUR], handoff };
|
||||
}
|
||||
|
||||
/**
|
||||
* The post-tour hand-off block [OOBE]: the two things a fresh user must walk
|
||||
* away UNDERSTANDING, in priority order —
|
||||
* 1. OWNERSHIP: the brain is markdown in a repo THEY own (or local-only,
|
||||
* with the one command that gives it a durable home). Ownership is the
|
||||
* trust story; say the URL, say what owning it means.
|
||||
* 2. THE ONE NEXT ACTION: run the cold-start skill. An empty brain is a
|
||||
* database; every flagship skill (book-mirror, briefings, meeting prep)
|
||||
* only becomes magical once the brain holds the user's real life —
|
||||
* cold-start is the designed filler (Gmail/calendar/contacts via
|
||||
* ClawVisor, or offline archives), one consented phase at a time.
|
||||
* Returned unconditionally in the machine shape (like `tour`); printed in
|
||||
* the report only on PASS. Relay it to the human verbatim.
|
||||
*/
|
||||
export function buildHandoff(ws: string): string[] {
|
||||
const origin = gitOriginUrl(ws);
|
||||
const ownership = origin
|
||||
? [
|
||||
`What you own: every memory your agent keeps is a markdown file in YOUR private repo — ${origin}.`,
|
||||
'Read it any time, take it to a second machine (`gbrain bootstrap attach`), or delete it and the brain is gone. It is yours.',
|
||||
]
|
||||
: [
|
||||
'What you own: your agent\'s memory is markdown on this machine only (no remote yet).',
|
||||
'Run `gbrain bootstrap repo` any time to give it a private GitHub home you own — readable, portable, deletable.',
|
||||
];
|
||||
return [
|
||||
...ownership,
|
||||
'',
|
||||
'Fill it next: an empty brain is a database; a filled one is a memory.',
|
||||
'Ask your agent to run the cold-start skill — it imports your real life',
|
||||
'(Gmail, calendar, contacts via ClawVisor, an OAuth vault so the agent never',
|
||||
'holds raw tokens; or offline archives like Google Takeout), one consented',
|
||||
'phase at a time. Each phase is independently valuable — stop whenever.',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
|
||||
'graph-query': ['--aliases', '--all', '--brain', '--depth', '--direction', '--explain', '--fast', '--force', '--from-pages', '--help', '--http', '--include-foreign', '--include-null-signature', '--json', '--lang', '--markdown', '--mcp-only', '--multimodal', '--near-symbol', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--supersessions', '--surface', '--symbol-kind', '--thin', '--timeout', '--token-ttl', '--type'],
|
||||
'hook': ['--aliases', '--all', '--allow-unverified-remote', '--batch-limit', '--brain', '--budget-ms', '--cached', '--count', '--delete-brain', '--detach', '--diff-filter', '--end-of-options', '--env', '--exclude-standard', '--fast', '--force', '--from-pages', '--get', '--harness', '--help', '--http', '--include-null-signature', '--jq', '--json', '--name-only', '--no-embedding', '--no-extract', '--once', '--others', '--path', '--pattern', '--pending', '--porcelain', '--quiet', '--reset', '--resolve', '--show-current', '--show-toplevel', '--source', '--stale', '--stats', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl'],
|
||||
'import': ['--aliases', '--all', '--asof', '--background', '--brain', '--brain-wide-max-cost-usd', '--by-mention', '--cached', '--code', '--compile', '--concurrency', '--embedding-dimensions', '--embedding-model', '--exclude', '--exclude-standard', '--explain', '--fast', '--federated', '--fix', '--follow', '--force', '--force-rechunk', '--fresh', '--from-pages', '--full', '--help', '--http', '--include-gitignored', '--include-null-signature', '--json', '--lang', '--markdown', '--max-age', '--multimodal', '--name-status', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--older-than', '--others', '--path', '--pattern', '--pending', '--pglite', '--priority', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--repo', '--reset', '--resolve', '--respect-gitignore', '--restore-only', '--since', '--skip-failed', '--source', '--source-id', '--stale', '--strategy', '--supabase', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl', '--url', '--workers'],
|
||||
'init': ['--all', '--brain', '--chat-model', '--check', '--ctx-size', '--embedding-dimensions', '--embedding-model', '--entity', '--expansion-model', '--fast', '--force', '--from-pages', '--grant-types', '--help', '--http', '--issuer-url', '--json', '--judge-model', '--key', '--mcp-only', '--mcp-url', '--migrate-only', '--model', '--multimodal', '--no', '--no-embed', '--no-embedding', '--non-interactive', '--oauth-client-id', '--oauth-client-secret', '--path', '--pglite', '--provenance', '--schema-pack', '--scopes', '--skip-embed-check', '--source', '--stale', '--supabase', '--surface', '--target', '--to', '--token-ttl', '--touchpoint', '--url', '--version'],
|
||||
'init': ['--all', '--brain', '--chat-model', '--check', '--ctx-size', '--embedding-dimensions', '--embedding-model', '--entity', '--expansion-model', '--fast', '--flag', '--force', '--from-pages', '--grant-types', '--help', '--http', '--issuer-url', '--json', '--judge-model', '--key', '--mcp-only', '--mcp-url', '--migrate-only', '--model', '--multimodal', '--no', '--no-embed', '--no-embedding', '--non-interactive', '--oauth-client-id', '--oauth-client-secret', '--path', '--pglite', '--provenance', '--schema-pack', '--scopes', '--skip-embed-check', '--source', '--stale', '--supabase', '--surface', '--to', '--token-ttl', '--touchpoint', '--url', '--version'],
|
||||
'integrations': ['--auto', '--brain', '--dry-run', '--embeddings', '--fast', '--force', '--from-pages', '--help', '--http', '--json', '--no-embedding', '--overwrite', '--refresh', '--reranking', '--source', '--surface', '--target', '--token-ttl'],
|
||||
'integrity': ['--aliases', '--all', '--auto', '--backend', '--background', '--brain', '--brain-wide-max-cost-usd', '--check', '--confidence', '--cost', '--dry-run', '--explain', '--fast', '--follow', '--force', '--fresh', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--limit', '--no-embedding', '--no-extract', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--review-lower', '--skip-bare-tweet', '--skip-urls', '--source', '--stale', '--supabase', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl', '--type', '--url'],
|
||||
'jobs': ['--abbrev-ref', '--aliases', '--all', '--allow-empty', '--allow-protected', '--allow-shell-jobs', '--apply', '--asof', '--auto', '--auto-fix', '--auto-with-prompt', '--background', '--backoff-delay', '--backoff-jitter', '--backoff-type', '--batch', '--batch-size', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--break-lock', '--budget-usd', '--budget-usd-per-day', '--by-mention', '--by-type', '--cached', '--catch-up', '--check', '--cli-path', '--cluster', '--cluster-errors', '--code', '--concurrency', '--confidence', '--confirm-destructive', '--content', '--date', '--days', '--delay', '--detach', '--diff-filter', '--dim', '--dimensions', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--empty', '--entities', '--exclude', '--exclude-standard', '--explain', '--fast', '--federated', '--federated-read', '--ff-only', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--force-retry', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--hard-deadline', '--health-interval', '--held-out', '--help', '--http', '--idempotency-key', '--image', '--include-frontmatter', '--include-gitignored', '--include-null-signature', '--infer-dates', '--inject-bootstrap', '--inline', '--input', '--install', '--interval', '--is-ancestor', '--json', '--kind', '--lang', '--limit', '--lock', '--markdown', '--max-age', '--max-attempts', '--max-cost-usd', '--max-crashes', '--max-rss', '--max-runtime-min', '--max-sources', '--max-stalled', '--max-usd', '--max-waiting', '--mcp-only', '--migrate-only', '--min-context', '--missing-path', '--mode', '--model', '--multimodal', '--name-only', '--name-status', '--near-symbol', '--ner', '--nice', '--no', '--no-auto-embed', '--no-embed', '--no-embedding', '--no-extract', '--no-federate', '--no-federated', '--no-gpg-sign', '--no-hard-deadline', '--no-inject', '--no-mutate', '--no-pull', '--no-renames', '--no-schema-pack', '--no-verify', '--no-worker', '--non-interactive', '--now', '--offset', '--older-than', '--once', '--order', '--orphan', '--others', '--output', '--override-disabled', '--pace', '--pace-max-concurrency', '--pack', '--parallel', '--params', '--path', '--pattern', '--pending', '--phase', '--pid-file', '--priority', '--progress-interval', '--progress-json', '--queue', '--quiet', '--redact-secrets', '--reenrich-after', '--refresh-cache', '--refresh-ms', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--respect-gitignore', '--restore-only', '--resume', '--retry-failed', '--review-lower', '--run-id', '--save', '--segment-limit', '--serial', '--session', '--session-id', '--short', '--show-toplevel', '--sigkill-rescue', '--since', '--skip-bare-tweet', '--skip-failed', '--skip-urls', '--sleep', '--slug', '--slugs', '--source', '--source-id', '--src-subpath', '--stale', '--stats', '--status', '--strategy', '--supersessions', '--surface', '--swap-only', '--symbol-kind', '--target', '--target-score', '--thin', '--thin-threshold', '--timeout', '--timeout-ms', '--to', '--token-ttl', '--trusted-extraction', '--type', '--types', '--uninstall', '--unsafe-bypass-dream-guard', '--url', '--user', '--verbose', '--verify', '--version', '--watch', '--wedge-rescue', '--with-db', '--workers', '--yes'],
|
||||
@@ -67,7 +67,7 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
|
||||
'orphans': ['--aliases', '--all', '--background', '--brain', '--brain-wide-max-cost-usd', '--count', '--explain', '--follow', '--help', '--include-null-signature', '--include-pseudo', '--json', '--no-extract', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin', '--timeout'],
|
||||
'pages': ['--aliases', '--all', '--brain', '--dry-run', '--help', '--include-null-signature', '--json', '--no-extract', '--older-than', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin'],
|
||||
'pglite-repair': ['--brain', '--break-lock', '--dry-rnu', '--dry-run', '--fast', '--force', '--from-pages', '--help', '--http', '--json', '--no-embedding', '--path', '--quiet', '--source', '--surface', '--token-ttl', '--yes'],
|
||||
'post-upgrade': ['--all', '--apply-clean-hunks', '--brain', '--check', '--code', '--compile', '--concurrency', '--detail', '--dim', '--embedding-dimensions', '--embedding-model', '--fast', '--ff-only', '--force', '--force-all', '--force-orchestrator', '--force-retry', '--force-schema', '--format', '--from-pages', '--help', '--host-dir', '--http', '--inject-bootstrap', '--inline', '--install', '--interval', '--json', '--limit', '--list', '--markdown', '--max-rss', '--migrate-only', '--migration', '--mode', '--model', '--multimodal', '--name-only', '--no', '--no-autopilot-install', '--no-embed', '--no-embedding', '--no-inject', '--no-worker', '--non-interactive', '--now', '--path', '--pglite', '--quiet', '--repo', '--reset', '--since', '--skills-dir', '--skip-verify', '--source', '--stale', '--status', '--supabase', '--surface', '--swap-only', '--target', '--to', '--token-ttl', '--uninstall', '--user', '--verbose', '--verify', '--version', '--workers', '--yes'],
|
||||
'post-upgrade': ['--all', '--apply-clean-hunks', '--brain', '--check', '--code', '--compile', '--concurrency', '--detail', '--dim', '--embedding-dimensions', '--embedding-model', '--fast', '--ff-only', '--flag', '--force', '--force-all', '--force-orchestrator', '--force-retry', '--force-schema', '--format', '--from-pages', '--help', '--host-dir', '--http', '--inject-bootstrap', '--inline', '--install', '--interval', '--json', '--limit', '--list', '--markdown', '--max-rss', '--migrate-only', '--migration', '--mode', '--model', '--multimodal', '--name-only', '--no', '--no-autopilot-install', '--no-embed', '--no-embedding', '--no-inject', '--no-worker', '--non-interactive', '--now', '--path', '--pglite', '--quiet', '--repo', '--reset', '--since', '--skills-dir', '--skip-verify', '--source', '--stale', '--status', '--supabase', '--surface', '--swap-only', '--target', '--to', '--token-ttl', '--uninstall', '--user', '--verbose', '--verify', '--version', '--workers', '--yes'],
|
||||
'protocol': ['--all', '--allow-empty', '--apply', '--asof', '--auto', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--budget-usd-per-day', '--by-mention', '--content', '--date', '--days', '--entities', '--fast', '--federated', '--file', '--follow', '--force', '--from-pages', '--help', '--http', '--image', '--json', '--kind', '--limit', '--max-usd', '--mode', '--multimodal', '--no-embedding', '--offset', '--path', '--progress-interval', '--progress-json', '--quiet', '--save', '--session', '--session-id', '--since', '--slug', '--slugs', '--source', '--stats', '--surface', '--synthesize', '--target', '--timeout', '--token', '--token-ttl', '--trusted-extraction', '--url', '--with-db', '--yes'],
|
||||
'providers': ['--brain', '--ctx-size', '--embedding-dimensions', '--embedding-model', '--embeddings', '--fast', '--force', '--from-pages', '--help', '--http', '--json', '--model', '--multimodal', '--no', '--no-embed', '--no-embedding', '--reranking', '--source', '--surface', '--token-ttl', '--touchpoint', '--version'],
|
||||
'publish': ['--accent', '--bg', '--border', '--brain', '--card-bg', '--code-bg', '--error', '--fg', '--help', '--json', '--link', '--muted', '--out', '--password', '--source', '--title'],
|
||||
@@ -78,7 +78,7 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
|
||||
'reindex-code': ['--abi', '--aliases', '--all', '--background', '--brain', '--brain-wide-max-cost-usd', '--chunker-debug', '--code', '--compile', '--concurrency', '--dry-run', '--embedding-dimensions', '--embedding-model', '--explain', '--fix', '--follow', '--force', '--force-rechunk', '--help', '--include-null-signature', '--json', '--judge-model', '--lang', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-runtime', '--model', '--multimodal', '--no', '--no-embed', '--no-extract', '--older-than', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--serial', '--source', '--stale', '--supersessions', '--thin', '--timeout', '--version', '--workers', '--yes'],
|
||||
'reindex-frontmatter': ['--aliases', '--all', '--brain', '--concurrency', '--dry-run', '--force', '--help', '--include-null-signature', '--json', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--slug-prefix', '--source', '--stale', '--supersessions', '--thin', '--workers', '--yes'],
|
||||
'reindex-search-vector': ['--aliases', '--all', '--background', '--brain', '--brain-wide-max-cost-usd', '--dry-run', '--explain', '--follow', '--help', '--include-null-signature', '--json', '--migrate-only', '--no-extract', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin', '--timeout', '--yes'],
|
||||
'reinit-pglite': ['--abbrev-ref', '--all', '--allow-empty', '--brain', '--break-lock', '--chat-model', '--concurrency', '--confirm-destructive', '--diff-filter', '--dir', '--embedding-dimensions', '--embedding-model', '--empty', '--entity', '--exclude', '--exclude-standard', '--expansion-model', '--fast', '--force', '--force-break-lock', '--format', '--from-pages', '--full', '--grant-types', '--hard-deadline', '--help', '--http', '--include-gitignored', '--interval', '--is-ancestor', '--issuer-url', '--json', '--key', '--lock', '--max-age', '--max-sources', '--mcp-only', '--mcp-url', '--migrate-only', '--missing-path', '--model', '--name-only', '--name-status', '--no-auto-embed', '--no-embed', '--no-embedding', '--no-extract', '--no-gpg-sign', '--no-hard-deadline', '--no-pull', '--no-renames', '--no-schema-pack', '--no-sync', '--no-verify', '--non-interactive', '--oauth-client-id', '--oauth-client-secret', '--orphan', '--parallel', '--path', '--pglite', '--priority', '--provenance', '--quiet', '--repo', '--retry-failed', '--schema-pack', '--scopes', '--serial', '--short', '--show-toplevel', '--skip-embed-check', '--skip-failed', '--slugs', '--source', '--source-id', '--src-subpath', '--stale', '--strategy', '--supabase', '--surface', '--target', '--timeout', '--to', '--token-ttl', '--url', '--verify', '--version', '--watch', '--workers', '--yes'],
|
||||
'reinit-pglite': ['--abbrev-ref', '--all', '--allow-empty', '--brain', '--break-lock', '--chat-model', '--concurrency', '--confirm-destructive', '--diff-filter', '--dir', '--embedding-dimensions', '--embedding-model', '--empty', '--entity', '--exclude', '--exclude-standard', '--expansion-model', '--fast', '--force', '--force-break-lock', '--format', '--from-pages', '--full', '--grant-types', '--hard-deadline', '--help', '--http', '--include-gitignored', '--interval', '--is-ancestor', '--issuer-url', '--json', '--key', '--lock', '--max-age', '--max-sources', '--mcp-only', '--mcp-url', '--migrate-only', '--missing-path', '--model', '--name-only', '--name-status', '--no-auto-embed', '--no-embed', '--no-embedding', '--no-extract', '--no-gpg-sign', '--no-hard-deadline', '--no-pull', '--no-renames', '--no-schema-pack', '--no-sync', '--no-verify', '--non-interactive', '--oauth-client-id', '--oauth-client-secret', '--orphan', '--parallel', '--path', '--pglite', '--priority', '--provenance', '--quiet', '--repo', '--retry-failed', '--schema-pack', '--scopes', '--serial', '--short', '--show-toplevel', '--skip-embed-check', '--skip-failed', '--slugs', '--source', '--source-id', '--src-subpath', '--stale', '--strategy', '--supabase', '--surface', '--timeout', '--to', '--token-ttl', '--url', '--verify', '--version', '--watch', '--workers', '--yes'],
|
||||
'remote': ['--all', '--background', '--brain', '--break-lock', '--by-mention', '--column', '--concurrency', '--content-audit', '--count', '--detach', '--dim', '--dir', '--drain', '--embedding-dimensions', '--embedding-model', '--fast', '--fix', '--force', '--force-retry', '--force-schema', '--from-pages', '--full', '--help', '--http', '--include-flagged', '--index-audit', '--json', '--locks', '--markdown', '--max-cost', '--max-cost-usd', '--max-jobs', '--max-rss', '--max-usd', '--mcp-only', '--migrate-only', '--multimodal', '--nice', '--no-embedding', '--older-than', '--parallel', '--params', '--path', '--pglite', '--phase', '--pid-file', '--porcelain', '--probe-pglite', '--progress-json', '--query', '--queue', '--rebuild-rollup', '--regenerate', '--remediate', '--remediation-plan', '--reset', '--resume', '--scope', '--scopes', '--since', '--skills-dir', '--skip-failed', '--slugs', '--source', '--source-id', '--stale', '--status', '--supabase', '--surface', '--target', '--target-score', '--timeout', '--to', '--token-ttl', '--top-k', '--url', '--window', '--workers', '--yes'],
|
||||
'repair-jsonb': ['--background', '--brain', '--brain-wide-max-cost-usd', '--dry-run', '--explain', '--fast', '--follow', '--force', '--from-pages', '--help', '--http', '--json', '--lang', '--markdown', '--multimodal', '--near-symbol', '--no-embedding', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--restore-only', '--source', '--stale', '--supabase', '--surface', '--symbol-kind', '--thin', '--timeout', '--token-ttl', '--url'],
|
||||
'report': ['--brain', '--content', '--dir', '--help', '--json', '--source', '--title', '--type'],
|
||||
@@ -103,7 +103,7 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
|
||||
'takes': ['--aliases', '--all', '--brain', '--bucket-size', '--by', '--claim', '--dir', '--domain', '--dry-run', '--evidence', '--expired', '--fast', '--federated', '--force', '--from-pages', '--help', '--holder', '--http', '--include-covered', '--include-null-signature', '--json', '--kind', '--limit', '--max-pages', '--no-embedding', '--no-extract', '--no-federated', '--outcome', '--path', '--pattern', '--pending', '--quality', '--refresh', '--repo', '--reset', '--resolve', '--restore-only', '--row', '--since', '--slugs', '--sort', '--source', '--source-id', '--stale', '--supersessions', '--surface', '--thin', '--token-ttl', '--unit', '--until', '--value', '--weight', '--who', '--yes'],
|
||||
'think': ['--aliases', '--all', '--anchor', '--brain', '--calibration-holder', '--explain', '--fast', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--max-usd', '--mcp-only', '--model', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--rounds', '--save', '--since', '--source', '--stale', '--supersessions', '--surface', '--take', '--thin', '--timeout', '--token-ttl', '--until', '--with-calibration'],
|
||||
'transcripts': ['--aliases', '--all', '--brain', '--days', '--full', '--help', '--include-null-signature', '--json', '--limit', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin', '--timeout'],
|
||||
'upgrade': ['--all', '--apply-clean-hunks', '--brain', '--check', '--code', '--compile', '--concurrency', '--detail', '--dim', '--embedding-dimensions', '--embedding-model', '--fast', '--ff-only', '--force', '--force-all', '--force-orchestrator', '--force-retry', '--force-schema', '--format', '--from-pages', '--help', '--host-dir', '--http', '--inject-bootstrap', '--inline', '--install', '--interval', '--json', '--limit', '--list', '--markdown', '--max-rss', '--migrate-only', '--migration', '--mode', '--model', '--multimodal', '--name-only', '--no', '--no-autopilot-install', '--no-embed', '--no-embedding', '--no-inject', '--no-worker', '--non-interactive', '--now', '--path', '--pglite', '--quiet', '--repo', '--reset', '--since', '--skills-dir', '--skip-verify', '--source', '--stale', '--status', '--supabase', '--surface', '--swap-only', '--target', '--to', '--token-ttl', '--uninstall', '--user', '--verbose', '--verify', '--version', '--workers', '--yes'],
|
||||
'upgrade': ['--all', '--apply-clean-hunks', '--brain', '--check', '--code', '--compile', '--concurrency', '--detail', '--dim', '--embedding-dimensions', '--embedding-model', '--fast', '--ff-only', '--flag', '--force', '--force-all', '--force-orchestrator', '--force-retry', '--force-schema', '--format', '--from-pages', '--help', '--host-dir', '--http', '--inject-bootstrap', '--inline', '--install', '--interval', '--json', '--limit', '--list', '--markdown', '--max-rss', '--migrate-only', '--migration', '--mode', '--model', '--multimodal', '--name-only', '--no', '--no-autopilot-install', '--no-embed', '--no-embedding', '--no-inject', '--no-worker', '--non-interactive', '--now', '--path', '--pglite', '--quiet', '--repo', '--reset', '--since', '--skills-dir', '--skip-verify', '--source', '--stale', '--status', '--supabase', '--surface', '--swap-only', '--target', '--to', '--token-ttl', '--uninstall', '--user', '--verbose', '--verify', '--version', '--workers', '--yes'],
|
||||
'watch': ['--aliases', '--all', '--brain', '--fast', '--federated', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--max-pages', '--min-confidence', '--no-embedding', '--no-extract', '--no-federated', '--path', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--stats', '--supersessions', '--surface', '--thin', '--token-ttl', '--window-turns'],
|
||||
'ze-switch': ['--aliases', '--all', '--brain', '--confirm-reembed', '--dry-run', '--force', '--help', '--ignore-env-override', '--ignore-missing-key', '--include-null-signature', '--json', '--markdown', '--no-extract', '--non-interactive', '--pattern', '--pending', '--reset', '--resolve', '--resume', '--source', '--stale', '--supersessions', '--thin', '--undo', '--yes'],
|
||||
};
|
||||
|
||||
+76
-16
@@ -10,6 +10,26 @@ import {
|
||||
} from './retry-matcher.ts';
|
||||
import { repairTimelineDedupIndex } from './timeline-dedup-repair.ts';
|
||||
|
||||
/**
|
||||
* When true, per-migration explanatory notices (e.g. the v123/v124 "here is
|
||||
* what this migration changed" lines that specific handlers write to stderr)
|
||||
* are suppressed. Set by runMigrations for a FRESH-install full replay — those
|
||||
* notices are useful diagnostics on an UPGRADE but pure noise as a new user's
|
||||
* first-run output. Module-level (not threaded through the Migration type)
|
||||
* because only a couple of handlers emit them. Guarded via `migrationNotice`.
|
||||
* Known limitation: concurrent runMigrations calls in one process (two engines
|
||||
* migrating simultaneously) share this flag — worst case is a suppressed or
|
||||
* extra stderr NOTICE line; migration execution/stamping is unaffected.
|
||||
*/
|
||||
let quietMigrationNotices = false;
|
||||
|
||||
/** Write a per-migration explanatory notice unless fresh-install quiet mode is
|
||||
* on. Handlers should route their "what changed" lines through this. */
|
||||
function migrationNotice(line: string): void {
|
||||
if (quietMigrationNotices) return;
|
||||
process.stderr.write(line);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema migrations — run automatically on initSchema().
|
||||
*
|
||||
@@ -5511,7 +5531,7 @@ export const MIGRATIONS: Migration[] = [
|
||||
// stderr, NOT stdout: migrations run lazily inside any command's
|
||||
// first DB connect — a console.log here polluted `doctor --json`
|
||||
// stdout and broke jq consumers (heavy-tests fm_wallclock).
|
||||
process.stderr.write(` v123: trigger functions recreated with language='english' (default — no backfill needed)\n`);
|
||||
migrationNotice(` v123: trigger functions recreated with language='english' (default — no backfill needed)\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5532,7 +5552,7 @@ export const MIGRATIONS: Migration[] = [
|
||||
WHERE search_vector IS NOT NULL;
|
||||
`);
|
||||
|
||||
process.stderr.write(` v123: trigger functions recreated with language='${lang}' + backfilled existing rows\n`);
|
||||
migrationNotice(` v123: trigger functions recreated with language='${lang}' + backfilled existing rows\n`);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -5600,7 +5620,7 @@ export const MIGRATIONS: Migration[] = [
|
||||
END;
|
||||
$fn$ LANGUAGE plpgsql;
|
||||
`);
|
||||
process.stderr.write(` v124: update_page_search_vector() no longer indexes compiled_truth (was overflowing tsvector on large pages, #2704)
|
||||
migrationNotice(` v124: update_page_search_vector() no longer indexes compiled_truth (was overflowing tsvector on large pages, #2704)
|
||||
`);
|
||||
},
|
||||
},
|
||||
@@ -6012,17 +6032,64 @@ export async function runMigrations(engine: BrainEngine): Promise<{ applied: num
|
||||
return { applied: 0, current };
|
||||
}
|
||||
|
||||
// Fresh install vs upgrade: a never-migrated brain (schema blob seeds
|
||||
// version='1'; every migration is >= 2) replays the FULL history — printing
|
||||
// ~240 lines of internal migration names as the user's first-run experience.
|
||||
// That wall makes a 2-second init read as complex and fragile ("1 → 125"
|
||||
// implies the brand-new install was 124 versions stale). Fresh installs get
|
||||
// one summary line; EXISTING brains keep the full per-migration detail
|
||||
// (upgrades are where the names carry diagnostic value).
|
||||
// GBRAIN_MIGRATE_VERBOSE=1 is the incident escape hatch (env-first, matching
|
||||
// the GBRAIN_SYNC_*/GBRAIN_PACE_* pattern).
|
||||
const freshInstall = current <= 1 && pending.length === sorted.length;
|
||||
const quietReplay = freshInstall && process.env.GBRAIN_MIGRATE_VERBOSE !== '1';
|
||||
// Suppress per-migration explanatory notices during a fresh-install replay
|
||||
// (they are upgrade diagnostics, noise on a new user's first run). Restored
|
||||
// in the finally so an in-process upgrade after a fresh init still narrates.
|
||||
quietMigrationNotices = quietReplay;
|
||||
|
||||
// Progress messages route to stderr so callers parsing stdout (e.g.
|
||||
// `gbrain jobs submit --json | jq`) aren't polluted by migration noise.
|
||||
process.stderr.write(` Schema version ${current} → ${LATEST_VERSION} (${pending.length} migration(s) pending)\n`);
|
||||
|
||||
// Pre-flight: warn about connections that might block DDL
|
||||
await checkForBlockingConnections(engine);
|
||||
if (quietReplay) {
|
||||
process.stderr.write(` Setting up brain schema (v${LATEST_VERSION})...\n`);
|
||||
} else {
|
||||
process.stderr.write(` Schema version ${current} → ${LATEST_VERSION} (${pending.length} migration(s) pending)\n`);
|
||||
}
|
||||
|
||||
let applied = 0;
|
||||
for (const m of pending) {
|
||||
process.stderr.write(` [${m.version}] ${m.name}...\n`);
|
||||
try {
|
||||
// Pre-flight: warn about connections that might block DDL
|
||||
await checkForBlockingConnections(engine);
|
||||
|
||||
for (const m of pending) {
|
||||
if (!quietReplay) process.stderr.write(` [${m.version}] ${m.name}...\n`);
|
||||
try {
|
||||
await applyOneMigration(engine, m);
|
||||
// Update version after both SQL and handler succeed. Inside the same
|
||||
// catch so a stamp-write failure is also NAMED in quiet mode.
|
||||
await engine.setConfig('version', String(m.version));
|
||||
} catch (err) {
|
||||
// Quiet fresh-install replay: name the failing migration — without the
|
||||
// per-step lines, the error would otherwise be anonymous.
|
||||
if (quietReplay) process.stderr.write(` [${m.version}] ${m.name} failed\n`);
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!quietReplay) process.stderr.write(` [${m.version}] ✓ ${m.name}\n`);
|
||||
applied++;
|
||||
}
|
||||
} finally {
|
||||
// Never leak the fresh-install quiet flag into a later in-process run —
|
||||
// covers every exit path from here on (incl. the pre-flight probe).
|
||||
quietMigrationNotices = false;
|
||||
}
|
||||
|
||||
return { applied, current: LATEST_VERSION };
|
||||
}
|
||||
|
||||
/** One migration's full body (SQL + handler + verify), extracted so the
|
||||
* runMigrations loop can name the failing migration in quiet-replay mode. */
|
||||
async function applyOneMigration(engine: BrainEngine, m: Migration): Promise<void> {
|
||||
// Pick SQL: engine-specific `sqlFor` wins over engine-agnostic `sql`.
|
||||
const sql = m.sqlFor?.[engine.kind] ?? m.sql;
|
||||
|
||||
@@ -6093,11 +6160,4 @@ export async function runMigrations(engine: BrainEngine): Promise<{ applied: num
|
||||
}
|
||||
}
|
||||
|
||||
// Update version after both SQL and handler succeed
|
||||
await engine.setConfig('version', String(m.version));
|
||||
process.stderr.write(` [${m.version}] ✓ ${m.name}\n`);
|
||||
applied++;
|
||||
}
|
||||
|
||||
return { applied, current: LATEST_VERSION };
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@ export async function runInitNudge(engine: BrainEngine): Promise<void> {
|
||||
let linkedCount = 0;
|
||||
let timelineCount = 0;
|
||||
let takesCount = 0;
|
||||
// -1 = the page-count probe failed: fail-open sentinel, treat as non-empty
|
||||
// so current behavior is preserved when the count is unknown.
|
||||
let totalPages = -1;
|
||||
let checksRan = 0;
|
||||
let checksAttempted = 0;
|
||||
let partial = false;
|
||||
@@ -82,6 +85,11 @@ export async function runInitNudge(engine: BrainEngine): Promise<void> {
|
||||
[],
|
||||
{ signal: controller.signal },
|
||||
),
|
||||
engine.executeRaw<{ count: string | number }>(
|
||||
`SELECT COUNT(*) AS count FROM pages WHERE deleted_at IS NULL`,
|
||||
[],
|
||||
{ signal: controller.signal },
|
||||
),
|
||||
]);
|
||||
clearTimeout(timer);
|
||||
|
||||
@@ -99,8 +107,16 @@ export async function runInitNudge(engine: BrainEngine): Promise<void> {
|
||||
else if (i === 2) linkedCount = n;
|
||||
else if (i === 3) timelineCount = n;
|
||||
else if (i === 4) takesCount = n;
|
||||
else if (i === 5) totalPages = n;
|
||||
}
|
||||
|
||||
// A brand-new EMPTY brain has no "opportunities" — telling a fresh user
|
||||
// "0 takes" at the end of their first init is jargon-noise on the
|
||||
// activation surface. Suppress the ENTIRE nudge on empty (including the
|
||||
// partial-checks notice below).
|
||||
const brainEmpty = totalPages === 0;
|
||||
if (brainEmpty) return;
|
||||
|
||||
// Aggregate: any non-zero metric triggers the nudge.
|
||||
const linkCoverage = totalEntities > 0 ? linkedCount / totalEntities : 1;
|
||||
const timelineCoverage = totalEntities > 0 ? timelineCount / totalEntities : 1;
|
||||
@@ -109,7 +125,6 @@ export async function runInitNudge(engine: BrainEngine): Promise<void> {
|
||||
|| (totalEntities > 0 && linkCoverage < 0.7)
|
||||
|| (totalEntities > 0 && timelineCoverage < 0.9)
|
||||
|| takesCount === 0;
|
||||
|
||||
if (!hasRecommendations && !partial) return;
|
||||
|
||||
// Emit one-line nudge. Be terse — init is the activation surface.
|
||||
@@ -123,6 +138,13 @@ export async function runInitNudge(engine: BrainEngine): Promise<void> {
|
||||
}
|
||||
if (takesCount === 0) parts.push('0 takes');
|
||||
|
||||
if (parts.length === 0 && partial) {
|
||||
process.stderr.write(
|
||||
`\n[onboard] Init checks incomplete (${checksRan}/${checksAttempted}) — run 'gbrain onboard --check' for full recommendations.\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
process.stderr.write(
|
||||
`\n[onboard] Brain has opportunities: ${parts.join(', ')}.\n` +
|
||||
`[onboard] Run 'gbrain onboard --check' to see the plan.` +
|
||||
|
||||
@@ -2776,10 +2776,12 @@ const get_brain_identity: Operation = {
|
||||
let latest_version: string | null = null;
|
||||
try {
|
||||
const su = await import('./self-upgrade.ts');
|
||||
const entry = su.readUpdateCache();
|
||||
if (entry && su.isCacheFresh(entry, Date.now()) && entry.marker.kind === 'upgrade_available') {
|
||||
// Shared stale/foreign-cache guard (pendingUpgradeVersion): only an
|
||||
// upgrade strictly newer than the RUNNING version counts.
|
||||
const latest = su.pendingUpgradeVersion(VERSION, Date.now());
|
||||
if (latest) {
|
||||
update_available = true;
|
||||
latest_version = entry.marker.latest ?? null;
|
||||
latest_version = latest;
|
||||
}
|
||||
} catch {
|
||||
/* never let the banner break the op */
|
||||
|
||||
@@ -252,6 +252,8 @@ export async function acquireLock(dataDir: string | undefined, opts?: { timeoutM
|
||||
`GBrain's local database is already open through \`gbrain serve\` (MCP, PID ${lockPid}). ` +
|
||||
`This brain uses PGLite, so a separate CLI process cannot open it at the same time. ` +
|
||||
`Stop \`gbrain serve\`, then retry this CLI command. ` +
|
||||
`(\`gbrain serve\` is usually spawned by your agent harness — close or exit that ` +
|
||||
`Claude Code/Codex session to release the database.) ` +
|
||||
`Or keep it running and use its MCP tools instead. ` +
|
||||
`A process with the recorded PID is still running, so GBrain will not remove ${lockDir} automatically.`,
|
||||
);
|
||||
|
||||
@@ -30,7 +30,7 @@ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unl
|
||||
import { dirname, join } from 'node:path';
|
||||
import { gbrainPath } from './config.ts';
|
||||
import { acquirePackLock, type PackLockOpts } from './schema-pack/pack-lock.ts';
|
||||
import { isValidVersionString, parseSemver, semverGt, semverLte } from './semver.ts';
|
||||
import { isNewerVersion, isValidVersionString, parseSemver, semverGt, semverLte } from './semver.ts';
|
||||
|
||||
// ── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -322,6 +322,29 @@ export function isCacheFresh(entry: CacheEntry, now: number): boolean {
|
||||
return now - entry.mtimeMs < ttl;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one shared "is an upgrade actually pending for THIS binary?" predicate.
|
||||
* Returns the latest version string when the cache is present, fresh, marks an
|
||||
* upgrade, AND that upgrade is strictly newer than the RUNNING binary — else
|
||||
* null. The running-version comparison is the load-bearing part: the cache
|
||||
* records the version of whatever binary WROTE it (an older gbrain on PATH can
|
||||
* write it via the detached refresh), so consumers must never trust
|
||||
* `marker.current` to describe themselves. Every upgrade-nag surface (CLI
|
||||
* startup marker, doctor, advisor, get_brain_identity) routes through here so
|
||||
* the suppression rule cannot drift per-surface. Never throws.
|
||||
*/
|
||||
export function pendingUpgradeVersion(runningVersion: string, now: number = Date.now()): string | null {
|
||||
try {
|
||||
const entry = readUpdateCache();
|
||||
if (!entry || !isCacheFresh(entry, now)) return null;
|
||||
if (entry.marker.kind !== 'upgrade_available' || !entry.marker.latest) return null;
|
||||
if (!isNewerVersion(runningVersion, entry.marker.latest)) return null;
|
||||
return entry.marker.latest;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Snooze (interactive prompting only; never overrides mode=off) ────────────
|
||||
|
||||
export function readSnooze(): SnoozeRecord | null {
|
||||
|
||||
@@ -118,6 +118,13 @@ function buildAdvisoryWithoutWorkspace(
|
||||
/**
|
||||
* Print the advisory to stderr at the end of init / post-upgrade.
|
||||
* No-op when buildAdvisory returns null.
|
||||
*
|
||||
* `init` prints a COMPACT 3-line pointer: the init success screen already
|
||||
* competes for one primary action (the memory-verbs funnel), and the full
|
||||
* 55-line agent-addressed banner buried it. The full banner remains the
|
||||
* `upgrade` surface (its designed audience) and stays available any time
|
||||
* via `gbrain advisor`. buildAdvisory itself is unchanged — it is the
|
||||
* agent-readable document, pinned by tests and shared with `gbrain advisor`.
|
||||
*/
|
||||
export function printAdvisoryIfRecommended(opts: {
|
||||
version: string;
|
||||
@@ -125,7 +132,51 @@ export function printAdvisoryIfRecommended(opts: {
|
||||
targetWorkspace?: string | null;
|
||||
targetSkillsDir?: string | null;
|
||||
}): void {
|
||||
const advisory = buildAdvisory(opts);
|
||||
if (!advisory) return;
|
||||
process.stderr.write(advisory);
|
||||
// Fail-open: this is decoration on the init success screen and runs AFTER
|
||||
// the brain is created (and, since the memory-verbs quickstart now prints
|
||||
// last, BEFORE it). An unreadable RESOLVER.md must never throw here and
|
||||
// starve the primary CTA — same posture as runInitNudge.
|
||||
try {
|
||||
const advisory = buildAdvisory(opts);
|
||||
if (!advisory) return;
|
||||
if (opts.context === 'init') {
|
||||
// Derive the counts for the compact form from the same detection the
|
||||
// full banner used. Detection is hoisted OUT of the filter (one receipt
|
||||
// read+parse total, matching buildAdvisory's own pattern).
|
||||
let workspace = opts.targetWorkspace ?? null;
|
||||
let skillsDir = opts.targetSkillsDir ?? null;
|
||||
if (!skillsDir) {
|
||||
const detected = autoDetectSkillsDir();
|
||||
if (detected.dir) {
|
||||
skillsDir = detected.dir;
|
||||
if (!workspace) workspace = resolvePath(skillsDir, '..');
|
||||
}
|
||||
}
|
||||
const all = currentRecommendedSet();
|
||||
const installed = workspace && skillsDir ? detectInstalledSlugs(skillsDir, workspace) : null;
|
||||
const missing = installed ? all.filter((s) => !installed.has(s.slug)) : all;
|
||||
if (missing.length === 0) return;
|
||||
const names = missing.map((s) => s.slug);
|
||||
const preview = names.slice(0, 4).join(', ') + (names.length > 4 ? ', …' : '');
|
||||
// No workspace detected → scaffold has no target; say so (the full
|
||||
// banner carries the same caveat via workspaceNotDetected).
|
||||
const noWorkspace = installed === null;
|
||||
// Human-voiced (prints on the init success screen where a person may read
|
||||
// it) — no `[AGENT]` stage-direction leaking to the human. An agent reading
|
||||
// the same line still knows the command to offer.
|
||||
process.stderr.write(
|
||||
`\n${missing.length} recommended skill(s) not installed yet (${preview}).\n` +
|
||||
// NOTE: no bare `--flag` tokens in this string — the flag-registry
|
||||
// generator harvests them from source strings and would register a
|
||||
// phantom flag on every command that imports this module.
|
||||
(noWorkspace
|
||||
? `Open your agent workspace first (scaffold needs a target), then \`${scaffoldCommandFor(missing, all)}\`; full list: gbrain advisor\n`
|
||||
: `Ask me to run \`${scaffoldCommandFor(missing, all)}\`, or see the full list: gbrain advisor\n`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
process.stderr.write(advisory);
|
||||
} catch {
|
||||
/* advisory is best-effort decoration — never break init */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
"consent": true,
|
||||
"phase": "engine",
|
||||
"persist": false,
|
||||
"question": "Optional: one API key (OpenAI, Anthropic, or Voyage) unlocks semantic search and automatic fact extraction. With no key, I run keyless: keyword search plus memory I write down myself — everything still works. Paste a key or say skip.",
|
||||
"question": "Optional: one API key upgrades me — OpenAI (semantic search + automatic fact extraction), Voyage (semantic search), or Anthropic (fact extraction). With no key, I run keyless: keyword search plus memory I write down myself — everything still works. Paste a key or say skip.",
|
||||
"default": "",
|
||||
"sink": "config",
|
||||
"maxLength": 256
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# gbrain agent workspace — template
|
||||
|
||||
<!-- gbrain-template-stamp: 0.45.10.0 -->
|
||||
<!-- gbrain-template-stamp: 0.45.11.0 -->
|
||||
|
||||
This repository is the **"Use this template"** distribution artifact for a
|
||||
[gbrain](https://github.com/garrytan/gbrain) personal-agent workspace — the same
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Unit tests for src/core/ai/probes.ts (probeOpenAICompat).
|
||||
*
|
||||
* Runs against a local Bun.serve fixture on an ephemeral port (port: 0) so
|
||||
* no real daemon is needed. Pins:
|
||||
* - models extraction from a valid {object:'list', data:[...]} body, with
|
||||
* non-string / missing ids filtered out
|
||||
* - non-list JSON body → models_endpoint_valid false, models undefined
|
||||
* - connection refused → reachable false
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { probeOpenAICompat } from '../src/core/ai/probes.ts';
|
||||
|
||||
describe('probeOpenAICompat — models extraction', () => {
|
||||
test('valid list body extracts string ids only', async () => {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch() {
|
||||
return Response.json({ object: 'list', data: [{ id: 'm1' }, { id: 42 }, {}] });
|
||||
},
|
||||
});
|
||||
try {
|
||||
const r = await probeOpenAICompat(`http://127.0.0.1:${server.port}`);
|
||||
expect(r.reachable).toBe(true);
|
||||
expect(r.models_endpoint_valid).toBe(true);
|
||||
expect(r.models).toEqual(['m1']);
|
||||
} finally {
|
||||
server.stop(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('non-list JSON body → valid false, models undefined', async () => {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch() {
|
||||
return Response.json({ hello: 'world' });
|
||||
},
|
||||
});
|
||||
try {
|
||||
const r = await probeOpenAICompat(`http://127.0.0.1:${server.port}`);
|
||||
expect(r.reachable).toBe(true);
|
||||
expect(r.models_endpoint_valid).toBe(false);
|
||||
expect(r.models).toBeUndefined();
|
||||
} finally {
|
||||
server.stop(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('connection refused → reachable false', async () => {
|
||||
// Grab an ephemeral port by binding, then release it before probing so
|
||||
// the connection is refused (nothing else claims the port that fast).
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch() {
|
||||
return new Response('unused');
|
||||
},
|
||||
});
|
||||
const port = server.port;
|
||||
try {
|
||||
await server.stop(true);
|
||||
const r = await probeOpenAICompat(`http://127.0.0.1:${port}`);
|
||||
expect(r.reachable).toBe(false);
|
||||
expect(r.error).toBeDefined();
|
||||
} finally {
|
||||
server.stop(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import { runBootstrap, workspaceBrainStats } from '../src/commands/bootstrap.ts'
|
||||
import type { ExecRunner } from '../src/core/bootstrap/repo.ts';
|
||||
import { attachWorkspace } from '../src/core/bootstrap/attach.ts';
|
||||
import { readReceipt, receiptPath, writeManifest, type InstallReceipt } from '../src/core/bootstrap/format.ts';
|
||||
import { GBRAIN_HOOK_MARKER_KEY, GBRAIN_HOOK_MARKER_VALUE } from '../src/core/bootstrap/host-specs.ts';
|
||||
import { deriveWorkspaceSourceId } from '../src/core/bootstrap/verify.ts';
|
||||
import { initState, setAnswer, skipAnswer, confirm, readBackHash } from '../src/core/bootstrap/interview.ts';
|
||||
|
||||
@@ -578,13 +579,43 @@ describe('MCP registration verification [FIX7]', () => {
|
||||
const r = await runHooks(runner);
|
||||
expect(r.result).toBe(0);
|
||||
expect(r.err).toContain('targets a DIFFERENT workspace');
|
||||
expect(calls.some((c) => c[1] === 'mcp' && c[2] === 'remove' && c[3] === 'gbrain')).toBe(true);
|
||||
// The remove must be SCOPED on claude-code: a scope-less remove can resolve
|
||||
// to a different scope's registration and leave the blocker in place.
|
||||
const removes = calls.filter((c) => c[1] === 'mcp' && c[2] === 'remove' && c[3] === 'gbrain');
|
||||
expect(removes.length).toBeGreaterThan(0);
|
||||
for (const c of removes) {
|
||||
const scopeIdx = c.indexOf('--scope');
|
||||
expect(scopeIdx).toBeGreaterThan(3);
|
||||
expect(c[scopeIdx + 1]).toBe('project');
|
||||
}
|
||||
const adds = calls.filter((c) => c[1] === 'mcp' && c[2] === 'add').length;
|
||||
expect(adds).toBe(2); // initial (foreign) + re-add after remove
|
||||
// After the fix, the smoke confirms the corrected registration.
|
||||
expect(r.out).toContain('verified targeting this workspace');
|
||||
}, 30_000);
|
||||
|
||||
test('mismatch + failed remove → exit 1 with the by-hand fix instruction; add never retried', async () => {
|
||||
// Stateful failure host: add refuses ("already exists"), get shows a
|
||||
// FOREIGN registration (mismatch), and the scoped remove itself fails.
|
||||
const calls: string[][] = [];
|
||||
const runner: ExecRunner = async (argv: string[]) => {
|
||||
calls.push(argv);
|
||||
if (argv[1] !== 'mcp') return { code: 0, stdout: '', stderr: '' };
|
||||
if (argv[2] === 'add') return { code: 1, stdout: '', stderr: 'MCP server gbrain already exists' };
|
||||
if (argv[2] === 'get') return { code: 0, stdout: FOREIGN, stderr: '' };
|
||||
if (argv[2] === 'remove') return { code: 1, stdout: '', stderr: 'nope' };
|
||||
return { code: 0, stdout: '', stderr: '' };
|
||||
};
|
||||
const r = await runHooks(runner);
|
||||
expect(r.result).toBe(1);
|
||||
expect(r.err).toContain('targets a DIFFERENT workspace');
|
||||
// Fail LOUD, not the old silent no-op loop: the message hands the human
|
||||
// the manual off-ramp instead of re-failing the add.
|
||||
expect(r.err).toContain('remove the stale registration by hand');
|
||||
const adds = calls.filter((c) => c[1] === 'mcp' && c[2] === 'add').length;
|
||||
expect(adds).toBe(1); // the failed remove halts the flow before any re-add
|
||||
}, 30_000);
|
||||
|
||||
test('host without `mcp get` → inconclusive, kept with a note (never a false bless)', async () => {
|
||||
const { runner } = mcpHost({ initialReg: OURS, getSupported: false });
|
||||
const r = await runHooks(runner);
|
||||
@@ -595,6 +626,108 @@ describe('MCP registration verification [FIX7]', () => {
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe('MCP host failure × hooks at the dispatcher (exit-127 skip / broken settings fail-closed)', () => {
|
||||
// Self-contained fixtures (the flip pattern): HOOKS_CONSENT left at its bank
|
||||
// default ('yes') so the hooks half of the phase is live in both tests.
|
||||
const scratch: string[] = [];
|
||||
afterAll(() => {
|
||||
for (const d of scratch) rmSync(d, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function failWorkspace(): { fws: string; fhome: string; fparent: string } {
|
||||
const fparent = mkdtempSync(join(tmpdir(), 'gb-fail-'));
|
||||
const fhome = join(fparent, '.gbrain');
|
||||
mkdirSync(fhome, { recursive: true });
|
||||
const fws = mkdtempSync(join(tmpdir(), 'gb-fail-ws-'));
|
||||
scratch.push(fparent, fws);
|
||||
const prev = process.env.GBRAIN_HOME;
|
||||
process.env.GBRAIN_HOME = fparent;
|
||||
try {
|
||||
expect(initState(fws).ok).toBe(true);
|
||||
for (const [key, value] of Object.entries(REQUIRED_ANSWERS)) {
|
||||
const r = setAnswer(fws, key, value);
|
||||
if (!r.ok) throw new Error(r.message);
|
||||
}
|
||||
expect(setAnswer(fws, 'MCP_SCOPE', 'project').ok).toBe(true);
|
||||
const h = readBackHash(fws);
|
||||
if (!h.ok) throw new Error(h.message);
|
||||
expect(confirm(fws, h.hash).ok).toBe(true);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.GBRAIN_HOME;
|
||||
else process.env.GBRAIN_HOME = prev;
|
||||
}
|
||||
return { fws, fhome, fparent };
|
||||
}
|
||||
|
||||
async function withFailHome<T>(parent: string, fn: () => Promise<T>): Promise<T> {
|
||||
const prev = process.env.GBRAIN_HOME;
|
||||
process.env.GBRAIN_HOME = parent;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.GBRAIN_HOME;
|
||||
else process.env.GBRAIN_HOME = prev;
|
||||
}
|
||||
}
|
||||
|
||||
test('`claude` missing (exit 127 on mcp add) → MCP skipped, hooks STILL install, exit 2, receipt detail hooks', async () => {
|
||||
const { fws, fhome, fparent } = failWorkspace();
|
||||
const r = await withFailHome(fparent, async () => {
|
||||
expect((await capture(() => runBootstrap(['render', '--workspace', fws]))).result).toBe(0);
|
||||
const runner: ExecRunner = async (argv: string[]) => {
|
||||
if (argv[0] === 'claude' && argv[1] === 'mcp' && argv[2] === 'add') {
|
||||
return { code: 127, stdout: '', stderr: 'claude: command not found' };
|
||||
}
|
||||
return { code: 0, stdout: '', stderr: '' };
|
||||
};
|
||||
return capture(() =>
|
||||
runBootstrap(['hooks', '--workspace', fws, '--harness', 'claude-code', '--gbrain-bin', process.execPath], {
|
||||
runner,
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(r.result).toBe(2);
|
||||
expect(r.err).toContain('is not on PATH');
|
||||
// The old early-return silently dropped hooks; now they install anyway
|
||||
// (hooks only write settings.local.json and need no host binary).
|
||||
expect(r.out).toContain('hooks installed');
|
||||
const settingsPath = join(fws, '.claude', 'settings.local.json');
|
||||
expect(existsSync(settingsPath)).toBe(true);
|
||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) as {
|
||||
hooks?: Record<string, Array<{ hooks?: Array<Record<string, unknown>> }>>;
|
||||
};
|
||||
const entries = Object.values(settings.hooks ?? {}).flatMap((groups) => groups.flatMap((g) => g.hooks ?? []));
|
||||
expect(entries.length).toBeGreaterThan(0);
|
||||
expect(entries.some((e) => e[GBRAIN_HOOK_MARKER_KEY] === GBRAIN_HOOK_MARKER_VALUE)).toBe(true);
|
||||
// Receipt records what actually landed: hooks only, no MCP.
|
||||
expect(readReceipt(fhome)?.registrations).toEqual([{ host: 'claude-code', scope: 'project', detail: 'hooks' }]);
|
||||
}, 30_000);
|
||||
|
||||
test('unparseable settings.local.json → hooks fail CLOSED (exit 1), file byte-identical, receipt detail mcp', async () => {
|
||||
const { fws, fhome, fparent } = failWorkspace();
|
||||
const broken = '{ definitely broken';
|
||||
const settingsPath = join(fws, '.claude', 'settings.local.json');
|
||||
const r = await withFailHome(fparent, async () => {
|
||||
expect((await capture(() => runBootstrap(['render', '--workspace', fws]))).result).toBe(0);
|
||||
mkdirSync(join(fws, '.claude'), { recursive: true });
|
||||
writeFileSync(settingsPath, broken, 'utf8');
|
||||
const { runner } = makeRunner();
|
||||
return capture(() =>
|
||||
runBootstrap(['hooks', '--workspace', fws, '--harness', 'claude-code', '--gbrain-bin', process.execPath], {
|
||||
runner,
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(r.result).toBe(1);
|
||||
// The refusal explains WHY (the file may carry permissions/allowlist
|
||||
// entries gbrain must not clobber) and names the repair path.
|
||||
expect(r.err).toContain('not valid JSON');
|
||||
expect(readFileSync(settingsPath, 'utf8')).toBe(broken);
|
||||
// MCP (step 1) landed before the hook failure — the receipt says exactly that.
|
||||
expect(readReceipt(fhome)?.registrations).toEqual([{ host: 'claude-code', scope: 'project', detail: 'mcp' }]);
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe('receipt overwrite guard wired into every writer [CX2-12]', () => {
|
||||
function writeNewerReceipt(dir: string): void {
|
||||
mkdirSync(join(dir, 'bootstrap'), { recursive: true });
|
||||
|
||||
@@ -170,18 +170,18 @@ describe('writeClaudeHooks [G5, CX2-17]', () => {
|
||||
expect(bak).toEqual({ permissions: { allow: ['X'] } });
|
||||
});
|
||||
|
||||
test('broken JSON: original backed up aside, loud note, clean file written', () => {
|
||||
test('broken JSON: write ABORTS fail-closed, file untouched, fix named', () => {
|
||||
// A parse-broken settings.local.json may carry permissions/allowlist
|
||||
// entries gbrain cannot see — rewriting it (the old backup-and-start-clean
|
||||
// behavior) silently dropped them from the live file. The write path now
|
||||
// matches removeClaudeHooks: refuse, name the fix, change nothing.
|
||||
const dir = ws();
|
||||
mkdirSync(join(dir, '.claude'), { recursive: true });
|
||||
writeFileSync(claudeSettingsPath(dir), '{ definitely broken json !!!');
|
||||
const res = writeClaudeHooks(dir, { gbrainBin: BIN, env: ENV });
|
||||
expect(res.brokenBackupPath).not.toBeNull();
|
||||
expect(existsSync(res.brokenBackupPath!)).toBe(true);
|
||||
expect(readFileSync(res.brokenBackupPath!, 'utf8')).toContain('definitely broken');
|
||||
expect(res.notes.join(' ')).toContain('not valid JSON');
|
||||
// Fresh file is valid and carries our hooks.
|
||||
const settings = readSettings(dir);
|
||||
expect(markerEntries(settings, 'SessionStart')).toHaveLength(1);
|
||||
const original = '{ definitely broken json !!!';
|
||||
writeFileSync(claudeSettingsPath(dir), original);
|
||||
expect(() => writeClaudeHooks(dir, { gbrainBin: BIN, env: ENV })).toThrow(/not valid JSON.*re-run/s);
|
||||
// Byte-identical after the refused write — nothing moved, nothing rewritten.
|
||||
expect(readFileSync(claudeSettingsPath(dir), 'utf8')).toBe(original);
|
||||
});
|
||||
|
||||
test('relative gbrainBin refused (GUI hosts inherit no PATH)', () => {
|
||||
|
||||
@@ -310,6 +310,49 @@ describe('[A8] provenance + read-back confirm', () => {
|
||||
expect(parsed.confirmed).toBeUndefined();
|
||||
});
|
||||
|
||||
test('setAnswer surfaces invalidatedConfirmation ONLY when a confirm existed', () => {
|
||||
const ws = makeWs();
|
||||
answerAllRequired(ws);
|
||||
// No prior confirmation → nothing was invalidated (falsy flag).
|
||||
const r0 = setAnswer(ws, 'SOUL_WINCE', 'Filler openers.');
|
||||
expect(r0.ok).toBe(true);
|
||||
if (!r0.ok || r0.sink !== 'state') throw new Error('expected a state-sink result');
|
||||
expect(r0.invalidatedConfirmation).toBeFalsy();
|
||||
// Full confirm, then a later set → the result SAYS it voided the confirm
|
||||
// (the CLI warns at --set time instead of failing much later at render).
|
||||
const h = readBackHash(ws);
|
||||
if (!h.ok) throw new Error(h.message);
|
||||
expect(confirm(ws, h.hash).ok).toBe(true);
|
||||
const r1 = setAnswer(ws, 'SOUL_GOOD_OUTPUT', 'A finished artifact.');
|
||||
expect(r1.ok).toBe(true);
|
||||
if (!r1.ok || r1.sink !== 'state') throw new Error('expected a state-sink result');
|
||||
expect(r1.invalidatedConfirmation).toBe(true);
|
||||
const st = status(ws);
|
||||
if (!st.ok) throw new Error(st.message);
|
||||
expect(st.confirmed).toBe(false);
|
||||
});
|
||||
|
||||
test('skipAnswer surfaces invalidatedConfirmation ONLY when a confirm existed (optional key — required keys refuse skip)', () => {
|
||||
const ws = makeWs();
|
||||
answerAllRequired(ws);
|
||||
// No prior confirmation → falsy flag on an optional-key skip.
|
||||
const r0 = skipAnswer(ws, 'SOUL_WINCE');
|
||||
expect(r0.ok).toBe(true);
|
||||
if (!r0.ok) throw new Error('unreachable');
|
||||
expect(r0.invalidatedConfirmation).toBeFalsy();
|
||||
// Full confirm, then a later optional-key skip → invalidation surfaced.
|
||||
const h = readBackHash(ws);
|
||||
if (!h.ok) throw new Error(h.message);
|
||||
expect(confirm(ws, h.hash).ok).toBe(true);
|
||||
const r1 = skipAnswer(ws, 'SOUL_WORLDVIEW');
|
||||
expect(r1.ok).toBe(true);
|
||||
if (!r1.ok) throw new Error('unreachable');
|
||||
expect(r1.invalidatedConfirmation).toBe(true);
|
||||
const st = status(ws);
|
||||
if (!st.ok) throw new Error(st.message);
|
||||
expect(st.confirmed).toBe(false);
|
||||
});
|
||||
|
||||
test('show returns the read-back payload with the hash once complete', () => {
|
||||
const ws = makeWs();
|
||||
answerAllRequired(ws);
|
||||
|
||||
@@ -158,6 +158,15 @@ describe('verifyWorkspace — keyless pass', () => {
|
||||
}
|
||||
expect(res.tour).toEqual([...FIRST_RUN_TOUR]);
|
||||
|
||||
// The OOBE hand-off block prints after the tour on PASS: ownership (this
|
||||
// ws has no origin remote → the local-only variant with the repo upgrade
|
||||
// path) and the ONE next action (the cold-start skill via ClawVisor).
|
||||
expect(res.report).toContain('What you own');
|
||||
expect(res.report).toContain('gbrain bootstrap repo');
|
||||
expect(res.report).toContain('cold-start');
|
||||
expect(res.report).toContain('ClawVisor');
|
||||
expect(res.handoff.length).toBeGreaterThan(0);
|
||||
|
||||
// Probe cleanup [G13]: pages, files, and the reconciled fact are gone.
|
||||
expect(existsSync(join(ws, 'brain', `${VERIFY_PROBE_SLUG}.md`))).toBe(false);
|
||||
expect(existsSync(join(ws, 'brain', `${VERIFY_PROBE_ENTITY_SLUG}.md`))).toBe(false);
|
||||
@@ -249,6 +258,21 @@ describe('verifyWorkspace — keyless pass', () => {
|
||||
expect(scan.detail).not.toContain('sk-AAAAAAAAAAAAAAAAAAAAAAAA');
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
|
||||
// Tour gating on FAIL: the report says fix-first and withholds the
|
||||
// celebration prompts ("broken, but go enjoy it" is a mixed signal) …
|
||||
expect(res.report).toContain('Fix the FAIL checks above');
|
||||
expect(res.report).not.toContain('Who am I to you?');
|
||||
// … the hand-off block is withheld with the tour (celebrating ownership
|
||||
// of a FAILED install is the same mixed signal) …
|
||||
expect(res.report).not.toContain('What you own');
|
||||
expect(res.report).not.toContain('cold-start');
|
||||
// … while the returned tour + handoff arrays stay unconditional so
|
||||
// machine consumers (--json) keep a stable shape, and the check names
|
||||
// the gate.
|
||||
expect(res.tour).toEqual([...FIRST_RUN_TOUR]);
|
||||
expect(res.handoff.length).toBeGreaterThan(0);
|
||||
expect(check(res.checks, 'first_run_tour')[0].detail).toContain('withheld');
|
||||
} finally {
|
||||
rmSync(githubPath, { force: true });
|
||||
writeFileSync(userPath, userOriginal);
|
||||
|
||||
@@ -208,6 +208,9 @@ describe('thin-client scratch-DB guard — jobs partial dispatch + config refusa
|
||||
expect(existsSync(join(tmp, '.gbrain', 'brain.pglite'))).toBe(false);
|
||||
expect(r.stdout + r.stderr).not.toContain('Schema version');
|
||||
expect(r.stdout + r.stderr).not.toContain('migration(s) pending');
|
||||
// A scratch store is a FRESH install, so a re-regression would print the
|
||||
// quiet-replay summary line, not the verbose header — pin both shapes.
|
||||
expect(r.stdout + r.stderr).not.toContain('Setting up brain schema');
|
||||
});
|
||||
|
||||
test('`gbrain jobs list` never fabricates a scratch local engine', async () => {
|
||||
@@ -216,5 +219,6 @@ describe('thin-client scratch-DB guard — jobs partial dispatch + config refusa
|
||||
const { existsSync } = await import('fs');
|
||||
expect(existsSync(join(tmp, '.gbrain', 'brain.pglite'))).toBe(false);
|
||||
expect(r.stdout + r.stderr).not.toContain('Schema version');
|
||||
expect(r.stdout + r.stderr).not.toContain('Setting up brain schema');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { withEnv } from './helpers/with-env.ts';
|
||||
import { checkSelfUpgradeHealth } from '../src/commands/doctor.ts';
|
||||
import { writeUpdateCache } from '../src/core/self-upgrade.ts';
|
||||
import { logSelfUpgrade } from '../src/core/audit/self-upgrade-audit.ts';
|
||||
import { VERSION } from '../src/version.ts';
|
||||
|
||||
async function withHome<T>(fn: (home: string) => T | Promise<T>): Promise<T> {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gbrain-doctor-su-'));
|
||||
@@ -40,7 +41,18 @@ describe('checkSelfUpgradeHealth', () => {
|
||||
const c = checkSelfUpgradeHealth();
|
||||
expect(c.status).toBe('ok');
|
||||
expect(c.message).toContain('update available');
|
||||
expect(c.message).toContain('0.99.0');
|
||||
expect(c.message).toContain('-> 0.99.0');
|
||||
});
|
||||
});
|
||||
|
||||
test('fresh cache with latest == running version → suppressed (no update-available nag)', async () => {
|
||||
await withHome(() => {
|
||||
// Stale/foreign cache: the recorded latest is the version we are already
|
||||
// running. The shared pendingUpgradeVersion guard must suppress the nag.
|
||||
writeUpdateCache({ kind: 'upgrade_available', current: VERSION, latest: VERSION });
|
||||
const c = checkSelfUpgradeHealth();
|
||||
expect(c.status).toBe('ok');
|
||||
expect(c.message).not.toContain('update available');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -89,36 +89,111 @@ describe('v0.37 T12 — fresh init env-detection (D1, D2, D3) + persistence (D5)
|
||||
|
||||
// ============================================================================
|
||||
|
||||
describe('v0.37 T12 — D3 non-TTY no-key fail-loud', () => {
|
||||
describe('v0.45 DX wave — non-TTY no-key defaults to keyless (typo still fail-loud)', () => {
|
||||
let tmpHome: string;
|
||||
let typoHome: string;
|
||||
|
||||
beforeAll(() => { tmpHome = makeTempHome(); });
|
||||
afterAll(() => { rmSync(tmpHome, { recursive: true, force: true }); });
|
||||
beforeAll(() => { tmpHome = makeTempHome(); typoHome = makeTempHome(); });
|
||||
afterAll(() => {
|
||||
rmSync(tmpHome, { recursive: true, force: true });
|
||||
rmSync(typoHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('--non-interactive with zero provider keys → exit 1 + paste-ready hint', async () => {
|
||||
test('--non-interactive with zero provider keys → keyless init succeeds with loud notice', async () => {
|
||||
const r = await runCli(['init', '--pglite', '--non-interactive'], {
|
||||
gbrainHome: tmpHome,
|
||||
env: {}, // no provider keys
|
||||
});
|
||||
expect(r.exitCode).toBe(1);
|
||||
// Fail-loud message includes the canonical env var list.
|
||||
expect(r.stderr).toContain('No embedding provider configured');
|
||||
expect(r.stderr).toContain('OPENAI_API_KEY');
|
||||
expect(r.stderr).toContain('ZEROENTROPY_API_KEY');
|
||||
expect(r.stderr).toContain('VOYAGE_API_KEY');
|
||||
// Suggests --no-embedding alternative.
|
||||
expect(r.stderr).toContain('--no-embedding');
|
||||
}, 60000);
|
||||
// Keyless is a first-class posture: the naive first command completes.
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stderr).toContain('keyless mode');
|
||||
// The notice names the upgrade affordance — the re-init recipe that
|
||||
// actually works, NOT `config set embedding_model` (which config.ts
|
||||
// hard-refuses as a schema-sizing no-op).
|
||||
expect(r.stderr).toContain('gbrain init --force --pglite --embedding-model');
|
||||
expect(r.stderr).not.toContain('config set embedding_model');
|
||||
// Config persisted with the deferred-embedding sentinel.
|
||||
const cfg = JSON.parse(readFileSync(join(tmpHome, '.gbrain', 'config.json'), 'utf-8'));
|
||||
expect(cfg.embedding_disabled).toBe(true);
|
||||
expect(cfg.engine).toBe('pglite');
|
||||
}, 240000);
|
||||
|
||||
test('--non-interactive with env-key typo surfaces Levenshtein hint', async () => {
|
||||
test('--non-interactive with env-key typo stays fail-loud with Levenshtein hint', async () => {
|
||||
const r = await runCli(['init', '--pglite', '--non-interactive'], {
|
||||
gbrainHome: tmpHome,
|
||||
gbrainHome: typoHome,
|
||||
env: { OPENAPI_API_KEY: 'sk-test-typo' },
|
||||
});
|
||||
// A near-miss key signals the user MEANT to configure a provider —
|
||||
// completing keyless would silently bury their typo.
|
||||
expect(r.exitCode).toBe(1);
|
||||
// D13 typo detection: surfaces "did you mean OPENAI_API_KEY"
|
||||
expect(r.stderr).toMatch(/did you mean OPENAI_API_KEY/i);
|
||||
// The hint leads with the keyless-continue option.
|
||||
expect(r.stderr).toContain('--no-embedding');
|
||||
}, 60000);
|
||||
|
||||
test('--non-interactive with multiple provider keys auto-picks the canonical default', async () => {
|
||||
const multiHome = makeTempHome();
|
||||
try {
|
||||
const r = await runCli(['init', '--pglite', '--non-interactive'], {
|
||||
gbrainHome: multiHome,
|
||||
env: {
|
||||
OPENAI_API_KEY: 'sk-test-only-for-init-resolution-NOT-CALLED',
|
||||
ZEROENTROPY_API_KEY: 'ze-test-only-for-init-resolution-NOT-CALLED',
|
||||
},
|
||||
});
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stderr).toContain('Multiple embedding providers env-ready');
|
||||
expect(r.stderr).toContain('Override with --embedding-model');
|
||||
const cfg = JSON.parse(readFileSync(join(multiHome, '.gbrain', 'config.json'), 'utf-8'));
|
||||
// Canonical default (DEFAULT_EMBEDDING_MODEL) wins when its key is present.
|
||||
expect(cfg.embedding_model).toBe('zeroentropyai:zembed-1');
|
||||
} finally {
|
||||
rmSync(multiHome, { recursive: true, force: true });
|
||||
}
|
||||
}, 240000);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
|
||||
describe('v0.45 DX wave — --supabase non-TTY guard + multi-key no-canonical fail-loud', () => {
|
||||
test('init --supabase without a TTY fails loud and names the --url escape hatch', async () => {
|
||||
// Legacy behavior was a silent exit-0 no-op (stdin closed → readLine
|
||||
// never resolved → process ended with NO config written) — the worst
|
||||
// failure shape for a scripted/agent caller.
|
||||
const home = makeTempHome();
|
||||
try {
|
||||
const r = await runCli(['init', '--supabase'], { gbrainHome: home, env: {} });
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.stderr).toContain('needs an interactive terminal');
|
||||
expect(r.stderr).toContain('--url');
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
test('multiple provider keys with NO canonical candidate stays fail-loud with disambiguation hint', async () => {
|
||||
// The canonical default provider (zeroentropyai) has no key here, so the
|
||||
// non-TTY auto-pick cannot resolve the ambiguity — it must fail loud
|
||||
// (D2/D3), not guess between openai and voyage.
|
||||
const home = makeTempHome();
|
||||
try {
|
||||
const r = await runCli(['init', '--pglite', '--non-interactive'], {
|
||||
gbrainHome: home,
|
||||
env: {
|
||||
OPENAI_API_KEY: 'sk-test-only-for-init-resolution-NOT-CALLED',
|
||||
VOYAGE_API_KEY: 'pa-test-only-for-init-resolution-NOT-CALLED',
|
||||
},
|
||||
});
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.stderr).toMatch(/Multiple embedding providers env-ready/);
|
||||
expect(r.stderr).toMatch(/Disambiguate by passing --embedding-model/);
|
||||
// Fail-loud path exits BEFORE any config write.
|
||||
expect(existsSync(join(home, '.gbrain', 'config.json'))).toBe(false);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
}, 120000);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -77,6 +77,21 @@ describe('self-upgrade marker on a real invocation', () => {
|
||||
expect(stderr).not.toContain('UPGRADE_AVAILABLE');
|
||||
});
|
||||
|
||||
test('cache latest == running version → suppressed (no marker, no human sentence)', () => {
|
||||
writeCache(`UPGRADE_AVAILABLE ${VERSION} ${VERSION}`);
|
||||
const { stderr } = runGbrain('notify');
|
||||
expect(stderr).not.toContain('UPGRADE_AVAILABLE');
|
||||
expect(stderr).not.toContain('Run: gbrain self-upgrade');
|
||||
});
|
||||
|
||||
test('foreign-writer cache → marker prints the RUNNING version, not the writer\'s', () => {
|
||||
// An older gbrain on PATH wrote the cache: marker.current is 0.0.1, not us.
|
||||
writeCache('UPGRADE_AVAILABLE 0.0.1 0.99.0');
|
||||
const { stderr } = runGbrain('notify');
|
||||
expect(stderr).toContain(`UPGRADE_AVAILABLE ${VERSION} 0.99.0`);
|
||||
expect(stderr).not.toContain('UPGRADE_AVAILABLE 0.0.1');
|
||||
});
|
||||
|
||||
test('active snooze for the version → no marker (notify mode honors snooze)', () => {
|
||||
writeCache(`UPGRADE_AVAILABLE ${VERSION} 0.99.0`);
|
||||
// snooze record: "<version> <level> <epoch-ms>" — fresh ts so it's active.
|
||||
|
||||
@@ -0,0 +1,585 @@
|
||||
/**
|
||||
* Real-PTY TTY harness — the interactive sibling of agent-harness.ts.
|
||||
*
|
||||
* agent-harness.ts drives the REAL `claude` / `codex` binaries HEADLESSLY
|
||||
* (`claude -p`, `codex exec`) — perfect for pass/fail door proofs, blind to
|
||||
* everything a human actually experiences: pickers, spinners, silence windows,
|
||||
* permission dialogs, interview prompts, rendered copy. This harness spawns any
|
||||
* CLI (gbrain itself, `claude`, `codex`) under a REAL pseudo-terminal via Bun's
|
||||
* built-in `terminal:` spawn option (Bun >= 1.3.10, pinned in package.json
|
||||
* engines + CI), so the child renders exactly what a user's terminal shows —
|
||||
* and records WHEN every byte arrived, so "the user stared at a frozen screen
|
||||
* for 9 seconds" is a measurable artifact, not a vibe.
|
||||
*
|
||||
* Built for two consumers:
|
||||
* 1. DX-exploration runs (`scripts/dx-explore.ts`) — capture the fresh-user
|
||||
* install funnel as timestamped transcripts for Don't-Make-Me-Think
|
||||
* audits (stall report + verbatim rendered copy per step).
|
||||
* 2. Future PTY e2e tests — the same waitFor/sendKey primitives the gstack
|
||||
* plan-mode PTY suite uses (pattern adapted from gstack's
|
||||
* test/helpers/claude-pty-runner.ts; no node-pty, no native modules).
|
||||
*
|
||||
* Hermeticity matches agent-harness.ts: every spawn goes through
|
||||
* hermeticChildEnv, so a DX run can NEVER see (or mutate) the operator's real
|
||||
* ~/.claude, ~/.codex, or ~/.gbrain unless the caller explicitly wires a
|
||||
* temp-dir override in.
|
||||
*
|
||||
* Pure helpers (stripAnsi, computeStalls, parseDriveCommand,
|
||||
* renderStallsReport, buildClaudeTuiSeed) are exported for the zero-subprocess
|
||||
* unit suite (test/tty-harness.test.ts).
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { hermeticChildEnv, type HermeticEnvOpts } from './agent-harness.ts';
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 1. Pure text helpers
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Strip ANSI escapes (CSI, OSC, charset selection, cursor save/restore) so
|
||||
* pattern matching runs against the text a human would read. Same sequence
|
||||
* classes the gstack PTY runner strips — cursor-POSITIONING escapes render
|
||||
* visually as whitespace but leave no character behind, so matched copy can
|
||||
* arrive with collapsed spacing ("ready to execute" → "readytoexecute").
|
||||
* Match copy with that in mind. */
|
||||
export function stripAnsi(s: string): string {
|
||||
return s
|
||||
.replace(/\x1b\[[\d;?]*[a-zA-Z]/g, '')
|
||||
.replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '')
|
||||
.replace(/\x1b[()][AB012]/g, '')
|
||||
.replace(/\x1b[78=>]/g, '');
|
||||
}
|
||||
|
||||
/** One captured PTY output burst. tMs is milliseconds since spawn. */
|
||||
export interface PtyFrame {
|
||||
tMs: number;
|
||||
data: string;
|
||||
}
|
||||
|
||||
/** A window of output silence long enough that a user would notice it. */
|
||||
export interface Stall {
|
||||
/** ms since spawn when the silence began. */
|
||||
startMs: number;
|
||||
durationMs: number;
|
||||
/** Last visible (ANSI-stripped) text on screen when the silence began —
|
||||
* what the user was staring at. '(no output yet)' for startup silence. */
|
||||
context: string;
|
||||
}
|
||||
|
||||
/** Tail of the stripped cumulative buffer, trimmed for a stall report. */
|
||||
function stallContext(cumulative: string): string {
|
||||
const visible = stripAnsi(cumulative);
|
||||
const lines = visible.split('\n').map((l) => l.trimEnd());
|
||||
while (lines.length > 0 && lines[lines.length - 1]!.trim() === '') lines.pop();
|
||||
return lines.slice(-4).join('\n').slice(-400);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every output gap >= thresholdMs (default 2000) in a frame sequence.
|
||||
* Counts three gap kinds a user actually experiences:
|
||||
* - startup silence: spawn → first byte
|
||||
* - mid-run silence: between consecutive frames
|
||||
* - trailing silence: last byte → endMs (pass the session duration to count
|
||||
* "it printed a question and then sat there" at the end of a run)
|
||||
*/
|
||||
export function computeStalls(
|
||||
frames: readonly PtyFrame[],
|
||||
opts: { thresholdMs?: number; endMs?: number } = {},
|
||||
): Stall[] {
|
||||
const threshold = opts.thresholdMs ?? 2000;
|
||||
const stalls: Stall[] = [];
|
||||
let cumulative = '';
|
||||
|
||||
if (frames.length === 0) {
|
||||
if (opts.endMs !== undefined && opts.endMs >= threshold) {
|
||||
stalls.push({ startMs: 0, durationMs: opts.endMs, context: '(no output yet)' });
|
||||
}
|
||||
return stalls;
|
||||
}
|
||||
|
||||
const first = frames[0]!;
|
||||
if (first.tMs >= threshold) {
|
||||
stalls.push({ startMs: 0, durationMs: first.tMs, context: '(no output yet)' });
|
||||
}
|
||||
cumulative += first.data;
|
||||
|
||||
for (let i = 1; i < frames.length; i++) {
|
||||
const prev = frames[i - 1]!;
|
||||
const cur = frames[i]!;
|
||||
const gap = cur.tMs - prev.tMs;
|
||||
if (gap >= threshold) {
|
||||
stalls.push({ startMs: prev.tMs, durationMs: gap, context: stallContext(cumulative) });
|
||||
}
|
||||
cumulative += cur.data;
|
||||
}
|
||||
|
||||
if (opts.endMs !== undefined) {
|
||||
const last = frames[frames.length - 1]!;
|
||||
const gap = opts.endMs - last.tMs;
|
||||
if (gap >= threshold) {
|
||||
stalls.push({ startMs: last.tMs, durationMs: gap, context: stallContext(cumulative) });
|
||||
}
|
||||
}
|
||||
|
||||
return stalls;
|
||||
}
|
||||
|
||||
/** Markdown stall report for a transcript dir — the audit-facing artifact. */
|
||||
export function renderStallsReport(stalls: readonly Stall[], totalMs: number): string {
|
||||
const header =
|
||||
`# Stall report\n\n` +
|
||||
`Total session: ${(totalMs / 1000).toFixed(1)}s. ` +
|
||||
`${stalls.length} silence window(s) a user would notice.\n`;
|
||||
if (stalls.length === 0) return header + '\nNo stalls at threshold.\n';
|
||||
const body = stalls
|
||||
.map(
|
||||
(s, i) =>
|
||||
`\n## Stall ${i + 1}: ${(s.durationMs / 1000).toFixed(1)}s at t+${(s.startMs / 1000).toFixed(1)}s\n\n` +
|
||||
'Screen when the silence began:\n\n```\n' +
|
||||
(s.context || '(blank screen)') +
|
||||
'\n```\n',
|
||||
)
|
||||
.join('');
|
||||
return header + body;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 2. Drive-mode control protocol (file-based, so a Conductor agent can steer
|
||||
// a live TUI across separate tool calls)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const KEY_MAP = {
|
||||
Enter: '\r',
|
||||
Up: '\x1b[A',
|
||||
Down: '\x1b[B',
|
||||
Right: '\x1b[C',
|
||||
Left: '\x1b[D',
|
||||
Esc: '\x1b',
|
||||
Tab: '\t',
|
||||
ShiftTab: '\x1b[Z',
|
||||
Space: ' ',
|
||||
Backspace: '\x7f',
|
||||
CtrlC: '\x03',
|
||||
CtrlD: '\x04',
|
||||
} as const satisfies Record<string, string>;
|
||||
|
||||
/** Literal union of key names (not plain string) so sendKey('Entr') is a
|
||||
* compile error in test code; drive mode's runtime-string path keeps the
|
||||
* `| string` overload with its runtime throw. */
|
||||
export type KeyName = keyof typeof KEY_MAP;
|
||||
|
||||
export type DriveCommand =
|
||||
| { kind: 'send'; data: string }
|
||||
| { kind: 'key'; key: string }
|
||||
| { kind: 'note'; text: string }
|
||||
| { kind: 'stop' };
|
||||
|
||||
/**
|
||||
* Parse one line of the drive-mode control channel (`input.jsonl`). Accepted
|
||||
* shapes — exactly one of:
|
||||
* {"line": "text"} → sends text + Enter (the common case)
|
||||
* {"send": "raw text (include \r yourself for Enter)"}
|
||||
* {"key": "Enter" | "Up" | ... (KEY_MAP names)}
|
||||
* {"note": "free-text annotation recorded into the transcript timeline"}
|
||||
* {"stop": true}
|
||||
* Raw control bytes inside the line are re-escaped before parsing — zsh's
|
||||
* builtin `echo` expands `\r` to a literal CR, which would otherwise make
|
||||
* the JSON unparseable and silently eat the command. Returns null for
|
||||
* malformed JSON, unknown keys, or unknown key names — drive mode skips
|
||||
* those lines loudly (stderr) instead of guessing.
|
||||
*/
|
||||
export function parseDriveCommand(line: string): DriveCommand | null {
|
||||
// Raw C0 control chars are never valid inside JSON strings; shells (zsh
|
||||
// echo, printf format strings) produce them from typed `\r`/`\n`/`\t`.
|
||||
// Re-escaping is strictly more accepting than rejecting the line.
|
||||
const sanitized = line.replace(/[\x00-\x1f]/g, (c) => {
|
||||
return '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0');
|
||||
});
|
||||
let obj: unknown;
|
||||
try {
|
||||
obj = JSON.parse(sanitized);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof obj !== 'object' || obj === null) return null;
|
||||
const rec = obj as Record<string, unknown>;
|
||||
if (typeof rec.line === 'string') {
|
||||
return { kind: 'send', data: rec.line.replace(/[\r\n]+$/, '') + '\r' };
|
||||
}
|
||||
if (typeof rec.send === 'string') return { kind: 'send', data: rec.send };
|
||||
if (typeof rec.key === 'string') {
|
||||
if (!(rec.key in KEY_MAP)) return null;
|
||||
return { kind: 'key', key: rec.key };
|
||||
}
|
||||
if (typeof rec.note === 'string') return { kind: 'note', text: rec.note };
|
||||
if (rec.stop === true) return { kind: 'stop' };
|
||||
return null;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 3. Claude Code TUI seed config
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Minimal `$CLAUDE_CONFIG_DIR/.claude.json` so an INTERACTIVE claude session
|
||||
* in a hermetic config dir skips first-run TUI prompts that would otherwise
|
||||
* hang an unattended DX run (shape adapted from gstack's hermetic-env.ts,
|
||||
* verified against claude 2.1.x):
|
||||
* - hasCompletedOnboarding: suppresses theme/onboarding flow
|
||||
* - customApiKeyResponses.approved (last 20 chars): suppresses the
|
||||
* "use this API key?" prompt when a key is exported
|
||||
* - projects[dir].hasTrustDialogAccepted: pre-trusts the workspace
|
||||
* Callers auditing FIRST-RUN friction itself should skip the seed on purpose.
|
||||
*/
|
||||
export function buildClaudeTuiSeed(opts: {
|
||||
apiKey?: string;
|
||||
trustedDirs: string[];
|
||||
}): Record<string, unknown> {
|
||||
const seed: Record<string, unknown> = {
|
||||
hasCompletedOnboarding: true,
|
||||
projects: Object.fromEntries(
|
||||
opts.trustedDirs.map((dir) => [
|
||||
dir,
|
||||
{ hasTrustDialogAccepted: true, hasCompletedProjectOnboarding: true },
|
||||
]),
|
||||
),
|
||||
};
|
||||
if (opts.apiKey) {
|
||||
seed.customApiKeyResponses = { approved: [opts.apiKey.slice(-20)] };
|
||||
}
|
||||
return seed;
|
||||
}
|
||||
|
||||
/** Write the seed into a hermetic CLAUDE_CONFIG_DIR. */
|
||||
export function seedClaudeTuiConfig(
|
||||
configDir: string,
|
||||
opts: { apiKey?: string; trustedDirs: string[] },
|
||||
): void {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, '.claude.json'),
|
||||
JSON.stringify(buildClaudeTuiSeed(opts), null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 4. The PTY session
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TtyLaunchOpts {
|
||||
cwd?: string;
|
||||
/** Terminal size. 120x40 default — TUIs lay out cleanly at this size. */
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
/** Env overrides layered LAST onto the hermetic base (HOME, GBRAIN_HOME,
|
||||
* CLAUDE_CONFIG_DIR, CODEX_HOME, API keys the scenario needs...). */
|
||||
env?: Record<string, string | undefined>;
|
||||
/** Extra allowlist entries for hermeticChildEnv (e.g. ['OPENAI_API_KEY',
|
||||
* 'CODEX_*'] for a codex child). */
|
||||
extraAllow?: HermeticEnvOpts['extraAllow'];
|
||||
/** Names to DELETE from the final env (applied after overrides). The
|
||||
* hermetic base deliberately passes auth keys through — a true-keyless DX
|
||||
* run must strip them, and overrides can't unset (undefined is skipped). */
|
||||
dropEnv?: string[];
|
||||
/** Wall-clock kill switch. Default 15 min. */
|
||||
timeoutMs?: number;
|
||||
/** Observer for every output burst (for callers that want to stream frames
|
||||
* somewhere as they arrive; saveTranscript already persists them at end). */
|
||||
onFrame?: (frame: PtyFrame) => void;
|
||||
}
|
||||
|
||||
export interface TtySession {
|
||||
argv: readonly string[];
|
||||
/** Date.now() at spawn — pair with frame tMs for absolute timestamps. */
|
||||
startedAtMs: number;
|
||||
send(data: string): void;
|
||||
sendKey(key: KeyName | string): void;
|
||||
/** Raw accumulated output (with ANSI). Forensics + replay. */
|
||||
raw(): string;
|
||||
/** ANSI-stripped output for pattern matching / human reading. */
|
||||
visible(): string;
|
||||
/** Timestamped output bursts captured so far. */
|
||||
frames(): readonly PtyFrame[];
|
||||
/** Mark current buffer position; visibleSince/waitFor can scope after it. */
|
||||
mark(): number;
|
||||
visibleSince(marker?: number): string;
|
||||
waitForAny(
|
||||
patterns: Array<RegExp | string>,
|
||||
opts?: { timeoutMs?: number; pollMs?: number; since?: number },
|
||||
): Promise<{ matched: RegExp | string; index: number }>;
|
||||
waitFor(
|
||||
pattern: RegExp | string,
|
||||
opts?: { timeoutMs?: number; pollMs?: number; since?: number },
|
||||
): Promise<void>;
|
||||
/** Resolve true once no output has arrived for quietMs (the screen has
|
||||
* settled — a picker/question is likely waiting). Resolves true immediately
|
||||
* if the process exited; false only on timeout. Never throws. */
|
||||
waitForQuiet(opts?: { quietMs?: number; timeoutMs?: number }): Promise<boolean>;
|
||||
/** Await process exit (bounded). Returns exit code or null if still alive. */
|
||||
waitForExit(timeoutMs?: number): Promise<number | null>;
|
||||
exited(): boolean;
|
||||
exitCode(): number | null;
|
||||
pid(): number | undefined;
|
||||
/** SIGINT, then SIGKILL after 2s. Safe to call repeatedly. */
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Does this Bun expose the `terminal:` spawn option? Probed once. Callers
|
||||
* (tests) skip PTY paths on false instead of hard-failing. */
|
||||
let _ptySupport: boolean | null = null;
|
||||
export function ptySupported(): boolean {
|
||||
if (_ptySupport !== null) return _ptySupport;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const proc = (Bun as any).spawn(['true'], {
|
||||
terminal: { cols: 20, rows: 5, data() {} },
|
||||
});
|
||||
_ptySupport = typeof proc?.terminal?.write === 'function' || proc?.terminal !== undefined;
|
||||
try {
|
||||
proc.kill?.('SIGKILL');
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
} catch {
|
||||
_ptySupport = false;
|
||||
}
|
||||
return _ptySupport;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn argv under a real PTY with a hermetic env. The caller owns lifetime:
|
||||
* always `await session.close()` (or waitForExit) — the wall timer is a
|
||||
* backstop, not a lifecycle.
|
||||
*/
|
||||
export function launchTty(argv: string[], opts: TtyLaunchOpts = {}): TtySession {
|
||||
if (argv.length === 0) throw new Error('launchTty: empty argv');
|
||||
const cols = opts.cols ?? 120;
|
||||
const rows = opts.rows ?? 40;
|
||||
const timeoutMs = opts.timeoutMs ?? 900_000;
|
||||
const startedAtMs = Date.now();
|
||||
|
||||
let buffer = '';
|
||||
const frames: PtyFrame[] = [];
|
||||
let lastFrameAt = 0; // ms since spawn; 0 until first byte
|
||||
let exited = false;
|
||||
let exitCodeCaptured: number | null = null;
|
||||
|
||||
const childEnv = hermeticChildEnv(opts.env ?? {}, { extraAllow: opts.extraAllow });
|
||||
for (const k of opts.dropEnv ?? []) delete childEnv[k];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const proc = (Bun as any).spawn(argv, {
|
||||
terminal: {
|
||||
cols,
|
||||
rows,
|
||||
data(_t: unknown, chunk: Buffer) {
|
||||
const frame: PtyFrame = { tMs: Date.now() - startedAtMs, data: chunk.toString('utf-8') };
|
||||
buffer += frame.data;
|
||||
frames.push(frame);
|
||||
lastFrameAt = frame.tMs;
|
||||
try {
|
||||
opts.onFrame?.(frame);
|
||||
} catch {
|
||||
/* observer errors never kill the session */
|
||||
}
|
||||
},
|
||||
},
|
||||
cwd: opts.cwd ?? process.cwd(),
|
||||
env: childEnv,
|
||||
});
|
||||
|
||||
let exitedPromise: Promise<void> = Promise.resolve();
|
||||
if (proc.exited && typeof proc.exited.then === 'function') {
|
||||
exitedPromise = proc.exited
|
||||
.then((code: number | null) => {
|
||||
exitCodeCaptured = code;
|
||||
exited = true;
|
||||
})
|
||||
.catch(() => {
|
||||
exited = true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort kill of the child's whole process TREE, not just the parent.
|
||||
* A PTY child (claude/codex/a shell) is normally the session/group leader of
|
||||
* its pty, so `process.kill(-pid, sig)` reaches its descendants (MCP servers,
|
||||
* sub-shells) — otherwise SIGKILL to the parent orphans them, leaking API
|
||||
* spend and PGLite locks. If the child isn't a group leader the negative-pid
|
||||
* kill throws (EPERM/ESRCH) and we fall back to killing the parent alone.
|
||||
*/
|
||||
function killTree(sig: NodeJS.Signals): void {
|
||||
const pid = proc.pid as number | undefined;
|
||||
if (typeof pid === 'number') {
|
||||
try {
|
||||
process.kill(-pid, sig);
|
||||
return;
|
||||
} catch {
|
||||
/* not a group leader — fall through to parent-only */
|
||||
}
|
||||
}
|
||||
try {
|
||||
proc.kill?.(sig);
|
||||
} catch {
|
||||
/* already dead */
|
||||
}
|
||||
}
|
||||
|
||||
const wallTimer = setTimeout(() => killTree('SIGKILL'), timeoutMs);
|
||||
|
||||
function send(data: string): void {
|
||||
if (exited) return;
|
||||
try {
|
||||
proc.terminal?.write?.(data);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function sendKey(key: KeyName | string): void {
|
||||
const seq = (KEY_MAP as Record<string, string>)[key];
|
||||
if (seq === undefined) throw new Error(`sendKey: unknown key ${JSON.stringify(key)}`);
|
||||
send(seq);
|
||||
}
|
||||
|
||||
let lastMark = 0;
|
||||
function mark(): number {
|
||||
lastMark = buffer.length;
|
||||
return lastMark;
|
||||
}
|
||||
|
||||
function visibleSince(marker?: number): string {
|
||||
return stripAnsi(buffer.slice(marker ?? lastMark));
|
||||
}
|
||||
|
||||
async function waitForAny(
|
||||
patterns: Array<RegExp | string>,
|
||||
waitOpts?: { timeoutMs?: number; pollMs?: number; since?: number },
|
||||
): Promise<{ matched: RegExp | string; index: number }> {
|
||||
const wTimeout = waitOpts?.timeoutMs ?? 60_000;
|
||||
const poll = waitOpts?.pollMs ?? 200;
|
||||
const since = waitOpts?.since;
|
||||
const start = Date.now();
|
||||
for (;;) {
|
||||
const visible = since !== undefined ? stripAnsi(buffer.slice(since)) : stripAnsi(buffer);
|
||||
for (let i = 0; i < patterns.length; i++) {
|
||||
const p = patterns[i]!;
|
||||
const idx = typeof p === 'string' ? visible.indexOf(p) : visible.search(p);
|
||||
if (idx >= 0) return { matched: p, index: idx };
|
||||
}
|
||||
if (exited) {
|
||||
throw new Error(
|
||||
`process exited (code=${exitCodeCaptured}) before any pattern matched. ` +
|
||||
`Last visible:\n${stripAnsi(buffer).slice(-2000)}`,
|
||||
);
|
||||
}
|
||||
if (Date.now() - start >= wTimeout) {
|
||||
throw new Error(
|
||||
`Timed out after ${wTimeout}ms waiting for any of: ${patterns
|
||||
.map((p) => (typeof p === 'string' ? JSON.stringify(p) : p.source))
|
||||
.join(', ')}\nLast visible:\n${stripAnsi(buffer).slice(-2000)}`,
|
||||
);
|
||||
}
|
||||
await Bun.sleep(poll);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(
|
||||
pattern: RegExp | string,
|
||||
waitOpts?: { timeoutMs?: number; pollMs?: number; since?: number },
|
||||
): Promise<void> {
|
||||
await waitForAny([pattern], waitOpts);
|
||||
}
|
||||
|
||||
async function waitForQuiet(quietOpts?: {
|
||||
quietMs?: number;
|
||||
timeoutMs?: number;
|
||||
}): Promise<boolean> {
|
||||
const quietMs = quietOpts?.quietMs ?? 1500;
|
||||
const wTimeout = quietOpts?.timeoutMs ?? 120_000;
|
||||
const start = Date.now();
|
||||
for (;;) {
|
||||
if (exited) return true;
|
||||
const sinceLast = Date.now() - startedAtMs - lastFrameAt;
|
||||
if (frames.length > 0 && sinceLast >= quietMs) return true;
|
||||
if (Date.now() - start >= wTimeout) return false;
|
||||
await Bun.sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForExit(exitTimeoutMs?: number): Promise<number | null> {
|
||||
await Promise.race([exitedPromise, Bun.sleep(exitTimeoutMs ?? timeoutMs)]);
|
||||
return exitCodeCaptured;
|
||||
}
|
||||
|
||||
async function close(): Promise<void> {
|
||||
clearTimeout(wallTimer);
|
||||
if (exited) return;
|
||||
killTree('SIGINT');
|
||||
await Promise.race([exitedPromise, Bun.sleep(2000)]);
|
||||
if (!exited) {
|
||||
killTree('SIGKILL');
|
||||
await Promise.race([exitedPromise, Bun.sleep(1000)]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
argv,
|
||||
startedAtMs,
|
||||
send,
|
||||
sendKey,
|
||||
raw: () => buffer,
|
||||
visible: () => stripAnsi(buffer),
|
||||
frames: () => frames,
|
||||
mark,
|
||||
visibleSince,
|
||||
waitForAny,
|
||||
waitFor,
|
||||
waitForQuiet,
|
||||
waitForExit,
|
||||
exited: () => exited,
|
||||
exitCode: () => exitCodeCaptured,
|
||||
pid: () => proc.pid as number | undefined,
|
||||
close,
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 5. Transcript persistence — the audit-facing artifact bundle
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TranscriptMeta {
|
||||
scenario: string;
|
||||
argv: readonly string[];
|
||||
startedAtIso: string;
|
||||
exitCode: number | null;
|
||||
durationMs: number;
|
||||
notes?: string[];
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a transcript bundle into `dir`:
|
||||
* meta.json — scenario, argv, timing, exit code, notes
|
||||
* raw.txt — full output with ANSI (replayable)
|
||||
* visible.txt — ANSI-stripped (grep/read this one)
|
||||
* frames.jsonl — one {tMs, data} per output burst (timing analysis)
|
||||
* stalls.md — the rendered silence report (thresholdMs = 2000)
|
||||
*/
|
||||
export function saveTranscript(
|
||||
dir: string,
|
||||
data: { frames: readonly PtyFrame[]; raw: string; meta: TranscriptMeta },
|
||||
): void {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'meta.json'), JSON.stringify(data.meta, null, 2));
|
||||
fs.writeFileSync(path.join(dir, 'raw.txt'), data.raw);
|
||||
fs.writeFileSync(path.join(dir, 'visible.txt'), stripAnsi(data.raw));
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'frames.jsonl'),
|
||||
data.frames.map((f) => JSON.stringify(f)).join('\n') + (data.frames.length ? '\n' : ''),
|
||||
);
|
||||
const stalls = computeStalls(data.frames, { endMs: data.meta.durationMs });
|
||||
fs.writeFileSync(path.join(dir, 'stalls.md'), renderStallsReport(stalls, data.meta.durationMs));
|
||||
}
|
||||
@@ -49,14 +49,17 @@ describe('recommendModeFor — auto-suggestion heuristic', () => {
|
||||
expect(r.reason).toMatch(/Haiku/);
|
||||
});
|
||||
|
||||
test('No OpenAI key → conservative (no LLM expansion possible)', () => {
|
||||
const r = recommendModeFor({ hasOpenAIKey: false });
|
||||
test('No expansion-capable key → conservative (LLM expansion cannot run)', () => {
|
||||
const r = recommendModeFor({ hasExpansionKey: false });
|
||||
expect(r.mode).toBe('conservative');
|
||||
expect(r.reason).toMatch(/No OpenAI/);
|
||||
// Provider-neutral copy: expansion routes through the chat lane, so an
|
||||
// Anthropic or Google key counts — the reason must not say "No OpenAI".
|
||||
expect(r.reason).toMatch(/expansion-capable/i);
|
||||
expect(r.reason).not.toMatch(/No OpenAI key/);
|
||||
});
|
||||
|
||||
test('Sonnet / unknown → tokenmax (preserve-v0.31.x default)', () => {
|
||||
const r = recommendModeFor({ subagentModel: 'anthropic:claude-sonnet-4-6', hasOpenAIKey: true });
|
||||
const r = recommendModeFor({ subagentModel: 'anthropic:claude-sonnet-4-6', hasExpansionKey: true });
|
||||
expect(r.mode).toBe('tokenmax');
|
||||
expect(r.reason).toMatch(/v0\.31\.x|preserve/i);
|
||||
});
|
||||
@@ -75,7 +78,7 @@ describe('recommendModeFor — auto-suggestion heuristic', () => {
|
||||
const r = recommendModeFor({
|
||||
defaultModel: 'anthropic:claude-opus-4-7',
|
||||
subagentModel: 'anthropic:claude-haiku-4-5',
|
||||
hasOpenAIKey: true,
|
||||
hasExpansionKey: true,
|
||||
});
|
||||
expect(r.mode).toBe('conservative');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Unit tests for src/core/onboard/init-nudge.ts (runInitNudge).
|
||||
*
|
||||
* runInitNudge fires 6 parallel COUNT probes (stale chunks, entities, linked
|
||||
* entities, timeline entities, takes, total pages) and prints a one-line
|
||||
* nudge to stderr. These tests pin the DX-wave behavior:
|
||||
* - EMPTY brain (pages count 0) → the whole nudge is suppressed, even
|
||||
* when takes === 0 (no "0 takes" jargon-noise on first init)
|
||||
* - pages probe REJECTS → fail-open sentinel treats the brain as
|
||||
* non-empty, so the takes nudge still fires
|
||||
* - non-empty + healthy + one rejected arm → partial-checks notice
|
||||
* - non-empty + takes 0 → "0 takes" opportunity nudge
|
||||
*
|
||||
* The gate is process.stderr.isTTY (NOT process.env), so monkeypatching it
|
||||
* here does not trip the serial-isolation rules for env-mutating tests.
|
||||
* Both isTTY and stderr.write are restored in finally.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { runInitNudge } from '../src/core/onboard/init-nudge.ts';
|
||||
import type { BrainEngine } from '../src/core/engine.ts';
|
||||
|
||||
/** Per-probe result: a count, or an Error to make that arm reject. */
|
||||
interface ProbeCounts {
|
||||
stale?: number | Error;
|
||||
entities?: number | Error;
|
||||
linked?: number | Error;
|
||||
timeline?: number | Error;
|
||||
takes?: number | Error;
|
||||
pages?: number | Error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub engine shaped like { executeRaw: async (sql) => [...] }. Routes each
|
||||
* of runInitNudge's 6 COUNT queries by a distinctive SQL fragment. Order
|
||||
* matters: the linked/timeline queries also contain "type IN ('person'",
|
||||
* so they are matched first.
|
||||
*/
|
||||
function stubEngine(counts: ProbeCounts): BrainEngine {
|
||||
const route = (sql: string): number | Error => {
|
||||
if (sql.includes('content_chunks')) return counts.stale ?? 0;
|
||||
if (sql.includes('FROM takes')) return counts.takes ?? 0;
|
||||
if (sql.includes('FROM links')) return counts.linked ?? 0;
|
||||
if (sql.includes('timeline_entries')) return counts.timeline ?? 0;
|
||||
if (sql.includes("type IN ('person'")) return counts.entities ?? 0;
|
||||
// 6th probe: SELECT COUNT(*) FROM pages WHERE deleted_at IS NULL
|
||||
return counts.pages ?? 0;
|
||||
};
|
||||
return {
|
||||
executeRaw: async (sql: string) => {
|
||||
const r = route(sql);
|
||||
if (r instanceof Error) throw r;
|
||||
return [{ count: r }];
|
||||
},
|
||||
} as unknown as BrainEngine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the nudge with stderr forced to look like a TTY and its writes
|
||||
* captured. Restores both in finally so no other test sees the patch.
|
||||
*/
|
||||
async function runNudgeCaptured(engine: BrainEngine): Promise<string> {
|
||||
const origIsTTY = process.stderr.isTTY;
|
||||
const origWrite = process.stderr.write;
|
||||
let out = '';
|
||||
try {
|
||||
(process.stderr as unknown as { isTTY: boolean }).isTTY = true;
|
||||
process.stderr.write = ((chunk: unknown) => {
|
||||
out += String(chunk);
|
||||
return true;
|
||||
}) as typeof process.stderr.write;
|
||||
await runInitNudge(engine);
|
||||
} finally {
|
||||
process.stderr.write = origWrite;
|
||||
(process.stderr as unknown as { isTTY: boolean | undefined }).isTTY = origIsTTY;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('runInitNudge — empty-brain suppression', () => {
|
||||
test('empty brain (pages 0, takes 0) prints NOTHING', async () => {
|
||||
const out = await runNudgeCaptured(
|
||||
stubEngine({ stale: 0, entities: 0, linked: 0, timeline: 0, takes: 0, pages: 0 }),
|
||||
);
|
||||
expect(out).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runInitNudge — pages probe failure is fail-open', () => {
|
||||
test('pages probe REJECTS with takes 0 → nudge still fires with "0 takes"', async () => {
|
||||
// The -1 sentinel means "count unknown" — treat as non-empty so the
|
||||
// pre-existing behavior (nudge on 0 takes) is preserved.
|
||||
const out = await runNudgeCaptured(
|
||||
stubEngine({
|
||||
stale: 0,
|
||||
entities: 0,
|
||||
linked: 0,
|
||||
timeline: 0,
|
||||
takes: 0,
|
||||
pages: new Error('pages probe failed'),
|
||||
}),
|
||||
);
|
||||
expect(out).toContain('Brain has opportunities');
|
||||
expect(out).toContain('0 takes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runInitNudge — partial-checks notice', () => {
|
||||
test('non-empty healthy brain with one rejected arm → "Init checks incomplete"', async () => {
|
||||
// pages > 0 (non-empty), takes > 0 (no opportunity part), entities 0
|
||||
// (coverage arms vacuous), stale 0 — but the linked probe rejects, so
|
||||
// partial=true with zero parts → the incomplete-checks line.
|
||||
const out = await runNudgeCaptured(
|
||||
stubEngine({
|
||||
stale: 0,
|
||||
entities: 0,
|
||||
linked: new Error('linked probe failed'),
|
||||
timeline: 0,
|
||||
takes: 5,
|
||||
pages: 10,
|
||||
}),
|
||||
);
|
||||
expect(out).toContain('Init checks incomplete');
|
||||
expect(out).toContain('(5/6)');
|
||||
expect(out).toContain('gbrain onboard --check');
|
||||
expect(out).not.toContain('Brain has opportunities');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runInitNudge — non-empty brain opportunities', () => {
|
||||
test('non-empty brain with takes 0 → "Brain has opportunities: 0 takes"', async () => {
|
||||
const out = await runNudgeCaptured(
|
||||
stubEngine({ stale: 0, entities: 0, linked: 0, timeline: 0, takes: 0, pages: 10 }),
|
||||
);
|
||||
expect(out).toContain('Brain has opportunities: 0 takes');
|
||||
expect(out).toContain("Run 'gbrain onboard --check' to see the plan");
|
||||
// All 6 probes succeeded — no partial-checks suffix.
|
||||
expect(out).not.toContain('checks complete');
|
||||
});
|
||||
});
|
||||
@@ -44,22 +44,62 @@ describe('pickProvider — defensive paths', () => {
|
||||
// OPENAI_API_KEY set → openai is env-ready. readLineSafe returns the
|
||||
// default '1' in non-stdin-TTY bun:test mode, so picker picks the first
|
||||
// ready recipe deterministically. We mostly want to verify NO null
|
||||
// return and a sensible payload shape.
|
||||
// return and a sensible payload shape. probeLocal is stubbed unreachable
|
||||
// so ollama drops out and the unit test never touches the network.
|
||||
let stderr = '';
|
||||
const got = await pickProvider({
|
||||
touchpoint: 'embedding',
|
||||
env: { OPENAI_API_KEY: 'sk-test' },
|
||||
isTTY: true,
|
||||
writeStderr: (s) => { stderr += s; },
|
||||
probeLocal: async () => ({ reachable: false }),
|
||||
});
|
||||
expect(got).not.toBeNull();
|
||||
if (got) {
|
||||
expect(got.fullModel).toMatch(/:/); // provider:model shape
|
||||
expect(got.dim).toBeGreaterThan(0); // embedding always has dims
|
||||
expect(stderr).toContain('Pick a embedding provider');
|
||||
expect(stderr).toContain('Pick an embedding provider');
|
||||
// Keyless is always an explicit option for embedding.
|
||||
expect(stderr).toContain('0) none — continue keyless');
|
||||
}
|
||||
});
|
||||
|
||||
test('keyless machine (no keys, ollama daemon down) → keyless default, returns null', async () => {
|
||||
let stderr = '';
|
||||
const got = await pickProvider({
|
||||
touchpoint: 'embedding',
|
||||
env: {},
|
||||
isTTY: true,
|
||||
writeStderr: (s) => { stderr += s; },
|
||||
probeLocal: async () => ({ reachable: false }),
|
||||
});
|
||||
// No keyed provider ready → default is 0 (keyless) → null return; the
|
||||
// caller continues keyless. A bare Enter can no longer select a broken
|
||||
// local daemon. (readLineSafe resolves the default in bun:test's
|
||||
// non-stdin-TTY mode, so the null return IS the default-path proof.)
|
||||
expect(got).toBeNull();
|
||||
expect(stderr).toContain('0) none — continue keyless');
|
||||
});
|
||||
|
||||
test('ollama daemon up but model not pulled → annotated with the pull fix, keyless still default', async () => {
|
||||
let stderr = '';
|
||||
const got = await pickProvider({
|
||||
touchpoint: 'embedding',
|
||||
env: {},
|
||||
isTTY: true,
|
||||
writeStderr: (s) => { stderr += s; },
|
||||
probeLocal: async () => ({
|
||||
reachable: true,
|
||||
models_endpoint_valid: true,
|
||||
models: ['some-other-model'],
|
||||
}),
|
||||
});
|
||||
expect(stderr).toContain('model not pulled — run: ollama pull');
|
||||
// Daemon-up-model-missing must NOT be the bare-Enter default: with no
|
||||
// keyed provider ready, the default resolves to 0 (keyless) → null.
|
||||
expect(got).toBeNull();
|
||||
});
|
||||
|
||||
test('caveat fires when picking non-Anthropic chat without ANTHROPIC_API_KEY', async () => {
|
||||
let stderr = '';
|
||||
const got = await pickProvider({
|
||||
@@ -118,6 +158,7 @@ describe('pickProvider — defensive paths', () => {
|
||||
env: { OPENAI_API_KEY: 'sk-test' },
|
||||
isTTY: true,
|
||||
writeStderr: (s) => { stderr += s; },
|
||||
probeLocal: async () => ({ reachable: false }),
|
||||
});
|
||||
expect(stderr).toContain('embedding provider');
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ import { CONFORMANCE_CASES } from '../src/core/verbs/conformance-fixtures.ts';
|
||||
import { writeSingleFact } from '../src/core/facts/write-single.ts';
|
||||
import {
|
||||
configureGateway,
|
||||
resetGateway,
|
||||
__setChatTransportForTests,
|
||||
__setEmbedTransportForTests,
|
||||
} from '../src/core/ai/gateway.ts';
|
||||
@@ -61,6 +62,15 @@ beforeAll(async () => {
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
__setUsageLogPathForTests(null);
|
||||
// The deterministic-embedder tests configureGateway() with a FAKE OpenAI
|
||||
// key on the MODULE-GLOBAL gateway. Without a reset, every later file in
|
||||
// this shard process inherits "embeddings configured" and (with the test
|
||||
// transport also cleared) fires a REAL API call with the fake key — the
|
||||
// shard-8 turn-context 401 flake. Reset config AND both transports so the
|
||||
// file leaves the process exactly as it found it.
|
||||
resetGateway();
|
||||
__setChatTransportForTests(null);
|
||||
__setEmbedTransportForTests(null);
|
||||
try { rmSync(home, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Quiet fresh-install migration replay (src/core/migrate.ts runMigrations).
|
||||
*
|
||||
* Pins:
|
||||
* - Fresh brain (version 1, all migrations pending) replays quietly: ONE
|
||||
* "Setting up brain schema (vN)..." line, no per-migration "[N] name..."
|
||||
* lines, and the v123/v124 handler notices are suppressed.
|
||||
* - GBRAIN_MIGRATE_VERBOSE=1 escape hatch restores full verbose output on
|
||||
* a fresh brain.
|
||||
* - Upgrades (current > 1) keep the full verbose "Schema version X → Y"
|
||||
* narration — including an in-process upgrade AFTER a quiet fresh replay,
|
||||
* which proves the module-level quietMigrationNotices flag is reset in
|
||||
* the finally and does not leak.
|
||||
* - A no-pending run applies nothing and stays silent.
|
||||
*
|
||||
* Serial: mutates process.env (GBRAIN_MIGRATE_VERBOSE, GBRAIN_PGLITE_SNAPSHOT)
|
||||
* and monkey-patches process.stderr.write; everything is saved in beforeAll /
|
||||
* restored in afterAll or per-test try/finally (repo rule R1).
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { runMigrations, LATEST_VERSION } from '../src/core/migrate.ts';
|
||||
|
||||
let engineA: PGLiteEngine; // fresh quiet replay + later upgrade re-run
|
||||
let engineB: PGLiteEngine | null = null; // fresh verbose replay (test 2)
|
||||
|
||||
let prevVerbose: string | undefined;
|
||||
let prevSnapshot: string | undefined;
|
||||
|
||||
/** Capture everything written to process.stderr.write while fn runs. */
|
||||
async function captureStderr(fn: () => Promise<void>): Promise<string> {
|
||||
const orig = process.stderr.write.bind(process.stderr);
|
||||
let out = '';
|
||||
process.stderr.write = ((chunk: string | Uint8Array): boolean => {
|
||||
out += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8');
|
||||
return true;
|
||||
}) as typeof process.stderr.write;
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
process.stderr.write = orig;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
prevVerbose = process.env.GBRAIN_MIGRATE_VERBOSE;
|
||||
prevSnapshot = process.env.GBRAIN_PGLITE_SNAPSHOT;
|
||||
// Default state for the file: quiet mode active, and NO snapshot fast-path —
|
||||
// a snapshot-restored engine skips runMigrations entirely, which would make
|
||||
// every assertion below vacuous.
|
||||
delete process.env.GBRAIN_MIGRATE_VERBOSE;
|
||||
delete process.env.GBRAIN_PGLITE_SNAPSHOT;
|
||||
|
||||
engineA = new PGLiteEngine();
|
||||
await engineA.connect({});
|
||||
// NOTE: initSchema() deliberately NOT called here — test 1 captures its
|
||||
// stderr output as the fresh-install replay under test.
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (prevVerbose === undefined) delete process.env.GBRAIN_MIGRATE_VERBOSE;
|
||||
else process.env.GBRAIN_MIGRATE_VERBOSE = prevVerbose;
|
||||
if (prevSnapshot === undefined) delete process.env.GBRAIN_PGLITE_SNAPSHOT;
|
||||
else process.env.GBRAIN_PGLITE_SNAPSHOT = prevSnapshot;
|
||||
|
||||
await engineA.disconnect();
|
||||
if (engineB) await engineB.disconnect();
|
||||
});
|
||||
|
||||
describe('quiet fresh-install replay', () => {
|
||||
test('fresh brain prints one summary line, no per-migration or notice lines', async () => {
|
||||
const out = await captureStderr(async () => {
|
||||
await engineA.initSchema();
|
||||
});
|
||||
|
||||
expect(out).toContain('Setting up brain schema (v');
|
||||
// No per-migration " [N] name..." progress lines.
|
||||
expect(out).not.toMatch(/\[\d+\] \S+\.\.\./);
|
||||
// No " [N] ✓ name" completion lines.
|
||||
expect(out).not.toContain('✓');
|
||||
// The verbose header is replaced, not printed alongside.
|
||||
expect(out).not.toContain('Schema version 1 →');
|
||||
// v123/v124 handler notices are suppressed via quietMigrationNotices.
|
||||
expect(out).not.toContain('v123:');
|
||||
expect(out).not.toContain('v124:');
|
||||
|
||||
// The replay actually ran to completion.
|
||||
expect(await engineA.getConfig('version')).toBe(String(LATEST_VERSION));
|
||||
});
|
||||
|
||||
test('GBRAIN_MIGRATE_VERBOSE=1 keeps full output on a fresh brain', async () => {
|
||||
process.env.GBRAIN_MIGRATE_VERBOSE = '1';
|
||||
try {
|
||||
engineB = new PGLiteEngine();
|
||||
await engineB.connect({});
|
||||
const out = await captureStderr(async () => {
|
||||
await engineB!.initSchema();
|
||||
});
|
||||
|
||||
expect(out).toContain('Schema version 1 →');
|
||||
expect(out).toMatch(/\[\d+\] ✓ /);
|
||||
expect(out).not.toContain('Setting up brain schema');
|
||||
} finally {
|
||||
delete process.env.GBRAIN_MIGRATE_VERBOSE;
|
||||
}
|
||||
});
|
||||
|
||||
test('upgrade path (current > 1) stays verbose — and the quiet flag did not leak from test 1', async () => {
|
||||
// Rewind engineA one version so exactly the last migration is pending.
|
||||
// This runs in the SAME process AFTER test 1's quiet replay, so verbose
|
||||
// output here also proves runMigrations' finally reset quietMigrationNotices.
|
||||
await engineA.setConfig('version', String(LATEST_VERSION - 1));
|
||||
|
||||
let result: { applied: number; current: number } | undefined;
|
||||
const out = await captureStderr(async () => {
|
||||
result = await runMigrations(engineA);
|
||||
});
|
||||
|
||||
expect(result?.applied).toBe(1);
|
||||
expect(result?.current).toBe(LATEST_VERSION);
|
||||
expect(out).toContain(`Schema version ${LATEST_VERSION - 1} → `);
|
||||
expect(out).toContain('✓');
|
||||
expect(out).not.toContain('Setting up brain schema');
|
||||
});
|
||||
|
||||
test('no-pending run applies nothing and emits no setup/migration lines', async () => {
|
||||
let result: { applied: number; current: number } | undefined;
|
||||
const out = await captureStderr(async () => {
|
||||
result = await runMigrations(engineA);
|
||||
});
|
||||
|
||||
expect(result?.applied).toBe(0);
|
||||
expect(result?.current).toBe(LATEST_VERSION);
|
||||
expect(out).not.toContain('Setting up brain schema');
|
||||
expect(out).not.toContain('Schema version');
|
||||
expect(out).not.toContain('✓');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { currentRecommendedSet } from '../src/core/advisor/recommended-set.ts';
|
||||
|
||||
describe('plugin membership curation (skills = plugin ∪ exclusions, disjoint)', () => {
|
||||
const root = join(import.meta.dir, '..');
|
||||
@@ -56,6 +57,18 @@ describe('plugin membership curation (skills = plugin ∪ exclusions, disjoint)'
|
||||
it('plugin skills has no duplicates', () => {
|
||||
expect(new Set(plugin.skills).size).toBe(plugin.skills.length);
|
||||
});
|
||||
|
||||
it('every RECOMMENDED skill is bundled (recommended-but-unscaffoldable is a broken funnel)', () => {
|
||||
// The post-install advisory + verify hand-off tell users to install these
|
||||
// by slug; `gbrain skillpack scaffold <slug>` resolves against the plugin
|
||||
// bundle. A recommendation the scaffold can't fulfill is a dead-end CTA —
|
||||
// the exact drift that kept cold-start (the day-one "now what?" skill)
|
||||
// unreachable for paste-in bootstrap users until v0.45.11.0.
|
||||
const unscaffoldable = currentRecommendedSet()
|
||||
.map((s) => s.slug)
|
||||
.filter((slug) => !bundled.includes(slug));
|
||||
expect(unscaffoldable).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bundled-skill reference closure (nothing bundled points at a non-bundled skill)', () => {
|
||||
|
||||
@@ -25,7 +25,9 @@ import { tmpdir } from 'os';
|
||||
import {
|
||||
buildAdvisory,
|
||||
detectInstalledSlugs,
|
||||
printAdvisoryIfRecommended,
|
||||
} from '../src/core/skillpack/post-install-advisory.ts';
|
||||
import { currentRecommendedSet } from '../src/core/advisor/recommended-set.ts';
|
||||
|
||||
const cleanup: string[] = [];
|
||||
|
||||
@@ -90,10 +92,20 @@ describe('detectInstalledSlugs', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('recommended set — OOBE invariants', () => {
|
||||
it('cold-start is the FIRST recommendation (the day-one "now what?" answer)', () => {
|
||||
// The compact init advisory previews the first slugs and `gbrain advisor`
|
||||
// ranks by list order — cold-start leads because every other recommended
|
||||
// skill only becomes magical once the brain holds the user's real life.
|
||||
expect(currentRecommendedSet()[0]!.slug).toBe('cold-start');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAdvisory — partial-install path', () => {
|
||||
it('lists ONLY missing skills when most are already installed', () => {
|
||||
const { workspace, skillsDir } = scratchWorkspace([
|
||||
'brain-ops',
|
||||
'cold-start',
|
||||
'article-enrichment',
|
||||
'strategic-reading',
|
||||
'concept-synthesis',
|
||||
@@ -135,6 +147,7 @@ describe('buildAdvisory — partial-install path', () => {
|
||||
describe('buildAdvisory — all-installed → null (no nag)', () => {
|
||||
it('returns null when every recommended skill is already installed', () => {
|
||||
const allRecommended = [
|
||||
'cold-start',
|
||||
'book-mirror',
|
||||
'article-enrichment',
|
||||
'strategic-reading',
|
||||
@@ -218,6 +231,72 @@ describe('buildAdvisory — agent-readable framing', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('printAdvisoryIfRecommended — compact init pointer vs full upgrade banner', () => {
|
||||
function captureStderr(fn: () => void): string {
|
||||
const orig = process.stderr.write;
|
||||
let out = '';
|
||||
process.stderr.write = ((chunk: unknown) => {
|
||||
out += String(chunk);
|
||||
return true;
|
||||
}) as typeof process.stderr.write;
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
process.stderr.write = orig;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
it('context init with all skills missing prints the compact human-voiced pointer', () => {
|
||||
const { workspace, skillsDir } = scratchWorkspace([]);
|
||||
const out = captureStderr(() =>
|
||||
printAdvisoryIfRecommended({
|
||||
version: '0.25.1',
|
||||
context: 'init',
|
||||
targetWorkspace: workspace,
|
||||
targetSkillsDir: skillsDir,
|
||||
}),
|
||||
);
|
||||
const names = currentRecommendedSet().map((s) => s.slug);
|
||||
expect(out).toContain('recommended skill(s) not installed yet');
|
||||
// Preview truncates at 4 slugs + ellipsis; the 5th slug never appears
|
||||
// (the scaffold command is --all when everything is missing).
|
||||
expect(out).toContain(`(${names.slice(0, 4).join(', ')}, …)`);
|
||||
expect(out).not.toContain(names[4]);
|
||||
expect(out).toContain('gbrain advisor');
|
||||
// The compact init pointer is human-voiced — no agent stage directions.
|
||||
expect(out).not.toContain('ACTION FOR THE AGENT');
|
||||
expect(out).not.toContain('[AGENT]');
|
||||
});
|
||||
|
||||
it('context init with everything installed prints NOTHING', () => {
|
||||
const allSlugs = currentRecommendedSet().map((s) => s.slug);
|
||||
const { workspace, skillsDir } = scratchWorkspace(allSlugs);
|
||||
const out = captureStderr(() =>
|
||||
printAdvisoryIfRecommended({
|
||||
version: '0.25.1',
|
||||
context: 'init',
|
||||
targetWorkspace: workspace,
|
||||
targetSkillsDir: skillsDir,
|
||||
}),
|
||||
);
|
||||
expect(out).toBe('');
|
||||
});
|
||||
|
||||
it('context upgrade with missing skills keeps the full agent-addressed banner', () => {
|
||||
const { workspace, skillsDir } = scratchWorkspace([]);
|
||||
const out = captureStderr(() =>
|
||||
printAdvisoryIfRecommended({
|
||||
version: '0.25.1',
|
||||
context: 'upgrade',
|
||||
targetWorkspace: workspace,
|
||||
targetSkillsDir: skillsDir,
|
||||
}),
|
||||
);
|
||||
expect(out).toContain('ACTION FOR THE AGENT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAdvisory — no workspace detected', () => {
|
||||
it('still renders an advisory with a workspace-detection note', () => {
|
||||
const advisory = buildAdvisory({
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Unit tests for `pendingUpgradeVersion` — the ONE shared "is an upgrade
|
||||
* actually pending for THIS binary?" predicate (src/core/self-upgrade.ts).
|
||||
*
|
||||
* Every upgrade-nag surface (CLI startup marker, doctor, advisor,
|
||||
* get_brain_identity) routes through it, so this file pins the suppression
|
||||
* rule centrally: a stale or foreign cache (latest <= running version) must
|
||||
* return null. The cache records the version of whatever binary WROTE it —
|
||||
* an older gbrain on PATH can write `UPGRADE_AVAILABLE 0.0.1 X` — so the
|
||||
* comparison must be against the RUNNING version, never `marker.current`.
|
||||
*
|
||||
* Also covers the advisor consumer (collect-version.ts): version_drift fires
|
||||
* only when the shared predicate says an upgrade is pending.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, utimesSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { withEnv } from './helpers/with-env.ts';
|
||||
import {
|
||||
CACHE_TTL_UPGRADE_AVAILABLE_MS,
|
||||
pendingUpgradeVersion,
|
||||
updateCachePath,
|
||||
writeUpdateCache,
|
||||
} from '../src/core/self-upgrade.ts';
|
||||
import { collectVersion } from '../src/core/advisor/collect-version.ts';
|
||||
import type { AdvisorContext } from '../src/core/advisor/types.ts';
|
||||
|
||||
/** Run `fn` with GBRAIN_HOME pointed at a fresh temp dir (env restored after). */
|
||||
async function withHome<T>(fn: (home: string) => T | Promise<T>): Promise<T> {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gbrain-pending-'));
|
||||
try {
|
||||
return await withEnv({ GBRAIN_HOME: dir }, () => fn(dir));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Backdate the cache file's mtime so `isCacheFresh` sees it as expired. */
|
||||
function backdateCache(byMs: number): void {
|
||||
const then = new Date(Date.now() - byMs);
|
||||
utimesSync(updateCachePath(), then, then);
|
||||
}
|
||||
|
||||
describe('pendingUpgradeVersion', () => {
|
||||
test('fresh cache, latest > running → returns latest', async () => {
|
||||
await withHome(() => {
|
||||
writeUpdateCache({ kind: 'upgrade_available', current: '0.42.0', latest: '0.99.0' });
|
||||
expect(pendingUpgradeVersion('0.42.0')).toBe('0.99.0');
|
||||
});
|
||||
});
|
||||
|
||||
test('fresh cache, latest == running → null (already current; suppress the nag)', async () => {
|
||||
await withHome(() => {
|
||||
writeUpdateCache({ kind: 'upgrade_available', current: '0.42.0', latest: '0.42.0' });
|
||||
expect(pendingUpgradeVersion('0.42.0')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('fresh cache, latest < running → null (downgrade/yanked never nags)', async () => {
|
||||
await withHome(() => {
|
||||
writeUpdateCache({ kind: 'upgrade_available', current: '0.0.1', latest: '0.42.0' });
|
||||
expect(pendingUpgradeVersion('0.99.0')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('foreign-writer cache: comparison is against the RUNNING version, not marker.current', async () => {
|
||||
await withHome(() => {
|
||||
// An old 0.0.1 binary on PATH wrote the cache. The running binary must
|
||||
// compare ITS version to latest — marker.current is untrusted.
|
||||
writeUpdateCache({ kind: 'upgrade_available', current: '0.0.1', latest: '0.99.0' });
|
||||
expect(pendingUpgradeVersion('0.99.0')).toBeNull(); // already on latest → suppressed
|
||||
expect(pendingUpgradeVersion('0.42.0')).toBe('0.99.0'); // genuinely behind → nag
|
||||
});
|
||||
});
|
||||
|
||||
test('stale cache (mtime beyond upgrade_available TTL) → null', async () => {
|
||||
await withHome(() => {
|
||||
writeUpdateCache({ kind: 'upgrade_available', current: '0.42.0', latest: '0.99.0' });
|
||||
backdateCache(CACHE_TTL_UPGRADE_AVAILABLE_MS + 60_000);
|
||||
expect(pendingUpgradeVersion('0.42.0')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('missing cache → null', async () => {
|
||||
await withHome(() => {
|
||||
expect(pendingUpgradeVersion('0.42.0')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('up_to_date marker kind → null', async () => {
|
||||
await withHome(() => {
|
||||
writeUpdateCache({ kind: 'up_to_date', current: '0.42.0' });
|
||||
expect(pendingUpgradeVersion('0.42.0')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('corrupt cache content → null (never throws)', async () => {
|
||||
await withHome((home) => {
|
||||
mkdirSync(join(home, '.gbrain'), { recursive: true });
|
||||
writeFileSync(updateCachePath(), 'UPGRADE_AVAILABLE not-a-version; rm -rf /\n');
|
||||
expect(pendingUpgradeVersion('0.42.0')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('advisor collectVersion (consumer of the shared predicate)', () => {
|
||||
const ctx = (version: string) => ({ version } as unknown as AdvisorContext);
|
||||
|
||||
test('fresh cache with newer latest → one version_drift finding', async () => {
|
||||
await withHome(async () => {
|
||||
writeUpdateCache({ kind: 'upgrade_available', current: '0.42.0', latest: '0.99.0' });
|
||||
const findings = await collectVersion.collect(ctx('0.42.0'));
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0].id).toBe('version_drift');
|
||||
expect(findings[0].title).toContain('0.99.0');
|
||||
expect(findings[0].title).toContain('0.42.0');
|
||||
});
|
||||
});
|
||||
|
||||
test('latest == ctx.version → no findings (stale/foreign cache suppressed)', async () => {
|
||||
await withHome(async () => {
|
||||
writeUpdateCache({ kind: 'upgrade_available', current: '0.42.0', latest: '0.99.0' });
|
||||
const findings = await collectVersion.collect(ctx('0.99.0'));
|
||||
expect(findings).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Unit suite for test/helpers/tty-harness.ts — the PTY DX harness.
|
||||
*
|
||||
* Pure helpers (stripAnsi, computeStalls, renderStallsReport,
|
||||
* parseDriveCommand, buildClaudeTuiSeed, saveTranscript) are exercised with
|
||||
* ZERO subprocesses. Two live smokes spawn `sh` under a real PTY (cheap,
|
||||
* no network, no API) and skip cleanly on a Bun without `terminal:` support
|
||||
* — the same fail-SKIP posture as the agent-harness door tests.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { mkdtempSync, readFileSync, rmSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import {
|
||||
stripAnsi,
|
||||
computeStalls,
|
||||
renderStallsReport,
|
||||
parseDriveCommand,
|
||||
buildClaudeTuiSeed,
|
||||
saveTranscript,
|
||||
launchTty,
|
||||
ptySupported,
|
||||
KEY_MAP,
|
||||
type PtyFrame,
|
||||
} from './helpers/tty-harness.ts';
|
||||
|
||||
describe('stripAnsi', () => {
|
||||
test('removes CSI color + cursor sequences', () => {
|
||||
expect(stripAnsi('\x1b[1;32mgreen\x1b[0m plain \x1b[2K\x1b[1Gline')).toBe('green plain line');
|
||||
});
|
||||
|
||||
test('removes OSC title sequences (BEL and ST terminated)', () => {
|
||||
expect(stripAnsi('\x1b]0;title\x07text')).toBe('text');
|
||||
expect(stripAnsi('\x1b]8;;http://x\x1b\\link')).toBe('link');
|
||||
});
|
||||
|
||||
test('removes charset selection and keypad modes', () => {
|
||||
expect(stripAnsi('\x1b(Bhello\x1b=world\x1b>')).toBe('helloworld');
|
||||
});
|
||||
|
||||
test('removes private-mode CSI (cursor hide/show)', () => {
|
||||
expect(stripAnsi('\x1b[?25lhidden\x1b[?25h')).toBe('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeStalls', () => {
|
||||
const frames: PtyFrame[] = [
|
||||
{ tMs: 100, data: 'boot\n' },
|
||||
{ tMs: 300, data: 'fast\n' },
|
||||
{ tMs: 5300, data: 'after long silence\n' },
|
||||
{ tMs: 5400, data: 'tail\n' },
|
||||
];
|
||||
|
||||
test('finds mid-run gaps over the threshold with the pre-gap screen as context', () => {
|
||||
const stalls = computeStalls(frames, { thresholdMs: 2000 });
|
||||
expect(stalls.length).toBe(1);
|
||||
expect(stalls[0]!.startMs).toBe(300);
|
||||
expect(stalls[0]!.durationMs).toBe(5000);
|
||||
expect(stalls[0]!.context).toContain('fast');
|
||||
});
|
||||
|
||||
test('counts startup silence (spawn → first byte)', () => {
|
||||
const late: PtyFrame[] = [{ tMs: 4000, data: 'finally\n' }];
|
||||
const stalls = computeStalls(late, { thresholdMs: 2000 });
|
||||
expect(stalls.length).toBe(1);
|
||||
expect(stalls[0]!.startMs).toBe(0);
|
||||
expect(stalls[0]!.context).toBe('(no output yet)');
|
||||
});
|
||||
|
||||
test('counts trailing silence when endMs is supplied', () => {
|
||||
const stalls = computeStalls(frames, { thresholdMs: 2000, endMs: 12_000 });
|
||||
expect(stalls.length).toBe(2);
|
||||
expect(stalls[1]!.startMs).toBe(5400);
|
||||
expect(stalls[1]!.durationMs).toBe(6600);
|
||||
expect(stalls[1]!.context).toContain('tail');
|
||||
});
|
||||
|
||||
test('below-threshold gaps are ignored', () => {
|
||||
expect(computeStalls(frames, { thresholdMs: 6000 })).toEqual([]);
|
||||
});
|
||||
|
||||
test('empty frames + endMs = one all-silence stall', () => {
|
||||
const stalls = computeStalls([], { thresholdMs: 2000, endMs: 3000 });
|
||||
expect(stalls.length).toBe(1);
|
||||
expect(stalls[0]!.durationMs).toBe(3000);
|
||||
});
|
||||
|
||||
test('empty frames without endMs = no stalls', () => {
|
||||
expect(computeStalls([], { thresholdMs: 2000 })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderStallsReport', () => {
|
||||
test('renders duration + context per stall', () => {
|
||||
const md = renderStallsReport(
|
||||
[{ startMs: 300, durationMs: 5000, context: 'Loading brain…' }],
|
||||
10_000,
|
||||
);
|
||||
expect(md).toContain('5.0s at t+0.3s');
|
||||
expect(md).toContain('Loading brain…');
|
||||
expect(md).toContain('1 silence window');
|
||||
});
|
||||
|
||||
test('clean report when no stalls', () => {
|
||||
expect(renderStallsReport([], 4000)).toContain('No stalls at threshold');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDriveCommand', () => {
|
||||
test('parses line / send / key / note / stop', () => {
|
||||
expect(parseDriveCommand('{"line":"hello"}')).toEqual({ kind: 'send', data: 'hello\r' });
|
||||
expect(parseDriveCommand('{"send":"hello\\r"}')).toEqual({ kind: 'send', data: 'hello\r' });
|
||||
expect(parseDriveCommand('{"key":"Enter"}')).toEqual({ kind: 'key', key: 'Enter' });
|
||||
expect(parseDriveCommand('{"note":"confusing picker"}')).toEqual({
|
||||
kind: 'note',
|
||||
text: 'confusing picker',
|
||||
});
|
||||
expect(parseDriveCommand('{"stop":true}')).toEqual({ kind: 'stop' });
|
||||
});
|
||||
|
||||
test('line strips its own trailing newline before appending Enter', () => {
|
||||
expect(parseDriveCommand('{"line":"hello\\n"}')).toEqual({ kind: 'send', data: 'hello\r' });
|
||||
});
|
||||
|
||||
test('re-escapes raw control bytes that zsh echo produces from \\r', () => {
|
||||
// A literal CR byte inside the JSON string (what `echo '{"send":"x\r"}'`
|
||||
// yields under zsh) must parse instead of being dropped.
|
||||
expect(parseDriveCommand('{"send":"x\r"}')).toEqual({ kind: 'send', data: 'x\r' });
|
||||
expect(parseDriveCommand('{"line":"y\r"}')).toEqual({ kind: 'send', data: 'y\r' });
|
||||
});
|
||||
|
||||
test('rejects malformed JSON, unknown keys, unknown key names', () => {
|
||||
expect(parseDriveCommand('not json')).toBeNull();
|
||||
expect(parseDriveCommand('{"frobnicate":1}')).toBeNull();
|
||||
expect(parseDriveCommand('{"key":"HyperMeta"}')).toBeNull();
|
||||
expect(parseDriveCommand('{"stop":false}')).toBeNull();
|
||||
expect(parseDriveCommand('null')).toBeNull();
|
||||
expect(parseDriveCommand('"str"')).toBeNull();
|
||||
});
|
||||
|
||||
test('every KEY_MAP name round-trips through the key command', () => {
|
||||
for (const name of Object.keys(KEY_MAP)) {
|
||||
expect(parseDriveCommand(JSON.stringify({ key: name }))).toEqual({ kind: 'key', key: name });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildClaudeTuiSeed', () => {
|
||||
test('marks onboarding complete and pre-trusts dirs', () => {
|
||||
const seed = buildClaudeTuiSeed({ trustedDirs: ['/tmp/ws-a', '/tmp/ws-b'] });
|
||||
expect(seed.hasCompletedOnboarding).toBe(true);
|
||||
const projects = seed.projects as Record<string, { hasTrustDialogAccepted: boolean }>;
|
||||
expect(projects['/tmp/ws-a']!.hasTrustDialogAccepted).toBe(true);
|
||||
expect(projects['/tmp/ws-b']!.hasTrustDialogAccepted).toBe(true);
|
||||
expect(seed.customApiKeyResponses).toBeUndefined();
|
||||
});
|
||||
|
||||
test('approves the last 20 chars of a provided API key', () => {
|
||||
const key = 'sk-ant-' + 'x'.repeat(40);
|
||||
const seed = buildClaudeTuiSeed({ apiKey: key, trustedDirs: [] });
|
||||
expect(seed.customApiKeyResponses).toEqual({ approved: [key.slice(-20)] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveTranscript', () => {
|
||||
test('writes the full bundle (meta, raw, visible, frames, stalls)', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gb-tty-unit-'));
|
||||
try {
|
||||
const frames: PtyFrame[] = [
|
||||
{ tMs: 50, data: '\x1b[32mready\x1b[0m\n' },
|
||||
{ tMs: 4050, data: 'done\n' },
|
||||
];
|
||||
saveTranscript(dir, {
|
||||
frames,
|
||||
raw: frames.map((f) => f.data).join(''),
|
||||
meta: {
|
||||
scenario: 'unit',
|
||||
argv: ['sh', '-c', 'x'],
|
||||
startedAtIso: '2026-08-12T00:00:00.000Z',
|
||||
exitCode: 0,
|
||||
durationMs: 4100,
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf8')).scenario).toBe('unit');
|
||||
expect(readFileSync(join(dir, 'visible.txt'), 'utf8')).toBe('ready\ndone\n');
|
||||
expect(readFileSync(join(dir, 'raw.txt'), 'utf8')).toContain('\x1b[32m');
|
||||
const frameLines = readFileSync(join(dir, 'frames.jsonl'), 'utf8').trim().split('\n');
|
||||
expect(frameLines.length).toBe(2);
|
||||
expect(readFileSync(join(dir, 'stalls.md'), 'utf8')).toContain('4.0s at t+0.1s');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!ptySupported())('launchTty (live PTY smoke)', () => {
|
||||
test('captures timestamped frames from a real PTY child', async () => {
|
||||
const session = launchTty(['sh', '-c', 'printf one; sleep 0.6; printf two'], {
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
const code = await session.waitForExit(10_000);
|
||||
await session.close();
|
||||
expect(code).toBe(0);
|
||||
expect(session.visible()).toContain('one');
|
||||
expect(session.visible()).toContain('two');
|
||||
const frames = session.frames();
|
||||
expect(frames.length).toBeGreaterThanOrEqual(2);
|
||||
// The 600ms sleep shows up as a measurable gap (loose bound: >= 300ms).
|
||||
// Match the specific stall rather than index 0 — a slow spawn on a loaded
|
||||
// CI box can prepend a startup stall ('(no output yet)') before it.
|
||||
const stalls = computeStalls(frames, { thresholdMs: 300 });
|
||||
expect(stalls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(stalls.some((s) => s.context.includes('one'))).toBe(true);
|
||||
}, 20_000);
|
||||
|
||||
test('send + waitFor drive an interactive child; child sees a real TTY', async () => {
|
||||
// Assert TTY-ness via `[ -t 0 ]` + a sentinel, NOT by matching the tty(1)
|
||||
// device path — macOS PTYs are /dev/ttysNNN but Linux CI PTYs are
|
||||
// /dev/pts/N, so a /dev\/tty pattern is a portability trap (bit us on CI).
|
||||
const session = launchTty(['sh', '-c', '[ -t 0 ] && echo IS_TTY || echo NOT_TTY; read line; echo "got:$line"'], {
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
await session.waitFor('IS_TTY', { timeoutMs: 8000 });
|
||||
session.send('ping\r');
|
||||
await session.waitFor('got:ping', { timeoutMs: 8000 });
|
||||
await session.waitForExit(5000);
|
||||
await session.close();
|
||||
expect(session.exited()).toBe(true);
|
||||
expect(session.visible()).not.toContain('NOT_TTY');
|
||||
}, 20_000);
|
||||
|
||||
test('waitForQuiet settles after output stops and reports exit as quiet', async () => {
|
||||
const session = launchTty(['sh', '-c', 'printf a; sleep 0.2; printf b'], {
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
const quiet = await session.waitForQuiet({ quietMs: 500, timeoutMs: 10_000 });
|
||||
expect(quiet).toBe(true);
|
||||
await session.close();
|
||||
}, 20_000);
|
||||
});
|
||||
@@ -7,6 +7,11 @@
|
||||
* in the module under test).
|
||||
*/
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||||
import {
|
||||
resetGateway,
|
||||
__setChatTransportForTests,
|
||||
__setEmbedTransportForTests,
|
||||
} from '../src/core/ai/gateway.ts';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import {
|
||||
assembleTurnContext,
|
||||
@@ -33,6 +38,15 @@ import {
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
// This file's corpus writes fire embed attempts iff the module-global
|
||||
// gateway looks configured AND keyed. A shard-mate can leave it configured
|
||||
// with a fake test key — the preload's beforeEach only restores when the
|
||||
// gateway is UNCONFIGURED, so that state persists and put_page 401s against
|
||||
// real OpenAI (the shard-8 flake). Reset back to the preload baseline
|
||||
// (real process.env → keyless degrade on CI) regardless of shard-mates.
|
||||
resetGateway();
|
||||
__setChatTransportForTests(null);
|
||||
__setEmbedTransportForTests(null);
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
|
||||
Reference in New Issue
Block a user