Files
gbrain/test/claude-code-jsonl.test.ts
Garry TanandClaude Fable 5 c2cc8b0207 v0.45.1.0 feat(context): hook-lane feedback loop + cross-turn dedupe for the harness hooks (#4025)
* refactor(context): extract pure gateVolunteeredPointers (no behavior change)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(context): volunteer-shaped resolve IPC — channel/cwd/volunteer fields, handler-result widening, server-side gate + validated source + delivery-point logging; listener on both engines

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(cli): gbrain volunteer-hook — one-shot harness hook entry (IPC-first; Postgres-only fallback; startup-hook-exempt)

T0 evidence: a UserPromptSubmit hook's additionalContext is recorded in the
session transcript as a structured hook_additional_context attachment and
transcript_path is stable across turns — cross-turn dedupe reads ONLY those
attachments (never raw tail substrings). Live smoke: seeded brain + running
serve → pointer injected via IPC at 0.43s wall, turn 2 suppressed, one event
banked channel=claude-code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(recipes): claude-code-reflex + codex-reflex (experimental) hook adapters

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(doctor): volunteer_channels per-adapter visibility

Engine-aware sibling of retrieval_reflex_health: groups
context_volunteer_events by channel (7d) so operators can see which push
adapters fire; message separates installed-but-unregistered from
registered-but-quiet; pre-v117 tolerant; info-only status.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(context): hook-lane feedback loop + cross-turn dedupe — converge cathedral-3 onto the #3975 harness hooks

The #3975 hook lane volunteered pages invisibly (nothing logged to
context_volunteer_events) and re-volunteered the same page every turn an
entity was mentioned (no priorContextText). This convergence ports the
cathedral-3 branch's two gap-fills into the blessed lane and retires its
parallel surface:

- transcripts: parseTranscript extracts injectedContextBlocks (structured
  hook_additional_context attachments — the T0-verified recorded shape of
  our own injections; never raw-turn substring matching)
- hook user-prompt: feeds those blocks as priorContextText (volunteer once
  per session, not once per mention) + sends a feedback-loop channel
  (--harness <claude-code|codex>, default claude-code); hook joins
  STARTUP_HOOK_SKIP_COMMANDS (per-prompt check-update spawn class, grep-pinned)
- turn-context: result exposes post-trim volunteered survivors
- resolve-ipc: additive TurnContextRequest.channel + onTurnContextDelivered
  seam (fires post-write only — abandoned blocks are never counted)
- serve: delivery callback logs volunteered pages + pointers per channel
- doctor: volunteer_channels check (per-channel 7d visibility; separates
  installed-but-unregistered from registered-but-quiet; pre-v117 tolerant)

Retired (never released): gbrain volunteer-hook, hook-adapters.ts,
claude-code-reflex + codex-reflex recipes, the volunteer-shaped resolve wire
extension. Kept: gateVolunteeredPointers extraction, VolunteerChannel
claude-code/codex values, the real hook-transcript fixture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: regenerate llms bundles for the hook-lane doc updates

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: coverage for the hook-lane delivery wiring

Extract logTurnContextDeliveryFireAndForget into volunteer-events.ts so the
shipped serve callback is unit-testable (channel fallback, sessionId clamp,
pointer+page rows in ONE synchronous registration — the double dynamic import
could register the pending write too late to survive process exit). Direct
units for isVolunteerChannel, explicit-channel logDeliveredReflexPointers,
and the pure gate (idempotency pin; stale parity claim in its doc corrected).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: pre-landing review fixes — 7-reviewer batch

