* docs: Knowledge Runtime design doc (draft) — 4-layer architecture + reduced-scope delta
Captures the Knowledge Runtime design thinking from the CEO review session:
Resolver SDK, Enrichment Orchestrator, Scheduler, Deterministic Output Builder.
The original 7-phase plan was drafted before v0.12.0 (knowledge graph layer)
and v0.11.x (Minions agent runtime) shipped. Cross-referenced against what's
already merged on master, roughly 60% of the 4-layer vision is already in
production under different names:
- Minions = scheduler + plugin contract (L1 + L3)
- Knowledge graph auto-link = deterministic output at L4 + orchestrator at L2
- BrainBench v1 benchmarks already validate the graph layer
The doc is kept as a draft design reference; the actual build-out will scope
down to the real delta (typed Resolver interface, BrainWriter API + validators,
BudgetLedger, CompletenessScorer, quiet-hours + stagger). See the CEO review
notes for the reduced plan.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(resolvers): Resolver SDK pass 1 — interface + registry (PR 1/5)
Adds the typed plugin interface that unifies external-lookup calls (X API,
Perplexity, HEAD check, brain-local slug resolution) behind a single shape:
registry.resolve('x_handle_to_tweet', { handle, keywords }, ctx)
→ { value, confidence, source, fetchedAt, raw? }
Zero behavior change — the registry is empty by default. Builtins
(url_reachable, x_handle_to_tweet) land in the next pass. ScheduledResolver
wrapping via Minions lands in PR 5.
New files:
- src/core/resolvers/interface.ts — Resolver<I,O>, ResolverResult<O>,
ResolverContext (engine, storage, config, logger, requestId, remote,
deadline, signal), ResolverError (not_found, already_registered,
unavailable, timeout, rate_limited, auth, schema, aborted, upstream)
- src/core/resolvers/registry.ts — ResolverRegistry (register/get/has/
list/resolve/clear/size) + getDefaultRegistry() for process-wide use
- src/core/resolvers/index.ts — barrel export
Design rules enforced by types:
- Every result carries confidence (0.0-1.0) + source attribution
- LLM-backed resolvers return confidence<1.0 by convention
- ctx.remote propagates the trust boundary (mirrors OperationContext.remote)
- AbortSignal threads through for cooperative cancellation
Smoke: imports + runs, list()/get()/resolve() behave as typed.
Dependency-free beyond types and storage/engine type imports.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(fail-improve): optional AbortSignal — Resolver SDK pass 2 (PR 1/5)
Extends FailImproveLoop.execute with an optional `opts.signal` that threads
through the deterministic-first / LLM-fallback flow. Needed by the Resolver
SDK so long-running lookups can be cooperatively cancelled when a caller
aborts (deadline hit, Minion job timeout, user ctrl-c).
Additive and backwards-compatible:
- execute() signature widens callbacks to (input, signal?) => ...; existing
two-arg callbacks are structurally compatible and ignore the extra arg.
- opts is optional; callers that omit it get pre-extension behavior.
- Aborts throw a DOM-style AbortError (name='AbortError'), matching what
fetch() throws, so downstream `err.name === 'AbortError'` branches work
unchanged.
- Aborted runs are NOT logged to the failure JSONL — not informative and
would pollute pattern analysis.
Abort check fires in three places:
- Before the deterministic call (pre-flight)
- Between deterministic miss and LLM call (mid-flight)
- Inside llmFallbackFn if the implementation respects signal itself
Smoke tests: 5 scenarios (existing sig, llm fallback, pre-abort, mid-flight
abort, signal threaded to fallback) — all pass. Existing test/fail-improve.test.ts
(13 tests, 27 expects) unchanged and passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(resolvers): url_reachable + x_handle_to_tweet — SDK pass 3 (PR 1/5)
Two reference resolver implementations that validate the interface against
real-world requirements: a deterministic free-cost check and a rate-limited
paid-backend lookup.
src/core/resolvers/builtin/url-reachable.ts
HEAD-check a URL, follow redirects (max 5), detect dead links. Reused
isInternalUrl() from the wave-3 SSRF hardening; re-validates every redirect
hop against the same filter. Falls back from HEAD to GET on 405/501.
Composes caller's AbortSignal with a per-request timeout via
AbortSignal.any (with manual-propagation fallback). Confidence=1 when the
backend answers; confidence=0 only on transport failure (DNS/connect/timeout).
src/core/resolvers/builtin/x-api/handle-to-tweet.ts
Find a tweet by handle + free-text keyword hint. Used by the upcoming
`gbrain integrity --auto` loop to repair the 1,424 bare-tweet citations
in Garry's brain. Confidence buckets align with the three-bucket contract:
- >=0.8 auto-repair (single strong match, or dominant in small candidate set)
- 0.5-0.8 review queue (ambiguous but promising)
- <0.5 skip (many candidates or weak match)
Scoring: normalized keyword-token overlap against tweet text, with margin
boost for dominant matches. Strict handle regex (X's username rules).
Retries on 429 up to 2x with Retry-After honor. Terminal 401/403 surfaces
as auth ResolverError so the caller stops hammering. Bearer token read
from ctx.config.x_api_bearer_token or X_API_BEARER_TOKEN env — never logged.
Smoke: registry accepts both, SSRF blocks localhost + file://, available()
returns false when token missing, schema validator rejects bad handles.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(resolvers): tests + gbrain resolvers CLI — SDK pass 4 (PR 1/5 complete)
Closes out PR 1. 43 new tests in test/resolvers.test.ts covering registry
contract, both reference builtins, all three confidence buckets, and every
ResolverError subcode.
test/resolvers.test.ts
- ResolverRegistry: register, duplicate-id rejection, get/has, list with
cost+backend filters, resolve, unavailable propagation, clear, default
singleton lifecycle.
- url_reachable: available(), SSRF guard on localhost + RFC1918 + 169.254
metadata + file:// scheme, empty-url schema error, 200/404 status
propagation, HEAD→GET fallback on 405, redirect chain, per-hop SSRF
re-validation, network failure → reachable=false, AbortSignal mid-flight.
- x_handle_to_tweet: token gate via env AND via ctx.config, invalid/long
handle schema errors, zero-candidate + single-strong + single-weak +
many-ambiguous confidence buckets (gates >=0.5 url emission), 401/403
auth error, 500 upstream error, 429 retry-then-rate_limited, X operator
stripping (prompt injection defense).
src/commands/resolvers.ts
- `gbrain resolvers list [--cost | --backend | --json]` pretty table
or JSON.
- `gbrain resolvers describe <id>` schema + availability detail.
- registerBuiltinResolvers() is idempotent; ready to be called from
future entry points (gbrain integrity, MCP server).
src/cli.ts wires `resolvers` into CLI_ONLY + dispatches to runResolvers.
Full suite: 1343 pass / 0 fail / 141 skip (E2E without DATABASE_URL).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(output): BrainWriter + Scaffolder + SlugRegistry — PR 2 pass 1/4
Lands the transactional writer library that the rest of the Knowledge
Runtime sits on top of. No callers routed through it yet — publish.ts /
backlinks.ts / put_page migrations are pass 4 and PR 2.5.
src/core/output/scaffold.ts
Deterministic URL / citation / link builders. Callers pass typed inputs
(handle + tweetId, account + messageId, slug + display text) and get
canonical markdown bytes out. LLM-generated URLs never touch disk.
- tweetCitation({handle, tweetId, dateISO?})
- emailCitation({account, messageId, subject, dateISO?})
- sourceCitation(resolverResult, {url?, label?})
- entityLink({slug, displayText, relativePrefix?})
- timelineLine({dateISO, summary, citation?})
ScaffoldError with codes for invalid_handle / invalid_tweet_id /
invalid_slug / invalid_message_id / invalid_date / empty.
src/core/output/slug-registry.ts
Solves the "Marc Benioff vs Marc-Benioff both slug to marc-benioff" bug.
create() probes engine.getPage and either returns the desired slug or
disambiguates (alice-smith → alice-smith-2). isFree() + suggestDisambiguators()
for interactive UX. Errors: collision, disambiguator_exhausted, invalid_slug.
src/core/output/writer.ts
BrainWriter.transaction(fn, ctx) wraps engine.transaction. The `fn`
callback receives a WriteTx with createEntity / appendTimeline /
setCompiledTruth / setFrontmatterField / putRawData / addLink (the last
creates both forward + reverse back-link atomically). On commit, per-page
validators run against all touchedSlugs. Strict mode throws on
error-severity findings, rolling back the outer tx. Lint mode (default for
PR 2 rollout) returns the report but commits regardless. Pages with
`validate: false` frontmatter skip validators entirely (grandfather hook
for PR 2 migration).
Integration smoke against PGLite: createEntity → disambiguator (2nd call
with same desired slug), addLink writes both forward + back-link,
strict-mode validator failure rolls back the transaction bit-identically.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(output): 4 pre-commit validators + tests — PR 2 pass 2/4
Lands the validator suite that BrainWriter runs before committing a
transaction. Paragraph-level deterministic checks, markdown-aware, skip
legacy pages via validate:false frontmatter.
src/core/output/validators/citation.ts
Every factual paragraph in compiled_truth carries at least one citation
marker: [Source: ...] or a linked URL. Splits paragraphs on blank lines,
strips fenced code / inline code / HTML comments before checking.
Ignores headings, key-value lines ("**Status:** Active"), table rows,
pure wikilink bullets (## See Also), and short labels without a factual
verb. Deterministic — no LLM, no semantic judgment.
src/core/output/validators/link.ts
Every [text](path) wikilink resolves to a page that exists (unless it's
an external http(s) URL, which this validator doesn't check; that's
url_reachable's job in PR 3). Strips relative prefix and .md extension.
Batches engine.getPage lookups per unique target. mailto/anchor/other
schemes flagged as warning. Links inside fenced code blocks are skipped.
src/core/output/validators/back-link.ts
Iron Law: if page X → page Y, then Y → X. Reads engine.getLinks(ctx.slug),
and for each target checks engine.getLinks(target) for a reverse edge.
Missing reverses flagged as warning (runAutoLink is the authoritative
enforcer on put_page; this is defense-in-depth for pages edited outside
the main write path).
src/core/output/validators/triple-hr.ts
Catches hygiene issues on the compiled_truth / timeline split: bare `---`
in compiled_truth would re-split on round-trip through parseMarkdown;
headings in the timeline section signal authoring mistakes. Both warn
(not error) — legacy pages legitimately use thematic breaks.
src/core/output/validators/index.ts
registerBuiltinValidators(writer) wires all four.
test/writer.test.ts
57 tests: Scaffolder (all 5 helpers + error paths), SlugRegistry (create,
disambiguator, collision throw, invalid-slug, isFree, suggestDisambiguators),
BrainWriter (happy path, disambiguate, addLink + reverse, strict rollback,
lint proceeds with report, off skips validators, validate:false grandfather,
setCompiledTruth, setFrontmatterField merge, registered validators list),
citation validator (all 11 shape cases), link validator (normalizeToSlug
including ../../, external URL skip, mailto warning, code-fence skip),
back-link validator (no outbound, missing reverse → warning, bidirectional
clean), triple-hr validator (clean, bare --- warning, fenced --- skipped,
heading in timeline warning, ## Timeline header allowed).
Full suite: 1400 pass / 0 fail / 141 skip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(migrations): v0.13.0 grandfather validate:false — PR 2 pass 3/4
Adds the TS migration that makes BrainWriter's strict-mode rollout safe:
every existing page gets `validate: false` in frontmatter so the new
citation / link / back-link / triple-HR validators skip legacy content.
gbrain integrity --auto (PR 3) clears the flag per-page once real citations
are repaired.
src/commands/migrations/v0_13_0_add_validate_false.ts
Four-phase orchestrator following the v0_12_0 pattern:
A. connect — loadConfig + createEngine. Does NOT write config (prior
learning: gbrain init --migrate-only semantics; never
flip Postgres users to PGLite via bare init).
B. snapshot — engine.getAllSlugs() upfront (prior learning:
listpages-pagination-mutation; OFFSET iteration is
self-invalidating when each write bumps updated_at).
C. grandfather — per slug, skip if frontmatter.validate already set,
else append-log pre-mutation snapshot to
~/.gbrain/migrations/v0_13_0-rollback.jsonl and
putPage with validate:false merged in. Batched 100
at a time so interruption losses are bounded.
D. verify — SQL count of pages with validate=false ≥ expectedTouched.
Idempotent: second run is a no-op. Reversible: rollback log is
append-only JSONL; future `gbrain apply-migrations --rollback v0.13.0`
replays it. Safe on empty brains (returns complete with 0 touched).
src/commands/migrations/index.ts
Registers v0_13_0 after v0_12_0 in semver order.
test/migrations-v0_13_0.test.ts
Registry integration (v0.13.0 present, semver-after-v0.12.0, pitch
metadata well-formed), orchestrator handles no-config gracefully,
dryRun skips the connect phase.
test/apply-migrations.test.ts
Updated two assertions that hard-coded the v0.12.0 skippedFuture list
to also include v0.13.0 (now skippedFuture when installed < 0.13.0).
Full suite: 1405 pass / 0 fail / 141 skip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(integrity): gbrain integrity — bare-tweet repair + dead-link scan (PR 3)
Ships the user-visible milestone for the Knowledge Runtime delta: a
command that finds brain-integrity issues and repairs them through the
BrainWriter + Resolver SDK infrastructure from PRs 1 and 2.
Targets the two quantified pain points from brain/CITATIONS.md:
- 1,424 of 3,115 people pages have bare tweet references without URLs
- An unknown fraction of existing URL citations have rotted
Subcommands:
gbrain integrity check Read-only report, optional --json
gbrain integrity auto Three-bucket repair loop
gbrain integrity review Print review-queue path + count
gbrain integrity reset-progress Clear the progress file
Three-bucket contract (matches x_handle_to_tweet resolver's confidence
scoring):
>=0.8 → auto-repair via BrainWriter transaction. Appends a timeline
entry on the page with a Scaffolder-built tweet citation (URL
from the API response, never from LLM text).
0.5-0.8 → append to ~/.gbrain/integrity-review.md with all candidates
sorted by match score, for batch human review.
<0.5 → log reason to ~/.gbrain/integrity.log.jsonl and skip.
Resumable: every processed slug hits ~/.gbrain/integrity-progress.jsonl
so an interrupted run resumes from the last slug. --fresh clears it.
Bare-tweet detection patterns (regex, deterministic, skip code fences
and already-cited lines):
- "tweeted about"
- "in/on a (recent|viral) tweet"
- "wrote a tweet/post"
- "posted on X"
- "via X" (but not "via X/handle" — already cited)
- possessive "his/her/their tweet"
External-link detection extracts all [text](https?://...) pairs (code
fences skipped) for optional dead-link probing via url_reachable.
Dead links are surfaced, not auto-repaired — no "correct" replacement
exists without human judgment.
Wiring: runIntegrity dispatches subcommands, registers builtin resolvers
into the default registry, connects to the brain engine, and uses
BrainWriter in strict-off mode (integrity is the repair path, not the
write-gate path).
Unit tests: 21 cover bare-tweet regex (all 9 phrase shapes + code-fence
skip + URL-already-present skip + per-line dedup), external-link
extraction (http+https, line numbers, fenced skip), frontmatter handle
extraction (x_handle, twitter, twitter_handle, x; preference order;
leading @ strip; null paths). End-to-end auto flow verified manually
via the resolver SDK tests + BrainWriter tests it composes.
src/cli.ts wires `integrity` into CLI_ONLY + dispatches to runIntegrity.
Full suite: 1426 pass / 0 fail / 141 skip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(enrichment): BudgetLedger + CompletenessScorer — PR 4
Two layer-2 primitives that slot under the resolver SDK and BrainWriter:
cost-aware spend caps and evidence-weighted per-page completeness scoring.
Schema migration v11 adds two tables:
budget_ledger (scope, resolver_id, local_date) PK — midnight rollover by
date column means a new calendar day upserts a new row; no rollover
thread, no race.
budget_reservations (reservation_id) — TTL-bounded held reservations
(default 60s) so process death between reserve() and commit() doesn't
strand money.
Rollback plan: DROP TABLE. Budget data is regenerable from resolver call
logs; no durable product value lives in the ledger.
src/core/enrichment/budget.ts
BudgetLedger.reserve({resolverId, estimateUsd, capUsd?, ttlSeconds?})
serializes concurrent reserves on {scope, resolver_id, local_date} via
SELECT ... FOR UPDATE. Returns {kind:'held', reservationId, ...} or
{kind:'exhausted', reason, spent, pending, cap} — never over-spends.
commit(id, actualUsd) moves money from reserved_usd to committed_usd and
marks the reservation status='committed'. rollback(id) zeros out the
reservation without touching committed. Commit-after-commit throws
already_finalized; rollback-after-commit is a no-op (callers don't need
to guard). commit-unknown-id throws reservation_not_found.
cleanupExpired() sweeps held reservations past expires_at and rolls them
back; reserve() opportunistically reclaims the target row's expired
reservations before acquiring its own lock.
IANA timezone config via opts.tz (default America/Los_Angeles); midnight
rollover is naturally expressed as a date column + Intl.DateTimeFormat
with en-CA locale (YYYY-MM-DD). DST is handled by the formatter.
src/core/enrichment/completeness.ts
Seven per-type rubrics (person, company, project, deal, concept, source,
media) + default. Each rubric's dimension weights sum to 1.0, checked at
module load. scorePage(page) returns {score, dimensionScores, rubric}
where score is 0.000–1.000.
Person rubric dimensions: has_role_and_company, has_source_urls,
has_timeline_entries, has_citations, has_backlinks, recency_score,
non_redundancy. The last two are the explicit fix for the two pathologies
called out in the codex review of the earlier design: stale pages that
never decay (30-day re-enrich forever) and Wilco-style repeated blocks
that pass Wintermute's length heuristic.
Pure functions. No engine calls — BrainWriter invokes scorePage after a
transaction and caches the result in frontmatter.completeness.
test/enrichment.test.ts — 23 tests:
BudgetLedger: under-cap held, over-cap exhausted, commit moves money,
rollback clears, commit-rollback no-op, commit-commit throws, commit-
unknown throws, invalid input, empty state null, scope isolation,
parallel reserves respect cap (10 parallel, cap 1.0, est 0.3 each →
≤ 3 held; state.reservedUsd ≤ 1.0), cleanupExpired reclaims TTL=0.
CompletenessScorer: all 8 rubrics sum to 1.0, empty person scores <0.3,
fully-enriched person >0.8, dimension scores exposed, role detection,
company/concept/source/media/default routing, recency decay with age,
non_redundancy penalizes repeated lines.
Full suite: 1449 pass / 0 fail / 141 skip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(minions): quiet-hours + stagger + claim-time gate — PR 5
Closes the scheduler gap per CEO plan: Minions v7 shipped a durable
runtime but nothing about when jobs should NOT run. This wires
quiet-hours enforcement at claim time (the codex correction — dispatch-
time is wrong because a queued job can become claimable after its window
opens) plus deterministic stagger slots to prevent cron-boundary storms.
Schema migration v12 adds two columns to minion_jobs:
quiet_hours JSONB — {start, end, tz, policy} window config
stagger_key TEXT — partitioning key for deterministic offset
Plus a partial index on stagger_key for later slot-assignment queries.
src/core/minions/quiet-hours.ts
evaluateQuietHours(cfg, now?) → 'allow' | 'skip' | 'defer'. Pure,
deterministic, no engine. Handles straight-line and wrap-around windows
(e.g. 22→7 spans midnight). IANA timezone via Intl.DateTimeFormat;
unknown tz fails open (allow) — safer than hard-blocking every job.
'skip' policy drops the event; 'defer' (default) re-queues for later.
src/core/minions/stagger.ts
staggerMinuteOffset(key) → 0–59, FNV-1a hash. Same key → same slot.
Pure; no module-level state. Used by scheduled resolvers that want to
avoid cron-boundary collisions ("10 jobs all fire at minute 0").
src/core/minions/worker.ts
MinionWorker.tick now consults evaluateQuietHours on every claimed job.
Verdict 'defer' → UPDATE status='delayed', delay_until = now() + 15m
(prevents immediate re-claim loops when the claim query re-runs).
Verdict 'skip' → UPDATE status='cancelled', error_text='skipped_quiet_hours'.
Both paths clear lock_token and require lock_token match in the WHERE
clause so a concurrent stall recovery can't race us.
test/minions-quiet-hours.test.ts — 25 tests:
evaluateQuietHours: null/undefined/invalid config paths (allow fail-open),
straight-line in/out + exclusive-end, wrap-around in (before midnight +
after), skip vs defer policy, timezone-offset propagation (winter PST
vs summer PDT), localHour parity with Date.getUTCHours.
staggerMinuteOffset: deterministic same key → same offset, different
keys spread across buckets (10 keys → ≥5 unique buckets), empty/non-
string edge cases.
Schema v12: quiet_hours and stagger_key columns exist on minion_jobs,
idx_minion_jobs_stagger_key index present.
Full suite: 1474 pass / 0 fail / 141 skip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(output): post-write validator lint hook — PR 2.5
Minimal integration of BrainWriter validators into the main write path,
feature-flag-gated and non-blocking. The CEO plan explicitly scoped PR 2.5
as a pre-soak landing step: the hook plugs in now, observability lands,
but strict-mode rejection is deferred to a follow-on release gated on the
7-day soak + BrainBench regression ≤1pt.
src/core/output/post-write.ts
runPostWriteLint(engine, slug, opts?) invokes the four BrainWriter
validators (citation, link, back-link, triple-hr) against a freshly
written page and returns a PostWriteLintResult. Skips cleanly when:
- config `writer.lint_on_put_page` is not truthy (default OFF; opts.force overrides)
- the page is not found (shouldn't happen in normal put_page flow)
- the page has frontmatter.validate === false (grandfathered)
Findings are logged to:
- ~/.gbrain/validator-lint.jsonl (capped at 20 findings per line)
- engine.logIngest (ingest_log table) for durable agent-inspectable history
Validator-level exceptions are swallowed so a buggy validator never
breaks put_page.
src/core/operations.ts put_page handler
After importFromContent + runAutoLink, imports runPostWriteLint and
invokes it. Result returns writer_lint: {error_count, warning_count} or
{skipped: reason}. Try/catch wraps the whole hook so an import or
runtime error never blocks the main write.
Enable locally:
gbrain config set writer.lint_on_put_page true
Then every put_page emits a writer_lint summary + appends structured
findings to the ingest log for analysis before the strict-mode flip.
test/post-write-lint.test.ts — 11 tests:
Flag reader (default off, true/1/on, other values false, explicit false)
Hook behavior (flag-off skip, page-not-found skip, validate:false
grandfather skip, force=true overrides flag, dirty page yields citation
error, clean page yields zero findings).
Full suite: 1485 pass / 0 fail / 141 skip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(migrations-v0_13_0): drop flaky no-config assertion
The 'does not succeed when no brain is configured' test assumed loadConfig
would return null when HOME is empty, but it also reads DATABASE_URL from
the environment. When .env.testing sources DATABASE_URL into the shell
(normal E2E lifecycle), the orchestrator connects successfully and runs
to completion — the test's assertion was unreachable.
The dry-run path is still covered by the remaining test in the same
describe block; registry integration and semver ordering are covered by
the sibling describe.
Full suite with DATABASE_URL live: 1574 pass / 0 fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(minions): wire quiet_hours + stagger_key into MinionJobInput + queue.add
Codex adversarial review caught that PR 5 (claim-time quiet-hours gate) was
cosmetic: the schema v12 column existed, the worker read it via
`readQuietHoursConfig(job)`, but `MinionJobInput` never accepted it,
`queue.add()` never inserted it, and `rowToMinionJob()` never mapped it out.
Result: every scheduled job saw `quiet_hours: null`, so the gate was a
no-op. Stagger_key had the same broken wiring.
- MinionJob (types.ts): add `quiet_hours` and `stagger_key` fields.
- MinionJobInput: add matching optional fields so callers can submit them.
- rowToMinionJob: parse both columns (JSONB handled the same way as `data`).
- MinionQueue.add: include both columns in the INSERT (idempotent + normal
paths), bound as $19/$20. The `$19::jsonb` cast matches the JSONB column
shape; the wire format is the same native-JS object path that fixed the
JSONB double-encode bug in v0.12.1.
After this, `await queue.add('x', {}, { quiet_hours: {start:22,end:7,
tz:"America/Los_Angeles",policy:"defer"} })` actually stores the window
and the worker's claim-time gate defers the job inside it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(minions): route quiet-hours 'skip' through cancelJob to rollup parents
Codex flagged that handleQuietHoursDefer with verdict='skip' directly set
status='cancelled' via raw UPDATE — bypassing MinionQueue.cancelJob, which
means:
- Parent jobs in 'waiting-children' never get rolled up.
- Descendant jobs don't cascade-cancel.
- Child-done inbox notification is skipped.
Result: a parent waiting on a child that fell inside quiet hours with
policy='skip' stays stuck forever.
Fix: release the lock, then delegate to queue.cancelJob(job.id) which
handles the recursive CTE + parent rollup + inbox posting correctly.
Falls back to a direct UPDATE only if cancelJob errors — even then, the
status transition is status-guarded to avoid stomping terminal states.
Defer path unchanged (no parent rollup needed since the job hasn't reached
a terminal state).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(budget): commit() re-checks cap + rejects negative actuals
Codex caught two cap-bypass bugs in BudgetLedger.commit():
1. reserve({estimateUsd: 0.01, capUsd: 1.0}) + commit(id, 100) silently
charged $100 to a $1-cap bucket. Cap is an advertised invariant that
the code was not enforcing.
2. Negative actuals (commit(id, -5)) were accepted, letting callers
artificially reduce committed_usd below the real spend. Refunds need
a dedicated API, not a side-channel on commit.
Fix:
- Reject non-finite AND negative actualUsd at entrypoint.
- Lock the ledger row FOR UPDATE during commit (same serialization as
reserve).
- Compute effective cap headroom = cap - other_committed - other_reserved
(excluding this reservation from the reserved pool since we're about to
finalize it).
- When actualUsd would exceed available, clamp committed_usd to max
available and throw BudgetError with the overage reported. The
reservation is still marked 'committed' (API call already happened;
don't retry-loop), but the cap is honored.
After this, a $1/day cap actually means $1/day.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(integrity): --dry-run no longer writes progress, poisoning resume
Codex caught that 'gbrain integrity auto --dry-run' appended progress
entries (status='repaired', 'reviewed', 'skipped', 'error') despite doing
no actual writes. The follow-on real run with default --resume would then
skip those slugs — the dry-run silently consumed the work queue.
Fix: gate every appendProgress() call in cmdAuto on !dryRun. Dry-run
still logs to the skip log / review queue (so the user sees what WOULD
happen), but the progress file stays untouched.
Behavior:
--dry-run → buckets counted + summary printed + review-queue
+ log populated, but progress file unchanged.
(default) → progress file tracks every processed slug, so
Ctrl-C + re-run resumes from the right place.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: bump version and changelog (v0.13.0.0)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(resolvers): DNS-rebinding defense + X rate-limit header parity
Two non-blocking codex findings on PR #210 rolled into one bisectable
commit because their tests share an import line.
url_reachable: hostname-string SSRF guard is vulnerable to DNS rebinding
(attacker-controlled DNS returns a public IP at validate time and
169.254.169.254 at fetch time). Add checkDnsRebinding() that resolves
the hostname via dns.lookup({all:true}) and rejects any result whose
A/AAAA record lands in a private range (v4 via isPrivateIpv4, v6
loopback/link-local/unique-local/IPv4-mapped). Applied on the initial
URL and on every redirect target. Null on DNS failure so genuine
network problems surface via fetch.
x_handle_to_tweet: rate-limit backoff only honored Retry-After and
ignored X's proprietary x-rate-limit-reset header. computeBackoffMs()
parses both (Retry-After = seconds or HTTP-date; x-rate-limit-reset =
epoch seconds), takes MAX, and clamps to [2s, 60s]. Exported for
testability; callers use it uniformly on every 429.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(writer): advisory lock on desiredSlug prevents cross-process TOCTOU
BrainWriter's createEntity checks engine.getPage(slug) and falls back
to putPage(), which upserts. Two putPage('people/alice') calls from
separate processes (a Claude Code session + a Minions worker, say) can
both read "free" from SlugRegistry and both call putPage, silently
overwriting each other with no disambiguation.
Take a transaction-scoped advisory lock keyed on hashtext(desiredSlug)
before the registry check. Concurrent writers for the same slug now
serialize at the DB level: the second observes the first's commit and
disambiguates to alice-2. PGLite is single-process so this is a
harmless no-op there. Wrapped in try/catch so engines/test doubles
that don't support advisory locks fall through to the existing
within-process check.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(validators): empty [Source:] no longer satisfies citation check
Regex /\[Source:[^\]]*\]/ matched decorative markers like [Source:]
and [Source: ] that carry zero provenance. Tighten to require at
least one non-whitespace character before the closing bracket. The
inline URL form ](https://...) already requires a scheme+host so it
stays as-is.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(auto-link): advisory lock serializes concurrent reconciliation
runAutoLink wraps getLinks + addLink/removeLink in a transaction, but
row-level locks alone don't prevent the union-of-writes race: two
concurrent put_page calls on the same slug can both read the same
existingKeys BEFORE either mutates a row, then proceed to add links
the other side's rewrite no longer mentions.
Take a transaction-scoped advisory lock on hashtext("auto_link:" ||
slug) at the start of the reconciliation. Concurrent writers on the
same slug now fully serialize; writers on different slugs still run
in parallel. No-op on engines without advisory locks (PGLite).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: expand coverage on abort-signal threading + integrity CLI dispatch
fail-improve: four new AbortSignal cases — pre-start abort, between
deterministic and LLM, signal forwarded into both callbacks, and
LLM-thrown AbortError propagates without logging a failure entry.
integrity: three new CLI dispatch cases — --help, no-subcommand (help),
and unknown subcommand (stderr + exit 1). Non-engine paths so they
exercise routing without spinning up a DB.
Coverage-only; no source changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(doctor): fold integrity sample scan into default health check
Expose scanIntegrity(engine, opts) as a pure library function — same
logic cmdCheck uses — and call it from doctor in non-fast mode with
a 500-page sampling limit. Surfaces bare-tweet phrase count and
external-link count as an 'integrity' check, warn-status when bare
tweets are present with a one-liner pointing at 'gbrain integrity
check' for the full report and 'integrity auto' for repair.
Read-only: no network, no writes, no resolver calls. Pages with
validate:false frontmatter are skipped (grandfathered). --fast mode
skips it entirely so the existing health-snapshot contract holds.
Users no longer need to remember three separate commands (doctor,
lint, integrity check) to audit brain health — doctor surfaces the
integrity signal by default, full scan stays available for deep dives.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(put_page): auto-extract timeline entries alongside auto-link
put_page already chunks, embeds, reconciles tags, and extracts
auto-links on every write. Timeline extraction has lived in a
separate command (gbrain extract timeline) that users had to remember
to run. Fold it into the write path: after the page commits, parse
timeline entries from compiled_truth + timeline body and insert via
addTimelineEntriesBatch. ON CONFLICT DO NOTHING keeps it idempotent
across re-writes.
Mirrors auto-link shape: best-effort post-hook, skipped for remote
(MCP) callers, gated by auto_timeline config (default TRUE). Response
includes auto_timeline: { created } alongside auto_links.
Side effect: a one-shot `gbrain put` now produces a complete page —
chunks, embeddings, links, AND timeline — instead of three commands
the user has to chain manually.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(migrate): verify target health after engine migration
After a PGLite↔Postgres migration, the user was left to run 'gbrain
doctor' themselves to confirm the target is good. Not great, because
the failure modes (partial copy, missing embeddings, schema drift)
all surface at next CLI use when the migration itself looks like it
succeeded.
Add verifyTarget() — inline doctor-lite that checks page count
matches the source, embedding coverage is above 90%, and schema
version is at latest. Prints a 3-line status table at the end of
migrate and points at 'gbrain doctor' for the full check. Non-fatal:
warns on discrepancies instead of failing the command so the user
sees the full picture.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(bench): add v0.13 knowledge runtime benchmark deltas
Two new benchmark scripts + one consolidated markdown comparing this
branch against master (c0b6219, v0.12.1):
benchmark-put-page-latency.ts — 200 put_page ops, measures the
per-write cost of Step B's auto-timeline extraction. Branch adds
~0.5ms mean latency and produces 300 timeline entries for free;
master produces zero and requires a separate 'gbrain extract timeline'
pass.
benchmark-knowledge-runtime.ts — three measurements in one script:
time-to-queryable (branch 40/40 vs master 0/40 on post-ingest
timeline queries), integrity repair rate (70/20/10 three-bucket
split via mocked resolver), doctor completeness (surfaces 100% of
real issues after Step A, respects grandfathered pages).
docs/benchmarks/2026-04-19-knowledge-runtime-v0.13.md — consolidated
report. Covers the four moved benchmarks plus side-by-side runs of
graph-quality and search-quality showing they're identical across
master and branch. Proof of no regression on the retrieval hot path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
715 lines
24 KiB
TypeScript
715 lines
24 KiB
TypeScript
/**
|
|
* BrainWriter + Scaffolder + SlugRegistry + 4 validators.
|
|
*
|
|
* Runs against PGLite in-memory. No network. Engine lifecycle per-suite
|
|
* via beforeAll/afterAll so migrations apply once.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { tmpdir } from 'os';
|
|
import { mkdtempSync, rmSync } from 'fs';
|
|
import { join } from 'path';
|
|
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import type { BrainEngine } from '../src/core/engine.ts';
|
|
import type { ResolverContext } from '../src/core/resolvers/index.ts';
|
|
|
|
import {
|
|
BrainWriter,
|
|
WriteError,
|
|
type ValidationReport,
|
|
} from '../src/core/output/writer.ts';
|
|
import { SlugRegistry, SlugRegistryError } from '../src/core/output/slug-registry.ts';
|
|
import {
|
|
tweetCitation,
|
|
emailCitation,
|
|
sourceCitation,
|
|
entityLink,
|
|
timelineLine,
|
|
ScaffoldError,
|
|
} from '../src/core/output/scaffold.ts';
|
|
import {
|
|
citationValidator,
|
|
linkValidator,
|
|
backLinkValidator,
|
|
tripleHrValidator,
|
|
registerBuiltinValidators,
|
|
} from '../src/core/output/validators/index.ts';
|
|
import {
|
|
splitParagraphs,
|
|
} from '../src/core/output/validators/citation.ts';
|
|
import {
|
|
normalizeToSlug,
|
|
isExternalUrl,
|
|
isNonBrainRef,
|
|
} from '../src/core/output/validators/link.ts';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Engine fixture
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let engine: BrainEngine;
|
|
let dbDir: string;
|
|
|
|
beforeAll(async () => {
|
|
dbDir = mkdtempSync(join(tmpdir(), 'writer-test-'));
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({ engine: 'pglite', database_path: dbDir });
|
|
await engine.initSchema();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
rmSync(dbDir, { recursive: true, force: true });
|
|
});
|
|
|
|
// Reset DB between tests by truncating — cheaper than tearing down PGLite.
|
|
async function reset(): Promise<void> {
|
|
await engine.executeRaw('TRUNCATE pages, links, content_chunks, timeline_entries, tags, raw_data, page_versions RESTART IDENTITY CASCADE');
|
|
}
|
|
|
|
function makeCtx(overrides: Partial<ResolverContext> = {}): ResolverContext {
|
|
return {
|
|
config: {},
|
|
logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
|
|
requestId: 'test',
|
|
remote: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Scaffolder
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Scaffolder', () => {
|
|
test('tweetCitation builds canonical form', () => {
|
|
const c = tweetCitation({ handle: 'garrytan', tweetId: '1234567890', dateISO: '2026-04-18' });
|
|
expect(c).toBe('[Source: [X/garrytan, 2026-04-18](https://x.com/garrytan/status/1234567890)]');
|
|
});
|
|
|
|
test('tweetCitation strips leading @', () => {
|
|
const c = tweetCitation({ handle: '@garrytan', tweetId: '1', dateISO: '2026-04-18' });
|
|
expect(c).toContain('X/garrytan');
|
|
expect(c).not.toContain('@garrytan');
|
|
});
|
|
|
|
test('tweetCitation rejects invalid handle', () => {
|
|
expect(() => tweetCitation({ handle: 'not a handle', tweetId: '1' })).toThrow(ScaffoldError);
|
|
});
|
|
|
|
test('tweetCitation rejects non-numeric tweet id', () => {
|
|
expect(() => tweetCitation({ handle: 'garrytan', tweetId: 'abc' })).toThrow(ScaffoldError);
|
|
});
|
|
|
|
test('tweetCitation rejects bad date format', () => {
|
|
expect(() => tweetCitation({ handle: 'garrytan', tweetId: '1', dateISO: '2026/04/18' })).toThrow(ScaffoldError);
|
|
});
|
|
|
|
test('emailCitation builds deep link and encodes account', () => {
|
|
const c = emailCitation({
|
|
account: 'garry@ycombinator.com',
|
|
messageId: 'abc123def456',
|
|
subject: 'Re: Deal',
|
|
dateISO: '2026-04-18',
|
|
});
|
|
expect(c).toContain('garry%40ycombinator.com');
|
|
expect(c).toContain('#inbox/abc123def456');
|
|
expect(c).toContain('"Re: Deal"');
|
|
});
|
|
|
|
test('emailCitation rejects short message id', () => {
|
|
expect(() => emailCitation({
|
|
account: 'x',
|
|
messageId: 'short',
|
|
subject: 'x',
|
|
})).toThrow(ScaffoldError);
|
|
});
|
|
|
|
test('sourceCitation with url', () => {
|
|
const r = sourceCitation({ source: 'perplexity-sonar', fetchedAt: new Date('2026-04-18') }, { url: 'https://example.com/r' });
|
|
expect(r).toBe('[Source: [perplexity-sonar, 2026-04-18](https://example.com/r)]');
|
|
});
|
|
|
|
test('sourceCitation without url', () => {
|
|
const r = sourceCitation({ source: 'perplexity-sonar', fetchedAt: new Date('2026-04-18') });
|
|
expect(r).toBe('[Source: perplexity-sonar, 2026-04-18]');
|
|
});
|
|
|
|
test('entityLink prefix + slug', () => {
|
|
const l = entityLink({ slug: 'people/alice-smith', displayText: 'Alice', relativePrefix: '../../' });
|
|
expect(l).toBe('[Alice](../../people/alice-smith.md)');
|
|
});
|
|
|
|
test('entityLink sanitizes display text', () => {
|
|
// Newlines → spaces, brackets stripped, trimmed
|
|
const l = entityLink({ slug: 'people/alice', displayText: 'A\nli[ce]' });
|
|
expect(l).toBe('[A lice](people/alice.md)');
|
|
});
|
|
|
|
test('entityLink rejects invalid slug', () => {
|
|
expect(() => entityLink({ slug: 'invalid', displayText: 'x' })).toThrow(ScaffoldError);
|
|
expect(() => entityLink({ slug: 'Bad/Slug', displayText: 'x' })).toThrow(ScaffoldError);
|
|
});
|
|
|
|
test('timelineLine builds canonical form', () => {
|
|
const l = timelineLine({ dateISO: '2026-04-18', summary: 'Met Alice', citation: '[Source: x, y]' });
|
|
expect(l).toBe('- **2026-04-18** | Met Alice [Source: x, y]');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SlugRegistry
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('SlugRegistry', () => {
|
|
beforeEach(async () => { await reset(); });
|
|
|
|
test('create on empty brain returns desired slug', async () => {
|
|
const reg = new SlugRegistry(engine);
|
|
const r = await reg.create({
|
|
desiredSlug: 'people/alice-smith',
|
|
displayName: 'Alice Smith',
|
|
type: 'person',
|
|
});
|
|
expect(r.slug).toBe('people/alice-smith');
|
|
expect(r.exact).toBe(true);
|
|
expect(r.disambiguator).toBeUndefined();
|
|
});
|
|
|
|
test('create disambiguates on collision', async () => {
|
|
const reg = new SlugRegistry(engine);
|
|
await engine.putPage('people/alice-smith', {
|
|
type: 'person', title: 'Alice Smith', compiled_truth: 'x', frontmatter: {},
|
|
});
|
|
const r = await reg.create({
|
|
desiredSlug: 'people/alice-smith',
|
|
displayName: 'Different Alice',
|
|
type: 'person',
|
|
});
|
|
expect(r.slug).toBe('people/alice-smith-2');
|
|
expect(r.exact).toBe(false);
|
|
expect(r.disambiguator).toBe(2);
|
|
});
|
|
|
|
test('create throws on collision when onCollision=throw', async () => {
|
|
const reg = new SlugRegistry(engine);
|
|
await engine.putPage('people/bob', { type: 'person', title: 'Bob', compiled_truth: 'x', frontmatter: {} });
|
|
await expect(reg.create({
|
|
desiredSlug: 'people/bob',
|
|
displayName: 'Bob',
|
|
type: 'person',
|
|
onCollision: 'throw',
|
|
})).rejects.toThrow(SlugRegistryError);
|
|
});
|
|
|
|
test('create throws on invalid slug', async () => {
|
|
const reg = new SlugRegistry(engine);
|
|
await expect(reg.create({
|
|
desiredSlug: 'bad slug with spaces',
|
|
displayName: 'x',
|
|
type: 'person',
|
|
})).rejects.toThrow(SlugRegistryError);
|
|
});
|
|
|
|
test('isFree + suggestDisambiguators', async () => {
|
|
const reg = new SlugRegistry(engine);
|
|
await engine.putPage('people/charlie', { type: 'person', title: 'Charlie', compiled_truth: 'x', frontmatter: {} });
|
|
expect(await reg.isFree('people/charlie')).toBe(false);
|
|
expect(await reg.isFree('people/dave')).toBe(true);
|
|
const suggestions = await reg.suggestDisambiguators('people/charlie', 3);
|
|
expect(suggestions).toEqual(['people/charlie-2', 'people/charlie-3', 'people/charlie-4']);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BrainWriter
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('BrainWriter', () => {
|
|
beforeEach(async () => { await reset(); });
|
|
|
|
test('transaction creates entity, returns slug + empty report', async () => {
|
|
const writer = new BrainWriter(engine);
|
|
const { result, report } = await writer.transaction(async (tx) => {
|
|
return tx.createEntity({
|
|
desiredSlug: 'people/alice',
|
|
displayName: 'Alice',
|
|
type: 'person',
|
|
compiledTruth: 'Alice is a person.',
|
|
});
|
|
}, makeCtx());
|
|
expect(result).toBe('people/alice');
|
|
expect(report.errorCount).toBe(0);
|
|
expect(report.touchedSlugs).toEqual(['people/alice']);
|
|
});
|
|
|
|
test('transaction disambiguates slug collision', async () => {
|
|
const writer = new BrainWriter(engine);
|
|
await writer.transaction(async (tx) => tx.createEntity({
|
|
desiredSlug: 'people/eve',
|
|
displayName: 'Eve',
|
|
type: 'person',
|
|
compiledTruth: 'first eve',
|
|
}), makeCtx());
|
|
const { result } = await writer.transaction(async (tx) => tx.createEntity({
|
|
desiredSlug: 'people/eve',
|
|
displayName: 'Eve (different)',
|
|
type: 'person',
|
|
compiledTruth: 'second eve',
|
|
}), makeCtx());
|
|
expect(result).toBe('people/eve-2');
|
|
});
|
|
|
|
test('addLink creates forward + back-link', async () => {
|
|
const writer = new BrainWriter(engine);
|
|
await writer.transaction(async (tx) => {
|
|
await tx.createEntity({ desiredSlug: 'people/a', displayName: 'A', type: 'person', compiledTruth: 'a' });
|
|
await tx.createEntity({ desiredSlug: 'people/b', displayName: 'B', type: 'person', compiledTruth: 'b' });
|
|
await tx.addLink('people/a', 'people/b', 'connected', 'knows');
|
|
}, makeCtx());
|
|
|
|
const outbound = await engine.getLinks('people/a');
|
|
const inbound = await engine.getBacklinks('people/a');
|
|
expect(outbound.map(l => l.to_slug)).toContain('people/b');
|
|
expect(inbound.map(l => l.from_slug)).toContain('people/b');
|
|
});
|
|
|
|
test('strict mode rolls back on validator error', async () => {
|
|
const writer = new BrainWriter(engine, { strictMode: 'strict' });
|
|
writer.register({
|
|
id: 'synthetic-fail',
|
|
async validate({ slug }) {
|
|
return [{ slug, validator: 'synthetic-fail', severity: 'error', message: 'boom' }];
|
|
},
|
|
});
|
|
await expect(writer.transaction(async (tx) => {
|
|
await tx.createEntity({
|
|
desiredSlug: 'people/ghost',
|
|
displayName: 'Ghost',
|
|
type: 'person',
|
|
compiledTruth: 'x',
|
|
});
|
|
}, makeCtx())).rejects.toThrow(WriteError);
|
|
|
|
// Page should not exist after rollback
|
|
const page = await engine.getPage('people/ghost');
|
|
expect(page).toBeNull();
|
|
});
|
|
|
|
test('lint mode does NOT roll back on validator error', async () => {
|
|
const writer = new BrainWriter(engine, { strictMode: 'lint' });
|
|
writer.register({
|
|
id: 'synthetic-fail',
|
|
async validate({ slug }) {
|
|
return [{ slug, validator: 'synthetic-fail', severity: 'error', message: 'still writes in lint' }];
|
|
},
|
|
});
|
|
const { result, report } = await writer.transaction(async (tx) => {
|
|
return tx.createEntity({
|
|
desiredSlug: 'people/lint-test',
|
|
displayName: 'Lint',
|
|
type: 'person',
|
|
compiledTruth: 'x',
|
|
});
|
|
}, makeCtx());
|
|
expect(result).toBe('people/lint-test');
|
|
expect(report.errorCount).toBe(1);
|
|
const page = await engine.getPage('people/lint-test');
|
|
expect(page).not.toBeNull();
|
|
});
|
|
|
|
test('off mode skips validators entirely', async () => {
|
|
const writer = new BrainWriter(engine, { strictMode: 'off' });
|
|
let called = 0;
|
|
writer.register({
|
|
id: 'should-not-run',
|
|
async validate() { called++; return []; },
|
|
});
|
|
await writer.transaction(async (tx) => {
|
|
await tx.createEntity({ desiredSlug: 'people/no-validator', displayName: 'x', type: 'person', compiledTruth: 'x' });
|
|
}, makeCtx());
|
|
expect(called).toBe(0);
|
|
});
|
|
|
|
test('validators skip pages with validate:false frontmatter', async () => {
|
|
const writer = new BrainWriter(engine, { strictMode: 'strict' });
|
|
let called = 0;
|
|
writer.register({
|
|
id: 'count',
|
|
async validate() { called++; return []; },
|
|
});
|
|
await writer.transaction(async (tx) => {
|
|
await tx.createEntity({
|
|
desiredSlug: 'people/grandfathered',
|
|
displayName: 'Old',
|
|
type: 'person',
|
|
compiledTruth: 'legacy content without citations',
|
|
frontmatter: { validate: false },
|
|
});
|
|
}, makeCtx());
|
|
expect(called).toBe(0);
|
|
});
|
|
|
|
test('setCompiledTruth updates existing page', async () => {
|
|
const writer = new BrainWriter(engine);
|
|
await writer.transaction(async (tx) => tx.createEntity({
|
|
desiredSlug: 'people/update',
|
|
displayName: 'Update',
|
|
type: 'person',
|
|
compiledTruth: 'original',
|
|
}), makeCtx());
|
|
await writer.transaction(async (tx) => tx.setCompiledTruth('people/update', 'updated'), makeCtx());
|
|
const page = await engine.getPage('people/update');
|
|
expect(page?.compiled_truth).toBe('updated');
|
|
});
|
|
|
|
test('setFrontmatterField merges into existing frontmatter', async () => {
|
|
const writer = new BrainWriter(engine);
|
|
await writer.transaction(async (tx) => tx.createEntity({
|
|
desiredSlug: 'people/fm',
|
|
displayName: 'FM',
|
|
type: 'person',
|
|
compiledTruth: 'x',
|
|
frontmatter: { role: 'founder' },
|
|
}), makeCtx());
|
|
await writer.transaction(async (tx) => tx.setFrontmatterField('people/fm', 'validate', false), makeCtx());
|
|
const page = await engine.getPage('people/fm');
|
|
expect(page?.frontmatter?.role).toBe('founder');
|
|
expect(page?.frontmatter?.validate).toBe(false);
|
|
});
|
|
|
|
test('registeredValidators lists ids', () => {
|
|
const writer = new BrainWriter(engine);
|
|
registerBuiltinValidators(writer);
|
|
expect(writer.registeredValidators).toEqual(['citation', 'link', 'back-link', 'triple-hr']);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Citation validator (pure, no engine needed for most cases)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('citation validator', () => {
|
|
beforeEach(async () => { await reset(); });
|
|
|
|
async function run(compiled: string, slug = 'concepts/test'): Promise<ReturnType<typeof citationValidator.validate>> {
|
|
return citationValidator.validate({
|
|
slug,
|
|
type: 'concept',
|
|
compiledTruth: compiled,
|
|
timeline: '',
|
|
frontmatter: {},
|
|
engine,
|
|
});
|
|
}
|
|
|
|
test('passes paragraph with [Source: ...]', async () => {
|
|
const findings = await run('Alice was a founder [Source: X/garrytan, 2026-04-18].');
|
|
expect(findings).toEqual([]);
|
|
});
|
|
|
|
test('passes paragraph with inline URL', async () => {
|
|
const findings = await run('She wrote [an essay](https://example.com/essay) about scaling.');
|
|
expect(findings).toEqual([]);
|
|
});
|
|
|
|
test('flags factual paragraph missing citation', async () => {
|
|
const findings = await run('Alice raised $5M in Series A from Sequoia.');
|
|
expect(findings).toHaveLength(1);
|
|
expect(findings[0].severity).toBe('error');
|
|
expect(findings[0].validator).toBe('citation');
|
|
});
|
|
|
|
test('ignores headings', async () => {
|
|
const findings = await run('# Big header\n## Subhead');
|
|
expect(findings).toEqual([]);
|
|
});
|
|
|
|
test('ignores key-value lines', async () => {
|
|
const findings = await run('**Status:** Active');
|
|
expect(findings).toEqual([]);
|
|
});
|
|
|
|
test('ignores code fences entirely', async () => {
|
|
const findings = await run('```\nThis paragraph inside code has no citation and should NOT trigger\n```');
|
|
expect(findings).toEqual([]);
|
|
});
|
|
|
|
test('a [Source:] INSIDE a code fence does NOT satisfy the check for surrounding prose', async () => {
|
|
const compiled = `Alice raised money.
|
|
|
|
\`\`\`
|
|
[Source: fake]
|
|
\`\`\``;
|
|
const findings = await run(compiled);
|
|
expect(findings.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('ignores inline code within paragraph (but paragraph still needs citation)', async () => {
|
|
const compiled = 'Alice shipped `gbrain` last week.';
|
|
const findings = await run(compiled);
|
|
expect(findings).toHaveLength(1);
|
|
});
|
|
|
|
test('ignores pure wikilink bullets (See Also style)', async () => {
|
|
const compiled = '- [Alice](../people/alice.md)';
|
|
const findings = await run(compiled);
|
|
expect(findings).toEqual([]);
|
|
});
|
|
|
|
test('ignores HTML comments', async () => {
|
|
const compiled = '<!-- This is a note -->';
|
|
const findings = await run(compiled);
|
|
expect(findings).toEqual([]);
|
|
});
|
|
|
|
test('ignores blockquotes', async () => {
|
|
const compiled = '> quoted content without citation';
|
|
const findings = await run(compiled);
|
|
expect(findings).toEqual([]);
|
|
});
|
|
|
|
test('empty [Source:] marker does NOT satisfy citation check', async () => {
|
|
const findings = await run('Alice raised $5M in Series A from Sequoia [Source:].');
|
|
expect(findings).toHaveLength(1);
|
|
expect(findings[0].validator).toBe('citation');
|
|
});
|
|
|
|
test('whitespace-only [Source: ] marker does NOT satisfy citation check', async () => {
|
|
const findings = await run('Alice raised $5M in Series A from Sequoia [Source: ].');
|
|
expect(findings).toHaveLength(1);
|
|
});
|
|
|
|
test('splitParagraphs handles blank-line separation', () => {
|
|
const input = 'First para.\n\nSecond para.';
|
|
const out = splitParagraphs(input);
|
|
expect(out).toHaveLength(2);
|
|
expect(out[0].startLine).toBe(1);
|
|
expect(out[1].startLine).toBe(3);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Link validator
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('link validator', () => {
|
|
beforeEach(async () => { await reset(); });
|
|
|
|
test('normalizeToSlug strips relative prefix + .md', () => {
|
|
expect(normalizeToSlug('people/alice.md')).toBe('people/alice');
|
|
expect(normalizeToSlug('../../people/alice.md')).toBe('people/alice');
|
|
expect(normalizeToSlug('/people/alice')).toBe('people/alice');
|
|
expect(normalizeToSlug('companies/acme/labs')).toBe('companies/acme/labs');
|
|
});
|
|
|
|
test('normalizeToSlug returns null for non-slug shapes', () => {
|
|
expect(normalizeToSlug('mailto:x@y')).toBeNull();
|
|
expect(normalizeToSlug('just-one-component')).toBeNull();
|
|
expect(normalizeToSlug('x')).toBeNull();
|
|
});
|
|
|
|
test('isExternalUrl detects http(s)', () => {
|
|
expect(isExternalUrl('https://example.com')).toBe(true);
|
|
expect(isExternalUrl('http://example.com')).toBe(true);
|
|
expect(isExternalUrl('people/alice.md')).toBe(false);
|
|
});
|
|
|
|
test('isNonBrainRef detects mailto/anchor/etc', () => {
|
|
expect(isNonBrainRef('mailto:x@y.com')).toBe(true);
|
|
expect(isNonBrainRef('#section')).toBe(true);
|
|
expect(isNonBrainRef('people/alice.md')).toBe(false);
|
|
});
|
|
|
|
test('flags dangling wikilink', async () => {
|
|
const findings = await linkValidator.validate({
|
|
slug: 'people/bob',
|
|
type: 'person',
|
|
compiledTruth: 'Bob met [Alice](../people/alice.md) yesterday [Source: meeting, 2026-04-18]',
|
|
timeline: '',
|
|
frontmatter: {},
|
|
engine,
|
|
});
|
|
expect(findings.length).toBeGreaterThan(0);
|
|
expect(findings[0].severity).toBe('error');
|
|
expect(findings[0].message).toContain('people/alice');
|
|
});
|
|
|
|
test('passes when wikilink target exists', async () => {
|
|
await engine.putPage('people/alice', { type: 'person', title: 'Alice', compiled_truth: 'x', frontmatter: {} });
|
|
const findings = await linkValidator.validate({
|
|
slug: 'people/bob',
|
|
type: 'person',
|
|
compiledTruth: 'Bob met [Alice](../people/alice.md).',
|
|
timeline: '',
|
|
frontmatter: {},
|
|
engine,
|
|
});
|
|
expect(findings).toEqual([]);
|
|
});
|
|
|
|
test('ignores external URLs', async () => {
|
|
const findings = await linkValidator.validate({
|
|
slug: 'concepts/x',
|
|
type: 'concept',
|
|
compiledTruth: 'Read [this](https://example.com/page) for context.',
|
|
timeline: '',
|
|
frontmatter: {},
|
|
engine,
|
|
});
|
|
expect(findings).toEqual([]);
|
|
});
|
|
|
|
test('flags mailto as warning', async () => {
|
|
const findings = await linkValidator.validate({
|
|
slug: 'concepts/x',
|
|
type: 'concept',
|
|
compiledTruth: 'Email [me](mailto:x@y.com).',
|
|
timeline: '',
|
|
frontmatter: {},
|
|
engine,
|
|
});
|
|
expect(findings.some(f => f.severity === 'warning')).toBe(true);
|
|
});
|
|
|
|
test('ignores links inside fenced code', async () => {
|
|
const compiled = '```\n[link](../people/not-real.md)\n```';
|
|
const findings = await linkValidator.validate({
|
|
slug: 'concepts/x',
|
|
type: 'concept',
|
|
compiledTruth: compiled,
|
|
timeline: '',
|
|
frontmatter: {},
|
|
engine,
|
|
});
|
|
expect(findings).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Back-link validator
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('back-link validator', () => {
|
|
beforeEach(async () => { await reset(); });
|
|
|
|
test('no outbound links → no findings', async () => {
|
|
await engine.putPage('people/isolated', { type: 'person', title: 'x', compiled_truth: 'x', frontmatter: {} });
|
|
const findings = await backLinkValidator.validate({
|
|
slug: 'people/isolated',
|
|
type: 'person',
|
|
compiledTruth: 'x',
|
|
timeline: '',
|
|
frontmatter: {},
|
|
engine,
|
|
});
|
|
expect(findings).toEqual([]);
|
|
});
|
|
|
|
test('outbound link without reverse → warning', async () => {
|
|
await engine.putPage('people/x', { type: 'person', title: 'x', compiled_truth: 'x', frontmatter: {} });
|
|
await engine.putPage('people/y', { type: 'person', title: 'y', compiled_truth: 'y', frontmatter: {} });
|
|
await engine.addLink('people/x', 'people/y', 'mentions', 'mentions');
|
|
// no reverse back-link
|
|
|
|
const findings = await backLinkValidator.validate({
|
|
slug: 'people/x',
|
|
type: 'person',
|
|
compiledTruth: 'x',
|
|
timeline: '',
|
|
frontmatter: {},
|
|
engine,
|
|
});
|
|
expect(findings.length).toBe(1);
|
|
expect(findings[0].severity).toBe('warning');
|
|
expect(findings[0].message).toContain('people/y');
|
|
});
|
|
|
|
test('bidirectional links → no findings', async () => {
|
|
await engine.putPage('people/a', { type: 'person', title: 'a', compiled_truth: 'x', frontmatter: {} });
|
|
await engine.putPage('people/b', { type: 'person', title: 'b', compiled_truth: 'x', frontmatter: {} });
|
|
await engine.addLink('people/a', 'people/b', 'x', 'knows');
|
|
await engine.addLink('people/b', 'people/a', 'x', 'knows_back');
|
|
|
|
const findings = await backLinkValidator.validate({
|
|
slug: 'people/a',
|
|
type: 'person',
|
|
compiledTruth: 'x',
|
|
timeline: '',
|
|
frontmatter: {},
|
|
engine,
|
|
});
|
|
expect(findings).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Triple-HR validator
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('triple-hr validator', () => {
|
|
test('no issues on clean compiled_truth', async () => {
|
|
const findings = await tripleHrValidator.validate({
|
|
slug: 'people/clean',
|
|
type: 'person',
|
|
compiledTruth: 'Clean content, no bar in compiled_truth.',
|
|
timeline: '- **2026-04-18** | Met',
|
|
frontmatter: {},
|
|
engine,
|
|
});
|
|
expect(findings).toEqual([]);
|
|
});
|
|
|
|
test('bare --- in compiled_truth flags warning', async () => {
|
|
const compiled = 'Alice did a thing.\n\n---\n\nAnd another.';
|
|
const findings = await tripleHrValidator.validate({
|
|
slug: 'people/dangerous',
|
|
type: 'person',
|
|
compiledTruth: compiled,
|
|
timeline: '',
|
|
frontmatter: {},
|
|
engine,
|
|
});
|
|
expect(findings.some(f => f.message.includes('---'))).toBe(true);
|
|
expect(findings[0].severity).toBe('warning');
|
|
});
|
|
|
|
test('--- inside code fence does NOT flag', async () => {
|
|
const compiled = 'Content.\n\n```\n---\nshown as output\n---\n```';
|
|
const findings = await tripleHrValidator.validate({
|
|
slug: 'people/safe',
|
|
type: 'person',
|
|
compiledTruth: compiled,
|
|
timeline: '',
|
|
frontmatter: {},
|
|
engine,
|
|
});
|
|
expect(findings).toEqual([]);
|
|
});
|
|
|
|
test('heading in timeline → warning', async () => {
|
|
const findings = await tripleHrValidator.validate({
|
|
slug: 'people/spill',
|
|
type: 'person',
|
|
compiledTruth: 'x',
|
|
timeline: '## This should not be here\n- **2026-04-18** | event',
|
|
frontmatter: {},
|
|
engine,
|
|
});
|
|
expect(findings.some(f => f.message.includes('Heading in timeline'))).toBe(true);
|
|
});
|
|
|
|
test('## Timeline header line in timeline is allowed', async () => {
|
|
const findings = await tripleHrValidator.validate({
|
|
slug: 'people/ok',
|
|
type: 'person',
|
|
compiledTruth: 'x',
|
|
timeline: '## Timeline\n- **2026-04-18** | event',
|
|
frontmatter: {},
|
|
engine,
|
|
});
|
|
expect(findings).toEqual([]);
|
|
});
|
|
});
|