Compare commits

...
Author SHA1 Message Date
Garry TanandClaude Opus 4.7 442fa22171 chore(scope): strip merge-phantoms command, document as deferred work
Per user direction after 30 rounds of codex review on the
merge-phantoms cleanup command. The command grew its own bug farm
because it duplicated the v0.32.2 extract_facts reconciliation
contract instead of reusing it. Codex kept finding real edge cases
in the parallel implementation; each fix exposed the next axis the
previous shape didn't cover.

Stripped
  - src/commands/merge-phantoms.ts (600 lines, deleted)
  - test/merge-phantoms.test.ts (31 tests, deleted)
  - src/cli.ts entries: CLI_ONLY, CLI_ONLY_SELF_HELP,
    THIN_CLIENT_REFUSED_COMMANDS, THIN_CLIENT_REFUSE_HINTS, the
    dispatch case, and the help text line
  - Unused `stubBodyChars` import in fence-write.ts (was only a
    silencer for the merge-phantoms-era import shape)

Kept (independently valuable)
  - src/core/entities/resolve.ts — prefix expansion + stub
    detection + real-page preservation. 35 tests.
  - src/core/facts/fence-write.ts — stub-guard for new + existing
    phantom files, with DB-side materialization for legitimate
    DB-only bare pages.
  - src/core/facts/backstop.ts + dropped-audit.ts — drops
    unresolvable facts to ~/.gbrain/facts.dropped.jsonl instead of
    creating legacy-shape DB rows that trip the v0.32.2
    reconciliation guard.
  - entities.prefix_expansion_dirs config key.
  - Exported helpers `tryPrefixExpansion`, `stubBodyChars`,
    `isStubBody`, `PHANTOM_STUB_MAX_BODY_CHARS` — kept exported for
    the future Option Beta implementation.

Documented
  - docs/designs/MERGE_PHANTOMS.md captures: the full problem
    statement, what the command was supposed to do, the round-by-
    round cascade table of 30 codex findings, the meta-insight (the
    fence is the system of record; the command was duplicating
    extract_facts's job), three plausible alternative designs
    (Alpha/Beta/Gamma), regression gotchas extracted from the
    iteration as a checklist, and a "how to pick this up" section
    for the future implementer.

6485 unit tests pass; `bun run verify` clean.

The resolver fix + stub-guard + backstop audit are ALL still in PR
#1010 and address the real bug that motivated the work (new bare-
name references no longer spawn phantom pages). Cleanup of pre-
existing phantoms is now deferred to a separate PR that should
start from the design doc.