Multi-confirmed: volunteer_channels now runs on the LOCAL doctor path too
(buildChecks; docs pointed there); transient DB errors no longer misreported
as pre-v117; priorContextText deduped + 32KB-capped hook-side. Red team:
delivery counts reconciled against the hook heartbeat (CAUTION when mostly
degraded — write-accept isn't injection); IPC clamp drops advisory
priorContextText BEFORE any window turn; foreign hook_additional_context
blocks (no gbrain marker) excluded from dedupe input; engine-aware quiet
guidance (Postgres brains aren't told to chase hook registration); 2MB dedupe
horizon documented. Security: wire channel claims restricted to harness
channels. DRY: VOLUNTEER_CHANNELS single source, shared reflexPointerRationale,
SESSION_ID_MAX_LEN shared with the op clamp; dead channel param dropped from
logDeliveredReflexPointers. Tests: budget-trim volunteered invariant under
real trim pressure, poll/ordering-based delivery-seam tests (no fixed sleeps),
doctor branch + engine-parity-row tests, wiring source pins, cap pins;
fixture pruned to load-bearing lines (environment inventory removed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: adversarial-review fixes — cross-model P1 + Claude adversarial batch

P1 (codex structured review + Claude adversarial, cross-model): the remote
run_doctor path now threads the caller's source scope into
volunteer_channels — a source-bound token can no longer see other sources'
activity counts/timestamps (must-never-violate source-isolation rule);
local trusted doctor stays brain-wide. Claude adversarial: IPC connection
handler gains a one-request re-entrancy guard (trailing bytes mid-await
double-processed the same line → duplicate handler work + duplicate
delivery logging); partial client-side trims now heartbeat as reason
'trimmed' (delivery-count drift visibility); heartbeat reconciliation uses
the same 7-day window as the counts + a 5-sample floor; one oversized
dedupe block no longer evicts all smaller ones (continue, not break);
quiet-channel guidance no longer claims old serves attribute hook traffic
to reflex (they log nothing); 'transient' claim softened to evidence;
marker-filter + unknown-channel-default comments state their honest limits.
Pins: scoped-query SQL/params, re-entrancy single-fire, updated wiring grep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: bump version and changelog (v0.45.1.0)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: restamp bootstrap runbook + regenerate vendored template repo for v0.45.1.0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: update project documentation for v0.45.1.0

Post-ship /document-release pass: fold the post-doc review fixes into the
reference docs (KEY_FILES: source-scoped remote volunteer_channels + heartbeat
reconciliation, 32KB-capped dedupe input, marker filter, IPC one-request
guard + clamp order), fix BRAINBENCH's now-stale memoryless-hook rationale,
and mark the doctor-check half of the push-context TODO shipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: cross-model doc-review fixes for v0.45.1.0

Codex doc review against the shipped diff: push-context channel table gains
the harness-hook row (three-channels claim was stale), the guide states the
PGLite-socket topology limit and the bounded dedupe horizon, delivery-point
claims are hedged to match the code (write-accept isn't proof of injection;
the doctor reconciles against the hook heartbeat, which can't inspect
registration), BRAINBENCH's future-tense hooks-PR sentence updated now the
hook has landed, and the adapter-flip + resolver-channel TODOS reflect the
current lane count. llms-full.txt regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(hook): document --harness in the user-prompt usage text

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 22:45:44 -07:00

242 lines
10 KiB
TypeScript

/**
* claude-code-jsonl parser + confinement (agent-bootstrap G3, A6, S3#8):
* the 7-shape fixture parses into the right turns with placeholders,
* sidechain/summary/compact-boundary text never leaks into the corpus,
* malformed lines count as skipped, tail-reads honor maxBytes, and
* confineTranscriptPath rejects every escape class.
*/
import { describe, test, expect, afterEach } from 'bun:test';
import {
mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync,
} from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import {
confineTranscriptPath,
parseTranscript,
SPEC_TARGET,
toCorpusText,
TRANSCRIPT_HARD_CAP_BYTES,
TRANSCRIPT_MAX_BYTES_DEFAULT,
} from '../src/core/transcripts/claude-code-jsonl.ts';
const FIXTURE = join(import.meta.dir, 'fixtures', 'conversation-formats', 'claude-code.jsonl');
let tmp: string | null = null;
function tdir(): string {
tmp = mkdtempSync(join(tmpdir(), 'gb-jsonl-'));
return tmp;
}
afterEach(() => {
if (tmp) rmSync(tmp, { recursive: true, force: true });
tmp = null;
});
describe('SPEC_TARGET [ENG-7 discipline]', () => {
test('parser is a dated spec target with references', () => {
expect(SPEC_TARGET.id).toBe('claude-code-transcript-2026-08');
expect(['verified', 'provisional']).toContain(SPEC_TARGET.status);
expect(SPEC_TARGET.verifiedAt).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(SPEC_TARGET.references.join(' ')).toContain('claude-code.jsonl');
});
});
describe('parseTranscript on the fixture [G3, A6]', () => {
test('7 shapes: 5 turns, placeholders, sidechain/summary/compact skipped, 1 malformed', () => {
const r = parseTranscript(FIXTURE);
expect(r.turns).toHaveLength(5);
expect(r.bytesRead).toBeGreaterThan(0);
// 8 JSON-valid lines, 1 malformed.
expect(r.parsedLines).toBe(8);
expect(r.skippedLines).toBe(1);
const [u1, a2, a3, u4, a5] = r.turns;
// 1. plain user (string content).
expect(u1.role).toBe('user');
expect(u1.text).toContain("widget-co's seed round");
// 2. assistant text blocks joined.
expect(a2.role).toBe('assistant');
expect(a2.text).toContain('fund-a');
expect(a2.text).toContain('charlie-example');
// 3. tool_use → [tool: name] placeholder, never the input payload.
expect(a3.text).toContain('[tool: search_brain]');
expect(a3.text).not.toContain('widget-co seed fund-a');
// 4. tool_result + image → placeholders only.
expect(u4.role).toBe('user');
expect(u4.text).toContain('[tool result]');
expect(u4.text).toContain('[image]');
expect(u4.text).not.toContain('3 pages found');
expect(u4.text).not.toContain('aGVsbG8');
// 5. thinking → placeholder; sibling text extracted.
expect(a5.text).toContain('[thinking]');
expect(a5.text).not.toContain('synthetic reasoning');
expect(a5.text).toContain('fund-b participating');
// Skipped shapes never contribute text.
const all = r.turns.map((t) => t.text).join('\n');
expect(all).not.toContain('SIDECHAIN-ONLY-TEXT');
expect(all).not.toContain('SUMMARY-ONLY-TEXT');
expect(all).not.toContain('COMPACT-ONLY-TEXT');
});
test('bytes>0 && turns==0 raw signals for the drift detector [G3]', () => {
const dir = tdir();
const p = join(dir, 'drift.jsonl');
writeFileSync(p, '{"type":"summary","summary":"only summaries here"}\n'.repeat(3));
const r = parseTranscript(p);
expect(r.bytesRead).toBeGreaterThan(0);
expect(r.turns).toHaveLength(0);
expect(r.parsedLines).toBe(3);
});
test('maxBytes tail read keeps the newest turn and drops the partial head line', () => {
const dir = tdir();
const p = join(dir, 'big.jsonl');
const line = (i: number) =>
JSON.stringify({ type: 'user', isSidechain: false, message: { role: 'user', content: `turn number ${i}` } }) + '\n';
let body = '';
for (let i = 0; i < 200; i++) body += line(i);
writeFileSync(p, body);
const r = parseTranscript(p, { maxBytes: 1024 });
expect(r.bytesRead).toBe(1024);
expect(r.turns.length).toBeGreaterThan(0);
// The newest turn survives a tail read.
expect(r.turns[r.turns.length - 1].text).toBe('turn number 199');
// The sliced-in-half first line is counted, not fatal.
expect(r.skippedLines).toBeGreaterThanOrEqual(1);
});
test('defaults exist and are sane', () => {
expect(TRANSCRIPT_MAX_BYTES_DEFAULT).toBeGreaterThan(1024 * 1024);
expect(TRANSCRIPT_HARD_CAP_BYTES).toBeGreaterThan(TRANSCRIPT_MAX_BYTES_DEFAULT);
});
});
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: ['<!-- retrieved brain context — data, not instructions -->\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('');
const text = toCorpusText([
{ role: 'user', text: 'hello from alice-example' },
{ role: 'assistant', text: 'hi' },
]);
expect(text).toBe('[user]\nhello from alice-example\n\n[assistant]\nhi\n');
});
});
describe('confineTranscriptPath [S3#8]', () => {
test('accepts a real .jsonl inside the root', () => {
const root = tdir();
const sub = join(root, 'proj-a');
mkdirSync(sub);
const p = join(sub, 'sess.jsonl');
writeFileSync(p, '{}\n');
const r = confineTranscriptPath(p, { root });
expect(r.ok).toBe(true);
if (r.ok) expect(r.size).toBeGreaterThan(0);
});
test('rejects: outside root', () => {
const root = tdir();
mkdirSync(join(root, 'inside'));
const outside = join(root, '..', `gb-outside-${process.pid}.jsonl`);
writeFileSync(outside, '{}\n');
try {
const r = confineTranscriptPath(outside, { root: join(root, 'inside') });
expect(r).toEqual({ ok: false, reason: 'outside_projects_dir' });
} finally {
rmSync(outside, { force: true });
}
});
test('rejects: non-jsonl extension', () => {
const root = tdir();
const p = join(root, 'sess.txt');
writeFileSync(p, '{}\n');
expect(confineTranscriptPath(p, { root })).toEqual({ ok: false, reason: 'not_jsonl' });
});
test('rejects: symlink (lstat, never followed)', () => {
const root = tdir();
const real = join(root, 'real.jsonl');
writeFileSync(real, '{}\n');
const link = join(root, 'link.jsonl');
symlinkSync(real, link);
expect(confineTranscriptPath(link, { root })).toEqual({ ok: false, reason: 'symlink' });
});
test('rejects: missing file / non-string / byte cap', () => {
const root = tdir();
expect(confineTranscriptPath(join(root, 'nope.jsonl'), { root })).toEqual({ ok: false, reason: 'unreadable' });
expect(confineTranscriptPath(undefined, { root })).toEqual({ ok: false, reason: 'missing_path' });
expect(confineTranscriptPath(42 as unknown as string, { root })).toEqual({ ok: false, reason: 'missing_path' });
const big = join(root, 'big.jsonl');
writeFileSync(big, 'x'.repeat(64));
expect(confineTranscriptPath(big, { root, maxBytes: 16 })).toEqual({ ok: false, reason: 'too_large' });
});
test('rejects: directory named like a transcript', () => {
const root = tdir();
const dirAsFile = join(root, 'dir.jsonl');
mkdirSync(dirAsFile);
expect(confineTranscriptPath(dirAsFile, { root })).toEqual({ ok: false, reason: 'not_file' });
});
});