Compare commits

..
Author SHA1 Message Date
Garry TanandClaude Fable 5 c275fa3fab fix(cli): pre-set GBRAIN_EVAL_CAPTURE/SCRUB_PII env wins over DB stash
The #1475 stash unconditionally overwrote the env vars from the merged DB
plane, silently clobbering an operator's exported value — inverting the
env-above-config precedence the same code comment advertises. Unlike
GBRAIN_EMBEDDING_MULTIMODAL, these keys have no loadConfig() env mapping,
so file/env-wins in loadConfigWithEngine never covered them. Guard the
stash writes: DB fills the gap only when the env var is unset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 12:06:59 -07:00
Garry TanandClaude Fable 5 ee095f1ca2 test(progress): assert net-zero live-reporter leak, not process-global zero
The signal-handler test asserted __liveReporterCountForTest() === 0 — a
process-global absolute that any earlier test file in the same bun shard
can poison (a production path that skips finish() on an error branch
leaves one live entry behind). Shard-5 LPT packing started co-locating
such a file before progress.test.ts, failing this test deterministically
on CI across unrelated PRs while master stayed green by packing luck.

Snapshot the count before the 50 lifecycles and assert no NET leak —
the same prior-state tolerance the handler assertion already applies
via installedBefore. The test still catches its own regression class
(any leak from these lifecycles shows up as delta >= 1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 11:22:40 -07:00
Garry Tan 5f123c1404 Merge remote-tracking branch 'origin/master' into fix/backlog-c136
# Conflicts:
#	src/cli.ts
2026-07-22 11:00:24 -07:00
948ccc7b4f fix(cli): wire gbrain bench publish dispatcher + honor DB-plane eval.capture
Two backlog fixes:

1. Takeover of #1476 (closes #1474): 'bench' was missing from CLI_ONLY and
   runBenchPublish was imported nowhere, so the documented
   'gbrain bench publish' hit 'Unknown command'. Adds the 'bench' token to
   master's current CLI_ONLY set (the original PR rewrote the line from a
   stale v0.41.14 snapshot, deleting ~15 newer commands), routes bench
   through a no-DB bypass (bench publish is pure file I/O), and adds
   'bench' to CLI_ONLY_SELF_HELP so --help reaches bench-publish's own
   usage text.

2. Fixes #1475: 'gbrain config set eval.capture true' persisted to the DB
   plane but was never read — the capture gate reads the sync file-plane
   ctx.config. loadConfigWithEngine now sparse-merges eval.capture /
   eval.scrub_pii (file wins per key), connectEngine stashes the merged
   values on GBRAIN_EVAL_CAPTURE / GBRAIN_EVAL_SCRUB_PII (same pattern as
   the multimodal flags), and the gates consult the stash when the file
   plane is silent.