Plan: ~/.claude/plans/mossy-popping-crown.md.
Design doc: docs/designs/MERGE_PHANTOMS.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 08:06:23 -07:00
Garry TanandClaude Opus 4.7 7f7a080f3a fix(merge-phantoms): codex round-30 P2 — bi-directional fence-drift detection
Codex round-30 P2: the round-29 drift check was one-directional —
it only fired when disk fence had MORE active rows than the DB. The
reverse case (user struck out / forgot fence rows but extract_facts
hasn't reconciled yet, so DB still has stale active rows) slipped
through: the merge would copy the stale DB rows into the canonical
page, undoing the user's strikethroughs.

Fix
  - Drift detection now fires when activeFenceCount !== facts_moved
    in EITHER direction.
  - Equal-count-but-different-content case: when counts match and
    fence has active rows, check that every active fence
    (claim, kind, source) tuple has a matching DB row. Missing
    tuples → drift, skip.
  - Files WITHOUT machine fence markers (legacy pre-v0.32.2
    phantoms) skip the drift check entirely — they're DB-only by
    construction, not drift.

Tests: 64 pass; `bun run verify` clean. New regression test seeds 3
DB rows + a fence with 1 active + 2 strikethrough rows on disk;
asserts the merge skips with fence_drift instead of resurrecting
the struck rows.

Plan: ~/.claude/plans/mossy-popping-crown.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:06:12 -07:00
Garry TanandClaude Opus 4.7 83d6277b46 fix(merge-phantoms): codex round-29 P1 — fence-drift check runs for ALL file-backed phantoms
Codex round-29 P1: the fence-drift check was gated on `facts_moved === 0`,
which meant a phantom with mixed state (1 DB row + 3 active fence rows
on disk) would migrate only the 1 DB row and unlink the file — losing
the 2 disk-only fact rows permanently. The patch's data-loss
prevention only covered the zero-DB-fact case.

Fix
  - Drift check now runs for every file-backed phantom regardless of
    DB-row count.
  - Drift definition: `activeFenceCount > facts_moved` (disk fence
    has more active rows than the DB knows about).
  - Same skip path as round-27 — `fence_drift` skip, log message
    points at extract_facts reconciliation.

Tests: 63 pass. New regression test seeds a 1-DB-row phantom plus a
3-active-fence-row .md, asserts merge skips with fence_drift, DB
row + file survive intact.

Plan: ~/.claude/plans/mossy-popping-crown.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:59:54 -07:00
Garry TanandClaude Opus 4.7 5431cad354 fix(merge-phantoms): codex round-28 P2 — run fence-drift check before dry-run report
Codex round-28 P2: when --dry-run is used on a phantom with zero
active DB rows but active facts on the on-disk fence, the preview
reported it under WOULD MERGE with facts_moved=0. The real run later
skipped the same page as `fence_drift` to avoid data loss. Preview
mismatched reality on exactly the case the guard exists for.

Fix
  - Lifted the fence-drift parse check out of the post-dry-run
    factless branch and into a pre-dry-run gate. Both dry-run and
    real runs see the same skip outcome.
  - The `factlessLocalPath` lookup result is captured during the
    pre-check and reused in the subsequent factless soft-delete +
    unlink path, avoiding a duplicate `lookupSourceLocalPath` call.

Tests: 62 pass; `bun run verify` clean. New regression test pins
that dry-run reports skipped[].skipped='fence_drift' for the
unreconciled-fence scenario.

Plan: ~/.claude/plans/mossy-popping-crown.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:53:24 -07:00
Garry TanandClaude Opus 4.7 591cc763b6 fix(merge-phantoms): codex round-27 P1 — preserve unreconciled disk fence facts
Codex round-27 P1: when DB has no active fact rows for a phantom but
the on-disk .md has a populated `## Facts` fence (crash between
writeFactsToFence renameSync and DB insert, manual fence edits before
extract_facts reconciles), the merge command's factless branch
treated it as empty, soft-deleted the row, and unlinked the file —
losing the fence facts permanently.

Fix
  - Before treating as factless, parse the on-disk fence via
    parseFactsFence. If any rows are active (not strikethrough,
    not forgotten, not superseded), skip with a new `fence_drift`
    reason instead of unlinking. The warn message points the
    operator at `gbrain dream --phase extract_facts` to reconcile
    the fence into the DB before re-running merge-phantoms.
  - Read or parse failures on the on-disk file also fall into the
    same skip branch (conservative — don't unlink a file we couldn't
    inspect).

Tests: 61 pass; `bun run verify` clean. New test seeds a phantom
with a populated fence on disk but no DB rows, asserts the merge
skips with fence_drift and leaves both the file AND the DB row
intact.

Plan: ~/.claude/plans/mossy-popping-crown.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:47:53 -07:00
Garry TanandClaude Opus 4.7 b9809f7046 fix(facts): codex round-26 P2 — check DB content before dropping existing-stub-file facts
Codex round-26 P2: the round-24 stub-guard for existing phantom .md
files trusted the disk body alone. If the same page was later updated
via put_page / MCP, pages.compiled_truth could carry real content
while the .md remained a stale stub. Facts for that legitimate bare
page were silently dropped to the audit log instead of landing on
the real DB body.

Fix
  - When disk says stub, ALSO read pages.compiled_truth + timeline
    via engine.getPage. Only drop when BOTH are stub-shaped.
  - When the DB has real content but disk is stub, materialize the
    DB body to disk (via serializeMarkdown) so the fence append
    lands on the real body. The next read of compiled_truth sees the
    full state.

Tests: 60 pass; `bun run verify` clean. New test seeds a real DB body
+ stale-stub .md on disk, runs writeFactsToFence, asserts the fact
landed AND the on-disk file was reconciled to contain the real body
plus the new fact.

Plan: ~/.claude/plans/mossy-popping-crown.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:41:16 -07:00
Garry TanandClaude Opus 4.7 4104a47594 fix(entities): codex round-25 P2 — return real bare pages immediately
Codex round-25 P2: when pg_trgm is unavailable and the input is a
capitalized real-bare-page name (e.g. `Alice` with a real `alice`
page), the old code:
  1. Bare-name override step recognized the bare slug was real
     (non-stub), fell through.
  2. Exact match on the capitalized input failed (case-sensitive).
  3. Fuzzy returned null (no pg_trgm).
  4. Catch-all prefix expansion routed to `people/alice-*`.
Net: a real top-level page got bypassed on engines without pg_trgm.

Fix
  - When bareBody is non-missing AND non-stub, return the token
    (the real bare slug) NOW instead of falling through. Eliminates
    the fuzzy/prefix detour entirely for capitalized references to
    real bare pages.
  - Stub bareBody still falls through into prefix expansion as
    before (the phantom-redirect case).
  - 'missing' bareBody still falls through to exact/fuzzy/prefix as
    before (the no-bare-page case from round-22).

Tests: 59 pass; `bun run verify` clean. New regression test pins
that `resolveEntitySlug('Realbare')` returns 'realbare' when a real
bare page exists, even with a competing prefix candidate.

Plan: ~/.claude/plans/mossy-popping-crown.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:35:59 -07:00
Garry TanandClaude Opus 4.7 14f17065ad fix(facts): codex round-24 P2 — block fact appends to existing stub phantom files
Codex round-24 P2: writeFactsToFence's stub-guard only fired on the
missing-file branch. A pre-v0.34.5 phantom .md file ALREADY on disk
would slip past the guard — writeFactsToFence read the existing
stub body and appended new facts to it. resolveEntitySlug can still
hand back an unprefixed slug when prefix expansion finds no
canonical, so future facts kept splitting onto the phantom.

Fix
  - The existing-file branch now also checks: if the slug is
    unprefixed AND the file body is stub-shaped (via isStubBody),
    fire the same stub-guard. Facts go to facts.dropped.jsonl,
    operator can recover via the audit log.
  - Non-stub bare files (intentional pages) still get appended to —
    consistent with the round-18 "real DB-only page" path.

Tests: 58 pass. New regression test seeds a stub-shaped phantom .md
on disk and asserts the stub-guard fires (stubGuardBlocked=true,
inserted=0).

Plan: ~/.claude/plans/mossy-popping-crown.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:28:43 -07:00
Garry TanandClaude Opus 4.7 ec253fb170 fix(entities): codex round-22 P2 — fuzzy title match wins over prefix expansion
Codex round-22 P2: the round-7 "prefix-first" change was too greedy.
When a bare token is the TITLE of one entity (e.g. "Liz" on
people/elizabeth-example) AND a slug prefix of another
(people/liz-smith), the resolver routed via prefix expansion →
people/liz-smith. User actually meant Liz=Elizabeth, so facts/recall
attached to the wrong page.

Fix
  - Prefix expansion only short-circuits exact/fuzzy when the bare
    slug EXISTS AS A STUB (phantom). When it's 'missing' entirely,
    we fall through to exact → fuzzy → catch-all prefix expansion.
    Real bare pages still hit exact in step 2 unchanged.
  - The catch-all step 4 still rescues bare names that have NO
    matching slug AND NO strong fuzzy hit (e.g. Jared with no
    `jared` page + low pg_trgm score) — they still route to
    `people/jared-friedman` instead of slugifying to a phantom.

Tests: 57 pass; `bun run verify` clean. New regression test seeds the
Liz/Elizabeth scenario and asserts fuzzy wins.

Plan: ~/.claude/plans/mossy-popping-crown.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:20:31 -07:00
Garry TanandClaude Opus 4.7 910965510d fix(merge-phantoms): codex round-21 P2 — disk-side stub check for unsynced edits
Codex round-21 P2: when an operator edited the phantom .md on disk
(e.g. added prose to alice.md) but hasn't run `gbrain sync`,
pages.compiled_truth is stale. The merge command trusted the stub-
shaped DB body, classified as phantom, proceeded with re-fence +
soft-delete + unlink — and the unsynced disk edits were lost.

Fix
  - When `local_path` is available, also read the on-disk phantom file
    and run stubBodyChars against THAT content. If the disk content
    is non-stub, skip with `not_a_stub` even when the DB body looks
    stubby. Conservative posture on read errors (treat as non-stub
    so the file survives).
  - Net: the merge only proceeds when BOTH the DB body AND the disk
    file (when present) look stub-shaped.

Tests: 56 pass; `bun run verify` clean. New test seeds the
stale-DB-row-with-edited-disk-file scenario and asserts the disk
content survives the merge.

Plan: ~/.claude/plans/mossy-popping-crown.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:13:07 -07:00
Garry TanandClaude Opus 4.7 03b8377ed6 fix(merge-phantoms): codex round-20 P2 — factless unlink + rollback canonical on import fail
Codex round-20 found two P2 issues. Both are cleanup/recovery bugs in
merge-phantoms that the operator hits in real-world scenarios.

P2 #1 — factless-phantom .md file lingers
  - The `if (facts_moved === 0)` early continue soft-deleted the DB
    row but bypassed the unlink-on-disk block at the loop tail. A
    factless phantom with a corresponding .md file would have its DB
    row purged after 72h, then the next sync re-imports the file and
    resurrects the phantom — undoing the round-19 cleanup for the
    factless case.
  - Fix: the factless branch now ALSO unlinks the phantom .md after
    softDeletePage. localPath is re-fetched in this branch (the
    earlier feasibility check only ran when facts_moved > 0). Best-
    effort: a thin-client source with localPath=null is a no-op; an
    unlink error logs a warn but doesn't fail the merge.

P2 #2 — rollback canonical rows when re-import fails
  - The round-15/16 comments claimed rerun was idempotent. Codex
    re-read engine.insertFacts and pointed out it uses plain
    INSERTs under the partial UNIQUE index on (source_id,
    source_markdown_slug, row_num). A failed importFromFile leaves
    canonical fact rows in the DB; rerunning after fixing the
    markdown hits the UNIQUE constraint and aborts until the operator
    manually clears the orphans. Not actually idempotent.
  - Fix: capture `preMergeBody` (the canonical .md content before
    writeFactsToFence) and rollback on import failure:
      - DELETE FROM facts WHERE id = ANY(fenceResult.ids).
      - Restore the markdown file: writeFileSync(preMergeBody) if we
        read the file pre-merge; unlinkSync if we materialized it
        from the DB body (sync artifact, not user state).
      - Both wrapped in best-effort try/catch with warn-on-failure
        instructions for manual recovery.

Tests: 55 pass; `bun run verify` clean.

Plan: ~/.claude/plans/mossy-popping-crown.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:09:05 -07:00
Garry TanandClaude Opus 4.7 6eb9dabda0 fix(merge-phantoms): codex round-19 P2 — unlink phantom .md file on soft-delete
Codex round-19 P2: when the phantom page also exists on disk (e.g.
`alice.md` created by the v0.34.5-era writeFactsToFence path), the
merge soft-deleted the DB row but left the markdown file behind. The
next `gbrain sync` / `gbrain import` walk would re-import the file
and resurrect the phantom DB row after autopilot's purge phase
hard-deleted the soft-deleted row — undoing the entire cleanup and
re-opening the bare-name fact-split path that motivated this PR.

Fix
  - After `softDeletePage` succeeds, the merge loop attempts to
    unlink the phantom .md file via `unlinkSync(join(localPath,
    row.slug + '.md'))`. Best-effort: the DB-row state is the
    primary correctness contract, so a failed unlink logs a warn
    asking the operator to clean up manually and the merge continues.
    Soft-delete + file removal is the matched pair that makes the
    cleanup durable across the next autopilot cycle.

Tests
  - New test seeds a phantom WITH a corresponding .md file on disk
    (mirroring what the v0.34.5-era writeFactsToFence produced),
    runs the merge, asserts both the .md file is gone AND the DB
    row is soft-deleted.

55 tests pass; `bun run verify` clean.

Plan: ~/.claude/plans/mossy-popping-crown.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:01:19 -07:00
Garry TanandClaude Opus 4.7 0e9656b044 fix(facts): codex round-18 fixes — writeFactsToFence respects DB-only real pages + resolver reads timeline
Codex round-18 caught a P1 + P2. Both are now stable across the
write-path AND the resolve-path.

P1 — writeFactsToFence dropped facts for legitimate DB-only bare pages
  - When an unprefixed slug is referenced by extract_facts AND the
    underlying .md file is missing on disk, the v0.34.5 stub-guard
    fired and the backstop logged the fact to facts.dropped.jsonl
    even when a real DB page existed for the slug (created via MCP
    put_page on a source with local_path but never synced to disk).
    Fact lost to disk; user has to recover from the audit log.
  - Fix: before firing the stub-guard, writeFactsToFence calls
    engine.getPage to check for an existing DB row. If found AND
    its body looks real (non-stub compiled_truth OR populated
    timeline), materialize it to disk via serializeMarkdown and let
    the fence append normally. Prefixed-slug stub-create path
    unchanged. Pure stub-guard path unchanged.

P2 — tryExactSlugBody missed the timeline column
  - The resolver's bare-name-override gate (round-7) probes the bare
    slug's body via tryExactSlugBody. That helper only read
    compiled_truth, so a real top-level page whose substantive
    content lived in pages.timeline looked stub-shaped, and the
    resolver would override the bare slug with prefix expansion.
  - Fix: tryExactSlugBody reads both compiled_truth + timeline and
    concatenates them (`<body>\n\n## Timeline\n\n<timeline>`) so
    `isStubBody` sees the timeline content. The check is symmetric
    with the merge-phantoms not_a_stub gate (round-16 P2 #1).

Tests
  - test/entity-resolve.test.ts: new test pinning the round-18 P2
    (bare slug `tl-only-page` with populated timeline column +
    canonical `concepts/tl-only-page-canonical` candidate; resolver
    must return the bare slug).
  - test/entity-resolve.test.ts: new test pinning the round-18 P1
    (real DB-only bare page `real-bare-page` with no .md file;
    writeFactsToFence materializes from DB and appends the fact
    rather than firing stub-guard). Asserts both inserted=1 and the
    resulting .md contains BOTH the original body AND the new fact.

54 tests pass; `bun run verify` clean.

Plan: ~/.claude/plans/mossy-popping-crown.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:55:29 -07:00
Garry TanandClaude Opus 4.7 e92521aa4c fix(merge-phantoms): codex round-17 P2 — materialize canonical from DB before re-fence
Codex round-17 P2: when the canonical page exists in the DB (e.g.
created via MCP put_page on a source with local_path but never synced
to disk), `${localPath}/${canonical}.md` may be missing. The previous
merge code called writeFactsToFence unconditionally, which would
stub-create a NEW markdown file containing only the title. The
subsequent importFromFile would then OVERWRITE the canonical's
pages.compiled_truth with the stub body. Real canonical content
destroyed in the process of "saving" the merged facts.

Fix
  - Before writeFactsToFence, check whether the canonical .md exists
    on disk. If not AND a DB row for the canonical exists, read the
    canonical's compiled_truth + timeline + frontmatter from the DB,
    serialize via the canonical `serializeMarkdown` (same helper
    every other write path uses), and write it to the expected path.
  - writeFactsToFence then APPENDS facts to the materialized file
    rather than stub-creating. importFromFile after writeFactsToFence
    refreshes compiled_truth from the canonical file that now contains
    BOTH the original body AND the migrated fence — no data loss.
  - If the canonical DB row doesn't exist either, fall through (the
    earlier `canonical_missing` skip already handles that case).

Tests
  - New test seeds a DB-only canonical via engine.putPage with a
    1000-char body, asserts the .md file doesn't exist pre-merge,
    runs the merge, asserts the canonical body survives intact in
    BOTH pages.compiled_truth AND the on-disk markdown.

52 tests pass; `bun run verify` clean.

Plan: ~/.claude/plans/mossy-popping-crown.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:46:42 -07:00
Garry TanandClaude Opus 4.7 a5919f919d fix(merge-phantoms): codex round-16 fixes — timeline-aware stub gate + retry idempotency note
Codex round-16 P2 #1 — Timeline-aware stub detection
  - listPhantomEntityPages now SELECTs `timeline` alongside
    compiled_truth. The not_a_stub gate sums stubBodyChars (which
    looks at compiled_truth minus the canonical fence) PLUS the
    timeline column's trimmed length. Any non-empty timeline content
    means the page is real and the merge skips with not_a_stub.
  - Pre-fix: a phantom-shaped compiled_truth (`# Alice\n`) combined
    with a populated `pages.timeline` column would slip past the
    gate, get re-fenced for facts, soft-deleted, and the timeline
    history would be lost when autopilot's purge phase hard-purged
    after 72h.

Codex round-16 P2 #2 — retry idempotency documented
  - The fence_write_failed path leaves phantom rows intact so the
    operator can rerun. Codex flagged "duplicate active facts on
    rerun" as a concern. The chain is actually idempotent by
    construction:
      - writeFactsToFence → upsertFactRow updates fence rows by
        (claim, kind, source) key (not append).
      - engine.insertFacts dedups by the partial UNIQUE index on
        (source_id, source_markdown_slug, row_num).
  - The visible cost between the failed run and a successful rerun
    is that pages.compiled_truth stays stale, so the next autopilot
    extract_facts cycle MIGHT reconcile against the old body. The
    catch-block comment now documents this and tells the operator
    to rerun before the next cycle.

Tests
  - New test pinning the round-16 P2 #1 fix: seed via raw SQL with a
    phantom-shaped compiled_truth + populated pages.timeline column.
    Asserts skip with not_a_stub and timeline survives intact.

51 tests pass; `bun run verify` clean.

Plan: ~/.claude/plans/mossy-popping-crown.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:41:09 -07:00
Garry TanandClaude Opus 4.7 a1888d04fc fix(merge-phantoms): codex round-15 P2 — check ImportResult status before deleting phantom
Codex round-15 P2: importFromFile can reject the canonical re-import
via a normal ImportResult (status: 'skipped' | 'error' | 'invalid'
with an `error` field) without throwing. Common cases: rewritten
markdown over MAX_FILE_SIZE, frontmatter slug no longer matches path.

The round-14 fix only caught the throw path, so a non-throwing
rejection would silently fall through to the delete + soft-delete
section. compiled_truth stays stale → next extract_facts cycle wipes
the migrated DB rows. Migration loss without operator visibility.

Fix
  - Capture `importStatus = await importFromFile(...)` from both the
    happy path and the try/catch error path.
  - After the call, gate the phantom-row deletion + soft-delete on
    `importStatus.status === 'imported'`. Any other status — or a
    null — falls into the same warn + skip + `fence_write_failed`
    branch the throw case uses. Phantom rows survive so the operator
    can rerun after fixing the markdown.

50 tests pass; `bun run verify` clean.

Plan: ~/.claude/plans/mossy-popping-crown.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:35:52 -07:00
Garry TanandClaude Opus 4.7 4ff43fde52 fix(merge-phantoms): codex round-14 P1 — refresh canonical compiled_truth post-merge
Codex round-14 P1: when merge-phantoms re-fences phantom facts into the
canonical page, writeFactsToFence updates the markdown file on disk
+ inserts new DB fact rows, BUT it does NOT update
`pages.compiled_truth`. The canonical's DB body stays stale.

Failure chain
  1. Operator runs `gbrain merge-phantoms` — facts migrate to canonical
     fence on disk; new DB rows have source_markdown_slug=canonical.
  2. Next autopilot cycle's `sync` phase sees git HEAD unchanged and
     reports up-to-date.
  3. `extract_facts` cycle phase reads canonical body via
     `engine.getPage()` — gets the STALE compiled_truth without the
     migrated fence rows.
  4. extract_facts.ts calls `deleteFactsForPage(canonical)` and wipes
     the freshly-migrated DB rows.
  5. User loses every fact merge-phantoms appeared to save.

Fix
  - After writeFactsToFence, call `importFromFile(engine, canonicalPath,
    relPath, {sourceId, noEmbed: true, forceRechunk: true})`. This
    re-reads the markdown, refreshes compiled_truth + content_chunks
    in lockstep, and keeps the next extract_facts cycle from wiping
    the rows. `noEmbed: true` keeps the merge fast — the autopilot's
    embed phase will catch up later.
  - If the re-import fails (malformed markdown post-write), log + skip
    with `fence_write_failed` and do NOT delete the phantom rows. The
    operator can rerun the merge after fixing the file. The fence-
    written DB rows that would otherwise become orphaned are still
    valid; the next extract_facts cycle cleans them up via the
    stale-body path.

50 tests pass; `bun run verify` clean.

Plan: ~/.claude/plans/mossy-popping-crown.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:32:09 -07:00
Garry TanandClaude Opus 4.7 ab43601d24 fix(merge-phantoms): codex round-13 P2 — honor source resolution chain
Codex round-13 P2: `gbrain merge-phantoms` without `--source` was
hardcoded to literal 'default'. In multi-source brains where the
operator works in a non-default source (via GBRAIN_SOURCE env,
.gbrain-source dotfile, or cwd-relative source), running merge-phantoms
either reported a false "no phantoms" result OR mutated the wrong
source's data.

Fix
  - CLI entry now calls `resolveSourceId(engine, explicitSource)` from
    `src/core/source-resolver.ts`. That helper is the project's
    canonical chain: `--source` flag → `GBRAIN_SOURCE` env →
    `.gbrain-source` dotfile walk-up → CWD-local registered source
    → 'default'. Matches the pattern used by every other
    source-scoped command.

50 tests pass; `bun run verify` clean.

Plan: ~/.claude/plans/mossy-popping-crown.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:25:47 -07:00
Garry TanandClaude Opus 4.7 1aab6cd5a5 fix(merge-phantoms): codex round-12 P2 — normalize embedding row shape
When a phantom fact has a non-null `embedding`, `executeRaw` returns it
in a driver-specific shape: postgres-js returns the text literal
`"[0.1,0.2,...]"`, while PGLite returns a Float32Array directly. The
merge command previously passed the raw row through into
FenceInputFact, and `engine.insertFacts` then called `toPgVectorLiteral`
which throws on string input. Result: merge fails mid-flight on
Postgres for any phantom with embedded facts — markdown fence has
already been rewritten, DB hasn't, divergence.

Fix
  - merge-phantoms.ts: column typed as `unknown` to match
    driver variance.
  - Mapping pipes `fr.embedding` through `tryParseEmbedding` from
    src/core/utils.ts. Handles both shapes (Float32Array passes
    through, string parses into Float32Array). Corrupt rows return
    null + warn-once rather than throwing.

50 tests pass; `bun run verify` clean.

Plan: ~/.claude/plans/mossy-popping-crown.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:20:26 -07:00
Garry TanandClaude Opus 4.7 e5e4c5efd1 fix(entities): codex round-11 P2 — strict-zero stub threshold
Codex round-11 caught the final phantom-misclassification edge case:
the 50-char `PHANTOM_STUB_MAX_BODY_CHARS` threshold would still treat
terse-but-intentional pages as stubs. Example: `# RAG` plus a one-
sentence note under 50 chars would classify as stub-shape, get its
exact-slug overridden by prefix expansion in resolveEntitySlug, and
be eligible for soft-deletion in merge-phantoms.

Fix
  - PHANTOM_STUB_MAX_BODY_CHARS lowered from 50 → 0. The new
    contract: stub iff NOTHING remains after stripping frontmatter,
    H1 title, and the canonical machine fence. Any user content
    beyond that — even one character — makes the page real and
    preserves the bare slug.
  - stubEntityPage from fence-write.ts produces exactly
    `# Title\n` after the frontmatter, which strips to "" under the
    regex chain, so threshold=0 captures real stubs without false
    positives.

Tests
  - test/entity-resolve.test.ts: new test pinning that
    resolveEntitySlug('rag-terse') returns 'rag-terse' (not the
    canonical) when the bare page has just one sentence of content.

50 tests across the two files all pass. `bun run verify` clean.

Plan: ~/.claude/plans/mossy-popping-crown.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:16:06 -07:00
Garry TanandClaude Opus 4.7 d8eaebf78b fix(facts): codex round-10 P2 — preserve valid_until across phantom merge
When merge-phantoms migrates a phantom fact with an explicit
`valid_until` date (e.g. "Alice on sabbatical through 2026-08-31"),
the date must survive the re-fence so the canonical's row keeps the
same validity window. Pre-fix the FenceInputFact API dropped
valid_until entirely, so time-bound facts became indefinitely active
after migration.

Fix
  - src/core/facts/fence-write.ts: FenceInputFact gains optional
    `validUntil?: Date`. writeFactsToFence threads it through to
    upsertFactRow as an ISO-date string (the fence row format).
  - src/commands/merge-phantoms.ts: phantom-fact SELECT now reads
    `valid_until` alongside the existing columns. The
    FenceInputFact mapping passes it through (NULL → undefined).

extractFactsFromFenceText already honors `validUntil` from fence rows
when computing the DB row's valid_until column, so the chain is end-
to-end after this commit: phantom DB → SELECT → FenceInputFact →
fence row → re-parse → canonical DB.

Tests
  - test/merge-phantoms.test.ts: new test seeds a phantom fact via
    raw SQL with valid_until = '2026-08-31', runs the merge, and
    asserts the canonical row preserves that exact date.

49 tests across the two files all pass. `bun run verify` clean.

Plan: ~/.claude/plans/mossy-popping-crown.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:10:51 -07:00
Garry TanandClaude Opus 4.7 a4055b76d7 fix(entities): codex round-9 P1 fixes — real fence markers + unbounded fact paging
Codex round-9 caught two P1s (data-correctness, not edge cases) on the
round-6/8 fixes. Both fail the gate.

P1#1 — stubBodyChars matched fictional fence markers
  - The round-6 regex stripped `<!-- facts --> ... <!-- /facts -->`,
    but the actual fence markers used by writeFactsToFence (defined
    in src/core/facts-fence.ts as FACTS_FENCE_BEGIN/END) are
    `<!--- gbrain:facts:begin -->` / `<!--- gbrain:facts:end -->`
    (triple-dash + namespaced markers). The regex never matched real
    fences, so fact-bearing phantoms exceeded the 50-char threshold,
    got classified as not_a_stub, and the merge-phantoms cleanup
    silently skipped EXACTLY the rows it was built to migrate.
  - Compounding effect: resolveEntitySlug's bare-name override
    treats those phantoms as "real" pages too (their body chars > 50
    after the broken strip), so new bare-name facts kept being
    appended to the phantom forever.
  - Fix: regex updated to match the canonical markers. The optional
    preceding `## Facts` heading consumption still works. Added a
    regression assertion (the new round-9 test seeds with the REAL
    markers and would fail loudly if the regex drifts again).

P1#2 — listFactsByEntity clamp loses overflow facts
  - The merge command read phantom facts via
    `listFactsByEntity(..., { limit: 10_000 })`. The engine clamps
    `limit` at MAX_SEARCH_LIMIT (100) via `clampSearchLimit`, so a
    phantom with >100 facts silently returned only 100. The merge
    re-fenced those 100, then `deleteFactsForPage` wiped ALL phantom
    rows, then softDeletePage soft-deleted the page. Overflow facts
    permanently lost.
  - Fix: replace listFactsByEntity with a raw SQL select that
    bypasses the clamp. Columns mirror FactRow so the mapping to
    FenceInputFact below is unchanged. Returns ALL active phantom
    rows in id-asc order.

Tests
  - New test: fact-bearing phantom with REAL gbrain:facts markers
    migrates correctly (pins the marker shape vs. resolve.ts regex).
  - New test: phantom with 150 facts migrates ALL 150 (above the
    100-clamp). Asserts `facts_moved === 150` and that the canonical
    has 150 rows post-merge with 0 phantom rows remaining.
  - Old round-6 test (used fictional `<!-- facts -->` markers) was
    removed — superseded by the round-9 test with real markers.

48 tests across the two files all pass. `bun run verify` clean.

Plan: ~/.claude/plans/mossy-popping-crown.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:05:49 -07:00
Garry TanandClaude Opus 4.7 465a09e79a fix(entities): codex round-8 P2 fixes — fence-marker-only strip + real-page preservation
Codex round-8 caught two P2 issues where the rounds-6/7 fixes were too
aggressive. No P1s; gate stays PASS. Both fixes calibrate the
"is this a v0.34.5 stub?" detector so it doesn't sweep real
user-authored pages.

P2#1 — strip facts fence by MARKERS, not by heading
  - The round-6 stubBodyChars regex stripped `## Facts[\s\S]*?(...)`
    which means it ate any user-authored prose under a normal
    `## Facts` heading, not just the canonical auto-generated
    `<!-- facts --> ... <!-- /facts -->` fence. A legitimate page
    with prose under a `## Facts` heading would return ≤50 chars,
    classify as stub, get re-fenced for facts only, soft-deleted —
    user prose gone.
  - Fix: regex now strips ONLY the canonical `<!-- facts -->` ...
    `<!-- /facts -->` fence pair (optionally with an immediately
    paired `## Facts` heading). Prose under a non-fenced heading
    survives in the body count and trips not_a_stub.

P2#2 — preserve exact bare-slug matches for real top-level pages
  - The round-7 "prefix-first" change overrode exact bare slugs
    unconditionally. A user with a legitimate top-level `rag` page
    + a `concepts/rag` page would see `resolveEntitySlug('rag')`
    redirect to `concepts/rag`, sending new facts away from the
    intentional bare page.
  - Fix: step 1 of resolveEntitySlug now peeks the bare slug's
    `compiled_truth` via a new `tryExactSlugBody` helper (3-value
    return: 'missing' | body-string). Prefix expansion only fires
    when the bare page is missing OR stub-shaped (via isStubBody).
    Real pages keep their bare slug; only phantom-shaped pages get
    overridden.

Refactor
  - stubBodyChars + PHANTOM_STUB_MAX_BODY_CHARS moved from
    src/commands/merge-phantoms.ts to src/core/entities/resolve.ts
    so the stub-detection contract is shared by both the resolver
    and the cleanup command. New exported `isStubBody` predicate
    wraps the threshold. merge-phantoms.ts imports them.

Tests
  - test/entity-resolve.test.ts:
      - New test pinning that a real top-level `rag-real` page (with
        a long body) is preserved as the exact slug even when a
        `concepts/rag-real-example` canonical exists.
      - Existing "prefers prefix expansion over phantom" test renamed
        to explicit "STUB-shaped unprefixed phantom" to call out the
        contract.
  - test/merge-phantoms.test.ts:
      - New test for a page with user-authored prose under a bare
        `## Facts` heading (no machine markers). Asserts skip with
        not_a_stub; page survives with prose intact.

47 tests across the two files all pass. `bun run verify` clean.

Plan: ~/.claude/plans/mossy-popping-crown.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:00:17 -07:00
Garry TanandClaude Opus 4.7 f7fe524262 fix(entities): codex round-7 P2 fixes — prefix-first resolver + preserve Timeline
Codex round-7 caught two P2 issues with the round-6 fixes. No P1s; gate
stays PASS. Both are coverage gaps that the merge-phantoms cleanup
command can't solve on its own — they need fixes in resolveEntitySlug
and the stub-detection helper.

P2#1 — bare-name prefix expansion must run BEFORE exact / fuzzy
  - Brains that still have a pre-fix phantom `alice.md` alongside the
    canonical `people/alice-example.md` would previously have seen new
    "Alice" references resolve via exact-match (slug='alice' matches
    the phantom row) or fuzzy-match (title 'Alice' matches the phantom
    by trgm). The fact would land in the phantom's fence and the
    split would keep growing — the merge-phantoms command can clean
    it up after the fact, but every new write recreates the problem.
  - Fix: moved the `isBareName(trimmed) → tryPrefixExpansion` block
    to step 1 in resolveEntitySlug, before exact-slug and fuzzy. For
    bare single-token names, a prefixed canonical wins over the
    unprefixed phantom. Multi-word / prefixed / hyphenated inputs
    skip the layer structurally (`isBareName` returns false) so their
    paths are unchanged.

P2#2 — preserve Timeline content in the stub gate
  - stubBodyChars was stripping `## Timeline` fences along with
    `## Facts`. A page with substantial Timeline but little prose
    would slip past the not_a_stub gate, get re-fenced for facts
    only, soft-deleted, and then hard-purged after 72h — losing the
    Timeline entirely.
  - Fix: stubBodyChars no longer strips Timeline. Facts stays
    stripped (codex round-6 P2 #2 — fact-bearing phantoms have to
    migrate). Timeline content now correctly trips not_a_stub.

Tests
  - test/entity-resolve.test.ts: new test pinning that
    resolveEntitySlug('alice') prefers `people/alice-example` over an
    unprefixed phantom `alice-phantom-test` in the same source.
  - test/merge-phantoms.test.ts: new test seeding a page with only a
    populated ## Timeline (no real prose). Asserts merge skips with
    `not_a_stub` and the page survives with its Timeline intact.

45 tests across the two files all pass. `bun run verify` clean.

Plan: ~/.claude/plans/mossy-popping-crown.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:53:12 -07:00
Garry TanandClaude Opus 4.7 0dbfc2d751 fix(merge-phantoms): codex round-6 P2 fixes — search all dirs + body-content-only stub gate
Codex round-6 caught two issues with the round-4 + round-5 fixes that
collide with each other. Both findings are P2 (gate stays PASS) but
they're exactly the cases merge-phantoms exists to fix.

P2#1 — phantom.type is unreliable for routing
  - Pre-fix `stubEntityPage` in fence-write.ts defaulted ALL unprefixed
    entity stubs to `type: concept` (the fallback branch for unknown
    prefix). A phantom `alice` that represents a real person therefore
    has stored type='concept'. The round-4 type-constraint fix routed
    that through `concepts/` only and missed the canonical
    `people/alice-example`. The merge command silently skipped the
    exact phantom shape it was built to fix.
  - Resolution: search every configured prefix-expansion directory
    rather than the phantom's type slot. Skip with a new `ambiguous`
    reason when MORE THAN ONE directory has a candidate (e.g. both
    `people/acme-*` AND `companies/acme-*`) — the operator decides
    manually instead of auto-picking. Round-4's cross-type mismerge
    risk is closed structurally by the ambiguity check, not the type
    filter.
  - New PhantomMerge fields: `skipped: 'ambiguous'`, `ambiguous_candidates: string[]`.

P2#2 — body_chars > 200 catches fact-bearing phantoms
  - Round-5's threshold counted total `compiled_truth` length. A
    pre-fix phantom that accumulated facts has the ## Facts fence
    inlined into compiled_truth — markers + table header + a couple
    of fact rows are well over 200 chars. The "is this a stub?" gate
    fired on exactly the phantoms merge-phantoms should migrate.
  - Resolution: new `stubBodyChars(compiled_truth)` helper extracts
    only the chars that aren't frontmatter, H1 title, ## Facts fence,
    or ## Timeline fence. A v0.34.5 stub returns 0 by this measure
    regardless of how many facts accumulated; a real user-imported
    page returns hundreds. Threshold tightened to 50 chars now that
    fence content is excluded.

Tests
  - Old "constrains merge to phantom entity type" test replaced with
    "cross-directory ambiguity" — same seed (acme + people/+companies/
    candidates) but the assertion now expects skip with `ambiguous`
    and both candidates listed in `ambiguous_candidates`.
  - New test "pre-fix phantom with type=concept still resolves to the
    right people/ canonical" pins the round-6 P2 #1 fix.
  - New test "fact-bearing phantom (long compiled_truth from fence)
    still migrates" pins the round-6 P2 #2 fix — uses a 400+ char
    compiled_truth that would have tripped the round-5 gate.

43 tests across the two files all pass. `bun run verify` clean.

Plan: ~/.claude/plans/mossy-popping-crown.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:47:21 -07:00
Garry TanandClaude Opus 4.7 889efabcc2 fix(merge-phantoms): codex round-5 P2 fixes — protect real pages + hermetic tests
Round-5 /codex review surfaced two P2 issues (no P1s, gate stays PASS).

P2#1 — protect non-phantom top-level entity pages
  - A user-imported `rag.md` (type: concept) or `alice.md` (type: person)
    with a real body would previously be classified as a phantom because
    the slug lacks `/`, then soft-deleted after re-fencing only the
    facts. The page's compiled_truth / body / links would be lost.
  - Fix: `listPhantomEntityPages` now returns `body_chars` (length of
    compiled_truth) alongside the row shape. The merge loop skips with
    a new `not_a_stub` reason when `body_chars > 200`. The threshold is
    conservative (stubs from `stubEntityPage` in fence-write.ts run
    well under 100 chars; 200 leaves headroom for a stub that grew a
    couple of small facts in its fence).
  - New `PhantomMerge.skipped` variant: `not_a_stub`.

P2#2 — hermetic GBRAIN_HOME isolation in test/merge-phantoms.test.ts
  - `writeFactsToFence` acquires page locks under
    `gbrainPath('page-locks')`, which resolves to the user's real
    `~/.gbrain/page-locks/` when `GBRAIN_HOME` isn't overridden. Tests
    that didn't set GBRAIN_HOME wrote lockfiles into the developer's
    real brain and would fail in hermetic / read-only-home CI runners
    with EPERM.
  - Fix: new `gbrainHome` tempdir in beforeAll plus a `withTestHome`
    helper that wraps test bodies in `withEnv({ GBRAIN_HOME })`. New
    `itHomed(name, body)` test wrapper applied to every test that
    calls `runMergePhantomsCore`. Plain `it()` retained for the
    listing test and the formatter test that don't touch page locks.
  - Same fix applied to test/entity-resolve.test.ts's integration
    regression test and the inverse "DOES create" assertion.

Tests
  - New test: `skips a top-level page that has real content (not a
    stub)` — seeds a 1300-char `rag` page at top level with a canonical
    `concepts/rag-example` companion. Asserts merge skips with
    `not_a_stub`, page survives intact with deleted_at=NULL.

41 tests across the two files all pass. `bun run verify` clean.

Plan: ~/.claude/plans/mossy-popping-crown.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:38:49 -07:00
Garry TanandClaude Opus 4.7 0155030e5a fix(merge-phantoms): codex round-4 fixes — type-constrained merge + dry-run feasibility
Round-4 /codex review surfaced two issues with the merge-phantoms
re-fence path. Both are data-correctness / operator-trust issues that
land cleanly without scope expansion.

P1 — type-constrained prefix expansion in merge-phantoms
  - Phantom `acme` of type=company was previously routed through the
    global prefix expansion order, which puts `people/` first. A brain
    with both `people/acme-example` (high chunk count, wrong type) AND
    `companies/acme-example` (correct type) would re-fence the company
    facts onto the person page, then soft-delete the phantom. Codex
    flagged this as a data correctness P1 — cross-type collisions
    silently corrupt the canonical pages.
  - Fix: extended `tryPrefixExpansion` with an optional
    `opts.dirs?: readonly string[]` that constrains the search to a
    caller-provided subset. merge-phantoms passes a single-element
    list derived from `row.type` via a new `TYPE_TO_DIR` constant
    map (person→people, company→companies, deal→deals, topic→topics,
    concept→concepts). Phantoms with unknown types skip cleanly.

P2 — dry-run feasibility-checks
  - Dry-run previously reported `merged` for sources with NULL
    `local_path` and facts to migrate; a real run would skip the same
    phantoms with `no_local_path`. Destructive command's preview was
    misleading for thin/no-filesystem source setups.
  - Fix: the local_path feasibility check now runs BEFORE the
    `opts.dryRun` short-circuit. The dry-run preview matches what
    the real run would do, exactly.

Tests
  - New cross-type test: phantom `acme` (type=company) with both
    `people/acme-example` (100 chunks) and `companies/acme-example`
    (3 chunks) seeded — asserts the fact lands on the company page,
    NOT the higher-chunk-count person page.
  - New dry-run-feasibility test: source with NULL local_path +
    phantom with facts — asserts dry-run reports `no_local_path` in
    `skipped`, not in `merged`.

40 tests across the two files all pass. `bun run verify` clean.

Plan: ~/.claude/plans/mossy-popping-crown.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:03:46 -07:00
Garry TanandClaude Opus 4.7 8e534ec2df fix(entities): codex round-3 P2 fixes — exact prefix, concepts default, thin-client refusal
Addresses 3 P2 findings from the third /codex review pass (no P1s; gate
elevated to PASS). All three are coverage / routing gaps surfaced after
the D8 + D9 fixes.

P2#1 — match exact prefix slugs during expansion
  - resolve.ts:tryPrefixExpansion was probing only `<dir>/<token>-%`
    (hyphen-suffix). Bare "Acme" against a canonical `companies/acme`
    (no hyphen) fell through to slugify → stub-guard → fact dropped
    + audited. The fix matches BOTH `<dir>/<token>` exactly AND
    `<dir>/<token>-%` via `slug = $2 OR slug LIKE $3` in the same
    correlated-subquery SELECT — the ORDER BY tiebreak still applies
    across both candidate shapes.

P2#2 — include concepts/ in default dir list
  - DEFAULT_PREFIX_EXPANSION_DIRS extended from
    ['people','companies','deals','topics'] to
    ['people','companies','deals','topics','concepts'].
  - Concepts (`concepts/rag`, `concepts/agentic-workflows`, etc.) are
    the canonical home for `type: concept` pages across gbrain docs
    and example schemas. Bare-name references like "RAG" or "Bitcoin"
    now resolve out of the box.

P2#3 — refuse merge-phantoms on thin-client installs
  - merge-phantoms is destructive + DB-bound (UPDATE facts, soft-delete
    pages, fence-write to local files). Adding it to CLI_ONLY alone
    let thin-client configs pass the thin-client refusal guard and
    hit a generic local-engine error.
  - Added to THIN_CLIENT_REFUSED_COMMANDS + a pinpoint hint in
    THIN_CLIENT_REFUSE_HINTS pointing at "run on the host machine."

Tests
  - test/entity-resolve.test.ts adds 3 new tests: exact prefix match
    via companies/glob, bare concept resolution via concepts/rag, and
    a regression assertion that DEFAULT_PREFIX_EXPANSION_DIRS is
    exactly ['people','companies','deals','topics','concepts'].
  - Two new seed pages (`companies/glob`, `concepts/rag`) exercise the
    new prefix-match shape.

38 tests across the two files all pass. `bun run verify` clean.

Plan: ~/.claude/plans/mossy-popping-crown.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:57:31 -07:00
Garry TanandClaude Opus 4.7 c861b43a64 fix(entities): codex re-review fixes — drop fact + audit + re-fence merge
Addresses the two new findings from `/codex review` post-implementation
(plan: mossy-popping-crown.md D8 + D9).

D8 (codex P1) — stub-guard fallback was producing legacy-shape rows
that tripped the v0.32.2 extract_facts reconciliation guard
  - backstop.ts stub-guard branch no longer calls engine.insertFact.
    Inserting facts with row_num=NULL + entity_slug=NOT NULL produced
    rows shaped like pre-v0.32.2 legacy migration rows, and the v0.32.2
    extract_facts cycle phase at src/core/cycle/extract-facts.ts:83
    refuses to reconcile while any such rows exist. Net: one unknown
    bare entity reference silently broke autopilot extract_facts forever.
  - New src/core/facts/dropped-audit.ts appends a structured JSONL
    entry to ~/.gbrain/facts.dropped.jsonl on each drop. Schema is
    documented in the file header (ts/source_id/phantom_slug/reason/
    fact/kind/notability/visibility/source/source_session). A future
    `gbrain replay-dropped` tool can re-process the entries once the
    canonical entity pages exist.
  - Backstop emits a stderr warn on each drop so operators see the
    cost in real time, not just in the audit file.
  - Best-effort writes — JSONL append failures never throw back into
    the facts pipeline; the fact is already lost, the audit log is a
    recovery aid.

D9 (codex P2) — merge-phantoms must move source_markdown_slug + row_num,
not just entity_slug
  - The pre-codex-review merge UPDATE only repointed entity_slug, leaving
    source_markdown_slug pointing at the soft-deleted phantom. The next
    extract_facts cycle phase that walked the canonical page would parse
    the canonical's fence (which doesn't include the migrated facts) and
    delete the migrated rows since they're keyed on the phantom's slug.
    Net: merge was a 72h delayed data-loss event.
  - merge-phantoms now re-fences the phantom's active facts into the
    canonical page's `## Facts` fence via writeFactsToFence. The new
    DB rows land with source_markdown_slug=canonical + proper row_num,
    and the canonical's markdown file on disk is updated to contain
    the migrated facts. Two delete passes wipe the old phantom rows
    (deleteFactsForPage for post-v0.32.2 + raw DELETE for
    NULL-source_markdown_slug pre-v0.32.2 rows).
  - Skipped paths added: no_local_path (thin-client source can't
    re-fence), fence_write_failed (defensive — canonical has a prefix
    so stub-guard structurally can't fire).
  - Skip-when-no-facts path: phantoms with zero active facts still
    get soft-deleted (the page itself is dead weight; no facts to migrate).

Tests
  - test/entity-resolve.test.ts integration regression updated: asserts
    no DB row is created, no legacy-shape rows accumulate, and the
    audit JSONL line is written. Uses withEnv(GBRAIN_HOME) to scope
    the audit file to a tempdir.
  - test/merge-phantoms.test.ts: 12 tests (up from 10), including a
    new no_local_path skip test, an empty-phantom soft-delete test,
    and an explicit "no legacy-shape rows after merge" regression.
    Source-id scoping test now asserts source_markdown_slug also
    moves correctly. The "fact entity_slug repoint does not multiply
    rows" test was removed (replaced by the markdown-file-on-disk
    assertion in the main re-fence test, which is stronger).
  - tryPrefixExpansion exported from resolve.ts so merge-phantoms can
    run the prefix step in isolation without resolveEntitySlug's
    exact-slug short-circuit (phantom matches itself).

