mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
fix(bootstrap): address two-model adversarial review of the DX wave
Fixes the regressions the 5-specialist + red-team + Claude/Codex adversarial
pass found in the F1–F21 changes, each with a test:
- Keyless upgrade hint pointed at `config set embedding_model`, which config.ts
hard-refuses as a schema-sizing no-op — now names the working re-init recipe
(`gbrain init --force --pglite --embedding-model <id>`), zero-key AND multi-key
paths.
- Multi-key TTY picker offered "continue keyless" but the caller aborted on it —
now honors keyless like the zero-key path.
- Detached update-refresh spawn used a `/gbrain$/` basename check that misfires
for a renamed/official-named compiled binary (`gbrain-darwin-arm64`) and
prepends the /$bunfs entrypoint — now detects dev-vs-compiled by the runtime
basename (bun|node) so the refresh always runs.
- `bootstrap status` reported the wire phase "done" on a hooks-only receipt
(host CLI missing at wire time) — now "partial" with a re-run hint, so a
resuming agent doesn't trust a false complete.
- Post-repair MCP mismatch re-verifies and aborts instead of blessing a
registration a racing writer may have re-claimed.
- probeOpenAICompat's abort timer now spans the body read (was cleared before
it), so a stalled `/v1/models` body can't hang init past the 1s cap.
- Centralized the 4-copy stale-cache upgrade predicate into
`pendingUpgradeVersion`; UPGRADE_AVAILABLE gains a GBRAIN_FORCE_UPGRADE_MARKER
override for PTY-based agent harnesses.
- Mode picker's expansion-key gate adds GEMINI_API_KEY; picker prompt is
article-aware ("an embedding" / "a chat"); dead `!brainEmpty` clause removed;
migrate.ts try/finally widened + stamp failures named in quiet mode.
- DX harness: credential copies scrubbed even on SIGINT/interrupt (+chmod 600),
child process TREE reaped on teardown, advisory made fail-open, KEY_MAP typed
as a literal union.
New tests: migrate quiet-replay, self-upgrade pending predicate + negative
cache cases, bootstrap 127/scoped-remove/broken-settings dispatch, interview
invalidation flag, verify tour-withheld-on-FAIL, init keyless/supabase/multi-key,
init-nudge branches, ai-probes model parsing. Regenerated flag registry +
template-repo.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
4ab5cf329d
commit
62bbd3c077
@@ -20,7 +20,12 @@ schema. The user gets new capabilities automatically.
|
||||
|
||||
gbrain stays current the way gstack does: it rides invocation frequency. A
|
||||
throttled, cache-read-only check runs at the start of every `gbrain` invocation
|
||||
(CLI and MCP) and emits an `UPGRADE_AVAILABLE <old> <new>` marker on stderr. No
|
||||
(CLI and MCP) and emits an `UPGRADE_AVAILABLE <old> <new>` marker on stderr. The
|
||||
raw marker line is suppressed when stderr is an interactive TTY (a human sees
|
||||
only the plain `gbrain X -> Y available` sentence, not the machine token); set
|
||||
`GBRAIN_FORCE_UPGRADE_MARKER=1` if an agent harness parses the token but runs
|
||||
under a PTY. `<old>` is always the RUNNING binary's version, so a stale or
|
||||
foreign-written cache never nags about an upgrade this binary already has. No
|
||||
host cron required — every agent kind (Claude Code, Codex, OpenClaw, Hermes, the
|
||||
`gbrain serve` host behind a Perplexity thin client) converges to current by
|
||||
construction. The behavior is governed by one file-plane config key,
|
||||
|
||||
+65
-9
@@ -9,7 +9,11 @@
|
||||
* 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):
|
||||
* the operator's real config is never WRITTEN. Two narrow reads exist for
|
||||
* auth: codex-install copies ~/.codex/auth.json into the temp CODEX_HOME, and
|
||||
* the claude seed records the API key's last 20 chars — both copies are
|
||||
* scrubbed at cleanup even under --keep, so no credential material outlives
|
||||
* the run):
|
||||
*
|
||||
* help First-touch comprehension surfaces: bare `gbrain`,
|
||||
* `gbrain --help`, `gbrain init --help`, `gbrain bootstrap
|
||||
@@ -65,13 +69,20 @@ import {
|
||||
saveTranscript,
|
||||
seedClaudeTuiConfig,
|
||||
parseDriveCommand,
|
||||
stripAnsi,
|
||||
type TtySession,
|
||||
type PtyFrame,
|
||||
} from '../test/helpers/tty-harness.ts';
|
||||
|
||||
const REPO_ROOT = path.resolve(import.meta.dir, '..');
|
||||
|
||||
/** Screen patterns that mean the paste-in install reached a passing verify —
|
||||
* ONE list shared by the claude-install and codex-install scenarios so the
|
||||
* two can't drift when the bootstrap's success copy changes. */
|
||||
const VERIFY_SUCCESS_PATTERNS: Array<RegExp | string> = [
|
||||
/bootstrap verify.*exit(?:ed|s)? 0/i,
|
||||
/verify\b.*\b(passed|0\b)/i,
|
||||
/All checks passed/i,
|
||||
];
|
||||
|
||||
// Same synthetic persona the door tests use — the interview can complete
|
||||
// unattended and nothing real about the operator ever enters a transcript.
|
||||
const PERSONA = {
|
||||
@@ -187,6 +198,10 @@ interface ScenarioCtx {
|
||||
keep: boolean;
|
||||
/** temp dirs to remove on completion unless --keep */
|
||||
cleanups: string[];
|
||||
/** Files carrying credential material (copied auth.json, seeded key
|
||||
* suffixes). ALWAYS deleted at cleanup — --keep keeps transcripts and
|
||||
* hermetic dirs for forensics, never credentials. */
|
||||
secretPaths: string[];
|
||||
events: Array<{ tMs: number; kind: 'input' | 'note' | 'screen'; data: string }>;
|
||||
t0: number;
|
||||
}
|
||||
@@ -196,14 +211,17 @@ function newCtx(args: CliArgs, needsGbrain: boolean): ScenarioCtx {
|
||||
args.dir ?? path.join(REPO_ROOT, '.context', 'dx-runs', `${args.scenario}-${nowStamp()}`),
|
||||
);
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
return {
|
||||
const ctx: ScenarioCtx = {
|
||||
outDir,
|
||||
gbrainBin: needsGbrain ? ensureGbrainBinary(args.gbrainBin, args.rebuild) : '',
|
||||
keep: args.keep,
|
||||
cleanups: [],
|
||||
secretPaths: [],
|
||||
events: [],
|
||||
t0: Date.now(),
|
||||
};
|
||||
installSignalScrub(ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function tmp(ctx: ScenarioCtx, prefix: string): string {
|
||||
@@ -216,11 +234,43 @@ function event(ctx: ScenarioCtx, kind: 'input' | 'note' | 'screen', data: string
|
||||
ctx.events.push({ tMs: Date.now() - ctx.t0, kind, data });
|
||||
}
|
||||
|
||||
/** Delete every credential copy. Idempotent; safe to call from a signal
|
||||
* handler AND from finishCtx (a second call is a no-op). This is the
|
||||
* "no credential outlives the run" guarantee — it must run even when a
|
||||
* 10-25min install is Ctrl-C'd (finally does NOT run on SIGINT default). */
|
||||
function scrubSecrets(ctx: ScenarioCtx): void {
|
||||
for (const p of ctx.secretPaths) {
|
||||
try {
|
||||
fs.rmSync(p, { force: true });
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Wire SIGINT/SIGTERM so an interrupted run still scrubs credentials before
|
||||
* the process dies. Registered once per scenario ctx. */
|
||||
function installSignalScrub(ctx: ScenarioCtx): void {
|
||||
const handler = (sig: NodeJS.Signals) => {
|
||||
scrubSecrets(ctx);
|
||||
process.stderr.write(`\n[dx-explore] ${sig}: scrubbed credential copies, exiting.\n`);
|
||||
process.exit(130);
|
||||
};
|
||||
process.once('SIGINT', handler);
|
||||
process.once('SIGTERM', handler);
|
||||
}
|
||||
|
||||
function finishCtx(ctx: ScenarioCtx): void {
|
||||
// Scrub credentials FIRST — before any other I/O that could throw (an
|
||||
// events.jsonl write failure must not strand auth files).
|
||||
scrubSecrets(ctx);
|
||||
fs.writeFileSync(
|
||||
path.join(ctx.outDir, 'events.jsonl'),
|
||||
ctx.events.map((e) => JSON.stringify(e)).join('\n') + (ctx.events.length ? '\n' : ''),
|
||||
);
|
||||
if (ctx.keep && ctx.secretPaths.length > 0) {
|
||||
log(`--keep: retained hermetic dirs, but scrubbed ${ctx.secretPaths.length} credential file(s)`);
|
||||
}
|
||||
if (!ctx.keep) {
|
||||
for (const d of ctx.cleanups) {
|
||||
try {
|
||||
@@ -244,8 +294,6 @@ function finishCtx(ctx: ScenarioCtx): void {
|
||||
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));
|
||||
@@ -448,6 +496,9 @@ async function scenarioClaudeInstall(ctx: ScenarioCtx): Promise<void> {
|
||||
// against the resolved path, so an unresolved seed misses.
|
||||
trustedDirs: [ws, fs.realpathSync(ws)],
|
||||
});
|
||||
// The seed records the key's last 20 chars — credential-adjacent, so it is
|
||||
// scrubbed at cleanup even with --keep.
|
||||
ctx.secretPaths.push(path.join(cfg, '.claude.json'));
|
||||
|
||||
log('REAL interactive claude running the paste-in bootstrap (10-25 min, real API cost)');
|
||||
log(`watch live: cat ${path.join(ctx.outDir, 'session', 'screen.txt')}`);
|
||||
@@ -478,7 +529,7 @@ async function scenarioClaudeInstall(ctx: ScenarioCtx): Promise<void> {
|
||||
// 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], {
|
||||
.waitForAny(VERIFY_SUCCESS_PATTERNS, {
|
||||
timeoutMs: 1_500_000,
|
||||
})
|
||||
.then(() => 'verify-signal')
|
||||
@@ -511,7 +562,12 @@ async function scenarioCodexInstall(ctx: ScenarioCtx): Promise<void> {
|
||||
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'));
|
||||
if (fs.existsSync(realAuth)) {
|
||||
const authCopy = path.join(codexHome, 'auth.json');
|
||||
fs.copyFileSync(realAuth, authCopy);
|
||||
fs.chmodSync(authCopy, 0o600); // copyFileSync doesn't preserve source mode
|
||||
ctx.secretPaths.push(authCopy); // scrubbed at cleanup, even with --keep
|
||||
}
|
||||
spawnSync('git', ['init', '-q', ws]);
|
||||
spawnSync('git', ['-C', ws, 'config', 'user.email', 'dx@example.com']);
|
||||
spawnSync('git', ['-C', ws, 'config', 'user.name', 'DX Explore']);
|
||||
@@ -541,7 +597,7 @@ async function scenarioCodexInstall(ctx: ScenarioCtx): Promise<void> {
|
||||
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], {
|
||||
.waitForAny(VERIFY_SUCCESS_PATTERNS, {
|
||||
timeoutMs: 1_500_000,
|
||||
})
|
||||
.then(() => 'verify-signal')
|
||||
|
||||
+22
-19
@@ -14,6 +14,7 @@ import { spawn } from 'child_process';
|
||||
import {
|
||||
readUpdateCache,
|
||||
isCacheFresh,
|
||||
pendingUpgradeVersion,
|
||||
readSnooze,
|
||||
isSnoozeActive,
|
||||
resolveSelfUpgradeMode,
|
||||
@@ -36,7 +37,6 @@ 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>();
|
||||
@@ -260,29 +260,26 @@ function maybeEmitUpdateMarker(command: string): void {
|
||||
const now = Date.now();
|
||||
const entry = readUpdateCache();
|
||||
if (entry && isCacheFresh(entry, now)) {
|
||||
// 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)
|
||||
) {
|
||||
// Shared stale/foreign-cache guard (pendingUpgradeVersion): only nag when
|
||||
// the cached latest is strictly newer than the RUNNING binary, and print
|
||||
// the running version — the cache records whatever binary WROTE it.
|
||||
const latest = pendingUpgradeVersion(VERSION, now);
|
||||
if (latest) {
|
||||
// notify mode honors a per-version snooze; auto mode ignores it.
|
||||
if (mode === 'notify' && isSnoozeActive(readSnooze(), entry.marker.latest, now)) return;
|
||||
if (mode === 'notify' && isSnoozeActive(readSnooze(), latest, now)) return;
|
||||
// The raw `UPGRADE_AVAILABLE <cur> <latest>` line is a MACHINE marker
|
||||
// (parsed by the self-upgrade skill / MCP via parseMarker). A human at
|
||||
// an interactive terminal should never see the token as the literal
|
||||
// first line of output — so emit it only when stderr is NOT a TTY
|
||||
// (agent harnesses capture stderr non-interactively and still get it).
|
||||
// The human sentence prints on both.
|
||||
if (!process.stderr.isTTY) {
|
||||
process.stderr.write(`UPGRADE_AVAILABLE ${VERSION} ${entry.marker.latest}\n`);
|
||||
// GBRAIN_FORCE_UPGRADE_MARKER=1 forces it for the rarer agent harness
|
||||
// that allocates a PTY yet still parses the token. The human sentence
|
||||
// prints on both.
|
||||
if (!process.stderr.isTTY || process.env.GBRAIN_FORCE_UPGRADE_MARKER === '1') {
|
||||
process.stderr.write(`UPGRADE_AVAILABLE ${VERSION} ${latest}\n`);
|
||||
}
|
||||
process.stderr.write(
|
||||
`gbrain ${VERSION} -> ${entry.marker.latest} available. Run: gbrain self-upgrade\n`,
|
||||
`gbrain ${VERSION} -> ${latest} available. Run: gbrain self-upgrade\n`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -297,9 +294,15 @@ function maybeEmitUpdateMarker(command: string): void {
|
||||
try {
|
||||
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];
|
||||
// Detect compiled-vs-dev by the RUNTIME's basename, not our own — a
|
||||
// published binary keeps its official name (`gbrain-darwin-arm64`, a
|
||||
// `gb` shim), so matching `/gbrain$/` on execPath would misfire and
|
||||
// prepend the `/$bunfs/root/...` virtual entrypoint (process.argv[1] in
|
||||
// a compiled Bun binary), producing an unknown-command child that never
|
||||
// refreshes. Dev mode runs under `bun`/`node`; anything else IS the
|
||||
// compiled binary and re-execs itself directly.
|
||||
const isDevRuntime = /[/\\](bun|node)(\.exe)?$/.test(exec);
|
||||
const argv = isDevRuntime ? [process.argv[1], ...refreshArgs] : refreshArgs;
|
||||
const child = spawn(exec, argv, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
|
||||
+26
-12
@@ -383,6 +383,15 @@ async function runStatus(ws: string, rest: string[], home: string): Promise<numb
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** One copy of the A8 invalidation warning — shared by --set and --skip so
|
||||
* the operator-facing instructions cannot drift between the two branches. */
|
||||
function warnInvalidatedConfirmation(): void {
|
||||
console.error(
|
||||
'note: this change voided the prior confirmation — read the full answer set back ' +
|
||||
'to the human, then `gbrain bootstrap interview --confirm <hash>` again before render.',
|
||||
);
|
||||
}
|
||||
|
||||
async function runInterview(ws: string, rest: string[]): Promise<number> {
|
||||
if (rest.includes('--init')) {
|
||||
const r = initState(ws);
|
||||
@@ -424,12 +433,7 @@ async function runInterview(ws: string, rest: string[]): Promise<number> {
|
||||
console.log(`${key}: routed to the 0600 config file (${routed.configKey}). Not recorded in interview state.`);
|
||||
return 0;
|
||||
}
|
||||
if (r.invalidatedConfirmation) {
|
||||
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.',
|
||||
);
|
||||
}
|
||||
if (r.invalidatedConfirmation) warnInvalidatedConfirmation();
|
||||
console.log(`${key} recorded.`);
|
||||
return 0;
|
||||
}
|
||||
@@ -445,12 +449,7 @@ 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.',
|
||||
);
|
||||
}
|
||||
if (r.invalidatedConfirmation) warnInvalidatedConfirmation();
|
||||
console.log(`${key} skipped.`);
|
||||
return 0;
|
||||
}
|
||||
@@ -784,6 +783,21 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
|
||||
console.error(`MCP re-registration failed (${argv.join(' ')}): ${re.stderr.trim() || `exit ${re.code}`}`);
|
||||
return 1;
|
||||
}
|
||||
// Re-add can itself return "already exists" if a racing writer
|
||||
// re-claimed the name between our remove and add — that registration
|
||||
// is NOT ours. Re-verify and abort rather than bless a foreign
|
||||
// endpoint that would intercept memory ops. (Only the recorded
|
||||
// warn-then-continue step-2 smoke did this before; here it's fatal.)
|
||||
const post = await verifyMcpTargetsWorkspace(runner, harness, mcpName, gbrainBin, sourceId);
|
||||
if (post === 'mismatch') {
|
||||
console.error(
|
||||
`after replacing '${mcpName}', it STILL targets a different workspace/binary — ` +
|
||||
`refusing to continue (a racing registration may have re-claimed the name). ` +
|
||||
`Inspect \`${argv[0]} mcp get ${mcpName}\`, remove it by hand, then re-run ` +
|
||||
`\`gbrain bootstrap hooks --harness ${harness} --repair\`.`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`MCP server '${mcpName}' already registered — could not confirm it targets this workspace ` +
|
||||
|
||||
+6
-11
@@ -77,7 +77,6 @@ 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 {
|
||||
@@ -1151,8 +1150,7 @@ export function checkSelfUpgradeHealth(): Check {
|
||||
const { loadConfig } = require('../core/config.ts');
|
||||
const {
|
||||
resolveSelfUpgradeMode,
|
||||
readUpdateCache,
|
||||
isCacheFresh,
|
||||
pendingUpgradeVersion,
|
||||
} = require('../core/self-upgrade.ts');
|
||||
const { readRecentSelfUpgrades } = require('../core/audit/self-upgrade-audit.ts');
|
||||
|
||||
@@ -1167,14 +1165,11 @@ export function checkSelfUpgradeHealth(): Check {
|
||||
}
|
||||
|
||||
const parts: string[] = [`mode=${mode}`];
|
||||
const entry = readUpdateCache();
|
||||
// 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)`);
|
||||
// Shared stale/foreign-cache guard: only report an upgrade strictly newer
|
||||
// than the RUNNING binary (pendingUpgradeVersion owns the rule).
|
||||
const pendingLatest = pendingUpgradeVersion(GBRAIN_BINARY_VERSION, Date.now());
|
||||
if (pendingLatest) {
|
||||
parts.push(`update available: ${GBRAIN_BINARY_VERSION} -> ${pendingLatest} (run: gbrain self-upgrade)`);
|
||||
}
|
||||
const failedVersions: string[] = cfg?.self_upgrade?.failed_versions ?? [];
|
||||
if (failedVersions.length > 0) {
|
||||
|
||||
@@ -117,7 +117,8 @@ async function resolveInputs(engine: BrainEngine): Promise<ModePickerInputs> {
|
||||
hasExpansionKey: Boolean(
|
||||
process.env.ANTHROPIC_API_KEY ||
|
||||
process.env.OPENAI_API_KEY ||
|
||||
process.env.GOOGLE_GENERATIVE_AI_API_KEY,
|
||||
process.env.GOOGLE_GENERATIVE_AI_API_KEY ||
|
||||
process.env.GEMINI_API_KEY, // gateway accepts GEMINI_API_KEY as a first-class alias
|
||||
),
|
||||
pageCount,
|
||||
};
|
||||
|
||||
@@ -15,13 +15,15 @@
|
||||
* 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).
|
||||
* - Embedding pickers always offer `0) none — continue keyless`. When no
|
||||
* KEYED provider is ready, keyless is the DEFAULT (bare Enter / 60s
|
||||
* timeout / EOF all resolve to 0 → null), so a local daemon is never
|
||||
* auto-selected. When a keyed provider IS ready the default is `1`, so an
|
||||
* unattended timeout picks that first keyed provider — NOT null; explicit
|
||||
* `0` is still keyless.
|
||||
* - Returns null on the keyless choice (and on invalid input); the embedding
|
||||
* caller continues keyless with a loud notice on BOTH the zero-key and the
|
||||
* multi-key paths (other touchpoints treat null as no-pick).
|
||||
* - When the user picks a non-Anthropic chat-capable recipe AND
|
||||
* `ANTHROPIC_API_KEY` is missing, prints the subagent caveat from D7
|
||||
* BEFORE returning the choice so the user sees the implication.
|
||||
@@ -163,7 +165,10 @@ export async function pickProvider(opts: PickProviderOpts): Promise<PickedProvid
|
||||
return null;
|
||||
}
|
||||
|
||||
writeStderr(`\nPick an ${opts.touchpoint} provider (env-ready providers shown):\n\n`);
|
||||
// Article-aware: touchpoint is 'embedding' | 'expansion' | 'chat' — a
|
||||
// hardcoded article renders "an chat provider".
|
||||
const article = /^[aeiou]/i.test(opts.touchpoint) ? 'an' : 'a';
|
||||
writeStderr(`\nPick ${article} ${opts.touchpoint} provider (env-ready providers shown):\n\n`);
|
||||
if (ready.length > 0) writeStderr(formatRecipeTable(ready, env) + '\n\n');
|
||||
|
||||
// Build numbered options (0 = keyless skip for embedding).
|
||||
|
||||
+15
-6
@@ -497,7 +497,8 @@ function printNoEmbeddingProviderHint(typos: Array<{ userSet: string; suggested:
|
||||
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(' (enable semantic search later by re-running with a key:');
|
||||
console.error(' gbrain init --force --pglite --embedding-model <id>)');
|
||||
console.error('');
|
||||
console.error('Or set a key for semantic search:');
|
||||
console.error(' export OPENAI_API_KEY=sk-… # openai:text-embedding-3-large (1536d)');
|
||||
@@ -516,13 +517,16 @@ function printNoEmbeddingProviderHint(typos: Array<{ userSet: string; suggested:
|
||||
}
|
||||
}
|
||||
|
||||
/** Loud keyless-continue notice for the no-keys default path. */
|
||||
/** Loud keyless-continue notice for the no-keys default path. The upgrade
|
||||
* command is `init --force` re-init, NOT `config set embedding_model` — that
|
||||
* key is a schema-sizing file-plane field that `gbrain config set` refuses
|
||||
* (it would be a silent no-op), so pointing users there is a dead end. */
|
||||
function printKeylessContinueNotice(): void {
|
||||
console.error(
|
||||
'No embedding provider keys detected — continuing in keyless mode:\n' +
|
||||
' keyword search + memory your agent writes down itself. Everything works.\n' +
|
||||
' One optional key upgrades search to semantic (import with `gbrain import --no-embed`\n' +
|
||||
' meanwhile). Add later: `gbrain config set embedding_model <id>`.',
|
||||
' One optional key upgrades search to semantic — set the key, then re-run\n' +
|
||||
' `gbrain init --force --pglite --embedding-model <id>` (re-imports via `gbrain sync`).',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -618,8 +622,13 @@ async function resolveEmbeddingByEnv(out: ResolvedAIOptions, nonInteractive: boo
|
||||
const { pickProvider } = await import('./init-provider-picker.ts');
|
||||
const picked = await pickProvider({ touchpoint: 'embedding', env: process.env, isTTY: true });
|
||||
if (!picked) {
|
||||
console.error('Init aborted: no embedding provider picked.');
|
||||
process.exit(1);
|
||||
// The embedding picker offers an explicit "0) none — continue keyless"
|
||||
// option (and returns null on it). Honor that instead of aborting: a user
|
||||
// with multiple keys who deliberately chose keyless gets keyless, matching
|
||||
// the zero-key path. (Ctrl-D / EOF / invalid also land here → keyless.)
|
||||
printKeylessContinueNotice();
|
||||
out.noEmbedding = true;
|
||||
return;
|
||||
}
|
||||
out.embedding_model = picked.fullModel;
|
||||
out.embedding_dimensions = picked.dim;
|
||||
|
||||
@@ -6,28 +6,16 @@
|
||||
* the self-upgrade refresh path.
|
||||
*/
|
||||
|
||||
import { readUpdateCache, isCacheFresh } from '../self-upgrade.ts';
|
||||
import { isNewerVersion } from '../semver.ts';
|
||||
import { pendingUpgradeVersion } from '../self-upgrade.ts';
|
||||
import type { AdvisorCollector } from './types.ts';
|
||||
|
||||
export const collectVersion: AdvisorCollector = {
|
||||
id: 'version',
|
||||
collect: async (ctx) => {
|
||||
let latest: string | undefined;
|
||||
try {
|
||||
const entry = readUpdateCache();
|
||||
// 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 {
|
||||
return [];
|
||||
}
|
||||
// Shared stale/foreign-cache guard: fresh cache only, and only an upgrade
|
||||
// strictly newer than the RUNNING version (pendingUpgradeVersion owns the
|
||||
// rule; never throws).
|
||||
const latest = pendingUpgradeVersion(ctx.version, Date.now());
|
||||
if (!latest) return [];
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -26,9 +26,15 @@ export async function probeOpenAICompat(baseUrl: string, timeoutMs: number = 100
|
||||
signal: controller.signal,
|
||||
headers: { accept: 'application/json' },
|
||||
});
|
||||
clearTimeout(timer);
|
||||
if (!res.ok) return { reachable: true, models_endpoint_valid: false, error: `HTTP ${res.status}` };
|
||||
if (!res.ok) {
|
||||
clearTimeout(timer);
|
||||
return { reachable: true, models_endpoint_valid: false, error: `HTTP ${res.status}` };
|
||||
}
|
||||
// Keep the abort timer live through the BODY read — a daemon that accepts,
|
||||
// returns headers, then stalls the body would otherwise hang past the
|
||||
// advertised timeout (the probe sits on init's interactive critical path).
|
||||
const body = await res.json().catch(() => null);
|
||||
clearTimeout(timer);
|
||||
if (!body || typeof body !== 'object') {
|
||||
return { reachable: true, models_endpoint_valid: false, error: 'non-JSON response' };
|
||||
}
|
||||
|
||||
@@ -244,7 +244,20 @@ export const PHASES: PhaseSpec[] = [
|
||||
detect: (ws, ctx) => {
|
||||
const regs = ctx.receipt?.registrations ?? [];
|
||||
if (regs.length > 0) {
|
||||
return { state: 'done', detail: regs.map((r) => `${r.host} (${r.scope})`).join(', ') };
|
||||
// A registration whose detail carries 'mcp' ('mcp' or 'mcp+hooks')
|
||||
// means MCP actually registered. A 'hooks'-only detail means the host
|
||||
// binary was missing at wire time (hooks landed, MCP did not) — the
|
||||
// phase is PARTIAL, not done, so a resuming agent re-runs it once the
|
||||
// CLI is on PATH instead of trusting a false "done".
|
||||
const mcpRegistered = regs.some((r) => (r.detail ?? '').includes('mcp'));
|
||||
if (mcpRegistered) {
|
||||
return { state: 'done', detail: regs.map((r) => `${r.host} (${r.scope})`).join(', ') };
|
||||
}
|
||||
return {
|
||||
state: 'partial',
|
||||
detail: 'hooks installed but MCP not registered (the harness CLI was not on PATH) — ' +
|
||||
're-run `gbrain bootstrap hooks --harness <claude-code|codex>` once it is',
|
||||
};
|
||||
}
|
||||
if (hooksInstalled(ws)) return { state: 'done', detail: 'hooks present in .claude/settings.local.json' };
|
||||
return { state: 'pending' };
|
||||
|
||||
@@ -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', '--to', '--touchpoint', '--url', '--version'],
|
||||
'init': ['--all', '--brain', '--chat-model', '--check', '--ctx-size', '--embedding-dimensions', '--embedding-model', '--entity', '--expansion-model', '--fast', '--flag', '--force', '--from-pages', '--grant-types', '--help', '--http', '--issuer-url', '--json', '--judge-model', '--key', '--mcp-only', '--mcp-url', '--migrate-only', '--model', '--multimodal', '--no', '--no-embed', '--no-embedding', '--non-interactive', '--oauth-client-id', '--oauth-client-secret', '--path', '--pglite', '--provenance', '--schema-pack', '--scopes', '--skip-embed-check', '--source', '--stale', '--supabase', '--surface', '--to', '--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-fix', '--auto-with-prompt', '--background', '--backoff-delay', '--backoff-jitter', '--backoff-type', '--batch', '--batch-size', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--break-lock', '--budget-usd', '--budget-usd-per-day', '--by-mention', '--by-type', '--cached', '--catch-up', '--check', '--cli-path', '--cluster', '--cluster-errors', '--code', '--concurrency', '--confidence', '--confirm-destructive', '--content', '--date', '--days', '--delay', '--detach', '--diff-filter', '--dimensions', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--empty', '--entities', '--exclude', '--exclude-standard', '--explain', '--fast', '--federated', '--federated-read', '--ff-only', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--force-retry', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--hard-deadline', '--health-interval', '--held-out', '--help', '--http', '--idempotency-key', '--image', '--include-frontmatter', '--include-gitignored', '--include-null-signature', '--infer-dates', '--inject-bootstrap', '--inline', '--input', '--install', '--interval', '--is-ancestor', '--json', '--kind', '--lang', '--limit', '--lock', '--markdown', '--max-age', '--max-attempts', '--max-cost-usd', '--max-crashes', '--max-rss', '--max-runtime-min', '--max-sources', '--max-stalled', '--max-usd', '--max-waiting', '--mcp-only', '--migrate-only', '--min-context', '--missing-path', '--mode', '--model', '--multimodal', '--name-only', '--name-status', '--near-symbol', '--ner', '--nice', '--no', '--no-auto-embed', '--no-embed', '--no-embedding', '--no-extract', '--no-federate', '--no-gpg-sign', '--no-hard-deadline', '--no-inject', '--no-mutate', '--no-pull', '--no-renames', '--no-schema-pack', '--no-verify', '--no-worker', '--non-interactive', '--now', '--offset', '--older-than', '--once', '--order', '--others', '--output', '--override-disabled', '--pace', '--pace-max-concurrency', '--pack', '--parallel', '--params', '--path', '--pattern', '--pending', '--phase', '--pid-file', '--priority', '--progress-interval', '--progress-json', '--queue', '--quiet', '--redact-secrets', '--reenrich-after', '--refresh-cache', '--refresh-ms', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--respect-gitignore', '--restore-only', '--resume', '--retry-failed', '--review-lower', '--run-id', '--save', '--segment-limit', '--serial', '--session', '--session-id', '--short', '--show-toplevel', '--sigkill-rescue', '--since', '--skip-bare-tweet', '--skip-failed', '--skip-urls', '--sleep', '--slug', '--slugs', '--source', '--source-id', '--src-subpath', '--stale', '--stats', '--status', '--strategy', '--supersessions', '--surface', '--swap-only', '--symbol-kind', '--target', '--target-score', '--thin', '--thin-threshold', '--timeout', '--timeout-ms', '--to', '--trusted-extraction', '--type', '--types', '--uninstall', '--unsafe-bypass-dream-guard', '--url', '--user', '--verbose', '--version', '--watch', '--wedge-rescue', '--with-db', '--workers', '--yes'],
|
||||
@@ -67,7 +67,7 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
|
||||
'orphans': ['--aliases', '--all', '--background', '--brain', '--brain-wide-max-cost-usd', '--count', '--explain', '--follow', '--help', '--include-null-signature', '--include-pseudo', '--json', '--no-extract', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin', '--timeout'],
|
||||
'pages': ['--aliases', '--all', '--brain', '--dry-run', '--help', '--include-null-signature', '--json', '--no-extract', '--older-than', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin'],
|
||||
'pglite-repair': ['--brain', '--break-lock', '--dry-rnu', '--dry-run', '--fast', '--force', '--from-pages', '--help', '--http', '--json', '--no-embedding', '--path', '--quiet', '--source', '--surface', '--yes'],
|
||||
'post-upgrade': ['--all', '--apply-clean-hunks', '--brain', '--check', '--code', '--compile', '--concurrency', '--embedding-dimensions', '--embedding-model', '--fast', '--ff-only', '--force', '--force-all', '--force-orchestrator', '--force-retry', '--force-schema', '--format', '--from-pages', '--help', '--host-dir', '--http', '--inject-bootstrap', '--inline', '--install', '--interval', '--json', '--limit', '--list', '--markdown', '--max-rss', '--migrate-only', '--migration', '--mode', '--model', '--multimodal', '--name-only', '--no', '--no-autopilot-install', '--no-embed', '--no-embedding', '--no-inject', '--no-worker', '--non-interactive', '--now', '--quiet', '--repo', '--since', '--skills-dir', '--skip-verify', '--source', '--stale', '--status', '--surface', '--swap-only', '--target', '--to', '--uninstall', '--user', '--verbose', '--verify', '--version', '--workers', '--yes'],
|
||||
'post-upgrade': ['--all', '--apply-clean-hunks', '--brain', '--check', '--code', '--compile', '--concurrency', '--embedding-dimensions', '--embedding-model', '--fast', '--ff-only', '--flag', '--force', '--force-all', '--force-orchestrator', '--force-retry', '--force-schema', '--format', '--from-pages', '--help', '--host-dir', '--http', '--inject-bootstrap', '--inline', '--install', '--interval', '--json', '--limit', '--list', '--markdown', '--max-rss', '--migrate-only', '--migration', '--mode', '--model', '--multimodal', '--name-only', '--no', '--no-autopilot-install', '--no-embed', '--no-embedding', '--no-inject', '--no-worker', '--non-interactive', '--now', '--quiet', '--repo', '--since', '--skills-dir', '--skip-verify', '--source', '--stale', '--status', '--surface', '--swap-only', '--target', '--to', '--uninstall', '--user', '--verbose', '--verify', '--version', '--workers', '--yes'],
|
||||
'protocol': ['--all', '--allow-empty', '--apply', '--asof', '--auto', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--budget-usd-per-day', '--by-mention', '--content', '--date', '--days', '--entities', '--fast', '--federated', '--file', '--follow', '--force', '--from-pages', '--help', '--http', '--image', '--json', '--kind', '--limit', '--max-usd', '--mode', '--multimodal', '--no-embedding', '--offset', '--path', '--progress-interval', '--progress-json', '--quiet', '--save', '--session', '--session-id', '--since', '--slug', '--slugs', '--source', '--stats', '--surface', '--synthesize', '--target', '--timeout', '--token', '--trusted-extraction', '--url', '--with-db', '--yes'],
|
||||
'providers': ['--brain', '--ctx-size', '--embedding-dimensions', '--embedding-model', '--embeddings', '--fast', '--force', '--from-pages', '--help', '--http', '--json', '--model', '--multimodal', '--no', '--no-embed', '--no-embedding', '--reranking', '--source', '--surface', '--touchpoint', '--version'],
|
||||
'publish': ['--accent', '--bg', '--border', '--brain', '--card-bg', '--code-bg', '--error', '--fg', '--help', '--json', '--link', '--muted', '--out', '--password', '--source', '--title'],
|
||||
@@ -103,7 +103,7 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
|
||||
'takes': ['--aliases', '--all', '--brain', '--bucket-size', '--by', '--claim', '--dir', '--domain', '--dry-run', '--evidence', '--expired', '--fast', '--federated', '--force', '--from-pages', '--help', '--holder', '--http', '--include-covered', '--include-null-signature', '--json', '--kind', '--limit', '--max-pages', '--no-embedding', '--no-extract', '--no-federated', '--outcome', '--path', '--pattern', '--pending', '--quality', '--refresh', '--repo', '--reset', '--resolve', '--restore-only', '--row', '--since', '--slugs', '--sort', '--source', '--source-id', '--stale', '--supersessions', '--surface', '--thin', '--unit', '--until', '--value', '--weight', '--who', '--yes'],
|
||||
'think': ['--aliases', '--all', '--anchor', '--brain', '--calibration-holder', '--explain', '--fast', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--max-usd', '--mcp-only', '--model', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--rounds', '--save', '--since', '--source', '--stale', '--supersessions', '--surface', '--take', '--thin', '--timeout', '--until', '--with-calibration'],
|
||||
'transcripts': ['--aliases', '--all', '--brain', '--days', '--full', '--help', '--include-null-signature', '--json', '--limit', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin', '--timeout'],
|
||||
'upgrade': ['--all', '--apply-clean-hunks', '--brain', '--check', '--code', '--compile', '--concurrency', '--embedding-dimensions', '--embedding-model', '--fast', '--ff-only', '--force', '--force-all', '--force-orchestrator', '--force-retry', '--force-schema', '--format', '--from-pages', '--help', '--host-dir', '--http', '--inject-bootstrap', '--inline', '--install', '--interval', '--json', '--limit', '--list', '--markdown', '--max-rss', '--migrate-only', '--migration', '--mode', '--model', '--multimodal', '--name-only', '--no', '--no-autopilot-install', '--no-embed', '--no-embedding', '--no-inject', '--no-worker', '--non-interactive', '--now', '--quiet', '--repo', '--since', '--skills-dir', '--skip-verify', '--source', '--stale', '--status', '--surface', '--swap-only', '--target', '--to', '--uninstall', '--user', '--verbose', '--verify', '--version', '--workers', '--yes'],
|
||||
'upgrade': ['--all', '--apply-clean-hunks', '--brain', '--check', '--code', '--compile', '--concurrency', '--embedding-dimensions', '--embedding-model', '--fast', '--ff-only', '--flag', '--force', '--force-all', '--force-orchestrator', '--force-retry', '--force-schema', '--format', '--from-pages', '--help', '--host-dir', '--http', '--inject-bootstrap', '--inline', '--install', '--interval', '--json', '--limit', '--list', '--markdown', '--max-rss', '--migrate-only', '--migration', '--mode', '--model', '--multimodal', '--name-only', '--no', '--no-autopilot-install', '--no-embed', '--no-embedding', '--no-inject', '--no-worker', '--non-interactive', '--now', '--quiet', '--repo', '--since', '--skills-dir', '--skip-verify', '--source', '--stale', '--status', '--surface', '--swap-only', '--target', '--to', '--uninstall', '--user', '--verbose', '--verify', '--version', '--workers', '--yes'],
|
||||
'watch': ['--aliases', '--all', '--brain', '--fast', '--federated', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--max-pages', '--min-confidence', '--no-embedding', '--no-extract', '--no-federated', '--path', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--stats', '--supersessions', '--surface', '--thin', '--window-turns'],
|
||||
'ze-switch': ['--aliases', '--all', '--brain', '--confirm-reembed', '--dry-run', '--force', '--help', '--ignore-env-override', '--ignore-missing-key', '--include-null-signature', '--json', '--markdown', '--no-extract', '--non-interactive', '--pattern', '--pending', '--reset', '--resolve', '--resume', '--source', '--stale', '--supersessions', '--thin', '--undo', '--yes'],
|
||||
};
|
||||
|
||||
+11
-6
@@ -17,6 +17,9 @@ import { repairTimelineDedupIndex } from './timeline-dedup-repair.ts';
|
||||
* notices are useful diagnostics on an UPGRADE but pure noise as a new user's
|
||||
* first-run output. Module-level (not threaded through the Migration type)
|
||||
* because only a couple of handlers emit them. Guarded via `migrationNotice`.
|
||||
* Known limitation: concurrent runMigrations calls in one process (two engines
|
||||
* migrating simultaneously) share this flag — worst case is a suppressed or
|
||||
* extra stderr NOTICE line; migration execution/stamping is unaffected.
|
||||
*/
|
||||
let quietMigrationNotices = false;
|
||||
|
||||
@@ -6053,15 +6056,18 @@ export async function runMigrations(engine: BrainEngine): Promise<{ applied: num
|
||||
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;
|
||||
try {
|
||||
// Pre-flight: warn about connections that might block DDL
|
||||
await checkForBlockingConnections(engine);
|
||||
|
||||
for (const m of pending) {
|
||||
if (!quietReplay) process.stderr.write(` [${m.version}] ${m.name}...\n`);
|
||||
try {
|
||||
await applyOneMigration(engine, m);
|
||||
// Update version after both SQL and handler succeed. Inside the same
|
||||
// catch so a stamp-write failure is also NAMED in quiet mode.
|
||||
await engine.setConfig('version', String(m.version));
|
||||
} catch (err) {
|
||||
// Quiet fresh-install replay: name the failing migration — without the
|
||||
// per-step lines, the error would otherwise be anonymous.
|
||||
@@ -6069,13 +6075,12 @@ export async function runMigrations(engine: BrainEngine): Promise<{ applied: num
|
||||
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++;
|
||||
}
|
||||
} finally {
|
||||
// Never leak the fresh-install quiet flag into a later in-process run.
|
||||
// Never leak the fresh-install quiet flag into a later in-process run —
|
||||
// covers every exit path from here on (incl. the pre-flight probe).
|
||||
quietMigrationNotices = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -112,8 +112,10 @@ export async function runInitNudge(engine: BrainEngine): Promise<void> {
|
||||
|
||||
// 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.
|
||||
// activation surface. Suppress the ENTIRE nudge on empty (including the
|
||||
// partial-checks notice below).
|
||||
const brainEmpty = totalPages === 0;
|
||||
if (brainEmpty) return;
|
||||
|
||||
// Aggregate: any non-zero metric triggers the nudge.
|
||||
const linkCoverage = totalEntities > 0 ? linkedCount / totalEntities : 1;
|
||||
@@ -122,9 +124,7 @@ export async function runInitNudge(engine: BrainEngine): Promise<void> {
|
||||
totalStale > 0
|
||||
|| (totalEntities > 0 && linkCoverage < 0.7)
|
||||
|| (totalEntities > 0 && timelineCoverage < 0.9)
|
||||
|| (takesCount === 0 && !brainEmpty);
|
||||
|
||||
if (brainEmpty) return;
|
||||
|| takesCount === 0;
|
||||
if (!hasRecommendations && !partial) return;
|
||||
|
||||
// Emit one-line nudge. Be terse — init is the activation surface.
|
||||
|
||||
+5
-10
@@ -2773,17 +2773,12 @@ 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();
|
||||
// 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)
|
||||
) {
|
||||
// Shared stale/foreign-cache guard (pendingUpgradeVersion): only an
|
||||
// upgrade strictly newer than the RUNNING version counts.
|
||||
const latest = su.pendingUpgradeVersion(VERSION, Date.now());
|
||||
if (latest) {
|
||||
update_available = true;
|
||||
latest_version = entry.marker.latest ?? null;
|
||||
latest_version = latest;
|
||||
}
|
||||
} catch {
|
||||
/* never let the banner break the op */
|
||||
|
||||
@@ -30,7 +30,7 @@ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unl
|
||||
import { dirname, join } from 'node:path';
|
||||
import { gbrainPath } from './config.ts';
|
||||
import { acquirePackLock, type PackLockOpts } from './schema-pack/pack-lock.ts';
|
||||
import { isValidVersionString, parseSemver, semverGt, semverLte } from './semver.ts';
|
||||
import { isNewerVersion, isValidVersionString, parseSemver, semverGt, semverLte } from './semver.ts';
|
||||
|
||||
// ── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -322,6 +322,29 @@ export function isCacheFresh(entry: CacheEntry, now: number): boolean {
|
||||
return now - entry.mtimeMs < ttl;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one shared "is an upgrade actually pending for THIS binary?" predicate.
|
||||
* Returns the latest version string when the cache is present, fresh, marks an
|
||||
* upgrade, AND that upgrade is strictly newer than the RUNNING binary — else
|
||||
* null. The running-version comparison is the load-bearing part: the cache
|
||||
* records the version of whatever binary WROTE it (an older gbrain on PATH can
|
||||
* write it via the detached refresh), so consumers must never trust
|
||||
* `marker.current` to describe themselves. Every upgrade-nag surface (CLI
|
||||
* startup marker, doctor, advisor, get_brain_identity) routes through here so
|
||||
* the suppression rule cannot drift per-surface. Never throws.
|
||||
*/
|
||||
export function pendingUpgradeVersion(runningVersion: string, now: number = Date.now()): string | null {
|
||||
try {
|
||||
const entry = readUpdateCache();
|
||||
if (!entry || !isCacheFresh(entry, now)) return null;
|
||||
if (entry.marker.kind !== 'upgrade_available' || !entry.marker.latest) return null;
|
||||
if (!isNewerVersion(runningVersion, entry.marker.latest)) return null;
|
||||
return entry.marker.latest;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Snooze (interactive prompting only; never overrides mode=off) ────────────
|
||||
|
||||
export function readSnooze(): SnoozeRecord | null {
|
||||
|
||||
@@ -132,36 +132,51 @@ export function printAdvisoryIfRecommended(opts: {
|
||||
targetWorkspace?: string | null;
|
||||
targetSkillsDir?: string | null;
|
||||
}): 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, '..');
|
||||
// Fail-open: this is decoration on the init success screen and runs AFTER
|
||||
// the brain is created (and, since the memory-verbs quickstart now prints
|
||||
// last, BEFORE it). An unreadable RESOLVER.md must never throw here and
|
||||
// starve the primary CTA — same posture as runInitNudge.
|
||||
try {
|
||||
const advisory = buildAdvisory(opts);
|
||||
if (!advisory) return;
|
||||
if (opts.context === 'init') {
|
||||
// Derive the counts for the compact form from the same detection the
|
||||
// full banner used. Detection is hoisted OUT of the filter (one receipt
|
||||
// read+parse total, matching buildAdvisory's own pattern).
|
||||
let workspace = opts.targetWorkspace ?? null;
|
||||
let skillsDir = opts.targetSkillsDir ?? null;
|
||||
if (!skillsDir) {
|
||||
const detected = autoDetectSkillsDir();
|
||||
if (detected.dir) {
|
||||
skillsDir = detected.dir;
|
||||
if (!workspace) workspace = resolvePath(skillsDir, '..');
|
||||
}
|
||||
}
|
||||
const all = currentRecommendedSet();
|
||||
const installed = workspace && skillsDir ? detectInstalledSlugs(skillsDir, workspace) : null;
|
||||
const missing = installed ? all.filter((s) => !installed.has(s.slug)) : all;
|
||||
if (missing.length === 0) return;
|
||||
const names = missing.map((s) => s.slug);
|
||||
const preview = names.slice(0, 4).join(', ') + (names.length > 4 ? ', …' : '');
|
||||
// No workspace detected → scaffold has no target; say so (the full
|
||||
// banner carries the same caveat via workspaceNotDetected).
|
||||
const noWorkspace = installed === null;
|
||||
// Human-voiced (prints on the init success screen where a person may read
|
||||
// it) — no `[AGENT]` stage-direction leaking to the human. An agent reading
|
||||
// the same line still knows the command to offer.
|
||||
process.stderr.write(
|
||||
`\n${missing.length} recommended skill(s) not installed yet (${preview}).\n` +
|
||||
// NOTE: no bare `--flag` tokens in this string — the flag-registry
|
||||
// generator harvests them from source strings and would register a
|
||||
// phantom flag on every command that imports this module.
|
||||
(noWorkspace
|
||||
? `Open your agent workspace first (scaffold needs a target), then \`${scaffoldCommandFor(missing, all)}\`; full list: gbrain advisor\n`
|
||||
: `Ask me to run \`${scaffoldCommandFor(missing, all)}\`, or see the full list: gbrain advisor\n`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
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 ? ', …' : '');
|
||||
// Human-voiced (prints on the init success screen where a person may read
|
||||
// it) — no `[AGENT]` stage-direction leaking to the human. An agent reading
|
||||
// the same line still knows the command to offer.
|
||||
process.stderr.write(
|
||||
`\n${missing.length} recommended skill(s) not installed yet (${preview}).\n` +
|
||||
`Ask me to run \`${scaffoldCommandFor(missing, all)}\`, or see the full list: gbrain advisor\n`,
|
||||
);
|
||||
return;
|
||||
process.stderr.write(advisory);
|
||||
} catch {
|
||||
/* advisory is best-effort decoration — never break init */
|
||||
}
|
||||
process.stderr.write(advisory);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# gbrain agent workspace — template
|
||||
|
||||
<!-- gbrain-template-stamp: 0.45.7.0 -->
|
||||
<!-- gbrain-template-stamp: 0.45.8.0 -->
|
||||
|
||||
This repository is the **"Use this template"** distribution artifact for a
|
||||
[gbrain](https://github.com/garrytan/gbrain) personal-agent workspace — the same
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Unit tests for src/core/ai/probes.ts (probeOpenAICompat).
|
||||
*
|
||||
* Runs against a local Bun.serve fixture on an ephemeral port (port: 0) so
|
||||
* no real daemon is needed. Pins:
|
||||
* - models extraction from a valid {object:'list', data:[...]} body, with
|
||||
* non-string / missing ids filtered out
|
||||
* - non-list JSON body → models_endpoint_valid false, models undefined
|
||||
* - connection refused → reachable false
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { probeOpenAICompat } from '../src/core/ai/probes.ts';
|
||||
|
||||
describe('probeOpenAICompat — models extraction', () => {
|
||||
test('valid list body extracts string ids only', async () => {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch() {
|
||||
return Response.json({ object: 'list', data: [{ id: 'm1' }, { id: 42 }, {}] });
|
||||
},
|
||||
});
|
||||
try {
|
||||
const r = await probeOpenAICompat(`http://127.0.0.1:${server.port}`);
|
||||
expect(r.reachable).toBe(true);
|
||||
expect(r.models_endpoint_valid).toBe(true);
|
||||
expect(r.models).toEqual(['m1']);
|
||||
} finally {
|
||||
server.stop(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('non-list JSON body → valid false, models undefined', async () => {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch() {
|
||||
return Response.json({ hello: 'world' });
|
||||
},
|
||||
});
|
||||
try {
|
||||
const r = await probeOpenAICompat(`http://127.0.0.1:${server.port}`);
|
||||
expect(r.reachable).toBe(true);
|
||||
expect(r.models_endpoint_valid).toBe(false);
|
||||
expect(r.models).toBeUndefined();
|
||||
} finally {
|
||||
server.stop(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('connection refused → reachable false', async () => {
|
||||
// Grab an ephemeral port by binding, then release it before probing so
|
||||
// the connection is refused (nothing else claims the port that fast).
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch() {
|
||||
return new Response('unused');
|
||||
},
|
||||
});
|
||||
const port = server.port;
|
||||
try {
|
||||
await server.stop(true);
|
||||
const r = await probeOpenAICompat(`http://127.0.0.1:${port}`);
|
||||
expect(r.reachable).toBe(false);
|
||||
expect(r.error).toBeDefined();
|
||||
} finally {
|
||||
server.stop(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import { runBootstrap, workspaceBrainStats } from '../src/commands/bootstrap.ts'
|
||||
import type { ExecRunner } from '../src/core/bootstrap/repo.ts';
|
||||
import { attachWorkspace } from '../src/core/bootstrap/attach.ts';
|
||||
import { readReceipt, receiptPath, writeManifest, type InstallReceipt } from '../src/core/bootstrap/format.ts';
|
||||
import { GBRAIN_HOOK_MARKER_KEY, GBRAIN_HOOK_MARKER_VALUE } from '../src/core/bootstrap/host-specs.ts';
|
||||
import { initState, setAnswer, skipAnswer, confirm, readBackHash } from '../src/core/bootstrap/interview.ts';
|
||||
|
||||
let tmpParent: string; // GBRAIN_HOME parent (configDir appends .gbrain)
|
||||
@@ -435,13 +436,43 @@ describe('MCP registration verification [FIX7]', () => {
|
||||
const r = await runHooks(runner);
|
||||
expect(r.result).toBe(0);
|
||||
expect(r.err).toContain('targets a DIFFERENT workspace');
|
||||
expect(calls.some((c) => c[1] === 'mcp' && c[2] === 'remove' && c[3] === 'gbrain')).toBe(true);
|
||||
// The remove must be SCOPED on claude-code: a scope-less remove can resolve
|
||||
// to a different scope's registration and leave the blocker in place.
|
||||
const removes = calls.filter((c) => c[1] === 'mcp' && c[2] === 'remove' && c[3] === 'gbrain');
|
||||
expect(removes.length).toBeGreaterThan(0);
|
||||
for (const c of removes) {
|
||||
const scopeIdx = c.indexOf('--scope');
|
||||
expect(scopeIdx).toBeGreaterThan(3);
|
||||
expect(c[scopeIdx + 1]).toBe('project');
|
||||
}
|
||||
const adds = calls.filter((c) => c[1] === 'mcp' && c[2] === 'add').length;
|
||||
expect(adds).toBe(2); // initial (foreign) + re-add after remove
|
||||
// After the fix, the smoke confirms the corrected registration.
|
||||
expect(r.out).toContain('verified targeting this workspace');
|
||||
}, 30_000);
|
||||
|
||||
test('mismatch + failed remove → exit 1 with the by-hand fix instruction; add never retried', async () => {
|
||||
// Stateful failure host: add refuses ("already exists"), get shows a
|
||||
// FOREIGN registration (mismatch), and the scoped remove itself fails.
|
||||
const calls: string[][] = [];
|
||||
const runner: ExecRunner = async (argv: string[]) => {
|
||||
calls.push(argv);
|
||||
if (argv[1] !== 'mcp') return { code: 0, stdout: '', stderr: '' };
|
||||
if (argv[2] === 'add') return { code: 1, stdout: '', stderr: 'MCP server gbrain already exists' };
|
||||
if (argv[2] === 'get') return { code: 0, stdout: FOREIGN, stderr: '' };
|
||||
if (argv[2] === 'remove') return { code: 1, stdout: '', stderr: 'nope' };
|
||||
return { code: 0, stdout: '', stderr: '' };
|
||||
};
|
||||
const r = await runHooks(runner);
|
||||
expect(r.result).toBe(1);
|
||||
expect(r.err).toContain('targets a DIFFERENT workspace');
|
||||
// Fail LOUD, not the old silent no-op loop: the message hands the human
|
||||
// the manual off-ramp instead of re-failing the add.
|
||||
expect(r.err).toContain('remove the stale registration by hand');
|
||||
const adds = calls.filter((c) => c[1] === 'mcp' && c[2] === 'add').length;
|
||||
expect(adds).toBe(1); // the failed remove halts the flow before any re-add
|
||||
}, 30_000);
|
||||
|
||||
test('host without `mcp get` → inconclusive, kept with a note (never a false bless)', async () => {
|
||||
const { runner } = mcpHost({ initialReg: OURS, getSupported: false });
|
||||
const r = await runHooks(runner);
|
||||
@@ -452,6 +483,108 @@ describe('MCP registration verification [FIX7]', () => {
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe('MCP host failure × hooks at the dispatcher (exit-127 skip / broken settings fail-closed)', () => {
|
||||
// Self-contained fixtures (the flip pattern): HOOKS_CONSENT left at its bank
|
||||
// default ('yes') so the hooks half of the phase is live in both tests.
|
||||
const scratch: string[] = [];
|
||||
afterAll(() => {
|
||||
for (const d of scratch) rmSync(d, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function failWorkspace(): { fws: string; fhome: string; fparent: string } {
|
||||
const fparent = mkdtempSync(join(tmpdir(), 'gb-fail-'));
|
||||
const fhome = join(fparent, '.gbrain');
|
||||
mkdirSync(fhome, { recursive: true });
|
||||
const fws = mkdtempSync(join(tmpdir(), 'gb-fail-ws-'));
|
||||
scratch.push(fparent, fws);
|
||||
const prev = process.env.GBRAIN_HOME;
|
||||
process.env.GBRAIN_HOME = fparent;
|
||||
try {
|
||||
expect(initState(fws).ok).toBe(true);
|
||||
for (const [key, value] of Object.entries(REQUIRED_ANSWERS)) {
|
||||
const r = setAnswer(fws, key, value);
|
||||
if (!r.ok) throw new Error(r.message);
|
||||
}
|
||||
expect(setAnswer(fws, 'MCP_SCOPE', 'project').ok).toBe(true);
|
||||
const h = readBackHash(fws);
|
||||
if (!h.ok) throw new Error(h.message);
|
||||
expect(confirm(fws, h.hash).ok).toBe(true);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.GBRAIN_HOME;
|
||||
else process.env.GBRAIN_HOME = prev;
|
||||
}
|
||||
return { fws, fhome, fparent };
|
||||
}
|
||||
|
||||
async function withFailHome<T>(parent: string, fn: () => Promise<T>): Promise<T> {
|
||||
const prev = process.env.GBRAIN_HOME;
|
||||
process.env.GBRAIN_HOME = parent;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.GBRAIN_HOME;
|
||||
else process.env.GBRAIN_HOME = prev;
|
||||
}
|
||||
}
|
||||
|
||||
test('`claude` missing (exit 127 on mcp add) → MCP skipped, hooks STILL install, exit 2, receipt detail hooks', async () => {
|
||||
const { fws, fhome, fparent } = failWorkspace();
|
||||
const r = await withFailHome(fparent, async () => {
|
||||
expect((await capture(() => runBootstrap(['render', '--workspace', fws]))).result).toBe(0);
|
||||
const runner: ExecRunner = async (argv: string[]) => {
|
||||
if (argv[0] === 'claude' && argv[1] === 'mcp' && argv[2] === 'add') {
|
||||
return { code: 127, stdout: '', stderr: 'claude: command not found' };
|
||||
}
|
||||
return { code: 0, stdout: '', stderr: '' };
|
||||
};
|
||||
return capture(() =>
|
||||
runBootstrap(['hooks', '--workspace', fws, '--harness', 'claude-code', '--gbrain-bin', process.execPath], {
|
||||
runner,
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(r.result).toBe(2);
|
||||
expect(r.err).toContain('is not on PATH');
|
||||
// The old early-return silently dropped hooks; now they install anyway
|
||||
// (hooks only write settings.local.json and need no host binary).
|
||||
expect(r.out).toContain('hooks installed');
|
||||
const settingsPath = join(fws, '.claude', 'settings.local.json');
|
||||
expect(existsSync(settingsPath)).toBe(true);
|
||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) as {
|
||||
hooks?: Record<string, Array<{ hooks?: Array<Record<string, unknown>> }>>;
|
||||
};
|
||||
const entries = Object.values(settings.hooks ?? {}).flatMap((groups) => groups.flatMap((g) => g.hooks ?? []));
|
||||
expect(entries.length).toBeGreaterThan(0);
|
||||
expect(entries.some((e) => e[GBRAIN_HOOK_MARKER_KEY] === GBRAIN_HOOK_MARKER_VALUE)).toBe(true);
|
||||
// Receipt records what actually landed: hooks only, no MCP.
|
||||
expect(readReceipt(fhome)?.registrations).toEqual([{ host: 'claude-code', scope: 'project', detail: 'hooks' }]);
|
||||
}, 30_000);
|
||||
|
||||
test('unparseable settings.local.json → hooks fail CLOSED (exit 1), file byte-identical, receipt detail mcp', async () => {
|
||||
const { fws, fhome, fparent } = failWorkspace();
|
||||
const broken = '{ definitely broken';
|
||||
const settingsPath = join(fws, '.claude', 'settings.local.json');
|
||||
const r = await withFailHome(fparent, async () => {
|
||||
expect((await capture(() => runBootstrap(['render', '--workspace', fws]))).result).toBe(0);
|
||||
mkdirSync(join(fws, '.claude'), { recursive: true });
|
||||
writeFileSync(settingsPath, broken, 'utf8');
|
||||
const { runner } = makeRunner();
|
||||
return capture(() =>
|
||||
runBootstrap(['hooks', '--workspace', fws, '--harness', 'claude-code', '--gbrain-bin', process.execPath], {
|
||||
runner,
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(r.result).toBe(1);
|
||||
// The refusal explains WHY (the file may carry permissions/allowlist
|
||||
// entries gbrain must not clobber) and names the repair path.
|
||||
expect(r.err).toContain('not valid JSON');
|
||||
expect(readFileSync(settingsPath, 'utf8')).toBe(broken);
|
||||
// MCP (step 1) landed before the hook failure — the receipt says exactly that.
|
||||
expect(readReceipt(fhome)?.registrations).toEqual([{ host: 'claude-code', scope: 'project', detail: 'mcp' }]);
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe('receipt overwrite guard wired into every writer [CX2-12]', () => {
|
||||
function writeNewerReceipt(dir: string): void {
|
||||
mkdirSync(join(dir, 'bootstrap'), { recursive: true });
|
||||
|
||||
@@ -310,6 +310,49 @@ describe('[A8] provenance + read-back confirm', () => {
|
||||
expect(parsed.confirmed).toBeUndefined();
|
||||
});
|
||||
|
||||
test('setAnswer surfaces invalidatedConfirmation ONLY when a confirm existed', () => {
|
||||
const ws = makeWs();
|
||||
answerAllRequired(ws);
|
||||
// No prior confirmation → nothing was invalidated (falsy flag).
|
||||
const r0 = setAnswer(ws, 'SOUL_WINCE', 'Filler openers.');
|
||||
expect(r0.ok).toBe(true);
|
||||
if (!r0.ok || r0.sink !== 'state') throw new Error('expected a state-sink result');
|
||||
expect(r0.invalidatedConfirmation).toBeFalsy();
|
||||
// Full confirm, then a later set → the result SAYS it voided the confirm
|
||||
// (the CLI warns at --set time instead of failing much later at render).
|
||||
const h = readBackHash(ws);
|
||||
if (!h.ok) throw new Error(h.message);
|
||||
expect(confirm(ws, h.hash).ok).toBe(true);
|
||||
const r1 = setAnswer(ws, 'SOUL_GOOD_OUTPUT', 'A finished artifact.');
|
||||
expect(r1.ok).toBe(true);
|
||||
if (!r1.ok || r1.sink !== 'state') throw new Error('expected a state-sink result');
|
||||
expect(r1.invalidatedConfirmation).toBe(true);
|
||||
const st = status(ws);
|
||||
if (!st.ok) throw new Error(st.message);
|
||||
expect(st.confirmed).toBe(false);
|
||||
});
|
||||
|
||||
test('skipAnswer surfaces invalidatedConfirmation ONLY when a confirm existed (optional key — required keys refuse skip)', () => {
|
||||
const ws = makeWs();
|
||||
answerAllRequired(ws);
|
||||
// No prior confirmation → falsy flag on an optional-key skip.
|
||||
const r0 = skipAnswer(ws, 'SOUL_WINCE');
|
||||
expect(r0.ok).toBe(true);
|
||||
if (!r0.ok) throw new Error('unreachable');
|
||||
expect(r0.invalidatedConfirmation).toBeFalsy();
|
||||
// Full confirm, then a later optional-key skip → invalidation surfaced.
|
||||
const h = readBackHash(ws);
|
||||
if (!h.ok) throw new Error(h.message);
|
||||
expect(confirm(ws, h.hash).ok).toBe(true);
|
||||
const r1 = skipAnswer(ws, 'SOUL_WORLDVIEW');
|
||||
expect(r1.ok).toBe(true);
|
||||
if (!r1.ok) throw new Error('unreachable');
|
||||
expect(r1.invalidatedConfirmation).toBe(true);
|
||||
const st = status(ws);
|
||||
if (!st.ok) throw new Error(st.message);
|
||||
expect(st.confirmed).toBe(false);
|
||||
});
|
||||
|
||||
test('show returns the read-back payload with the hash once complete', () => {
|
||||
const ws = makeWs();
|
||||
answerAllRequired(ws);
|
||||
|
||||
@@ -247,6 +247,15 @@ describe('verifyWorkspace — keyless pass', () => {
|
||||
expect(scan.detail).not.toContain('sk-AAAAAAAAAAAAAAAAAAAAAAAA');
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
|
||||
// Tour gating on FAIL: the report says fix-first and withholds the
|
||||
// celebration prompts ("broken, but go enjoy it" is a mixed signal) …
|
||||
expect(res.report).toContain('Fix the FAIL checks above');
|
||||
expect(res.report).not.toContain('Who am I to you?');
|
||||
// … while the returned tour array stays unconditional so machine
|
||||
// consumers (--json) keep a stable shape, and the check names the gate.
|
||||
expect(res.tour).toEqual([...FIRST_RUN_TOUR]);
|
||||
expect(check(res.checks, 'first_run_tour')[0].detail).toContain('withheld');
|
||||
} finally {
|
||||
rmSync(githubPath, { force: true });
|
||||
writeFileSync(userPath, userOriginal);
|
||||
|
||||
@@ -208,6 +208,9 @@ describe('thin-client scratch-DB guard — jobs partial dispatch + config refusa
|
||||
expect(existsSync(join(tmp, '.gbrain', 'brain.pglite'))).toBe(false);
|
||||
expect(r.stdout + r.stderr).not.toContain('Schema version');
|
||||
expect(r.stdout + r.stderr).not.toContain('migration(s) pending');
|
||||
// A scratch store is a FRESH install, so a re-regression would print the
|
||||
// quiet-replay summary line, not the verbose header — pin both shapes.
|
||||
expect(r.stdout + r.stderr).not.toContain('Setting up brain schema');
|
||||
});
|
||||
|
||||
test('`gbrain jobs list` never fabricates a scratch local engine', async () => {
|
||||
@@ -216,5 +219,6 @@ describe('thin-client scratch-DB guard — jobs partial dispatch + config refusa
|
||||
const { existsSync } = await import('fs');
|
||||
expect(existsSync(join(tmp, '.gbrain', 'brain.pglite'))).toBe(false);
|
||||
expect(r.stdout + r.stderr).not.toContain('Schema version');
|
||||
expect(r.stdout + r.stderr).not.toContain('Setting up brain schema');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { withEnv } from './helpers/with-env.ts';
|
||||
import { checkSelfUpgradeHealth } from '../src/commands/doctor.ts';
|
||||
import { writeUpdateCache } from '../src/core/self-upgrade.ts';
|
||||
import { logSelfUpgrade } from '../src/core/audit/self-upgrade-audit.ts';
|
||||
import { VERSION } from '../src/version.ts';
|
||||
|
||||
async function withHome<T>(fn: (home: string) => T | Promise<T>): Promise<T> {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gbrain-doctor-su-'));
|
||||
@@ -40,7 +41,18 @@ describe('checkSelfUpgradeHealth', () => {
|
||||
const c = checkSelfUpgradeHealth();
|
||||
expect(c.status).toBe('ok');
|
||||
expect(c.message).toContain('update available');
|
||||
expect(c.message).toContain('0.99.0');
|
||||
expect(c.message).toContain('-> 0.99.0');
|
||||
});
|
||||
});
|
||||
|
||||
test('fresh cache with latest == running version → suppressed (no update-available nag)', async () => {
|
||||
await withHome(() => {
|
||||
// Stale/foreign cache: the recorded latest is the version we are already
|
||||
// running. The shared pendingUpgradeVersion guard must suppress the nag.
|
||||
writeUpdateCache({ kind: 'upgrade_available', current: VERSION, latest: VERSION });
|
||||
const c = checkSelfUpgradeHealth();
|
||||
expect(c.status).toBe('ok');
|
||||
expect(c.message).not.toContain('update available');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -107,8 +107,11 @@ describe('v0.45 DX wave — non-TTY no-key defaults to keyless (typo still fail-
|
||||
// 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');
|
||||
// The notice names the upgrade affordance — the re-init recipe that
|
||||
// actually works, NOT `config set embedding_model` (which config.ts
|
||||
// hard-refuses as a schema-sizing no-op).
|
||||
expect(r.stderr).toContain('gbrain init --force --pglite --embedding-model');
|
||||
expect(r.stderr).not.toContain('config set embedding_model');
|
||||
// Config persisted with the deferred-embedding sentinel.
|
||||
const cfg = JSON.parse(readFileSync(join(tmpHome, '.gbrain', 'config.json'), 'utf-8'));
|
||||
expect(cfg.embedding_disabled).toBe(true);
|
||||
@@ -153,6 +156,48 @@ describe('v0.45 DX wave — non-TTY no-key defaults to keyless (typo still fail-
|
||||
|
||||
// ============================================================================
|
||||
|
||||
describe('v0.45 DX wave — --supabase non-TTY guard + multi-key no-canonical fail-loud', () => {
|
||||
test('init --supabase without a TTY fails loud and names the --url escape hatch', async () => {
|
||||
// Legacy behavior was a silent exit-0 no-op (stdin closed → readLine
|
||||
// never resolved → process ended with NO config written) — the worst
|
||||
// failure shape for a scripted/agent caller.
|
||||
const home = makeTempHome();
|
||||
try {
|
||||
const r = await runCli(['init', '--supabase'], { gbrainHome: home, env: {} });
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.stderr).toContain('needs an interactive terminal');
|
||||
expect(r.stderr).toContain('--url');
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
test('multiple provider keys with NO canonical candidate stays fail-loud with disambiguation hint', async () => {
|
||||
// The canonical default provider (zeroentropyai) has no key here, so the
|
||||
// non-TTY auto-pick cannot resolve the ambiguity — it must fail loud
|
||||
// (D2/D3), not guess between openai and voyage.
|
||||
const home = makeTempHome();
|
||||
try {
|
||||
const r = await runCli(['init', '--pglite', '--non-interactive'], {
|
||||
gbrainHome: home,
|
||||
env: {
|
||||
OPENAI_API_KEY: 'sk-test-only-for-init-resolution-NOT-CALLED',
|
||||
VOYAGE_API_KEY: 'pa-test-only-for-init-resolution-NOT-CALLED',
|
||||
},
|
||||
});
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.stderr).toMatch(/Multiple embedding providers env-ready/);
|
||||
expect(r.stderr).toMatch(/Disambiguate by passing --embedding-model/);
|
||||
// Fail-loud path exits BEFORE any config write.
|
||||
expect(existsSync(join(home, '.gbrain', 'config.json'))).toBe(false);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
}, 120000);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
|
||||
describe('v0.37 T12 — D6 regression: bug-reporter no-op keys exit 1 with Levenshtein', () => {
|
||||
let tmpHome: string;
|
||||
|
||||
|
||||
@@ -77,6 +77,21 @@ describe('self-upgrade marker on a real invocation', () => {
|
||||
expect(stderr).not.toContain('UPGRADE_AVAILABLE');
|
||||
});
|
||||
|
||||
test('cache latest == running version → suppressed (no marker, no human sentence)', () => {
|
||||
writeCache(`UPGRADE_AVAILABLE ${VERSION} ${VERSION}`);
|
||||
const { stderr } = runGbrain('notify');
|
||||
expect(stderr).not.toContain('UPGRADE_AVAILABLE');
|
||||
expect(stderr).not.toContain('Run: gbrain self-upgrade');
|
||||
});
|
||||
|
||||
test('foreign-writer cache → marker prints the RUNNING version, not the writer\'s', () => {
|
||||
// An older gbrain on PATH wrote the cache: marker.current is 0.0.1, not us.
|
||||
writeCache('UPGRADE_AVAILABLE 0.0.1 0.99.0');
|
||||
const { stderr } = runGbrain('notify');
|
||||
expect(stderr).toContain(`UPGRADE_AVAILABLE ${VERSION} 0.99.0`);
|
||||
expect(stderr).not.toContain('UPGRADE_AVAILABLE 0.0.1');
|
||||
});
|
||||
|
||||
test('active snooze for the version → no marker (notify mode honors snooze)', () => {
|
||||
writeCache(`UPGRADE_AVAILABLE ${VERSION} 0.99.0`);
|
||||
// snooze record: "<version> <level> <epoch-ms>" — fresh ts so it's active.
|
||||
|
||||
+35
-20
@@ -150,7 +150,7 @@ export function renderStallsReport(stalls: readonly Stall[], totalMs: number): s
|
||||
// a live TUI across separate tool calls)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const KEY_MAP: Record<string, string> = {
|
||||
export const KEY_MAP = {
|
||||
Enter: '\r',
|
||||
Up: '\x1b[A',
|
||||
Down: '\x1b[B',
|
||||
@@ -163,8 +163,11 @@ export const KEY_MAP: Record<string, string> = {
|
||||
Backspace: '\x7f',
|
||||
CtrlC: '\x03',
|
||||
CtrlD: '\x04',
|
||||
};
|
||||
} as const satisfies Record<string, string>;
|
||||
|
||||
/** Literal union of key names (not plain string) so sendKey('Entr') is a
|
||||
* compile error in test code; drive mode's runtime-string path keeps the
|
||||
* `| string` overload with its runtime throw. */
|
||||
export type KeyName = keyof typeof KEY_MAP;
|
||||
|
||||
export type DriveCommand =
|
||||
@@ -282,7 +285,8 @@ export interface TtyLaunchOpts {
|
||||
dropEnv?: string[];
|
||||
/** Wall-clock kill switch. Default 15 min. */
|
||||
timeoutMs?: number;
|
||||
/** Observer for every output burst (drive mode streams frames to disk). */
|
||||
/** Observer for every output burst (for callers that want to stream frames
|
||||
* somewhere as they arrive; saveTranscript already persists them at end). */
|
||||
onFrame?: (frame: PtyFrame) => void;
|
||||
}
|
||||
|
||||
@@ -398,13 +402,32 @@ export function launchTty(argv: string[], opts: TtyLaunchOpts = {}): TtySession
|
||||
});
|
||||
}
|
||||
|
||||
const wallTimer = setTimeout(() => {
|
||||
try {
|
||||
proc.kill?.('SIGKILL');
|
||||
} catch {
|
||||
/* ignore */
|
||||
/**
|
||||
* Best-effort kill of the child's whole process TREE, not just the parent.
|
||||
* A PTY child (claude/codex/a shell) is normally the session/group leader of
|
||||
* its pty, so `process.kill(-pid, sig)` reaches its descendants (MCP servers,
|
||||
* sub-shells) — otherwise SIGKILL to the parent orphans them, leaking API
|
||||
* spend and PGLite locks. If the child isn't a group leader the negative-pid
|
||||
* kill throws (EPERM/ESRCH) and we fall back to killing the parent alone.
|
||||
*/
|
||||
function killTree(sig: NodeJS.Signals): void {
|
||||
const pid = proc.pid as number | undefined;
|
||||
if (typeof pid === 'number') {
|
||||
try {
|
||||
process.kill(-pid, sig);
|
||||
return;
|
||||
} catch {
|
||||
/* not a group leader — fall through to parent-only */
|
||||
}
|
||||
}
|
||||
}, timeoutMs);
|
||||
try {
|
||||
proc.kill?.(sig);
|
||||
} catch {
|
||||
/* already dead */
|
||||
}
|
||||
}
|
||||
|
||||
const wallTimer = setTimeout(() => killTree('SIGKILL'), timeoutMs);
|
||||
|
||||
function send(data: string): void {
|
||||
if (exited) return;
|
||||
@@ -416,7 +439,7 @@ export function launchTty(argv: string[], opts: TtyLaunchOpts = {}): TtySession
|
||||
}
|
||||
|
||||
function sendKey(key: KeyName | string): void {
|
||||
const seq = KEY_MAP[key as string];
|
||||
const seq = (KEY_MAP as Record<string, string>)[key];
|
||||
if (seq === undefined) throw new Error(`sendKey: unknown key ${JSON.stringify(key)}`);
|
||||
send(seq);
|
||||
}
|
||||
@@ -494,18 +517,10 @@ export function launchTty(argv: string[], opts: TtyLaunchOpts = {}): TtySession
|
||||
async function close(): Promise<void> {
|
||||
clearTimeout(wallTimer);
|
||||
if (exited) return;
|
||||
try {
|
||||
proc.kill?.('SIGINT');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
killTree('SIGINT');
|
||||
await Promise.race([exitedPromise, Bun.sleep(2000)]);
|
||||
if (!exited) {
|
||||
try {
|
||||
proc.kill?.('SIGKILL');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
killTree('SIGKILL');
|
||||
await Promise.race([exitedPromise, Bun.sleep(1000)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Unit tests for src/core/onboard/init-nudge.ts (runInitNudge).
|
||||
*
|
||||
* runInitNudge fires 6 parallel COUNT probes (stale chunks, entities, linked
|
||||
* entities, timeline entities, takes, total pages) and prints a one-line
|
||||
* nudge to stderr. These tests pin the DX-wave behavior:
|
||||
* - EMPTY brain (pages count 0) → the whole nudge is suppressed, even
|
||||
* when takes === 0 (no "0 takes" jargon-noise on first init)
|
||||
* - pages probe REJECTS → fail-open sentinel treats the brain as
|
||||
* non-empty, so the takes nudge still fires
|
||||
* - non-empty + healthy + one rejected arm → partial-checks notice
|
||||
* - non-empty + takes 0 → "0 takes" opportunity nudge
|
||||
*
|
||||
* The gate is process.stderr.isTTY (NOT process.env), so monkeypatching it
|
||||
* here does not trip the serial-isolation rules for env-mutating tests.
|
||||
* Both isTTY and stderr.write are restored in finally.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { runInitNudge } from '../src/core/onboard/init-nudge.ts';
|
||||
import type { BrainEngine } from '../src/core/engine.ts';
|
||||
|
||||
/** Per-probe result: a count, or an Error to make that arm reject. */
|
||||
interface ProbeCounts {
|
||||
stale?: number | Error;
|
||||
entities?: number | Error;
|
||||
linked?: number | Error;
|
||||
timeline?: number | Error;
|
||||
takes?: number | Error;
|
||||
pages?: number | Error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub engine shaped like { executeRaw: async (sql) => [...] }. Routes each
|
||||
* of runInitNudge's 6 COUNT queries by a distinctive SQL fragment. Order
|
||||
* matters: the linked/timeline queries also contain "type IN ('person'",
|
||||
* so they are matched first.
|
||||
*/
|
||||
function stubEngine(counts: ProbeCounts): BrainEngine {
|
||||
const route = (sql: string): number | Error => {
|
||||
if (sql.includes('content_chunks')) return counts.stale ?? 0;
|
||||
if (sql.includes('FROM takes')) return counts.takes ?? 0;
|
||||
if (sql.includes('FROM links')) return counts.linked ?? 0;
|
||||
if (sql.includes('timeline_entries')) return counts.timeline ?? 0;
|
||||
if (sql.includes("type IN ('person'")) return counts.entities ?? 0;
|
||||
// 6th probe: SELECT COUNT(*) FROM pages WHERE deleted_at IS NULL
|
||||
return counts.pages ?? 0;
|
||||
};
|
||||
return {
|
||||
executeRaw: async (sql: string) => {
|
||||
const r = route(sql);
|
||||
if (r instanceof Error) throw r;
|
||||
return [{ count: r }];
|
||||
},
|
||||
} as unknown as BrainEngine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the nudge with stderr forced to look like a TTY and its writes
|
||||
* captured. Restores both in finally so no other test sees the patch.
|
||||
*/
|
||||
async function runNudgeCaptured(engine: BrainEngine): Promise<string> {
|
||||
const origIsTTY = process.stderr.isTTY;
|
||||
const origWrite = process.stderr.write;
|
||||
let out = '';
|
||||
try {
|
||||
(process.stderr as unknown as { isTTY: boolean }).isTTY = true;
|
||||
process.stderr.write = ((chunk: unknown) => {
|
||||
out += String(chunk);
|
||||
return true;
|
||||
}) as typeof process.stderr.write;
|
||||
await runInitNudge(engine);
|
||||
} finally {
|
||||
process.stderr.write = origWrite;
|
||||
(process.stderr as unknown as { isTTY: boolean | undefined }).isTTY = origIsTTY;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('runInitNudge — empty-brain suppression', () => {
|
||||
test('empty brain (pages 0, takes 0) prints NOTHING', async () => {
|
||||
const out = await runNudgeCaptured(
|
||||
stubEngine({ stale: 0, entities: 0, linked: 0, timeline: 0, takes: 0, pages: 0 }),
|
||||
);
|
||||
expect(out).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runInitNudge — pages probe failure is fail-open', () => {
|
||||
test('pages probe REJECTS with takes 0 → nudge still fires with "0 takes"', async () => {
|
||||
// The -1 sentinel means "count unknown" — treat as non-empty so the
|
||||
// pre-existing behavior (nudge on 0 takes) is preserved.
|
||||
const out = await runNudgeCaptured(
|
||||
stubEngine({
|
||||
stale: 0,
|
||||
entities: 0,
|
||||
linked: 0,
|
||||
timeline: 0,
|
||||
takes: 0,
|
||||
pages: new Error('pages probe failed'),
|
||||
}),
|
||||
);
|
||||
expect(out).toContain('Brain has opportunities');
|
||||
expect(out).toContain('0 takes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runInitNudge — partial-checks notice', () => {
|
||||
test('non-empty healthy brain with one rejected arm → "Init checks incomplete"', async () => {
|
||||
// pages > 0 (non-empty), takes > 0 (no opportunity part), entities 0
|
||||
// (coverage arms vacuous), stale 0 — but the linked probe rejects, so
|
||||
// partial=true with zero parts → the incomplete-checks line.
|
||||
const out = await runNudgeCaptured(
|
||||
stubEngine({
|
||||
stale: 0,
|
||||
entities: 0,
|
||||
linked: new Error('linked probe failed'),
|
||||
timeline: 0,
|
||||
takes: 5,
|
||||
pages: 10,
|
||||
}),
|
||||
);
|
||||
expect(out).toContain('Init checks incomplete');
|
||||
expect(out).toContain('(5/6)');
|
||||
expect(out).toContain('gbrain onboard --check');
|
||||
expect(out).not.toContain('Brain has opportunities');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runInitNudge — non-empty brain opportunities', () => {
|
||||
test('non-empty brain with takes 0 → "Brain has opportunities: 0 takes"', async () => {
|
||||
const out = await runNudgeCaptured(
|
||||
stubEngine({ stale: 0, entities: 0, linked: 0, timeline: 0, takes: 0, pages: 10 }),
|
||||
);
|
||||
expect(out).toContain('Brain has opportunities: 0 takes');
|
||||
expect(out).toContain("Run 'gbrain onboard --check' to see the plan");
|
||||
// All 6 probes succeeded — no partial-checks suffix.
|
||||
expect(out).not.toContain('checks complete');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Quiet fresh-install migration replay (src/core/migrate.ts runMigrations).
|
||||
*
|
||||
* Pins:
|
||||
* - Fresh brain (version 1, all migrations pending) replays quietly: ONE
|
||||
* "Setting up brain schema (vN)..." line, no per-migration "[N] name..."
|
||||
* lines, and the v123/v124 handler notices are suppressed.
|
||||
* - GBRAIN_MIGRATE_VERBOSE=1 escape hatch restores full verbose output on
|
||||
* a fresh brain.
|
||||
* - Upgrades (current > 1) keep the full verbose "Schema version X → Y"
|
||||
* narration — including an in-process upgrade AFTER a quiet fresh replay,
|
||||
* which proves the module-level quietMigrationNotices flag is reset in
|
||||
* the finally and does not leak.
|
||||
* - A no-pending run applies nothing and stays silent.
|
||||
*
|
||||
* Serial: mutates process.env (GBRAIN_MIGRATE_VERBOSE, GBRAIN_PGLITE_SNAPSHOT)
|
||||
* and monkey-patches process.stderr.write; everything is saved in beforeAll /
|
||||
* restored in afterAll or per-test try/finally (repo rule R1).
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { runMigrations, LATEST_VERSION } from '../src/core/migrate.ts';
|
||||
|
||||
let engineA: PGLiteEngine; // fresh quiet replay + later upgrade re-run
|
||||
let engineB: PGLiteEngine | null = null; // fresh verbose replay (test 2)
|
||||
|
||||
let prevVerbose: string | undefined;
|
||||
let prevSnapshot: string | undefined;
|
||||
|
||||
/** Capture everything written to process.stderr.write while fn runs. */
|
||||
async function captureStderr(fn: () => Promise<void>): Promise<string> {
|
||||
const orig = process.stderr.write.bind(process.stderr);
|
||||
let out = '';
|
||||
process.stderr.write = ((chunk: string | Uint8Array): boolean => {
|
||||
out += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8');
|
||||
return true;
|
||||
}) as typeof process.stderr.write;
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
process.stderr.write = orig;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
prevVerbose = process.env.GBRAIN_MIGRATE_VERBOSE;
|
||||
prevSnapshot = process.env.GBRAIN_PGLITE_SNAPSHOT;
|
||||
// Default state for the file: quiet mode active, and NO snapshot fast-path —
|
||||
// a snapshot-restored engine skips runMigrations entirely, which would make
|
||||
// every assertion below vacuous.
|
||||
delete process.env.GBRAIN_MIGRATE_VERBOSE;
|
||||
delete process.env.GBRAIN_PGLITE_SNAPSHOT;
|
||||
|
||||
engineA = new PGLiteEngine();
|
||||
await engineA.connect({});
|
||||
// NOTE: initSchema() deliberately NOT called here — test 1 captures its
|
||||
// stderr output as the fresh-install replay under test.
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (prevVerbose === undefined) delete process.env.GBRAIN_MIGRATE_VERBOSE;
|
||||
else process.env.GBRAIN_MIGRATE_VERBOSE = prevVerbose;
|
||||
if (prevSnapshot === undefined) delete process.env.GBRAIN_PGLITE_SNAPSHOT;
|
||||
else process.env.GBRAIN_PGLITE_SNAPSHOT = prevSnapshot;
|
||||
|
||||
await engineA.disconnect();
|
||||
if (engineB) await engineB.disconnect();
|
||||
});
|
||||
|
||||
describe('quiet fresh-install replay', () => {
|
||||
test('fresh brain prints one summary line, no per-migration or notice lines', async () => {
|
||||
const out = await captureStderr(async () => {
|
||||
await engineA.initSchema();
|
||||
});
|
||||
|
||||
expect(out).toContain('Setting up brain schema (v');
|
||||
// No per-migration " [N] name..." progress lines.
|
||||
expect(out).not.toMatch(/\[\d+\] \S+\.\.\./);
|
||||
// No " [N] ✓ name" completion lines.
|
||||
expect(out).not.toContain('✓');
|
||||
// The verbose header is replaced, not printed alongside.
|
||||
expect(out).not.toContain('Schema version 1 →');
|
||||
// v123/v124 handler notices are suppressed via quietMigrationNotices.
|
||||
expect(out).not.toContain('v123:');
|
||||
expect(out).not.toContain('v124:');
|
||||
|
||||
// The replay actually ran to completion.
|
||||
expect(await engineA.getConfig('version')).toBe(String(LATEST_VERSION));
|
||||
});
|
||||
|
||||
test('GBRAIN_MIGRATE_VERBOSE=1 keeps full output on a fresh brain', async () => {
|
||||
process.env.GBRAIN_MIGRATE_VERBOSE = '1';
|
||||
try {
|
||||
engineB = new PGLiteEngine();
|
||||
await engineB.connect({});
|
||||
const out = await captureStderr(async () => {
|
||||
await engineB!.initSchema();
|
||||
});
|
||||
|
||||
expect(out).toContain('Schema version 1 →');
|
||||
expect(out).toMatch(/\[\d+\] ✓ /);
|
||||
expect(out).not.toContain('Setting up brain schema');
|
||||
} finally {
|
||||
delete process.env.GBRAIN_MIGRATE_VERBOSE;
|
||||
}
|
||||
});
|
||||
|
||||
test('upgrade path (current > 1) stays verbose — and the quiet flag did not leak from test 1', async () => {
|
||||
// Rewind engineA one version so exactly the last migration is pending.
|
||||
// This runs in the SAME process AFTER test 1's quiet replay, so verbose
|
||||
// output here also proves runMigrations' finally reset quietMigrationNotices.
|
||||
await engineA.setConfig('version', String(LATEST_VERSION - 1));
|
||||
|
||||
let result: { applied: number; current: number } | undefined;
|
||||
const out = await captureStderr(async () => {
|
||||
result = await runMigrations(engineA);
|
||||
});
|
||||
|
||||
expect(result?.applied).toBe(1);
|
||||
expect(result?.current).toBe(LATEST_VERSION);
|
||||
expect(out).toContain(`Schema version ${LATEST_VERSION - 1} → `);
|
||||
expect(out).toContain('✓');
|
||||
expect(out).not.toContain('Setting up brain schema');
|
||||
});
|
||||
|
||||
test('no-pending run applies nothing and emits no setup/migration lines', async () => {
|
||||
let result: { applied: number; current: number } | undefined;
|
||||
const out = await captureStderr(async () => {
|
||||
result = await runMigrations(engineA);
|
||||
});
|
||||
|
||||
expect(result?.applied).toBe(0);
|
||||
expect(result?.current).toBe(LATEST_VERSION);
|
||||
expect(out).not.toContain('Setting up brain schema');
|
||||
expect(out).not.toContain('Schema version');
|
||||
expect(out).not.toContain('✓');
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,9 @@ import { tmpdir } from 'os';
|
||||
import {
|
||||
buildAdvisory,
|
||||
detectInstalledSlugs,
|
||||
printAdvisoryIfRecommended,
|
||||
} from '../src/core/skillpack/post-install-advisory.ts';
|
||||
import { currentRecommendedSet } from '../src/core/advisor/recommended-set.ts';
|
||||
|
||||
const cleanup: string[] = [];
|
||||
|
||||
@@ -218,6 +220,72 @@ describe('buildAdvisory — agent-readable framing', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('printAdvisoryIfRecommended — compact init pointer vs full upgrade banner', () => {
|
||||
function captureStderr(fn: () => void): string {
|
||||
const orig = process.stderr.write;
|
||||
let out = '';
|
||||
process.stderr.write = ((chunk: unknown) => {
|
||||
out += String(chunk);
|
||||
return true;
|
||||
}) as typeof process.stderr.write;
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
process.stderr.write = orig;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
it('context init with all skills missing prints the compact human-voiced pointer', () => {
|
||||
const { workspace, skillsDir } = scratchWorkspace([]);
|
||||
const out = captureStderr(() =>
|
||||
printAdvisoryIfRecommended({
|
||||
version: '0.25.1',
|
||||
context: 'init',
|
||||
targetWorkspace: workspace,
|
||||
targetSkillsDir: skillsDir,
|
||||
}),
|
||||
);
|
||||
const names = currentRecommendedSet().map((s) => s.slug);
|
||||
expect(out).toContain('recommended skill(s) not installed yet');
|
||||
// Preview truncates at 4 slugs + ellipsis; the 5th slug never appears
|
||||
// (the scaffold command is --all when everything is missing).
|
||||
expect(out).toContain(`(${names.slice(0, 4).join(', ')}, …)`);
|
||||
expect(out).not.toContain(names[4]);
|
||||
expect(out).toContain('gbrain advisor');
|
||||
// The compact init pointer is human-voiced — no agent stage directions.
|
||||
expect(out).not.toContain('ACTION FOR THE AGENT');
|
||||
expect(out).not.toContain('[AGENT]');
|
||||
});
|
||||
|
||||
it('context init with everything installed prints NOTHING', () => {
|
||||
const allSlugs = currentRecommendedSet().map((s) => s.slug);
|
||||
const { workspace, skillsDir } = scratchWorkspace(allSlugs);
|
||||
const out = captureStderr(() =>
|
||||
printAdvisoryIfRecommended({
|
||||
version: '0.25.1',
|
||||
context: 'init',
|
||||
targetWorkspace: workspace,
|
||||
targetSkillsDir: skillsDir,
|
||||
}),
|
||||
);
|
||||
expect(out).toBe('');
|
||||
});
|
||||
|
||||
it('context upgrade with missing skills keeps the full agent-addressed banner', () => {
|
||||
const { workspace, skillsDir } = scratchWorkspace([]);
|
||||
const out = captureStderr(() =>
|
||||
printAdvisoryIfRecommended({
|
||||
version: '0.25.1',
|
||||
context: 'upgrade',
|
||||
targetWorkspace: workspace,
|
||||
targetSkillsDir: skillsDir,
|
||||
}),
|
||||
);
|
||||
expect(out).toContain('ACTION FOR THE AGENT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAdvisory — no workspace detected', () => {
|
||||
it('still renders an advisory with a workspace-detection note', () => {
|
||||
const advisory = buildAdvisory({
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Unit tests for `pendingUpgradeVersion` — the ONE shared "is an upgrade
|
||||
* actually pending for THIS binary?" predicate (src/core/self-upgrade.ts).
|
||||
*
|
||||
* Every upgrade-nag surface (CLI startup marker, doctor, advisor,
|
||||
* get_brain_identity) routes through it, so this file pins the suppression
|
||||
* rule centrally: a stale or foreign cache (latest <= running version) must
|
||||
* return null. The cache records the version of whatever binary WROTE it —
|
||||
* an older gbrain on PATH can write `UPGRADE_AVAILABLE 0.0.1 X` — so the
|
||||
* comparison must be against the RUNNING version, never `marker.current`.
|
||||
*
|
||||
* Also covers the advisor consumer (collect-version.ts): version_drift fires
|
||||
* only when the shared predicate says an upgrade is pending.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, utimesSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { withEnv } from './helpers/with-env.ts';
|
||||
import {
|
||||
CACHE_TTL_UPGRADE_AVAILABLE_MS,
|
||||
pendingUpgradeVersion,
|
||||
updateCachePath,
|
||||
writeUpdateCache,
|
||||
} from '../src/core/self-upgrade.ts';
|
||||
import { collectVersion } from '../src/core/advisor/collect-version.ts';
|
||||
import type { AdvisorContext } from '../src/core/advisor/types.ts';
|
||||
|
||||
/** Run `fn` with GBRAIN_HOME pointed at a fresh temp dir (env restored after). */
|
||||
async function withHome<T>(fn: (home: string) => T | Promise<T>): Promise<T> {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gbrain-pending-'));
|
||||
try {
|
||||
return await withEnv({ GBRAIN_HOME: dir }, () => fn(dir));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Backdate the cache file's mtime so `isCacheFresh` sees it as expired. */
|
||||
function backdateCache(byMs: number): void {
|
||||
const then = new Date(Date.now() - byMs);
|
||||
utimesSync(updateCachePath(), then, then);
|
||||
}
|
||||
|
||||
describe('pendingUpgradeVersion', () => {
|
||||
test('fresh cache, latest > running → returns latest', async () => {
|
||||
await withHome(() => {
|
||||
writeUpdateCache({ kind: 'upgrade_available', current: '0.42.0', latest: '0.99.0' });
|
||||
expect(pendingUpgradeVersion('0.42.0')).toBe('0.99.0');
|
||||
});
|
||||
});
|
||||
|
||||
test('fresh cache, latest == running → null (already current; suppress the nag)', async () => {
|
||||
await withHome(() => {
|
||||
writeUpdateCache({ kind: 'upgrade_available', current: '0.42.0', latest: '0.42.0' });
|
||||
expect(pendingUpgradeVersion('0.42.0')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('fresh cache, latest < running → null (downgrade/yanked never nags)', async () => {
|
||||
await withHome(() => {
|
||||
writeUpdateCache({ kind: 'upgrade_available', current: '0.0.1', latest: '0.42.0' });
|
||||
expect(pendingUpgradeVersion('0.99.0')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('foreign-writer cache: comparison is against the RUNNING version, not marker.current', async () => {
|
||||
await withHome(() => {
|
||||
// An old 0.0.1 binary on PATH wrote the cache. The running binary must
|
||||
// compare ITS version to latest — marker.current is untrusted.
|
||||
writeUpdateCache({ kind: 'upgrade_available', current: '0.0.1', latest: '0.99.0' });
|
||||
expect(pendingUpgradeVersion('0.99.0')).toBeNull(); // already on latest → suppressed
|
||||
expect(pendingUpgradeVersion('0.42.0')).toBe('0.99.0'); // genuinely behind → nag
|
||||
});
|
||||
});
|
||||
|
||||
test('stale cache (mtime beyond upgrade_available TTL) → null', async () => {
|
||||
await withHome(() => {
|
||||
writeUpdateCache({ kind: 'upgrade_available', current: '0.42.0', latest: '0.99.0' });
|
||||
backdateCache(CACHE_TTL_UPGRADE_AVAILABLE_MS + 60_000);
|
||||
expect(pendingUpgradeVersion('0.42.0')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('missing cache → null', async () => {
|
||||
await withHome(() => {
|
||||
expect(pendingUpgradeVersion('0.42.0')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('up_to_date marker kind → null', async () => {
|
||||
await withHome(() => {
|
||||
writeUpdateCache({ kind: 'up_to_date', current: '0.42.0' });
|
||||
expect(pendingUpgradeVersion('0.42.0')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('corrupt cache content → null (never throws)', async () => {
|
||||
await withHome((home) => {
|
||||
mkdirSync(join(home, '.gbrain'), { recursive: true });
|
||||
writeFileSync(updateCachePath(), 'UPGRADE_AVAILABLE not-a-version; rm -rf /\n');
|
||||
expect(pendingUpgradeVersion('0.42.0')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('advisor collectVersion (consumer of the shared predicate)', () => {
|
||||
const ctx = (version: string) => ({ version } as unknown as AdvisorContext);
|
||||
|
||||
test('fresh cache with newer latest → one version_drift finding', async () => {
|
||||
await withHome(async () => {
|
||||
writeUpdateCache({ kind: 'upgrade_available', current: '0.42.0', latest: '0.99.0' });
|
||||
const findings = await collectVersion.collect(ctx('0.42.0'));
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0].id).toBe('version_drift');
|
||||
expect(findings[0].title).toContain('0.99.0');
|
||||
expect(findings[0].title).toContain('0.42.0');
|
||||
});
|
||||
});
|
||||
|
||||
test('latest == ctx.version → no findings (stale/foreign cache suppressed)', async () => {
|
||||
await withHome(async () => {
|
||||
writeUpdateCache({ kind: 'upgrade_available', current: '0.42.0', latest: '0.99.0' });
|
||||
const findings = await collectVersion.collect(ctx('0.99.0'));
|
||||
expect(findings).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -207,9 +207,11 @@ describe.skipIf(!ptySupported())('launchTty (live PTY smoke)', () => {
|
||||
const frames = session.frames();
|
||||
expect(frames.length).toBeGreaterThanOrEqual(2);
|
||||
// The 600ms sleep shows up as a measurable gap (loose bound: >= 300ms).
|
||||
// Match the specific stall rather than index 0 — a slow spawn on a loaded
|
||||
// CI box can prepend a startup stall ('(no output yet)') before it.
|
||||
const stalls = computeStalls(frames, { thresholdMs: 300 });
|
||||
expect(stalls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(stalls[0]!.context).toContain('one');
|
||||
expect(stalls.some((s) => s.context.includes('one'))).toBe(true);
|
||||
}, 20_000);
|
||||
|
||||
test('send + waitFor drive an interactive child; child sees a real TTY', async () => {
|
||||
|
||||
Reference in New Issue
Block a user