Co-authored-by: Mr-B-1 <Mr-B-1@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:30:23 -07:00
19 changed files with 253 additions and 534 deletions
+2 -2
View File
@@ -23,7 +23,7 @@ health_checks:
label: "Auth provider"
checks:
- type: http
url: "$CLAWVISOR_URL/ready"
url: "$CLAWVISOR_URL/health"
label: "ClawVisor"
- type: env_exists
name: GOOGLE_CLIENT_ID
@@ -135,7 +135,7 @@ Tell the user:
Validate:
```bash
curl -sf "$CLAWVISOR_URL/ready" && echo "PASS: ClawVisor reachable" || echo "FAIL"
curl -sf "$CLAWVISOR_URL/health" && echo "PASS: ClawVisor reachable" || echo "FAIL"
```
**STOP until ClawVisor validates.**
-308
View File
@@ -1,308 +0,0 @@
---
id: contacts-to-brain
name: Contacts-to-Brain
version: 0.1.0
description: Google Contacts become canonical people/ pages, enriching brain entities with ground-truth name/email/phone/org data.
category: sense
requires: [credential-gateway]
secrets:
- name: CLAWVISOR_URL
description: ClawVisor gateway URL (Option A — recommended, handles OAuth for you)
where: https://clawvisor.com — create an agent, activate Google Contacts service
- name: CLAWVISOR_AGENT_TOKEN
description: ClawVisor agent token (Option A)
where: https://clawvisor.com — agent settings, copy the agent token
- name: GOOGLE_CLIENT_ID
description: Google OAuth2 client ID (Option B — direct API access, you manage tokens)
where: https://console.cloud.google.com/apis/credentials — create OAuth 2.0 Client ID
- name: GOOGLE_CLIENT_SECRET
description: Google OAuth2 client secret (Option B)
where: https://console.cloud.google.com/apis/credentials — same page as client ID
health_checks:
- type: any_of
label: "Auth provider"
checks:
- type: http
url: "$CLAWVISOR_URL/ready"
label: "ClawVisor"
- type: env_exists
name: GOOGLE_CLIENT_ID
label: "Google OAuth"
setup_time: 15 min
cost_estimate: "$0 (both options are free)"
---
# Contacts-to-Brain: Your Address Book Becomes Ground Truth
Calendar attendees and email senders are the bulk of `people/<slug>` brain pages.
Your Google Contacts is the ground-truth directory — canonical name, email, phone,
organization — for those same entities. Syncing it closes the "who is this person"
loop automatically and feeds enrichment.
## IMPORTANT: Instructions for the Agent
**You are the installer.** Follow these steps precisely.
**Why this matters:** email-to-brain and calendar-to-brain create people pages
from whatever name string the API returned — sometimes an email prefix, sometimes
a nickname. Contacts carries the authoritative record. After this recipe runs,
"j.smith@acme-example.com" and "Jon S" resolve to the same person page with the
right display name, phone, and company.
**The output is staging files, not direct writes:** the deterministic collector
dumps contact records to `brain/contacts/.staging/`; YOU (the agent) merge them
into `people/<slug>` pages using judgment — the Notability Gate in
`skills/_brain-filing-rules.md` applies. Not every contact deserves a page.
**Do not skip steps. Verify after each step.**
## Architecture
```
Google Contacts (People API, paginated)
↓ (ClawVisor credential gateway: list_contacts / get_contact / search_contacts)
Contacts Sync Script (deterministic Node.js)
↓ Outputs:
├── brain/contacts/.raw/contacts-{date}.json (raw API responses, provenance)
└── brain/contacts/.staging/{slug}.md (one markdown record per contact)
Agent reads staging files
↓ Judgment calls (Notability Gate):
├── Merge into existing people/<slug> pages (name/email/phone/org enrichment)
├── Create new pages ONLY for notable contacts not already in brain
└── Skip the rest (staging is not the brain)
```
## Opinionated Defaults
**Staging record format** (one file per contact, deterministic):
```markdown
# Alice Example
- **Emails:** alice@acme-example.com, alice@gmail.com
- **Phone:** +1 555 0100
- **Organization:** Acme Example — VP Engineering
- **Source:** Google Contacts (resourceName people/c123, synced 2026-07-21)
```
**Enrichment, not duplication:** if `people/alice-example.md` already exists,
append missing fields to it with a `[Source: Google Contacts]` citation. Do NOT
create a second page. Slug-match by normalized name, then by email against
existing page content.
**Notability Gate (from `skills/_brain-filing-rules.md`):** a contact with no
brain presence gets a new page only if they appear elsewhere in the brain
(calendar attendee, email correspondent) or the user confirms they matter.
When in doubt, DON'T create — a junk page degrades search quality.
## Prerequisites
1. **GBrain installed and configured** (`gbrain doctor` passes)
2. **Node.js 18+** (for the sync script)
3. **Google Contacts access** via ONE of:
- **Option A: ClawVisor** (recommended, handles OAuth for you, no token management)
- **Option B: Google OAuth2 directly** (you manage tokens, no extra service needed)
## Setup Flow
### Step 1: Choose and Configure Contacts Access
Ask the user: "How do you want to connect to Google Contacts?
**Option A: ClawVisor (recommended)**
ClawVisor handles OAuth, token refresh, and encryption. If you already use
ClawVisor for email-to-brain or calendar-to-brain, this uses the same setup —
just activate the Google Contacts service on your existing agent.
**Option B: Google OAuth2 directly**
Connect to the Google People API directly. No extra service needed, but you
manage OAuth tokens yourself."
#### Option A: ClawVisor Setup
Tell the user:
"I need your ClawVisor URL and agent token.
1. Go to https://clawvisor.com
2. Create an agent (or use existing)
3. Activate the **Google Contacts** service
4. Create a standing task with purpose: 'Full contacts access for people
enrichment: list contacts, read contact details, search contacts across
all connected Google accounts.'
IMPORTANT: Be EXPANSIVE in the task purpose. Narrow purposes block requests.
5. Copy the gateway URL and agent token"
Validate:
```bash
curl -sf "$CLAWVISOR_URL/ready" && echo "PASS: ClawVisor reachable" || echo "FAIL"
```
**STOP until ClawVisor validates.**
#### Option B: Google OAuth2 Setup
Same flow as `recipes/credential-gateway.md` Option B, with the contacts scope:
1. https://console.cloud.google.com/apis/credentials — create an OAuth client ID
(Desktop app), consent screen scope: `https://www.googleapis.com/auth/contacts.readonly`
2. Enable the People API: https://console.cloud.google.com/apis/library/people.googleapis.com
3. Run the OAuth flow; store tokens in `~/.gbrain/google-tokens.json` (auto-refresh on expiry)
Validate:
```bash
[ -n "$GOOGLE_CLIENT_ID" ] && [ -n "$GOOGLE_CLIENT_SECRET" ] \
&& echo "PASS: Google OAuth credentials set" \
|| echo "FAIL: Missing GOOGLE_CLIENT_ID or GOOGLE_CLIENT_SECRET"
```
**STOP until OAuth flow completes and tokens are stored.**
### Step 2: Set Up the Contacts Sync Script
```bash
mkdir -p contacts-sync
cd contacts-sync
npm init -y
```
The sync script needs these capabilities:
1. **Paginated retrieval**`list_contacts` (People API `people.connections.list`)
returns pages of up to 100; follow `nextPageToken` until exhausted. Request
fields: names, emailAddresses, phoneNumbers, organizations, metadata.
2. **Deterministic staging output** — one markdown file per contact at
`brain/contacts/.staging/{slug}.md`, slug from normalized display name
(fall back to email prefix). Same contact = same file on every run (idempotent).
3. **Raw JSON preservation** — save raw API responses to
`brain/contacts/.raw/contacts-{date}.json` for provenance.
4. **Skip empty records** — contacts with no name AND no email are noise; drop them.
### Step 3: Run the Full Sync
```bash
node contacts-sync.mjs
```
Verify:
```bash
ls brain/contacts/.staging/ | head -10
```
Should show one file per contact, e.g. `alice-example.md`, `charlie-example.md`.
### Step 4: Enrich People Pages (Agent Judgment)
This is YOUR job (the agent). For each staging record:
1. **Check brain**: `gbrain search "contact name"` — do they have a
`people/<slug>` page? Also search by email address.
2. **Existing page** → merge the ground-truth fields (canonical name, emails,
phone, organization) into the page, each with a
`[Source: Google Contacts]` citation. Fix a wrong/partial display name.
3. **No page** → apply the Notability Gate: create a page only if the contact
already appears in the brain (calendar, email) or is clearly relevant.
Otherwise skip.
4. **Back-link** per the Iron Law in `skills/_brain-filing-rules.md`: an
organization with a brain page gets a link from the person's page and back.
After enrichment, import and embed:
```bash
gbrain sync --no-pull --no-embed && gbrain embed --stale
```
Verify:
```bash
gbrain search "alice-example" --limit 3
```
Should return the enriched people page with contact details.
### Step 5: Set Up Weekly Sync
Contacts change slowly; once a week is plenty:
```bash
# Cron: every Sunday at 9 AM
0 9 * * 0 cd /path/to/contacts-sync && node contacts-sync.mjs
```
After each sync, re-run the Step 4 enrichment pass over CHANGED staging files
only (compare mtime or diff against git), then:
```bash
gbrain sync --no-pull --no-embed && gbrain embed --stale
```
### Step 6: Log Setup Completion
```bash
mkdir -p ~/.gbrain/integrations/contacts-to-brain
echo '{"ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","event":"setup_complete","source_version":"0.1.0","status":"ok","details":{"contacts":"CONTACT_COUNT"}}' >> ~/.gbrain/integrations/contacts-to-brain/heartbeat.jsonl
```
Tell the user: "Contacts-to-brain is set up. [N] contacts staged; [M] people
pages enriched with ground-truth contact data. Weekly sync keeps it current."
## Implementation Guide
### Pagination
```
list_all_contacts():
contacts = []
token = null
do:
page = list_contacts({ pageSize: 100, pageToken: token,
personFields: 'names,emailAddresses,phoneNumbers,organizations,metadata' })
contacts += page.connections
token = page.nextPageToken
while token
return contacts
```
### Slug Normalization
```
slugify(contact):
name = contact.names?[0]?.displayName
if not name:
name = contact.emailAddresses?[0]?.value.split('@')[0]
return name.toLowerCase().normalize('NFD')
.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
```
Collision (two contacts, same slug): suffix with the email domain
(`alice-example-acme-example`) rather than overwrite.
### What the Agent Should Test After Setup
1. **Idempotency:** run the sync twice. `git status` on `brain/contacts/.staging/`
shows no changes on the second run.
2. **Pagination:** with 250+ contacts, verify the staging count matches the
Google Contacts count (not capped at 100).
3. **Notability Gate:** verify a one-off contact with no brain presence did NOT
get a `people/` page.
4. **Enrichment merge:** verify an existing people page gained contact fields
without losing its prior content, each with a `[Source: Google Contacts]`
citation.
## Cost Estimate
| Component | Monthly Cost |
|-----------|-------------|
| ClawVisor (free tier) | $0 |
| Google People API | $0 (within free quota) |
| **Total** | **$0** |
## Troubleshooting
**No contacts returned:**
- Check ClawVisor has the Google Contacts service activated
- Check the standing task purpose is expansive enough
- Option B: verify the People API is enabled and the token carries
`contacts.readonly`
**Duplicate people pages after enrichment:**
- The agent matched by name but the brain page slug differs — search by email
address too before creating, then merge and delete the duplicate
**Contacts with no name:**
- The sync script falls back to the email prefix; records with neither name
nor email are dropped as noise
+4 -4
View File
@@ -23,7 +23,7 @@ health_checks:
label: "Auth provider"
checks:
- type: http
url: "$CLAWVISOR_URL/ready"
url: "$CLAWVISOR_URL/health"
label: "ClawVisor"
- type: env_exists
name: GOOGLE_CLIENT_ID
@@ -75,7 +75,7 @@ Tell the user:
3. Activate the services you need:
- **Gmail** (for email-to-brain)
- **Google Calendar** (for calendar-to-brain)
- **Google Contacts** (for contacts-to-brain)
- **Google Contacts** (for enrichment)
4. Create a standing task with a broad purpose. CRITICAL: be EXPANSIVE.
Good purpose: 'Full executive assistant access to Gmail, Calendar, and
@@ -88,7 +88,7 @@ Tell the user:
Validate:
```bash
curl -sf "$CLAWVISOR_URL/ready" \
curl -sf "$CLAWVISOR_URL/health" \
&& echo "PASS: ClawVisor reachable" \
|| echo "FAIL: ClawVisor not reachable — check the URL"
```
@@ -171,7 +171,7 @@ can now access your Google services."
## How to Verify
1. **ClawVisor:** `curl $CLAWVISOR_URL/ready` returns OK.
1. **ClawVisor:** `curl $CLAWVISOR_URL/health` returns OK.
2. **Google OAuth:** Tokens exist at `~/.gbrain/google-tokens.json`.
3. **Gmail access:** Run the email collector — it should pull recent messages.
4. **Calendar access:** Run the calendar sync — it should pull today's events.
+3 -3
View File
@@ -23,7 +23,7 @@ health_checks:
label: "Auth provider"
checks:
- type: http
url: "$CLAWVISOR_URL/ready"
url: "$CLAWVISOR_URL/health"
label: "ClawVisor"
- type: env_exists
name: GOOGLE_CLIENT_ID
@@ -130,7 +130,7 @@ Tell the user:
Validate:
```bash
curl -sf "$CLAWVISOR_URL/ready" && echo "PASS: ClawVisor reachable" || echo "FAIL"
curl -sf "$CLAWVISOR_URL/health" && echo "PASS: ClawVisor reachable" || echo "FAIL"
```
**STOP until ClawVisor validates.**
@@ -328,7 +328,7 @@ threads you already replied to. Sent mail acts as a negative filter.
## Troubleshooting
**No emails collected:**
- Check ClawVisor health: `curl $CLAWVISOR_URL/ready`
- Check ClawVisor health: `curl $CLAWVISOR_URL/health`
- Check standing task is active and has Gmail service enabled
- Check task purpose is expansive enough (narrow purposes block requests)
+43 -1
View File
@@ -54,7 +54,7 @@ export function bigintToStringReplacer(_key: string, value: unknown): unknown {
}
// CLI-only commands that bypass the operation layer
export const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'enrich', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'maintain', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'reconcile-links', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'calibration', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser', 'status', 'connect', 'skillopt', 'quarantine', 'self-upgrade', 'advisor', 'watch', 'reindex-search-vector']);
export const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'bench', 'sync', 'extract', 'extract-conversation-facts', 'enrich', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'maintain', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'reconcile-links', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'calibration', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser', 'status', 'connect', 'skillopt', 'quarantine', 'self-upgrade', 'advisor', 'watch', 'reindex-search-vector']);
// CLI-only commands whose handlers print their own --help text. These are
// excluded from the generic short-circuit so detailed per-command and
// per-subcommand usage stays reachable.
@@ -106,6 +106,9 @@ const CLI_ONLY_SELF_HELP = new Set([
// `gbrain connect --help` prints its own usage (flags + examples) from
// runConnect; route around the generic one-line short-circuit.
'connect',
// #1474: bench-publish ships its own detailed HELP (flags, exit codes,
// the export → publish → gate loop). Route around the generic stub.
'bench',
]);
// v114 (#1941): alias -> operation lookup, kept separate from `cliOps` so
@@ -1429,6 +1432,29 @@ async function handleCliOnly(command: string, args: string[]) {
return;
}
// #1474: `gbrain bench publish` is pure file I/O (reads a captured
// eval-candidates NDJSON from `gbrain eval export`, writes a baseline
// NDJSON). No DB access; bypass connectEngine entirely so the documented
// export → publish → gate loop works on machines without a brain.
// The v0.41.1 wave shipped bench-publish.ts + docs/eval-bench.md but this
// dispatcher case was never added, so the command hit 'Unknown command'.
if (command === 'bench') {
if (args[0] === 'publish') {
const { runBenchPublish } = await import('./commands/bench-publish.ts');
await runBenchPublish(args.slice(1));
return;
}
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
const { runBenchPublish } = await import('./commands/bench-publish.ts');
await runBenchPublish(['--help']);
return;
}
console.error(`Unknown bench subcommand: ${args[0]}`);
console.error('Usage: gbrain bench publish --from <captured.ndjson> --to <baseline.ndjson> [flags]');
console.error(' See docs/eval-bench.md for the full loop: eval export → bench publish → eval gate');
process.exit(2);
}
// v0.42.x (#2390): `gbrain eval chronicle` is deterministic — brings its own
// in-memory PGLite, no DB/gateway. CI fixture gate runs anywhere.
if (command === 'eval' && args[0] === 'chronicle') {
@@ -2224,6 +2250,22 @@ async function connectEngine(opts?: { probeOnly?: boolean }): Promise<BrainEngin
if (merged.embedding_image_ocr_model !== undefined) {
process.env.GBRAIN_EMBEDDING_IMAGE_OCR_MODEL = merged.embedding_image_ocr_model;
}
// #1475: stash the merged eval.* flags the same way. The capture gate
// (isEvalCaptureEnabled / isEvalScrubEnabled) runs against ctx.config,
// which is built from the sync file-plane loadConfig() in both the CLI
// op path and MCP dispatch — it never sees the DB plane directly. The
// gates consult this stash when the file plane is silent, so
// `gbrain config set eval.capture true` actually turns capture on.
// A pre-set env value wins over the DB plane (env-above-config, the
// incident escape hatch) — unlike GBRAIN_EMBEDDING_MULTIMODAL these
// keys have no loadConfig() env mapping, so without this guard the
// DB stash would silently clobber an operator's export.
if (process.env.GBRAIN_EVAL_CAPTURE === undefined && merged.eval?.capture !== undefined) {
process.env.GBRAIN_EVAL_CAPTURE = String(merged.eval.capture);
}
if (process.env.GBRAIN_EVAL_SCRUB_PII === undefined && merged.eval?.scrub_pii !== undefined) {
process.env.GBRAIN_EVAL_SCRUB_PII = String(merged.eval.scrub_pii);
}
// Always re-configure with merged values when DB merge succeeded. The
// trigger used to be field-name-gated (only when embedding_multimodal_model
// was set); that coupled the gate to the field set and would silently
+1 -11
View File
@@ -334,10 +334,6 @@ export async function scanIntegrity(
if (!page) continue;
// Skip grandfathered pages (opted out of brain-integrity enforcement)
if ((page.frontmatter as Record<string, unknown> | undefined)?.validate === false) continue;
// Skip code pages: indexed source files aren't prose. 'tweet' in an
// identifier or comment is not a bare-tweet citation gap, and auto-repair
// would inject wikilink brackets into source code.
if (page.type === 'code') continue;
pagesScanned++;
bareHits.push(...findBareTweetHits(page.compiled_truth, slug));
externalHits.push(...findExternalLinks(page.compiled_truth, slug));
@@ -367,9 +363,6 @@ async function scanIntegrityBatch(
// YAML) diverges from the sequential path's strict === false check. Intentional
// — gbrain lint should reject stringly-typed validate at write time.
const validateCondition = sql`AND (frontmatter->>'validate' IS NULL OR frontmatter->>'validate' != 'false')`;
// Mirror of the sequential path's `page.type === 'code'` skip: code pages
// (indexed source files) are never prose-integrity candidates.
const codeCondition = sql`AND type IS DISTINCT FROM 'code'`;
// v0.32.8: scan ONE row per (source_id, slug) pair, not one per slug.
// Pre-fix used DISTINCT ON (slug) which collapsed multi-source rows into
@@ -379,7 +372,7 @@ async function scanIntegrityBatch(
const rows = await sql`
SELECT slug, compiled_truth, frontmatter
FROM pages
WHERE 1=1 ${typeCondition} ${validateCondition} ${codeCondition}
WHERE 1=1 ${typeCondition} ${validateCondition}
ORDER BY source_id, slug
LIMIT ${limit}
`;
@@ -468,9 +461,6 @@ async function cmdAuto(args: string[]): Promise<void> {
const page = await engine.getPage(slug, { sourceId: source_id });
if (!page) continue;
// Never auto-repair code pages — injecting tweet citations into
// indexed source files corrupts them. Same gate as scanIntegrity.
if (page.type === 'code') continue;
pagesProcessed++;
progress.tick(1, slug);
+19
View File
@@ -816,6 +816,25 @@ export async function loadConfigWithEngine(
merged.dream = mergedDream;
}
// #1475: eval.* DB-plane merge. `gbrain config set eval.capture true`
// writes the DB plane (both keys are in KNOWN_CONFIG_KEYS, so `set`
// accepts them silently), but the capture gate (isEvalCaptureEnabled)
// reads the merged config. Without this merge the DB value was written
// and never read — capture only fired via GBRAIN_CONTRIBUTOR_MODE=1.
// Sparse per-key merge: file/env wins per key, DB fills the gaps.
const dbEvalCapture = await dbBool('eval.capture');
const dbEvalScrub = await dbBool('eval.scrub_pii');
const mergedEval: NonNullable<GBrainConfig['eval']> = { ...(merged.eval ?? {}) };
if (mergedEval.capture === undefined && dbEvalCapture !== undefined) {
mergedEval.capture = dbEvalCapture;
}
if (mergedEval.scrub_pii === undefined && dbEvalScrub !== undefined) {
mergedEval.scrub_pii = dbEvalScrub;
}
if (Object.keys(mergedEval).length > 0) {
merged.eval = mergedEval;
}
return merged;
}
+12 -1
View File
@@ -251,6 +251,14 @@ registerBackgroundWorkDrainer({
export function isEvalCaptureEnabled(config: GBrainConfig | null | undefined): boolean {
if (config?.eval?.capture === true) return true;
if (config?.eval?.capture === false) return false;
// #1475: DB-plane stash. `gbrain config set eval.capture true` lands in the
// config table; connectEngine stamps the merged value here because
// ctx.config is the sync file-plane load and never sees the DB plane.
// Explicit per-key setting (file above, DB here) beats the broad
// CONTRIBUTOR_MODE flag, matching how file-plane `false` already wins.
// Doubles as a direct operator env knob.
if (process.env.GBRAIN_EVAL_CAPTURE === 'true') return true;
if (process.env.GBRAIN_EVAL_CAPTURE === 'false') return false;
return process.env.GBRAIN_CONTRIBUTOR_MODE === '1';
}
@@ -263,5 +271,8 @@ export function isEvalCaptureEnabled(config: GBrainConfig | null | undefined): b
* have explicit `capture: true`.
*/
export function isEvalScrubEnabled(config: GBrainConfig | null | undefined): boolean {
return config?.eval?.scrub_pii !== false;
if (config?.eval?.scrub_pii === false) return false;
if (config?.eval?.scrub_pii === true) return true;
// #1475: DB-plane stash — see isEvalCaptureEnabled. Default stays true.
return process.env.GBRAIN_EVAL_SCRUB_PII !== 'false';
}
+5 -22
View File
@@ -5208,18 +5208,9 @@ export class PGLiteEngine implements BrainEngine {
const { rows: [h] } = await this.db.query(`
WITH entity_pages AS (
SELECT id, slug FROM pages WHERE type IN ('person', 'company')
),
narrative_pages AS (
-- Composition-aware score: code source files and calendar daily
-- files are orphans-by-design (no inbound wikilinks, no Timeline
-- fence). Excluding them from the orphan/link-density/timeline
-- denominators keeps a bulk code/calendar import from cratering
-- brain_score.
SELECT id FROM pages WHERE type IS NULL OR type NOT IN ('code', 'calendar-index')
)
SELECT
(SELECT count(*) FROM pages) as page_count,
(SELECT count(*) FROM narrative_pages) as narrative_page_count,
(SELECT count(*) FROM content_chunks WHERE embedded_at IS NOT NULL)::float /
GREATEST((SELECT count(*) FROM content_chunks), 1)::float as embed_coverage,
0 as stale_pages,
@@ -5231,8 +5222,7 @@ export class PGLiteEngine implements BrainEngine {
) as dead_links,
(SELECT count(*) FROM content_chunks WHERE embedded_at IS NULL) as missing_embeddings,
(SELECT count(*) FROM links) as link_count,
(SELECT count(DISTINCT te.page_id) FROM timeline_entries te
WHERE te.page_id IN (SELECT id FROM narrative_pages)) as pages_with_timeline,
(SELECT count(DISTINCT page_id) FROM timeline_entries) as pages_with_timeline,
(SELECT count(*) FROM entity_pages e
WHERE EXISTS (SELECT 1 FROM links l WHERE l.to_page_id = e.id))::float /
GREATEST((SELECT count(*) FROM entity_pages), 1)::float as link_coverage,
@@ -5254,19 +5244,12 @@ export class PGLiteEngine implements BrainEngine {
const { rows: islandedRows } = await this.db.query(`
SELECT p.slug
FROM pages p
-- Narrative pages only (same type filter as the narrative_pages CTE):
-- code/calendar-index pages are orphans-by-design and must not count
-- against the noOrphans component (#1144).
WHERE (p.type IS NULL OR p.type NOT IN ('code', 'calendar-index'))
AND NOT EXISTS (SELECT 1 FROM links l WHERE l.to_page_id = p.id)
WHERE NOT EXISTS (SELECT 1 FROM links l WHERE l.to_page_id = p.id)
AND NOT EXISTS (SELECT 1 FROM links l WHERE l.from_page_id = p.id)
`);
const r = h as Record<string, unknown>;
const pageCount = Number(r.page_count);
// Composition-aware denominators (excludes code/calendar-index pages);
// a code-only brain has nothing narrative to penalize → full marks.
const narrativePageCount = Number(r.narrative_page_count);
const embedCoverage = Number(r.embed_coverage);
const stalePages = await this.countStalePagesForExtraction({ versionTs: LINK_EXTRACTOR_VERSION_TS });
const orphanOverrides = await loadOrphanPolicyOverrides(this);
@@ -5276,9 +5259,9 @@ export class PGLiteEngine implements BrainEngine {
const linkCount = Number(r.link_count);
const pagesWithTimeline = Number(r.pages_with_timeline);
const linkDensity = narrativePageCount > 0 ? Math.min(linkCount / narrativePageCount, 1) : 1;
const timelineCoverageDensity = narrativePageCount > 0 ? Math.min(pagesWithTimeline / narrativePageCount, 1) : 1;
const noOrphans = narrativePageCount > 0 ? 1 - (orphanPages / narrativePageCount) : 1;
const linkDensity = pageCount > 0 ? Math.min(linkCount / pageCount, 1) : 0;
const timelineCoverageDensity = pageCount > 0 ? Math.min(pagesWithTimeline / pageCount, 1) : 0;
const noOrphans = pageCount > 0 ? 1 - (orphanPages / pageCount) : 1;
const noDeadLinks = pageCount > 0 ? 1 - Math.min(deadLinks / pageCount, 1) : 1;
// Bug 11 — per-component points. Sum equals brainScore by construction
// so `doctor` can render a breakdown that adds up to the total.
+5 -22
View File
@@ -5328,18 +5328,9 @@ export class PostgresEngine implements BrainEngine {
const [h] = await sql`
WITH entity_pages AS (
SELECT id, slug FROM pages WHERE type IN ('person', 'company')
),
narrative_pages AS (
-- Composition-aware score: code source files and calendar daily
-- files are orphans-by-design (no inbound wikilinks, no Timeline
-- fence). Excluding them from the orphan/link-density/timeline
-- denominators keeps a bulk code/calendar import from cratering
-- brain_score.
SELECT id FROM pages WHERE type IS NULL OR type NOT IN ('code', 'calendar-index')
)
SELECT
(SELECT count(*) FROM pages) as page_count,
(SELECT count(*) FROM narrative_pages) as narrative_page_count,
(SELECT count(*) FROM content_chunks WHERE embedded_at IS NOT NULL)::float /
GREATEST((SELECT count(*) FROM content_chunks), 1)::float as embed_coverage,
0 as stale_pages,
@@ -5349,8 +5340,7 @@ export class PostgresEngine implements BrainEngine {
) as dead_links,
(SELECT count(*) FROM content_chunks WHERE embedded_at IS NULL) as missing_embeddings,
(SELECT count(*) FROM links) as link_count,
(SELECT count(DISTINCT te.page_id) FROM timeline_entries te
WHERE te.page_id IN (SELECT id FROM narrative_pages)) as pages_with_timeline,
(SELECT count(DISTINCT page_id) FROM timeline_entries) as pages_with_timeline,
(SELECT count(*) FROM entity_pages e
WHERE EXISTS (SELECT 1 FROM links l WHERE l.to_page_id = e.id))::float /
GREATEST((SELECT count(*) FROM entity_pages), 1)::float as link_coverage,
@@ -5371,18 +5361,11 @@ export class PostgresEngine implements BrainEngine {
const islandedRows = await sql<{ slug: string }[]>`
SELECT p.slug
FROM pages p
-- Narrative pages only (same type filter as the narrative_pages CTE):
-- code/calendar-index pages are orphans-by-design and must not count
-- against the noOrphans component (#1144).
WHERE (p.type IS NULL OR p.type NOT IN ('code', 'calendar-index'))
AND NOT EXISTS (SELECT 1 FROM links l WHERE l.to_page_id = p.id)
WHERE NOT EXISTS (SELECT 1 FROM links l WHERE l.to_page_id = p.id)
AND NOT EXISTS (SELECT 1 FROM links l WHERE l.from_page_id = p.id)
`;
const pageCount = Number(h.page_count);
// Composition-aware denominators (excludes code/calendar-index pages);
// a code-only brain has nothing narrative to penalize → full marks.
const narrativePageCount = Number(h.narrative_page_count);
const embedCoverage = Number(h.embed_coverage);
const stalePages = await this.countStalePagesForExtraction({ versionTs: LINK_EXTRACTOR_VERSION_TS });
const orphanOverrides = await loadOrphanPolicyOverrides(this);
@@ -5392,9 +5375,9 @@ export class PostgresEngine implements BrainEngine {
const pagesWithTimeline = Number(h.pages_with_timeline);
// brain_score: 0-100 weighted average
const linkDensity = narrativePageCount > 0 ? Math.min(linkCount / narrativePageCount, 1) : 1;
const timelineCoverageWhole = narrativePageCount > 0 ? Math.min(pagesWithTimeline / narrativePageCount, 1) : 1;
const noOrphans = narrativePageCount > 0 ? 1 - (orphanPages / narrativePageCount) : 1;
const linkDensity = pageCount > 0 ? Math.min(linkCount / pageCount, 1) : 0;
const timelineCoverageWhole = pageCount > 0 ? Math.min(pagesWithTimeline / pageCount, 1) : 0;
const noOrphans = pageCount > 0 ? 1 - (orphanPages / pageCount) : 1;
const noDeadLinks = pageCount > 0 ? 1 - Math.min(deadLinks / pageCount, 1) : 1;
// Per-component points. Sum equals brainScore by construction.
//
+67
View File
@@ -0,0 +1,67 @@
// #1474: the v0.41.1 wave shipped bench-publish.ts + docs/eval-bench.md
// advertising `gbrain bench publish`, but the cli.ts dispatcher case was never
// added — the documented command hit 'Unknown command'. These tests spawn the
// real CLI (no DB needed; bench publish is pure file I/O) and fail on any
// regression of the dispatcher wiring.
import { describe, expect, test } from 'bun:test';
import { mkdtempSync, writeFileSync, existsSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
function runCli(args: string[]): { stdout: string; stderr: string; code: number } {
const result = spawnSync(process.execPath, ['run', 'src/cli.ts', 'bench', ...args], {
encoding: 'utf8',
cwd: process.cwd(),
env: { ...process.env },
});
return { stdout: result.stdout ?? '', stderr: result.stderr ?? '', code: result.status ?? -1 };
}
describe('gbrain bench dispatcher (#1474)', () => {
test('bench --help reaches bench-publish help without a DB (was: Unknown command)', () => {
const { stdout, stderr, code } = runCli(['--help']);
expect(stderr).not.toContain('Unknown command');
expect(code).toBe(0);
expect(stdout).toContain('gbrain bench publish');
expect(stdout).toContain('--from');
});
test('unknown bench subcommand exits 2 with usage', () => {
const { stderr, code } = runCli(['bogus']);
expect(code).toBe(2);
expect(stderr).toContain('Unknown bench subcommand');
expect(stderr).toContain('bench publish');
});
test('bench publish roundtrip: captured NDJSON in, baseline file out', () => {
const tmp = mkdtempSync(join(tmpdir(), 'bench-cli-'));
try {
const row = {
tool_name: 'query',
query: 'hello world',
retrieved_slugs: ['slug-a'],
retrieved_chunk_ids: [1],
source_ids: ['default'],
expand_enabled: false,
detail: 'medium',
detail_resolved: 'medium',
vector_enabled: true,
expansion_applied: false,
latency_ms: 100,
remote: false,
job_id: null,
subagent_id: null,
};
const from = join(tmp, 'captured.ndjson');
const to = join(tmp, 'personal.baseline.ndjson');
writeFileSync(from, `${JSON.stringify(row)}\n`);
const { code, stderr } = runCli(['publish', '--from', from, '--to', to]);
expect(stderr).not.toContain('Unknown command');
expect(code).toBe(0);
expect(existsSync(to)).toBe(true);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
});
-34
View File
@@ -119,40 +119,6 @@ describe('Bug 11 — orphan_pages is "no inbound links"', () => {
});
});
describe('#1144 — brain_score is composition-aware', () => {
test('code and calendar-index pages do not count as orphans or dilute density metrics', async () => {
// Two linked narrative pages + a flood of orphan-by-design pages.
await engine.putPage('people/alice', { type: 'person', title: 'Alice', compiled_truth: 'a', frontmatter: {} });
await engine.putPage('people/bob', { type: 'person', title: 'Bob', compiled_truth: 'b', frontmatter: {} });
const aId = (await (engine as any).db.query(`SELECT id FROM pages WHERE slug='people/alice'`)).rows[0].id;
const bId = (await (engine as any).db.query(`SELECT id FROM pages WHERE slug='people/bob'`)).rows[0].id;
await (engine as any).db.query(
`INSERT INTO links (from_page_id, to_page_id, link_type) VALUES ($1, $2, 'mentions')`,
[aId, bId],
);
for (let i = 0; i < 10; i++) {
await engine.putPage(`code/src/f${i}.py`, { type: 'code', title: `f${i}.py`, compiled_truth: 'def f(): pass', frontmatter: {} });
}
await engine.putPage('daily/calendar/2026-01-01', { type: 'calendar-index', title: '2026-01-01', compiled_truth: 'events', frontmatter: {} });
const h = await engine.getHealth();
// 11 unlinked code/calendar pages exist, but both narrative pages are
// linked → 0 orphans, full no-orphans marks.
expect(h.orphan_pages).toBe(0);
expect(h.no_orphans_score).toBe(15);
// Link density: 1 link / 2 narrative pages, not 1 / 13 total pages.
expect(h.link_density_score).toBe(Math.round(0.5 * 25));
});
test('a brain with ONLY non-narrative pages gets full composition marks', async () => {
await engine.putPage('code/src/only.py', { type: 'code', title: 'only.py', compiled_truth: 'x = 1', frontmatter: {} });
const h = await engine.getHealth();
expect(h.no_orphans_score).toBe(15);
expect(h.link_density_score).toBe(25);
expect(h.timeline_coverage_score).toBe(15);
});
});
describe('Bug 11 — doctor renders brain_score breakdown', () => {
test('doctor source contains brain_score breakdown rendering', async () => {
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
-45
View File
@@ -763,48 +763,3 @@ describeBoth('Engine parity — federated sourceIds[] secondary reads (#2200)',
}
});
});
// #1144 — brain_score is composition-aware: code / calendar-index pages are
// orphans-by-design and must not feed the orphan/link-density/timeline
// denominators. Both engines must agree.
async function seedComposition(eng: BrainEngine) {
await eng.putPage('people/comp-alice', { type: 'person', title: 'Alice', compiled_truth: 'a', timeline: '' });
await eng.putPage('people/comp-bob', { type: 'person', title: 'Bob', compiled_truth: 'b', timeline: '' });
await eng.addLink('people/comp-alice', 'people/comp-bob', 'knows', 'mentions', 'markdown');
for (let i = 0; i < 5; i++) {
await eng.putPage(`code/src/comp-f${i}.py`, { type: 'code', title: `f${i}.py`, compiled_truth: 'def f(): pass', timeline: '' });
}
await eng.putPage('daily/calendar/2026-01-01', { type: 'calendar-index', title: '2026-01-01', compiled_truth: 'events', timeline: '' });
}
describeBoth('Engine parity — getHealth composition-aware brain_score (#1144)', () => {
let pgEngine: BrainEngine;
let pgliteEngine: PGLiteEngine;
beforeAll(async () => {
pgEngine = await setupDB();
await seedComposition(pgEngine);
pgliteEngine = new PGLiteEngine();
await pgliteEngine.connect({});
await pgliteEngine.initSchema();
await seedComposition(pgliteEngine);
}, 90_000);
afterAll(async () => {
await pgliteEngine.disconnect();
await teardownDB();
}, 30_000);
test('orphan/link-density/timeline components identical and exclude code + calendar-index pages', async () => {
const pg = await pgEngine.getHealth();
const pglite = await pgliteEngine.getHealth();
for (const k of ['orphan_pages', 'no_orphans_score', 'link_density_score', 'timeline_coverage_score', 'brain_score'] as const) {
expect(pg[k]).toBe(pglite[k]);
}
// 6 non-narrative pages exist but both narrative pages are linked → 0 orphans.
expect(pg.orphan_pages).toBe(0);
expect(pg.no_orphans_score).toBe(15);
// 1 link / 2 narrative pages, not / 8 total pages.
expect(pg.link_density_score).toBe(Math.round(0.5 * 25));
});
});
-29
View File
@@ -140,35 +140,6 @@ describeE2E('scanIntegrity batch parity (E2E, Postgres-only)', () => {
});
});
describe('code pages', () => {
test('type:code page is skipped on both paths (#1143)', async () => {
const engine = getEngine();
await engine.putPage('people/alice', {
type: 'person',
title: 'Alice',
compiled_truth: 'Alice tweeted about something.',
timeline: '',
frontmatter: {},
});
await engine.putPage('code/src/bot.py', {
type: 'code',
title: 'src/bot.py (python)',
compiled_truth: '# the user tweeted about this feature\ndef send_tweet():\n pass',
timeline: '',
frontmatter: { language: 'python', file: 'src/bot.py' },
});
const batchResult = await scanIntegrity(engine, { limit: 100, batchLoad: true });
const seqResult = await scanIntegrity(engine, { limit: 100, batchLoad: false });
expect(batchResult.pagesScanned).toBe(seqResult.pagesScanned);
expect(batchResult.pagesScanned).toBe(1);
expect(batchResult.bareHits.map(h => h.slug)).not.toContain('code/src/bot.py');
expect(seqResult.bareHits.map(h => h.slug)).not.toContain('code/src/bot.py');
});
});
describe('topPages', () => {
test('topPages ordering matches between paths', async () => {
const engine = getEngine();
+59
View File
@@ -309,3 +309,62 @@ describe('isEvalCaptureEnabled / isEvalScrubEnabled (CONTRIBUTOR_MODE-gated)', (
} finally { restore(); }
});
});
describe('DB-plane stash (#1475): GBRAIN_EVAL_CAPTURE / GBRAIN_EVAL_SCRUB_PII', () => {
// connectEngine stamps `gbrain config set eval.capture` (DB plane) onto
// these env vars because ctx.config is the sync file-plane load. Without
// the stash check the DB value was written and never read.
const origCapture = process.env.GBRAIN_EVAL_CAPTURE;
const origScrub = process.env.GBRAIN_EVAL_SCRUB_PII;
const origMode = process.env.GBRAIN_CONTRIBUTOR_MODE;
const restore = () => {
if (origCapture === undefined) delete process.env.GBRAIN_EVAL_CAPTURE;
else process.env.GBRAIN_EVAL_CAPTURE = origCapture;
if (origScrub === undefined) delete process.env.GBRAIN_EVAL_SCRUB_PII;
else process.env.GBRAIN_EVAL_SCRUB_PII = origScrub;
if (origMode === undefined) delete process.env.GBRAIN_CONTRIBUTOR_MODE;
else process.env.GBRAIN_CONTRIBUTOR_MODE = origMode;
};
test('stash=true turns capture on when file plane is silent (the #1475 repro)', () => {
delete process.env.GBRAIN_CONTRIBUTOR_MODE;
process.env.GBRAIN_EVAL_CAPTURE = 'true';
try {
expect(isEvalCaptureEnabled(null)).toBe(true);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const noEval: any = { engine: 'pglite' };
expect(isEvalCaptureEnabled(noEval)).toBe(true);
} finally { restore(); }
});
test('stash=false wins over CONTRIBUTOR_MODE=1 (explicit per-key beats broad flag)', () => {
process.env.GBRAIN_CONTRIBUTOR_MODE = '1';
process.env.GBRAIN_EVAL_CAPTURE = 'false';
try {
expect(isEvalCaptureEnabled(null)).toBe(false);
} finally { restore(); }
});
test('file-plane explicit value still wins over the stash', () => {
process.env.GBRAIN_EVAL_CAPTURE = 'true';
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const disabled: any = { engine: 'pglite', eval: { capture: false } };
expect(isEvalCaptureEnabled(disabled)).toBe(false);
} finally { restore(); }
});
test('scrub stash: false disables, file plane wins, default stays true', () => {
process.env.GBRAIN_EVAL_SCRUB_PII = 'false';
try {
expect(isEvalScrubEnabled(null)).toBe(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fileWins: any = { engine: 'pglite', eval: { scrub_pii: true } };
expect(isEvalScrubEnabled(fileWins)).toBe(true);
} finally { restore(); }
delete process.env.GBRAIN_EVAL_SCRUB_PII;
try {
expect(isEvalScrubEnabled(null)).toBe(true);
} finally { restore(); }
});
});
-16
View File
@@ -201,15 +201,6 @@ describe('scanIntegrity', () => {
timeline: '',
frontmatter: { validate: false },
});
// Indexed source file — 'tweeted about' in a comment must NOT be flagged
// as a bare-tweet citation gap (#1143: auto-repair would corrupt source).
await engine.putPage('code/src/bot.py', {
type: 'code',
title: 'src/bot.py (python)',
compiled_truth: '# the user tweeted about this feature\ndef send_tweet():\n pass',
timeline: '',
frontmatter: { language: 'python', file: 'src/bot.py' },
});
}, 60_000);
afterAll(async () => {
@@ -232,13 +223,6 @@ describe('scanIntegrity', () => {
expect(slugs).not.toContain('people/legacy');
});
test('skips type:code pages (#1143 — bare-tweet false positives on source files)', async () => {
const res = await scanIntegrity(engine);
const slugs = res.bareHits.map(h => h.slug);
expect(slugs).not.toContain('code/src/bot.py');
expect(res.pagesScanned).toBe(2);
});
test('honors limit', async () => {
const res = await scanIntegrity(engine, { limit: 1 });
expect(res.pagesScanned).toBe(1);
+25
View File
@@ -302,4 +302,29 @@ describe('loadConfigWithEngine (Phase 4 / F3)', () => {
expect(merged?.engine).toBe('pglite');
});
});
describe('eval.* DB-plane merge (#1475)', () => {
test('gbrain config set eval.capture true reaches the merged config', async () => {
// The #1475 repro: DB plane has eval.capture=true, file plane silent.
// Pre-fix the merge skipped eval.* entirely and capture never fired.
const base: GBrainConfig = { engine: 'pglite' };
const engine = makeEngine({ 'eval.capture': 'true', 'eval.scrub_pii': 'false' });
const merged = await loadConfigWithEngine(engine, base);
expect(merged?.eval?.capture).toBe(true);
expect(merged?.eval?.scrub_pii).toBe(false);
});
test('file plane wins per key; DB fills only the gaps', async () => {
const base: GBrainConfig = { engine: 'pglite', eval: { capture: false } };
const engine = makeEngine({ 'eval.capture': 'true', 'eval.scrub_pii': 'false' });
const merged = await loadConfigWithEngine(engine, base);
expect(merged?.eval?.capture).toBe(false); // file wins
expect(merged?.eval?.scrub_pii).toBe(false); // DB fills the gap
});
test('no eval keys anywhere leaves cfg.eval undefined', async () => {
const merged = await loadConfigWithEngine(makeEngine({}), { engine: 'pglite' });
expect(merged?.eval).toBeUndefined();
});
});
});
+8 -2
View File
@@ -218,15 +218,21 @@ describe('progress reporter', () => {
test('only one process-level signal handler installed across many reporters', () => {
// Baseline: one handler already installed by prior tests in this file.
const installedBefore = __signalHandlerInstalledForTest();
// liveReporters is process-global: earlier test files in the same shard
// can leave a live entry behind (e.g. a production path that skips
// finish() on an error branch). Assert NET-zero leak from THIS test's
// lifecycles, not an absolute zero we don't control — same tolerance
// the handler assertion below already applies via `installedBefore`.
const liveBefore = __liveReporterCountForTest();
const { stream } = sink(false);
for (let i = 0; i < 50; i++) {
const p = createProgress({ mode: 'json', stream, minIntervalMs: 0, minItems: 1 });
p.start(`phase_${i}`, 1);
p.finish();
}
// After 50 reporter lifecycles, still exactly one handler and zero leaked live entries.
// After 50 reporter lifecycles, still exactly one handler and zero NEWLY leaked live entries.
expect(__signalHandlerInstalledForTest()).toBe(installedBefore || true);
expect(__liveReporterCountForTest()).toBe(0);
expect(__liveReporterCountForTest()).toBe(liveBefore);
});
test('startHeartbeat() fires heartbeats and stop() clears', async () => {
-34
View File
@@ -1,34 +0,0 @@
/**
* #1098 — ClawVisor health checks must hit /ready (surfaces db/vault
* degradation), never /health (bare liveness, masks those failure modes).
* #1099 — the credential-gateway recipe tells users to activate Google
* Contacts; the canonical consumer recipe must actually ship.
*/
import { describe, test, expect } from 'bun:test';
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
const RECIPES = join(import.meta.dir, '../recipes');
describe('#1098 — ClawVisor health checks use /ready', () => {
for (const f of ['email-to-brain.md', 'calendar-to-brain.md', 'credential-gateway.md', 'contacts-to-brain.md']) {
test(`${f} references $CLAWVISOR_URL/ready, never /health`, () => {
const text = readFileSync(join(RECIPES, f), 'utf-8');
expect(text).not.toContain('$CLAWVISOR_URL/health');
expect(text).toContain('$CLAWVISOR_URL/ready');
});
}
});
describe('#1099 — canonical contacts-to-brain recipe ships', () => {
test('recipes/contacts-to-brain.md exists with the sibling frontmatter shape', () => {
const p = join(RECIPES, 'contacts-to-brain.md');
expect(existsSync(p)).toBe(true);
const text = readFileSync(p, 'utf-8');
expect(text).toContain('id: contacts-to-brain');
expect(text).toContain('requires: [credential-gateway]');
expect(text).toContain('health_checks:');
expect(text).toContain('contacts.readonly');
});
});