diff --git a/CHANGELOG.md b/CHANGELOG.md index 047250d35..373a69866 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,35 @@ All notable changes to GBrain will be documented in this file. - Automatic per-turn and session-end pushes wait until the repo phase has verified the repo is private, so nothing is published to an unverified remote. To take advantage of v0.45.2.0: upgrade with `bun install -g github:garrytan/gbrain#latest-stable`. Nothing to migrate. To use the new path, create an empty private repo under your own account, clone it, open it in your agent, and run the bootstrap block — it adopts your repo. If anything about the repo or push looks off, `gbrain doctor` names it with the exact fix. +## [0.45.1.0] - 2026-08-11 + +**Your per-prompt brain hooks are now measurable and non-repetitive.** v0.45.0.0's paste-in agent install gave every prompt a context injection; this release makes that channel behave like a product instead of a firehose. The hook remembers what it already told you — a page it injected earlier in the session isn't re-injected every time the name comes up — and every delivery now lands in the same precision feedback loop the other push channels use, so `gbrain volunteer-context --stats` and a new doctor check show exactly which harnesses are firing and how useful their pushes are. + +### Added +- **Cross-turn dedupe for the per-prompt hook.** `gbrain hook user-prompt` reads its own previous injections back out of the session transcript (recorded as structured attachments — verified against a live Claude Code session) and suppresses re-volunteering, so a page is pushed once per session, not once per mention. The dedupe input is deduplicated and byte-capped, only gbrain-marked blocks count (another tool's hook output can't silence your brain), and the extraction is structural — a slug appearing in some tool payload can't over-suppress. +- **Per-harness feedback loop.** Delivered hook context now logs to the volunteered-pages feedback table under its harness channel (`claude-code` today; `--harness codex` reserved for a codex hook registration), counted at the delivery point only — a block the hook abandoned mid-deadline is never counted, and the hook records partial trims so drift is visible. +- **`volunteer_channels` doctor check** on both the local and remote doctor: per-channel activity over the last 7 days, with guidance that distinguishes "hook installed but never registered (restart the session)" from "registered but quiet", engine-aware messaging, and a caution when the hook's own heartbeat shows deliveries mostly degrading. + +### Changed +- The turn-context IPC response now carries the post-budget volunteered pages, and the request carries an attribution channel — both additive; older serves and clients interoperate unchanged (an older serve simply doesn't log hook deliveries until restarted). +- When a turn-context request exceeds the IPC message cap, the advisory dedupe payload is dropped before any conversation turn — context quality is never sacrificed to preserve a hint. + +### Fixed +- A remote doctor report requested with a source-scoped token no longer aggregates push-activity metadata across sources it isn't authorized for. +- The IPC connection handler processes exactly one request per connection — trailing bytes can no longer double-process a request (which would have double-counted deliveries). +- A transient database error during the doctor's channel check is no longer misreported as an old-schema brain. + +## To take advantage of v0.45.1.0 + +No migration and no re-registration needed. **Restart your `gbrain serve`** (or +just restart the harness session — it respawns the MCP serve) so the new +delivery logging activates; hooks registered by `gbrain bootstrap` pick up the +dedupe automatically on the next prompt. Then check the loop is live: + +```bash +gbrain volunteer-context --stats # per-channel precision, incl. claude-code +gbrain doctor # look for the volunteer_channels check +``` ## [0.45.0.0] - 2026-08-10 diff --git a/TODOS.md b/TODOS.md index de313e5fa..6e4c6ddfc 100644 --- a/TODOS.md +++ b/TODOS.md @@ -7,7 +7,7 @@ Deferred from the BrainBench wave (eng-reviewed; plan + GSTACK REVIEW REPORT at - [ ] **`--live` agent-in-the-loop know-to-ask.** Replay fixtures with a real model deciding whether to issue retrieval calls; grade the agent, not just the deterministic reflex. Pre-registered in `docs/eval/BRAINBENCH.md` (the v1 metric grades the injection decision, which IS the shipped mechanism). Needs: seeded N-repeat methodology for model stochasticity + budget rails. Priority: P2. - [ ] **Intrusion-budget gating calibration.** `avg_injected_tokens` is reported, non-gating (decision 18) — a wrong threshold is worse than none. After a few weeks of scoreboard data across PRs, pick calibrated per-seam thresholds and promote it to a gated metric. Priority: P2. -- [ ] **Flip contract adapters to production when real integrations land.** `adapters/claude-code.ts` exports the UserPromptSubmit hook wire types; the real hook swaps the in-process transport for an exec of the hook script and flips `seam: 'contract'` → `'production'` with continuous bench numbers. Same for codex fragments. This is the integration PR's checklist item — without it the seam disclosure goes stale. Priority: P1 (attached to the harness-integration PR, not standalone). +- [ ] **Flip contract adapters to production — claude-code half now unblocked.** `adapters/claude-code.ts` exports the UserPromptSubmit hook wire types; the real hook (`gbrain hook user-prompt`, shipped with the bootstrap lane and extended with cross-turn dedupe + the channel feedback loop in the cathedral-3 convergence) swaps the in-process transport for an exec of the hook script and flips `seam: 'contract'` → `'production'` with continuous bench numbers. Note the production hook also exercises transcript-based dedupe, which the memoryless contract row deliberately doesn't. Same for codex fragments when that integration lands. Priority: P1 (the claude-code integration has landed; this is now standalone-actionable). - [ ] **Cathedral 1 conformance-kit fixture import.** The memory-verbs conformance scenarios convert to BrainBench fixtures via the published `evals/brainbench/schema/fixture.schema.json` once `garrytan/cathedral-1` merges ("conformance tests double as BrainBench seed fixtures", decision log 2026-06-12). Free corpus growth from already-reviewed scenarios. Blocked by: cathedral-1 on master. Priority: P2. - [ ] **Live-embeddings fidelity mode (`--embeddings`).** Hermetic CI grades the keyword/alias arms only (disclosed); an opt-in mode seeding real embeddings would grade write-back/continuity retrieval through the vector path. Same budget rails as `--llm`. Priority: P3. - [ ] **Community fixture intake + competitor adapters.** The TD1 remainder after the generated corpus absorbed in-PR growth: an `external-authors/`-style intake path for contributed fixtures (validator + privacy guard already gate them) and adapters for non-gbrain memory systems against the published schemas, enabling true head-to-head rows in the gbrain-evals scorecard. Priority: P3. @@ -491,6 +491,42 @@ Deferred from the #2139 delta-estimator wave. See plan + GSTACK REVIEW REPORT at filed embedding-latency-by-minutes complaint. **Start:** thread per-source estimates through `runOne` (`src/commands/sync.ts`); design worked out at D8A in the plan. +## Harness hook lane follow-ups (filed from the cathedral-3 convergence) + +Filed when the cathedral-3 branch converged its push-adapter work into the +#3975 hook lane (feedback loop + cross-turn dedupe for `gbrain hook +user-prompt`). Context: the hook lane now logs channel-attributed volunteer +events at the IPC delivery point and dedupes via the transcript's +`hook_additional_context` attachments. + +- [ ] **P3 — PostToolUse / mid-turn push adapter, evaluated against per-channel stats.** + The user-prompt hook fires at prompt time only; entities that first appear mid-turn + in tool output (a file opened, a person named in a search result) get no pointer + until the NEXT prompt. Harnesses expose a PostToolUse hook, but it fires dozens of + times per turn (one `gbrain hook` process spawn each). Now that the feedback loop + exists, the per-channel `--stats` precision + volume data is exactly the evidence + needed to decide. **Trigger:** claude-code channel stats showing healthy precision + plus user reports of "it only noticed on my next message". **Start:** + `src/commands/hook.ts` (the event already has a dispatch slot pattern), + `src/core/bootstrap/hooks.ts` registration writers. +- [ ] **P3 — engine-uniform IPC listener (Postgres serves).** serve's resolve/turn_context + socket is PGLite-gated (`src/mcp/server.ts`: `cfg?.engine === 'pglite'`), so on a + Postgres brain `gbrain hook user-prompt` short-circuits (`no_pglite_path`) and the + hook lane is PGLite-only. Extending the listener needs (a) a canonical per-connection + socket path for brains with no data dir (e.g. `~/.gbrain/run/resolve-.sock`, + 0700 dir) and (b) a secret-file home for `turn_context` auth (same hash-keyed run dir). + The cathedral-3 branch prototyped (a) as `resolveSocketPathForConfig` (see branch + history at commit 2350294c) before the convergence dropped it pending the secret + design. **Trigger:** a Postgres-brain user asking why hooks stay silent. **Start:** + `src/core/context/resolve-ipc.ts` socket-path helpers + `src/mcp/server.ts` listener gate + + `src/commands/hook.ts:no_pglite_path` branch. +- [ ] **P3 — thin-client remote push route.** Thin-client installs (remote_mcp) have no + local engine and no serve socket — every push channel is dead there and only the + hook's typed heartbeat reason says why. The natural route is `volunteer_context` + over the remote MCP transport (`callRemoteTool`), rate-limited per prompt. + **Trigger:** thin-client adoption of bootstrap. **Start:** `src/commands/hook.ts` + user-prompt branch + `src/cli.ts` remote-tool plumbing. + ## gbrain#2095 push-based context follow-ups (v0.43+) Filed from the #2095 wave (volunteer_context op + reflex window + `gbrain watch`). @@ -504,20 +540,22 @@ are the bar). Plan + GSTACK REVIEW REPORT at deployments get push too. **Cons:** async plumbing + auth scoping; no consumer wired today. **Where:** `src/commands/serve-http.ts` + `src/core/context/volunteer.ts`. **Blocked by:** a real consumer (revisit when one exists). -- [ ] **P3 — policy skill + doctor check for push-context.** The ambient reflex - needed doctor visibility because silent failure was invisible; volunteer is - invoked-on-demand so v1 skipped it. If `volunteer-context --stats` adoption shows - agents not discovering the surface, ship a `push-context` recipe (mirror - `recipes/retrieval-reflex/`) + a doctor check reading the events table. - **Where:** `recipes/`, `src/commands/doctor.ts`. +- [ ] **P3 — policy skill (recipe) for push-context.** The doctor-check half of + this item shipped with the harness hook lane: `volunteer_channels` + (`src/commands/doctor.ts:checkVolunteerChannels`) reads the events table + per-channel on both the local and remote doctor. Remaining scope: if + `volunteer-context --stats` adoption shows agents not discovering the + surface, ship a `push-context` recipe (mirror `recipes/retrieval-reflex/`). + **Where:** `recipes/`. - [ ] **P3 — structured `messages[]` param for volunteer_context.** v1 takes a string window (`user:`/`assistant:` prefixes) to avoid a dual-shape contract. If MCP callers accumulate parsing bugs, add a structured array param beside it. **Where:** `src/core/operations.ts:volunteer_context` + `src/core/context/volunteer.ts:parseWindow`. - [ ] **P3 — index shapes for the per-turn resolver query.** The arm-2 resolver (`retrieval-reflex.ts`: `lower(title) = ANY() OR slug = ANY() OR slug LIKE - ANY('%/...')`) predates #2095 but now runs per turn on three channels - (reflex window, volunteer_context, watch) federated across sources. Neither + ANY('%/...')`) predates #2095 but now runs per turn on four channel surfaces + (reflex window, volunteer_context, watch, and the harness-hook `turn_context` + lane) federated across sources. Neither the leading-wildcard suffix arm nor `lower(title)` is index-served. If per-turn latency telemetry on large brains comes back hot: add `(source_id, lower(title))` btree + a reverse(slug) text_pattern_ops (or diff --git a/docs/architecture/KEY_FILES.md b/docs/architecture/KEY_FILES.md index 2afb182b2..86e961a59 100644 --- a/docs/architecture/KEY_FILES.md +++ b/docs/architecture/KEY_FILES.md @@ -19,7 +19,7 @@ per-release `**vX.Y.Z:**` narration — CI enforces this - `src/core/search/explain-formatter.ts` — renders `SearchResult[]` as a multi-line per-result breakdown for `gbrain search --explain`. Reads every boost-stamping field. Handles the "no boosts applied" empty path. 4-decimal precision with trailing-zero strip. Pinned by `test/search/explain-formatter.test.ts`. - `src/core/search/mode.ts` — Named search-mode bundles + the search cache key. `MODE_BUNDLES` (conservative/balanced/tokenmax) and the resolution chain (per-call `SearchOpts` → per-key `search.*` config → bundle → balanced fallback) resolve every search knob; `knobsHash` folds every result-shaping knob into the `query_cache` key, and `KNOBS_HASH_VERSION` (exported from this file — the single source of truth for the current cache-key version) is bumped whenever a new knob shapes results so stale cache rows become unreachable. `graph_signals: boolean` knob in `ModeBundle` (defaults: `conservative=false`, `balanced=true`, `tokenmax=true`). `KNOBS_HASH_VERSION` appends a `gs=` parts entry per the cache-key contamination convention so a graph-on cache write can't be served to a graph-off lookup. `SearchKeyOverrides` + `SearchPerCallOpts` + `loadOverridesFromConfig` + `SEARCH_MODE_CONFIG_KEYS` + `resolveSearchMode` + `attributeKnob` all carry the field. Opt-out: `gbrain config set search.graph_signals false`. Mid-deploy `query_cache` rows from before the upgrade hash differently — natural row segregation, clears within `cache.ttl_seconds` (3600s default). `title_boost: number | undefined` knob in `ModeBundle` (default `1.25` for all three modes; multiplier for the post-fusion title-phrase boost). Override chain: per-call `SearchOpts` → `search.title_boost` config (clamped `[1.0, 5.0]`) → bundle. `KNOBS_HASH_VERSION` appends a `tib=` parts entry so a title-boost-on cache write can't be served to a title-boost-off lookup. `SEARCH_MODE_CONFIG_KEYS` gains `search.title_boost`. Cross-modal knobs in `ModeBundle`: `cross_modal_both_text_weight`/`cross_modal_both_image_weight` (weighted RRF for 'both' modality, defaults 0.6/0.4), `image_query_text_refinement_weight`/`image_query_image_refinement_weight` (hybrid intersect for `searchByImage` query refinement, defaults 0.4/0.6), `unified_multimodal` + `unified_multimodal_only` (unified-column routing flags), `cross_modal_llm_intent` (opt-in LLM escalation). `SEARCH_MODE_CONFIG_KEYS` carries the corresponding config keys, and the modality knobs participate in `knobsHash` so a cached text-mode result can't be served to an image-mode caller. - `src/core/context-engine.ts` + `src/openclaw-context-engine.ts` — the deterministic context engine OpenClaw loads on every turn (`assemble()` injects the Live Context block, zero-LLM). `createGBrainContextEngine({workspaceDir, resolveEntities?})` accepts an OPTIONAL host-injected resolver (`ENGINE_API_VERSION` 0.2.0, additive — older hosts work unchanged; the plugin entry maps `ctx.resolveEntities`/`ctx.brainQuery` onto it). `assemble()` runs the Retrieval Reflex after the Live Context block: extracts the current turn's user text, builds prior-context text (every message EXCEPT the current turn — suppression must not see the triggering mention), passes the rolling window (`getWindowTurns`, last 12 user/assistant turns; the reflex slices to its configured `retrieval_reflex_window_turns`), and appends the pointer block. `warmReflex()` fires at construction. -- `src/core/context/` — Retrieval Reflex (Layer 1, issue #1981). `entity-salience.ts`: pure, zero-LLM, precision-biased `extractCandidates(text)` (capitalized runs + `@handles`, STOPWORDS + soft COMMON_WORDS + sentence-start guard, deterministic, capped) + `extractCandidatesFromWindow(turns)` (#2095: merges per-turn extraction across the last N turns by normalizeAlias form with occurrence/newest-turn/user-mention metadata; salience-ordered — recency > frequency > user-role — so the cap drops stale assistant chatter first). `retrieval-reflex.ts`: `resolveEntitiesToPointers(engine, sourceId, candidates, opts)` — alias arm (`resolveAliases`, caught per-arm for pre-v110 brains) + exact title/slug-suffix arm (the recall fix: real slugs are namespaced `people/x` but `slugify` drops the prefix); pointers carry `source_id`/`arm`/`confidence`/`matchedNorm` (#2095 — `ARM_CONFIDENCE` alias 0.9 / title 0.8 / slug-suffix 0.6 lives next to the arm definitions; arm-2 provenance classified in JS since the combined OR can't report which predicate matched); opts: `sourceIds?` federated scope (alias arm loops per source, arm 2 uses `source_id = ANY`), `suppression?` ('slug-and-title' legacy default; 'slug-only' REQUIRED under windowing — the title rule would suppress every entity merely mentioned in a prior window turn), ambient-channel event logging is DELIVERY-side, not in-resolver — `logDeliveredReflexPointers(engine, pointers)` fires only once a block is actually handed to the consumer (serve's resolve-IPC `onDelivered` hook post-write; `buildReflexAddition` post-timeout on the direct rung), so abandoned/timed-out blocks never pollute the volunteered-vs-used stats; synopsis runs through `stripTakesFence`/`stripFactsFence` (the same privacy boundary `get_page` applies) so private facts never reach the prompt; capped at `MAX_POINTERS`. `reflex.ts`: the orchestrator + engine-aware resolver ladder (host `resolveEntities` → PGLite serve IPC → Postgres cached process-singleton → disabled), zero-candidate fast path, fail-open + timeout, heartbeat write for the doctor check, `reflexEnabled(cfg)` (file/env gate, default ON; DB-plane does NOT gate — `assemble()` is sync); windowed extraction when `windowTurns` present and `retrieval_reflex_window_turns` (default 4; 1 = exact legacy behavior) > 1 — switches suppression to slug-only; accept-side reflex-channel logging fires after the per-turn timeout admits the block (direct-Postgres rung only — IPC logs server-side at delivery; host-injected resolvers are a documented gap). `resolve-ipc.ts`: local unix-socket resolve protocol (client + server) so PGLite resolves through the single connection `gbrain serve` holds (a second opener would hit the exclusive lock; a subprocess would force-steal it past the 5-min staleness window and crash). Wired into `src/mcp/server.ts` (serve binds `/.gbrain-resolve.sock` on PGLite, cleaned up on shutdown). Doctor surface: `retrieval_reflex_health` in `src/commands/doctor.ts` (reads the heartbeat for truthful runtime status; categorized in `doctor-categories.ts`). Config: `retrieval_reflex` + `retrieval_reflex_max_pointers` + `retrieval_reflex_window_turns` in `src/core/config.ts` (env `GBRAIN_RETRIEVAL_REFLEX_WINDOW_TURNS`). `volunteer.ts` (#2095): `parseWindow` (lenient `user:`/`assistant:` prefixes, unprefixed → one user turn), `volunteerContext` (extract → resolve → +0.05 multi-turn/newest-turn boost → `min_confidence` 0.7 gate → cap 3/5; deterministic rationale strings, never raw conversation text; slug-only suppression), `volunteerUsageStats` (per-arm/channel precision from the `pages.last_retrieved_at > volunteered_at` join — APPROXIMATE: the 5-min last-retrieved throttle causes false negatives, unrelated reads false positives). `volunteer-events.ts` (#2095): `insertVolunteerEvents` (ONE multi-row parameterized INSERT), `logVolunteerEventsFireAndForget` + bounded drain registered as the `volunteer-events` background-work sink (order 4), `purgeStaleVolunteerEvents` (90-day GC, called from the dream cycle's purge phase). Policy layer ships as the `retrieval-reflex` recipe (`recipes/retrieval-reflex/`). Pinned by `test/context/entity-salience.test.ts`, `test/retrieval-reflex.test.ts`, `test/context/resolve-ipc.test.ts`, `test/doctor-retrieval-reflex.test.ts`, `test/volunteer-context.test.ts`, `test/e2e/volunteer-context-postgres.test.ts`. +- `src/core/context/` — Retrieval Reflex (Layer 1, issue #1981). `entity-salience.ts`: pure, zero-LLM, precision-biased `extractCandidates(text)` (capitalized runs + `@handles`, STOPWORDS + soft COMMON_WORDS + sentence-start guard, deterministic, capped) + `extractCandidatesFromWindow(turns)` (#2095: merges per-turn extraction across the last N turns by normalizeAlias form with occurrence/newest-turn/user-mention metadata; salience-ordered — recency > frequency > user-role — so the cap drops stale assistant chatter first). `retrieval-reflex.ts`: `resolveEntitiesToPointers(engine, sourceId, candidates, opts)` — alias arm (`resolveAliases`, caught per-arm for pre-v110 brains) + exact title/slug-suffix arm (the recall fix: real slugs are namespaced `people/x` but `slugify` drops the prefix); pointers carry `source_id`/`arm`/`confidence`/`matchedNorm` (#2095 — `ARM_CONFIDENCE` alias 0.9 / title 0.8 / slug-suffix 0.6 lives next to the arm definitions; arm-2 provenance classified in JS since the combined OR can't report which predicate matched); opts: `sourceIds?` federated scope (alias arm loops per source, arm 2 uses `source_id = ANY`), `suppression?` ('slug-and-title' legacy default; 'slug-only' REQUIRED under windowing — the title rule would suppress every entity merely mentioned in a prior window turn), ambient-channel event logging is DELIVERY-side, not in-resolver — `logDeliveredReflexPointers(engine, pointers)` fires only once a block is actually handed to the consumer (serve's resolve-IPC `onDelivered` hook post-write; `buildReflexAddition` post-timeout on the direct rung), so abandoned/timed-out blocks never pollute the volunteered-vs-used stats; synopsis runs through `stripTakesFence`/`stripFactsFence` (the same privacy boundary `get_page` applies) so private facts never reach the prompt; capped at `MAX_POINTERS`. `reflex.ts`: the orchestrator + engine-aware resolver ladder (host `resolveEntities` → PGLite serve IPC → Postgres cached process-singleton → disabled), zero-candidate fast path, fail-open + timeout, heartbeat write for the doctor check, `reflexEnabled(cfg)` (file/env gate, default ON; DB-plane does NOT gate — `assemble()` is sync); windowed extraction when `windowTurns` present and `retrieval_reflex_window_turns` (default 4; 1 = exact legacy behavior) > 1 — switches suppression to slug-only; accept-side reflex-channel logging fires after the per-turn timeout admits the block (direct-Postgres rung only — IPC logs server-side at delivery; host-injected resolvers are a documented gap). `resolve-ipc.ts`: local unix-socket resolve protocol (client + server) so PGLite resolves through the single connection `gbrain serve` holds (a second opener would hit the exclusive lock; a subprocess would force-steal it past the 5-min staleness window and crash). Wired into `src/mcp/server.ts` (serve binds `/.gbrain-resolve.sock` on PGLite, cleaned up on shutdown). Doctor surface: `retrieval_reflex_health` in `src/commands/doctor.ts` (reads the heartbeat for truthful runtime status; categorized in `doctor-categories.ts`) + `volunteer_channels` (engine-aware sibling: groups `context_volunteer_events` by channel over 7 days so operators see which push channels — reflex/op/watch/claude-code/codex — actually fire; info-only; the LOCAL doctor runs it brain-wide while the remote report path threads the caller's source scope, so a source-bound token never sees other sources' activity counts/timestamps; counts are reconciled against the hook heartbeat over the same 7-day window — a mostly-degraded week gets a CAUTION note, since a server-side delivery count isn't proof of injection; quiet-channel guidance is engine-aware — Postgres brains are told the hook lane is quiet by design rather than to chase registration — and walks both quiet classes (installed-but-unregistered vs registered-but-quiet; the check can't inspect registration itself); pre-v117 tolerant, and transient DB errors are reported as such, never as an old schema; pinned by `test/doctor-volunteer-channels.test.ts`). Config: `retrieval_reflex` + `retrieval_reflex_max_pointers` + `retrieval_reflex_window_turns` in `src/core/config.ts` (env `GBRAIN_RETRIEVAL_REFLEX_WINDOW_TURNS`). `volunteer.ts` (#2095): `parseWindow` (lenient `user:`/`assistant:` prefixes, unprefixed → one user turn), `volunteerContext` (extract → resolve → +0.05 multi-turn/newest-turn boost → `min_confidence` 0.7 gate → cap 3/5; deterministic rationale strings, never raw conversation text; slug-only suppression), `volunteerUsageStats` (per-arm/channel precision from the `pages.last_retrieved_at > volunteered_at` join — APPROXIMATE: the 5-min last-retrieved throttle causes false negatives, unrelated reads false positives). `volunteer-events.ts` (#2095): `insertVolunteerEvents` (ONE multi-row parameterized INSERT), `logVolunteerEventsFireAndForget` + bounded drain registered as the `volunteer-events` background-work sink (order 4), `purgeStaleVolunteerEvents` (90-day GC, called from the dream cycle's purge phase). Policy layer ships as the `retrieval-reflex` recipe (`recipes/retrieval-reflex/`). Pinned by `test/context/entity-salience.test.ts`, `test/retrieval-reflex.test.ts`, `test/context/resolve-ipc.test.ts`, `test/doctor-retrieval-reflex.test.ts`, `test/volunteer-context.test.ts`, `test/e2e/volunteer-context-postgres.test.ts`. - `src/commands/watch.ts` — `gbrain watch` (#2095): the push transport. Reads turns from stdin as they arrive (`user:`/`assistant:` prefixes; unprefixed = user turn), keeps a rolling in-process window (`--window-turns`, default 4), calls `volunteerContext` per turn, streams pointers to stdout (`--json` for JSONL with turn attribution), logs `channel: 'watch'` events with a per-session id. Session dedupe feeds already-pushed slugs back as priorContext so the core's slug-only suppression dedupes. Blocks in the stdin iteration (interactive alive until Ctrl-C/Ctrl-D; piped ends at EOF) — deliberately NOT in DAEMON_COMMANDS; SIGINT closes the stream so teardown flows through finishCliTeardown. Per-turn resolution failures are fail-open. Registered in CLI_ONLY + CLI_ONLY_SELF_HELP + THIN_CLIENT_REFUSED_COMMANDS (thin clients use the `volunteer_context` MCP op). Pinned by `test/watch-command.test.ts`. - `src/commands/integrations.ts` — recipe install. The resolver-row install fence is keyed by `manifest.recipe` (`gbrain::resolver-rows`), so a second `copy-into-host-repo` recipe no longer writes a block mislabeled with the first recipe's name. Pinned by `test/integrations-install.test.ts`. Health-check DSL includes the staleness-aware `heartbeat_max_age` type (#2787): declares the sense's expected cadence (`max_age: 48h`), and `integrations doctor` FAILS when the newest heartbeat event is older — the only check type that catches a green-but-dead sense (all others are point-in-time). Not embedded-gated (reads only the local heartbeat file). Recipe frontmatter carries `output_paths` (repo-relative dirs the collector writes, e.g. calendar-to-brain → `daily/calendar/`); `getConfiguredCollectorOutputs()` surfaces them for the #2788 db_only-collision check/warning. Pinned by `test/integrations-heartbeat-max-age.test.ts`. Standalone integration recipe management (no DB needed). Exports `getRecipeDirs()` (trust-tagged recipe sources), SSRF helpers (`isInternalUrl`, `parseOctet`, `hostnameToOctets`, `isPrivateIpv4`). Only package-bundled recipes are `embedded=true`; `$GBRAIN_RECIPES_DIR` and cwd `./recipes/` are untrusted and cannot run `command`/`http`/string health checks. - `src/core/audit/audit-writer.ts` — shared JSONL audit primitive consolidating the hand-rolled audit modules. Exports `createAuditWriter({kind, recordSchema})` returning `{log, readRecent}` plus shared helpers `computeIsoWeekFilename(kind, now?)` and `resolveAuditDir()` (honors `GBRAIN_AUDIT_DIR`). ISO-week file rotation; best-effort writes (stderr warn on failure, never throws); read-path scans current-week + previous-week files for boundary spans. Refactored onto it for parity: `src/core/rerank-audit.ts`, `src/core/audit-slug-fallback.ts`, `src/core/minions/handlers/shell-audit.ts`, `src/core/minions/handlers/supervisor-audit.ts`, `src/core/facts/phantom-audit.ts` (each module's public API preserved bit-for-bit). The `graph-signals-failures` audit (`logGraphSignalsFailure`) uses the same primitive. One hand-rolled audit remains at `src/core/skillpack/audit.ts`. Pinned by `test/audit/audit-writer.test.ts`. @@ -502,10 +502,10 @@ User-facing contract: `docs/guides/bootstrap.md`. Runbook the paste block fetche - `src/core/bootstrap/lock.ts` — the bootstrap-run mutex (atomic mkdir + pid liveness + age guard + ownership token; steal requires dead pid AND stale age) and the family's shared typed `BootstrapError` (GH_MISSING/GH_AUTH carry exit 2 = human action needed). - `src/core/bootstrap/repo.ts` / `attach.ts` / `uninstall.ts` — private-repo lifecycle. `createPrivateRepo`: gh gates, slugified name probe, `gh repo create --private --source --push`, privacy verified via `gh api .private` (rate-limit/5xx is VERIFY_UNAVAILABLE, distinct from not-private) before any push, idempotency keyed off the remote URL. A pre-existing origin is adopted (disposition 'adopted') when the authed gh user owns it, there's no recorded `repo_url`, and it is SAFE — empty or already carrying our history (`assertAdoptableOrigin`; a foreign-content repo is refused `ORIGIN_NOT_EMPTY`, never a silent no-op); this is the create-repo-first path. Org-owned origins and anything else are refused and pointed at attach. Repo-local git identity is set in both create and adopt paths before commit; `repo_url` is recorded only after a successful push. `attachWorkspace` (machine two): requires an `initialized` manifest, writes this machine's receipt, returns structured wiring steps. `uninstallWorkspace`: receipt-keyed, refuses under a live serve (read-only lock probe — never opens the engine), removes exactly receipt-recorded paths + marker-keyed host entries, keeps the brain unless `--delete-brain` AND bootstrap created it; never wholesale-deletes the gbrain home. All gh/git through an injectable ExecRunner seam. - `src/core/bootstrap/hooks.ts` + `host-specs.ts` — host wiring. `host-specs.ts` is the ONE module owning host-format assumptions (dated spec targets with verifiedAt + doc references: claude-code hooks/settings shapes incl. the 10,000-char hook-output cap; codex mcp-add argv; no-TOML-writer-in-v1 decision recorded). `writeClaudeHooks` does a structural JSON merge into `.claude/settings.local.json` keyed by a `_gbrain` marker — foreign hooks and permissions survive, re-runs dedupe, broken JSON is backed up loudly; `registerClaudeMcp`/`registerCodexMcp` build argv only (project scope default, `-e GBRAIN_SOURCE` so MCP writes land in the workspace source, and `serve --surface full` pinned so a pre-existing `mcp_surface: verbs` config row can't silently narrow the bootstrap op surface). -- `src/commands/hook.ts` — engine-free `gbrain hook {session-start,user-prompt,stop,session-end}` (zero engine modules in the import graph; a hook must NEVER contend for the PGLite writer lock). user-prompt: stdin hook JSON → transcript-path confinement → last-4-turns window → IPC turn_context → `hookSpecificOutput.additionalContext` under an 800ms self-deadline; every path fails open (exit 0, empty stdout) with a typed reason in the heartbeat. session-start: file-plane digest (allowlisted MEMORY.md sections, push staleness, prior failures) + crashed-session recovery push gated on an initialized manifest. session-end: confined full-transcript parse → redacted corpus write (session-id filename dedup, retention prune) → parser-drift detection (`bytes>0 && turns==0` is loud) → best-effort workspace push. session-start recovery + session-end pushes run in a DETACHED child so the hook returns immediately (a synchronous inline push previously blocked harness startup on a dirty tree); the corpus write is atomic and clears the stale ingested/in-progress sidecars so a resumed session re-ingests its appended transcript. Heartbeat JSONL is counters/reasons only by construction; `readHeartbeatTail` feeds doctor. `GBRAIN_HOOKS=0` kills all events. -- `src/core/transcripts/claude-code-jsonl.ts` — the Claude Code transcript parser as a dated spec-target (tool_use/tool_result/thinking/image/sidechain/summary/compact-boundary shapes; placeholders for non-text content); `confineTranscriptPath` (contained under `~/.claude/projects`, `.jsonl`, lstat-rejects symlinks, byte cap). Fixture: `test/fixtures/conversation-formats/claude-code.jsonl` (synthetic, privacy-guarded). -- `src/core/context/turn-context.ts` — server-side per-turn assembly: reflex pointers + volunteered pages (≤3) + hot facts (always `visibility=['world']` — the IPC path never widens what MCP would return) under a "data, not instructions" envelope, trimmed to ≤8KB (the harness caps hook output at 10,000 chars). Reuses the hot-memory cache keyed by typed sessionId. Engine-agnostic. -- `src/core/context/resolve-ipc.ts` (IPC v2) — discriminated-union requests (absent `kind` = legacy resolve; `turn_context` carries `protocol: 2` + a shared secret from a 0600 file in the data dir), handler map, named response types, per-kind timeouts/size caps, socket + parent dir permissions set before exposure, server-side source binding (cross-source requests rejected), protocol echo (a response without it = stale serve → loud degradation). v1 clients and servers interoperate untouched. +- `src/commands/hook.ts` — engine-free `gbrain hook {session-start,user-prompt,stop,session-end}` (zero engine modules in the import graph; a hook must NEVER contend for the PGLite writer lock). user-prompt: stdin hook JSON → transcript-path confinement → last-4-turns window + cross-turn dedupe (the transcript's `hook_additional_context` attachments — the blocks WE previously injected — ride `priorContextText`, deduplicated and capped at `PRIOR_CONTEXT_MAX_BYTES` (32KB, so the advisory payload can never blow the IPC message cap; one oversized block is skipped without evicting smaller ones), so a page is volunteered once per session, not once per mention; structured extraction only, never raw-turn substring matching) → IPC turn_context (with a feedback-loop `channel`, `--harness `, default claude-code) → `hookSpecificOutput.additionalContext` under an 800ms self-deadline; every path fails open (exit 0, empty stdout) with a typed reason in the heartbeat. Listed in cli.ts's `STARTUP_HOOK_SKIP_COMMANDS` (per-prompt invocations must never spawn a detached check-update child; membership is pinned by a source grep — the runtime path no-ops under NODE_ENV=test). session-start: file-plane digest (allowlisted MEMORY.md sections, push staleness, prior failures) + crashed-session recovery push gated on an initialized manifest. session-end: confined full-transcript parse → redacted corpus write (session-id filename dedup, retention prune) → parser-drift detection (`bytes>0 && turns==0` is loud) → best-effort workspace push. session-start recovery + session-end pushes run in a DETACHED child so the hook returns immediately (a synchronous inline push previously blocked harness startup on a dirty tree); the corpus write is atomic and clears the stale ingested/in-progress sidecars so a resumed session re-ingests its appended transcript. Heartbeat JSONL is counters/reasons only by construction; `readHeartbeatTail` feeds doctor. `GBRAIN_HOOKS=0` kills all events. +- `src/core/transcripts/claude-code-jsonl.ts` — the Claude Code transcript parser as a dated spec-target (tool_use/tool_result/thinking/image/sidechain/summary/compact-boundary shapes; placeholders for non-text content); also extracts `injectedContextBlocks` — the `hook_additional_context` attachment lines a gbrain hook previously injected (verified live against claude CLI 2.1.224; marker-filtered, so a foreign hook's blocks are excluded and another tool's output can't suppress volunteering — a same-user mislabeling guard, not an authenticity check), the user-prompt hook's cross-turn dedupe input; `confineTranscriptPath` (contained under `~/.claude/projects`, `.jsonl`, lstat-rejects symlinks, byte cap). Fixtures: `test/fixtures/conversation-formats/claude-code.jsonl` (synthetic, privacy-guarded) + `test/fixtures/hook-transcript.jsonl` (real captured hook round-trip). +- `src/core/context/turn-context.ts` — server-side per-turn assembly: reflex pointers + volunteered pages (≤3) + hot facts (always `visibility=['world']` — the IPC path never widens what MCP would return) under a "data, not instructions" envelope, trimmed to ≤8KB (the harness caps hook output at 10,000 chars). The result exposes `pointers` AND post-trim `volunteered` — exactly what the rendered text carries — so the IPC delivery point can log the feedback loop without ever counting a trimmed-out page. Reuses the hot-memory cache keyed by typed sessionId. Engine-agnostic. +- `src/core/context/resolve-ipc.ts` (IPC v2) — discriminated-union requests (absent `kind` = legacy resolve; `turn_context` carries `protocol: 2` + a shared secret from a 0600 file in the data dir, plus an additive `channel` for feedback-loop attribution — wire channel claims are validated to the harness channels at the logging site, anything else logs as the default hook channel), handler map, named response types, per-kind timeouts/size caps, socket + parent dir permissions set before exposure, server-side source binding (cross-source requests rejected), protocol echo (a response without it = stale serve → loud degradation). The connection handler processes exactly ONE request per connection (trailing bytes mid-await never double-process a line or double-log a delivery); the client clamps a too-big request below the message cap by dropping the advisory `priorContextText` BEFORE any conversation turn. Delivery seams: `onDelivered` (resolve kind) and `onTurnContextDelivered` (turn_context kind) both fire ONLY after the response write succeeds — a block abandoned before the serve responded is never counted (serve's callback logs the delivered block's volunteered pages + pointers to `context_volunteer_events` under the request channel); write-accept still isn't proof of injection (the client can trim/drop after receipt), which is why the `volunteer_channels` doctor check reconciles counts against the hook heartbeat. v1 clients and servers interoperate untouched. - `src/core/facts/visibility.ts` — `resolveDefaultVisibility(engine)` / `resolveVisibilityParam`: the ONE resolver behind all four facts-visibility default sites (`facts.default_visibility` config key; explicit caller value always wins; invalid values fail closed to private). Bootstrap sets the workspace brain's default to `world` so the principal's own sessions can recall their facts — a documented, security-relevant knob. - `src/core/sweep.ts` + `src/commands/sweep.ts` — the serve-resident maintenance sweep (the lock owner closes the persistence loop): facts-fence reconciliation (zero-LLM, reuses the cycle extractor with a slug subset), deterministic link/timeline extraction over recent workspace pages (the same cores as `gbrain extract` — remote put_page deliberately skips these, the sweep is where the graph compounds), and spend-gated corpus ingest (skipped keyless; sidecar-marked exactly-once). Bounded, fail-soft, never throws; armed at serve startup (3s, best-effort) and on 10-min idle ticks through the injectable timer seam, everything unref'd; `GBRAIN_SWEEP=0` kills it. `gbrain sweep --once` is the trusted CLI seam `bootstrap verify` uses (CLI-only, never over MCP). - `src/core/capability.ts` — config-plane keyless/keyed detection + the honest capability report (`keyless mode: keyword search, agent-authored memory; add ONE key to unlock…`) rendered by verify and the runbook. diff --git a/docs/eval/BRAINBENCH.md b/docs/eval/BRAINBENCH.md index 9b5f77492..daf70c46d 100644 --- a/docs/eval/BRAINBENCH.md +++ b/docs/eval/BRAINBENCH.md @@ -21,15 +21,17 @@ Every scoreboard row carries a `seam` column: | Harness | Seam | What the row actually measures | |---|---|---| | `openclaw` | **production** | The shipped OpenClaw context-engine pipeline, byte-for-byte (`extractCandidates` → `resolveEntitiesToPointers`, 3-pointer budget, prior-context suppression, markdown pointer block). | -| `claude-code` | **contract** | gbrain's memory primitives driven through the UserPromptSubmit hook wire contract (`{prompt, session_id, cwd}` in → `{hookSpecificOutput.additionalContext}` out, exported from `src/eval/brainbench/adapters/claude-code.ts`). 2-pointer budget; NO conversation memory — a hook sees only the current prompt, so suppression is off and the re-injection cost is visible as `false_fire_rate`. | +| `claude-code` | **contract** | gbrain's memory primitives driven through the UserPromptSubmit hook wire contract (`{prompt, session_id, cwd}` in → `{hookSpecificOutput.additionalContext}` out, exported from `src/eval/brainbench/adapters/claude-code.ts`). 2-pointer budget; NO conversation memory — this row deliberately models the memoryless wire contract (suppression off), so the re-injection cost is visible as `false_fire_rate`; the shipped `gbrain hook user-prompt` layers transcript-based cross-turn dedupe on top of this same contract. | | `codex` | **contract** | The fragments model: a static entity-index preamble (computed once, slugs not counted as injections) + at most ONE per-turn fragment. Measures how much push quality degrades when injection is mostly static. | **Contract rows do NOT measure third-party harness behavior.** They measure gbrain's primitives under each harness's injection-shape constraints. The rows are comparable because fixtures, brain, and gold are identical — only the seam -contract varies. When a real integration lands (the hooks/fragments PR), its -adapter swaps transport (exec the real hook) and flips to `production` with -continuous numbers. Also not graded, by design: the production orchestrator's +contract varies. The real Claude Code integration has landed (`gbrain hook +user-prompt`, registered by `gbrain bootstrap`); flipping this adapter to exec +the real hook and report `production` numbers is a filed follow-up (TODOS.md — +"Flip contract adapters to production"). Same for codex fragments when that +integration lands. Also not graded, by design: the production orchestrator's config gate, integration heartbeat, and 1500 ms timeout wrapper. All three adapters drive ONE shared pipeline (`adapters/shared.ts`) with diff --git a/docs/guides/push-context.md b/docs/guides/push-context.md index e4e104756..89757a5ea 100644 --- a/docs/guides/push-context.md +++ b/docs/guides/push-context.md @@ -5,13 +5,14 @@ contributed anything. Push-based context inverts that — the brain volunteers relevant pages from the recent conversation, confidence-gated so push noise never becomes worse than pull silence. -Three channels share one zero-LLM core (`src/core/context/volunteer.ts`): +The push channels share one zero-LLM core (`src/core/context/volunteer.ts`): | Channel | Surface | When to use | |---|---|---| | `reflex` | automatic, inside the context engine | default-on for plugin hosts; nothing to call | | `op` | `gbrain volunteer-context` / MCP `volunteer_context` | agents without the plugin; one call per turn | | `watch` | `gbrain watch` | stream a transcript in, volunteered pages stream out | +| `claude-code` / `codex` | `gbrain hook user-prompt` (registered by `gbrain bootstrap`) | per-prompt injection inside a harness; see "Harness hooks" below | ## How it decides @@ -53,6 +54,42 @@ through a running serve's resolve socket rather than taking the lock. Routing watch through that same socket is a filed follow-up (TODOS.md). Postgres brains are unaffected. +## Harness hooks (the prompt-time channel) + +`gbrain bootstrap` registers `gbrain hook user-prompt` as a Claude Code +`UserPromptSubmit` hook: every prompt is assembled into a per-turn context +block (reflex pointers + volunteered pages + hot facts) through a running +serve's IPC socket and injected as `additionalContext`. Two properties make +this channel production-grade rather than spammy-and-invisible: + +- **Cross-turn dedupe.** The hook reads its OWN previous injections back out + of the session transcript (Claude Code records them as structured + `hook_additional_context` attachments; only gbrain-marked blocks count) and + passes them as prior context — so a page is volunteered once per session, + not once per mention. The dedupe horizon is bounded (the recent transcript + window, byte-capped), so a marathon session can eventually re-volunteer its + oldest injections. The extraction is structural, never substring matching + over raw turn text, so a short slug appearing in a tool payload can't + over-suppress. +- **The feedback loop.** The serve logs each DELIVERED block's volunteered + pages and pointers to `context_volunteer_events` under the hook's channel + (`claude-code` by default; a codex hook registration passes + `--harness codex`). `gbrain volunteer-context --stats` then shows + per-harness precision, and `gbrain doctor`'s `volunteer_channels` check + shows which channels actually fire, with guidance for the two quiet cases: + "hook installed but never registered (restart the session)" and "registered + but quiet". Logging happens at the delivery point only — a block abandoned + before the serve responded is never counted — and because a delivered + response still isn't proof of injection (the hook can trim or drop it + client-side), the doctor check reconciles the counts against the hook's own + heartbeat and cautions when they diverge. + +The hook lane rides the PGLite serve's IPC socket: on a Postgres brain or a +thin-client install the hook stays quiet by design (pull-mode retrieval covers +those; extending the lane is a filed follow-up in TODOS.md). + +Kill switch: `GBRAIN_HOOKS=0`. Install/uninstall: `docs/guides/bootstrap.md`. + ## Config | Key | Default | What it does | diff --git a/llms-full.txt b/llms-full.txt index 721b9a750..914a442bf 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -3732,13 +3732,14 @@ contributed anything. Push-based context inverts that — the brain volunteers relevant pages from the recent conversation, confidence-gated so push noise never becomes worse than pull silence. -Three channels share one zero-LLM core (`src/core/context/volunteer.ts`): +The push channels share one zero-LLM core (`src/core/context/volunteer.ts`): | Channel | Surface | When to use | |---|---|---| | `reflex` | automatic, inside the context engine | default-on for plugin hosts; nothing to call | | `op` | `gbrain volunteer-context` / MCP `volunteer_context` | agents without the plugin; one call per turn | | `watch` | `gbrain watch` | stream a transcript in, volunteered pages stream out | +| `claude-code` / `codex` | `gbrain hook user-prompt` (registered by `gbrain bootstrap`) | per-prompt injection inside a harness; see "Harness hooks" below | ## How it decides @@ -3780,6 +3781,42 @@ through a running serve's resolve socket rather than taking the lock. Routing watch through that same socket is a filed follow-up (TODOS.md). Postgres brains are unaffected. +## Harness hooks (the prompt-time channel) + +`gbrain bootstrap` registers `gbrain hook user-prompt` as a Claude Code +`UserPromptSubmit` hook: every prompt is assembled into a per-turn context +block (reflex pointers + volunteered pages + hot facts) through a running +serve's IPC socket and injected as `additionalContext`. Two properties make +this channel production-grade rather than spammy-and-invisible: + +- **Cross-turn dedupe.** The hook reads its OWN previous injections back out + of the session transcript (Claude Code records them as structured + `hook_additional_context` attachments; only gbrain-marked blocks count) and + passes them as prior context — so a page is volunteered once per session, + not once per mention. The dedupe horizon is bounded (the recent transcript + window, byte-capped), so a marathon session can eventually re-volunteer its + oldest injections. The extraction is structural, never substring matching + over raw turn text, so a short slug appearing in a tool payload can't + over-suppress. +- **The feedback loop.** The serve logs each DELIVERED block's volunteered + pages and pointers to `context_volunteer_events` under the hook's channel + (`claude-code` by default; a codex hook registration passes + `--harness codex`). `gbrain volunteer-context --stats` then shows + per-harness precision, and `gbrain doctor`'s `volunteer_channels` check + shows which channels actually fire, with guidance for the two quiet cases: + "hook installed but never registered (restart the session)" and "registered + but quiet". Logging happens at the delivery point only — a block abandoned + before the serve responded is never counted — and because a delivered + response still isn't proof of injection (the hook can trim or drop it + client-side), the doctor check reconciles the counts against the hook's own + heartbeat and cautions when they diverge. + +The hook lane rides the PGLite serve's IPC socket: on a Postgres brain or a +thin-client install the hook stays quiet by design (pull-mode retrieval covers +those; extending the lane is a filed follow-up in TODOS.md). + +Kill switch: `GBRAIN_HOOKS=0`. Install/uninstall: `docs/guides/bootstrap.md`. + ## Config | Key | Default | What it does | diff --git a/src/cli.ts b/src/cli.ts index d381b061d..7defc4359 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -209,6 +209,12 @@ for (const op of operations) { // GBRAIN_SKIP_STARTUP_HOOKS for any children they spawn. const STARTUP_HOOK_SKIP_COMMANDS = new Set([ 'upgrade', 'post-upgrade', 'check-update', 'self-upgrade', + // hook runs once per harness EVENT (user-prompt fires per prompt): a stale + // update cache would spawn a detached network-touching check-update child + // per prompt and emit UPGRADE_AVAILABLE stderr per turn. NOTE: this path + // no-ops under NODE_ENV=test, so membership is pinned by a source grep + // (test/hook-command.serial.test.ts), not a runtime test. + 'hook', ]); /** diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index a7291b169..802f2d7e9 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -432,6 +432,119 @@ export async function jsonbIntegrityCheck( } } +/** + * Per-channel push-context visibility (harness hook adapters). Groups + * context_volunteer_events by channel over the last 7 days so the operator + * can see which adapters (ambient reflex / op / watch / claude-code / codex) + * are actually firing. Engine-aware SIBLING of buildRetrievalReflexCheck + * (which is engine-free and heartbeat-file based) — deliberately a separate + * check so the existing builder keeps its signature. + * + * Info-only (never warn/fail on quiet channels — most installs use a subset). + * The message distinguishes the two "installed but nothing happens" classes: + * a hook script that never registered (restart the harness session) vs a + * registered adapter whose channel went quiet. Pre-v117 brains (no events + * table) return ok with a note instead of throwing. A serve started before + * this build logs hook traffic as 'reflex' — restart serve after upgrade. + */ +export async function checkVolunteerChannels( + engine: BrainEngine, + opts: { sourceIds?: string[] } = {}, +): Promise { + const name = 'volunteer_channels'; + try { + // Source scoping (cross-model P1): remote source-bound callers pass their + // authorized ids — an unqualified aggregate would leak other sources' + // activity counts/timestamps. Local trusted doctor passes none (brain-wide). + // Unscoped shape: no source_id predicate → the composite + // (source_id, volunteered_at DESC) index can't range-scan and this + // seq-scans the table. Accepted DELIBERATELY for the local info check + // (table is TTL-pruned at 90 days) — do NOT reuse on a hot path. + const scoped = Array.isArray(opts.sourceIds) && opts.sourceIds.length > 0; + const rows = await engine.executeRaw<{ channel: string; n: string | number; last_fired: string | Date | null }>( + scoped + ? `SELECT channel, count(*)::int AS n, max(volunteered_at) AS last_fired + FROM context_volunteer_events + WHERE source_id = ANY($1::text[]) + AND volunteered_at > now() - interval '7 days' + GROUP BY channel + ORDER BY channel` + : `SELECT channel, count(*)::int AS n, max(volunteered_at) AS last_fired + FROM context_volunteer_events + WHERE volunteered_at > now() - interval '7 days' + GROUP BY channel + ORDER BY channel`, + scoped ? [opts.sourceIds] : [], + ); + const channels: Record = {}; + for (const r of rows) { + channels[r.channel] = { + count: Number(r.n), + last_fired: r.last_fired ? new Date(r.last_fired).toISOString() : null, + }; + } + const active = Object.keys(channels); + + // RT reconciliation: server-side delivery counts fire at the response + // write — a hook client that timed out / hit its deadline / trimmed to + // nothing still gets counted. The hook's own heartbeat records those + // degradations, so surface the degraded rate next to the counts: a + // "healthy" channel with a mostly-degraded heartbeat is delivery failure. + let heartbeatNote = ''; + let heartbeat: { user_prompt_ok: number; user_prompt_degraded: number } | undefined; + try { + const { readHeartbeatTail } = await import('./hook.ts'); + const tail = await readHeartbeatTail(200); + // Same 7-day window as the event counts (a month-old degraded streak + // must not indict a healthy current week), and a minimum sample floor + // so one bad entry can't trigger the caution. + const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000; + const up = tail.filter( + (e) => e.event === 'user-prompt' && Date.parse(e.ts ?? '') >= cutoff, + ); + if (up.length >= 5) { + const degraded = up.filter((e) => e.outcome !== 'ok').length; + heartbeat = { user_prompt_ok: up.length - degraded, user_prompt_degraded: degraded }; + if (degraded > up.length / 2) { + heartbeatNote = ` — CAUTION: the hook heartbeat shows ${degraded}/${up.length} user-prompt events degraded this week, so server-side counts may overstate what was actually injected`; + } + } + } catch { /* heartbeat surface is best-effort */ } + + // Engine-aware quiet-channel guidance: the harness-hook lane rides the + // PGLite serve socket — on a Postgres brain, "check your registration and + // restart" can never make the channel fire (pull-mode covers Postgres). + const cfg = (() => { try { return loadConfig(); } catch { return null; } })(); + const quietGuidance = + cfg?.engine === 'pglite' + ? 'if a hook adapter is installed, confirm its registration landed and the harness session was RESTARTED (hooks snapshot at session start); a serve older than this build logs NOTHING for the hook lane — restart serve on the new build to activate the feedback loop' + : 'note: the harness-hook channels require a PGLite serve socket — on this engine the hook lane stays quiet by design (pull-mode retrieval covers it)'; + const message = active.length + ? `push-context channels active (7d): ${active.map((c) => `${c}=${channels[c].count}`).join(', ')}${heartbeatNote}` + : `no push-context activity in 7 days — ${quietGuidance}`; + return { + name, + status: 'ok', + message, + details: { window_days: 7, channels, ...(heartbeat ? { hook_heartbeat: heartbeat } : {}) }, + }; + } catch (e) { + // Discriminate table-absent (pre-v117 brain) from transient failures — + // a connection blip on a fully-migrated brain must not be misreported + // as an old schema. Info-only either way; never block doctor. + const msg = e instanceof Error ? e.message : String(e); + const tableAbsent = /does not exist|undefined table|no such table|42P01/i.test(msg); + return { + name, + status: 'ok', + message: tableAbsent + ? 'volunteer-events table not available (pre-v117 brain) — per-channel push visibility inactive' + : `volunteer_channels query failed (info-only check; may or may not be transient): ${msg}`, + details: { window_days: 7, channels: {} }, + }; + } +} + export async function takesWeightGridCheck(engine: BrainEngine): Promise { try { const rows = await engine.executeRaw<{ off_grid: string | number; total: string | number }>( @@ -665,7 +778,10 @@ export async function checkSourceConfigShape(engine: BrainEngine): Promise { +export async function doctorReportRemote( + engine: BrainEngine, + opts: { sourceIds?: string[] } = {}, +): Promise { const checks: Check[] = []; // 1. Connection @@ -924,6 +1040,12 @@ export async function doctorReportRemote(engine: BrainEngine): Promise void; /** TEST SEAM: user-prompt deadline override (wall-clock flake control). */ userPromptDeadlineMs?: number; + /** + * Feedback-loop attribution channel (`--harness `). + * Default 'claude-code' — the only harness bootstrap registers hooks for + * today; a codex hook registration passes the flag explicitly. + */ + harness?: 'claude-code' | 'codex'; } // ── Entry point ───────────────────────────────────────────────────────────── @@ -143,6 +152,8 @@ Events (wired into .claude/settings.local.json by gbrain bootstrap): push status, hook health) to stdout user-prompt read hook JSON on stdin, request per-turn context from a running 'gbrain serve' over IPC, print additionalContext JSON + (--harness sets the feedback-loop channel; + default claude-code, unknown values fall back to the default) stop append to the per-session live buffer session-end ingest the session transcript into the dream corpus (secret-scanned), prune old corpus files, push the workspace @@ -158,6 +169,14 @@ export async function runHook(args: string[], io: HookIo = {}): Promise write(io, USAGE + '\n'); return 0; } + // `--harness ` — feedback-loop channel attribution for + // user-prompt. Unknown values fall back to the default (fail-open: a bad + // registration must never break the hook contract). + const harnessIdx = args.indexOf('--harness'); + if (harnessIdx >= 0 && !io.harness) { + const v = args[harnessIdx + 1]; + if (v === 'claude-code' || v === 'codex') io = { ...io, harness: v }; + } if (!event || !['session-start', 'user-prompt', 'stop', 'session-end'].includes(event)) { process.stderr.write(USAGE + '\n'); return 1; @@ -742,6 +761,7 @@ async function hookUserPrompt(io: HookIo): Promise { // S3#8: transcript_path is untrusted input. A present-but-unconfined // path aborts the event (heartbeat + empty stdout), never "best effort". let turns: WindowTurn[] = []; + let priorContextText: string | undefined; if (j.transcript_path !== undefined && j.transcript_path !== null) { const conf = confineTranscriptPath(j.transcript_path, { ...(io.transcriptRoot ? { root: io.transcriptRoot } : {}), @@ -750,6 +770,34 @@ async function hookUserPrompt(io: HookIo): Promise { try { const parsed = parseTranscript(conf.path, { maxBytes: USER_PROMPT_TRANSCRIPT_MAX_BYTES }); turns = parsed.turns.slice(-USER_PROMPT_WINDOW_TURNS); + // Cross-turn dedupe: feed the blocks WE previously injected this + // session back as priorContextText (slug-only suppression + volunteer + // dedupe inside assembleTurnContext) — a page is volunteered once per + // session, not once per mention. Structured extraction only (the + // gbrain-marked hook_additional_context attachments), never raw turn + // text, so a short slug in a tool payload can't over-suppress. + // Bounded before send: identical blocks dedupe (the same pointer + // re-recorded each turn is pure redundancy) and the newest blocks are + // kept under a byte cap — an unbounded join would eventually exceed + // the 256KB IPC message cap and permanently silence the channel. + // Horizon note: the tail read bounds dedupe to the newest + // USER_PROMPT_TRANSCRIPT_MAX_BYTES of transcript — in very long + // sessions the oldest injections roll out and their pages become + // volunteerable again (documented, preferable to a state file). + if (parsed.injectedContextBlocks.length) { + const unique = [...new Set(parsed.injectedContextBlocks)]; + const kept: string[] = []; + let bytes = 0; + for (const block of unique.reverse()) { // newest first + const b = Buffer.byteLength(block, 'utf8') + 2; + // Skip (not break): one oversized block must not evict every + // older, smaller block — that would disable ALL dedupe at once. + if (bytes + b > PRIOR_CONTEXT_MAX_BYTES) continue; + kept.unshift(block); // restore oldest → newest order + bytes += b; + } + if (kept.length) priorContextText = kept.join('\n\n'); + } } catch { turns = []; // unreadable-mid-flight — the prompt alone still works } @@ -773,8 +821,14 @@ async function hookUserPrompt(io: HookIo): Promise { const res = await requestTurnContext(socketPath, { secret, window: turns, + ...(priorContextText ? { priorContextText } : {}), ...(sessionId ? { sessionId } : {}), ...(sourceId ? { sourceId } : {}), + // Feedback-loop attribution: the serve logs the delivered block's + // volunteered pages/pointers under this channel. Bootstrap registers + // hooks for Claude Code only today; a future codex registration passes + // `--harness codex` on the hook command. + channel: io.harness ?? 'claude-code', }); if (res === IPC_UNAVAILABLE) { return { outcome: 'degraded', reason: 'ipc_unavailable', turns: turns.length }; @@ -805,6 +859,14 @@ async function hookUserPrompt(io: HookIo): Promise { } if (blockText.length === 0) return { outcome: 'degraded', reason: 'over_cap', turns: turns.length }; guardedWrite(payload + '\n'); + // Partial trim is delivery-count drift: the serve already logged the FULL + // post-budget set at the response write, but pages cut from the tail here + // were never injected. Record it so the doctor's heartbeat reconciliation + // (and a future reconciler) can see the divergence — outcome stays ok + // (context WAS injected), the reason carries the signal. + if (blockText.length < text.length) { + return { outcome: 'ok', reason: 'trimmed', turns: turns.length }; + } return { outcome: 'ok', turns: turns.length }; })(); diff --git a/src/core/cli-flag-registry.generated.ts b/src/core/cli-flag-registry.generated.ts index f8aeea296..79c96101a 100644 --- a/src/core/cli-flag-registry.generated.ts +++ b/src/core/cli-flag-registry.generated.ts @@ -34,7 +34,7 @@ export const CLI_FLAG_REGISTRY: Record = { 'config': ['--aliases', '--all', '--brain', '--column', '--coverage-override', '--embedding-dimensions', '--embedding-model', '--fast', '--federated-read', '--follow', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--markdown', '--model', '--multimodal', '--no-embedding', '--no-extract', '--pattern', '--pending', '--pglite', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--surface', '--thin', '--yes'], 'connect': ['--agent', '--bearer-token-env-var', '--bind', '--brain', '--client-id', '--client-secret', '--force', '--grant-types', '--help', '--http', '--install', '--json', '--name', '--oauth', '--public-url', '--register', '--scopes', '--show-token', '--source', '--timeout-ms', '--token', '--token-endpoint-auth-method', '--url', '--version', '--yes'], 'conversation-parser': ['--aliases', '--all', '--brain', '--help', '--include-null-signature', '--json', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin'], - 'doctor': ['--ab', '--abi', '--aliases', '--all', '--allow-shell-jobs', '--auto', '--auto-fix', '--auto-update', '--background', '--batch', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--build-index', '--by-mention', '--by-type', '--check', '--column', '--compile', '--concurrency', '--confidence', '--confirm', '--content-audit', '--count', '--days', '--delete-brain', '--detach', '--dim', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--exclusive', '--explain', '--fast', '--fix', '--follow', '--force', '--force-break-lock', '--force-retry', '--force-schema', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--grant-types', '--harness', '--health-interval', '--help', '--history', '--home', '--http', '--include-flagged', '--include-frontmatter', '--include-null-signature', '--include-pseudo', '--index-audit', '--init', '--input', '--jq', '--json', '--lang', '--limit', '--locks', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-crashes', '--max-jobs', '--max-rss', '--max-usd', '--mcp-only', '--migrate-only', '--model', '--multimodal', '--name-only', '--name-status', '--near-symbol', '--nice', '--no', '--no-embed', '--no-embedding', '--no-extract', '--no-mutate', '--oauth-client-secret', '--older-than', '--once', '--overwrite', '--parallel', '--params', '--path', '--pattern', '--pending', '--pglite', '--phase', '--pid-file', '--porcelain', '--priority', '--progress-interval', '--progress-json', '--query', '--queue', '--quiet', '--rebuild-rollup', '--refresh', '--refresh-unqualified', '--regenerate', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--restore-only', '--resume', '--review-lower', '--rollback', '--scope', '--scopes', '--set', '--show-toplevel', '--since', '--skills-dir', '--skip-bare-tweet', '--skip-failed', '--skip-urls', '--skip-verify', '--slugs', '--source', '--source-id', '--stale', '--status', '--strategy', '--strict', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--target', '--target-score', '--thin', '--timeout', '--to', '--top-k', '--type', '--undo-wave', '--unsafe-bypass-dream-guard', '--untracked-files', '--url', '--use-captured-snapshot', '--verbose', '--version', '--window', '--with-calibration', '--workers', '--yes'], + 'doctor': ['--ab', '--abi', '--aliases', '--all', '--allow-shell-jobs', '--auto', '--auto-fix', '--auto-update', '--background', '--batch', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--build-index', '--by-mention', '--by-type', '--check', '--column', '--compile', '--concurrency', '--confidence', '--confirm', '--content-audit', '--count', '--days', '--delete-brain', '--detach', '--dim', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--exclusive', '--explain', '--fast', '--fix', '--follow', '--force', '--force-break-lock', '--force-retry', '--force-schema', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--grant-types', '--harness', '--health-interval', '--help', '--history', '--home', '--http', '--include-flagged', '--include-frontmatter', '--include-null-signature', '--include-pseudo', '--index-audit', '--init', '--input', '--jq', '--json', '--lang', '--limit', '--locks', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-crashes', '--max-jobs', '--max-rss', '--max-usd', '--mcp-only', '--migrate-only', '--model', '--multimodal', '--name-only', '--name-status', '--near-symbol', '--nice', '--no', '--no-embed', '--no-embedding', '--no-extract', '--no-mutate', '--oauth-client-secret', '--older-than', '--once', '--overwrite', '--parallel', '--params', '--path', '--pattern', '--pending', '--pglite', '--phase', '--pid-file', '--porcelain', '--priority', '--progress-interval', '--progress-json', '--query', '--queue', '--quiet', '--rebuild-rollup', '--refresh', '--refresh-unqualified', '--regenerate', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--restore-only', '--resume', '--review-lower', '--rollback', '--scope', '--scopes', '--set', '--show-toplevel', '--since', '--skills-dir', '--skip-bare-tweet', '--skip-failed', '--skip-urls', '--skip-verify', '--slugs', '--source', '--source-id', '--stale', '--stats', '--status', '--strategy', '--strict', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--target', '--target-score', '--thin', '--timeout', '--to', '--top-k', '--type', '--undo-wave', '--unsafe-bypass-dream-guard', '--untracked-files', '--url', '--use-captured-snapshot', '--verbose', '--version', '--window', '--with-calibration', '--workers', '--yes'], 'dream': ['--against', '--aliases', '--all', '--allow-regression', '--anchor', '--asof', '--background', '--batch', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--budget-usd', '--budget-usd-answer', '--budget-usd-retrieval', '--by-type', '--by-type-floor', '--code', '--committed-baseline', '--compare', '--compile', '--concurrent', '--ctx-size', '--cycles', '--date', '--dimensions', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--embeddings', '--expansion', '--explain', '--fast', '--federated', '--fix', '--fixtures', '--follow', '--force', '--force-break-lock', '--force-rechunk', '--force-retry', '--from', '--from-db', '--from-pages', '--gold', '--harness', '--help', '--http', '--include-holdout', '--include-null-signature', '--input', '--install', '--json', '--judge-model', '--justification', '--keyword-only', '--lang', '--limit', '--llm', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-runtime', '--max-tokens', '--max-usd', '--mcp-only', '--min-recall', '--mode', '--model', '--models', '--modes', '--multimodal', '--name', '--near-symbol', '--no', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--no-llm', '--no-mutate', '--no-trajectory', '--once', '--out', '--output', '--output-dir', '--parallel', '--path', '--pattern', '--pending', '--pglite', '--phase', '--priority', '--progress-interval', '--progress-json', '--pull', '--quiet', '--receipt-dir', '--remediate', '--repo', '--reranking', '--reset', '--resolve', '--restore-only', '--resume-from', '--retrieval-only', '--rounds', '--rubric-version', '--save', '--seed', '--short', '--show-toplevel', '--skip-replay', '--slot-a-model', '--slot-b-model', '--slot-c-model', '--slug', '--slug-prefix', '--source', '--source-id', '--stale', '--suite', '--suites', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--take', '--task', '--thin', '--threshold', '--timeout', '--to', '--top-k', '--undo', '--unsafe-bypass-dream-guard', '--update-baseline', '--version', '--window', '--yes'], 'edges-backfill': ['--aliases', '--all', '--all-sources', '--brain', '--concurrency', '--federated', '--help', '--include-null-signature', '--json', '--max-age', '--max-chunks', '--max-cost-usd', '--no-extract', '--no-federated', '--older-than', '--path', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--supersessions', '--thin', '--timeout', '--workers'], 'embed': ['--aliases', '--all', '--background', '--batch-size', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--catch-up', '--dry-run', '--embedding-dimensions', '--embedding-model', '--explain', '--fast', '--fix', '--follow', '--force', '--force-break-lock', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--lang', '--markdown', '--max-age', '--max-cost-usd', '--model', '--multimodal', '--name', '--near-symbol', '--no', '--no-embed', '--no-embedding', '--no-extract', '--pace', '--pace-max-concurrency', '--parallel', '--path', '--pattern', '--pending', '--pglite', '--prefix', '--priority', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--restore-only', '--serial', '--slugs', '--source', '--stale', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--thin', '--timeout', '--version'], @@ -50,7 +50,7 @@ export const CLI_FLAG_REGISTRY: Record = { 'friction': ['--agent', '--brain', '--help', '--hint', '--json', '--kind', '--message', '--no-redact', '--phase', '--redact', '--run-id', '--severity', '--source', '--transcript-path', '--transcripts'], 'frontmatter': ['--aliases', '--all', '--allow-catch-all', '--brain', '--cached', '--diff-filter', '--dry-run', '--exclude-standard', '--fast', '--fix', '--force', '--from-pages', '--get', '--help', '--http', '--include-catch-all', '--include-null-signature', '--json', '--name-only', '--name-status', '--no-embedding', '--no-extract', '--no-verify', '--others', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--strategy', '--supersessions', '--surface', '--thin', '--timeout', '--uninstall', '--write-back'], 'graph-query': ['--aliases', '--all', '--brain', '--depth', '--direction', '--explain', '--fast', '--force', '--from-pages', '--help', '--http', '--include-foreign', '--include-null-signature', '--json', '--lang', '--markdown', '--mcp-only', '--multimodal', '--near-symbol', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--supersessions', '--surface', '--symbol-kind', '--thin', '--timeout', '--type'], - 'hook': ['--aliases', '--all', '--batch-limit', '--brain', '--budget-ms', '--count', '--delete-brain', '--detach', '--env', '--fast', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--no-embedding', '--no-extract', '--once', '--path', '--pattern', '--pending', '--porcelain', '--reset', '--resolve', '--show-toplevel', '--source', '--stale', '--supersessions', '--surface', '--thin', '--timeout'], + 'hook': ['--aliases', '--all', '--batch-limit', '--brain', '--budget-ms', '--count', '--delete-brain', '--detach', '--env', '--fast', '--force', '--from-pages', '--harness', '--help', '--http', '--include-null-signature', '--json', '--no-embedding', '--no-extract', '--once', '--path', '--pattern', '--pending', '--porcelain', '--reset', '--resolve', '--show-toplevel', '--source', '--stale', '--stats', '--supersessions', '--surface', '--thin', '--timeout'], 'import': ['--aliases', '--all', '--asof', '--background', '--brain', '--brain-wide-max-cost-usd', '--by-mention', '--cached', '--code', '--compile', '--concurrency', '--embedding-dimensions', '--embedding-model', '--exclude', '--exclude-standard', '--explain', '--fast', '--federated', '--fix', '--follow', '--force', '--force-rechunk', '--fresh', '--from-pages', '--full', '--help', '--http', '--include-gitignored', '--include-null-signature', '--json', '--lang', '--markdown', '--max-age', '--multimodal', '--name-status', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--older-than', '--others', '--path', '--pattern', '--pending', '--pglite', '--priority', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--repo', '--reset', '--resolve', '--respect-gitignore', '--restore-only', '--since', '--skip-failed', '--source', '--source-id', '--stale', '--strategy', '--supabase', '--supersessions', '--surface', '--thin', '--timeout', '--url', '--workers'], 'init': ['--all', '--brain', '--chat-model', '--check', '--ctx-size', '--embedding-dimensions', '--embedding-model', '--entity', '--expansion-model', '--fast', '--force', '--from-pages', '--grant-types', '--help', '--http', '--issuer-url', '--json', '--judge-model', '--key', '--mcp-only', '--mcp-url', '--migrate-only', '--model', '--multimodal', '--no', '--no-embed', '--no-embedding', '--non-interactive', '--oauth-client-id', '--oauth-client-secret', '--path', '--pglite', '--provenance', '--schema-pack', '--scopes', '--skip-embed-check', '--source', '--stale', '--supabase', '--surface', '--target', '--to', '--touchpoint', '--url', '--version'], 'integrations': ['--auto', '--brain', '--dry-run', '--embeddings', '--fast', '--force', '--from-pages', '--help', '--http', '--json', '--no-embedding', '--overwrite', '--refresh', '--reranking', '--source', '--surface', '--target'], @@ -104,6 +104,6 @@ export const CLI_FLAG_REGISTRY: Record = { 'think': ['--aliases', '--all', '--anchor', '--brain', '--calibration-holder', '--explain', '--fast', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--max-usd', '--mcp-only', '--model', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--rounds', '--save', '--since', '--source', '--stale', '--supersessions', '--surface', '--take', '--thin', '--timeout', '--until', '--with-calibration'], 'transcripts': ['--aliases', '--all', '--brain', '--days', '--full', '--help', '--include-null-signature', '--json', '--limit', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin', '--timeout'], 'upgrade': ['--all', '--apply-clean-hunks', '--brain', '--check', '--code', '--compile', '--concurrency', '--embedding-dimensions', '--embedding-model', '--fast', '--ff-only', '--force', '--force-all', '--force-orchestrator', '--force-retry', '--force-schema', '--format', '--from-pages', '--help', '--host-dir', '--http', '--inject-bootstrap', '--inline', '--install', '--interval', '--json', '--limit', '--list', '--markdown', '--max-rss', '--migrate-only', '--migration', '--mode', '--model', '--multimodal', '--name-only', '--no', '--no-autopilot-install', '--no-embed', '--no-embedding', '--no-inject', '--no-worker', '--non-interactive', '--now', '--quiet', '--repo', '--since', '--skills-dir', '--skip-verify', '--source', '--stale', '--status', '--surface', '--swap-only', '--target', '--to', '--uninstall', '--user', '--verbose', '--verify', '--version', '--workers', '--yes'], - 'watch': ['--aliases', '--all', '--brain', '--fast', '--federated', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--max-pages', '--min-confidence', '--no-embedding', '--no-extract', '--no-federated', '--path', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--supersessions', '--surface', '--thin', '--window-turns'], + 'watch': ['--aliases', '--all', '--brain', '--fast', '--federated', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--max-pages', '--min-confidence', '--no-embedding', '--no-extract', '--no-federated', '--path', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--stats', '--supersessions', '--surface', '--thin', '--window-turns'], 'ze-switch': ['--aliases', '--all', '--brain', '--confirm-reembed', '--dry-run', '--force', '--help', '--ignore-env-override', '--ignore-missing-key', '--include-null-signature', '--json', '--markdown', '--no-extract', '--non-interactive', '--pattern', '--pending', '--reset', '--resolve', '--resume', '--source', '--stale', '--supersessions', '--thin', '--undo', '--yes'], }; diff --git a/src/core/context/resolve-ipc.ts b/src/core/context/resolve-ipc.ts index 2fe53ed96..0ec36ced9 100644 --- a/src/core/context/resolve-ipc.ts +++ b/src/core/context/resolve-ipc.ts @@ -93,6 +93,17 @@ export interface TurnContextRequest { /** Optional source claim — the server REJECTS any value other than its bound source [CX2-10]. */ sourceId?: string; maxBytes?: number; + /** + * Event-attribution channel for the delivery-point feedback loop (harness + * hook adapters): the server logs the delivered block's volunteered pages / + * pointers to context_volunteer_events under this channel so + * `volunteer-context --stats` and the volunteer_channels doctor check see + * per-harness firing. Validated server-side against the known channel set; + * absent/unknown → 'claude-code' (the only harness bootstrap registers + * hooks for today). Additive: old servers ignore it (no logging — the + * pre-feedback-loop status quo). + */ + channel?: string; } export type IpcRequest = ResolveRequest | TurnContextRequest; @@ -130,6 +141,16 @@ export interface IpcServerOpts { * count as "volunteered". */ onDelivered?: (block: PointerBlock, req: ResolveRequest) => void; + /** + * turn_context sibling of onDelivered — fired ONLY after an ok + * turn_context response with a non-empty block was successfully written to + * the client. This is the #2095 feedback-loop seam for the hook lane: the + * callback logs the block's post-trim volunteered pages + pointers to + * context_volunteer_events under req.channel. Same red-team rule as + * onDelivered: a block the client's budget abandoned was never injected + * and must not be counted. + */ + onTurnContextDelivered?: (result: TurnContextResult, req: TurnContextRequest) => void; /** * The server's registered source [CX2-10]. turn_context requests naming a * DIFFERENT sourceId are rejected with 'source_mismatch'; the handler always @@ -258,7 +279,15 @@ export async function requestTurnContext( window: Array.isArray(req.window) ? [...req.window] : [], }; let line = JSON.stringify(full); - // Trim oldest-first until the request fits the message cap [G11]. + // Trim to the message cap [G11] in priority order: the ADVISORY dedupe + // payload (priorContextText) is dropped BEFORE any essential window turn — + // evicting the window first would silently hollow out candidate extraction + // (empty blocks with ok:true) to preserve a hint. Then window turns, + // oldest-first. + if (Buffer.byteLength(line, 'utf8') + 1 > MAX_MSG_BYTES && full.priorContextText) { + delete full.priorContextText; + line = JSON.stringify(full); + } while (Buffer.byteLength(line, 'utf8') + 1 > MAX_MSG_BYTES && full.window.length > 0) { full.window.shift(); line = JSON.stringify(full); @@ -364,15 +393,24 @@ export async function startResolveIpcServer( return new Promise((resolve) => { const server = net.createServer((conn) => { let buf = ''; + // One request per connection: once a line is being handled, later data + // events are ignored. Without this, bytes arriving after the newline + // while the async handler is mid-await would re-find the SAME first + // line and process it concurrently — duplicate handler work, duplicate + // response writes, and duplicated delivery-point event logging. + let handled = false; conn.setEncoding('utf8'); conn.on('data', async (chunk: string) => { + if (handled) return; buf += chunk; if (buf.length > MAX_MSG_BYTES) { conn.destroy(); return; } const nl = buf.indexOf('\n'); if (nl < 0) return; + handled = true; const line = buf.slice(0, nl); let resp: string; let delivered: { block: PointerBlock; req: ResolveRequest } | null = null; + let deliveredTurnContext: { result: TurnContextResult; req: TurnContextRequest } | null = null; try { const parsed = JSON.parse(line) as IpcRequest; const kind = (parsed as { kind?: unknown }).kind ?? 'resolve'; @@ -391,9 +429,15 @@ export async function startResolveIpcServer( if (block) delivered = { block, req }; } } else if (kind === 'turn_context') { - resp = JSON.stringify( - await handleTurnContext(parsed as TurnContextRequest, handlers, opts), - ); + const req = parsed as TurnContextRequest; + const tcResp = await handleTurnContext(req, handlers, opts); + resp = JSON.stringify(tcResp); + // Feedback-loop seam: only an ok response carrying a non-empty + // block counts as a candidate delivery (rejections, degraded-null + // and empty blocks injected nothing). + if (tcResp.ok && tcResp.block && tcResp.block.text) { + deliveredTurnContext = { result: tcResp.block, req }; + } } else { resp = JSON.stringify({ ok: false, error: `unknown_kind:${String(kind)}` }); } @@ -407,6 +451,9 @@ export async function startResolveIpcServer( if (delivered && opts.onDelivered) { try { opts.onDelivered(delivered.block, delivered.req); } catch { /* telemetry only */ } } + if (deliveredTurnContext && opts.onTurnContextDelivered) { + try { opts.onTurnContextDelivered(deliveredTurnContext.result, deliveredTurnContext.req); } catch { /* telemetry only */ } + } } catch { /* client gone — do NOT log undelivered pointers */ } conn.end(); }); diff --git a/src/core/context/retrieval-reflex.ts b/src/core/context/retrieval-reflex.ts index 76c550323..25da07ec1 100644 --- a/src/core/context/retrieval-reflex.ts +++ b/src/core/context/retrieval-reflex.ts @@ -352,6 +352,16 @@ export function renderPointerBlock(pointers: ReflexPointer[]): string { * precision toward zero (corrupting the exact stats users tune * min_confidence with). */ +/** + * Canonical rationale template for a delivered reflex pointer — shared by the + * ambient-channel logger below AND the hook lane's delivery logger + * (volunteer-events.ts:logTurnContextDeliveryFireAndForget) so the two + * channels' rationale strings can never drift. + */ +export function reflexPointerRationale(p: ReflexPointer): string { + return `${p.arm} match "${p.display}"`; +} + export function logDeliveredReflexPointers(engine: BrainEngine, pointers: ReflexPointer[]): void { if (!pointers.length) return; void import('./volunteer-events.ts') @@ -359,7 +369,7 @@ export function logDeliveredReflexPointers(engine: BrainEngine, pointers: Reflex logVolunteerEventsFireAndForget( engine, volunteerEventRowsFrom( - pointers.map((p) => ({ ...p, rationale: `${p.arm} match "${p.display}"` })), + pointers.map((p) => ({ ...p, rationale: reflexPointerRationale(p) })), { channel: 'reflex' }, ), ); diff --git a/src/core/context/turn-context.ts b/src/core/context/turn-context.ts index 1afd9cc07..bbddb91fc 100644 --- a/src/core/context/turn-context.ts +++ b/src/core/context/turn-context.ts @@ -61,6 +61,14 @@ export interface TurnContextResult { text: string; /** Reflex pointers that survived suppression + budget. */ pointers: ReflexPointer[]; + /** + * Volunteered pages that survived dedupe + budget — exactly what the + * rendered text carries. Exposed so the IPC delivery point can log them to + * context_volunteer_events with channel attribution (the #2095 feedback + * loop); without this the hook lane fires invisibly to `--stats`/doctor. + * Optional for wire back-compat (an older serve's block omits it). + */ + volunteered?: VolunteeredPage[]; /** Hot facts included after budget trimming. */ factsCount: number; degradedReason?: string; @@ -192,6 +200,10 @@ export async function assembleTurnContext( return { text, pointers, + // Post-trim survivors: budget trimming mutates these arrays in place, so + // this is exactly the set present in `text` — never the pre-budget pool + // (logging a trimmed-out page would corrupt the precision stats). + volunteered, factsCount: facts.length, ...(degradedReason ? { degradedReason } : {}), }; diff --git a/src/core/context/volunteer-events.ts b/src/core/context/volunteer-events.ts index 2ec14a7b6..d8af0a62f 100644 --- a/src/core/context/volunteer-events.ts +++ b/src/core/context/volunteer-events.ts @@ -20,10 +20,85 @@ import type { BrainEngine } from './../engine.ts'; import { registerBackgroundWorkDrainer } from '../background-work.ts'; +import { reflexPointerRationale } from './retrieval-reflex.ts'; export const VOLUNTEER_EVENTS_TTL_DAYS = 90; -export type VolunteerChannel = 'op' | 'reflex' | 'watch'; +/** Single source of truth for channel values — type + guards derive from it. */ +export const VOLUNTEER_CHANNELS = ['op', 'reflex', 'watch', 'claude-code', 'codex'] as const; +export type VolunteerChannel = (typeof VOLUNTEER_CHANNELS)[number]; + +/** The harness subset — the ONLY channels a wire caller may claim. */ +export const HARNESS_CHANNELS = ['claude-code', 'codex'] as const; +export type HarnessChannel = (typeof HARNESS_CHANNELS)[number]; + +/** Wire fallback: the only harness bootstrap registers hooks for today. */ +export const DEFAULT_HOOK_CHANNEL: HarnessChannel = 'claude-code'; + +/** session_id trust-boundary clamp — shared with the volunteer_context op. */ +export const SESSION_ID_MAX_LEN = 256; + +export function isVolunteerChannel(v: unknown): v is VolunteerChannel { + return (VOLUNTEER_CHANNELS as readonly string[]).includes(v as string); +} + +/** + * Wire-facing guard: a secret-holding IPC client may attribute deliveries to + * a HARNESS channel only — accepting internal channels ('op'/'reflex'/'watch') + * from the wire would let a hook client pollute the internal channels' + * precision stats (the same corruption class the delivery-point rule guards). + */ +export function isHarnessChannel(v: unknown): v is HarnessChannel { + return (HARNESS_CHANNELS as readonly string[]).includes(v as string); +} + +/** + * The hook lane's feedback loop (#2095 closed over turn_context): log a + * DELIVERED turn-context block's post-trim volunteered pages + reflex + * pointers to context_volunteer_events under the request's channel, so + * `volunteer-context --stats` and the volunteer_channels doctor check see + * per-harness firing. Serve wires this as resolve-ipc's onTurnContextDelivered + * callback — which fires ONLY after the response write succeeded (an + * abandoned block was never injected and must not be counted). Channel is + * validated; absent/unknown → 'claude-code' (the only harness bootstrap + * registers hooks for today). sessionId is clamped like the op path's + * trust-boundary clamp. Fire-and-forget: never throws into the server. + */ +export function logTurnContextDeliveryFireAndForget( + engine: BrainEngine, + result: { + volunteered?: Array[0][number]>; + pointers?: import('./retrieval-reflex.ts').ReflexPointer[]; + }, + req: { channel?: string; sessionId?: string }, +): void { + try { + // Harness channels ONLY from the wire — see isHarnessChannel. A present- + // but-unknown value ALSO maps to the default: ACCEPTED misattribution + // (a typo'd local registration is the operator's own config; a dedicated + // 'unknown' bucket was considered and declined to keep the channel value + // set closed while bootstrap registers exactly one harness). + const channel: VolunteerChannel = isHarnessChannel(req.channel) ? req.channel : DEFAULT_HOOK_CHANNEL; + const sessionId = typeof req.sessionId === 'string' ? req.sessionId.slice(0, SESSION_ID_MAX_LEN) : null; + // Pointer rows use the shared reflex rationale template; mapped inline + // (not via logDeliveredReflexPointers) so the pending write registers + // synchronously (a late registration can be dropped at process exit). + const rows = [ + ...(result.volunteered?.length + ? volunteerEventRowsFrom(result.volunteered, { channel, session_id: sessionId }) + : []), + ...(result.pointers?.length + ? volunteerEventRowsFrom( + result.pointers.map((p) => ({ ...p, rationale: reflexPointerRationale(p) })), + { channel, session_id: sessionId }, + ) + : []), + ]; + if (rows.length) logVolunteerEventsFireAndForget(engine, rows); + } catch { + /* telemetry only — a logging bug must never surface into the IPC server */ + } +} /** * Map volunteered pages to event rows for one channel — the ONE place the diff --git a/src/core/context/volunteer.ts b/src/core/context/volunteer.ts index dcdd721ad..64a31c466 100644 --- a/src/core/context/volunteer.ts +++ b/src/core/context/volunteer.ts @@ -35,6 +35,7 @@ import { resolveEntitiesToPointers, ARM_CONFIDENCE, type ResolveArm, + type PointerBlock, } from './retrieval-reflex.ts'; export const VOLUNTEER_DEFAULT_MAX_PAGES = 3; @@ -121,41 +122,43 @@ function rationaleFor(arm: ResolveArm, display: string, c: WindowEntityCandidate return parts.join('; '); } -/** - * Volunteer confidence-gated pages for a conversation window. Pure read — - * event logging is the CALLER's job (through the volunteer-events sink). - * Non-relational, zero-LLM; returns [] when nothing clears the gate. - */ -export async function volunteerContext( - engine: BrainEngine, - turns: WindowTurn[], - opts: VolunteerOpts, -): Promise { - if (!turns.length || !opts.sourceIds?.length) return []; - const candidates = extractCandidatesFromWindow(turns); - if (!candidates.length) return []; +/** Options for the pure confidence-gate step (see gateVolunteeredPointers). */ +export interface GateOpts { + maxPages?: number; + minConfidence?: number; + /** Skipped BEFORE gate + cap — see VolunteerOpts.excludeSlugs. */ + excludeSlugs?: ReadonlySet; + /** Turn count of the extraction window — feeds the rationale template. */ + windowSize: number; +} + +/** Build the norm→candidate provenance map the gate joins pointers against. */ +export function candidatesByNorm(candidates: WindowEntityCandidate[]): Map { const byNorm = new Map(); for (const c of candidates) { const norm = normalizeAlias(c.query); if (norm && !byNorm.has(norm)) byNorm.set(norm, c); } + return byNorm; +} +/** + * The pure confidence-gate step: pointer pool in, gated VolunteeredPage[] out. + * Extracted from volunteerContext so the gate is deterministic, zero-I/O, and + * directly unit-testable (idempotency pinned in test/volunteer-context.test.ts: + * gating an already-gated set is a no-op). + */ +export function gateVolunteeredPointers( + block: PointerBlock, + byNorm: ReadonlyMap, + opts: GateOpts, +): VolunteeredPage[] { const maxPages = clampMaxPages(opts.maxPages); const minConfidence = typeof opts.minConfidence === 'number' && opts.minConfidence >= 0 && opts.minConfidence <= 1 ? opts.minConfidence : VOLUNTEER_DEFAULT_MIN_CONFIDENCE; - // Resolve up to the hard cap so the confidence gate sees the full pool — - // a gated-out alias hit must not shadow a passing title hit behind it. - const block = await resolveEntitiesToPointers(engine, opts.sourceIds[0], candidates, { - sourceIds: opts.sourceIds, - priorContextText: opts.priorContext, - suppression: 'slug-only', - maxPointers: VOLUNTEER_MAX_PAGES_CAP * 2, - }); - if (!block) return []; - const out: VolunteeredPage[] = []; for (const p of block.pointers) { if (opts.excludeSlugs?.has(p.slug)) continue; // before gate + cap — see VolunteerOpts @@ -172,7 +175,7 @@ export async function volunteerContext( display: p.display, confidence, arm: p.arm, - rationale: rationaleFor(p.arm, p.display, cand, turns.length), + rationale: rationaleFor(p.arm, p.display, cand, opts.windowSize), synopsis: p.synopsis, }); if (out.length >= maxPages) break; @@ -180,6 +183,38 @@ export async function volunteerContext( return out; } +/** + * Volunteer confidence-gated pages for a conversation window. Pure read — + * event logging is the CALLER's job (through the volunteer-events sink). + * Non-relational, zero-LLM; returns [] when nothing clears the gate. + */ +export async function volunteerContext( + engine: BrainEngine, + turns: WindowTurn[], + opts: VolunteerOpts, +): Promise { + if (!turns.length || !opts.sourceIds?.length) return []; + const candidates = extractCandidatesFromWindow(turns); + if (!candidates.length) return []; + + // Resolve up to the hard cap so the confidence gate sees the full pool — + // a gated-out alias hit must not shadow a passing title hit behind it. + const block = await resolveEntitiesToPointers(engine, opts.sourceIds[0], candidates, { + sourceIds: opts.sourceIds, + priorContextText: opts.priorContext, + suppression: 'slug-only', + maxPointers: VOLUNTEER_MAX_PAGES_CAP * 2, + }); + if (!block) return []; + + return gateVolunteeredPointers(block, candidatesByNorm(candidates), { + maxPages: opts.maxPages, + minConfidence: opts.minConfidence, + excludeSlugs: opts.excludeSlugs, + windowSize: turns.length, + }); +} + /** * Canonical human rendering of one volunteered page — shared by * `gbrain volunteer-context` (cli.ts formatResult) and `gbrain watch` so the diff --git a/src/core/doctor-categories.ts b/src/core/doctor-categories.ts index 4156d66a5..6aa01d649 100644 --- a/src/core/doctor-categories.ts +++ b/src/core/doctor-categories.ts @@ -130,6 +130,9 @@ export const SKILL_CHECK_NAMES: ReadonlySet = new Set([ 'memory_verbs_usage', 'resolver_health', 'retrieval_reflex_health', + // Harness hook adapters: per-channel push-context visibility (sibling of + // retrieval_reflex_health — same "is my agent's context wiring live?" question). + 'volunteer_channels', 'skill_brain_first', 'skill_conformance', 'skills_manifest_integrity', diff --git a/src/core/operations.ts b/src/core/operations.ts index 5d640d154..3c2bdabfd 100644 --- a/src/core/operations.ts +++ b/src/core/operations.ts @@ -3024,7 +3024,12 @@ const run_doctor: Operation = { params: {}, handler: async (ctx) => { const { doctorReportRemote } = await import('../commands/doctor.ts'); - return doctorReportRemote(ctx.engine); + // Source isolation (cross-model P1): a source-bound caller's report must + // not aggregate other sources' activity. Scope-aware checks (currently + // volunteer_channels) filter on these ids; unscoped ctx = brain-wide. + const scope = sourceScopeOpts(ctx); + const sourceIds = scope.sourceIds ?? (scope.sourceId ? [scope.sourceId] : undefined); + return doctorReportRemote(ctx.engine, { sourceIds }); }, scope: 'admin', localOnly: false, @@ -4003,12 +4008,12 @@ const volunteer_context: Operation = { // volunteer-events sink (drained at exit). Never fails the op. if (pages.length) { try { - const { logVolunteerEventsFireAndForget, volunteerEventRowsFrom } = await import('./context/volunteer-events.ts'); + const { logVolunteerEventsFireAndForget, volunteerEventRowsFrom, SESSION_ID_MAX_LEN } = await import('./context/volunteer-events.ts'); // Trust-boundary clamps (remote MCP callers): cap session_id length so // a read-scoped token can't bank unbounded TEXT per request, and only // log integer turns — a non-integer would throw inside the single // multi-row INSERT and silently drop the whole batch. - const sessionId = typeof p.session_id === 'string' ? p.session_id.slice(0, 256) : null; + const sessionId = typeof p.session_id === 'string' ? p.session_id.slice(0, SESSION_ID_MAX_LEN) : null; const turn = typeof p.turn === 'number' && Number.isInteger(p.turn) && Math.abs(p.turn) <= 2_147_483_647 ? p.turn diff --git a/src/core/transcripts/claude-code-jsonl.ts b/src/core/transcripts/claude-code-jsonl.ts index dbb1e4424..33c8130b5 100644 --- a/src/core/transcripts/claude-code-jsonl.ts +++ b/src/core/transcripts/claude-code-jsonl.ts @@ -100,6 +100,17 @@ export function confineTranscriptPath( export interface ParsedTranscript { /** Conversation turns, oldest → newest (WindowTurn — the IPC window shape). */ turns: WindowTurn[]; + /** + * Context blocks a gbrain hook previously INJECTED this session, oldest → + * newest. Claude Code records a UserPromptSubmit hook's additionalContext + * as a structured `{"type":"attachment","attachment":{"type": + * "hook_additional_context","content":[...]}}` line (verified live against + * claude CLI 2.1.224). Selected structurally — never by substring matching + * over raw turn text, which would over-suppress short slugs appearing in + * tool payloads. The user-prompt hook feeds these back as priorContextText + * so a page is volunteered once per session, not once per mention. + */ + injectedContextBlocks: string[]; /** Bytes actually read (== min(file size, maxBytes)). */ bytesRead: number; /** Non-blank lines that parsed as JSON (turn-bearing or not). */ @@ -142,6 +153,7 @@ export function parseTranscript( const lines = raw.split('\n'); const turns: WindowTurn[] = []; + const injectedContextBlocks: string[] = []; let parsedLines = 0; let skippedLines = 0; for (const line of lines) { @@ -157,10 +169,51 @@ export function parseTranscript( continue; } parsedLines++; + const injected = entryToInjectedBlock(entry); + if (injected) { + injectedContextBlocks.push(injected); + continue; + } const turn = entryToTurn(entry); if (turn) turns.push(turn); } - return { turns, bytesRead, parsedLines, skippedLines }; + return { turns, injectedContextBlocks, bytesRead, parsedLines, skippedLines }; +} + +/** + * Markers that identify a block as A gbrain injection. Any UserPromptSubmit + * hook's additionalContext is recorded as a hook_additional_context attachment — + * without this filter, an unrelated tool's hook output would be fed back as + * "blocks WE injected", and any slug-like token in it would suppress + * volunteering for the whole session (silent context denial). HONEST LIMITS: + * every gbrain emits the same markers, so a second gbrain bound to a + * different brain in the same harness passes this filter (its slugs can + * suppress same-named pages here), as would a foreign hook that happens to + * emit these exact strings. Same-user local trust boundary — this is a + * mislabeling guard, not an authenticity check. The envelope constant is + * turn-context.ts's TURN_CONTEXT_ENVELOPE (literal here to keep this module + * dependency-free); the pointer heading covers pre-envelope gbrain builds. + */ +const GBRAIN_BLOCK_MARKERS = [ + '', + '## Brain pages mentioned this turn', +] as const; + +/** + * One transcript line → a previously-injected GBRAIN context block, or null. + * See ParsedTranscript.injectedContextBlocks for the recorded shape. + */ +function entryToInjectedBlock(entry: unknown): string | null { + if (typeof entry !== 'object' || entry === null) return null; + const e = entry as Record; + if (e.type !== 'attachment') return null; + const att = e.attachment; + if (typeof att !== 'object' || att === null) return null; + const a = att as Record; + if (a.type !== 'hook_additional_context' || !Array.isArray(a.content)) return null; + const text = (a.content as unknown[]).filter((c): c is string => typeof c === 'string').join('\n').trim(); + if (!text) return null; + return GBRAIN_BLOCK_MARKERS.some((m) => text.includes(m)) ? text : null; } /** One transcript line → a WindowTurn, or null for non-turn/skipped shapes. */ diff --git a/src/mcp/server.ts b/src/mcp/server.ts index b0d96d6be..a243f4f6b 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -17,6 +17,7 @@ import { } from '../core/context/resolve-ipc.ts'; import { resolveEntitiesToPointers, logDeliveredReflexPointers } from '../core/context/retrieval-reflex.ts'; import { assembleTurnContext } from '../core/context/turn-context.ts'; +import { logTurnContextDeliveryFireAndForget } from '../core/context/volunteer-events.ts'; export async function startMcpServer(engine: BrainEngine, opts: { surface?: McpSurface } = {}) { const server = new Server( @@ -99,8 +100,9 @@ export async function startMcpServer(engine: BrainEngine, opts: { surface?: McpS await server.connect(transport); // Retrieval Reflex (#1981, D9=C): on a PGLite brain, serve owns the single - // connection, so the context engine resolves salient entities THROUGH us over - // a local unix socket rather than opening a second (impossible) connection. + // connection, so the context engine (and the per-prompt hook command) + // resolve salient entities THROUGH us over a local unix socket rather than + // opening a second (impossible) connection. // Best-effort; failure to bind never blocks the MCP server. let resolveServer: import('node:net').Server | null = null; let resolveSocket: string | null = null; @@ -155,6 +157,13 @@ export async function startMcpServer(engine: BrainEngine, opts: { surface?: McpS // client's 250ms budget abandoned was never injected, and counting it // would corrupt the volunteered-vs-used precision stats (red-team). onDelivered: (block) => logDeliveredReflexPointers(engine, block.pointers), + // The hook lane's feedback loop (#2095 closed over turn_context): + // the delivered block's post-trim volunteered pages + pointers land + // in context_volunteer_events under the request's channel. Body + // lives in volunteer-events.ts (logTurnContextDeliveryFireAndForget) + // so the shipped wiring is unit-testable. + onTurnContextDelivered: (result, req) => + logTurnContextDeliveryFireAndForget(engine, result, req), boundSourceId: defaultSource, secret: ipcSecret, }, diff --git a/test/claude-code-jsonl.test.ts b/test/claude-code-jsonl.test.ts index 3bf5712d4..184d5e999 100644 --- a/test/claude-code-jsonl.test.ts +++ b/test/claude-code-jsonl.test.ts @@ -112,6 +112,64 @@ describe('parseTranscript on the fixture [G3, A6]', () => { }); }); +describe('injectedContextBlocks — the hook dedupe input (T0-verified shape)', () => { + // Real transcript captured live (claude CLI 2.1.224) with a UserPromptSubmit + // hook installed: two prompts, two hook_additional_context attachments. + const HOOK_FIXTURE = join(import.meta.dir, 'fixtures', 'hook-transcript.jsonl'); + + test('real fixture: both injected blocks recovered, oldest → newest', () => { + const r = parseTranscript(HOOK_FIXTURE); + expect(r.injectedContextBlocks).toHaveLength(2); + expect(r.injectedContextBlocks[0]).toContain('Brain pages mentioned this turn'); + expect(r.injectedContextBlocks[0]).toContain('companies/acme-example'); + // The injections are attachments, not turns — turn extraction unaffected + // (thinking-only assistant lines surface as their [thinking] placeholder). + expect(r.turns.map((t) => t.text)).toEqual([ + 'Reply with exactly: OK', '[thinking]', 'OK', + 'Reply with exactly: OK2', '[thinking]', 'OK2', + ]); + }); + + test('over-suppression pin: entity text in a USER prompt is NOT an injected block', () => { + const dir = mkdtempSync(join(tmpdir(), 'ccjsonl-inj-')); + const p = join(dir, 't.jsonl'); + writeFileSync(p, [ + JSON.stringify({ type: 'user', message: { role: 'user', content: 'I met Widget Co yesterday' } }), + JSON.stringify({ type: 'attachment', attachment: { type: 'hook_additional_context', content: ['## Brain pages mentioned this turn\n- Acme → companies/acme'] } }), + // A DIFFERENT attachment type must not be collected either. + JSON.stringify({ type: 'attachment', attachment: { type: 'task_reminder', content: ['not ours'] } }), + ].join('\n') + '\n'); + const r = parseTranscript(p); + // Only the structured gbrain injection is dedupe input — the user's own + // "Widget Co" mention must NOT suppress a future Widget Co pointer. + expect(r.injectedContextBlocks).toHaveLength(1); + expect(r.injectedContextBlocks[0]).not.toContain('Widget'); + expect(r.turns).toHaveLength(1); + rmSync(dir, { recursive: true, force: true }); + }); + + test('foreign-hook contamination pin: another tool\'s hook_additional_context is NOT dedupe input', () => { + const dir = mkdtempSync(join(tmpdir(), 'ccjsonl-foreign-')); + const p = join(dir, 't.jsonl'); + writeFileSync(p, [ + // A foreign UserPromptSubmit hook records the same attachment type but + // carries no gbrain marker — treating it as "ours" would let any + // slug-like token in it suppress volunteering for the whole session. + JSON.stringify({ type: 'attachment', attachment: { type: 'hook_additional_context', content: ['linter status: companies/acme has TODOs'] } }), + // gbrain's own envelope-marked block IS collected. + JSON.stringify({ type: 'attachment', attachment: { type: 'hook_additional_context', content: ['\n- Acme → companies/acme'] } }), + ].join('\n') + '\n'); + const r = parseTranscript(p); + expect(r.injectedContextBlocks).toHaveLength(1); + expect(r.injectedContextBlocks[0]).toContain('retrieved brain context'); + rmSync(dir, { recursive: true, force: true }); + }); + + test('7-shape fixture (no hook installed) → empty injectedContextBlocks', () => { + expect(parseTranscript(FIXTURE).injectedContextBlocks).toEqual([]); + }); +}); + describe('toCorpusText', () => { test('role-labeled blocks; empty turns → empty string', () => { expect(toCorpusText([])).toBe(''); diff --git a/test/doctor-volunteer-channels.test.ts b/test/doctor-volunteer-channels.test.ts new file mode 100644 index 000000000..335a42c76 --- /dev/null +++ b/test/doctor-volunteer-channels.test.ts @@ -0,0 +1,183 @@ +/** + * volunteer_channels doctor check — per-channel push-context visibility. + * + * Engine-aware sibling of retrieval_reflex_health: groups + * context_volunteer_events by channel (7 days) so operators can see which + * push channels (reflex/op/watch/claude-code/codex) actually fire. Info-only + * (status never worse than ok); pre-v117 brains (no table) degrade to a note + * instead of throwing; transient failures are NOT misreported as pre-v117. + * + * Hermetic: stub engine + temp GBRAIN_HOME per test (the check reads the real + * config for engine-aware guidance and the hook heartbeat for delivery + * reconciliation — both must come from the temp home, never this machine's). + */ +import { describe, test, expect } from 'bun:test'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { checkVolunteerChannels } from '../src/commands/doctor.ts'; +import type { BrainEngine } from '../src/core/engine.ts'; +import { withEnv } from './helpers/with-env.ts'; + +function stubEngine( + rows: Array<{ channel: string; n: number | string; last_fired: string | Date | null }> | Error, + captured?: Array<{ sql: string; params: unknown[] }>, +): BrainEngine { + return { + executeRaw: async (sql: string, params: unknown[]) => { + captured?.push({ sql, params }); + if (rows instanceof Error) throw rows; + return rows; + }, + } as unknown as BrainEngine; +} + +/** Temp gbrain home with a config; returns the parent for GBRAIN_HOME. */ +function tmpHome(engine: 'pglite' | 'postgres'): string { + const parent = mkdtempSync(join(tmpdir(), 'dvc-home-')); + const dir = join(parent, '.gbrain'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'config.json'), + JSON.stringify( + engine === 'pglite' + ? { engine: 'pglite', database_path: join(dir, 'brain.pglite') } + : { engine: 'postgres', database_url: 'postgres://u:p@host:5432/db' }, + ), + ); + return parent; +} + +describe('checkVolunteerChannels', () => { + test('groups active channels with counts + last_fired; status ok', async () => { + const home = tmpHome('pglite'); + await withEnv({ GBRAIN_HOME: home }, async () => { + const check = await checkVolunteerChannels( + stubEngine([ + { channel: 'claude-code', n: 12, last_fired: '2026-08-10T10:00:00Z' }, + { channel: 'reflex', n: 40, last_fired: '2026-08-11T09:00:00Z' }, + ]), + ); + expect(check.name).toBe('volunteer_channels'); + expect(check.status).toBe('ok'); + expect(check.message).toContain('claude-code=12'); + expect(check.message).toContain('reflex=40'); + const channels = (check.details as { channels: Record }).channels; + expect(channels['claude-code'].count).toBe(12); + expect(channels['claude-code'].last_fired).toContain('2026-08-10'); + }); + rmSync(home, { recursive: true, force: true }); + }); + + test('quiet week on PGLite: registration/restart diagnosis; status ok (info-only)', async () => { + const home = tmpHome('pglite'); + await withEnv({ GBRAIN_HOME: home }, async () => { + const check = await checkVolunteerChannels(stubEngine([])); + expect(check.status).toBe('ok'); + expect(check.message).toContain('no push-context activity'); + expect(check.message).toContain('RESTARTED'); // hooks snapshot at session start + }); + rmSync(home, { recursive: true, force: true }); + }); + + test('quiet week on Postgres: engine-aware message — never sends the operator chasing hook registration (red-team)', async () => { + const home = tmpHome('postgres'); + await withEnv({ GBRAIN_HOME: home }, async () => { + const check = await checkVolunteerChannels(stubEngine([])); + expect(check.status).toBe('ok'); + expect(check.message).toContain('PGLite serve socket'); + expect(check.message).not.toContain('RESTARTED'); + }); + rmSync(home, { recursive: true, force: true }); + }); + + test('heartbeat reconciliation: mostly-degraded hook deliveries surface a CAUTION next to healthy-looking counts (red-team phantom-delivery guard)', async () => { + const home = tmpHome('pglite'); + const hooksDir = join(home, '.gbrain', 'integrations', 'hooks'); + mkdirSync(hooksDir, { recursive: true }); + const lines = [ + ...Array.from({ length: 8 }, () => ({ ts: new Date().toISOString(), event: 'user-prompt', outcome: 'degraded', reason: 'deadline' })), + ...Array.from({ length: 2 }, () => ({ ts: new Date().toISOString(), event: 'user-prompt', outcome: 'ok' })), + ]; + writeFileSync(join(hooksDir, 'heartbeat.jsonl'), lines.map((l) => JSON.stringify(l)).join('\n') + '\n'); + await withEnv({ GBRAIN_HOME: home }, async () => { + const check = await checkVolunteerChannels( + stubEngine([{ channel: 'claude-code', n: 10, last_fired: '2026-08-11T09:00:00Z' }]), + ); + expect(check.message).toContain('CAUTION'); + expect(check.message).toContain('overstate'); + const hb = (check.details as { hook_heartbeat?: { user_prompt_degraded: number } }).hook_heartbeat; + expect(hb?.user_prompt_degraded).toBe(8); + }); + rmSync(home, { recursive: true, force: true }); + }); + + test('pre-v117 brain (table absent) → ok with a note, never a throw', async () => { + const home = tmpHome('pglite'); + await withEnv({ GBRAIN_HOME: home }, async () => { + const check = await checkVolunteerChannels(stubEngine(new Error('relation "context_volunteer_events" does not exist'))); + expect(check.status).toBe('ok'); + expect(check.message).toContain('pre-v117'); + expect((check.details as { channels: object }).channels).toEqual({}); + }); + rmSync(home, { recursive: true, force: true }); + }); + + test('transient failure is NOT misreported as pre-v117 (multi-specialist finding)', async () => { + const home = tmpHome('pglite'); + await withEnv({ GBRAIN_HOME: home }, async () => { + const check = await checkVolunteerChannels(stubEngine(new Error('connection reset by peer'))); + expect(check.status).toBe('ok'); + expect(check.message).not.toContain('pre-v117'); + expect(check.message).toContain('transient'); + }); + rmSync(home, { recursive: true, force: true }); + }); + + test('engine-parity row shapes: string counts and Date/null last_fired coerce correctly', async () => { + const home = tmpHome('pglite'); + await withEnv({ GBRAIN_HOME: home }, async () => { + const check = await checkVolunteerChannels( + stubEngine([ + { channel: 'op', n: '7', last_fired: null }, + { channel: 'reflex', n: 3, last_fired: new Date('2026-08-10T10:00:00Z') }, + ]), + ); + const channels = (check.details as { channels: Record }).channels; + expect(channels['op'].count).toBe(7); // postgres.js bigint-as-string + expect(channels['op'].last_fired).toBeNull(); + expect(channels['reflex'].last_fired).toContain('2026-08-10'); // Date → ISO + }); + rmSync(home, { recursive: true, force: true }); + }); + + test('source isolation (cross-model P1): a scoped caller\'s query filters on its authorized sources', async () => { + const home = tmpHome('pglite'); + await withEnv({ GBRAIN_HOME: home }, async () => { + const captured: Array<{ sql: string; params: unknown[] }> = []; + await checkVolunteerChannels(stubEngine([], captured), { sourceIds: ['team-a'] }); + expect(captured[0].sql).toContain('source_id = ANY'); + expect(captured[0].params[0]).toEqual(['team-a']); + // Unscoped (trusted local) stays brain-wide. + captured.length = 0; + await checkVolunteerChannels(stubEngine([], captured)); + expect(captured[0].sql).not.toContain('source_id = ANY'); + }); + rmSync(home, { recursive: true, force: true }); + }); + + test('wiring pins: the check runs on BOTH doctor paths and serve registers the delivery callback (source greps)', () => { + const doctorSrc = readFileSync(join(import.meta.dir, '..', 'src', 'commands', 'doctor.ts'), 'utf8'); + // Local path (buildChecks) AND remote path both push the check — the docs + // point local operators at `gbrain doctor`, so remote-only is a doc lie. + const pushes = doctorSrc.match(/checks\.push\(await checkVolunteerChannels\(engine/g) ?? []; + expect(pushes.length).toBeGreaterThanOrEqual(2); + // Serve wires the feedback-loop callback — deleting the registration + // would silently disconnect the hook-lane feedback loop while every unit + // test stays green (the body lives in volunteer-events.ts; the seam is + // tested with stub callbacks). + const serverSrc = readFileSync(join(import.meta.dir, '..', 'src', 'mcp', 'server.ts'), 'utf8'); + expect(serverSrc).toContain('onTurnContextDelivered'); + expect(serverSrc).toContain('logTurnContextDeliveryFireAndForget(engine, result, req)'); + }); +}); diff --git a/test/fixtures/hook-transcript.jsonl b/test/fixtures/hook-transcript.jsonl new file mode 100644 index 000000000..08ca3e85d --- /dev/null +++ b/test/fixtures/hook-transcript.jsonl @@ -0,0 +1,9 @@ +{"parentUuid":null,"isSidechain":false,"promptId":"78f45f0e-2c39-4b1e-9d8f-95398888a476","type":"user","message":{"role":"user","content":"Reply with exactly: OK"},"uuid":"c5412863-c32f-4238-befe-211fd8e9e40a","timestamp":"2026-08-07T22:57:11.762Z","permissionMode":"default","promptSource":"sdk","userType":"external","entrypoint":"sdk-ts","cwd":"/private/tmp/gbrain-t0-hook-test","sessionId":"6be46ff6-277c-47f9-95ac-9168ef494450","version":"2.1.224","gitBranch":"HEAD"} +{"parentUuid":"c5412863-c32f-4238-befe-211fd8e9e40a","isSidechain":false,"attachment":{"type":"deferred_tools_delta","content":["non-hook attachment payload (pruned)"]},"type":"attachment","uuid":"0914b2ae-9fa3-4835-8c47-1340372590f9","timestamp":"2026-08-07T22:57:11.761Z","userType":"external","entrypoint":"sdk-ts","cwd":"/private/tmp/gbrain-t0-hook-test","sessionId":"6be46ff6-277c-47f9-95ac-9168ef494450","version":"2.1.224","gitBranch":"HEAD"} +{"parentUuid":"506a2299-320a-4849-ae4f-06b2ddf9f5c2","isSidechain":false,"attachment":{"type":"hook_additional_context","content":["## Brain pages mentioned this turn\n- **Acme Example** → `companies/acme-example` — T0MARKER synopsis text (use get_page before relying on details)"],"hookName":"UserPromptSubmit","toolUseID":"hook-dfcc4b2e-6420-4ce0-aefc-5adf5f327471","hookEvent":"UserPromptSubmit"},"type":"attachment","uuid":"4aab3af5-fbc5-4bf0-8e8c-dcebd96f9f9e","timestamp":"2026-08-07T22:57:11.997Z","userType":"external","entrypoint":"sdk-ts","cwd":"/private/tmp/gbrain-t0-hook-test","sessionId":"6be46ff6-277c-47f9-95ac-9168ef494450","version":"2.1.224","gitBranch":"HEAD"} +{"parentUuid":"4aab3af5-fbc5-4bf0-8e8c-dcebd96f9f9e","isSidechain":false,"message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdpDQwaJ4BRvcyTt8oapt","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"Ev8CCpMBCBAYAipA+CQZdUNK9xbPsi5AAWMR+eOlEO7dW62oifzjyKspp2s4+A5EE+DZx6l6/osd8+uxk5F8N/Coo4OT6rpKB7A95DIZY2xhdWRlLWhhaWt1LTQtNS0yMDI1MTAwMTgAQgh0aGlua2luZ1okZjUwZTdmOGYtNDRiZS00ZDNiLTkxODctMWVjODVkOTcxMTdhEgxozpFIaIr6nwJjQqUaDD6b6wbVHgiQq3/4OiIw0coI5rHvLPv/+qUn3LbnCPrFnuAET5hDjFL+r2euwW829zXy525WJRP/E3ur3W+GKpgBg+H5fYUgUehfoAiUdTb9bNNerh4+bOoUBypRP1fieWJMQJb9ukNTLwhKtVlz0jghlK3XJviYKRGvOaH4Xtz6B2ZJ+mqfDLQvD4qAfv3UrFSFEMt3dJkt+eKLdTnoU28j+RN1W06ub5ZT3ZbCDU6nfDDnBLKkHJZAs+hxQ+nKxKEPlH1BW0i7CwG5zTtzGG/9Jn+ebmG3ct0YAQ=="}],"stop_reason":"end_turn","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":8505,"cache_read_input_tokens":17923,"output_tokens":42,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":8505},"inference_geo":"not_available","iterations":[{"input_tokens":10,"output_tokens":42,"cache_read_input_tokens":17923,"cache_creation_input_tokens":8505,"cache_creation":{"ephemeral_5m_input_tokens":8505,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"diagnostics":null},"requestId":"req_011CdpDQvxLyfQCWv2P8khkD","type":"assistant","uuid":"1baf6f33-ee55-4201-a213-30cc5ec4330e","timestamp":"2026-08-07T22:57:12.996Z","userType":"external","entrypoint":"sdk-ts","cwd":"/private/tmp/gbrain-t0-hook-test","sessionId":"6be46ff6-277c-47f9-95ac-9168ef494450","version":"2.1.224","gitBranch":"HEAD"} +{"parentUuid":"1baf6f33-ee55-4201-a213-30cc5ec4330e","isSidechain":false,"message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdpDQwaJ4BRvcyTt8oapt","type":"message","role":"assistant","content":[{"type":"text","text":"OK"}],"stop_reason":"end_turn","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":8505,"cache_read_input_tokens":17923,"output_tokens":42,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":8505},"inference_geo":"not_available","iterations":[{"input_tokens":10,"output_tokens":42,"cache_read_input_tokens":17923,"cache_creation_input_tokens":8505,"cache_creation":{"ephemeral_5m_input_tokens":8505,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"diagnostics":null},"requestId":"req_011CdpDQvxLyfQCWv2P8khkD","type":"assistant","uuid":"6157afae-2c5f-49ba-8c92-f45c784ad20c","timestamp":"2026-08-07T22:57:12.998Z","userType":"external","entrypoint":"sdk-ts","cwd":"/private/tmp/gbrain-t0-hook-test","sessionId":"6be46ff6-277c-47f9-95ac-9168ef494450","version":"2.1.224","gitBranch":"HEAD"} +{"parentUuid":"6157afae-2c5f-49ba-8c92-f45c784ad20c","isSidechain":false,"promptId":"5abf932e-bcd0-4f5e-ab08-b70bcc9430ff","type":"user","message":{"role":"user","content":"Reply with exactly: OK2"},"uuid":"c2547569-6ab3-45f8-86f3-b6dd21d457c6","timestamp":"2026-08-07T22:57:38.070Z","permissionMode":"default","promptSource":"sdk","userType":"external","entrypoint":"sdk-ts","cwd":"/private/tmp/gbrain-t0-hook-test","sessionId":"6be46ff6-277c-47f9-95ac-9168ef494450","version":"2.1.224","gitBranch":"HEAD"} +{"parentUuid":"c2547569-6ab3-45f8-86f3-b6dd21d457c6","isSidechain":false,"attachment":{"type":"hook_additional_context","content":["## Brain pages mentioned this turn\n- **Acme Example** → `companies/acme-example` — T0MARKER synopsis text (use get_page before relying on details)"],"hookName":"UserPromptSubmit","toolUseID":"hook-ac0a75fb-4416-4776-8a3b-8f26074a7e92","hookEvent":"UserPromptSubmit"},"type":"attachment","uuid":"7ed2565b-cd29-4c68-8a49-6120be7a565c","timestamp":"2026-08-07T22:57:38.081Z","userType":"external","entrypoint":"sdk-ts","cwd":"/private/tmp/gbrain-t0-hook-test","sessionId":"6be46ff6-277c-47f9-95ac-9168ef494450","version":"2.1.224","gitBranch":"HEAD"} +{"parentUuid":"7ed2565b-cd29-4c68-8a49-6120be7a565c","isSidechain":false,"message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdpDSsqNWmeG6GyEPqti9","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"EssCCpMBCBAYAipAoIHdw7UeVQFGVggtXZWJo+MzYkcxbdDKhzUeYIZlYhbMIJ9K89udijGxf9wsx1Mlz95+tbcUve2wfhViilEJzjIZY2xhdWRlLWhhaWt1LTQtNS0yMDI1MTAwMTgAQgh0aGlua2luZ1okZjUwZTdmOGYtNDRiZS00ZDNiLTkxODctMWVjODVkOTcxMTdhEgzabyBggUAlP8Q0na8aDCiuK4NGCa/6eHl6BiIw6szGDxbpIvQkLYbqTKRJlCA6n64XIx6hnyoUBs3FAEbak0GdBL7twTiF4lHSOxv0KmU6b5udn/54+g/96TjFQo0arUzfDj+g3RVuopeFQE4ylawKLkaEZfnBL4q6URiV1aiBO0ksa3bL9JbczvdY+brnQ4AX+Xkn55XGz6jpX59B3dSREcTdIOLP9e9KiuV1Tjv3V3iBwxgB"}],"stop_reason":"end_turn","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":121,"cache_read_input_tokens":26428,"output_tokens":33,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":121},"inference_geo":"not_available","iterations":[{"input_tokens":10,"output_tokens":33,"cache_read_input_tokens":26428,"cache_creation_input_tokens":121,"cache_creation":{"ephemeral_5m_input_tokens":121,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"diagnostics":null},"requestId":"req_011CdpDSrW1XHHRdozGtBYFD","type":"assistant","uuid":"07076359-b0d0-4fe4-a9df-a27d72f828d8","timestamp":"2026-08-07T22:57:39.029Z","userType":"external","entrypoint":"sdk-ts","cwd":"/private/tmp/gbrain-t0-hook-test","sessionId":"6be46ff6-277c-47f9-95ac-9168ef494450","version":"2.1.224","gitBranch":"HEAD"} +{"parentUuid":"07076359-b0d0-4fe4-a9df-a27d72f828d8","isSidechain":false,"message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdpDSsqNWmeG6GyEPqti9","type":"message","role":"assistant","content":[{"type":"text","text":"OK2"}],"stop_reason":"end_turn","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":121,"cache_read_input_tokens":26428,"output_tokens":33,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":121},"inference_geo":"not_available","iterations":[{"input_tokens":10,"output_tokens":33,"cache_read_input_tokens":26428,"cache_creation_input_tokens":121,"cache_creation":{"ephemeral_5m_input_tokens":121,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"diagnostics":null},"requestId":"req_011CdpDSrW1XHHRdozGtBYFD","type":"assistant","uuid":"1aaf0c13-c744-4b1d-a678-6c7bd64af48a","timestamp":"2026-08-07T22:57:39.031Z","userType":"external","entrypoint":"sdk-ts","cwd":"/private/tmp/gbrain-t0-hook-test","sessionId":"6be46ff6-277c-47f9-95ac-9168ef494450","version":"2.1.224","gitBranch":"HEAD"} diff --git a/test/hook-command.serial.test.ts b/test/hook-command.serial.test.ts index d1cb67193..97499d112 100644 --- a/test/hook-command.serial.test.ts +++ b/test/hook-command.serial.test.ts @@ -210,6 +210,96 @@ describe('user-prompt', () => { expect((await lastHeartbeat())?.turns).toBe(5); }); + test('cross-turn dedupe: previously-injected blocks ride priorContextText; channel defaults to claude-code', async () => { + const dataDir = join(tmp, 'data'); + writePgliteConfig(dataDir); + let seen: TurnContextRequest | null = null; + await startServer({ dataDir, blockText: 'ok', onRequest: (r) => { seen = r; } }); + // Real captured transcript (claude CLI 2.1.224, hook installed): two + // hook_additional_context attachments naming companies/acme-example. + const projRoot = join(tmp, 'projects'); + mkdirSync(join(projRoot, 'p1'), { recursive: true }); + const transcript = join(projRoot, 'p1', 'sess.jsonl'); + copyFileSync(join(import.meta.dir, 'fixtures', 'hook-transcript.jsonl'), transcript); + const out = collectStdout(); + await runHook(['user-prompt'], { + ...out.io, + stdin: JSON.stringify({ prompt: 'more about Acme Example?', transcript_path: transcript, session_id: 's-3' }), + transcriptRoot: projRoot, + }); + expect(seen).not.toBeNull(); + // Dedupe input = ONLY the structured injections (both blocks, joined) — + // the serve suppresses re-volunteering companies/acme-example this turn. + expect(seen!.priorContextText).toContain('companies/acme-example'); + expect(seen!.priorContextText).not.toContain('Reply with exactly'); // never raw turn text + // Feedback-loop attribution: default channel is claude-code (the only + // harness bootstrap registers hooks for today). + expect(seen!.channel).toBe('claude-code'); + }); + + test('priorContextText is deduped and byte-capped: an injection-heavy session can never blow the IPC message cap', async () => { + const dataDir = join(tmp, 'data'); + writePgliteConfig(dataDir); + let seen: TurnContextRequest | null = null; + await startServer({ dataDir, blockText: 'ok', onRequest: (r) => { seen = r; } }); + const projRoot = join(tmp, 'projects'); + mkdirSync(join(projRoot, 'p1'), { recursive: true }); + const transcript = join(projRoot, 'p1', 'sess.jsonl'); + // 60 injections: 50 identical (per-turn re-records of one block) + 10 + // distinct 8KB blocks — raw join would be ~90KB+; the cap keeps ≤32KB + // of NEWEST distinct blocks. + const bigBlock = (i: number) => + `## Brain pages mentioned this turn\n- **Page ${i}** → \`pages/p${i}\` — ${'x'.repeat(8000)}`; + const lines = [ + ...Array.from({ length: 50 }, () => + JSON.stringify({ type: 'attachment', attachment: { type: 'hook_additional_context', content: ['## Brain pages mentioned this turn\n- **Dup** → `pages/dup` — same block every turn'] } })), + ...Array.from({ length: 10 }, (_, i) => + JSON.stringify({ type: 'attachment', attachment: { type: 'hook_additional_context', content: [bigBlock(i)] } })), + ]; + writeFileSync(transcript, lines.join('\n') + '\n'); + const out = collectStdout(); + await runHook(['user-prompt'], { + ...out.io, + stdin: JSON.stringify({ prompt: 'more about Acme?', transcript_path: transcript, session_id: 's-cap' }), + transcriptRoot: projRoot, + }); + expect(seen).not.toBeNull(); + const prior = seen!.priorContextText!; + expect(Buffer.byteLength(prior, 'utf8')).toBeLessThanOrEqual(32 * 1024); + // Newest-first retention: the newest distinct block survives the cap... + expect(prior).toContain('pages/p9'); + // ...and identical re-records collapsed to one occurrence. + expect(prior.split('pages/dup').length - 1).toBeLessThanOrEqual(1); + }); + + test('--harness codex flags the channel; unknown values fall back to the default', async () => { + const dataDir = join(tmp, 'data'); + writePgliteConfig(dataDir); + const seen: TurnContextRequest[] = []; + await startServer({ dataDir, blockText: 'ok', onRequest: (r) => { seen.push(r); } }); + const out = collectStdout(); + await runHook(['user-prompt', '--harness', 'codex'], { + ...out.io, + stdin: JSON.stringify({ prompt: 'hello Acme' }), + }); + await runHook(['user-prompt', '--harness', 'vim'], { + ...out.io, + stdin: JSON.stringify({ prompt: 'hello Acme' }), + }); + expect(seen).toHaveLength(2); + expect(seen[0].channel).toBe('codex'); + expect(seen[1].channel).toBe('claude-code'); // fail-open to the default + }); + + test('hook ∈ STARTUP_HOOK_SKIP_COMMANDS (source grep — maybeEmitUpdateMarker no-ops under NODE_ENV=test, so no runtime test can pin this)', () => { + const cliSrc = readFileSync(join(import.meta.dir, '..', 'src', 'cli.ts'), 'utf8'); + const m = cliSrc.match(/const STARTUP_HOOK_SKIP_COMMANDS = new Set\(\[[\s\S]*?\]\);/); + expect(m).not.toBeNull(); + // user-prompt fires once per user PROMPT: a stale update cache would + // otherwise spawn a detached check-update child per prompt. + expect(m![0]).toContain("'hook'"); + }); + test('confinement rejection aborts: heartbeat + exit 0 empty [S3#8]', async () => { const dataDir = join(tmp, 'data'); writePgliteConfig(dataDir); diff --git a/test/resolve-ipc-v2.test.ts b/test/resolve-ipc-v2.test.ts index f8220aee7..6c453ddde 100644 --- a/test/resolve-ipc-v2.test.ts +++ b/test/resolve-ipc-v2.test.ts @@ -448,3 +448,152 @@ describe('resolve kind honors boundSourceId [CX2-10]', () => { expect(seenSourceId).toBe('any-source-at-all'); }); }); + +describe('onTurnContextDelivered — the hook lane feedback-loop seam (#2095)', () => { + const richBlock: TurnContextResult = { + text: 'CONTEXT BLOCK', + pointers: [{ display: 'Alice', slug: 'people/alice', source_id: 'default', synopsis: 'x', arm: 'alias', confidence: 0.9 }], + volunteered: [{ slug: 'companies/acme', source_id: 'default', display: 'Acme', confidence: 0.85, arm: 'title', rationale: 'exact title match "Acme"', synopsis: 'y' }], + factsCount: 0, + }; + + test('fires after an ok non-empty delivery, with the request (channel attribution)', async () => { + const dir = tmpDir(); + const sock = resolveSocketPath(dir); + const secret = ensureIpcSecret(dir); + const delivered: Array<{ result: TurnContextResult; req: TurnContextRequest }> = []; + const server = await startResolveIpcServer( + sock, + { resolve: async () => null, turn_context: async () => richBlock }, + { + secret, + boundSourceId: 'default', + onTurnContextDelivered: (result, req) => delivered.push({ result, req }), + }, + ); + servers.push(server!); + const resp = await requestTurnContext(sock, { secret, window: [{ role: 'user', text: 'hi Acme' }], channel: 'claude-code', sessionId: 's-1' }); + expect((resp as TurnContextResponse).ok).toBe(true); + // Poll (never a fixed sleep — the post-write callback races the client's + // response receipt and can land late on a loaded box). + const deadline = Date.now() + 2000; + while (delivered.length === 0 && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 10)); + } + expect(delivered).toHaveLength(1); + expect(delivered[0].result.volunteered).toHaveLength(1); + expect(delivered[0].result.pointers).toHaveLength(1); + expect(delivered[0].req.channel).toBe('claude-code'); + expect(delivered[0].req.sessionId).toBe('s-1'); + }); + + test('does NOT fire on rejections or empty blocks (nothing was injected)', async () => { + const dir = tmpDir(); + const sock = resolveSocketPath(dir); + const secret = ensureIpcSecret(dir); + const fired: string[] = []; + let emptyMode = true; + const server = await startResolveIpcServer( + sock, + { resolve: async () => null, turn_context: async () => (emptyMode ? stubBlock : richBlock) }, + { secret, boundSourceId: 'default', onTurnContextDelivered: (_r, req) => { fired.push(req.sessionId ?? '?'); } }, + ); + servers.push(server!); + + // Empty block: ok response, but nothing injected → no callback. + await requestTurnContext(sock, { secret, window: [{ role: 'user', text: 'hi' }], sessionId: 'empty-1' }); + // Unauthorized: rejection → no callback. + await requestTurnContext(sock, { secret: 'wrong-secret', window: [{ role: 'user', text: 'hi' }], sessionId: 'unauth-1' }); + // Absence proven by ORDERING, not timing: a third request whose callback + // IS expected must be the FIRST and only delivery observed. + emptyMode = false; + await requestTurnContext(sock, { secret, window: [{ role: 'user', text: 'hi' }], sessionId: 'sentinel-1' }); + const deadline = Date.now() + 2000; + while (fired.length === 0 && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 10)); + } + expect(fired).toEqual(['sentinel-1']); + }); + + test('clamp priority: oversized priorContextText is dropped BEFORE any window turn (red-team trim inversion)', async () => { + const dir = tmpDir(); + const sock = resolveSocketPath(dir); + const secret = ensureIpcSecret(dir); + let seen: TurnContextRequest | null = null; + const server = await startResolveIpcServer( + sock, + { resolve: async () => null, turn_context: async (req) => { seen = req; return richBlock; } }, + { secret, boundSourceId: 'default' }, + ); + servers.push(server!); + const window: WindowTurn[] = Array.from({ length: 4 }, (_, i) => ({ role: 'user' as const, text: `turn ${i} about Acme` })); + const resp = await requestTurnContext(sock, { + secret, + window, + priorContextText: 'p'.repeat(300 * 1024), // alone exceeds the 256KB cap + }); + expect((resp as TurnContextResponse).ok).toBe(true); + // The ADVISORY dedupe payload was sacrificed; the ESSENTIAL window + // arrived intact — evicting turns to preserve a hint would silently + // hollow out candidate extraction. + expect(seen!.priorContextText).toBeUndefined(); + expect(seen!.window).toHaveLength(4); + }); + + test('re-entrancy guard: trailing bytes after the request line never double-process it (duplicate delivery logging)', async () => { + const dir = tmpDir(); + const sock = resolveSocketPath(dir); + const secret = ensureIpcSecret(dir); + let handlerCalls = 0; + let fired = 0; + const server = await startResolveIpcServer( + sock, + { + resolve: async () => null, + turn_context: async () => { + handlerCalls++; + await new Promise((r) => setTimeout(r, 50)); // hold the handler mid-await + return richBlock; + }, + }, + { secret, boundSourceId: 'default', onTurnContextDelivered: () => { fired++; } }, + ); + servers.push(server!); + + // Raw client: request line + trailing garbage bytes while the handler is + // still awaiting — pre-guard, the second data event re-found the SAME + // line and processed it concurrently (duplicate rows in the stats table). + const req = JSON.stringify({ kind: 'turn_context', protocol: 2, secret, window: [{ role: 'user', text: 'hi' }] }); + const responses: string[] = []; + await new Promise((done) => { + const c = net.createConnection(sock); + c.setEncoding('utf8'); + c.on('connect', () => { + c.write(req + '\n'); + setTimeout(() => { try { c.write('trailing garbage\n'); } catch { /* closed */ } }, 10); + }); + c.on('data', (d: string) => { responses.push(d); }); + c.on('close', () => done()); + c.on('error', () => done()); + }); + await new Promise((r) => setTimeout(r, 100)); + expect(handlerCalls).toBe(1); + expect(fired).toBe(1); + expect(responses.join('').split('\n').filter(Boolean)).toHaveLength(1); + }); + + test('channel is additive on the wire: a server without the callback ignores it', async () => { + const dir = tmpDir(); + const sock = resolveSocketPath(dir); + const secret = ensureIpcSecret(dir); + const server = await startResolveIpcServer( + sock, + { resolve: async () => null, turn_context: async () => richBlock }, + { secret, boundSourceId: 'default' }, // no onTurnContextDelivered registered + ); + servers.push(server!); + const resp = await requestTurnContext(sock, { secret, window: [{ role: 'user', text: 'hi' }], channel: 'codex' }); + expect((resp as TurnContextResponse).ok).toBe(true); + expect((resp as TurnContextResponse).block?.text).toBe('CONTEXT BLOCK'); + }); +}); diff --git a/test/turn-context.test.ts b/test/turn-context.test.ts index 2a002cef9..5567b5564 100644 --- a/test/turn-context.test.ts +++ b/test/turn-context.test.ts @@ -107,6 +107,62 @@ describe('assembleTurnContext', () => { expect(hits).toBe(1); }); + test('result.volunteered carries the POST-trim survivors (feedback-loop input, never the pre-budget pool)', async () => { + // Two distinct entities: one resolves as a reflex pointer (subject of the + // newest turn), the other only via the volunteer arm. + await seedPage('people/alice-example', 'Alice Example', 'Alice Example founder profile.'); + await seedPage('companies/widget-co', 'Widget Co', 'Widget Co company page.'); + const r = await assembleTurnContext(engine, { + sourceId: 'default', + window: [ + { role: 'user', text: 'Widget Co update?' }, + { role: 'user', text: 'and what about Widget Co and Alice Example today?' }, + ], + }); + // Whatever the split between arms, the union of pointers + volunteered is + // exactly what the rendered text carries — the delivery-point logger's + // contract (logging a trimmed-out page would corrupt precision stats). + const surfaced = [...r.pointers.map((p) => p.slug), ...(r.volunteered ?? []).map((v) => v.slug)]; + for (const slug of surfaced) { + expect(r.text).toContain(slug); + } + expect(surfaced.length).toBeGreaterThan(0); + // No overlap between the arms (volunteer dedupes against pointers). + expect(new Set(surfaced).size).toBe(surfaced.length); + // volunteered is always present on a fresh assembly (empty when none). + expect(Array.isArray(r.volunteered)).toBe(true); + }); + + test('budget-trimmed volunteered pages are excluded from result.volunteered (never the pre-budget pool)', async () => { + // Five distinct entities: pointers arm takes its cap, the volunteer arm + // takes the rest; a tight maxBytes then forces the volunteered while-loop + // to drop at least one. The invariant under test: result.volunteered is + // EXACTLY the set present in the rendered text — a copy-before-trim + // refactor would silently log trimmed-out pages to the precision stats. + // Lowercase lead-in: a capitalized verb would merge into the first + // candidate's capitalized run and drop it from extraction. + const names = ['Aaa Corp', 'Bbb Corp', 'Ccc Corp', 'Ddd Corp', 'Eee Corp', 'Fff Corp', 'Ggg Corp']; + for (const n of names) { + const slug = `companies/${n.split(' ')[0].toLowerCase()}`; + await seedPage(slug, n, `${n} — ${'synopsis filler '.repeat(20)}.`); + } + const window = [{ role: 'user' as const, text: `we saw ${names.join(', ')} today` }]; + const full = await assembleTurnContext(engine, { sourceId: 'default', window }); + const fullVolunteered = full.volunteered ?? []; + expect(fullVolunteered.length).toBeGreaterThan(1); // needs ≥2 to trim meaningfully + + // Budget sized to keep the block but force dropping ≥1 volunteered page. + const tight = Math.max(600, Buffer.byteLength(full.text, 'utf8') - 150); + const r = await assembleTurnContext(engine, { sourceId: 'default', window, maxBytes: tight }); + expect(r.degradedReason).toBe('budget_trimmed'); + const trimmed = r.volunteered ?? []; + expect(trimmed.length).toBeLessThan(fullVolunteered.length); // ≥1 dropped + for (const v of trimmed) expect(r.text).toContain(v.slug); // survivors ARE in the text + const droppedSlugs = fullVolunteered.map((v) => v.slug).filter((s) => !trimmed.some((v) => v.slug === s)); + expect(droppedSlugs.length).toBeGreaterThan(0); + for (const s of droppedSlugs) expect(r.text).not.toContain(s); // dropped are NOT in the text + }); + test('budget trims facts BEFORE pointers, lowest confidence first [ENG-1]', async () => { await seedPage('people/alice-example', 'Alice Example', 'Alice Example founder profile.'); for (let i = 0; i < 8; i++) { diff --git a/test/volunteer-context.test.ts b/test/volunteer-context.test.ts index a972ef4f9..97c6f7262 100644 --- a/test/volunteer-context.test.ts +++ b/test/volunteer-context.test.ts @@ -17,8 +17,11 @@ import { parseWindow, volunteerContext, volunteerUsageStats, + gateVolunteeredPointers, + candidatesByNorm, VOLUNTEER_DEFAULT_MIN_CONFIDENCE, } from '../src/core/context/volunteer.ts'; +import type { PointerBlock } from '../src/core/context/retrieval-reflex.ts'; import { insertVolunteerEvents } from '../src/core/context/volunteer-events.ts'; import { TAKES_FENCE_BEGIN, TAKES_FENCE_END } from '../src/core/takes-fence.ts'; @@ -239,6 +242,43 @@ describe('volunteerContext', () => { }); }); +describe('gateVolunteeredPointers — direct unit (the pure gate step)', () => { + const BLOCK: PointerBlock = { + pointers: [ + { display: 'Alice Example', slug: 'people/alice-example', source_id: 'default', synopsis: 'x', arm: 'alias', confidence: 0.9 }, + { display: 'Widget Co', slug: 'companies/widget-co', source_id: 'default', synopsis: 'y', arm: 'slug-suffix', confidence: 0.6 }, + ], + text: 'BLOCK', + }; + const CANDS = candidatesByNorm( + extractCandidatesFromWindow([{ role: 'user', text: 'Alice Example met Widget Co' }]), + ); + + test('gates below-threshold arms out; passes alias arm with newest-turn boost', () => { + const pages = gateVolunteeredPointers(BLOCK, CANDS, { windowSize: 1 }); + expect(pages.map((p) => p.slug)).toEqual(['people/alice-example']); // slug-suffix 0.6+0.05 < 0.7 + expect(pages[0].confidence).toBeCloseTo(0.95); // 0.9 alias + 0.05 newest-turn + }); + + test('idempotent: re-gating the survivors changes nothing', () => { + const once = gateVolunteeredPointers(BLOCK, CANDS, { windowSize: 1 }); + const survivorsAsBlock: PointerBlock = { + pointers: once.map((p) => ({ display: p.display, slug: p.slug, source_id: p.source_id, synopsis: p.synopsis, arm: p.arm, confidence: p.confidence })), + text: 'SURVIVORS', + }; + const twice = gateVolunteeredPointers(survivorsAsBlock, CANDS, { windowSize: 1 }); + expect(twice.map((p) => p.slug)).toEqual(once.map((p) => p.slug)); + }); + + test('excludeSlugs skips BEFORE the cap so a recurring slug never starves new pages', () => { + const pages = gateVolunteeredPointers(BLOCK, CANDS, { + windowSize: 1, + excludeSlugs: new Set(['people/alice-example']), + }); + expect(pages).toEqual([]); + }); +}); + describe('volunteerUsageStats', () => { test('join math: used = last_retrieved_at > volunteered_at, labeled approximate', async () => { await seed('people/alice-example', 'Alice Example', 'Founder.'); diff --git a/test/volunteer-events-delivery.test.ts b/test/volunteer-events-delivery.test.ts new file mode 100644 index 000000000..c6a4e821f --- /dev/null +++ b/test/volunteer-events-delivery.test.ts @@ -0,0 +1,134 @@ +/** + * The hook lane's delivery logging — the SHIPPED wiring behind serve's + * onTurnContextDelivered callback (logTurnContextDeliveryFireAndForget) plus + * the isVolunteerChannel runtime guard. Hermetic: stub engine captures the + * multi-row INSERT; the fire-and-forget sink is drained per test. + */ +import { describe, test, expect, beforeEach } from 'bun:test'; +import { + isVolunteerChannel, + logTurnContextDeliveryFireAndForget, + awaitPendingVolunteerEventWrites, + _resetPendingVolunteerEventWritesForTests, +} from '../src/core/context/volunteer-events.ts'; +import { logDeliveredReflexPointers, type ReflexPointer } from '../src/core/context/retrieval-reflex.ts'; +import type { VolunteeredPage } from '../src/core/context/volunteer.ts'; +import type { BrainEngine } from '../src/core/engine.ts'; + +interface CapturedInsert { sql: string; params: unknown[] } + +function stubEngine(captured: CapturedInsert[], fail = false): BrainEngine { + return { + executeRaw: async (sql: string, params: unknown[]) => { + if (fail) throw new Error('insert exploded'); + captured.push({ sql, params }); + return []; + }, + } as unknown as BrainEngine; +} + +const PAGE: VolunteeredPage = { + slug: 'companies/acme', source_id: 'default', display: 'Acme', confidence: 0.85, + arm: 'title', rationale: 'exact title match "Acme"', synopsis: 'a company', +}; +const POINTER: ReflexPointer = { + display: 'Alice', slug: 'people/alice', source_id: 'default', synopsis: 'x', arm: 'alias', confidence: 0.9, +}; + +beforeEach(() => { + _resetPendingVolunteerEventWritesForTests(); +}); + +describe('isVolunteerChannel', () => { + test('accepts exactly the five known channels; rejects everything else', () => { + for (const ok of ['op', 'reflex', 'watch', 'claude-code', 'codex']) { + expect(isVolunteerChannel(ok)).toBe(true); + } + for (const bad of ['vim', '', null, undefined, 42, {}, 'CLAUDE-CODE', 'hook']) { + expect(isVolunteerChannel(bad)).toBe(false); + } + }); +}); + +describe('logTurnContextDeliveryFireAndForget — the shipped serve wiring', () => { + test('volunteered pages land under the request channel with the clamped sessionId', async () => { + const captured: CapturedInsert[] = []; + const engine = stubEngine(captured); + logTurnContextDeliveryFireAndForget( + engine, + { volunteered: [PAGE], pointers: [] }, + { channel: 'codex', sessionId: 's'.repeat(400) }, + ); + await awaitPendingVolunteerEventWrites(2000); + expect(captured).toHaveLength(1); + const { params } = captured[0]; + expect(params).toContain('codex'); + expect(params).toContain('companies/acme'); + // Trust-boundary clamp: 400-char sessionId stored at 256. + const session = (params as string[]).find((p) => typeof p === 'string' && p.startsWith('sss')); + expect(session!.length).toBe(256); + }); + + test('absent/unknown channel falls back to claude-code (the only registered harness today)', async () => { + const captured: CapturedInsert[] = []; + logTurnContextDeliveryFireAndForget(stubEngine(captured), { volunteered: [PAGE] }, {}); + await awaitPendingVolunteerEventWrites(2000); + expect(captured[0].params).toContain('claude-code'); + + captured.length = 0; + _resetPendingVolunteerEventWritesForTests(); + logTurnContextDeliveryFireAndForget(stubEngine(captured), { volunteered: [PAGE] }, { channel: 'not-a-channel' }); + await awaitPendingVolunteerEventWrites(2000); + expect(captured[0].params).toContain('claude-code'); + }); + + test('pointers log through the reflex-pointer path under the SAME channel', async () => { + const captured: CapturedInsert[] = []; + logTurnContextDeliveryFireAndForget(stubEngine(captured), { volunteered: [], pointers: [POINTER] }, { channel: 'claude-code' }); + await awaitPendingVolunteerEventWrites(2000); + expect(captured).toHaveLength(1); + expect(captured[0].params).toContain('claude-code'); + expect(captured[0].params).toContain('people/alice'); + }); + + test('empty delivery logs nothing; a failing insert never throws into the caller', async () => { + const captured: CapturedInsert[] = []; + logTurnContextDeliveryFireAndForget(stubEngine(captured), { volunteered: [], pointers: [] }, { channel: 'claude-code' }); + await awaitPendingVolunteerEventWrites(2000); + expect(captured).toHaveLength(0); + + // Fire-and-forget: the sink swallows the insert failure. + expect(() => + logTurnContextDeliveryFireAndForget(stubEngine([], true), { volunteered: [PAGE] }, { channel: 'op' }), + ).not.toThrow(); + await awaitPendingVolunteerEventWrites(2000); + }); +}); + +describe('logDeliveredReflexPointers — ambient reflex channel', () => { + test('logs under the reflex channel with the shared rationale template', async () => { + const captured: CapturedInsert[] = []; + logDeliveredReflexPointers(stubEngine(captured), [POINTER]); + await awaitPendingVolunteerEventWrites(2000); + expect(captured).toHaveLength(1); + expect(captured[0].params).toContain('reflex'); + expect(captured[0].params).toContain('alias match "Alice"'); // reflexPointerRationale parity + }); +}); + +describe('channel guards', () => { + test('isHarnessChannel accepts only harness channels — internal channels are refused from the wire', async () => { + const { isHarnessChannel } = await import('../src/core/context/volunteer-events.ts'); + expect(isHarnessChannel('claude-code')).toBe(true); + expect(isHarnessChannel('codex')).toBe(true); + for (const internal of ['op', 'reflex', 'watch']) expect(isHarnessChannel(internal)).toBe(false); + }); + + test('wire delivery claiming an INTERNAL channel falls back to the harness default (stat-pollution guard)', async () => { + const captured: CapturedInsert[] = []; + logTurnContextDeliveryFireAndForget(stubEngine(captured), { volunteered: [PAGE] }, { channel: 'reflex' }); + await awaitPendingVolunteerEventWrites(2000); + expect(captured[0].params).toContain('claude-code'); + expect(captured[0].params).not.toContain('reflex'); + }); +});