35 tests across the two files all pass. `bun run verify` clean.

Plan: ~/.claude/plans/mossy-popping-crown.md D8 + D9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:51:18 -07:00
Garry TanandClaude Opus 4.7 11c58deb48 fix(entities): codex-reviewed PR — privacy scrub, config dirs, perf, merge-phantoms
Implements the /plan-eng-review + /codex review outcomes for the
entity-resolution prefix-expansion fix (plan: mossy-popping-crown.md).

D2 — entities.prefix_expansion_dirs config key
  - PREFIX_EXPANSION_DIRS hardcoded list replaced with a config-driven
    list. Default ['people','companies','deals','topics'] matches the
    stub-guard's recognized prefix set in fence-write.ts. Custom brains
    override via `entities.prefix_expansion_dirs` to support funds/,
    advisors/, etc. New exported getPrefixExpansionDirs() resolver
    drops bad config entries silently (non-string / empty) and falls
    back to defaults.

D3 — narrow column-missing catch
  - Bare `catch {}` in tryPrefixExpansion / tryExactSlug / tryFuzzyMatch
    replaced with isUndefinedColumnError(err, 'deleted_at') check
    (v0.26.9 utils.ts pattern). Genuine failures (pool exhaustion,
    lock timeout, network blip) propagate instead of masquerading as
    "no prefix match." tryFuzzyMatch retains the additional pg_trgm
    operator-missing fallback.

D4 — dead-code cleanup
  - LIMIT 5 → LIMIT 1 (only rows[0] was ever read).
  - Collapsed the unreachable `if (rows.length === 1)` branch — the
    multi-match path returned the same expression.

D5 — privacy scrub + full coverage matrix (codex P1)
  - test/entity-resolve.test.ts seed rewritten with canonical
    placeholders (alice-example, bob-example, charlie-example,
    dave-example, acme-example, frank-aaa-example, ...). `bun run
    check:privacy` and `check:test-names` now pass.
  - 20 new tests beyond the original 13: companies/ prefix expansion,
    links contribution to connection_count, identical-count tiebreak
    (slug ASC), cross-source isolation, getPrefixExpansionDirs config
    resolution (default + override + bad-entries fallback + funds/
    end-to-end), and the IRON-RULE integration regression: stub-guard
    blocks unprefixed slug → backstop path → DB row exists → no
    markdown file created.

D6 — correlated subqueries replace full-table GROUP BYs (codex P2)
  - tryPrefixExpansion's connection_count rewritten as three
    correlated `(SELECT COUNT(*) ... WHERE col = p.id)` lookups. Hits
    the existing idx_links_to, idx_links_from, idx_chunks_page indexes
    instead of aggregating the entire links + content_chunks tables
    per call. Cost now scales with the slug-LIKE match count (~1-3),
    not brain size.

D7 — gbrain merge-phantoms operator command
  - New src/commands/merge-phantoms.ts: scans for pre-v0.34.5 phantom
    unprefixed entity pages (slug NOT LIKE '%/%' AND type IN
    person/company/deal/topic/concept AND deleted_at IS NULL), runs
    tryPrefixExpansion on each, repoints facts via UPDATE facts SET
    entity_slug = canonical, soft-deletes the phantom via
    engine.softDeletePage (v0.26.5 destructive-guard).
  - Flags: --dry-run, --source <SOURCE_ID>, --json.
  - Idempotent — second run produces zero merges.
  - Wired into src/cli.ts as CLI_ONLY + CLI_ONLY_SELF_HELP. NOT
    exposed as MCP op (destructive, operator-only).
  - 10 tests in test/merge-phantoms.test.ts covering dry-run,
    single-canonical merge, no-canonical skip, soft-deleted-target
    skip, fact dedup, source-id scoping, idempotency, output shape.
  - tryPrefixExpansion newly exported from resolve.ts so
    merge-phantoms can run the prefix step in isolation without the
    exact-slug short-circuit (phantom would otherwise exact-match
    itself).

