From 248fb7a90f9ce5fe2e5d01fe486345d08834ed40 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 30 May 2026 14:58:40 -0700 Subject: [PATCH] v0.41.38.0 fix: code-callers/callees honor .gbrain-source pin + gbrain dream runs on postgres engines (#1666) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(code-callers/callees): honor .gbrain-source pin via full source-resolution chain code-callers and code-callees called resolveDefaultSource directly, which only knew "1 source -> use it, else multiple_sources_ambiguous" and ignored the .gbrain-source pin (and env / local_path / brain_default / sole_non_default tiers). On a multi-source brain they errored even when a pin clearly selected one source, while code-def/code-refs "worked" only because they never scope by source at all. New shared helper resolveScopedSourceOrThrow(engine, cwd) in sources-ops.ts runs the full resolveSourceWithTier chain and applies the ambiguity guard ONLY when nothing matched (tier seed_default). Both commands route through it; an explicit --source/--all-sources still overrides. Adds source_id + scope to the JSON envelope, a stderr nudge on the sole_non_default tier (matches sync/import), a zero-result "try --all-sources" hint, and clean exit-2 handling for a bad pin. Tests: test/code-scoped-source-resolve.test.ts (8 helper cases incl. dotfile pin, env/brain_default/sole_non_default tiers, ambiguity, bad pin) and test/code-callers-pin.serial.test.ts (9 CLI-wiring cases via process.chdir). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(dream): run against postgres engines (skip filesystem-only phases when no checkout) gbrain dream hard-failed with "No brain directory found" on a postgres/Supabase brain with no local checkout, so the DB-only maintenance phases (notably resolve_symbol_edges, the call-graph builder) could never run. doctor even recommended `gbrain dream --source `, a command that couldn't run. - dream.ts: resolveBrainDir returns string|null (order: --dir -> resolved source's local_path -> sync.repo_path -> null); runDream owns the both-null (no checkout AND no engine) exit 1. - cycle.ts: CycleOpts.brainDir is string|null; resolveSourceForDir null-tolerant; the 6 filesystem phases (lint/backlinks/sync/synthesize/extract/patterns) skip with reason 'no_brain_dir' when there's no checkout; DB phases run. cycleSourceId = opts.sourceId ?? resolveSourceForDir(...) scopes the per-source DB phases (extract_facts/extract_atoms/calibration) correctly even on a checkout-less brain (previously they scoped to 'default' while the cycle stamped the requested source fresh — a freshness stamp that lied). deriveStatus counts resolved/ambiguous edges as work so an edges-only cycle reports 'ok'. - jobs.ts: the autopilot-cycle handler passes null (not cwd '.') when no repo is configured, so the queued cycle follows the same no_brain_dir contract. Tests: test/dream-postgres.test.ts (8: null-brainDir path, --source scope regression, edges->ok, both-null exit) + test/jobs-autopilot-cycle-braindir.test.ts (1: handler passes null -> FS phases skip). Co-Authored-By: Claude Opus 4.8 (1M context) * chore: bump version and changelog (v0.41.38.0) Co-Authored-By: Claude Opus 4.8 (1M context) * fix(review): scope dream brainDir to --source; null fallback for phase handlers; quarantine heavy PGLite tests Addresses pre-landing review findings (codex P1/P2): - dream.ts resolveBrainDir: when --source resolves but that source has no on-disk checkout, return null (DB-only) instead of falling through to the global sync.repo_path. That global path belongs to the default/unscoped brain; running FS phases against it while DB phases + the last_full_cycle_at stamp target the requested source mixed scopes (codex P1). Adds a regression test (--source repo-a + a configured global sync.repo_path → brain_dir null). - jobs.ts makePhaseHandler: fall back to null (not cwd '.') when no repo is configured, matching the autopilot-cycle handler + gbrain dream. A direct phase job (synthesize/patterns) on a checkout-less brain now skips FS phases as no_brain_dir instead of running against the worker cwd (codex P2). - Move dream-postgres + jobs-autopilot-cycle-braindir tests to *.serial.test.ts (they run full runCycle passes; the serial pass avoids parallel-shard PGLite cold-start contention). Co-Authored-By: Claude Opus 4.8 (1M context) * docs(todos): record v0.41.38.0 dream-postgres / source-pin follow-ups Co-Authored-By: Claude Opus 4.8 (1M context) * docs: update CLAUDE.md Key Files for v0.41.38.0 (dream-postgres + source pin) - dream.ts entry: resolveBrainDir returns string|null; checkout-less postgres runs DB phases + skips FS phases (no_brain_dir); --source-with-no-checkout doesn't borrow a different source's global repo. - cycle.ts entry: CycleOpts.brainDir nullable; cycleSourceId per-source scope; deriveStatus counts edges; jobs.ts handlers pass null not '.'. - Regenerated llms-full.txt (bun run build:llms). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 105 ++++++++ CLAUDE.md | 4 +- TODOS.md | 31 +++ VERSION | 2 +- llms-full.txt | 4 +- package.json | 2 +- src/commands/code-callees.ts | 62 ++++- src/commands/code-callers.ts | 85 ++++-- src/commands/dream.ts | 64 ++++- src/commands/jobs.ts | 18 +- src/core/cycle.ts | 106 ++++++-- src/core/sources-ops.ts | 42 +++ test/code-callers-pin.serial.test.ts | 245 ++++++++++++++++++ test/code-scoped-source-resolve.test.ts | 186 +++++++++++++ test/dream-postgres.serial.test.ts | 238 +++++++++++++++++ ...bs-autopilot-cycle-braindir.serial.test.ts | 68 +++++ 16 files changed, 1190 insertions(+), 72 deletions(-) create mode 100644 test/code-callers-pin.serial.test.ts create mode 100644 test/code-scoped-source-resolve.test.ts create mode 100644 test/dream-postgres.serial.test.ts create mode 100644 test/jobs-autopilot-cycle-braindir.serial.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 332670638..31b8a39c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,111 @@ All notable changes to GBrain will be documented in this file. +## [0.41.38.0] - 2026-05-30 + +**Two fixes for Supabase brains with a code source. `gbrain code-callers` and +`gbrain code-callees` now respect your `.gbrain-source` pin instead of demanding +`--source` every time, and `gbrain dream` finally runs on a postgres brain that +has no local checkout instead of dying with "No brain directory found."** + +If you build a code call graph with gbrain (the way gstack's `/sync-gbrain` +does), both of these bit you. You'd run `gbrain dream` to build the graph and it +would error out on Supabase. And every `gbrain code-callers Foo` query forced a +`--source` flag even though you'd pinned the source with a `.gbrain-source` +dotfile in your repo. Now "who calls this / what does this call" works out of +the box on a Supabase brain. + +### What changed for you + +- **`code-callers` / `code-callees` honor the source pin.** When you don't pass + `--source` or `--all-sources`, they now resolve through the same chain as + `gbrain sources current`: flag, then `GBRAIN_SOURCE`, then the + `.gbrain-source` dotfile, then the source whose `local_path` contains your cwd, + then the brain default, then the single non-default source. They only fall + back to the old "multi-source, pick one" error when nothing in that chain + matches. `code-def` / `code-refs` are unchanged. +- **`gbrain dream` runs on postgres brains with no checkout.** The maintenance + cycle's database-only phases (including `resolve_symbol_edges`, the call-graph + builder) run against the DB. The six filesystem phases (lint, backlinks, sync, + synthesize, extract, patterns) are skipped with a clear reason instead of a + blanket failure. `gbrain doctor`'s "Run `gbrain dream --source `" + recommendation now actually works on this engine. + +### How to use it + +```bash +# In a repo with a .gbrain-source pin, on a multi-source Supabase brain: +gbrain code-callers myFunction # resolves to the pinned source, no --source +gbrain code-callees myFunction --json # JSON now includes source_id + scope + +# Build the call graph on a checkout-less postgres brain: +gbrain dream --phase resolve_symbol_edges +gbrain dream --source my-code-source # scopes per-source phases correctly +``` + +### Things to know + +- `code-callers` / `code-callees` JSON output gains `source_id` and `scope` + (`"single"` or `"all"`) so a tool can confirm which source it actually queried. + When the pin auto-routed to the only non-default source, a one-line stderr + nudge names it (same as `gbrain sync`). A zero-result implicit-scope query + appends a "try `--all-sources`" hint. +- `gbrain dream`'s JSON report exposes skipped filesystem phases as + `phases[].status: "skipped"` with `phases[].details.reason: "no_brain_dir"` — + a stable shape downstream tools can key off. Pass `--dir ` to run the + filesystem phases. +- `gbrain dream --source ` now scopes the per-source database phases + (`extract_facts`, `extract_atoms`, calibration) to that source even with no + checkout. Previously they silently ran against the `default` source while the + cycle marked your source "fresh" — a freshness stamp that lied. +- An edges-only cycle now reports `ok` (not `clean`) when it resolves call-graph + edges, so you can tell real work from a no-op. +- The queued `autopilot-cycle` job (what `gbrain remote ping` triggers) follows + the same checkout-less behavior instead of running against the worker's + current directory. + +## To take advantage of v0.41.38.0 + +Nothing to run. `gbrain upgrade` ships the fix. After upgrading, on a postgres +brain with a `.gbrain-source` pin: + +```bash +gbrain code-callers # should resolve without --source +gbrain dream --phase resolve_symbol_edges # should run, not error +``` + +If `gbrain dream` still prints "No brain directory found and no database +connection," your `~/.gbrain/config.json` has neither a postgres `database_url` +nor a `sync.repo_path`; run `gbrain doctor` and file an issue with its output. + +### Itemized changes + +**code-callers / code-callees source resolution** +- New `resolveScopedSourceOrThrow(engine, cwd)` in `src/core/sources-ops.ts`: + runs `resolveSourceWithTier` and only applies the multi-source ambiguity guard + (`resolveDefaultSource`) on the `seed_default` tier. Returns `{source_id, tier}`. +- `src/commands/code-callers.ts` + `src/commands/code-callees.ts` route through + it, add `source_id` + `scope` to the JSON envelope, emit the `sole_non_default` + stderr nudge, append the zero-result `--all-sources` hint, and surface a bad + `.gbrain-source` / `GBRAIN_SOURCE` value as a clean `invalid_source_pin` exit 2. + +**gbrain dream on postgres** +- `src/commands/dream.ts`: `resolveBrainDir` returns `string | null` (resolution + order: `--dir` → resolved source's `local_path` → `sync.repo_path` → null); + `runDream` owns the both-null (no checkout AND no engine) exit 1. +- `src/core/cycle.ts`: `CycleOpts.brainDir` is now `string | null`; + `resolveSourceForDir` is null-tolerant; the six filesystem phases skip with + `reason: "no_brain_dir"` when there's no checkout; `cycleSourceId = + opts.sourceId ?? resolveSourceForDir(...)` scopes the per-source DB phases; + `deriveStatus` counts resolved/ambiguous edges as work. +- `src/commands/jobs.ts`: the `autopilot-cycle` handler passes `null` (not cwd + `'.'`) when no repo is configured. + +**Tests** +- `test/code-scoped-source-resolve.test.ts` (8), `test/code-callers-pin.serial.test.ts` + (9), `test/dream-postgres.test.ts` (8), `test/jobs-autopilot-cycle-braindir.test.ts` + (1) — covering the pin chain, env/brain_default/sole_non_default tiers, the + source-scope regression, the null-brainDir path, edges→ok, and the both-null exit. ## [0.41.37.0] - 2026-05-30 **A four-bug critical fix wave: your tags stop disappearing on reindex, a diff --git a/CLAUDE.md b/CLAUDE.md index 8c568a3c9..0fde0d968 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -291,7 +291,7 @@ strict behavior when unset. - `src/commands/import.ts` — `gbrain import` CLI + `runImport` library entrypoint. v0.34.2.0 replaces the prior positional-index checkpoint (`processedIndex: N` into a sorted file list) with a path-set checkpoint via `src/core/import-checkpoint.ts`. The walk still applies `sortNewestFirst()` for embed-cost ordering, but checkpoint correctness no longer depends on sort order. A file enters `completed: Set` only when its `processFile` returns success (including content-hash short-circuit no-ops); failed files never enter the set, so the next run retries them automatically with no manual `~/.gbrain/import-checkpoint.json` delete. Three bug classes died: parallel-import-with-slow-worker drops the slow file on crash-resume (closed — the slow file isn't in `completed` until its own `processFile` resolves), failed-file-bumps-counter-past-itself (closed — failures don't add to `completed`), and v0.33.x sort-flip-drops-newest-N-on-cross-version-resume (closed — order is no longer part of the checkpoint). Old positional checkpoints are detected and discarded with a stderr line on first resume; re-walking is cheap because `content_hash` short-circuits unchanged files. Checkpoint persists every 100 successful adds, not every 100 processed files, so a long failure tail doesn't churn the JSON. Pinned by `test/import-checkpoint.test.ts` (18 unit cases over the helpers) + `test/import-resume.test.ts` (5 integration cases under PGLite, including the SLUG_MISMATCH retry regression codex caught during plan-eng-review). - `src/core/import-checkpoint.ts` (v0.34.2.0) — `loadCheckpoint(brainDir)`, `saveCheckpoint(brainDir, completed)`, `resumeFilter(files, completed, brainDir)`, `clearCheckpoint()`, plus the `ImportCheckpoint` type. Path-set checkpoint format (`{schema_version, brainDir, completed: string[]}`) replaces the v0.33.x positional `{processedIndex: N}` format. Atomic write via `.tmp` + `rename()` so a mid-write crash never leaves a partial JSON. `loadCheckpoint` returns `null` on: missing file, malformed JSON, brainDir mismatch (you ran import against a different brain), and the old positional format (logged to stderr before being discarded). `resumeFilter` returns `{toProcess, skippedCount}` — pure, no I/O, deterministic. `clearCheckpoint` is no-op-on-missing for clean-exit cleanup. Honors `GBRAIN_HOME` via `gbrainPath()` so test isolation via `withEnv({GBRAIN_HOME: tmpdir})` works without monkey-patching the fs layer. Best-effort persistence — `saveCheckpoint` logs warnings on write errors but never throws, so import keeps making progress even if disk is full. - `src/core/sort-newest-first.ts` (v0.34.2.0) — single source of truth for the descending-lex sort that `gbrain import` and `gbrain sync` both apply. Mutates in place (Array.prototype.sort semantics), returns the same array reference for fluent chaining. Empty/single-element inputs short-circuit. Future ordering changes flip one line in this helper instead of touching two CLI commands. Pinned by `test/sort-newest-first.test.ts` (5 hermetic cases: descending order, mixed prefixes, empty input, single-element input, in-place-mutation contract). -- `src/core/cycle.ts` — v0.17 brain maintenance cycle primitive (extended to **9 phases in v0.29**). `runCycle(engine: BrainEngine | null, opts: CycleOpts): Promise` composes phases in semantically-driven order: **lint → backlinks → sync → synthesize → extract → patterns → recompute_emotional_weight → embed → orphans**. v0.29 adds the `recompute_emotional_weight` phase between patterns and embed; it sees the union of `syncPagesAffected` + `synthesizeWrittenSlugs` for incremental mode, or all pages when neither anchor is set (full backfill via `gbrain dream --phase recompute_emotional_weight`). v0.29 also extends `CycleReport.totals` with `pages_emotional_weight_recomputed` (additive, schema_version stays "1"). v0.23's `synthesize` phase runs after sync (cross-references see fresh brain) and before extract (auto-link materializes its writes); `patterns` runs after extract so it reads a fresh graph (codex finding #7 — subagent put_page sets `ctx.remote=true` and skips auto-link/timeline by default; extract is the canonical materialization). Three callers: `gbrain dream` CLI, `gbrain autopilot` daemon's inline path, and the Minions `autopilot-cycle` handler. Coordination via `gbrain_cycle_locks` DB table + `~/.gbrain/cycle.lock` file lock with PID-liveness for PGLite. `CycleReport.schema_version: "1"` is stable; totals additively grew in v0.23 (`transcripts_processed`, `synth_pages_written`, `patterns_written`). `yieldBetweenPhases` runs between phases. **v0.23 added `yieldDuringPhase`** for in-phase keepalive — synthesize/patterns call it during long waits to renew the cycle-lock TTL. Engine nullable; lock-skip on read-only phase selections. v0.22.1 (#403): `CycleOpts.signal?: AbortSignal` propagates the worker's abort signal; `checkAborted()` fires between every phase. v0.22.1 (#417): `runPhaseSync` returns `pagesAffected` via `SyncPhaseResult`; `runCycle` captures it and threads to `runPhaseExtract` as the 4th arg. v0.22.1 (Codex F2): `runPhaseSync` takes `willRunExtractPhase: boolean` and sets `noExtract: phases.includes('extract')` so `gbrain dream --phase sync` doesn't silently lose extraction. v0.22.5 (#475): `resolveSourceForDir(engine, brainDir)` threads `sourceId` to `performSync()` so sync reads the per-source `sources.last_commit` anchor instead of the drift-prone global `config.sync.last_commit` key. +- `src/core/cycle.ts` — v0.17 brain maintenance cycle primitive (extended to **9 phases in v0.29**). `runCycle(engine: BrainEngine | null, opts: CycleOpts): Promise` composes phases in semantically-driven order: **lint → backlinks → sync → synthesize → extract → patterns → recompute_emotional_weight → embed → orphans**. v0.29 adds the `recompute_emotional_weight` phase between patterns and embed; it sees the union of `syncPagesAffected` + `synthesizeWrittenSlugs` for incremental mode, or all pages when neither anchor is set (full backfill via `gbrain dream --phase recompute_emotional_weight`). v0.29 also extends `CycleReport.totals` with `pages_emotional_weight_recomputed` (additive, schema_version stays "1"). v0.23's `synthesize` phase runs after sync (cross-references see fresh brain) and before extract (auto-link materializes its writes); `patterns` runs after extract so it reads a fresh graph (codex finding #7 — subagent put_page sets `ctx.remote=true` and skips auto-link/timeline by default; extract is the canonical materialization). Three callers: `gbrain dream` CLI, `gbrain autopilot` daemon's inline path, and the Minions `autopilot-cycle` handler. Coordination via `gbrain_cycle_locks` DB table + `~/.gbrain/cycle.lock` file lock with PID-liveness for PGLite. `CycleReport.schema_version: "1"` is stable; totals additively grew in v0.23 (`transcripts_processed`, `synth_pages_written`, `patterns_written`). `yieldBetweenPhases` runs between phases. **v0.23 added `yieldDuringPhase`** for in-phase keepalive — synthesize/patterns call it during long waits to renew the cycle-lock TTL. Engine nullable; lock-skip on read-only phase selections. v0.22.1 (#403): `CycleOpts.signal?: AbortSignal` propagates the worker's abort signal; `checkAborted()` fires between every phase. v0.22.1 (#417): `runPhaseSync` returns `pagesAffected` via `SyncPhaseResult`; `runCycle` captures it and threads to `runPhaseExtract` as the 4th arg. v0.22.1 (Codex F2): `runPhaseSync` takes `willRunExtractPhase: boolean` and sets `noExtract: phases.includes('extract')` so `gbrain dream --phase sync` doesn't silently lose extraction. v0.22.5 (#475): `resolveSourceForDir(engine, brainDir)` threads `sourceId` to `performSync()` so sync reads the per-source `sources.last_commit` anchor instead of the drift-prone global `config.sync.last_commit` key. **v0.41.38.0 (postgres support):** `CycleOpts.brainDir` is now `string | null`; when null (checkout-less postgres/Supabase brain) the 6 filesystem phases (lint/backlinks/sync/synthesize/extract/patterns) skip with `details.reason: 'no_brain_dir'` and the DB-only phases run. `resolveSourceForDir` is null-tolerant (returns undefined). `cycleSourceId = opts.sourceId ?? resolveSourceForDir(engine, brainDir)` is the canonical per-source scope for `extract_facts`/`extract_atoms`/calibration — so `gbrain dream --source repo-a` reconciles repo-a's facts even with no checkout (pre-fix they scoped to `'default'` while the cycle stamped repo-a fresh — a freshness stamp that lied). `deriveStatus` counts `edges_resolved`/`edges_ambiguous` as work so an edges-only cycle reports `ok` not `clean`. The `jobs.ts` `autopilot-cycle` + phase-wrapper handlers pass `null` (not cwd `'.'`) when no repo is configured. Pinned by `test/dream-postgres.serial.test.ts` + `test/jobs-autopilot-cycle-braindir.serial.test.ts`. - `src/core/cycle/synthesize.ts` (v0.23) — Synthesize phase: conversation-transcript-to-brain pipeline. Reads from `dream.synthesize.session_corpus_dir`, runs cheap Haiku verdict (cached in `dream_verdicts`), then fans out one Sonnet subagent per worth-processing transcript with `allowed_slug_prefixes` (sourced from `skills/_brain-filing-rules.json` `dream_synthesize_paths.globs`). Orchestrator collects slugs from `subagent_tool_executions` (NOT `pages.updated_at` — codex finding #2) and reverse-renders DB → markdown via `serializeMarkdown`. Cooldown via `dream.synthesize.last_completion_ts`, written ONLY on success. Idempotency key `dream:synth::`. Auto-commit deferred to v1.1 (codex #5). `--dry-run` runs Haiku, skips Sonnet (codex #8). Subagent never gets fs-write access. **v0.23.2:** `renderPageToMarkdown` (now exported) stamps `dream_generated: true` and `dream_cycle_date` into every reverse-write's frontmatter; `writeSummaryPage` does the same on the dream-cycle summary index. The marker is the explicit identity surface checked by `isDreamOutput` in `transcript-discovery.ts` — replaces the v0.23.1 content-prefix heuristic that could miss real output (`serializeMarkdown` doesn't embed slugs in body) and false-positive on user transcripts citing brain pages. `judgeSignificance` and `JudgeClient` are exported; `judgeSignificance` accepts a `verdictModel` parameter (default `claude-haiku-4-5-20251001`) loaded from `dream.synthesize.verdict_model` via `loadSynthConfig`. **v0.30.2:** model-aware chunker `splitTranscriptByBudget(content, contentHash, maxChars)` splits oversized transcripts at paragraph boundaries (`## Topic:` → `---` → `\n` ladder) using a deterministic offset seeded from the first 32 bits of `contentHash` so retries chunk identically. Per-chunk char budget computed from `MODEL_CONTEXT_TOKENS[resolvedModel] × 0.9 × 3.5 chars/token`; non-Anthropic ids fall back to a 180K-token safe default with a once-per-process stderr warning. Operator overrides: `dream.synthesize.max_prompt_tokens` (floor 100K, wins when set) and `dream.synthesize.max_chunks_per_transcript` (default 24). Per-chunk idempotency keys `dream:synth:::cof`; single-chunk transcripts preserve the legacy `dream:synth::` key byte-for-byte (D8 lookup), so existing brains skip with `already_synthesized_legacy_single_chunk` instead of re-spending Sonnet on upgrade. `collectChildPutPageSlugs` raw-fetches every (job_id, slug) pair (not `SELECT DISTINCT`) and rewrites bare-hash6 slugs to `-c` for chunked children (D6 — orchestrator-side, zero Sonnet trust). Cap-hit skips don't write to `dream_verdicts`, so raising the cap on next run re-attempts cleanly. D7 scope: bounds INITIAL prompt size only; tool-loop turn-N accumulation is caught by the v0.30.2 terminal-error classification in `subagent.ts`, not bounded ahead of time. **v0.41+ community-PR-wave (reworked PR #1349):** `makeHaikuClient()` replaced by gateway-routed `makeJudgeClient(verdictModel)` exported helper — mirrors `tryBuildGatewayClient` in `src/core/think/index.ts:579-637` (the v0.35.5.0 #952 pattern). Construction-time provider/key probe returns `null` on a clear miss (unknown provider id via `resolveRecipe` AIConfigError, or Anthropic provider with no key via `hasAnthropicKey()` matching the same env + config-file pattern think uses). Verdict loop wraps `judgeSignificance` in try/catch for `AIConfigError` so mid-run provider failures (revoked key, recipe misconfig) surface as per-transcript `worth=false, reasons=['gateway error: ...']` instead of crashing the phase. Canonical config key `models.dream.synthesize_verdict` (per `PER_TASK_KEYS` in `src/core/model-config.ts`); contributor's parallel `DEEPSEEK_API_KEY` fetch adapter dropped in favor of recipe-registered providers (DeepSeek, OpenRouter, Voyage, Ollama, llama-server, ...). `JudgeClient` interface signature preserved verbatim for test-seam stability. CI guard at `scripts/check-gateway-routed-no-direct-anthropic.sh` prevents future contributors from re-introducing `new Anthropic()` here or in `think/index.ts`. Pinned by 13 cases in `test/cycle/synthesize-gateway-adapter.test.ts` (A1-A9 unit + R3 parsed-verdict semantic parity + R3 corollary) and 1 E2E case in `test/e2e/dream-synthesize-pglite.test.ts` ('gateway-adapter mid-run AIConfigError catch') plus 4 R1-R4 critical regression pins in `test/cycle/regression-pr-wave-r1-r2-r4.test.ts`. **v0.41.5.0:** narrow `anthropic:` prefix fix at the queue.add boundary (lines 395-404). `resolveModel` returns bare ids from `TIER_DEFAULTS`/`DEFAULT_ALIASES` (e.g. `claude-sonnet-4-6`); the subagent validator requires `provider:model` form and was rejecting with `unknown provider`, dropping synthesize to `status: fail` with `SYNTH_PHASE_FAIL`. Conditional prefix at the call site (only when no colon AND starts with `claude-`) avoids changing the shared constants which would ripple across every `resolveModel` caller. - `scripts/check-gateway-routed-no-direct-anthropic.sh` (v0.41+ community-PR-wave) — CI guard that fails the build if `src/core/cycle/synthesize.ts` or `src/core/think/index.ts` reintroduces a runtime `new Anthropic()` constructor call or a value-shaped `import Anthropic from '@anthropic-ai/sdk'` import. Type-only imports (`import type Anthropic from '@anthropic-ai/sdk'`) stay allowed for adapter types. Comment lines (`//` or ` *` prefixes) are excluded so historical JSDoc references don't false-fire. Mirrors `scripts/check-jsonb-pattern.sh`. Wired into `bun run verify` and `bun run check:all`. Extend `GUARDED_FILES` in the script when migrating another file off direct SDK construction. - `src/core/cycle/patterns.ts` (v0.23) — Patterns phase: cross-session theme detection over reflections within `dream.patterns.lookback_days` (default 30). Names a pattern only when ≥`dream.patterns.min_evidence` (default 3) reflections support it. Single Sonnet subagent; same allow-list path as synthesize. Runs AFTER `extract` so the graph is fresh. @@ -305,7 +305,7 @@ strict behavior when unset. - `src/core/transcripts.ts` (v0.29) — `listRecentTranscripts(engine, opts)` library reused by both the `gbrain transcripts recent` CLI and the `get_recent_transcripts` MCP op. Reads `dream.synthesize.session_corpus_dir` + `dream.synthesize.meeting_transcripts_dir` config keys (same as `discoverTranscripts`); walks for `.txt` files within `days`; applies `isDreamOutput` guard from `transcript-discovery.ts` (skips dream-generated files); returns `{path, date, mtime, length, summary}[]` sorted newest-first. Summary mode (default true) returns first non-empty line + ~250 trailing chars. Full mode caps at 100KB/file. Missing/non-existent corpus dirs return `[]`, not error. **Trust gate lives in the op handler, not here**: the op throws `permission_denied` for `ctx.remote === true`; this library is a trusted library function used by both the gated op and the local CLI. - `src/core/operations-descriptions.ts` (v0.29) — Constants module for tool descriptions. Pinned via `test/operations-descriptions.test.ts`. Houses `GET_RECENT_SALIENCE_DESCRIPTION`, `FIND_ANOMALIES_DESCRIPTION`, `GET_RECENT_TRANSCRIPTS_DESCRIPTION` plus the redirect-edited `LIST_PAGES_DESCRIPTION`, `QUERY_DESCRIPTION`, `SEARCH_DESCRIPTION`. Stable surface for the Tier-2 LLM routing eval — extracting them keeps the test from binding to whatever was in `operations.ts` at test-run time. - `src/core/cycle/transcript-discovery.ts` (v0.23) — Pure filesystem walk for synthesize. `discoverTranscripts(opts)` filters `.txt` files by date range, min_chars, and word-boundary regex `excludePatterns` (Q-3: `medical` matches "medical advice" but NOT "comedical"; power users may pass full regex). `readSingleTranscript(path)` is the `gbrain dream --input ` ad-hoc path. **v0.23.2 self-consumption guard:** `DREAM_OUTPUT_MARKER_RE` (anchored at frontmatter open `---\n`, optional BOM + CRLF tolerance, scans first 2000 chars for `dream_generated: true` with case-insensitive value and word boundary on `true`) drives `isDreamOutput(content, bypass=false)`. Both `discoverTranscripts` and `readSingleTranscript` skip matching files and emit a `[dream] skipped : dream_generated marker` stderr log (no more silent skips). `bypassGuard?: boolean` on `DiscoverOpts` and `readSingleTranscript`'s opts disables the guard for the explicit `--unsafe-bypass-dream-guard` escape hatch only — never auto-applied for `--input`. Replaces v0.23.1's `DREAM_OUTPUT_SLUGS` content-prefix list. -- `src/commands/dream.ts` — v0.17 `gbrain dream` CLI; ~80-line thin alias over `runCycle`. brainDir resolution requires explicit `--dir` OR `sync.repo_path` config. Flags: `--dry-run`, `--json`, `--phase `, `--pull`, `--dir `. **v0.23 added** `--input ` (ad-hoc transcript, implies `--phase synthesize`), `--date YYYY-MM-DD`, `--from --to ` (backfill range). Conflict detection: `--input` + `--date` exits 2. ISO date validation. `--dry-run` runs Haiku significance verdict but skips Sonnet synthesis (codex finding #8 — NOT zero LLM calls). Exit code 1 on status=failed. **v0.23.2 added** `--unsafe-bypass-dream-guard` (long-form intentional, plumbed through `runCycle.synthBypassDreamGuard` → `SynthesizePhaseOpts.bypassDreamGuard` → `discoverTranscripts({bypassGuard})` and `readSingleTranscript({bypassGuard})`). Loud stderr warning fires at synthesize-phase entry when set. Never auto-applied for `--input` so any caller can't silently re-trigger the loop bug. +- `src/commands/dream.ts` — v0.17 `gbrain dream` CLI; ~80-line thin alias over `runCycle`. brainDir resolution requires explicit `--dir` OR `sync.repo_path` config. Flags: `--dry-run`, `--json`, `--phase `, `--pull`, `--dir `. **v0.23 added** `--input ` (ad-hoc transcript, implies `--phase synthesize`), `--date YYYY-MM-DD`, `--from --to ` (backfill range). Conflict detection: `--input` + `--date` exits 2. ISO date validation. `--dry-run` runs Haiku significance verdict but skips Sonnet synthesis (codex finding #8 — NOT zero LLM calls). Exit code 1 on status=failed. **v0.23.2 added** `--unsafe-bypass-dream-guard` (long-form intentional, plumbed through `runCycle.synthBypassDreamGuard` → `SynthesizePhaseOpts.bypassDreamGuard` → `discoverTranscripts({bypassGuard})` and `readSingleTranscript({bypassGuard})`). Loud stderr warning fires at synthesize-phase entry when set. Never auto-applied for `--input` so any caller can't silently re-trigger the loop bug. **v0.41.38.0:** `resolveBrainDir` now returns `string | null` (order: `--dir` → the resolved `--source`'s `local_path` → global `sync.repo_path` → null) instead of `process.exit(1)` on "No brain directory found". A checkout-less postgres/Supabase brain runs the DB-only phases (incl. `resolve_symbol_edges`, the call-graph builder) and skips the 6 filesystem phases with `details.reason: 'no_brain_dir'`; `runDream` owns the only hard error (no checkout AND no engine). When `--source` resolves but that source has no on-disk checkout, returns null (DB-only) rather than borrowing a different source's global `sync.repo_path` (would mix scopes — codex P1 review). Pinned by `test/dream-postgres.serial.test.ts`. - `src/commands/friction.ts` + `src/core/friction.ts` (v0.23) — `gbrain friction {log,render,list,summary}` reporter. Append-only JSONL under `$GBRAIN_HOME/friction/.jsonl`. Schema is a flat extension of `StructuredAgentError` (D20). Render groups by severity → phase, defaults to `--redact` for md output (strips `$HOME`/`$CWD` to placeholders so reports paste safely in PRs). Run-id resolves from `--run-id` > `$GBRAIN_FRICTION_RUN_ID` > `standalone.jsonl`. Skills the claw-test exercises gain a `_friction-protocol.md` callout so agents know when to log friction. - `src/commands/claw-test.ts` + `src/core/claw-test/` (v0.23) — `gbrain claw-test [--scenario ] [--live --agent openclaw]`. End-to-end "fresh user" friction harness. Two modes: scripted (CI gate, agent-free) and live (real openclaw subprocess, $1–2 in tokens). Sets `GBRAIN_HOME=` for hermeticity and captures gbrain's `--progress-json` events from each child's stderr to verify expected phases ran (`import.files`, `extract.links_fs`, `doctor.db_checks`). Phases for scripted mode: setup → install_brain (`gbrain init --pglite`) → import (`--no-embed`) → query → extract → verify (`gbrain doctor --json`, asserts `status: 'ok'`) → render. Live mode hands `BRIEF.md` from `test/fixtures/claw-test-scenarios//` to the agent runner. v1 ships with the OpenClaw runner only (`src/core/claw-test/runners/openclaw.ts`, invokes `openclaw agent --local --agent --message `); hermes runner deferred to v1.1. Transcript capture (`transcript-capture.ts`) uses `fs.createWriteStream` with `'drain'`-event backpressure — D17 fix for the 256KB-burst child-stall scenario. v0.18 upgrade scenario seeded via `seed-pglite.ts` SQL replay. - `skills/_friction-protocol.md` (v0.23) — shared cross-cutting convention skill (like `_brain-filing-rules.md`). Tells agents when to call `gbrain friction log` and how to choose a severity. Routes to friction CLI from any skill the claw-test exercises. diff --git a/TODOS.md b/TODOS.md index 6a3a87900..50eabd808 100644 --- a/TODOS.md +++ b/TODOS.md @@ -1,5 +1,36 @@ # TODOS +## v0.41.38.0 dream-postgres / source-pin follow-ups (v0.42+) + +Deferred from the v0.41.38.0 wave (code-callers/callees pin + dream-on-postgres). +Documented tradeoffs, not blockers — the shipped bug fixes are complete and tested. + +- [ ] **P1 — Per-source autopilot fan-out passes the global repoPath.** + `src/commands/autopilot-fanout.ts:~206` submits every per-source `autopilot-cycle` + job with `repoPath: opts.repoPath` (the global checkout), not `src.local_path`. + With v0.41.38.0's `cycleSourceId = opts.sourceId ?? resolveSourceForDir(...)`, + a per-source job now reconciles DB phases for `src.id` while the filesystem + phases (sync/lint/extract) run against the default brain's checkout, then stamps + `src.id` fresh — mixed scope. Pre-existing fan-out limitation (cycle.ts PHASE_SCOPE + comment already notes genuine per-source fan-out needs deferred work); the common + single-source autopilot path (legacy no-source dispatch) is unaffected. Fix: + resolve brainDir from the source's `local_path` inside the `autopilot-cycle` + handler when `source_id` is set (mirror dream.ts's T1), so FS and DB phases agree. + Needs its own review (touches the deferred autopilot path). +- [ ] **P2 — `.gbrain-source` with invalid SYNTAX still falls through silently.** + `readDotfileWalk` (source-resolver.ts:39) intentionally skips a dotfile whose + content fails `isValidSourceId` (e.g. `repo_a` with an underscore) per the v0.31.8 + P1-F silent-fallback design, so `resolveScopedSourceOrThrow` resolves it to a + later tier rather than surfacing `invalid_source_pin`. A valid-syntax-but-missing + pin DOES surface (assertSourceExists throws). Decide whether a typo'd dotfile + should warn loudly; changing it alters resolver semantics shared by other callers. +- [ ] **P3 — Sibling source-scoped commands don't honor the pin.** `blast`/`flow`/ + `clusters`/`wiki` still call `resolveDefaultSource` directly. Route them through + `resolveScopedSourceOrThrow` for consistency with code-callers/code-callees. +- [ ] **P3 — `gbrain autopilot` CLI daemon pre-guard.** `autopilot.ts:~152` + `if (!repoPath) exit 1` still blocks the daemon on a checkout-less postgres brain. + Relax to the same null-brainDir contract so the daemon can run DB phases. + ## v0.41.37.0 critical-fix-wave follow-ups (v0.42+) Filed from the v0.41.37.0 wave (#1621 tag-wipe, #1581 grandfather hang, diff --git a/VERSION b/VERSION index cafecd08a..66a062674 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.41.37.0 +0.41.38.0 \ No newline at end of file diff --git a/llms-full.txt b/llms-full.txt index 2c801acb7..344899ac2 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -433,7 +433,7 @@ strict behavior when unset. - `src/commands/import.ts` — `gbrain import` CLI + `runImport` library entrypoint. v0.34.2.0 replaces the prior positional-index checkpoint (`processedIndex: N` into a sorted file list) with a path-set checkpoint via `src/core/import-checkpoint.ts`. The walk still applies `sortNewestFirst()` for embed-cost ordering, but checkpoint correctness no longer depends on sort order. A file enters `completed: Set` only when its `processFile` returns success (including content-hash short-circuit no-ops); failed files never enter the set, so the next run retries them automatically with no manual `~/.gbrain/import-checkpoint.json` delete. Three bug classes died: parallel-import-with-slow-worker drops the slow file on crash-resume (closed — the slow file isn't in `completed` until its own `processFile` resolves), failed-file-bumps-counter-past-itself (closed — failures don't add to `completed`), and v0.33.x sort-flip-drops-newest-N-on-cross-version-resume (closed — order is no longer part of the checkpoint). Old positional checkpoints are detected and discarded with a stderr line on first resume; re-walking is cheap because `content_hash` short-circuits unchanged files. Checkpoint persists every 100 successful adds, not every 100 processed files, so a long failure tail doesn't churn the JSON. Pinned by `test/import-checkpoint.test.ts` (18 unit cases over the helpers) + `test/import-resume.test.ts` (5 integration cases under PGLite, including the SLUG_MISMATCH retry regression codex caught during plan-eng-review). - `src/core/import-checkpoint.ts` (v0.34.2.0) — `loadCheckpoint(brainDir)`, `saveCheckpoint(brainDir, completed)`, `resumeFilter(files, completed, brainDir)`, `clearCheckpoint()`, plus the `ImportCheckpoint` type. Path-set checkpoint format (`{schema_version, brainDir, completed: string[]}`) replaces the v0.33.x positional `{processedIndex: N}` format. Atomic write via `.tmp` + `rename()` so a mid-write crash never leaves a partial JSON. `loadCheckpoint` returns `null` on: missing file, malformed JSON, brainDir mismatch (you ran import against a different brain), and the old positional format (logged to stderr before being discarded). `resumeFilter` returns `{toProcess, skippedCount}` — pure, no I/O, deterministic. `clearCheckpoint` is no-op-on-missing for clean-exit cleanup. Honors `GBRAIN_HOME` via `gbrainPath()` so test isolation via `withEnv({GBRAIN_HOME: tmpdir})` works without monkey-patching the fs layer. Best-effort persistence — `saveCheckpoint` logs warnings on write errors but never throws, so import keeps making progress even if disk is full. - `src/core/sort-newest-first.ts` (v0.34.2.0) — single source of truth for the descending-lex sort that `gbrain import` and `gbrain sync` both apply. Mutates in place (Array.prototype.sort semantics), returns the same array reference for fluent chaining. Empty/single-element inputs short-circuit. Future ordering changes flip one line in this helper instead of touching two CLI commands. Pinned by `test/sort-newest-first.test.ts` (5 hermetic cases: descending order, mixed prefixes, empty input, single-element input, in-place-mutation contract). -- `src/core/cycle.ts` — v0.17 brain maintenance cycle primitive (extended to **9 phases in v0.29**). `runCycle(engine: BrainEngine | null, opts: CycleOpts): Promise` composes phases in semantically-driven order: **lint → backlinks → sync → synthesize → extract → patterns → recompute_emotional_weight → embed → orphans**. v0.29 adds the `recompute_emotional_weight` phase between patterns and embed; it sees the union of `syncPagesAffected` + `synthesizeWrittenSlugs` for incremental mode, or all pages when neither anchor is set (full backfill via `gbrain dream --phase recompute_emotional_weight`). v0.29 also extends `CycleReport.totals` with `pages_emotional_weight_recomputed` (additive, schema_version stays "1"). v0.23's `synthesize` phase runs after sync (cross-references see fresh brain) and before extract (auto-link materializes its writes); `patterns` runs after extract so it reads a fresh graph (codex finding #7 — subagent put_page sets `ctx.remote=true` and skips auto-link/timeline by default; extract is the canonical materialization). Three callers: `gbrain dream` CLI, `gbrain autopilot` daemon's inline path, and the Minions `autopilot-cycle` handler. Coordination via `gbrain_cycle_locks` DB table + `~/.gbrain/cycle.lock` file lock with PID-liveness for PGLite. `CycleReport.schema_version: "1"` is stable; totals additively grew in v0.23 (`transcripts_processed`, `synth_pages_written`, `patterns_written`). `yieldBetweenPhases` runs between phases. **v0.23 added `yieldDuringPhase`** for in-phase keepalive — synthesize/patterns call it during long waits to renew the cycle-lock TTL. Engine nullable; lock-skip on read-only phase selections. v0.22.1 (#403): `CycleOpts.signal?: AbortSignal` propagates the worker's abort signal; `checkAborted()` fires between every phase. v0.22.1 (#417): `runPhaseSync` returns `pagesAffected` via `SyncPhaseResult`; `runCycle` captures it and threads to `runPhaseExtract` as the 4th arg. v0.22.1 (Codex F2): `runPhaseSync` takes `willRunExtractPhase: boolean` and sets `noExtract: phases.includes('extract')` so `gbrain dream --phase sync` doesn't silently lose extraction. v0.22.5 (#475): `resolveSourceForDir(engine, brainDir)` threads `sourceId` to `performSync()` so sync reads the per-source `sources.last_commit` anchor instead of the drift-prone global `config.sync.last_commit` key. +- `src/core/cycle.ts` — v0.17 brain maintenance cycle primitive (extended to **9 phases in v0.29**). `runCycle(engine: BrainEngine | null, opts: CycleOpts): Promise` composes phases in semantically-driven order: **lint → backlinks → sync → synthesize → extract → patterns → recompute_emotional_weight → embed → orphans**. v0.29 adds the `recompute_emotional_weight` phase between patterns and embed; it sees the union of `syncPagesAffected` + `synthesizeWrittenSlugs` for incremental mode, or all pages when neither anchor is set (full backfill via `gbrain dream --phase recompute_emotional_weight`). v0.29 also extends `CycleReport.totals` with `pages_emotional_weight_recomputed` (additive, schema_version stays "1"). v0.23's `synthesize` phase runs after sync (cross-references see fresh brain) and before extract (auto-link materializes its writes); `patterns` runs after extract so it reads a fresh graph (codex finding #7 — subagent put_page sets `ctx.remote=true` and skips auto-link/timeline by default; extract is the canonical materialization). Three callers: `gbrain dream` CLI, `gbrain autopilot` daemon's inline path, and the Minions `autopilot-cycle` handler. Coordination via `gbrain_cycle_locks` DB table + `~/.gbrain/cycle.lock` file lock with PID-liveness for PGLite. `CycleReport.schema_version: "1"` is stable; totals additively grew in v0.23 (`transcripts_processed`, `synth_pages_written`, `patterns_written`). `yieldBetweenPhases` runs between phases. **v0.23 added `yieldDuringPhase`** for in-phase keepalive — synthesize/patterns call it during long waits to renew the cycle-lock TTL. Engine nullable; lock-skip on read-only phase selections. v0.22.1 (#403): `CycleOpts.signal?: AbortSignal` propagates the worker's abort signal; `checkAborted()` fires between every phase. v0.22.1 (#417): `runPhaseSync` returns `pagesAffected` via `SyncPhaseResult`; `runCycle` captures it and threads to `runPhaseExtract` as the 4th arg. v0.22.1 (Codex F2): `runPhaseSync` takes `willRunExtractPhase: boolean` and sets `noExtract: phases.includes('extract')` so `gbrain dream --phase sync` doesn't silently lose extraction. v0.22.5 (#475): `resolveSourceForDir(engine, brainDir)` threads `sourceId` to `performSync()` so sync reads the per-source `sources.last_commit` anchor instead of the drift-prone global `config.sync.last_commit` key. **v0.41.38.0 (postgres support):** `CycleOpts.brainDir` is now `string | null`; when null (checkout-less postgres/Supabase brain) the 6 filesystem phases (lint/backlinks/sync/synthesize/extract/patterns) skip with `details.reason: 'no_brain_dir'` and the DB-only phases run. `resolveSourceForDir` is null-tolerant (returns undefined). `cycleSourceId = opts.sourceId ?? resolveSourceForDir(engine, brainDir)` is the canonical per-source scope for `extract_facts`/`extract_atoms`/calibration — so `gbrain dream --source repo-a` reconciles repo-a's facts even with no checkout (pre-fix they scoped to `'default'` while the cycle stamped repo-a fresh — a freshness stamp that lied). `deriveStatus` counts `edges_resolved`/`edges_ambiguous` as work so an edges-only cycle reports `ok` not `clean`. The `jobs.ts` `autopilot-cycle` + phase-wrapper handlers pass `null` (not cwd `'.'`) when no repo is configured. Pinned by `test/dream-postgres.serial.test.ts` + `test/jobs-autopilot-cycle-braindir.serial.test.ts`. - `src/core/cycle/synthesize.ts` (v0.23) — Synthesize phase: conversation-transcript-to-brain pipeline. Reads from `dream.synthesize.session_corpus_dir`, runs cheap Haiku verdict (cached in `dream_verdicts`), then fans out one Sonnet subagent per worth-processing transcript with `allowed_slug_prefixes` (sourced from `skills/_brain-filing-rules.json` `dream_synthesize_paths.globs`). Orchestrator collects slugs from `subagent_tool_executions` (NOT `pages.updated_at` — codex finding #2) and reverse-renders DB → markdown via `serializeMarkdown`. Cooldown via `dream.synthesize.last_completion_ts`, written ONLY on success. Idempotency key `dream:synth::`. Auto-commit deferred to v1.1 (codex #5). `--dry-run` runs Haiku, skips Sonnet (codex #8). Subagent never gets fs-write access. **v0.23.2:** `renderPageToMarkdown` (now exported) stamps `dream_generated: true` and `dream_cycle_date` into every reverse-write's frontmatter; `writeSummaryPage` does the same on the dream-cycle summary index. The marker is the explicit identity surface checked by `isDreamOutput` in `transcript-discovery.ts` — replaces the v0.23.1 content-prefix heuristic that could miss real output (`serializeMarkdown` doesn't embed slugs in body) and false-positive on user transcripts citing brain pages. `judgeSignificance` and `JudgeClient` are exported; `judgeSignificance` accepts a `verdictModel` parameter (default `claude-haiku-4-5-20251001`) loaded from `dream.synthesize.verdict_model` via `loadSynthConfig`. **v0.30.2:** model-aware chunker `splitTranscriptByBudget(content, contentHash, maxChars)` splits oversized transcripts at paragraph boundaries (`## Topic:` → `---` → `\n` ladder) using a deterministic offset seeded from the first 32 bits of `contentHash` so retries chunk identically. Per-chunk char budget computed from `MODEL_CONTEXT_TOKENS[resolvedModel] × 0.9 × 3.5 chars/token`; non-Anthropic ids fall back to a 180K-token safe default with a once-per-process stderr warning. Operator overrides: `dream.synthesize.max_prompt_tokens` (floor 100K, wins when set) and `dream.synthesize.max_chunks_per_transcript` (default 24). Per-chunk idempotency keys `dream:synth:::cof`; single-chunk transcripts preserve the legacy `dream:synth::` key byte-for-byte (D8 lookup), so existing brains skip with `already_synthesized_legacy_single_chunk` instead of re-spending Sonnet on upgrade. `collectChildPutPageSlugs` raw-fetches every (job_id, slug) pair (not `SELECT DISTINCT`) and rewrites bare-hash6 slugs to `-c` for chunked children (D6 — orchestrator-side, zero Sonnet trust). Cap-hit skips don't write to `dream_verdicts`, so raising the cap on next run re-attempts cleanly. D7 scope: bounds INITIAL prompt size only; tool-loop turn-N accumulation is caught by the v0.30.2 terminal-error classification in `subagent.ts`, not bounded ahead of time. **v0.41+ community-PR-wave (reworked PR #1349):** `makeHaikuClient()` replaced by gateway-routed `makeJudgeClient(verdictModel)` exported helper — mirrors `tryBuildGatewayClient` in `src/core/think/index.ts:579-637` (the v0.35.5.0 #952 pattern). Construction-time provider/key probe returns `null` on a clear miss (unknown provider id via `resolveRecipe` AIConfigError, or Anthropic provider with no key via `hasAnthropicKey()` matching the same env + config-file pattern think uses). Verdict loop wraps `judgeSignificance` in try/catch for `AIConfigError` so mid-run provider failures (revoked key, recipe misconfig) surface as per-transcript `worth=false, reasons=['gateway error: ...']` instead of crashing the phase. Canonical config key `models.dream.synthesize_verdict` (per `PER_TASK_KEYS` in `src/core/model-config.ts`); contributor's parallel `DEEPSEEK_API_KEY` fetch adapter dropped in favor of recipe-registered providers (DeepSeek, OpenRouter, Voyage, Ollama, llama-server, ...). `JudgeClient` interface signature preserved verbatim for test-seam stability. CI guard at `scripts/check-gateway-routed-no-direct-anthropic.sh` prevents future contributors from re-introducing `new Anthropic()` here or in `think/index.ts`. Pinned by 13 cases in `test/cycle/synthesize-gateway-adapter.test.ts` (A1-A9 unit + R3 parsed-verdict semantic parity + R3 corollary) and 1 E2E case in `test/e2e/dream-synthesize-pglite.test.ts` ('gateway-adapter mid-run AIConfigError catch') plus 4 R1-R4 critical regression pins in `test/cycle/regression-pr-wave-r1-r2-r4.test.ts`. **v0.41.5.0:** narrow `anthropic:` prefix fix at the queue.add boundary (lines 395-404). `resolveModel` returns bare ids from `TIER_DEFAULTS`/`DEFAULT_ALIASES` (e.g. `claude-sonnet-4-6`); the subagent validator requires `provider:model` form and was rejecting with `unknown provider`, dropping synthesize to `status: fail` with `SYNTH_PHASE_FAIL`. Conditional prefix at the call site (only when no colon AND starts with `claude-`) avoids changing the shared constants which would ripple across every `resolveModel` caller. - `scripts/check-gateway-routed-no-direct-anthropic.sh` (v0.41+ community-PR-wave) — CI guard that fails the build if `src/core/cycle/synthesize.ts` or `src/core/think/index.ts` reintroduces a runtime `new Anthropic()` constructor call or a value-shaped `import Anthropic from '@anthropic-ai/sdk'` import. Type-only imports (`import type Anthropic from '@anthropic-ai/sdk'`) stay allowed for adapter types. Comment lines (`//` or ` *` prefixes) are excluded so historical JSDoc references don't false-fire. Mirrors `scripts/check-jsonb-pattern.sh`. Wired into `bun run verify` and `bun run check:all`. Extend `GUARDED_FILES` in the script when migrating another file off direct SDK construction. - `src/core/cycle/patterns.ts` (v0.23) — Patterns phase: cross-session theme detection over reflections within `dream.patterns.lookback_days` (default 30). Names a pattern only when ≥`dream.patterns.min_evidence` (default 3) reflections support it. Single Sonnet subagent; same allow-list path as synthesize. Runs AFTER `extract` so the graph is fresh. @@ -447,7 +447,7 @@ strict behavior when unset. - `src/core/transcripts.ts` (v0.29) — `listRecentTranscripts(engine, opts)` library reused by both the `gbrain transcripts recent` CLI and the `get_recent_transcripts` MCP op. Reads `dream.synthesize.session_corpus_dir` + `dream.synthesize.meeting_transcripts_dir` config keys (same as `discoverTranscripts`); walks for `.txt` files within `days`; applies `isDreamOutput` guard from `transcript-discovery.ts` (skips dream-generated files); returns `{path, date, mtime, length, summary}[]` sorted newest-first. Summary mode (default true) returns first non-empty line + ~250 trailing chars. Full mode caps at 100KB/file. Missing/non-existent corpus dirs return `[]`, not error. **Trust gate lives in the op handler, not here**: the op throws `permission_denied` for `ctx.remote === true`; this library is a trusted library function used by both the gated op and the local CLI. - `src/core/operations-descriptions.ts` (v0.29) — Constants module for tool descriptions. Pinned via `test/operations-descriptions.test.ts`. Houses `GET_RECENT_SALIENCE_DESCRIPTION`, `FIND_ANOMALIES_DESCRIPTION`, `GET_RECENT_TRANSCRIPTS_DESCRIPTION` plus the redirect-edited `LIST_PAGES_DESCRIPTION`, `QUERY_DESCRIPTION`, `SEARCH_DESCRIPTION`. Stable surface for the Tier-2 LLM routing eval — extracting them keeps the test from binding to whatever was in `operations.ts` at test-run time. - `src/core/cycle/transcript-discovery.ts` (v0.23) — Pure filesystem walk for synthesize. `discoverTranscripts(opts)` filters `.txt` files by date range, min_chars, and word-boundary regex `excludePatterns` (Q-3: `medical` matches "medical advice" but NOT "comedical"; power users may pass full regex). `readSingleTranscript(path)` is the `gbrain dream --input ` ad-hoc path. **v0.23.2 self-consumption guard:** `DREAM_OUTPUT_MARKER_RE` (anchored at frontmatter open `---\n`, optional BOM + CRLF tolerance, scans first 2000 chars for `dream_generated: true` with case-insensitive value and word boundary on `true`) drives `isDreamOutput(content, bypass=false)`. Both `discoverTranscripts` and `readSingleTranscript` skip matching files and emit a `[dream] skipped : dream_generated marker` stderr log (no more silent skips). `bypassGuard?: boolean` on `DiscoverOpts` and `readSingleTranscript`'s opts disables the guard for the explicit `--unsafe-bypass-dream-guard` escape hatch only — never auto-applied for `--input`. Replaces v0.23.1's `DREAM_OUTPUT_SLUGS` content-prefix list. -- `src/commands/dream.ts` — v0.17 `gbrain dream` CLI; ~80-line thin alias over `runCycle`. brainDir resolution requires explicit `--dir` OR `sync.repo_path` config. Flags: `--dry-run`, `--json`, `--phase `, `--pull`, `--dir `. **v0.23 added** `--input ` (ad-hoc transcript, implies `--phase synthesize`), `--date YYYY-MM-DD`, `--from --to ` (backfill range). Conflict detection: `--input` + `--date` exits 2. ISO date validation. `--dry-run` runs Haiku significance verdict but skips Sonnet synthesis (codex finding #8 — NOT zero LLM calls). Exit code 1 on status=failed. **v0.23.2 added** `--unsafe-bypass-dream-guard` (long-form intentional, plumbed through `runCycle.synthBypassDreamGuard` → `SynthesizePhaseOpts.bypassDreamGuard` → `discoverTranscripts({bypassGuard})` and `readSingleTranscript({bypassGuard})`). Loud stderr warning fires at synthesize-phase entry when set. Never auto-applied for `--input` so any caller can't silently re-trigger the loop bug. +- `src/commands/dream.ts` — v0.17 `gbrain dream` CLI; ~80-line thin alias over `runCycle`. brainDir resolution requires explicit `--dir` OR `sync.repo_path` config. Flags: `--dry-run`, `--json`, `--phase `, `--pull`, `--dir `. **v0.23 added** `--input ` (ad-hoc transcript, implies `--phase synthesize`), `--date YYYY-MM-DD`, `--from --to ` (backfill range). Conflict detection: `--input` + `--date` exits 2. ISO date validation. `--dry-run` runs Haiku significance verdict but skips Sonnet synthesis (codex finding #8 — NOT zero LLM calls). Exit code 1 on status=failed. **v0.23.2 added** `--unsafe-bypass-dream-guard` (long-form intentional, plumbed through `runCycle.synthBypassDreamGuard` → `SynthesizePhaseOpts.bypassDreamGuard` → `discoverTranscripts({bypassGuard})` and `readSingleTranscript({bypassGuard})`). Loud stderr warning fires at synthesize-phase entry when set. Never auto-applied for `--input` so any caller can't silently re-trigger the loop bug. **v0.41.38.0:** `resolveBrainDir` now returns `string | null` (order: `--dir` → the resolved `--source`'s `local_path` → global `sync.repo_path` → null) instead of `process.exit(1)` on "No brain directory found". A checkout-less postgres/Supabase brain runs the DB-only phases (incl. `resolve_symbol_edges`, the call-graph builder) and skips the 6 filesystem phases with `details.reason: 'no_brain_dir'`; `runDream` owns the only hard error (no checkout AND no engine). When `--source` resolves but that source has no on-disk checkout, returns null (DB-only) rather than borrowing a different source's global `sync.repo_path` (would mix scopes — codex P1 review). Pinned by `test/dream-postgres.serial.test.ts`. - `src/commands/friction.ts` + `src/core/friction.ts` (v0.23) — `gbrain friction {log,render,list,summary}` reporter. Append-only JSONL under `$GBRAIN_HOME/friction/.jsonl`. Schema is a flat extension of `StructuredAgentError` (D20). Render groups by severity → phase, defaults to `--redact` for md output (strips `$HOME`/`$CWD` to placeholders so reports paste safely in PRs). Run-id resolves from `--run-id` > `$GBRAIN_FRICTION_RUN_ID` > `standalone.jsonl`. Skills the claw-test exercises gain a `_friction-protocol.md` callout so agents know when to log friction. - `src/commands/claw-test.ts` + `src/core/claw-test/` (v0.23) — `gbrain claw-test [--scenario ] [--live --agent openclaw]`. End-to-end "fresh user" friction harness. Two modes: scripted (CI gate, agent-free) and live (real openclaw subprocess, $1–2 in tokens). Sets `GBRAIN_HOME=` for hermeticity and captures gbrain's `--progress-json` events from each child's stderr to verify expected phases ran (`import.files`, `extract.links_fs`, `doctor.db_checks`). Phases for scripted mode: setup → install_brain (`gbrain init --pglite`) → import (`--no-embed`) → query → extract → verify (`gbrain doctor --json`, asserts `status: 'ok'`) → render. Live mode hands `BRIEF.md` from `test/fixtures/claw-test-scenarios//` to the agent runner. v1 ships with the OpenClaw runner only (`src/core/claw-test/runners/openclaw.ts`, invokes `openclaw agent --local --agent --message `); hermes runner deferred to v1.1. Transcript capture (`transcript-capture.ts`) uses `fs.createWriteStream` with `'drain'`-event backpressure — D17 fix for the 256KB-burst child-stall scenario. v0.18 upgrade scenario seeded via `seed-pglite.ts` SQL replay. - `skills/_friction-protocol.md` (v0.23) — shared cross-cutting convention skill (like `_brain-filing-rules.md`). Tells agents when to call `gbrain friction log` and how to choose a severity. Routes to friction CLI from any skill the claw-test exercises. diff --git a/package.json b/package.json index ad23780bd..216525414 100644 --- a/package.json +++ b/package.json @@ -141,5 +141,5 @@ "bun": ">=1.3.10" }, "license": "MIT", - "version": "0.41.37.0" + "version": "0.41.38.0" } diff --git a/src/commands/code-callees.ts b/src/commands/code-callees.ts index 640e8f17a..baf739b02 100644 --- a/src/commands/code-callees.ts +++ b/src/commands/code-callees.ts @@ -5,9 +5,10 @@ * Forward view of the A1 call graph. Matches `from_symbol_qualified` * in both code_edges_chunk + code_edges_symbol. * - * v0.34 W0b (Codex finding #7): pre-v0.34 default was inverted to - * cross-source whenever --source was omitted. See code-callers.ts for - * the full rationale. Same fix here. + * Source resolution: honors the full chain (incl. the `.gbrain-source` pin) + * via `resolveScopedSourceOrThrow` when --source/--all-sources are omitted. + * See code-callers.ts for the full rationale. Same behavior here. JSON + * envelope carries `source_id` + `scope`. * * Output: same JSON-on-non-TTY convention as code-callers / code-def / * code-refs. @@ -15,7 +16,19 @@ import type { BrainEngine } from '../core/engine.ts'; import { errorFor, serializeError } from '../core/errors.ts'; -import { resolveDefaultSource, SourceResolutionError } from '../core/sources-ops.ts'; +import { resolveScopedSourceOrThrow, SourceResolutionError } from '../core/sources-ops.ts'; +import { formatSoleNonDefaultNudge } from '../core/source-resolver.ts'; + +/** A bad/invalid `.gbrain-source` pin or GBRAIN_SOURCE value surfaces from + * `resolveSourceWithTier`'s `assertSourceExists` as a plain Error with one of + * these message prefixes. Mirrors dream.ts:isResolverUserError. */ +function isResolverUserError(e: unknown): boolean { + if (!(e instanceof Error)) return false; + const m = e.message; + return (m.startsWith('Source "') && m.includes(' not found.')) + || m.startsWith('Invalid --source value') + || m.startsWith('Invalid GBRAIN_SOURCE value'); +} function parseFlag(args: string[], name: string): string | undefined { const i = args.indexOf(name); @@ -49,10 +62,16 @@ export async function runCodeCallees(engine: BrainEngine, args: string[]): Promi const allSources = args.includes('--all-sources'); let sourceId = parseFlag(args, '--source'); - // v0.34 W0b: source-scoped default. Matches code-callers behavior. + // Full source-resolution chain (honors .gbrain-source pin, env, local_path, + // brain_default, sole_non_default). Matches code-callers behavior. if (!allSources && !sourceId) { try { - sourceId = await resolveDefaultSource(engine); + const resolved = await resolveScopedSourceOrThrow(engine); + sourceId = resolved.source_id; + if (resolved.tier === 'sole_non_default') { + const nudge = formatSoleNonDefaultNudge(resolved.source_id); + if (nudge) console.error(nudge); + } } catch (e: unknown) { if (e instanceof SourceResolutionError) { const env = errorFor({ @@ -68,6 +87,20 @@ export async function runCodeCallees(engine: BrainEngine, args: string[]): Promi } process.exit(2); } + if (isResolverUserError(e)) { + const env = errorFor({ + class: 'UsageError', + code: 'invalid_source_pin', + message: (e as Error).message, + hint: 'fix the .gbrain-source pin / GBRAIN_SOURCE value, or pass --source / --all-sources', + }).envelope; + if (shouldEmitJson(args)) { + console.log(JSON.stringify({ error: env })); + } else { + console.error((e as Error).message); + } + process.exit(2); + } throw e; } } @@ -79,10 +112,23 @@ export async function runCodeCallees(engine: BrainEngine, args: string[]): Promi sourceId: sourceId ?? undefined, }); + const scope = allSources ? 'all' : 'single'; + const envelopeSourceId = allSources ? null : (sourceId ?? null); + if (shouldEmitJson(args)) { - console.log(JSON.stringify({ symbol: sym, count: edges.length, callees: edges }, null, 2)); + const out: Record = { + symbol: sym, source_id: envelopeSourceId, scope, count: edges.length, callees: edges, + }; + if (edges.length === 0 && !allSources && sourceId) { + out.hint = `No callees in source '${sourceId}'. Try --all-sources to search every source.`; + } + console.log(JSON.stringify(out, null, 2)); } else if (edges.length === 0) { - console.log(`No callees found for "${sym}".`); + if (!allSources && sourceId) { + console.log(`No callees found for "${sym}" in source '${sourceId}'. Try --all-sources to search every source.`); + } else { + console.log(`No callees found for "${sym}".`); + } } else { console.log(`${edges.length} callee(s) for "${sym}":`); for (const e of edges) { diff --git a/src/commands/code-callers.ts b/src/commands/code-callers.ts index f19bff0aa..8c334c799 100644 --- a/src/commands/code-callers.ts +++ b/src/commands/code-callers.ts @@ -11,21 +11,37 @@ * in repo A ≠ same string in repo B). Pass `--all-sources` to search * globally. * - * v0.34 W0b (Codex finding #7): the pre-v0.34 implementation set - * `allSources: allSources || !sourceId`, which INVERTED the documented - * default to global whenever --source was omitted. Multi-source brains - * cross-contaminated structural retrieval despite the docstring claim. - * Fix: when --source is omitted AND --all-sources is NOT set, resolve to - * the brain's only source (single-source brains) or fail with a clear - * error listing valid source ids (multi-source brains). + * Source resolution: when --source is omitted AND --all-sources is NOT set, + * resolve through the full source-resolution chain via + * `resolveScopedSourceOrThrow` (flag → env → .gbrain-source dotfile → + * local_path → brain_default → sole_non_default), matching `gbrain sources + * current`. A `.gbrain-source` pin selects the source; only a no-signal + * multi-source brain still fails with `multiple_sources_ambiguous`. (Pre- + * v0.41.30 this called `resolveDefaultSource` directly, which ignored the pin + * and errored on every multi-source brain — Codex finding #7's source-scoped + * default is preserved; the pin is now honored on top of it.) `--all-sources` + * searches globally and overrides any pin. * - * Output: non-TTY → JSON envelope. TTY → human table. Follows the - * code-def / code-refs pattern. + * Output: non-TTY → JSON envelope (carries `source_id` + `scope`). TTY → human + * table. Follows the code-def / code-refs pattern. */ import type { BrainEngine } from '../core/engine.ts'; import { errorFor, serializeError } from '../core/errors.ts'; -import { resolveDefaultSource, SourceResolutionError } from '../core/sources-ops.ts'; +import { resolveScopedSourceOrThrow, SourceResolutionError } from '../core/sources-ops.ts'; +import { formatSoleNonDefaultNudge } from '../core/source-resolver.ts'; + +/** A bad/invalid `.gbrain-source` pin or GBRAIN_SOURCE value surfaces from + * `resolveSourceWithTier`'s `assertSourceExists` as a plain Error with one of + * these message prefixes. Mirrors dream.ts:isResolverUserError so we surface a + * clean usage error instead of an uncaught stack. */ +function isResolverUserError(e: unknown): boolean { + if (!(e instanceof Error)) return false; + const m = e.message; + return (m.startsWith('Source "') && m.includes(' not found.')) + || m.startsWith('Invalid --source value') + || m.startsWith('Invalid GBRAIN_SOURCE value'); +} function parseFlag(args: string[], name: string): string | undefined { const i = args.indexOf(name); @@ -59,12 +75,20 @@ export async function runCodeCallers(engine: BrainEngine, args: string[]): Promi const allSources = args.includes('--all-sources'); let sourceId = parseFlag(args, '--source'); - // v0.34 W0b: when neither --source nor --all-sources is set, resolve - // to the brain's only source. Multi-source brains require an explicit - // choice — no more silent cross-source default. + // When neither --source nor --all-sources is set, resolve through the full + // source-resolution chain (honors the .gbrain-source pin, env, local_path, + // brain_default, sole_non_default). Only a no-signal multi-source brain + // still errors as multiple_sources_ambiguous. if (!allSources && !sourceId) { try { - sourceId = await resolveDefaultSource(engine); + const resolved = await resolveScopedSourceOrThrow(engine); + sourceId = resolved.source_id; + // Nudge only when we auto-routed to the sole non-default source (the one + // tier with no explicit user signal). Matches sync/import behavior. + if (resolved.tier === 'sole_non_default') { + const nudge = formatSoleNonDefaultNudge(resolved.source_id); + if (nudge) console.error(nudge); + } } catch (e: unknown) { if (e instanceof SourceResolutionError) { const env = errorFor({ @@ -80,6 +104,22 @@ export async function runCodeCallers(engine: BrainEngine, args: string[]): Promi } process.exit(2); } + // Bad/invalid pin (.gbrain-source or GBRAIN_SOURCE points at a missing + // source) → clean usage error, not an uncaught stack. + if (isResolverUserError(e)) { + const env = errorFor({ + class: 'UsageError', + code: 'invalid_source_pin', + message: (e as Error).message, + hint: 'fix the .gbrain-source pin / GBRAIN_SOURCE value, or pass --source / --all-sources', + }).envelope; + if (shouldEmitJson(args)) { + console.log(JSON.stringify({ error: env })); + } else { + console.error((e as Error).message); + } + process.exit(2); + } throw e; } } @@ -91,10 +131,23 @@ export async function runCodeCallers(engine: BrainEngine, args: string[]): Promi sourceId: sourceId ?? undefined, }); + const scope = allSources ? 'all' : 'single'; + const envelopeSourceId = allSources ? null : (sourceId ?? null); + if (shouldEmitJson(args)) { - console.log(JSON.stringify({ symbol: sym, count: edges.length, callers: edges }, null, 2)); + const out: Record = { + symbol: sym, source_id: envelopeSourceId, scope, count: edges.length, callers: edges, + }; + if (edges.length === 0 && !allSources && sourceId) { + out.hint = `No callers in source '${sourceId}'. Try --all-sources to search every source.`; + } + console.log(JSON.stringify(out, null, 2)); } else if (edges.length === 0) { - console.log(`No callers found for "${sym}".`); + if (!allSources && sourceId) { + console.log(`No callers found for "${sym}" in source '${sourceId}'. Try --all-sources to search every source.`); + } else { + console.log(`No callers found for "${sym}".`); + } } else { console.log(`${edges.length} caller(s) for "${sym}":`); for (const e of edges) { diff --git a/src/commands/dream.ts b/src/commands/dream.ts index 9746b4e6c..28b4b9463 100644 --- a/src/commands/dream.ts +++ b/src/commands/dream.ts @@ -198,18 +198,26 @@ function parseArgs(args: string[]): DreamArgs { /** * Resolve the brain directory without the `findRepoRoot` footgun. * - * Prior dream.ts walked up 10 levels of cwd looking for `.git` and would - * happily run lint + sync against an unrelated git repo the user happened - * to be cd'd into. This resolver only trusts two sources: - * 1. An explicit --dir argument. - * 2. The `sync.repo_path` config key set by `gbrain init` (engine-backed). + * Resolution order (v0.41.30 — postgres support): + * 1. An explicit --dir argument (exits 1 if it doesn't exist — a real mistake). + * 2. T1: when --source resolved to a source that has an on-disk `local_path`, + * use it (matches `gbrain sync`, lets that source's filesystem phases run). + * 3. The legacy `sync.repo_path` config key (pre-v0.18 default-source brains). + * 4. `null` — no local checkout. The cycle then SKIPS filesystem phases + * (lint/backlinks/sync/synthesize/extract/patterns) with reason + * `no_brain_dir` and runs the DB-only phases (resolve_symbol_edges, embed, + * orphans, ...). This is what makes `gbrain dream` work on a postgres / + * Supabase brain with no checkout. `runDream` owns the only hard error: + * no checkout AND no engine = truly nothing to run. * - * If neither is available, we error out instead of guessing. + * Still never walks cwd for a `.git` — only the explicit / source / config + * signals are trusted. */ async function resolveBrainDir( engine: BrainEngine | null, explicit: string | null, -): Promise { + resolvedSourceId?: string, +): Promise { if (explicit) { if (!existsSync(explicit)) { console.error(`--dir path does not exist: ${explicit}`); @@ -220,6 +228,22 @@ async function resolveBrainDir( return resolve(explicit); } + // T1: the user scoped to a specific source via --source/--source-id; if that + // source has a checkout on disk, use it so its filesystem phases can run. + if (engine && resolvedSourceId) { + const src = await fetchSource(engine, resolvedSourceId); + if (src?.local_path && existsSync(src.local_path)) { + return resolve(src.local_path); + } + // Explicit --source whose checkout isn't on disk → DB-only (skip FS phases). + // Do NOT fall through to the global sync.repo_path below: that path belongs + // to the default/unscoped brain, and running FS phases (sync/lint/extract) + // against it while the DB phases AND the last_full_cycle_at stamp target + // would mix scopes — syncing one source's checkout while + // marking a different source fresh. (codex P1 review finding.) + return null; + } + if (engine) { const configured = await engine.getConfig('sync.repo_path'); if (configured && existsSync(configured)) { @@ -227,10 +251,9 @@ async function resolveBrainDir( } } - console.error( - 'No brain directory found. Pass --dir or configure one via `gbrain init`.', - ); - process.exit(1); + // No checkout found. Return null (NOT exit) — DB-only phases can still run + // against the engine. The both-null hard error lives in runDream. + return null; } function printHelp() { @@ -251,7 +274,11 @@ Options: --json Emit the CycleReport as JSON (agent-readable) --phase Run a single phase: ${ALL_PHASES.join(' | ')} --pull git pull the brain repo before syncing (default: no pull) - --dir Brain directory (default: configured brain) + --dir Brain directory (default: configured brain). On a + postgres/remote brain with no local checkout, the + filesystem phases (lint, backlinks, sync, synthesize, + extract, patterns) are skipped (reason: no_brain_dir) + and the DB-only phases still run. --source Scope the cycle to one source so doctor's cycle_freshness check sees a fresh stamp on @@ -420,7 +447,18 @@ export async function runDream(engine: BrainEngine | null, args: string[]): Prom } } - const brainDir = await resolveBrainDir(engine, opts.dir); + const brainDir = await resolveBrainDir(engine, opts.dir, resolvedSourceId); + // Both-null is the only hard error: no local checkout AND no DB connection + // means neither filesystem phases nor DB phases can run. With an engine but + // no checkout, the cycle skips filesystem phases and runs DB-only phases + // (resolve_symbol_edges, embed, orphans, ...) — the postgres support path. + if (brainDir === null && engine === null) { + console.error( + 'No brain directory found and no database connection. ' + + 'Pass --dir or configure a brain via `gbrain init`.', + ); + process.exit(1); + } const phases: CyclePhase[] | undefined = opts.phase ? [opts.phase] : undefined; const report = await runCycle(engine, { diff --git a/src/commands/jobs.ts b/src/commands/jobs.ts index 620cb2860..b1a9a4701 100644 --- a/src/commands/jobs.ts +++ b/src/commands/jobs.ts @@ -1334,9 +1334,14 @@ export async function registerBuiltinHandlers(worker: MinionWorker, engine: Brai // throw on partial: a flaky phase must not block every future cycle. worker.register('autopilot-cycle', async (job) => { const { runCycle } = await import('../core/cycle.ts'); - const repoPath = typeof job.data.repoPath === 'string' + // v0.41.30 (T2): fall back to null (NOT cwd '.') when no repo is configured. + // The queued cycle is the same primitive `gbrain dream` uses; a checkout-less + // postgres brain should skip filesystem phases (no_brain_dir) and run the + // DB-only phases (resolve_symbol_edges, embed, ...) — not silently lint/sync + // against whatever directory the worker happens to be running in. + const repoPath: string | null = typeof job.data.repoPath === 'string' ? job.data.repoPath - : (await engine.getConfig('sync.repo_path')) ?? '.'; + : (await engine.getConfig('sync.repo_path')) ?? null; // v0.38 (codex r1 P1-2 + P1-5): per-source dispatch threading. // - source_id: when set, runCycle uses the per-source lock ID and @@ -1525,9 +1530,14 @@ export async function registerBuiltinHandlers(worker: MinionWorker, engine: Brai // the single source of truth for phase semantics. const makePhaseHandler = (phase: string) => async (job: any) => { const { runCycle } = await import('../core/cycle.ts'); - const repoPath = typeof job.data.repoPath === 'string' + // v0.41.38 (codex P2 review): fall back to null (NOT cwd '.') when no repo + // is configured, matching the autopilot-cycle handler + `gbrain dream`. On a + // checkout-less postgres brain a filesystem phase (synthesize/patterns/...) + // skips with reason 'no_brain_dir' instead of running against the worker cwd; + // DB-only phases (resolve_symbol_edges/embed/...) ignore brainDir either way. + const repoPath: string | null = typeof job.data.repoPath === 'string' ? job.data.repoPath - : ((await engine.getConfig('sync.repo_path')) ?? '.'); + : ((await engine.getConfig('sync.repo_path')) ?? null); const report = await runCycle(engine, { brainDir: repoPath, phases: [phase as any], diff --git a/src/core/cycle.ts b/src/core/cycle.ts index 33bfa006f..4e1c3350f 100644 --- a/src/core/cycle.ts +++ b/src/core/cycle.ts @@ -348,8 +348,13 @@ export interface CycleOpts { dryRun?: boolean; /** Defaults to ALL_PHASES. Pass a subset for --phase lint etc. */ phases?: CyclePhase[]; - /** Brain directory (git repo). Required for filesystem phases. */ - brainDir: string; + /** + * Brain directory (git repo). Required for filesystem phases (lint, + * backlinks, sync, synthesize, extract, patterns). `null` when the brain has + * no on-disk checkout (postgres/remote engine) — those phases are skipped + * with reason `no_brain_dir` and the DB-only phases still run. + */ + brainDir: string | null; /** Whether sync should run `git pull`. Default false (cron-safe). */ pull?: boolean; /** @@ -754,8 +759,11 @@ interface SyncPhaseResult extends PhaseResult { */ async function resolveSourceForDir( engine: BrainEngine, - brainDir: string, + brainDir: string | null, ): Promise { + // No checkout → no path-derived source. Callers fall back to opts.sourceId + // (the cycleSourceId precedence) or 'default'. + if (brainDir === null) return undefined; try { const rows = await engine.executeRaw<{ id: string }>( `SELECT id FROM sources WHERE local_path = $1 LIMIT 1`, @@ -1288,6 +1296,33 @@ export async function runCycle( const timestamp = new Date().toISOString(); const phaseResults: PhaseResult[] = []; + // Capture as a const so it narrows to `string` inside the `else` branches of + // the per-phase `if (brainDir === null)` guards, even within async closures + // (const bindings narrow across closures; property accesses don't). + const brainDir = opts.brainDir; + + // Skip result for a filesystem phase when the brain has no on-disk checkout. + const skipNoBrainDir = (phase: CyclePhase): PhaseResult => ({ + phase, + status: 'skipped', + duration_ms: 0, + summary: 'requires a local brain directory; this brain has no on-disk checkout ' + + '(postgres/remote engine); pass --dir to run filesystem phases', + details: { reason: 'no_brain_dir' }, + }); + + // A1: canonical per-source scope for the DB-capable per-source phases + // (extract_facts, extract_atoms, the calibration trio). Explicit --source + // (opts.sourceId) wins; else derive from the resolved checkout dir. Without + // this, `gbrain dream --source repo-a` on a checkout-less brain would scope + // those phases to 'default' (resolveSourceForDir(null) → undefined) while the + // cycle still locks + stamps last_full_cycle_at for repo-a — a freshness + // stamp that lies. resolveSourceForDir returns undefined when brainDir is + // null, so opts.sourceId is the only signal in the no-checkout case. + const cycleSourceId: string | undefined = engine + ? (opts.sourceId ?? (await resolveSourceForDir(engine, brainDir))) + : opts.sourceId; + const progress = createProgress(cliOptsToProgressOptions(getCliOptions())); // Decide if we need the cycle lock: any state-mutating phase in the selection. @@ -1417,22 +1452,30 @@ export async function runCycle( // ── Phase 1: lint ──────────────────────────────────────────── if (phases.includes('lint')) { checkAborted(opts.signal); - progress.start('cycle.lint'); - const { result, duration_ms } = await timePhase(() => runPhaseLint(opts.brainDir, dryRun)); - result.duration_ms = duration_ms; - phaseResults.push(result); - progress.finish(); + if (brainDir === null) { + phaseResults.push(skipNoBrainDir('lint')); + } else { + progress.start('cycle.lint'); + const { result, duration_ms } = await timePhase(() => runPhaseLint(brainDir, dryRun)); + result.duration_ms = duration_ms; + phaseResults.push(result); + progress.finish(); + } await safeYield(opts.yieldBetweenPhases); } // ── Phase 2: backlinks ────────────────────────────────────── if (phases.includes('backlinks')) { checkAborted(opts.signal); - progress.start('cycle.backlinks'); - const { result, duration_ms } = await timePhase(() => runPhaseBacklinks(opts.brainDir, dryRun)); - result.duration_ms = duration_ms; - phaseResults.push(result); - progress.finish(); + if (brainDir === null) { + phaseResults.push(skipNoBrainDir('backlinks')); + } else { + progress.start('cycle.backlinks'); + const { result, duration_ms } = await timePhase(() => runPhaseBacklinks(brainDir, dryRun)); + result.duration_ms = duration_ms; + phaseResults.push(result); + progress.finish(); + } await safeYield(opts.yieldBetweenPhases); } @@ -1452,9 +1495,11 @@ export async function runCycle( summary: 'no database connected', details: { reason: 'no_database' }, }); + } else if (brainDir === null) { + phaseResults.push(skipNoBrainDir('sync')); } else { progress.start('cycle.sync'); - const { result, duration_ms } = await timePhase(() => runPhaseSync(engine, opts.brainDir, dryRun, pull, phases.includes('extract'))); + const { result, duration_ms } = await timePhase(() => runPhaseSync(engine, brainDir, dryRun, pull, phases.includes('extract'))); result.duration_ms = duration_ms; // Capture changed slugs for incremental extract. syncPagesAffected = (result as SyncPhaseResult).pagesAffected; @@ -1474,11 +1519,13 @@ export async function runCycle( summary: 'no database connected', details: { reason: 'no_database' }, }); + } else if (brainDir === null) { + phaseResults.push(skipNoBrainDir('synthesize')); } else { progress.start('cycle.synthesize'); const { runPhaseSynthesize } = await import('./cycle/synthesize.ts'); const { result, duration_ms } = await timePhase(() => runPhaseSynthesize(engine, { - brainDir: opts.brainDir, + brainDir, dryRun, yieldDuringPhase: opts.yieldDuringPhase, inputFile: opts.synthInputFile, @@ -1510,12 +1557,14 @@ export async function runCycle( summary: 'no database connected', details: { reason: 'no_database' }, }); + } else if (brainDir === null) { + phaseResults.push(skipNoBrainDir('extract')); } else { // Pass changed slugs from sync for incremental extract. // If sync didn't run (phases exclude it) or failed, syncPagesAffected // is undefined → extract falls back to full walk (safe default). progress.start('cycle.extract'); - const { result, duration_ms } = await timePhase(() => runPhaseExtract(engine, opts.brainDir, dryRun, syncPagesAffected)); + const { result, duration_ms } = await timePhase(() => runPhaseExtract(engine, brainDir, dryRun, syncPagesAffected)); result.duration_ms = duration_ms; phaseResults.push(result); progress.finish(); @@ -1548,9 +1597,9 @@ export async function runCycle( // already-resolved cycle scope; sourceId defaults to 'default' when // the sources table doesn't recognize this brainDir (pre-multi- // source installs). - const xfSourceId = (await resolveSourceForDir(engine, opts.brainDir)) ?? 'default'; + const xfSourceId = cycleSourceId ?? 'default'; const { result, duration_ms } = await timePhase(() => - runPhaseExtractFacts(engine, opts.brainDir, xfSourceId, dryRun, syncPagesAffected)); + runPhaseExtractFacts(engine, brainDir, xfSourceId, dryRun, syncPagesAffected)); result.duration_ms = duration_ms; phaseResults.push(result); progress.finish(); @@ -1591,7 +1640,7 @@ export async function runCycle( } else { progress.start('cycle.extract_atoms'); const { runPhaseExtractAtoms } = await import('./cycle/extract-atoms.ts'); - const xaSourceId = (await resolveSourceForDir(engine, opts.brainDir)) ?? 'default'; + const xaSourceId = cycleSourceId ?? 'default'; // v0.41.2.1 (D9 #5): union sync + synthesize affected slugs so the // incremental discovery path doesn't miss pages just-written by the // synthesize phase that ran earlier in the same cycle. @@ -1603,7 +1652,7 @@ export async function runCycle( ] : undefined; const { result, duration_ms } = await timePhase(() => runPhaseExtractAtoms(engine, { - brainDir: opts.brainDir, + brainDir: brainDir ?? undefined, sourceId: xaSourceId, dryRun, affectedSlugs: xaAffectedSlugs, @@ -1659,11 +1708,13 @@ export async function runCycle( summary: 'no database connected', details: { reason: 'no_database' }, }); + } else if (brainDir === null) { + phaseResults.push(skipNoBrainDir('patterns')); } else { progress.start('cycle.patterns'); const { runPhasePatterns } = await import('./cycle/patterns.ts'); const { result, duration_ms } = await timePhase(() => runPhasePatterns(engine, { - brainDir: opts.brainDir, + brainDir, dryRun, yieldDuringPhase: opts.yieldDuringPhase, })); @@ -1700,7 +1751,7 @@ export async function runCycle( progress.start('cycle.synthesize_concepts'); const { runPhaseSynthesizeConcepts } = await import('./cycle/synthesize-concepts.ts'); const { result, duration_ms } = await timePhase(() => runPhaseSynthesizeConcepts(engine, { - brainDir: opts.brainDir, + brainDir: brainDir ?? undefined, dryRun, // v0.41.19.0 (T3): closure refreshes cycle lock + fires outer hook. yieldDuringPhase: buildYieldDuringPhase(lock, opts.yieldDuringPhase), @@ -1798,7 +1849,7 @@ export async function runCycle( if (engine) { const cfgMod = await import('./config.ts'); const calibrationConfig = cfgMod.loadConfig() ?? ({} as ReturnType & object); - const calibrationSourceId = await resolveSourceForDir(engine, opts.brainDir); + const calibrationSourceId = cycleSourceId; const calibrationCtx = { engine, config: calibrationConfig, @@ -1812,7 +1863,7 @@ export async function runCycle( checkAborted(opts.signal); progress.start('cycle.propose_takes'); const { runPhaseProposeTakes } = await import('./cycle/propose-takes.ts'); - const { result, duration_ms } = await timePhase(() => runPhaseProposeTakes(calibrationCtx, { repoPath: opts.brainDir }) as Promise); + const { result, duration_ms } = await timePhase(() => runPhaseProposeTakes(calibrationCtx, { repoPath: brainDir ?? undefined }) as Promise); result.duration_ms = duration_ms; phaseResults.push(result); progress.finish(); @@ -2126,6 +2177,11 @@ function deriveStatus(phases: PhaseResult[], totals: CycleReport['totals']): Cyc totals.pages_synced > 0 || totals.pages_extracted > 0 || totals.pages_embedded > 0 || - totals.pages_emotional_weight_recomputed > 0; + totals.pages_emotional_weight_recomputed > 0 || + // A7: a code brain runs `gbrain dream` specifically to build the call graph + // (resolve_symbol_edges). Without these, an edges-only cycle reports 'clean' + // — indistinguishable from "nothing happened" even when N edges resolved. + totals.edges_resolved > 0 || + totals.edges_ambiguous > 0; return anyWork ? 'ok' : 'clean'; } diff --git a/src/core/sources-ops.ts b/src/core/sources-ops.ts index a2ecacb32..9e912b84f 100644 --- a/src/core/sources-ops.ts +++ b/src/core/sources-ops.ts @@ -51,6 +51,7 @@ import { } from './git-remote.ts'; import { gbrainPath } from './config.ts'; import { isValidSourceId } from './source-id.ts'; +import { resolveSourceWithTier, type SourceTier } from './source-resolver.ts'; // ── Errors ────────────────────────────────────────────────────────────────── @@ -446,6 +447,47 @@ export async function resolveDefaultSource(engine: BrainEngine): Promise ); } +/** Result of `resolveScopedSourceOrThrow`: the resolved source id plus the + * tier that won, so callers can nudge (sole_non_default) or surface the + * source in their output envelope. */ +export interface ScopedSourceResolution { + source_id: string; + tier: SourceTier; +} + +/** + * Source scope for the structural-retrieval commands (`code-callers` / + * `code-callees`) when neither `--source` nor `--all-sources` is given. + * + * Runs the FULL 7-tier resolution chain via `resolveSourceWithTier` + * (flag → env → dotfile → local_path → brain_default → sole_non_default → + * seed_default), so a `.gbrain-source` pin (or any real signal) selects the + * source. The multi-source ambiguity guard (`resolveDefaultSource`) is + * applied ONLY when the chain matched nothing real (tier `seed_default`): + * 1 source → returns it, 0 → `no_sources` throw, 2+ → `multiple_sources_ambiguous`. + * + * Contrast with `resolveSourceId` (silently returns `'default'` and never + * throws on ambiguity) — this helper deliberately preserves the loud + * multi-source error when there's genuinely no signal. + * + * @throws SourceResolutionError on a no-signal 0/2+-source brain (seed_default tier). + * @throws Error ("Source \"…\" not found." / "Invalid …") on a bad pin / env value + * via `assertSourceExists` inside `resolveSourceWithTier` — callers should + * surface these as clean usage errors, not uncaught stacks. + */ +export async function resolveScopedSourceOrThrow( + engine: BrainEngine, + cwd: string = process.cwd(), +): Promise { + const resolved = await resolveSourceWithTier(engine, null, cwd); + if (resolved.tier !== 'seed_default') { + return { source_id: resolved.source_id, tier: resolved.tier }; + } + // Nothing in the chain matched → apply the ambiguity guard (may throw). + const id = await resolveDefaultSource(engine); + return { source_id: id, tier: 'seed_default' }; +} + // ── listSources ───────────────────────────────────────────────────────────── export async function listSources( diff --git a/test/code-callers-pin.serial.test.ts b/test/code-callers-pin.serial.test.ts new file mode 100644 index 000000000..c14eb1add --- /dev/null +++ b/test/code-callers-pin.serial.test.ts @@ -0,0 +1,245 @@ +/** + * v0.41.30.0 — code-callers / code-callees end-to-end source resolution (BUG 1). + * + * Serial (`*.serial.test.ts`) because it process.chdir()s into a temp dir + * holding a .gbrain-source pin — process.cwd() is process-global and races + * with parallel files. Drives the real runCodeCallers / runCodeCallees through + * their process.cwd()-based resolution, asserting: + * - a .gbrain-source pin resolves on a multi-source brain (no exit 2) + * - no pin + no flag + multi-source still errors (exit 2) + * - explicit --source overrides + * - A4: JSON envelope carries source_id + scope; --all-sources → null/'all' + * - A4: sole_non_default tier emits the stderr nudge + * - A5: zero-result implicit scope appends the "try --all-sources" hint + * + * PGLite in-memory, no DATABASE_URL. + */ + +import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach, spyOn } from 'bun:test'; +import { mkdtempSync, writeFileSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { PGLiteEngine } from '../src/core/pglite-engine.ts'; +import { resetPgliteState } from './helpers/reset-pglite.ts'; +import { withEnv } from './helpers/with-env.ts'; +import { runCodeCallers } from '../src/commands/code-callers.ts'; +import { runCodeCallees } from '../src/commands/code-callees.ts'; + +let engine: PGLiteEngine; +let origCwd: string; + +beforeAll(async () => { + engine = new PGLiteEngine(); + await engine.connect({}); + await engine.initSchema(); + origCwd = process.cwd(); +}); + +afterAll(async () => { + await engine.disconnect(); +}); + +beforeEach(async () => { + await resetPgliteState(engine); +}); + +afterEach(() => { + process.chdir(origCwd); +}); + +async function addSource(id: string, localPath: string | null): Promise { + await engine.executeRaw( + `INSERT INTO sources (id, name, local_path, config, created_at) + VALUES ($1, $1, $2, '{}'::jsonb, NOW()) ON CONFLICT (id) DO NOTHING`, + [id, localPath], + ); +} + +function pinnedDir(sourceId: string): string { + const dir = mkdtempSync(join(tmpdir(), 'gbrain-pin-cli-')); + writeFileSync(join(dir, '.gbrain-source'), `${sourceId}\n`); + return dir; +} + +/** Run fn with process.exit + console.log/error spied. Returns captured output + * + the exit code if process.exit was called (spied to throw an EXIT sentinel). */ +async function capture(fn: () => Promise): Promise<{ logs: string[]; errs: string[]; exitCode: number | null }> { + const logs: string[] = []; + const errs: string[] = []; + let exitCode: number | null = null; + const logSpy = spyOn(console, 'log').mockImplementation((m?: unknown) => { logs.push(String(m)); }); + const errSpy = spyOn(console, 'error').mockImplementation((m?: unknown) => { errs.push(String(m)); }); + const exitSpy = spyOn(process, 'exit').mockImplementation(((code?: number) => { + exitCode = code ?? 0; + throw new Error('EXIT'); + }) as never); + try { + await fn(); + } catch (e) { + if (!(e instanceof Error) || e.message !== 'EXIT') throw e; + } finally { + logSpy.mockRestore(); + errSpy.mockRestore(); + exitSpy.mockRestore(); + } + return { logs, errs, exitCode }; +} + +describe('code-callers / code-callees — .gbrain-source pin (CLI wiring)', () => { + test('pin resolves on a multi-source brain: no exit 2, output names the pinned source', async () => { + await addSource('repo-a', '/fake/a'); + await addSource('repo-b', '/fake/b'); + const dir = pinnedDir('repo-a'); + process.chdir(dir); + try { + const callers = await withEnv({ GBRAIN_SOURCE: undefined }, () => + capture(() => runCodeCallers(engine, ['someSym', '--no-json']))); + expect(callers.exitCode).toBeNull(); // resolved, did NOT error + expect(callers.logs.join('\n')).toContain("repo-a"); + + const callees = await withEnv({ GBRAIN_SOURCE: undefined }, () => + capture(() => runCodeCallees(engine, ['someSym', '--no-json']))); + expect(callees.exitCode).toBeNull(); + expect(callees.logs.join('\n')).toContain("repo-a"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test('no pin + no flag + multi-source → exit 2 (ambiguous preserved)', async () => { + await addSource('repo-a', '/fake/a'); + await addSource('repo-b', '/fake/b'); + const dir = mkdtempSync(join(tmpdir(), 'gbrain-nopin-cli-')); + process.chdir(dir); + try { + const callers = await withEnv({ GBRAIN_SOURCE: undefined }, () => + capture(() => runCodeCallers(engine, ['someSym', '--no-json']))); + expect(callers.exitCode).toBe(2); + expect(callers.errs.join('\n')).toContain('--source'); + + const callees = await withEnv({ GBRAIN_SOURCE: undefined }, () => + capture(() => runCodeCallees(engine, ['someSym', '--no-json']))); + expect(callees.exitCode).toBe(2); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test('explicit --source overrides (even with a conflicting pin)', async () => { + await addSource('repo-a', '/fake/a'); + await addSource('repo-b', '/fake/b'); + const dir = pinnedDir('repo-a'); + process.chdir(dir); + try { + const { logs, exitCode } = await withEnv({ GBRAIN_SOURCE: undefined }, () => + capture(() => runCodeCallers(engine, ['someSym', '--source', 'repo-b', '--json']))); + expect(exitCode).toBeNull(); + const env = JSON.parse(logs.join('\n')); + expect(env.source_id).toBe('repo-b'); + expect(env.scope).toBe('single'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test('A4: JSON envelope carries source_id + scope (resolved pin)', async () => { + await addSource('repo-a', '/fake/a'); + await addSource('repo-b', '/fake/b'); + const dir = pinnedDir('repo-a'); + process.chdir(dir); + try { + const { logs } = await withEnv({ GBRAIN_SOURCE: undefined }, () => + capture(() => runCodeCallers(engine, ['someSym', '--json']))); + const env = JSON.parse(logs.join('\n')); + expect(env.source_id).toBe('repo-a'); + expect(env.scope).toBe('single'); + expect(env.symbol).toBe('someSym'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test('A4: --all-sources → source_id null, scope "all"', async () => { + await addSource('repo-a', '/fake/a'); + await addSource('repo-b', '/fake/b'); + const dir = mkdtempSync(join(tmpdir(), 'gbrain-all-cli-')); + process.chdir(dir); + try { + const { logs, exitCode } = await capture(() => + runCodeCallers(engine, ['someSym', '--all-sources', '--json'])); + expect(exitCode).toBeNull(); + const env = JSON.parse(logs.join('\n')); + expect(env.source_id).toBeNull(); + expect(env.scope).toBe('all'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test('A4: sole_non_default tier emits the stderr nudge', async () => { + await addSource('repo-a', '/fake/a'); // default + one non-default w/ local_path + const dir = mkdtempSync(join(tmpdir(), 'gbrain-sole-cli-')); + process.chdir(dir); + try { + const { errs, exitCode } = await withEnv( + { GBRAIN_SOURCE: undefined, GBRAIN_NO_SOLE_NON_DEFAULT_NUDGE: undefined }, + () => capture(() => runCodeCallers(engine, ['someSym', '--json']))); + expect(exitCode).toBeNull(); + expect(errs.join('\n')).toContain("routing to source 'repo-a'"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test('A4: dotfile-pin tier does NOT emit the sole_non_default nudge', async () => { + await addSource('repo-a', '/fake/a'); + await addSource('repo-b', '/fake/b'); + const dir = pinnedDir('repo-a'); + process.chdir(dir); + try { + const { errs } = await withEnv({ GBRAIN_SOURCE: undefined }, () => + capture(() => runCodeCallers(engine, ['someSym', '--json']))); + expect(errs.join('\n')).not.toContain('routing to source'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test('A5: zero-result implicit scope appends the "try --all-sources" hint', async () => { + await addSource('repo-a', '/fake/a'); + await addSource('repo-b', '/fake/b'); + const dir = pinnedDir('repo-a'); + process.chdir(dir); + try { + // human output + const human = await withEnv({ GBRAIN_SOURCE: undefined }, () => + capture(() => runCodeCallers(engine, ['someSym', '--no-json']))); + expect(human.logs.join('\n')).toContain('Try --all-sources'); + + // JSON hint field + const jsonRun = await withEnv({ GBRAIN_SOURCE: undefined }, () => + capture(() => runCodeCallees(engine, ['someSym', '--json']))); + const env = JSON.parse(jsonRun.logs.join('\n')); + expect(env.count).toBe(0); + expect(env.hint).toContain('--all-sources'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test('bad .gbrain-source pin → exit 2 with JSON error envelope', async () => { + await addSource('repo-a', '/fake/a'); + await addSource('repo-b', '/fake/b'); + const dir = pinnedDir('nonexistent-src'); + process.chdir(dir); + try { + const { logs, exitCode } = await withEnv({ GBRAIN_SOURCE: undefined }, () => + capture(() => runCodeCallers(engine, ['someSym', '--json']))); + expect(exitCode).toBe(2); + const env = JSON.parse(logs.join('\n')); + expect(env.error.code).toBe('invalid_source_pin'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/test/code-scoped-source-resolve.test.ts b/test/code-scoped-source-resolve.test.ts new file mode 100644 index 000000000..d9eb1e8b6 --- /dev/null +++ b/test/code-scoped-source-resolve.test.ts @@ -0,0 +1,186 @@ +/** + * v0.41.30.0 — resolveScopedSourceOrThrow resolution rule (BUG 1). + * + * code-callers / code-callees used to call resolveDefaultSource directly, + * which only knew "1 source → use it, else multiple_sources_ambiguous" and + * ignored the .gbrain-source pin. The new helper runs the full 7-tier chain + * (flag → env → dotfile → local_path → brain_default → sole_non_default → + * seed_default) and only applies the ambiguity guard on the no-signal + * seed_default tier. + * + * This drives the helper directly with an explicit cwd (hermetic). The CLI + * end-to-end wiring (process.cwd() + chdir) lives in + * test/code-callers-pin.serial.test.ts. + * + * PGLite in-memory, no DATABASE_URL. + */ + +import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; +import { mkdtempSync, writeFileSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { PGLiteEngine } from '../src/core/pglite-engine.ts'; +import { resolveScopedSourceOrThrow, SourceResolutionError } from '../src/core/sources-ops.ts'; +import { resetPgliteState } from './helpers/reset-pglite.ts'; +import { withEnv } from './helpers/with-env.ts'; + +let engine: PGLiteEngine; + +beforeAll(async () => { + engine = new PGLiteEngine(); + await engine.connect({}); + await engine.initSchema(); +}); + +afterAll(async () => { + await engine.disconnect(); +}); + +beforeEach(async () => { + await resetPgliteState(engine); +}); + +async function addSource(id: string, localPath: string | null): Promise { + await engine.executeRaw( + `INSERT INTO sources (id, name, local_path, config, created_at) + VALUES ($1, $1, $2, '{}'::jsonb, NOW()) ON CONFLICT (id) DO NOTHING`, + [id, localPath], + ); +} + +/** A throwaway directory NOT under any registered source's local_path and + * with no .gbrain-source, so the resolver falls through to the DB tiers. */ +function cleanCwd(): string { + return mkdtempSync(join(tmpdir(), 'gbrain-scoped-clean-')); +} + +describe('resolveScopedSourceOrThrow', () => { + test('single-source brain: returns default via seed_default tier (no throw)', async () => { + const cwd = cleanCwd(); + try { + const r = await withEnv({ GBRAIN_SOURCE: undefined }, () => + resolveScopedSourceOrThrow(engine, cwd)); + expect(r.source_id).toBe('default'); + expect(r.tier).toBe('seed_default'); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test('.gbrain-source pin resolves on a multi-source brain (THE bug)', async () => { + await addSource('repo-a', '/fake/a'); + await addSource('repo-b', '/fake/b'); + const cwd = mkdtempSync(join(tmpdir(), 'gbrain-scoped-pin-')); + writeFileSync(join(cwd, '.gbrain-source'), 'repo-a\n'); + try { + const r = await withEnv({ GBRAIN_SOURCE: undefined }, () => + resolveScopedSourceOrThrow(engine, cwd)); + expect(r.source_id).toBe('repo-a'); + expect(r.tier).toBe('dotfile'); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test('no pin + no signal + multi-source → multiple_sources_ambiguous', async () => { + await addSource('repo-a', '/fake/a'); + await addSource('repo-b', '/fake/b'); + const cwd = cleanCwd(); + let caught: unknown = null; + try { + await withEnv({ GBRAIN_SOURCE: undefined }, () => + resolveScopedSourceOrThrow(engine, cwd)); + } catch (e) { + caught = e; + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + expect(caught).toBeInstanceOf(SourceResolutionError); + if (caught instanceof SourceResolutionError) { + expect(caught.code).toBe('multiple_sources_ambiguous'); + expect(caught.availableSources).toContain('repo-a'); + expect(caught.availableSources).toContain('repo-b'); + } + }); + + test('default + one non-default source with local_path → sole_non_default tier', async () => { + await addSource('repo-a', '/fake/a'); + const cwd = cleanCwd(); + try { + const r = await withEnv({ GBRAIN_SOURCE: undefined }, () => + resolveScopedSourceOrThrow(engine, cwd)); + expect(r.source_id).toBe('repo-a'); + expect(r.tier).toBe('sole_non_default'); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test('GBRAIN_SOURCE env tier wins on a multi-source brain', async () => { + await addSource('repo-a', '/fake/a'); + await addSource('repo-b', '/fake/b'); + const cwd = cleanCwd(); + try { + const r = await withEnv({ GBRAIN_SOURCE: 'repo-b' }, () => + resolveScopedSourceOrThrow(engine, cwd)); + expect(r.source_id).toBe('repo-b'); + expect(r.tier).toBe('env'); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test('brain_default (sources.default config) tier wins when no pin/env/cwd-match', async () => { + await addSource('repo-a', '/fake/a'); + await addSource('repo-b', '/fake/b'); + await engine.setConfig('sources.default', 'repo-a'); + const cwd = cleanCwd(); + try { + const r = await withEnv({ GBRAIN_SOURCE: undefined }, () => + resolveScopedSourceOrThrow(engine, cwd)); + expect(r.source_id).toBe('repo-a'); + expect(r.tier).toBe('brain_default'); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test('zero sources → no_sources throw', async () => { + await engine.executeRaw(`DELETE FROM sources WHERE id = 'default'`, []); + const cwd = cleanCwd(); + let caught: unknown = null; + try { + await withEnv({ GBRAIN_SOURCE: undefined }, () => + resolveScopedSourceOrThrow(engine, cwd)); + } catch (e) { + caught = e; + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + expect(caught).toBeInstanceOf(SourceResolutionError); + if (caught instanceof SourceResolutionError) { + expect(caught.code).toBe('no_sources'); + } + }); + + test('bad .gbrain-source pin (nonexistent source) throws a resolver user error', async () => { + await addSource('repo-a', '/fake/a'); + await addSource('repo-b', '/fake/b'); + const cwd = mkdtempSync(join(tmpdir(), 'gbrain-scoped-badpin-')); + writeFileSync(join(cwd, '.gbrain-source'), 'does-not-exist\n'); + let caught: unknown = null; + try { + await withEnv({ GBRAIN_SOURCE: undefined }, () => + resolveScopedSourceOrThrow(engine, cwd)); + } catch (e) { + caught = e; + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + // Bad pin surfaces as a plain Error from assertSourceExists, NOT a + // SourceResolutionError — the command layer maps this to a clean exit 2. + expect(caught).toBeInstanceOf(Error); + expect(caught).not.toBeInstanceOf(SourceResolutionError); + expect((caught as Error).message).toContain('does-not-exist'); + }); +}); diff --git a/test/dream-postgres.serial.test.ts b/test/dream-postgres.serial.test.ts new file mode 100644 index 000000000..51c48073e --- /dev/null +++ b/test/dream-postgres.serial.test.ts @@ -0,0 +1,238 @@ +/** + * v0.41.30.0 — `gbrain dream` runs against a checkout-less (postgres-shaped) + * brain (BUG 2). + * + * Pre-fix dream.ts:resolveBrainDir process.exit(1)'d with "No brain directory + * found" when neither --dir nor an on-disk sync.repo_path existed — so the + * DB-only maintenance phases (notably resolve_symbol_edges, the call-graph + * builder) could never run on a Supabase brain. Now brainDir can be null: the + * 6 filesystem phases skip with reason `no_brain_dir` and the DB phases run. + * + * Covers: the null-brainDir path, A1 (the --source per-source scope fix), + * A7 (deriveStatus reports `ok` not `clean` when edges resolve), and the + * both-null hard error. PGLite in-memory (the bug branches on + * `brainDir === null`, not engine.kind, so PGLite is a faithful proxy). + */ + +import { describe, test, expect, beforeAll, afterAll, beforeEach, spyOn } from 'bun:test'; +import { mkdtempSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { PGLiteEngine } from '../src/core/pglite-engine.ts'; +import { runDream } from '../src/commands/dream.ts'; +import { runCycle } from '../src/core/cycle.ts'; +import { resetPgliteState } from './helpers/reset-pglite.ts'; + +let engine: PGLiteEngine; + +beforeAll(async () => { + engine = new PGLiteEngine(); + await engine.connect({}); + await engine.initSchema(); +}); + +afterAll(async () => { + await engine.disconnect(); +}); + +beforeEach(async () => { + await resetPgliteState(engine); +}); + +function phase(report: any, name: string) { + return report.phases.find((p: any) => p.phase === name); +} + +// ─── BUG 2: no checkout → DB phases run, FS phases skip ────────────── + +describe('runDream — checkout-less brain (no --dir, no sync.repo_path)', () => { + test('full --dry-run returns a report with brain_dir null (no "No brain directory found" exit)', async () => { + const report = await runDream(engine, ['--dry-run', '--json']); + expect(report).toBeTruthy(); + if (!report) return; + expect(report.brain_dir).toBeNull(); + // A filesystem phase is present and skipped with the no_brain_dir reason. + const lint = phase(report, 'lint'); + expect(lint?.status).toBe('skipped'); + expect(lint?.details?.reason).toBe('no_brain_dir'); + // A DB-only phase ran (not a no_brain_dir skip). + const orphans = phase(report, 'orphans'); + expect(orphans).toBeTruthy(); + expect(orphans?.details?.reason).not.toBe('no_brain_dir'); + }); + + test('--phase resolve_symbol_edges runs on a checkout-less brain (the call-graph phase)', async () => { + const report = await runDream(engine, ['--phase', 'resolve_symbol_edges', '--json']); + expect(report).toBeTruthy(); + if (!report) return; + expect(report.brain_dir).toBeNull(); + const rse = phase(report, 'resolve_symbol_edges'); + expect(rse).toBeTruthy(); + // It RAN — not skipped for no_brain_dir / no_database, not failed. + expect(rse?.status).not.toBe('fail'); + expect(rse?.details?.reason).not.toBe('no_brain_dir'); + expect(rse?.details?.reason).not.toBe('no_database'); + }); + + test('--phase lint skips with no_brain_dir (does not exit 1)', async () => { + const report = await runDream(engine, ['--phase', 'lint', '--json']); + expect(report).toBeTruthy(); + if (!report) return; + const lint = phase(report, 'lint'); + expect(lint?.status).toBe('skipped'); + expect(lint?.details?.reason).toBe('no_brain_dir'); + }); + + test('--source --dry-run on a checkout-less brain succeeds (the command doctor recommends)', async () => { + await engine.executeRaw( + `INSERT INTO sources (id, name, local_path, config, created_at) + VALUES ('repo-a', 'repo-a', NULL, '{}'::jsonb, NOW()) ON CONFLICT (id) DO NOTHING`, + [], + ); + const report = await runDream(engine, ['--source', 'repo-a', '--dry-run', '--json']); + expect(report).toBeTruthy(); + if (!report) return; + expect(report.brain_dir).toBeNull(); + expect(report.status).not.toBe('failed'); + }); +}); + +// ─── A1: --source scopes per-source DB phases correctly on null brainDir ── + +describe('runDream — A1 per-source scope (no checkout)', () => { + test('dream --source repo-a --phase extract_facts reconciles facts for repo-a, not default', async () => { + await engine.executeRaw( + `INSERT INTO sources (id, name, local_path, config, created_at) + VALUES ('repo-a', 'repo-a', '/fake/repo-a', '{}'::jsonb, NOW()) ON CONFLICT (id) DO NOTHING`, + [], + ); + // A page that exists ONLY under repo-a, carrying a Facts fence. With the + // pre-fix bug, brainDir===null → resolveSourceForDir(null)→undefined → + // xfSourceId='default' → getPage('people/bob',{sourceId:'default'}) → null → + // 0 facts. The fix makes cycleSourceId = opts.sourceId = 'repo-a'. + const fence = + `# Bob\n\nBody.\n\n## Facts\n\n` + + `\n` + + `| # | claim | kind | confidence | visibility | notability | valid_from | valid_until | source | context |\n` + + `|---|-------|------|------------|------------|------------|------------|-------------|--------|---------|\n` + + `| 1 | Founded Acme | fact | 1.0 | world | high | 2017-01-01 | | linkedin | |\n` + + `\n`; + await engine.executeRaw( + `INSERT INTO pages (slug, source_id, title, type, page_kind, compiled_truth, timeline, frontmatter, updated_at, created_at) + VALUES ('people/bob', 'repo-a', 'Bob', 'person', 'markdown', $1, '', '{}'::jsonb, NOW(), NOW())`, + [fence], + ); + + const report = await runDream(engine, ['--source', 'repo-a', '--phase', 'extract_facts', '--json']); + expect(report).toBeTruthy(); + if (!report) return; + expect(report.brain_dir).toBeNull(); + + const rows = await engine.executeRaw<{ source_id: string; fact: string }>( + `SELECT source_id, fact FROM facts WHERE source_markdown_slug = 'people/bob'`, + [], + ); + expect(rows.length).toBe(1); + expect(rows[0].source_id).toBe('repo-a'); // NOT 'default' (the A1 bug) + expect(rows[0].fact).toBe('Founded Acme'); + }); + + test('--source repo-a (no checkout) does NOT borrow the global sync.repo_path of a different source', async () => { + // codex P1 regression: with --source set but that source having no on-disk + // checkout, resolveBrainDir must return null (DB-only) and NOT fall through + // to the global sync.repo_path — otherwise FS phases run against the default + // brain's checkout while DB phases + the freshness stamp target repo-a. + const globalRepo = mkdtempSync(join(tmpdir(), 'gbrain-global-repo-')); + try { + await engine.setConfig('sync.repo_path', globalRepo); // exists on disk + await engine.executeRaw( + `INSERT INTO sources (id, name, local_path, config, created_at) + VALUES ('repo-a', 'repo-a', NULL, '{}'::jsonb, NOW()) ON CONFLICT (id) DO NOTHING`, + [], + ); + const report = await runDream(engine, ['--source', 'repo-a', '--phase', 'lint', '--json']); + expect(report).toBeTruthy(); + if (!report) return; + // brain_dir must be null — NOT the globalRepo path. + expect(report.brain_dir).toBeNull(); + const lint = report.phases.find((p: any) => p.phase === 'lint'); + expect(lint?.status).toBe('skipped'); + expect(lint?.details?.reason).toBe('no_brain_dir'); + } finally { + rmSync(globalRepo, { recursive: true, force: true }); + } + }); +}); + +// ─── A7: edges-only cycle reports ok, not clean ───────────────────── + +describe('runCycle — A7 deriveStatus counts resolved edges as work', () => { + test('an edges-only cycle that resolves an edge reports status ok (not clean)', async () => { + await engine.executeRaw( + `INSERT INTO sources (id, name, local_path, config, created_at) + VALUES ('src-a', 'src-a', '/fake/src-a', '{}'::jsonb, NOW()) ON CONFLICT (id) DO NOTHING`, + [], + ); + const pageRows = await engine.executeRaw<{ id: number }>( + `INSERT INTO pages (slug, source_id, title, type, page_kind, compiled_truth, frontmatter, updated_at, created_at) + VALUES ('src/foo.ts', 'src-a', 'src/foo.ts', 'code', 'code', '', '{}'::jsonb, NOW(), NOW()) + RETURNING id`, + [], + ); + const pageId = pageRows[0]!.id; + const caller = await engine.executeRaw<{ id: number }>( + `INSERT INTO content_chunks (page_id, chunk_index, chunk_text, chunk_source, language, symbol_name_qualified, symbol_type) + VALUES ($1, 0, '// caller', 'compiled_truth', 'typescript', 'callerA', 'function') RETURNING id`, + [pageId], + ); + await engine.executeRaw( + `INSERT INTO content_chunks (page_id, chunk_index, chunk_text, chunk_source, language, symbol_name_qualified, symbol_type) + VALUES ($1, 1, '// def', 'compiled_truth', 'typescript', 'targetFn', 'function')`, + [pageId], + ); + await engine.executeRaw( + `INSERT INTO code_edges_symbol (from_chunk_id, from_symbol_qualified, to_symbol_qualified, edge_type, source_id, edge_metadata) + VALUES ($1, 'callerA', 'targetFn', 'calls', 'src-a', '{}'::jsonb)`, + [caller[0]!.id], + ); + + const report = await runCycle(engine, { brainDir: null, phases: ['resolve_symbol_edges'] }); + expect(report.totals.edges_resolved).toBe(1); + expect(report.status).toBe('ok'); // pre-A7 this was 'clean' + }); +}); + +// ─── both-null hard error preserved ───────────────────────────────── + +describe('runDream — both-null (no engine, no dir) still exits 1', () => { + test('runDream(null, []) exits 1', async () => { + const exitSpy = spyOn(process, 'exit').mockImplementation((() => { throw new Error('EXIT'); }) as never); + const errSpy = spyOn(console, 'error').mockImplementation(() => {}); + let threw = ''; + try { + await runDream(null, []); + } catch (e) { + threw = (e as Error).message; + } + // Assert BEFORE mockRestore — bun's mockRestore clears recorded calls. + expect(threw).toBe('EXIT'); + expect(exitSpy).toHaveBeenCalledWith(1); + exitSpy.mockRestore(); + errSpy.mockRestore(); + }); + + test("runDream(null, ['--phase','orphans']) exits 1", async () => { + const exitSpy = spyOn(process, 'exit').mockImplementation((() => { throw new Error('EXIT'); }) as never); + const errSpy = spyOn(console, 'error').mockImplementation(() => {}); + let threw = ''; + try { + await runDream(null, ['--phase', 'orphans']); + } catch (e) { + threw = (e as Error).message; + } + expect(threw).toBe('EXIT'); + expect(exitSpy).toHaveBeenCalledWith(1); + exitSpy.mockRestore(); + errSpy.mockRestore(); + }); +}); diff --git a/test/jobs-autopilot-cycle-braindir.serial.test.ts b/test/jobs-autopilot-cycle-braindir.serial.test.ts new file mode 100644 index 000000000..147ec44fe --- /dev/null +++ b/test/jobs-autopilot-cycle-braindir.serial.test.ts @@ -0,0 +1,68 @@ +/** + * v0.41.30.0 (T2) — the Minions `autopilot-cycle` handler runs on a + * checkout-less brain. + * + * Pre-fix jobs.ts defaulted repoPath to cwd `'.'` when no repo was configured, + * then fed that into runCycle — so a queued cycle (what `gbrain remote ping` + * triggers) on a checkout-less postgres brain ran filesystem phases against the + * worker's cwd instead of skipping them. Now it passes `null`, so the handler + * follows the same no_brain_dir contract as `gbrain dream`. + * + * Drives the REAL handler (captured from registerBuiltinHandlers) — not a + * source-grep — so a future refactor that reintroduces the '.' fallback fails + * here. PGLite in-memory. + */ + +import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; +import { PGLiteEngine } from '../src/core/pglite-engine.ts'; +import { registerBuiltinHandlers } from '../src/commands/jobs.ts'; +import { resetPgliteState } from './helpers/reset-pglite.ts'; + +let engine: PGLiteEngine; + +beforeAll(async () => { + engine = new PGLiteEngine(); + await engine.connect({}); + await engine.initSchema(); +}); + +afterAll(async () => { + await engine.disconnect(); +}); + +beforeEach(async () => { + await resetPgliteState(engine); +}); + +/** Capture registered handlers via a minimal fake worker. */ +async function captureHandlers(): Promise Promise>> { + const handlers = new Map Promise>(); + const fakeWorker = { register(name: string, fn: (job: any) => Promise) { handlers.set(name, fn); } }; + await registerBuiltinHandlers(fakeWorker as never, engine); + return handlers; +} + +describe('jobs autopilot-cycle handler — no repo configured', () => { + test('feeds null brainDir → filesystem phases skip (no_brain_dir), DB phases run', async () => { + const handlers = await captureHandlers(); + const handler = handlers.get('autopilot-cycle'); + expect(handler).toBeTruthy(); + + // No sync.repo_path config on a fresh brain, no repoPath in job data → + // the handler must pass null (not cwd '.') to runCycle. + const result = await handler!({ + data: { phases: ['lint', 'resolve_symbol_edges'] }, + signal: undefined, + }); + + const report = result.report; + expect(report.brain_dir).toBeNull(); + const lint = report.phases.find((p: any) => p.phase === 'lint'); + expect(lint?.status).toBe('skipped'); + expect(lint?.details?.reason).toBe('no_brain_dir'); + const rse = report.phases.find((p: any) => p.phase === 'resolve_symbol_edges'); + expect(rse).toBeTruthy(); + expect(rse?.details?.reason).not.toBe('no_brain_dir'); + expect(rse?.status).not.toBe('fail'); + }); +});