mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 08:53:22 +00:00
feat(bootstrap): TTY DX exploration harness + Krug onboarding fix wave
Add a real-PTY exploration harness and land 16 verified "Don't Make Me Think" fixes on the paste-in install experience for Claude Code and Codex. Harness: - test/helpers/tty-harness.ts — spawns any CLI (gbrain/claude/codex) under a real pseudo-terminal (Bun terminal: spawn), timestamps every output burst, and turns silence windows into a measurable stall report. Hermetic; pure helpers unit-tested in test/tty-harness.test.ts. - scripts/dx-explore.ts — drives the fresh-user funnel (help / init / real claude-install / real codex-install / manual drive mode), writing transcripts to .context/dx-runs/ (gitignored). Fixes (all adversarially verified against the code first): - Keyless bare `gbrain init` completes in keyless mode instead of exit 1; multi-key non-TTY auto-picks the canonical default; typo stays fail-loud. - Provider picker probe-gates ollama (daemon-up != model-pulled) and offers an explicit "continue keyless" option that is the bare-Enter default. - Fresh-brain init prints one schema-setup line instead of ~240 migration names (GBRAIN_MIGRATE_VERBOSE=1 restores detail). - Init epilogue: memory-verbs funnel is last-on-screen; skills advisory compacted for init; Mod Status trimmed. - PGLite live-serve lock error names the fix (close the agent session). - Mode-picker banner interpolates the applied mode; expansion-key gate is Anthropic/OpenAI/Google, not OpenAI-only. - Missing `claude` binary skips MCP but still installs hooks; honest copy. - Foreign MCP-registration removal targets the conflicting scope and fails loud if it does not land. - Upgrade marker compares the running binary to latest and self-spawns via execPath, so a current/newer binary no longer nags from a stale cache. - interview --set/--skip after --confirm warns it voided the confirmation. - init --help matches behavior; init --supabase fails loud on non-TTY. - Provider capabilities attributed per provider across README / runbook / questions bank / bootstrap.md. - First-run tour: restart-first, prompt 3 true on day one, withheld on FAIL; README gives Codex the same scripted magic moment. - Empty-brain "0 takes" onboard nudge suppressed. - Broken settings.local.json aborts the hooks write fail-closed instead of silently dropping the user's permissions. Regenerated cli-flag-registry.generated.ts and llms-full.txt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
6fae2c10ff
commit
c0eddce479
+1
-1
@@ -87,7 +87,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:**
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ 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). Restart Codex after the install and ask "what did I tell you my top jobs were?" — that's the moment it clicks.
|
||||
|
||||
> **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).
|
||||
|
||||
|
||||
@@ -68,9 +68,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)).
|
||||
|
||||
@@ -180,3 +182,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.
|
||||
|
||||
+5
-4
@@ -1103,9 +1103,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:**
|
||||
|
||||
@@ -1641,7 +1642,7 @@ 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). Restart Codex after the install and ask "what did I tell you my top jobs were?" — that's the moment it clicks.
|
||||
|
||||
> **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).
|
||||
|
||||
|
||||
@@ -0,0 +1,667 @@
|
||||
/**
|
||||
* 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 read or written):
|
||||
*
|
||||
* 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,
|
||||
stripAnsi,
|
||||
type TtySession,
|
||||
type PtyFrame,
|
||||
} from '../test/helpers/tty-harness.ts';
|
||||
|
||||
const REPO_ROOT = path.resolve(import.meta.dir, '..');
|
||||
|
||||
// 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[];
|
||||
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 });
|
||||
return {
|
||||
outDir,
|
||||
gbrainBin: needsGbrain ? ensureGbrainBinary(args.gbrainBin, args.rebuild) : '',
|
||||
keep: args.keep,
|
||||
cleanups: [],
|
||||
events: [],
|
||||
t0: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
function finishCtx(ctx: ScenarioCtx): void {
|
||||
fs.writeFileSync(
|
||||
path.join(ctx.outDir, 'events.jsonl'),
|
||||
ctx.events.map((e) => JSON.stringify(e)).join('\n') + (ctx.events.length ? '\n' : ''),
|
||||
);
|
||||
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 framesPath = path.join(sessDir, 'frames.jsonl');
|
||||
fs.writeFileSync(framesPath, '');
|
||||
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)],
|
||||
});
|
||||
|
||||
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([/bootstrap verify.*exit(?:ed|s)? 0/i, /verify\b.*\b(passed|0\b)/i, /All checks passed/i], {
|
||||
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)) fs.copyFileSync(realAuth, path.join(codexHome, 'auth.json'));
|
||||
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([/bootstrap verify.*exit(?:ed|s)? 0/i, /verify\b.*\b(passed|0\b)/i, /All checks passed/i], {
|
||||
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();
|
||||
+25
-8
@@ -36,6 +36,7 @@ import { callRemoteTool, RemoteMcpError, unpackToolResult } from './core/mcp-cli
|
||||
import { maybePromptForUpgrade } from './core/thin-client-upgrade-prompt.ts';
|
||||
import { CLI_FLAG_REGISTRY } from './core/cli-flag-registry.generated.ts';
|
||||
import { VERSION } from './version.ts';
|
||||
import { isNewerVersion } from './core/semver.ts';
|
||||
|
||||
// Build CLI name -> operation lookup
|
||||
const cliOps = new Map<string, Operation>();
|
||||
@@ -259,12 +260,21 @@ 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) {
|
||||
// Guard against a stale/foreign cache: the cache records the version of
|
||||
// whatever binary WROTE it (an older gbrain on PATH can write it via the
|
||||
// detached refresh). Compare the RUNNING binary to latest, and print the
|
||||
// running version — otherwise a freshly-upgraded binary nags its user
|
||||
// with "old -> latest available" that self-upgrade cannot satisfy.
|
||||
if (
|
||||
entry.marker.kind === 'upgrade_available' &&
|
||||
entry.marker.latest &&
|
||||
isNewerVersion(VERSION, entry.marker.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`);
|
||||
process.stderr.write(`UPGRADE_AVAILABLE ${VERSION} ${entry.marker.latest}\n`);
|
||||
process.stderr.write(
|
||||
`gbrain ${entry.marker.current} -> ${entry.marker.latest} available. Run: gbrain self-upgrade\n`,
|
||||
`gbrain ${VERSION} -> ${entry.marker.latest} available. Run: gbrain self-upgrade\n`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -273,19 +283,26 @@ 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'];
|
||||
// Compiled binary: execPath IS gbrain. Dev (bun src/cli.ts): re-exec the
|
||||
// entrypoint.
|
||||
const argv = /[/\\]gbrain(\.exe)?$/.test(exec) ? refreshArgs : [process.argv[1], ...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 */
|
||||
|
||||
+69
-17
@@ -356,7 +356,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(
|
||||
@@ -424,6 +424,12 @@ 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) {
|
||||
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.',
|
||||
);
|
||||
}
|
||||
console.log(`${key} recorded.`);
|
||||
return 0;
|
||||
}
|
||||
@@ -439,6 +445,12 @@ async function runInterview(ws: string, rest: string[]): Promise<number> {
|
||||
console.error(r.message);
|
||||
return 1;
|
||||
}
|
||||
if (r.invalidatedConfirmation) {
|
||||
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.',
|
||||
);
|
||||
}
|
||||
console.log(`${key} skipped.`);
|
||||
return 0;
|
||||
}
|
||||
@@ -711,6 +723,11 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
|
||||
return withLock(ws, async () => {
|
||||
// 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 } : {}) })
|
||||
@@ -720,10 +737,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);
|
||||
@@ -741,7 +760,25 @@ 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}`}`);
|
||||
@@ -759,7 +796,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);
|
||||
@@ -789,10 +826,22 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
|
||||
let hooksWritten = false;
|
||||
if (harness === 'claude-code') {
|
||||
if (hooksConsent) {
|
||||
const r = writeClaudeHooks(ws, {
|
||||
gbrainBin,
|
||||
env: { GBRAIN_SOURCE: sourceId, ...(gbrainHome ? { GBRAIN_HOME: gbrainHome } : {}) },
|
||||
});
|
||||
let r: ReturnType<typeof writeClaudeHooks>;
|
||||
try {
|
||||
r = writeClaudeHooks(ws, {
|
||||
gbrainBin,
|
||||
env: { GBRAIN_SOURCE: sourceId, ...(gbrainHome ? { GBRAIN_HOME: gbrainHome } : {}) },
|
||||
});
|
||||
} catch (e) {
|
||||
// Fail-closed on an unparseable settings.local.json: 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.`);
|
||||
for (const note of r.notes) console.error(note);
|
||||
@@ -807,15 +856,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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ import { probeLivePgliteHolder, resolveBrainDataDir } from '../core/bootstrap/un
|
||||
import { readRunbookStamp, hooksInstalled, listVerifyRuns } from '../core/bootstrap/status.ts';
|
||||
import { resolveGbrainHome } from '../core/gbrain-home.ts';
|
||||
import { VERSION as GBRAIN_BINARY_VERSION } from '../version.ts';
|
||||
import { isNewerVersion } from '../core/semver.ts';
|
||||
import { execFileSync } from 'child_process';
|
||||
|
||||
export interface Check {
|
||||
@@ -1163,8 +1164,13 @@ 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)`);
|
||||
// Compare against the RUNNING binary (not the cache-writer's recorded
|
||||
// version) so a stale/foreign cache can't report an already-done upgrade.
|
||||
if (
|
||||
entry && isCacheFresh(entry, Date.now()) && entry.marker.kind === 'upgrade_available' &&
|
||||
entry.marker.latest && isNewerVersion(GBRAIN_BINARY_VERSION, entry.marker.latest)
|
||||
) {
|
||||
parts.push(`update available: ${GBRAIN_BINARY_VERSION} -> ${entry.marker.latest} (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,11 @@ 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,
|
||||
),
|
||||
pageCount,
|
||||
};
|
||||
}
|
||||
@@ -214,7 +223,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 +235,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,20 @@
|
||||
* 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, so a bare Enter (or
|
||||
* the 60s timeout) can never select a local daemon the user didn't ask
|
||||
* for.
|
||||
* - On Ctrl-D / EOF / timeout / explicit skip: returns null; the
|
||||
* embedding caller continues keyless with a loud notice (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 +30,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 +54,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 +119,54 @@ 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');
|
||||
writeStderr(`\nPick an ${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 +176,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];
|
||||
|
||||
+96
-34
@@ -491,9 +491,15 @@ 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(' (add a key later with `gbrain config set 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 +507,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 +516,16 @@ function printNoEmbeddingProviderHint(typos: Array<{ userSet: string; suggested:
|
||||
}
|
||||
}
|
||||
|
||||
/** Loud keyless-continue notice for the no-keys default path. */
|
||||
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 (import with `gbrain import --no-embed`\n' +
|
||||
' meanwhile). Add later: `gbrain config set embedding_model <id>`.',
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveEmbeddingByEnv(out: ResolvedAIOptions, nonInteractive: boolean): Promise<void> {
|
||||
const ready = await groupReadyByProvider('embedding');
|
||||
const isTTY = !nonInteractive && !!process.stdin.isTTY;
|
||||
@@ -548,28 +561,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);
|
||||
@@ -1089,7 +1130,6 @@ async function initPGLite(opts: {
|
||||
} else {
|
||||
console.log('Next: gbrain import <dir>');
|
||||
}
|
||||
printMemoryVerbsQuickstart();
|
||||
console.log('');
|
||||
console.log('When you outgrow local: gbrain migrate --to supabase');
|
||||
reportModStatus();
|
||||
@@ -1101,6 +1141,11 @@ 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 memory-verbs funnel prints LAST so the three copy-paste commands
|
||||
// are the final thing on screen — the success screen's one primary
|
||||
// action (it used to sit mid-scroll under the skills advisory).
|
||||
printMemoryVerbsQuickstart();
|
||||
}
|
||||
} finally {
|
||||
try { await engine.disconnect(); } catch { /* best-effort */ }
|
||||
@@ -1357,7 +1402,6 @@ async function initPostgres(opts: {
|
||||
} 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 +1411,10 @@ 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);
|
||||
|
||||
// Memory-verbs funnel last-on-screen (same rationale as the PGLite
|
||||
// epilogue): the three copy-paste commands are the one primary action.
|
||||
printMemoryVerbsQuickstart();
|
||||
}
|
||||
} finally {
|
||||
try { await engine.disconnect(); } catch { /* best-effort */ }
|
||||
@@ -1402,6 +1450,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 +1484,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 +1644,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 +1667,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 +1700,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,7 +6,8 @@
|
||||
* the self-upgrade refresh path.
|
||||
*/
|
||||
|
||||
import { readUpdateCache } from '../self-upgrade.ts';
|
||||
import { readUpdateCache, isCacheFresh } from '../self-upgrade.ts';
|
||||
import { isNewerVersion } from '../semver.ts';
|
||||
import type { AdvisorCollector } from './types.ts';
|
||||
|
||||
export const collectVersion: AdvisorCollector = {
|
||||
@@ -15,7 +16,13 @@ export const collectVersion: AdvisorCollector = {
|
||||
let latest: string | undefined;
|
||||
try {
|
||||
const entry = readUpdateCache();
|
||||
if (entry && entry.marker.kind === 'upgrade_available' && entry.marker.latest) {
|
||||
// Fresh cache only, and compare against the RUNNING version — a stale
|
||||
// or foreign-binary cache must not nag about an upgrade already done.
|
||||
if (
|
||||
entry && isCacheFresh(entry, Date.now()) &&
|
||||
entry.marker.kind === 'upgrade_available' && entry.marker.latest &&
|
||||
isNewerVersion(ctx.version, entry.marker.latest)
|
||||
) {
|
||||
latest = entry.marker.latest;
|
||||
}
|
||||
} catch {
|
||||
|
||||
+10
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -29,7 +33,12 @@ export async function probeOpenAICompat(baseUrl: string, timeoutMs: number = 100
|
||||
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:
|
||||
@@ -69,7 +70,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[];
|
||||
}
|
||||
@@ -178,10 +181,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[] = [];
|
||||
@@ -204,14 +210,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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,7 +289,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 };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -96,11 +96,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.',
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -834,9 +838,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 [D3.6]' };
|
||||
checks.push(tourCheck);
|
||||
|
||||
const ok = checks.every((c) => c.ok || c.warn === true);
|
||||
if (!ok) tourCheck.detail = 'tour withheld — prints on PASS [D3.6]';
|
||||
|
||||
const ts = new Date().toISOString();
|
||||
persistVerifyRun(gbrainHomeDir, { ts, ok, checks });
|
||||
@@ -850,8 +856,18 @@ 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.
|
||||
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}`));
|
||||
} 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] };
|
||||
}
|
||||
|
||||
@@ -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', '--type'],
|
||||
'hook': ['--aliases', '--all', '--batch-limit', '--brain', '--budget-ms', '--count', '--delete-brain', '--detach', '--env', '--fast', '--force', '--from-pages', '--harness', '--help', '--http', '--include-null-signature', '--json', '--no-embedding', '--no-extract', '--once', '--path', '--pattern', '--pending', '--porcelain', '--reset', '--resolve', '--show-toplevel', '--source', '--stale', '--stats', '--supersessions', '--surface', '--thin', '--timeout'],
|
||||
'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', '--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', '--touchpoint', '--url', '--version'],
|
||||
'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', '--to', '--touchpoint', '--url', '--version'],
|
||||
'integrations': ['--auto', '--brain', '--dry-run', '--embeddings', '--fast', '--force', '--from-pages', '--help', '--http', '--json', '--no-embedding', '--overwrite', '--refresh', '--reranking', '--source', '--surface', '--target'],
|
||||
'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', '--type', '--url'],
|
||||
'jobs': ['--abbrev-ref', '--aliases', '--all', '--allow-empty', '--allow-protected', '--allow-shell-jobs', '--apply', '--asof', '--auto', '--auto-with-prompt', '--background', '--backoff-delay', '--backoff-jitter', '--backoff-type', '--batch-size', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--break-lock', '--budget-usd', '--budget-usd-per-day', '--by-mention', '--cached', '--catch-up', '--check', '--cli-path', '--cluster', '--cluster-errors', '--code', '--concurrency', '--confidence', '--confirm-destructive', '--content', '--date', '--days', '--delay', '--detach', '--diff-filter', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--empty', '--entities', '--exclude', '--exclude-standard', '--explain', '--fast', '--federated', '--federated-read', '--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', '--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-gpg-sign', '--no-hard-deadline', '--no-inject', '--no-mutate', '--no-pull', '--no-renames', '--no-schema-pack', '--no-verify', '--no-worker', '--now', '--offset', '--older-than', '--once', '--order', '--others', '--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-ms', '--remediate', '--repo', '--reset', '--resolve', '--respect-gitignore', '--restore-only', '--resume', '--retry-failed', '--review-lower', '--run-id', '--save', '--segment-limit', '--serial', '--session', '--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', '--thin', '--thin-threshold', '--timeout', '--timeout-ms', '--trusted-extraction', '--type', '--types', '--uninstall', '--unsafe-bypass-dream-guard', '--url', '--user', '--verbose', '--version', '--watch', '--wedge-rescue', '--with-db', '--workers', '--yes'],
|
||||
@@ -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', '--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', '--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', '--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', '--url', '--version', '--watch', '--workers', '--yes'],
|
||||
'reinit-pglite': ['--abbrev-ref', '--all', '--allow-empty', '--brain', '--break-lock', '--chat-model', '--concurrency', '--confirm-destructive', '--diff-filter', '--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', '--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', '--url', '--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', '--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', '--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', '--url'],
|
||||
'report': ['--brain', '--content', '--dir', '--help', '--json', '--source', '--title', '--type'],
|
||||
|
||||
+38
-9
@@ -5978,17 +5978,53 @@ 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';
|
||||
|
||||
// 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`);
|
||||
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`);
|
||||
}
|
||||
|
||||
// Pre-flight: warn about connections that might block DDL
|
||||
await checkForBlockingConnections(engine);
|
||||
|
||||
let applied = 0;
|
||||
for (const m of pending) {
|
||||
process.stderr.write(` [${m.version}] ${m.name}...\n`);
|
||||
if (!quietReplay) process.stderr.write(` [${m.version}] ${m.name}...\n`);
|
||||
try {
|
||||
await applyOneMigration(engine, m);
|
||||
} 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;
|
||||
}
|
||||
|
||||
// Update version after both SQL and handler succeed
|
||||
await engine.setConfig('version', String(m.version));
|
||||
if (!quietReplay) process.stderr.write(` [${m.version}] ✓ ${m.name}\n`);
|
||||
applied++;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -6059,11 +6095,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,14 @@ 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 recommendation arms on empty.
|
||||
const brainEmpty = totalPages === 0;
|
||||
|
||||
// Aggregate: any non-zero metric triggers the nudge.
|
||||
const linkCoverage = totalEntities > 0 ? linkedCount / totalEntities : 1;
|
||||
const timelineCoverage = totalEntities > 0 ? timelineCount / totalEntities : 1;
|
||||
@@ -108,8 +122,9 @@ export async function runInitNudge(engine: BrainEngine): Promise<void> {
|
||||
totalStale > 0
|
||||
|| (totalEntities > 0 && linkCoverage < 0.7)
|
||||
|| (totalEntities > 0 && timelineCoverage < 0.9)
|
||||
|| takesCount === 0;
|
||||
|| (takesCount === 0 && !brainEmpty);
|
||||
|
||||
if (brainEmpty) return;
|
||||
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.` +
|
||||
|
||||
@@ -2773,8 +2773,15 @@ const get_brain_identity: Operation = {
|
||||
let latest_version: string | null = null;
|
||||
try {
|
||||
const su = await import('./self-upgrade.ts');
|
||||
const { isNewerVersion } = await import('./semver.ts');
|
||||
const entry = su.readUpdateCache();
|
||||
if (entry && su.isCacheFresh(entry, Date.now()) && entry.marker.kind === 'upgrade_available') {
|
||||
// Compare against the RUNNING version, not the cache-writer's — a
|
||||
// stale/foreign cache must not report an upgrade this binary already has.
|
||||
if (
|
||||
entry && su.isCacheFresh(entry, Date.now()) &&
|
||||
entry.marker.kind === 'upgrade_available' &&
|
||||
entry.marker.latest && isNewerVersion(VERSION, entry.marker.latest)
|
||||
) {
|
||||
update_available = true;
|
||||
latest_version = entry.marker.latest ?? null;
|
||||
}
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -127,5 +134,32 @@ export function printAdvisoryIfRecommended(opts: {
|
||||
}): void {
|
||||
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 (cheap: re-runs the receipt parse).
|
||||
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 missing =
|
||||
workspace && skillsDir
|
||||
? all.filter((s) => !detectInstalledSlugs(skillsDir, workspace).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 ? ', …' : '');
|
||||
process.stderr.write(
|
||||
`\n${missing.length} recommended skill(s) not yet installed (${preview}).\n` +
|
||||
`[AGENT] Ask the user before installing. If yes: ${scaffoldCommandFor(missing, all)}\n` +
|
||||
`Full list + descriptions: gbrain advisor\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
process.stderr.write(advisory);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -166,18 +166,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)', () => {
|
||||
|
||||
@@ -89,36 +89,66 @@ 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.
|
||||
expect(r.stderr).toContain('gbrain 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);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -0,0 +1,570 @@
|
||||
/**
|
||||
* 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: Record<string, string> = {
|
||||
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',
|
||||
};
|
||||
|
||||
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 (drive mode streams frames to disk). */
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
const wallTimer = setTimeout(() => {
|
||||
try {
|
||||
proc.kill?.('SIGKILL');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 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[key as string];
|
||||
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;
|
||||
try {
|
||||
proc.kill?.('SIGINT');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
await Promise.race([exitedPromise, Bun.sleep(2000)]);
|
||||
if (!exited) {
|
||||
try {
|
||||
proc.kill?.('SIGKILL');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* 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).
|
||||
const stalls = computeStalls(frames, { thresholdMs: 300 });
|
||||
expect(stalls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(stalls[0]!.context).toContain('one');
|
||||
}, 20_000);
|
||||
|
||||
test('send + waitFor drive an interactive child; child sees a real TTY', async () => {
|
||||
const session = launchTty(['sh', '-c', 'tty; read line; echo "got:$line"'], {
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
await session.waitFor(/\/dev\/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);
|
||||
}, 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);
|
||||
});
|
||||
Reference in New Issue
Block a user