33 tests across the two test files all pass. `bun run verify`
clean (privacy + test-names + jsonb + source-id + progress +
test-isolation + wasm + admin-build + admin-scope-drift +
cli-exec + system-of-record + eval-glossary + typecheck).

Plan: ~/.claude/plans/mossy-popping-crown.md
Codex review: addressed all 3 findings (2 P1 + 1 P2) per the plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:26:48 -07:00
Garry TanandClaude Opus 4.7 a8dc759adf chore(scope): strip 6 unrelated files from entity-resolution PR
Per /plan-eng-review D1 (mossy-popping-crown.md). The original Wintermute
commit bundled in-progress brain artifacts and operator scripts that don't
belong in the entity-resolution fix:

- docs/proposals/temporal-contradiction-probe.md — proposal doc (separate PR)
- reports/network-intelligence/2026-05-{12,13,14}-1700.md — generated brain artifacts
- scripts/sql.mjs — operator script (separate PR if useful)
- scripts/supersede.mjs — operator script with hardcoded /data/brain path

The entity-resolution + stub-guard + backstop fix in resolve.ts, fence-write.ts,
backstop.ts, and the test file is preserved. Codex P1 docs-privacy finding
also dies here (the banned 'Wintermute' name lived in the stripped proposal).

Plan: ~/.claude/plans/mossy-popping-crown.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:12:12 -07:00
Wintermute 8affc147ee fix(entities): prefix-expansion resolve for bare first names + stub guard
Root cause: resolveEntitySlug fell through to slugify('Jared') → 'jared'
because pg_trgm scores too low on short strings (similarity < 0.4).
writeFactsToFence then stub-created people/jared.md instead of routing
to the existing people/jared-friedman page.

Three-layer fix:
1. resolve.ts: new isBareName() + tryPrefixExpansion() step queries
   people/<token>-% then companies/<token>-%, picks highest-connection
   match as tiebreaker
2. fence-write.ts: stub-creation guard refuses to create pages with
   no directory prefix (bare 'jared' instead of 'people/jared-*')
3. backstop.ts: stubGuardBlocked result routes facts to legacy DB-only
   path so they aren't silently dropped

13 new tests (entity-resolve.test.ts), all passing.
2026-05-15 04:56:30 +00:00
7 changed files with 1936 additions and 15 deletions
+482
View File
@@ -0,0 +1,482 @@
# MERGE_PHANTOMS — design retrospective + future implementation guide
**Status:** Deferred. The `gbrain merge-phantoms` command was built and then
stripped from PR #1010 before merge. This doc captures the full context for a
future agent to pick up the work with a cleaner abstraction.
**History pointers:**
- PR #1010 (`fix/entity-resolution-prefix-expansion`) — the resolver + stub-guard
+ backstop-audit work that DID land.
- Plan: `~/.claude/plans/mossy-popping-crown.md` (decisions D1D9).
- Codex iteration commits: 32 commits on `fix/entity-resolution-prefix-expansion`,
rounds 130. The stripped `src/commands/merge-phantoms.ts` and
`test/merge-phantoms.test.ts` are recoverable from any commit between
`c861b43a` (initial scaffold) and `7f7a080f` (round-30 final).
---
## Problem statement
Pre-v0.34.5, the entity resolver had this failure mode:
1. User says "I just talked to Alice" in a session.
2. `extract_facts` calls `resolveEntitySlug(engine, source_id, "Alice")`.
3. The resolver tries exact-match, then fuzzy via pg_trgm. Short bare names
like "Alice" score below the 0.4 similarity threshold, so fuzzy fails.
4. Resolver falls through to `slugify("Alice")``"alice"`.
5. `writeFactsToFence(..., { slug: "alice" })` doesn't find `alice.md` on disk,
so it calls `stubEntityPage("alice")` which produces a minimal stub:
```
---
type: concept
title: alice
slug: alice
---
# alice
```
6. A phantom page is born at the brain's root. The fact lands in its `## Facts`
fence. The user's REAL `people/alice-example.md` doesn't see the fact.
7. Repeat for every bare-name reference. Over months, the brain accumulates a
pile of phantom unprefixed entity pages (`alice.md`, `jared.md`, `diana.md`)
splitting facts away from their canonical prefixed pages.
The phantom population on a real production brain (the original PR was filed
against an OpenClaw deployment) was in the hundreds.
---
## What the PR was supposed to do — the original three-layer fix
PR #1010 landed three independent layers, all of which are still in master:
1. **Resolver prefix expansion** (`src/core/entities/resolve.ts`). When a bare
name's exact + fuzzy match fails, the resolver walks each configured entity
directory (`entities.prefix_expansion_dirs`, default `['people', 'companies',
'deals', 'topics', 'concepts']`) and queries `slug = '<dir>/<token>'` OR `slug
LIKE '<dir>/<token>-%'`. Picks the highest-connection match. `Alice` now
finds `people/alice-example` BEFORE falling through to slugify.
2. **Stub-creation guard** (`src/core/facts/fence-write.ts`). When
`writeFactsToFence` would stub-create a new entity page whose slug has no
directory prefix, it refuses and returns `stubGuardBlocked: true`. The
guard also fires on existing stub-shaped files (post-round-24) and consults
the DB body to avoid blocking legitimate DB-only pages (post-round-26).
3. **Backstop dropped-fact audit** (`src/core/facts/backstop.ts` +
`src/core/facts/dropped-audit.ts`). When the stub-guard fires, the backstop
no longer inserts a legacy-shape DB row (that path tripped the v0.32.2
`extract_facts` reconciliation guard). Instead it appends a structured entry
to `~/.gbrain/facts.dropped.jsonl` for operator recovery. The fact text is
preserved verbatim so a future `gbrain replay-dropped` tool can re-process
the entries once the canonical entity pages exist.
These three layers stop NEW phantom creation. They do not address the existing
pile.
---
## What `gbrain merge-phantoms` was supposed to do
D7 of the plan-eng-review (the user upgraded my "add to TODOs" recommendation
to "build it now in this PR") was a destructive operator command:
```
gbrain merge-phantoms [--dry-run] [--source SOURCE_ID] [--json]
```
For each unprefixed entity page in the brain:
1. Find the canonical target via prefix expansion (e.g. `alice` → `people/alice-example`).
2. Re-fence the phantom's active facts into the canonical's `## Facts` fence.
3. Soft-delete the phantom page (v0.26.5 destructive-guard machinery).
4. Hard-purge after 72h via the autopilot cycle's purge phase.
Stated simply: "find phantoms, merge them into their canonical, delete the
phantom." This sounded straightforward.
It was not.
---
## How it became a bug farm
The command was built in PR #1010 and reviewed by `codex review --base master`
30 times. Each round found 1-2 real bugs. By round 30 the file had grown to
~600 lines with 8 skip reasons, bi-directional drift detection, rollback
machinery on import failure, on-disk stub detection, DB stale-detection,
materialize-from-DB-before-fence, and tuple-set comparison for content drift.
Below is the round-by-round account. Each round found a real bug — codex
wasn't inventing problems. The patches WERE necessary given the chosen
abstraction. But the abstraction itself was wrong, which is why the work
never converged.
### The cascade table
| Round | Severity | Finding | Fix |
|-------|----------|---------|-----|
| 1 | P1 | Real names in test fixtures break `check:privacy` | Scrub to placeholders |
| 1 | P1 | Private OpenClaw-fork names in stripped proposal doc | Strip doc |
| 1 | P2 | Full table GROUP-BYs in connection-count query | Correlated subqueries |
| 2 | P1 | Backstop legacy-DB insert trips v0.32.2 extract_facts guard | Drop fact + JSONL audit |
| 2 | P2 | Merge UPDATE only moves entity_slug, leaves source_markdown_slug stale | Re-fence into canonical |
| 3 | P2 | Resolver misses `<dir>/<token>` (no hyphen suffix) | Match both shapes |
| 3 | P2 | Default dir list missing `concepts/` | Add to default |
| 3 | P2 | merge-phantoms missing from thin-client refusal | Add |
| 4 | P1 | Cross-type collision (acme-company merged into people/acme-*) | Type-constrained search |
| 4 | P2 | Dry-run reports merge that real run would skip | Move feasibility check before dry-run |
| 5 | P2 | Real top-level pages (rag.md) classified as phantoms | Body-size threshold |
| 5 | P2 | Tests acquire page-locks under user's real ~/.gbrain | GBRAIN_HOME isolation |
| 6 | P2 | Pre-fix phantoms have `type: concept` default — type filter breaks them | Search all dirs |
| 6 | P2 | Fact-bearing phantoms exceed body threshold | Strip fence from body count |
| 7 | P2 | Live resolver still routes new facts to existing phantoms | Prefix-first ordering |
| 7 | P2 | Timeline content stripped from stub detection | Preserve Timeline |
| 8 | P2 | Stub-strip matches `## Facts` heading without machine markers | Match only fence markers |
| 8 | P2 | Prefix-first overrides real top-level pages | Stub-shape gate |
| 9 | **P1** | `stubBodyChars` regex used fictional fence markers (`<!-- facts -->`) — never matched real fences (`<!--- gbrain:facts:begin -->`) | Real markers |
| 9 | **P1** | `listFactsByEntity({ limit: 10_000 })` clamps at MAX_SEARCH_LIMIT=100 — overflow lost | Raw SQL |
| 10 | P2 | `valid_until` dropped during fact migration | Thread through FenceInputFact |
| 11 | P2 | 50-char threshold misclassifies terse real pages | Lower to 0 |
| 12 | P2 | postgres-js returns embeddings as text strings, not Float32Array | tryParseEmbedding |
| 13 | P2 | Hardcoded 'default' ignores GBRAIN_SOURCE / .gbrain-source chain | Use resolveSourceId |
| 14 | **P1** | writeFactsToFence doesn't refresh `pages.compiled_truth` — next extract_facts wipes migrated rows | Re-import canonical |
| 15 | P2 | importFromFile non-throw failure (skipped/error) still proceeds to delete phantom | Check ImportResult status |
| 16 | P2 | Timeline-only pages misclassified as stubs | Include pages.timeline column |
| 16 | P2 | Retry idempotency comment was wrong (real rerun fails on UNIQUE) | Rollback logic |
| 17 | P2 | DB-only canonical (put_page MCP, no .md) gets stub-overwritten | Materialize from DB |
| 18 | **P1** | writeFactsToFence stub-guard drops facts for legitimate DB-only bare pages | Materialize-then-append |
| 18 | P2 | tryExactSlugBody misses timeline column | Include in concat |
| 19 | P2 | Soft-deleted phantom .md file lingers — next sync resurrects | Unlink on soft-delete |
| 20 | P2 | Factless-phantom early continue bypasses unlink | Apply unlink in factless branch |
| 20 | P2 | Rerun NOT idempotent — engine.insertFacts uses plain INSERTs | Rollback canonical rows on import failure |
| 21 | P2 | DB stale relative to disk — unsynced .md edits get unlinked | Disk-side stub check |
| 22 | P2 | Prefix expansion overrides legitimate fuzzy title matches (Liz/Elizabeth) | Stub-only override |
| 23 | — | **CLEAN ROUND** (1 of 30) | — |
| 24 | P2 | Existing stub-shaped .md files slip past guard | Guard on existing files |
| 25 | P2 | Capitalized bare name `Alice` bounces past real bare slug | Return token immediately |
| 26 | P2 | Disk-stub gate drops facts when DB has real content | Check DB before dropping |
| 27 | **P1** | Factless DB but populated disk fence — unlink loses unreconciled facts | Parse fence, skip with fence_drift |
| 28 | P2 | Dry-run reports merge for fence-drift case real run would skip | Move drift check before dry-run |
| 29 | **P1** | Drift check only ran when facts_moved=0; mixed-state phantoms still lost disk-only facts | Run drift check for all file-backed phantoms |
| 30 | P2 | One-directional drift detection — user's strikethroughs get resurrected from stale DB | Bi-directional drift + tuple-set comparison |
### Why the cascade kept producing real bugs
Read down that table. There's a pattern. Each fix changed the shape of the
state machine. Each new shape exposed a new axis the previous shape didn't
consider:
- Body-size threshold (5) → fence inflates body length (6)
- Strip Facts fence (6) → also stripped Timeline (7)
- Prefix-first ordering (7) → overrides real pages (8) → 50-char threshold (8) →
misclassifies terse pages (11) → strict-zero threshold (11)
- Type-constrained search (4) → pre-fix concept default (6) → search all dirs +
ambiguity skip (6) → ambiguous candidates field (6)
- Stale compiled_truth (14) → re-import canonical → import can fail without
throwing (15) → rollback on failure (15) → rollback is incomplete (16)
- Fence-drift check (27) → only ran for factless (29) → only one-directional (30)
This is a textbook "the design grows its own bugs" pattern. The defensive
checks were all real — codex caught them with concrete reproductions — but
they were defending the wrong shape.
---
## The meta-insight — why this is the wrong shape
The fence is the system of record per the v0.32.2 contract (`src/core/cycle/extract-facts.ts:1-32`).
The DB index is downstream. Reconciliation between the two is the
`extract_facts` cycle phase's job.
`merge-phantoms` was trying to do BOTH:
1. Route facts from phantom slug → canonical slug (an entity-resolution concern).
2. Reconcile the markdown fence ↔ DB index across the move (a reconciliation concern).
By doing #2 in a parallel command instead of letting the existing reconciliation
infrastructure handle it, the command had to duplicate every drift-handling
case that `extract_facts` already handles. That's the bug farm:
**every drift case `extract_facts` knows about had to be re-discovered by
codex review on `merge-phantoms`.**
Examples of duplication:
- `extract_facts` has the v0.32.2 reconciliation guard (`row_num IS NULL AND
entity_slug IS NOT NULL` → refuse to reconcile). `merge-phantoms`'s round-2
P1 was tripping that guard.
- `extract_facts` deletes facts by `source_markdown_slug = slug` and re-inserts
from the fence. `merge-phantoms`'s round-14 P1 was forgetting to refresh
`compiled_truth` so the next `extract_facts` would do exactly this delete-and-
re-insert against stale state.
- `extract_facts` already handles fence drift (strikethrough, forgotten,
superseded). `merge-phantoms`'s rounds 2730 were re-implementing the same
drift detection.
---
## Speculation — the platonic-ideal implementations
Three plausible shapes for a future agent. Listed in order of "amount of
existing infrastructure reused." Pick based on operator UX preferences.
### Option Alpha — report-only command, manual remediation
```
gbrain merge-phantoms [--source SOURCE_ID] [--json]
```
Read-only. Lists phantoms + suggested canonical targets. User runs
existing primitives to remediate:
```
$ gbrain merge-phantoms
3 phantom unprefixed entity pages found in source=default:
alice.md → people/alice-example (4 facts on phantom, 0 on canonical)
jared.md → people/jared-friedman (12 facts on phantom, 3 on canonical)
acme.md → companies/acme-example (1 fact on phantom)
To merge:
1. Run `gbrain dream --phase extract_facts` to reconcile fence ↔ DB.
2. For each phantom:
- Edit alice.md's facts fence: move row to people/alice-example.md.
- `rm alice.md`
- `gbrain sync`
To verify no facts are lost, compare counts before/after via `gbrain recall
--entity people/alice-example | wc -l`.
```
- **Code size:** ~80 lines
- **Risk:** zero (no destructive paths)
- **Operator burden:** high
- **Best when:** the brain has fewer than ~20 phantoms and the operator wants
full control.
### Option Beta — phantom redirect in the extract_facts cycle phase
Don't build a separate command. Add phantom-redirect logic to the existing
`runExtractFacts` function in `src/core/cycle/extract-facts.ts`.
When `extract_facts` walks pages:
1. If the page has an unprefixed slug AND is type=person/company/deal/topic/concept:
2. Compute the canonical target via `tryPrefixExpansion`.
3. If canonical exists and is unambiguous:
- For each fact row keyed on the phantom, move it to the canonical
(update `entity_slug` + `source_markdown_slug`).
- Append the migrated fence rows to the canonical's markdown body.
- Soft-delete the phantom page + unlink the .md file.
4. If canonical is ambiguous or missing, leave the phantom alone (continue to
reconcile in place; the operator can resolve manually later).
This piggybacks on:
- The existing `extract_facts` empty-fence guard (the v0.32.2 reconciliation
contract).
- The existing fence parser / strikethrough / forget semantics.
- The existing `deleteFactsForPage` + `insertFacts` reconcile pattern.
- The existing autopilot purge phase (72h soft-delete TTL).
Reconciliation drift is the EXISTING handler's problem, not a parallel
implementation. The phantom-redirect concern is small: "compute the canonical
target, treat the migrated fence as the new source-of-record for the canonical
page."
- **Code size:** ~150 lines added to extract-facts.ts + minimal new tests
- **Risk:** low (reuses battle-tested code paths)
- **Operator burden:** zero (automatic on next autopilot cycle)
- **Best when:** the brain is actively running autopilot. This is the right
default.
**Open design question for Beta:** does the operator want SEE the migration
happen, or should it be invisible? If invisible, the operator might be
surprised when `alice.md` disappears from their brain repo. Suggest: emit a
progress event (`cycle.extract_facts.phantom_redirected`) and tally counts
in the cycle report so `gbrain doctor` can surface them.
### Option Gamma — phantoms as a first-class schema concept
The most invasive option. Add `pages.canonical_of TEXT REFERENCES pages.slug`
to the schema:
```sql
ALTER TABLE pages ADD COLUMN canonical_of TEXT;
-- canonical_of points at the page this row is a phantom of, NULL when
-- the row is itself canonical.
CREATE INDEX idx_pages_canonical_of ON pages(canonical_of) WHERE canonical_of IS NOT NULL;
```
Then:
- `resolveEntitySlug` follows `canonical_of` transparently: if it lands on a
phantom page, return its canonical.
- `writeFactsToFence` follows `canonical_of` BEFORE picking a target path.
- Search (`hybridSearch`) hides phantom pages from results (already in the
visibility chain via `deleted_at`).
- Migration becomes a SQL UPDATE: `UPDATE pages SET canonical_of = $canonical
WHERE slug = $phantom_slug AND source_id = $source_id`.
- The markdown file stays on disk as a tombstone with frontmatter
`canonical_of: people/alice-example` until the operator deletes it manually
(no destructive command needed).
- **Code size:** schema migration + ~20 lines per affected callsite (resolver,
fence-write, search). Maybe 300 lines total.
- **Risk:** medium (touches schema + multiple callsites)
- **Operator burden:** zero
- **Best when:** the brain has thousands of phantoms or wants phantom-as-
first-class concept for other reasons (e.g. alias support).
**This option also unlocks:** entity aliases (`canonical_of` becomes "alias
of"). User can have `alice.md` with `canonical_of: people/alice-example` as a
deliberate redirect for legacy URLs. The phantom-fix becomes a special case
of a general alias system.
### Recommendation among the three
Build **Beta** first. It's the smallest change that solves the actual problem,
reuses existing infrastructure, and runs automatically. The bug farm went away
the moment the reconciliation concern moved into the existing reconcile path.
Iterate to **Gamma** if/when alias support is needed for other reasons. The
schema column is small enough that adding it later is fine — `canonical_of`
defaults NULL and only the phantom-redirect callsite needs to set it.
Skip **Alpha** unless the operator explicitly wants manual control.
---
## What's recoverable from PR #1010's iteration
Even though the implementation is being scrapped, the codex iteration found
real bugs that a future implementation MUST handle. Treat the round-by-round
commit messages as a regression checklist:
- **Round 9 (markers):** the fence markers are `<!--- gbrain:facts:begin -->`
and `<!--- gbrain:facts:end -->`, NOT `<!-- facts -->`. They live as
exported constants in `src/core/facts-fence.ts:53-54`. Use them.
- **Round 9 (clamp):** `listFactsByEntity` clamps `limit` at MAX_SEARCH_LIMIT
(100). For unbounded reads, go through raw SQL.
- **Round 10 (valid_until):** `FenceInputFact.validUntil` is now part of the
fence-write contract. Carry it through migrations.
- **Round 12 (embeddings):** postgres-js returns pgvector embeddings as text
strings; PGLite returns Float32Array directly. Normalize via
`tryParseEmbedding` from `src/core/utils.ts`.
- **Round 13 (source resolution):** any operator command must honor the
4-tier resolveSourceId chain.
- **Round 14 (stale compiled_truth):** writeFactsToFence does NOT refresh
`pages.compiled_truth`. The next extract_facts cycle will reconcile from
the markdown, but if anything reads compiled_truth between writeFactsToFence
and the next cycle, it sees stale state.
- **Round 17 (DB-only canonical):** canonical pages can exist in the DB via
MCP `put_page` without ever having a .md file. Any code that calls
writeFactsToFence on them must materialize the body from DB first.
- **Round 18 (timeline column):** `pages.timeline` is a separate column. Stub
detection must read both compiled_truth + timeline.
- **Round 22 (fuzzy precedence):** prefix expansion should NOT short-circuit
fuzzy when no bare slug exists. The "Liz/Elizabeth" case.
- **Round 27 + 29 + 30 (fence drift):** the fence is the system of record. Any
operation that mutates DB rows MUST verify fence/DB consistency first, in
both directions, including tuple-content comparison when counts match.
The stripped `merge-phantoms.ts` (last good version is commit `7f7a080f`)
is a worked example of EVERY one of these gotchas. Read it before building
Option Beta — not to copy, but as a regression checklist.
---
## Code pointers — what was stripped, what stayed
**Stripped from PR #1010 (will not land):**
- `src/commands/merge-phantoms.ts` — the entire 600-line command
- `test/merge-phantoms.test.ts` — 31 tests
- `src/cli.ts` entries: `CLI_ONLY`, `CLI_ONLY_SELF_HELP`,
`THIN_CLIENT_REFUSED_COMMANDS`, `THIN_CLIENT_REFUSE_HINTS`, the dispatch
case, and the help text line
**Kept (lands with PR #1010):**
- `src/core/entities/resolve.ts` — full resolver with prefix expansion, stub
detection, real-page preservation. ALL of this is independently valuable.
- `src/core/facts/fence-write.ts` — stub-guard (rounds 1, 24, 26) and the
DB-materialize path (round 18).
- `src/core/facts/backstop.ts` — dropped-fact audit (round 2 P1).
- `src/core/facts/dropped-audit.ts` — JSONL audit log infrastructure.
- `test/entity-resolve.test.ts` — 33 tests for resolver behavior.
- `entities.prefix_expansion_dirs` config key.
**Useful primitives (kept, intentionally exported for future Option Beta):**
- `resolve.ts:tryPrefixExpansion(engine, source_id, token, opts?)` — search
configured directories for prefix-match candidates.
- `resolve.ts:stubBodyChars(compiled_truth)` — detect v0.34.5 stub shape.
- `resolve.ts:isStubBody(compiled_truth)` — boolean wrapper.
- `resolve.ts:PHANTOM_STUB_MAX_BODY_CHARS` — threshold constant (0).
- `resolve.ts:getPrefixExpansionDirs()` — config-driven resolver dir list.
A future Option Beta implementation will likely use all five.
---
## Open questions for the future implementer
1. **Should phantom redirect happen during extract_facts (autopilot-time) or
eagerly during resolveEntitySlug (write-time)?** Beta proposes the former
so the heavy lifting happens in batch and gets cycle-level reporting.
Write-time would route facts AROUND the phantom without ever migrating
the page — different semantics.
2. **What's the right UX for ambiguous canonical?** When `alice` matches
both `people/alice-example` AND `people/alice-other`, what happens?
merge-phantoms skipped with `ambiguous`. The plan-eng-review suggested
surfacing this for operator resolution. A redirect-during-cycle approach
could log to `~/.gbrain/audit/phantom-ambiguous.jsonl` and continue.
3. **What about phantoms in non-default sources?** The current PR has source
isolation (the `resolveSourceId` chain), but a multi-source brain might
have the phantom in source A and the canonical in source B (mounted brain).
Cross-source redirect is out of scope for v0.34.5 but worth thinking about.
4. **Should Option Beta also handle phantom links?** The `links` table has
`from_page_id` / `to_page_id` referencing the phantom row. After redirect,
those need to point at the canonical. Easy SQL but it needs to happen.
5. **What about `find_orphans` / `gbrain doctor` reporting?** A redirect-aware
doctor check could surface "N phantom pages pending redirect" so operators
know what's coming.
---
## How to pick this up
A future agent doing this work should:
1. Read this doc top to bottom.
2. Read the round-by-round commit messages on PR #1010's commits between
`c861b43a` and `7f7a080f` — they're a regression checklist.
3. Decide Alpha / Beta / Gamma after a real `/plan-eng-review` on the
abstraction question. **Do not** start from the stripped
`merge-phantoms.ts` and try to clean it up. That code is the wrong
shape; rewriting it as Option Beta is faster than refactoring it.
4. If choosing Option Beta, the test surface should pin every regression
in the cascade table above. The cascade table is the test backlog.
5. Land the rewrite as its own PR, not bolted onto a resolver fix.
---
## Lessons learned (about the iteration, not the bug)
This isn't an indictment of any particular decision. The cascade was a
predictable outcome of three things compounding:
1. **The plan-eng-review user-upgraded D7 from "follow-up" to "build it now."**
That decision turned a small PR into a large one. Future plans should
resist this — destructive operator commands should ALWAYS be follow-ups,
not riders on the fix that motivated them.
2. **The chosen abstraction duplicated existing infrastructure.** The fence
is the system of record. Any code that mutates fence + DB independently
has to re-implement reconciliation. The cascade was the cost of that
duplication.
3. **Codex review is brutally thorough.** Each round caught a real issue.
The bugs WERE in the new code. But codex can't tell you "this whole
abstraction is wrong" — it can only point at specific failure modes.
The meta-insight required stepping out of the loop.
For the next destructive cleanup command in this codebase: do the design
work BEFORE the implementation. Make the reviewer answer "is this the right
abstraction?" before they're asked to review "does this code work?"
+17
View File
@@ -106,6 +106,23 @@ export interface GBrainConfig {
oauth_client_id: string;
oauth_client_secret?: string;
};
/**
* v0.34.5 — entity-resolver tuning. Controls which slug directories
* the prefix-expansion step (`src/core/entities/resolve.ts`) walks
* when canonicalizing a bare first-name reference like "Alice" into
* `people/alice-example`. The first matching directory wins (after the
* connection-count tiebreak inside that directory).
*
* Default (when unset): `['people', 'companies', 'deals', 'topics']` —
* matches the stub-guard's recognized prefix set in
* `src/core/facts/fence-write.ts`. Override to support custom entity
* schemas (`['funds', 'advisors']` etc.) once the rest of the system
* grows to recognize them. Order matters: list higher-priority dirs first.
*/
entities?: {
prefix_expansion_dirs?: string[];
};
}
/**
+314 -11
View File
@@ -9,9 +9,26 @@
* Pure helper; the engine layer is the data dependency injected by callers.
* Lives under `src/core/entities/` so signal-detector can reuse it for the
* Sonnet pass too without circular import through facts/.
*
* v0.34.5 — added a prefix-expansion step between fuzzy match and
* slugify fallback. Bare first names like "Alice" scored too low on
* pg_trgm (short strings have terrible trigram overlap), so they fell
* through to slugify("Alice") → "alice", which then spawned a phantom
* `people/alice.md` stub instead of resolving to an existing
* `people/alice-example` page. The fix queries `slug LIKE 'people/X-%'`
* (then `companies/X-%`, etc., per the configured dir list) when fuzzy
* fails on a single-word bare name, and uses connection count
* (links + chunks) as the tiebreaker when multiple candidates match.
*
* The dir list is config-driven via `entities.prefix_expansion_dirs`
* (see `src/core/config.ts`). Default covers the four directories the
* stub-guard recognizes; custom brains override to support funds/,
* advisors/, etc. See plan `mossy-popping-crown.md` D2.
*/
import type { BrainEngine } from '../engine.ts';
import { isUndefinedColumnError } from '../utils.ts';
import { loadConfig } from '../config.ts';
/**
* Canonicalize a free-form entity reference to a page slug.
@@ -21,7 +38,10 @@ import type { BrainEngine } from '../engine.ts';
* exact pages.slug row in this source), return it untouched.
* 2. Try fuzzy match against pages.slug + pages.title within the source
* (case-insensitive). Pick the highest-trgm-score match if any.
* 3. Fall back to a deterministic slugify: lowercase-no-spaces with
* 3. Prefix-expansion match for bare single-token names: walk each
* configured entity directory (`entities.prefix_expansion_dirs`) and
* query `<dir>/<token>-%`. Highest-connection wins.
* 4. Fall back to a deterministic slugify: lowercase-no-spaces with
* hyphen-collapse. NOT prefixed with a directory — caller decides
* whether to prefix `people/`, `companies/`, etc.
*
@@ -37,22 +57,255 @@ export async function resolveEntitySlug(
const trimmed = raw.trim();
if (!trimmed) return null;
// 1. Exact match on slug. If raw already looks like a slug (or matches
// a row exactly), use it.
// 1. Bare-name STUB override (codex rounds 7 + 8 + 22): when the
// bare slug EXISTS as a stub-shaped page (a v0.34.5 phantom),
// redirect to the canonical via prefix expansion. This is the
// ONLY case where prefix expansion runs before exact/fuzzy —
// we want to stop fact-writes from accumulating on the phantom.
//
// Round-22 calibration: when the bare slug is MISSING (no page
// at all), prefix expansion runs LATER (step 4) so fuzzy gets
// a chance first. Otherwise a bare reference like "Liz" would
// redirect to `people/liz-smith` even when `people/elizabeth-
// example` has title 'Liz' (a much better fuzzy match).
if (isBareName(trimmed)) {
const token = slugify(trimmed);
const bareBody = await tryExactSlugBody(engine, source_id, token);
if (bareBody !== 'missing') {
if (isStubBody(bareBody)) {
// Phantom-shaped bare slug exists — redirect to canonical.
const expanded = await tryPrefixExpansion(engine, source_id, token);
if (expanded) return expanded;
// Prefix expansion found nothing; fall through to fuzzy/etc.
} else {
// Real bare page (intentional top-level entity). Return the
// token NOW so a capitalized input like `Alice` doesn't bounce
// through fuzzy/prefix expansion and get misrouted to a
// sibling `people/alice-*` page. Codex round-25 P2.
return token;
}
}
// bareBody === 'missing': no page yet. Fall through to exact,
// fuzzy, and catch-all prefix expansion (in that order).
}
// 2. Exact match on slug. Catches prefixed slugs (`people/alice-example`)
// that the caller passed verbatim, and intentional bare slugs
// that step 1 declined to override.
if (looksLikeSlug(trimmed)) {
const exact = await tryExactSlug(engine, source_id, trimmed);
if (exact) return exact;
}
// 2. Fuzzy match against existing pages within the source. Match either
// on slug fragment or on title.
// 3. Fuzzy match against existing pages within the source. Match either
// on slug fragment or on title. Title matches are how "Liz" finds
// `people/elizabeth-example` when that page's title is 'Liz'.
const fuzzy = await tryFuzzyMatch(engine, source_id, trimmed);
if (fuzzy) return fuzzy;
// 3. Fallback: deterministic slugify.
// 4. Bare-name catch-all prefix expansion: the bare slug doesn't
// exist at all (step 1 saw 'missing'), exact didn't match, and
// fuzzy didn't find a strong title hit. Try prefix expansion as
// a last resort so first-name references like "Jared" (no page,
// no fuzzy hit, low pg_trgm score) still land on
// `people/jared-friedman` instead of slugifying to a phantom.
if (isBareName(trimmed)) {
const expanded = await tryPrefixExpansion(engine, source_id, slugify(trimmed));
if (expanded) return expanded;
}
// 5. Fallback: deterministic slugify. NOT prefixed — caller decides.
return slugify(trimmed);
}
/**
* "Bare name" detector — true when the input is a single word with no
* slash, no embedded prefix marker, and slugifies to a non-empty token.
* Multi-word inputs (e.g. "Alice Example") are handled by fuzzy match;
* this gate only fires for short first-name-shaped tokens.
*/
function isBareName(raw: string): boolean {
if (raw.includes('/')) return false;
// One-token input. Whitespace-tokenize: "Alice" → 1, "Alice Example" → 2.
const tokens = raw.trim().split(/\s+/).filter(Boolean);
if (tokens.length !== 1) return false;
const slug = slugify(raw);
if (!slug) return false;
// Reject hyphenated multi-token slugs like "alice-example" — those
// should hit the exact-slug or fuzzy path, not prefix expansion.
if (slug.includes('-')) return false;
return true;
}
/**
* Default prefix-expansion directories. Covers the four entity types
* the stub-guard recognizes in `src/core/facts/fence-write.ts` plus
* `concepts/` which is the canonical home for `type: concept` pages
* across gbrain docs and example schemas (e.g. `concepts/rag`,
* `concepts/agentic-workflows`). Bare-name references like "RAG" or
* "Bitcoin" therefore resolve out of the box. Override per-brain via
* the `entities.prefix_expansion_dirs` config key.
*/
export const DEFAULT_PREFIX_EXPANSION_DIRS = ['people', 'companies', 'deals', 'topics', 'concepts'] as const;
/**
* Body-content threshold — anything above this is treated as a real
* user page rather than a v0.34.5-era stub.
*
* Threshold is 0 (codex round-11 P2): stubs from `stubEntityPage` in
* fence-write.ts produce exactly `# Title\n` after the frontmatter,
* which strips to "" via the regex chain below. Any non-empty
* remainder means the user wrote something — even one sentence — and
* the page should be preserved.
*
* A previous 50-char threshold misclassified terse hand-written pages
* (e.g. `# RAG` + a single sentence). The strict-zero version flips
* the contract: "stub iff nothing-but-stub-shape remains."
*/
export const PHANTOM_STUB_MAX_BODY_CHARS = 0;
/**
* Strip frontmatter, H1 title, and the v0.32.2 facts-fence MARKERS
* (not the heading text alone) from a `compiled_truth` body. Returns
* the trimmed remainder.
*
* Codex rounds 6 + 7 + 8 calibration:
* - Round 6: facts fence inflates body length on fact-bearing
* phantoms; must be excluded so the stub detector catches them.
* - Round 7: timeline content is NOT migrated by merge-phantoms;
* must be preserved in the body count so pages with a populated
* Timeline trip not_a_stub.
* - Round 8: strip ONLY the canonical `<!-- facts -->` ...
* `<!-- /facts -->` fence pair (and an immediately-preceding
* `## Facts` heading if and only if it's paired with the fence
* markers). NEVER strip arbitrary content under a `## Facts`
* heading without machine markers — that's user-authored prose.
*
* A v0.34.5 stub returns ~0 chars; a fact-bearing stub does too
* (the fence between machine markers strips out); a real page with
* user-authored content under any heading returns hundreds.
*/
export function stubBodyChars(compiledTruth: string | null | undefined): number {
if (!compiledTruth) return 0;
const stripped = compiledTruth
.replace(/^---\n[\s\S]*?\n---\n?/, '')
.replace(/^#\s+.+\r?\n?/m, '')
// Strip the canonical machine-generated facts fence. Markers MUST
// match the constants in src/core/facts-fence.ts exactly —
// `<!--- gbrain:facts:begin -->` / `<!--- gbrain:facts:end -->`.
// Codex round-9 P1 #1 caught a marker mismatch: an earlier
// version used the fictional `<!-- facts -->` shape which never
// matched real fences, so fact-bearing phantoms slipped past
// not_a_stub and merge-phantoms skipped them. The optional
// preceding `## Facts` heading is consumed too so the auto-
// generated section disappears cleanly. A user-authored
// `## Facts` heading WITHOUT the machine markers is preserved.
.replace(/(?:^##\s*Facts\s*\n+)?<!---\s*gbrain:facts:begin\s*-->[\s\S]*?<!---\s*gbrain:facts:end\s*-->\n?/m, '')
.trim();
return stripped.length;
}
/**
* Predicate wrapper around `stubBodyChars` for callers that don't
* need the raw char count. True when the body looks like a v0.34.5-era
* stub (or an empty page).
*/
export function isStubBody(compiledTruth: string | null | undefined): boolean {
return stubBodyChars(compiledTruth) <= PHANTOM_STUB_MAX_BODY_CHARS;
}
/**
* Resolve the active prefix-expansion dir list. Config-first, default-fallback.
* Exported for tests and for callers that want to inspect the configured set.
*/
export function getPrefixExpansionDirs(): readonly string[] {
const cfg = loadConfig();
const fromConfig = cfg?.entities?.prefix_expansion_dirs;
if (Array.isArray(fromConfig) && fromConfig.length > 0) {
// Filter to non-empty strings; anything else silently drops so a bad
// config entry can't 500 the resolver. Matches the v0.31.12
// model-tier resolver's "fall back to defaults on bad input" posture.
const cleaned = fromConfig.filter((s): s is string => typeof s === 'string' && s.length > 0);
if (cleaned.length > 0) return cleaned;
}
return DEFAULT_PREFIX_EXPANSION_DIRS;
}
/**
* Look up pages whose slug is `<dir>/<token>` OR starts with
* `<dir>/<token>-` for each configured entity directory. When multiple
* candidates match within a directory, pick the one with the highest
* connection count (links_in + links_out + chunk count) — the most-
* mentioned entity is the most likely canonical target for a bare-name
* reference. When no candidates match in any directory, returns null
* and the caller falls through to slugify.
*
* Also exported so `gbrain merge-phantoms` can run the prefix step in
* isolation: resolveEntitySlug's exact-slug short-circuit would match
* a phantom against itself before reaching prefix expansion. `token`
* should be a pre-slugified single word (e.g. `'alice'`, not `'Alice'`).
*
* The connection-count subqueries are correlated (per page row) rather
* than full table aggregates so the cost scales with the number of
* slug-matching candidates (typically 1-3), not with brain size.
* Indexes used: `idx_links_to`, `idx_links_from`, `idx_chunks_page`.
*/
export async function tryPrefixExpansion(
engine: BrainEngine,
source_id: string,
token: string,
opts: { dirs?: readonly string[] } = {},
): Promise<string | null> {
// Callers can constrain the search to a specific subset of directories
// (e.g. merge-phantoms passes a single-element list matching the
// phantom's entity type to prevent cross-type mismerges). Defaults to
// the full configured set via getPrefixExpansionDirs.
const searchDirs = opts.dirs ?? getPrefixExpansionDirs();
for (const dir of searchDirs) {
// Match BOTH `<dir>/<token>` exactly AND `<dir>/<token>-%` (hyphenated
// variants). Without the exact-match leg, a brain whose canonical slug
// is `companies/acme` (no hyphen-suffix) would never resolve bare
// "Acme" — codex caught this in the third /codex review pass. The
// ORDER BY tiebreak still applies across both candidate shapes.
const exact = `${dir}/${token}`;
const hyphen = `${dir}/${token}-%`;
try {
const rows = await engine.executeRaw<{
slug: string;
connection_count: number;
}>(
// Correlated subqueries: each per p.id, hitting the existing
// (to_page_id), (from_page_id), (page_id) indexes. The outer
// WHERE clause uses `slug = $2 OR slug LIKE $3` so both the
// exact and the prefix candidates feed the same tiebreak.
`SELECT p.slug,
((SELECT COUNT(*) FROM links WHERE to_page_id = p.id) +
(SELECT COUNT(*) FROM links WHERE from_page_id = p.id) +
(SELECT COUNT(*) FROM content_chunks WHERE page_id = p.id))
AS connection_count
FROM pages p
WHERE p.source_id = $1
AND p.deleted_at IS NULL
AND (p.slug = $2 OR p.slug LIKE $3)
ORDER BY connection_count DESC, p.slug ASC
LIMIT 1`,
[source_id, exact, hyphen],
);
if (rows.length === 0) continue;
return rows[0].slug;
} catch (err) {
// Narrow probe: column-missing on legacy brains (most likely
// `deleted_at` pre-v0.26.5) falls through to the next directory.
// Genuine failures (pool exhaustion, lock timeout, network blip)
// propagate so they're visible instead of silently masquerading
// as "no prefix match." See v0.26.9 D14 in CLAUDE.md.
if (isUndefinedColumnError(err, 'deleted_at')) continue;
throw err;
}
}
return null;
}
function looksLikeSlug(s: string): boolean {
// Slug shape: lowercase letters/digits with at least one slash OR matches
// [a-z0-9-]+ exactly. Anything with whitespace or capital letters fails.
@@ -61,6 +314,48 @@ function looksLikeSlug(s: string): boolean {
return /^[a-z0-9/_-]+$/.test(s);
}
/**
* Probe the body of a bare-slug exact match without returning the
* slug. Returns:
* - 'missing' when no page exists with that exact slug
* - the body string when a page exists (may be empty or a stub)
*
* Used by step 1 of resolveEntitySlug to distinguish phantom-shaped
* bare slugs (where the canonical should win) from real top-level
* pages (where the bare slug should win). The 3-value return shape
* makes the caller's intent explicit (missing vs. stub vs. real).
*/
async function tryExactSlugBody(
engine: BrainEngine,
source_id: string,
candidate: string,
): Promise<string | 'missing'> {
try {
const rows = await engine.executeRaw<{ compiled_truth: string | null; timeline: string | null }>(
`SELECT compiled_truth, timeline FROM pages
WHERE source_id = $1 AND slug = $2 AND deleted_at IS NULL
LIMIT 1`,
[source_id, candidate],
);
if (rows.length === 0) return 'missing';
// Codex round-18 P2: include the timeline column so a real
// top-level page whose substantive content lives in
// pages.timeline isn't classified as stub-shaped by the
// resolver's bare-name-override gate. The downstream
// `isStubBody(body)` check will see a non-empty body whenever
// EITHER compiled_truth has real content OR the timeline column
// does. Concatenated via a separator so a single non-empty
// column is enough to defeat the stub heuristic.
const compiled = rows[0].compiled_truth ?? '';
const timeline = (rows[0].timeline ?? '').trim();
if (timeline.length > 0) return `${compiled}\n\n## Timeline\n\n${timeline}`;
return compiled;
} catch (err) {
if (isUndefinedColumnError(err, 'deleted_at')) return 'missing';
throw err;
}
}
async function tryExactSlug(
engine: BrainEngine,
source_id: string,
@@ -72,8 +367,11 @@ async function tryExactSlug(
[source_id, candidate],
);
if (rows.length > 0) return rows[0].slug;
} catch {
// Defensive: fail open. Caller still gets a slug from the fallback.
} catch (err) {
// Legacy brain without `deleted_at` column — fall through to slugify
// fallback. Other failures propagate.
if (isUndefinedColumnError(err, 'deleted_at')) return null;
throw err;
}
return null;
}
@@ -107,9 +405,14 @@ async function tryFuzzyMatch(
[source_id, lc, fragment],
);
if (rows.length > 0 && rows[0].score >= 0.4) return rows[0].slug;
} catch {
// pg_trgm functions might not be available on every engine config;
// fall through to slugify.
} catch (err) {
// pg_trgm functions (`similarity`, `%`) might not be available on
// every engine config; same for `deleted_at` on legacy brains.
// Either fall through to the prefix-expansion + slugify path.
if (isUndefinedColumnError(err, 'deleted_at')) return null;
const msg = err instanceof Error ? err.message : String(err);
if (/similarity|operator does not exist|function similarity/i.test(msg)) return null;
throw err;
}
return null;
}
+36
View File
@@ -40,6 +40,7 @@
import type { BrainEngine, FactInsertStatus, NewFact } from '../engine.ts';
import { isFactsBackstopEligible } from './eligibility.ts';
import type { PageType } from '../types.ts';
import { appendDroppedFactAudit } from './dropped-audit.ts';
export interface FactsBackstopCtx {
engine: BrainEngine;
@@ -455,6 +456,41 @@ async function runPipelineWithBody(
// would write rows to a DB index whose fence is broken.
continue;
}
if (result.stubGuardBlocked) {
// v0.34.5 (codex P1 follow-up): writeFactsToFence refused to spawn a
// phantom unprefixed entity page (e.g. `zoolander.md` at brain root).
// The pre-codex-review version of this branch inserted these facts
// via `engine.insertFact` to avoid silent data loss, but that
// produced rows shaped like pre-v0.32.2 legacy migration rows
// (`row_num IS NULL AND entity_slug IS NOT NULL`), and the v0.32.2
// extract_facts cycle phase guard at `src/core/cycle/extract-facts.ts:83`
// refuses to run reconciliation while any such rows exist. One
// unknown bare entity reference silently broke autopilot extract_facts
// forever.
//
// Fix: log loudly + append a structured entry to
// `~/.gbrain/facts.dropped.jsonl` for operator recovery. The fact
// text is preserved verbatim so a future `gbrain replay-dropped` tool
// can re-process once the canonical entity page exists.
for (const { f } of group) {
// eslint-disable-next-line no-console
console.warn(
`[facts] dropping fact (no canonical for unprefixed slug=${slug}): "${f.fact.slice(0, 80)}${f.fact.length > 80 ? '…' : ''}". Logged to ~/.gbrain/facts.dropped.jsonl for recovery.`,
);
appendDroppedFactAudit({
source_id: ctx.sourceId,
phantom_slug: slug,
reason: 'stub_guard_blocked',
fact: f.fact,
kind: f.kind ?? null,
notability: f.notability ?? null,
visibility,
source: f.source,
source_session: f.source_session ?? null,
});
}
continue;
}
if (result.legacyFallback) {
// Defensive: writeFactsToFence sees localPath as null. We
// checked above so this shouldn't fire — log loud + skip.
+75
View File
@@ -0,0 +1,75 @@
/**
* v0.34.5 — facts.dropped.jsonl operator-recoverable audit trail.
*
* When the stub-guard backstop fallback fires (an unprefixed bare-name
* entity reference like "Zoolander" that couldn't resolve to any
* canonical people/companies/deals/topics page), we used to insert a
* DB-only fact row via `engine.insertFact` to avoid silent data loss.
* That created rows shaped like pre-v0.32.2 legacy migration rows
* (`row_num IS NULL AND entity_slug IS NOT NULL`), which the v0.32.2
* extract_facts cycle phase guard at `src/core/cycle/extract-facts.ts`
* REFUSES to reconcile around. Net: one unknown bare entity reference
* silently broke the autopilot reconciliation pass forever (codex P1
* caught this in /codex review post-implementation).
*
* Fix: don't insert. Log to stderr (operator visibility) AND append a
* structured entry to `~/.gbrain/facts.dropped.jsonl` so a follow-up
* tool can re-process the dropped facts once the user creates the
* canonical entity pages. The fact text is preserved verbatim — no
* lossy summarization — so recovery is lossless.
*
* Schema (per line, JSON object):
* - ts: ISO 8601 timestamp
* - source_id: brain source
* - phantom_slug: the unresolved bare slug (e.g. 'zoolander')
* - reason: 'stub_guard_blocked' (only reason today; field is open
* for future operator-visibility surfaces)
* - fact: the verbatim fact text
* - kind: NewFact kind (fact / event / preference / commitment / belief)
* - notability: high / medium / low
* - visibility: private / world
* - source: provenance string (e.g. 'mcp:put_page', 'sync:import')
* - source_session: session id if present
*
* Best-effort: every write is wrapped in try/catch and never throws
* back into the caller. The hot-path facts pipeline must not break
* because the audit dir is read-only / disk full / etc.
*/
import { appendFileSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { gbrainPath } from '../config.ts';
export interface DroppedFactEntry {
source_id: string;
phantom_slug: string;
reason: 'stub_guard_blocked';
fact: string;
kind: string | null;
notability: 'high' | 'medium' | 'low' | null;
visibility: 'private' | 'world';
source: string;
source_session: string | null;
}
const AUDIT_PATH = (): string => gbrainPath('facts.dropped.jsonl');
export function appendDroppedFactAudit(entry: DroppedFactEntry): void {
try {
const path = AUDIT_PATH();
mkdirSync(dirname(path), { recursive: true });
const line = JSON.stringify({
ts: new Date().toISOString(),
...entry,
});
appendFileSync(path, `${line}\n`, 'utf-8');
} catch (err) {
// Best-effort: log to stderr but never throw back into the facts
// pipeline. The fact is already lost; the audit log is a recovery
// aid, not a critical path.
// eslint-disable-next-line no-console
console.warn(
`[facts.dropped] couldn't append audit entry: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
+113 -4
View File
@@ -63,6 +63,15 @@ export interface FenceInputFact {
/** Defaults to 1.0 when undefined (matches engine.insertFact behavior). */
confidence?: number;
validFrom?: Date;
/**
* Optional time-bound expiration for the fact (e.g. "valid through
* 2026-08-31"). When set, the rendered fence row carries the
* date so re-fence operations preserve the validity window
* (codex round-10 P2 fix). Defaults to undefined for normal
* extraction paths where the LLM never produces an explicit upper
* bound.
*/
validUntil?: Date;
embedding: Float32Array | null;
sessionId: string | null;
}
@@ -76,6 +85,16 @@ export interface FenceWriteResult {
legacyFallback?: true;
/** True when fence parse-validate failed; rows were NOT inserted, .tmp quarantined. */
fenceWriteFailed?: true;
/**
* True when the stub-creation guard refused to spawn a phantom entity
* page for an unprefixed bare slug (e.g. `jared` with no `people/`
* directory). Rows were NOT inserted; the caller is expected to route
* the facts to the legacy DB-only path so they aren't silently dropped.
*
* This is the v0.34.5 fix for the entity-resolution bug where `"Jared"`
* fell through resolution and produced a top-level `jared.md` stub.
*/
stubGuardBlocked?: true;
}
const FAILURE_LOG_PATH = (): string => gbrainPath('facts.write_failures.jsonl');
@@ -161,14 +180,101 @@ export async function writeFactsToFence(
return withPageLock(
target.slug,
async () => {
// 1. Read existing body or stub-create.
// 1. Read existing body, materialize from DB, or stub-create.
let body: string;
if (existsSync(filePath)) {
body = readFileSync(filePath, 'utf-8');
// Codex round-24 + round-26 stub-guard: an existing phantom
// .md file on disk should block fact appends so future facts
// don't keep accumulating on a v0.34.5-era phantom. But the
// check must also consult the DB row — if disk is stale-stub
// while pages.compiled_truth has real content (e.g. a
// subsequent put_page / MCP update never re-synced to disk),
// the page is intentional and the fact must land.
if (!target.slug.includes('/')) {
const { isStubBody } = await import('../entities/resolve.ts');
if (isStubBody(body)) {
// Disk says stub. Check the DB body too before dropping.
const dbPage = await engine.getPage(target.slug, {
sourceId: target.sourceId,
});
const dbStub =
dbPage === null ||
(isStubBody(dbPage.compiled_truth ?? '') &&
(dbPage.timeline ?? '').trim().length === 0);
if (dbStub) {
// eslint-disable-next-line no-console
console.warn(
`[facts] refusing to append to existing stub-shaped phantom slug=${target.slug} (file present, body stub-shape, DB also empty/stub). Routing to dropped-audit.`,
);
return { inserted: 0, ids: [], stubGuardBlocked: true };
}
// DB has real content; disk is stale. Materialize the DB
// body over the stale stub so the fence write appends to
// real content rather than a stub.
const { serializeMarkdown } = await import('../markdown.ts');
const tags = Array.isArray(dbPage.frontmatter?.tags)
? (dbPage.frontmatter!.tags as string[])
: [];
body = serializeMarkdown(
(dbPage.frontmatter as Record<string, unknown>) ?? {},
dbPage.compiled_truth ?? '',
dbPage.timeline ?? '',
{ type: dbPage.type, title: dbPage.title, tags },
);
}
// Body is non-stub (intentional bare page) — append below.
}
} else {
// Stub-create the parent directory if it doesn't exist.
// The stub-creation guard prevents v0.34.5-era phantom entity
// pages (`jared.md`, `alice.md` at brain root) from being
// spawned. But there's a benign case: a legitimate top-level
// DB page (created via MCP put_page or importFromContent on a
// source with local_path but never synced to disk). For that
// case we materialize the DB body to disk BEFORE the fence
// append, so the user's content survives intact.
//
// Codex round-17 P2 + round-18 P1 — distinguish:
// - Real DB page with non-stub body → materialize + append.
// - DB page with stub body OR no DB row at all → fire guard.
const existingPage = await engine.getPage(target.slug, {
sourceId: target.sourceId,
});
const { isStubBody } = await import('../entities/resolve.ts');
const hasRealContent =
existingPage !== null &&
(!isStubBody(existingPage.compiled_truth ?? '') ||
(existingPage.timeline ?? '').trim().length > 0);
if (!hasRealContent && !target.slug.includes('/')) {
// No real DB body AND unprefixed slug — this would spawn a
// phantom. Caller routes to the dropped-audit log.
// eslint-disable-next-line no-console
console.warn(
`[facts] refusing to stub-create unprefixed entity page slug=${target.slug} — routing to legacy DB-only path. Provide a directory prefix (people/, companies/, etc.) to opt into fence writes.`,
);
return { inserted: 0, ids: [], stubGuardBlocked: true };
}
mkdirSync(dirname(filePath), { recursive: true });
body = stubEntityPage(target.slug);
if (existingPage && hasRealContent) {
// Materialize the existing DB body to disk so the fence
// write appends to the real content rather than a stub.
const { serializeMarkdown } = await import('../markdown.ts');
const tags = Array.isArray(existingPage.frontmatter?.tags)
? (existingPage.frontmatter!.tags as string[])
: [];
body = serializeMarkdown(
(existingPage.frontmatter as Record<string, unknown>) ?? {},
existingPage.compiled_truth ?? '',
existingPage.timeline ?? '',
{ type: existingPage.type, title: existingPage.title, tags },
);
} else {
// Prefixed slug with no DB row → legitimately stub-create
// a new entity page (the path that always worked).
body = stubEntityPage(target.slug);
}
}
// 2. Upsert each fact onto the fence in input order. row_num
@@ -176,6 +282,9 @@ export async function writeFactsToFence(
const assignedRowNums: number[] = [];
for (const f of facts) {
const validFromStr = (f.validFrom ?? new Date()).toISOString().slice(0, 10);
const validUntilStr = f.validUntil
? f.validUntil.toISOString().slice(0, 10)
: undefined;
const { body: updated, rowNum } = upsertFactRow(body, {
claim: f.fact,
kind: (f.kind ?? 'fact') as 'fact' | 'event' | 'preference' | 'commitment' | 'belief',
@@ -183,7 +292,7 @@ export async function writeFactsToFence(
visibility: f.visibility,
notability: f.notability ?? 'medium',
validFrom: validFromStr,
validUntil: undefined,
validUntil: validUntilStr,
source: f.source,
context: f.context ?? undefined,
});
+899
View File
@@ -0,0 +1,899 @@
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import {
resolveEntitySlug,
slugify,
getPrefixExpansionDirs,
DEFAULT_PREFIX_EXPANSION_DIRS,
} from '../src/core/entities/resolve.ts';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import type { BrainEngine } from '../src/core/engine.ts';
import { writeFactsToFence } from '../src/core/facts/fence-write.ts';
import { withEnv } from './helpers/with-env.ts';
/**
* v0.34.5 — entity resolution prefix expansion tests.
*
* Validates that bare first names resolve to existing pages via prefix
* expansion, preventing phantom stub creation.
*
* Privacy: all seed data uses canonical placeholders per CLAUDE.md
* "Privacy rule: scrub real names from public docs" — alice-example,
* bob-example, charlie-example, dave-example, acme-example. The
* bug being fixed is name-agnostic; slugify('Alice') exercises the
* same path that slugify('Jared') did pre-fix.
*
* Coverage matrix per plan mossy-popping-crown.md D5:
* - prefix expansion happy path (single + multi candidate)
* - tiebreak via connection_count DESC, slug ASC
* - companies/ prefix
* - links contribution to connection_count (not just chunks)
* - identical connection_count → slug ASC fallback
* - source-id isolation
* - config-driven dirs (entities.prefix_expansion_dirs)
* - integration regression: stub-guard → backstop → DB row, no markdown file
*/
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({ database_url: '' });
await engine.initSchema();
// Seed test pages. Two variants per first name so the tiebreak
// path has real candidates to choose between.
const pages = [
{ slug: 'people/alice-example', title: 'Alice Example', type: 'person' },
{ slug: 'people/charlie-example', title: 'Charlie Example', type: 'person' },
{ slug: 'people/charlie-ross-example', title: 'Charlie Ross Example', type: 'person' },
{ slug: 'people/bob-example', title: 'Bob Example', type: 'person' },
{ slug: 'people/bob-bankman-example', title: 'Bob Bankman Example', type: 'person' },
{ slug: 'people/dave-example', title: 'Dave Example', type: 'person' },
{ slug: 'companies/acme-example', title: 'Acme Example', type: 'company' },
{ slug: 'companies/acme-atlas-example', title: 'Acme Atlas Example', type: 'company' },
// Links-only candidate (no chunks) — exercises the links contribution
// to connection_count.
{ slug: 'people/eve-example', title: 'Eve Example', type: 'person' },
{ slug: 'people/eve-friend-example', title: 'Eve Friend Example', type: 'person' },
// Tiebreak candidates with identical connection counts — exercises
// the deterministic slug ASC secondary sort.
{ slug: 'people/frank-aaa-example', title: 'Frank Aaa Example', type: 'person' },
{ slug: 'people/frank-zzz-example', title: 'Frank Zzz Example', type: 'person' },
// Custom-dir candidate for config-driven test (`funds`).
{ slug: 'funds/founders-x-example', title: 'Founders X Example', type: 'concept' },
// Exact-prefix-match coverage (no hyphen suffix): `companies/glob` and
// `concepts/rag` are the canonical shape codex flagged the resolver
// would miss in its post-D9 review pass.
{ slug: 'companies/glob', title: 'Glob', type: 'company' },
{ slug: 'concepts/rag', title: 'RAG', type: 'concept' },
];
for (const p of pages) {
await engine.putPage(p.slug, {
type: p.type as any,
title: p.title,
compiled_truth: `# ${p.title}`,
frontmatter: { type: p.type, title: p.title, slug: p.slug },
}, { sourceId: 'default' });
}
// Give alice-example 10 chunks — exercises the single-best-match path.
await seedChunks(engine, 'people/alice-example', 10);
// bob-example > bob-bankman-example — tiebreak via chunk count.
await seedChunks(engine, 'people/bob-example', 20);
await seedChunks(engine, 'people/bob-bankman-example', 3);
// charlie-example > charlie-ross-example.
await seedChunks(engine, 'people/charlie-example', 15);
await seedChunks(engine, 'people/charlie-ross-example', 2);
// acme-example > acme-atlas-example.
await seedChunks(engine, 'companies/acme-example', 8);
await seedChunks(engine, 'companies/acme-atlas-example', 1);
// dave-example: single companion match, low chunks.
await seedChunks(engine, 'people/dave-example', 4);
// Links-only contribution: eve-example wins via inbound links rather
// than chunks. The UNIQUE constraint on (from_page_id, to_page_id,
// link_type, link_source, origin_page_id) means each (from, to, type)
// pair allows at most 4 distinct rows (link_source: markdown/frontmatter/
// manual/NULL). Seed multiple from-pages so eve accumulates enough
// inbound links to beat eve-friend-example deterministically.
const eve = await pageId(engine, 'people/eve-example');
const alice = await pageId(engine, 'people/alice-example');
const charlie = await pageId(engine, 'people/charlie-example');
const dave = await pageId(engine, 'people/dave-example');
const eveFriend = await pageId(engine, 'people/eve-friend-example');
if (eve && alice && charlie && dave) {
const sources: Array<string | null> = ['markdown', 'frontmatter', 'manual', null];
for (const from of [alice, charlie, dave]) {
for (const ls of sources) {
await engine.executeRaw(
`INSERT INTO links (from_page_id, to_page_id, link_type, link_source)
VALUES ($1, $2, 'mentions', $3)
ON CONFLICT DO NOTHING`,
[from, eve, ls],
);
}
}
}
// Eve-friend gets exactly one inbound link so it's a clear loser.
if (eveFriend && alice) {
await engine.executeRaw(
`INSERT INTO links (from_page_id, to_page_id, link_type, link_source)
VALUES ($1, $2, 'mentions', 'markdown')
ON CONFLICT DO NOTHING`,
[alice, eveFriend],
);
}
// Identical-count tiebreak: frank-aaa-example and frank-zzz-example
// each get 5 chunks. The resolver should deterministically pick
// frank-aaa-example (ASC).
await seedChunks(engine, 'people/frank-aaa-example', 5);
await seedChunks(engine, 'people/frank-zzz-example', 5);
// funds candidate gets 3 chunks so the config-driven test has data to match.
await seedChunks(engine, 'funds/founders-x-example', 3);
// Source-isolation setup: create a sibling source and put `people/alice-example`
// there too with WAY more chunks. The resolver in source 'default' must
// still return `people/alice-example` (the default's row), NOT leak to
// the high-chunk row in the other source.
await engine.executeRaw(
`INSERT INTO sources (id, name, config) VALUES ('other-src', 'other-src', '{}'::jsonb)
ON CONFLICT (id) DO NOTHING`,
[],
);
await engine.putPage('people/alice-example', {
type: 'person' as any,
title: 'Alice (other source)',
compiled_truth: `# Alice in other source`,
frontmatter: { type: 'person', title: 'Alice (other source)', slug: 'people/alice-example' },
}, { sourceId: 'other-src' });
await seedChunks(engine, 'people/alice-example', 50, 'other-src');
// Sanity: put a name only in other-src so it must NOT resolve in default.
await engine.putPage('people/grace-other-example', {
type: 'person' as any,
title: 'Grace (other source only)',
compiled_truth: `# Grace only in other source`,
frontmatter: { type: 'person', title: 'Grace (other source only)', slug: 'people/grace-other-example' },
}, { sourceId: 'other-src' });
await seedChunks(engine, 'people/grace-other-example', 20, 'other-src');
});
afterAll(async () => {
await engine.disconnect();
});
async function seedChunks(eng: PGLiteEngine, slug: string, count: number, sourceId = 'default'): Promise<void> {
const rows = await eng.executeRaw<{ id: string }>(
`SELECT id FROM pages WHERE slug = $1 AND source_id = $2`,
[slug, sourceId],
);
if (rows.length === 0) return;
const pid = rows[0].id;
for (let i = 0; i < count; i++) {
await eng.executeRaw(
`INSERT INTO content_chunks (page_id, chunk_index, chunk_text)
VALUES ($1, $2, $3)
ON CONFLICT (page_id, chunk_index) DO NOTHING`,
[pid, i, `Chunk ${i} about ${slug}`],
);
}
}
async function pageId(eng: PGLiteEngine, slug: string, sourceId = 'default'): Promise<string | null> {
const rows = await eng.executeRaw<{ id: string }>(
`SELECT id FROM pages WHERE slug = $1 AND source_id = $2 LIMIT 1`,
[slug, sourceId],
);
return rows[0]?.id ?? null;
}
describe('resolveEntitySlug — prefix expansion (v0.34.5)', () => {
it('fuzzy title match wins over prefix expansion when no bare slug exists (round-22 P2)', async () => {
// Codex round-22 P2: when a bare token is the TITLE of one
// entity (e.g. "Liz" on people/elizabeth-example) AND also a
// slug prefix of another (people/liz-smith), the resolver
// previously routed via prefix expansion → people/liz-smith
// (wrong; user meant Liz=Elizabeth). Fix: bare-name prefix
// expansion only short-circuits exact/fuzzy when there's a
// STUB-shaped bare slug to override. With no bare slug at all,
// fuzzy runs first.
await engine.putPage(
'people/elizabeth-fuzzy-example',
{
type: 'person' as any,
title: 'Liz',
compiled_truth: '# Liz (Elizabeth)',
frontmatter: { type: 'person', title: 'Liz', slug: 'people/elizabeth-fuzzy-example' },
},
{ sourceId: 'default' },
);
await seedChunks(engine, 'people/elizabeth-fuzzy-example', 50);
await engine.putPage(
'people/liz-smith-example',
{
type: 'person' as any,
title: 'Liz Smith',
compiled_truth: '# Liz Smith',
frontmatter: { type: 'person', title: 'Liz Smith', slug: 'people/liz-smith-example' },
},
{ sourceId: 'default' },
);
await seedChunks(engine, 'people/liz-smith-example', 5);
// "Liz" should fuzzy-match the title and return elizabeth, NOT
// prefix-expand to liz-smith.
const result = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'Liz');
expect(result).toBe('people/elizabeth-fuzzy-example');
});
it('prefers prefix expansion over an existing STUB-shaped unprefixed phantom (round-7 P2)', async () => {
// Codex round-7 P2 #1: when both the canonical `people/alice-example`
// AND an unprefixed STUB phantom `alice` exist, resolveEntitySlug
// must return the canonical, NOT exact-match the phantom and keep
// splitting facts onto it.
await engine.putPage(
'alice-phantom-test',
{
type: 'concept' as any,
title: 'alice-phantom-test',
compiled_truth: '# alice-phantom-test',
frontmatter: { type: 'concept', title: 'alice-phantom-test', slug: 'alice-phantom-test' },
},
{ sourceId: 'default' },
);
// The seed `people/alice-example` already exists from beforeAll.
// resolveEntitySlug('alice') must return the canonical, not the
// bare slug.
const result = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'alice');
expect(result).toBe('people/alice-example');
});
it('returns a real bare page for capitalized input without fuzzy/prefix detours (round-25 P2)', async () => {
// Codex round-25 P2: when pg_trgm is unavailable and the input
// is a capitalized real-bare-page name (`Alice` with `alice` real
// page), the old code fell through to fuzzy (null on no pg_trgm)
// then catch-all prefix expansion → people/alice-* (wrong; user
// meant the bare `alice` page). The fix returns the token NOW
// when bareBody is real (non-stub).
const realBody = '# Realbare\n\nIntentional top-level page with prose.';
await engine.putPage(
'realbare',
{
type: 'concept' as any,
title: 'Realbare',
compiled_truth: realBody,
frontmatter: { type: 'concept', title: 'Realbare', slug: 'realbare' },
},
{ sourceId: 'default' },
);
await engine.putPage(
'people/realbare-other-example',
{
type: 'person' as any,
title: 'Realbare Other Example',
compiled_truth: '# Realbare Other',
frontmatter: { type: 'person', title: 'Realbare Other Example', slug: 'people/realbare-other-example' },
},
{ sourceId: 'default' },
);
await seedChunks(engine, 'people/realbare-other-example', 50);
// Capitalized single-word bare name `Realbare`. isBareName→true,
// slugify('Realbare')→'realbare'. The bare `realbare` page exists
// and is real (non-stub). Must return 'realbare' from step 1
// directly — without bouncing through fuzzy/catch-all prefix
// expansion that could mis-route to people/realbare-*.
const result = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'Realbare');
expect(result).toBe('realbare');
});
it('preserves a bare page whose content lives in the timeline column (round-18 P2)', async () => {
// Codex round-18 P2: tryExactSlugBody must look at BOTH
// compiled_truth AND timeline when deciding whether a bare slug
// is real or stub-shaped. A real page with stubby
// compiled_truth + populated pages.timeline column would
// previously look stub-shaped and get overridden by prefix
// expansion.
await engine.executeRaw(
`INSERT INTO pages (source_id, slug, type, page_kind, title, compiled_truth, timeline, frontmatter, content_hash, updated_at)
VALUES ('default', 'tl-only-page', 'concept', 'markdown', 'TL Only Page', '# TL Only Page',
'- 2026-04-01: First milestone\n- 2026-05-01: Second milestone',
$1::jsonb, 'h', now())
ON CONFLICT (source_id, slug) DO UPDATE SET timeline = EXCLUDED.timeline`,
[JSON.stringify({ type: 'concept', title: 'TL Only Page', slug: 'tl-only-page' })],
);
await engine.putPage(
'concepts/tl-only-page-canonical',
{
type: 'concept' as any,
title: 'TL Only Page Canonical',
compiled_truth: '# canonical',
frontmatter: { type: 'concept', title: 'TL Only Page Canonical', slug: 'concepts/tl-only-page-canonical' },
},
{ sourceId: 'default' },
);
// The bare slug exists with a timeline-column body, so the
// resolver must NOT override to the canonical.
const result = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'tl-only-page');
expect(result).toBe('tl-only-page');
});
it('preserves a TERSE real top-level page (one sentence) (round-11 P2)', async () => {
// Codex round-11 P2: a 50-char threshold misclassifies terse but
// intentional pages (e.g. `# RAG` + a one-sentence note). The
// fix is threshold = 0 — only the literal stub shape (frontmatter
// + H1 + maybe an empty fence) is a stub. Any user content beyond
// that, however short, makes the page real.
await engine.putPage(
'rag-terse',
{
type: 'concept' as any,
title: 'RAG Terse',
compiled_truth: '# RAG Terse\n\nLook it up.',
frontmatter: { type: 'concept', title: 'RAG Terse', slug: 'rag-terse' },
},
{ sourceId: 'default' },
);
await engine.putPage(
'concepts/rag-terse-example',
{
type: 'concept' as any,
title: 'RAG Terse Example',
compiled_truth: '# RAG Terse Example',
frontmatter: { type: 'concept', title: 'RAG Terse Example', slug: 'concepts/rag-terse-example' },
},
{ sourceId: 'default' },
);
// The 10-char "Look it up." should be enough to keep the bare
// page intact even with the canonical candidate present.
const result = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'rag-terse');
expect(result).toBe('rag-terse');
});
it('preserves exact bare-slug match for a REAL top-level page (round-8 P2 #2)', async () => {
// Codex round-8 P2 #2: when a user has a legitimate top-level
// `rag-real` page with real body content AND a `concepts/rag-real`
// prefixed page, the bare `rag-real` must NOT be overridden by
// prefix expansion. The bare page is intentional, not a phantom.
const realBody = '# RAG Real\n\nThis is a real top-level page with intentional content. '.repeat(20);
await engine.putPage(
'rag-real',
{
type: 'concept' as any,
title: 'RAG Real',
compiled_truth: realBody,
frontmatter: { type: 'concept', title: 'RAG Real', slug: 'rag-real' },
},
{ sourceId: 'default' },
);
await engine.putPage(
'concepts/rag-real-example',
{
type: 'concept' as any,
title: 'RAG Real Example',
compiled_truth: '# RAG Real Example',
frontmatter: { type: 'concept', title: 'RAG Real Example', slug: 'concepts/rag-real-example' },
},
{ sourceId: 'default' },
);
// resolveEntitySlug('rag-real') must NOT redirect to concepts/...
const result = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'rag-real');
expect(result).toBe('rag-real');
});
it('resolves "Alice" to people/alice-example', async () => {
const result = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'Alice');
expect(result).toBe('people/alice-example');
});
it('resolves "alice" (lowercase) to people/alice-example', async () => {
const result = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'alice');
expect(result).toBe('people/alice-example');
});
it('resolves "Charlie" to people/charlie-example (more connections)', async () => {
const result = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'Charlie');
expect(result).toBe('people/charlie-example');
});
it('resolves "Bob" to people/bob-example (more connections)', async () => {
const result = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'Bob');
expect(result).toBe('people/bob-example');
});
it('resolves "Dave" to people/dave-example (single match)', async () => {
const result = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'Dave');
expect(result).toBe('people/dave-example');
});
it('falls through to slugify for unknown names', async () => {
const result = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'Zyxwvut');
expect(result).toBe('zyxwvut');
});
it('exact match still works for fully-qualified slugs', async () => {
const result = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'people/alice-example');
expect(result).toBe('people/alice-example');
});
it('multi-word input does NOT trigger prefix expansion', async () => {
// "Alice Example" should go through fuzzy match, not prefix expansion
const result = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'Alice Example');
// Should resolve via fuzzy match to the same page
expect(result).toContain('alice-example');
});
it('hyphenated input does NOT trigger prefix expansion', async () => {
const result = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'alice-example');
expect(result).toBe('people/alice-example');
});
it('returns null for empty input', async () => {
const result = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', '');
expect(result).toBeNull();
});
});
describe('resolveEntitySlug — additional coverage (D5)', () => {
it('expands bare "Acme" to companies/acme-example via the companies/ directory', async () => {
const result = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'Acme');
expect(result).toBe('companies/acme-example');
});
it('uses inbound + outbound link counts (not just chunks) for connection_count', async () => {
// eve-example has many inbound links + zero chunks; eve-friend-example
// has one inbound link + zero chunks. The winner is whichever the
// connection_count expression scores higher, and that scoring HAS
// to consider links or eve-example would tie eve-friend-example.
const result = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'Eve');
expect(result).toBe('people/eve-example');
});
it('breaks identical-count ties deterministically via slug ASC', async () => {
// frank-aaa-example and frank-zzz-example both have exactly 5
// chunks and 0 links. Tiebreak is slug ASC → "aaa" wins.
const result = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'Frank');
expect(result).toBe('people/frank-aaa-example');
});
it('matches exact prefix slug (no hyphen suffix) — companies/glob', async () => {
// Codex review post-D9: prefix expansion previously only matched
// `<dir>/<token>-%` and missed canonical slugs like `companies/glob`
// or `people/alice`. The fix also matches `<dir>/<token>` exactly.
const result = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'Glob');
expect(result).toBe('companies/glob');
});
it('resolves bare concept names via the default concepts/ directory', async () => {
// Codex review post-D9: `concepts/` is documented everywhere and
// the default `type: concept` home, but the original default dir
// list omitted it.
const result = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'RAG');
expect(result).toBe('concepts/rag');
});
it('scopes prefix expansion to the requested source_id', async () => {
// people/alice-example exists in BOTH 'default' (10 chunks) and
// 'other-src' (50 chunks). Resolving "Alice" in 'default' must
// return default's row, not leak to other-src's higher-chunk row.
// If the SQL ever drops `WHERE p.source_id = $1`, the high-chunk
// other-src row would win the tiebreak — this test pins that.
const defaultResult = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'Alice');
expect(defaultResult).toBe('people/alice-example');
// "Grace" only exists in other-src. Resolving in 'default' must
// fall through to slugify, NOT find the other-src row.
const defaultGrace = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'Grace');
expect(defaultGrace).toBe('grace');
// And in other-src, Grace resolves cleanly.
const otherGrace = await resolveEntitySlug(engine as unknown as BrainEngine, 'other-src', 'Grace');
expect(otherGrace).toBe('people/grace-other-example');
});
});
describe('getPrefixExpansionDirs — config-driven (D2)', () => {
it('returns DEFAULT_PREFIX_EXPANSION_DIRS when no config exists', async () => {
const tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-prefix-default-'));
try {
const dirs = await withEnv({ GBRAIN_HOME: tmpHome }, () => getPrefixExpansionDirs());
expect(dirs).toEqual([...DEFAULT_PREFIX_EXPANSION_DIRS]);
} finally {
rmSync(tmpHome, { recursive: true, force: true });
}
});
it('default dir list includes concepts/ for concept entity resolution', async () => {
expect(DEFAULT_PREFIX_EXPANSION_DIRS).toContain('concepts');
expect([...DEFAULT_PREFIX_EXPANSION_DIRS]).toEqual([
'people',
'companies',
'deals',
'topics',
'concepts',
]);
});
it('honors entities.prefix_expansion_dirs config override', async () => {
const tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-prefix-custom-'));
try {
// GBRAIN_HOME=<tmp> → config lives at <tmp>/.gbrain/config.json.
const cfgDir = join(tmpHome, '.gbrain');
const { mkdirSync } = await import('node:fs');
mkdirSync(cfgDir, { recursive: true });
writeFileSync(
join(cfgDir, 'config.json'),
JSON.stringify({
engine: 'pglite',
entities: { prefix_expansion_dirs: ['funds', 'people'] },
}),
'utf-8',
);
const dirs = await withEnv({ GBRAIN_HOME: tmpHome }, () => getPrefixExpansionDirs());
expect(dirs).toEqual(['funds', 'people']);
} finally {
rmSync(tmpHome, { recursive: true, force: true });
}
});
it('falls back to defaults when config override is empty or malformed', async () => {
const tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-prefix-bad-'));
try {
const cfgDir = join(tmpHome, '.gbrain');
const { mkdirSync } = await import('node:fs');
mkdirSync(cfgDir, { recursive: true });
writeFileSync(
join(cfgDir, 'config.json'),
JSON.stringify({
engine: 'pglite',
entities: { prefix_expansion_dirs: [123, '', null] }, // all bad
}),
'utf-8',
);
const dirs = await withEnv({ GBRAIN_HOME: tmpHome }, () => getPrefixExpansionDirs());
expect(dirs).toEqual([...DEFAULT_PREFIX_EXPANSION_DIRS]);
} finally {
rmSync(tmpHome, { recursive: true, force: true });
}
});
it('resolves through a custom funds/ directory when configured', async () => {
const tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-prefix-funds-'));
try {
const cfgDir = join(tmpHome, '.gbrain');
const { mkdirSync } = await import('node:fs');
mkdirSync(cfgDir, { recursive: true });
writeFileSync(
join(cfgDir, 'config.json'),
JSON.stringify({
engine: 'pglite',
entities: { prefix_expansion_dirs: ['funds'] },
}),
'utf-8',
);
const result = await withEnv({ GBRAIN_HOME: tmpHome }, () =>
resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'Founders'),
);
expect(result).toBe('funds/founders-x-example');
} finally {
rmSync(tmpHome, { recursive: true, force: true });
}
});
});
describe('stub-guard + backstop integration (D5 regression — IRON RULE)', () => {
it('writeFactsToFence refuses to stub-create an unprefixed slug; backstop drops fact + audits to JSONL', async () => {
// Regression for the literal bug class this PR fixes. When
// resolveEntitySlug falls through to slugify("Zander") → "zander"
// (no matching page, no prefix), writeFactsToFence must NOT create
// a phantom `zander.md`. Instead it returns stubGuardBlocked: true
// so the caller (backstop) logs + audits + skips.
//
// The pre-codex-review version of this PR inserted these facts into
// the DB to avoid silent data loss, but that produced
// legacy-shape rows (row_num NULL + entity_slug NOT NULL) that
// trip the v0.32.2 extract_facts reconciliation guard. The fix is
// to NOT insert and instead write a structured audit entry to
// ~/.gbrain/facts.dropped.jsonl for operator recovery.
const brainDir = mkdtempSync(join(tmpdir(), 'gbrain-stub-guard-'));
const gbrainHome = mkdtempSync(join(tmpdir(), 'gbrain-home-stub-'));
try {
// Codex round-5 P2: writeFactsToFence acquires page-locks under
// `gbrainPath('page-locks')`. Without GBRAIN_HOME isolation the
// test writes lockfiles into the developer's real ~/.gbrain and
// fails in hermetic / read-only-home runners with EPERM.
const result = await withEnv({ GBRAIN_HOME: gbrainHome }, () =>
writeFactsToFence(
engine as unknown as BrainEngine,
{ sourceId: 'default', localPath: brainDir, slug: 'zander' },
[
{
fact: 'Zander likes integration tests.',
kind: 'fact' as const,
notability: 'medium' as const,
source: 'test:regression',
visibility: 'private' as const,
embedding: null,
sessionId: null,
},
],
),
);
// The guard fired.
expect(result.stubGuardBlocked).toBe(true);
expect(result.inserted).toBe(0);
expect(result.ids).toEqual([]);
// No phantom markdown file was created at the brain root.
expect(existsSync(join(brainDir, 'zander.md'))).toBe(false);
// Simulate the backstop's fallback under a controlled GBRAIN_HOME
// so the JSONL lands in the tempdir and not the real brain.
const { appendDroppedFactAudit } = await import('../src/core/facts/dropped-audit.ts');
await withEnv({ GBRAIN_HOME: gbrainHome }, async () => {
appendDroppedFactAudit({
source_id: 'default',
phantom_slug: 'zander',
reason: 'stub_guard_blocked',
fact: 'Zander likes integration tests.',
kind: 'fact',
notability: 'medium',
visibility: 'private',
source: 'test:regression',
source_session: null,
});
});
// The fact is NOT in the DB — this is the v0.32.2 reconciliation
// guard fix. Previous regression test asserted the OPPOSITE; this
// updated assertion pins the codex P1 fix.
const rows = await engine.executeRaw<{ id: number }>(
`SELECT id FROM facts WHERE source_id = 'default' AND fact = 'Zander likes integration tests.'`,
[],
);
expect(rows.length).toBe(0);
// No legacy-shape rows produced by this code path.
const legacyRows = await engine.executeRaw<{ n: number }>(
`SELECT COUNT(*)::int AS n FROM facts WHERE row_num IS NULL AND entity_slug = 'zander'`,
[],
);
expect(legacyRows[0].n).toBe(0);
// The audit log captured the fact for operator recovery.
const auditPath = join(gbrainHome, '.gbrain', 'facts.dropped.jsonl');
expect(existsSync(auditPath)).toBe(true);
const auditLines = readFileSync(auditPath, 'utf-8').trim().split('\n');
expect(auditLines.length).toBe(1);
const entry = JSON.parse(auditLines[0]);
expect(entry.phantom_slug).toBe('zander');
expect(entry.reason).toBe('stub_guard_blocked');
expect(entry.fact).toBe('Zander likes integration tests.');
expect(entry.kind).toBe('fact');
expect(entry.notability).toBe('medium');
// And no markdown file.
expect(existsSync(join(brainDir, 'zander.md'))).toBe(false);
} finally {
rmSync(brainDir, { recursive: true, force: true });
rmSync(gbrainHome, { recursive: true, force: true });
}
});
it('writeFactsToFence appends to a real DB page even when the .md file is stale-stub (round-26 P2)', async () => {
// Codex round-26 P2: when the .md file on disk is a stale stub
// but pages.compiled_truth has real content (e.g. subsequent
// put_page / MCP write never re-synced to disk), facts must
// land on the real page rather than dropping to the audit log.
const realBody = '# Real Stale-Disk Page\n\nIntentional real DB content that the disk file lost.';
await engine.putPage(
'stale-disk-real',
{
type: 'concept' as any,
title: 'Real Stale-Disk Page',
compiled_truth: realBody,
frontmatter: { type: 'concept', title: 'Real Stale-Disk Page', slug: 'stale-disk-real' },
},
{ sourceId: 'default' },
);
const brainDir = mkdtempSync(join(tmpdir(), 'gbrain-stale-disk-'));
const gbrainHome = mkdtempSync(join(tmpdir(), 'gbrain-stale-disk-home-'));
try {
// Stub-shaped .md on disk (out of sync with DB).
writeFileSync(
join(brainDir, 'stale-disk-real.md'),
'---\ntype: concept\ntitle: Stale\nslug: stale-disk-real\n---\n\n# Stale\n',
'utf-8',
);
const result = await withEnv({ GBRAIN_HOME: gbrainHome }, () =>
writeFactsToFence(
engine as unknown as BrainEngine,
{ sourceId: 'default', localPath: brainDir, slug: 'stale-disk-real' },
[
{
fact: 'A new fact for the stale-disk real page.',
kind: 'fact' as const,
notability: 'medium' as const,
source: 'test:regression',
visibility: 'private' as const,
embedding: null,
sessionId: null,
},
],
),
);
// No stub-guard fire; fact landed.
expect(result.stubGuardBlocked).toBeUndefined();
expect(result.inserted).toBe(1);
// Markdown reconciled to DB body + appended fact.
const onDisk = readFileSync(join(brainDir, 'stale-disk-real.md'), 'utf-8');
expect(onDisk).toContain('Intentional real DB content');
expect(onDisk).toContain('A new fact for the stale-disk real page.');
} finally {
rmSync(brainDir, { recursive: true, force: true });
rmSync(gbrainHome, { recursive: true, force: true });
}
});
it('writeFactsToFence blocks appends to an existing stub-shaped phantom file (round-24 P2)', async () => {
// Codex round-24 P2: a pre-v0.34.5 phantom file on disk would
// previously slip past the stub-guard because the guard only
// fired on missing files. New facts kept appending to the
// phantom, growing the split.
const brainDir = mkdtempSync(join(tmpdir(), 'gbrain-existing-phantom-'));
const gbrainHome = mkdtempSync(join(tmpdir(), 'gbrain-existing-phantom-home-'));
try {
// Seed a stub-shaped phantom file on disk (no machine fence yet).
const phantomFile = join(brainDir, 'zoltan-phantom.md');
writeFileSync(
phantomFile,
'---\ntype: person\ntitle: zoltan-phantom\nslug: zoltan-phantom\n---\n\n# zoltan-phantom\n',
'utf-8',
);
const result = await withEnv({ GBRAIN_HOME: gbrainHome }, () =>
writeFactsToFence(
engine as unknown as BrainEngine,
{ sourceId: 'default', localPath: brainDir, slug: 'zoltan-phantom' },
[
{
fact: 'A fact about Zoltan.',
kind: 'fact' as const,
notability: 'medium' as const,
source: 'test:regression',
visibility: 'private' as const,
embedding: null,
sessionId: null,
},
],
),
);
// Stub-guard fired, no insert.
expect(result.stubGuardBlocked).toBe(true);
expect(result.inserted).toBe(0);
} finally {
rmSync(brainDir, { recursive: true, force: true });
rmSync(gbrainHome, { recursive: true, force: true });
}
});
it('writeFactsToFence materializes a real DB-only unprefixed page instead of firing stub-guard (round-18 P1)', async () => {
// Codex round-18 P1: when an unprefixed slug has a real DB body
// (put_page MCP created it on a source with local_path but never
// synced to disk), writeFactsToFence must NOT fire the stub-
// guard and drop the fact. Instead it should materialize the DB
// body to disk, then append the fence — preserving both the
// existing page content AND the new fact.
const realBody = '# Real Bare Page\n\nThis is real content that must be preserved.';
await engine.putPage(
'real-bare-page',
{
type: 'concept' as any,
title: 'Real Bare Page',
compiled_truth: realBody,
frontmatter: { type: 'concept', title: 'Real Bare Page', slug: 'real-bare-page' },
},
{ sourceId: 'default' },
);
const brainDir = mkdtempSync(join(tmpdir(), 'gbrain-stub-guard-real-'));
const gbrainHome = mkdtempSync(join(tmpdir(), 'gbrain-stub-guard-real-home-'));
try {
// The bare-slug .md file does NOT exist on disk pre-call.
expect(existsSync(join(brainDir, 'real-bare-page.md'))).toBe(false);
const result = await withEnv({ GBRAIN_HOME: gbrainHome }, () =>
writeFactsToFence(
engine as unknown as BrainEngine,
{ sourceId: 'default', localPath: brainDir, slug: 'real-bare-page' },
[
{
fact: 'A fact about the real bare page.',
kind: 'fact' as const,
notability: 'medium' as const,
source: 'test:regression',
visibility: 'private' as const,
embedding: null,
sessionId: null,
},
],
),
);
// No stub-guard fire; fact inserted.
expect(result.stubGuardBlocked).toBeUndefined();
expect(result.inserted).toBe(1);
// Markdown file now exists at the bare path WITH the real body + the fence.
const filePath = join(brainDir, 'real-bare-page.md');
expect(existsSync(filePath)).toBe(true);
const onDisk = readFileSync(filePath, 'utf-8');
expect(onDisk).toContain('This is real content that must be preserved.');
expect(onDisk).toContain('A fact about the real bare page.');
} finally {
rmSync(brainDir, { recursive: true, force: true });
rmSync(gbrainHome, { recursive: true, force: true });
}
});
it('writeFactsToFence DOES create the page when the slug has a directory prefix', async () => {
// Inverse coverage: confirm the guard ONLY fires on unprefixed slugs.
// A `people/yvonne-example` write should land normally.
const brainDir = mkdtempSync(join(tmpdir(), 'gbrain-stub-guard-ok-'));
const gbrainHome = mkdtempSync(join(tmpdir(), 'gbrain-stub-guard-ok-home-'));
try {
// GBRAIN_HOME isolation per codex round-5 P2 (page-locks).
const result = await withEnv({ GBRAIN_HOME: gbrainHome }, () =>
writeFactsToFence(
engine as unknown as BrainEngine,
{ sourceId: 'default', localPath: brainDir, slug: 'people/yvonne-example' },
[
{
fact: 'Yvonne likes prefixed slugs.',
kind: 'fact' as const,
notability: 'medium' as const,
source: 'test:regression',
visibility: 'private' as const,
embedding: null,
sessionId: null,
},
],
),
);
// No guard fire; row inserted.
expect(result.stubGuardBlocked).toBeUndefined();
expect(result.inserted).toBe(1);
expect(result.ids.length).toBe(1);
// Markdown file exists at the prefixed path.
expect(existsSync(join(brainDir, 'people/yvonne-example.md'))).toBe(true);
} finally {
rmSync(brainDir, { recursive: true, force: true });
rmSync(gbrainHome, { recursive: true, force: true });
}
});
});
describe('slugify', () => {
it('lowercases and hyphenates', () => {
expect(slugify('Alice Example')).toBe('alice-example');
});
it('handles single word', () => {
expect(slugify('Alice')).toBe('alice');
});
it('strips accents', () => {
expect(slugify('José García')).toBe('jose-garcia');
});
});