From ad44a7a8c5e7a70038715624094ed5123a5b2e83 Mon Sep 17 00:00:00 2001 From: "Cat8zfu14fb-Opus-4.8" Date: Tue, 21 Jul 2026 14:58:20 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(F257):=20#2=20L=E7=B3=BB=E5=88=97?= =?UTF-8?q?=E6=AE=B5=E8=A7=82=E6=B5=8B=E7=B2=92=E5=BA=A6=20=E2=80=94=20nat?= =?UTF-8?q?ive-L0=20per-segment=20L1-L7=20trace=20via=20bridge=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Make L1-L7 (governance-core session identity) observable per-segment for native-L0 cats (Claude/Codex/OpenCode), so the Console segment lifeline is no longer blank for them. ## Why (root cause — verified by reading, not the plan's guess) The slice-2 plan located the fix at trace-collector.ts:116-129 (the `session-init-pack-only` aggregate branch). Investigation of the actual call graph proved that branch is DEAD CODE for native-L0: - native-L0 builds session identity via buildStaticIdentityPackOnly() which does NOT run the hook pipeline → drainCapturedTraces().session is null. - route → buildFromPipeline(null, turnResult, …): trace-bridge.ts:50 returns non-null (turnResult/D-hooks present) with an EMPTY session-segment array → the collectTrace fallback (incl. pack-only branch) is never reached. - Net: native-L0 traces carry only D-series per-turn segments; L1-L7 never become ObservedSegments → segment-lifeline queries return nothing. ## Fix (bridge path, single-source) collectNativeL0SessionTrace() runs the session-init pipeline in trace-only mode (prompt discarded — the native --system-prompt-file is authoritative) and returns the L-scoped PipelineResult. The routes feed it as buildFromPipeline's sessionResult for native-L0 only (non-native path unchanged). eventsToSegments already maps L1-L7 → ObservedSegment (id/version/fired/hash/tokens); zero bridge or downstream-consumer changes. Respects the override snapshot already refreshed by the routes (refreshOverrideSnapshot precedes the trace block). ## Tradeoff (disclosed for review) Adds one session-init pipeline run per native-L0 turn, but only inside the fire-and-forget trace block (after prompt assembly; never on the model-call critical path) and only when a trace store is present. L-series is deterministic per cat, so a per-cat cache (invalidate on OverrideChangeEvent) is the follow-up optimization (KD-15 "L0 编译链 + cache invalidation" runway). Fail-open: returns null on error → degrades to prior D-only behavior, never breaks invocation. ## Non-goal Pack-block observation (buildStaticIdentityPackOnly output has no hookIds) stays out of scope; #2 is L-series. Delivery channel left 'pack-only' (existing contract, trace-bridge.test.js:155) — lifeline/judgment read segments, not channel. ## Evidence - red→green: pre-fix native-L0 = 0 observable L segments; post-fix = 7 (L1-L7). - new test f257-lseries-trace.test.js 4/4 (unit + bridge map + §16e store→lifeline reachability + registry metadata). - tsc clean; zero new biome warnings (count parity vs develop_base baseline). - affected suites green: trace-bridge/injection-trace-store/segment-lifeline/ l0-pipeline-equivalence/hook-pipeline/segment-judgment-engine(+harness-eval)/ segment-lifeline-chain/dual-path/transport-boundary/hook-pipeline-integration/ hook-registry/system-prompt-builder. - agent-router 89/11 and prompt-segments-eval-domain 11/4 failures proven pre-existing via git-stash baseline parity (identical names before/after). Co-Authored-By: Claude Opus 4.8 --- .../services/agents/routing/route-parallel.ts | 15 +- .../services/agents/routing/route-serial.ts | 15 +- .../prompt-hooks/PipelinePromptBuilder.ts | 37 +++ packages/api/test/f257-lseries-trace.test.js | 229 ++++++++++++++++++ 4 files changed, 292 insertions(+), 4 deletions(-) create mode 100644 packages/api/test/f257-lseries-trace.test.js diff --git a/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts b/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts index 7e176a15c4..9504ded16c 100644 --- a/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts +++ b/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts @@ -29,7 +29,11 @@ import { prepareGuideContext, } from '../../../../guides/GuideRoutingInterceptor.js'; import { triggerRecallCorrelation } from '../../../../memory/recall-correlation-hook.js'; -import { drainCapturedTraces, refreshOverrideSnapshot } from '../../../../prompt-hooks/PipelinePromptBuilder.js'; +import { + collectNativeL0SessionTrace, + drainCapturedTraces, + refreshOverrideSnapshot, +} from '../../../../prompt-hooks/PipelinePromptBuilder.js'; import { getTraceStore } from '../../../../prompt-hooks/trace-bootstrap.js'; // F257: Pipeline trace bridge — richer per-hook traces, replaces redundant v0 re-collection import { buildFromPipeline } from '../../../../prompt-hooks/trace-bridge.js'; @@ -397,8 +401,15 @@ export async function* routeParallel( const traceStore = getTraceStore(); if (traceStore && !preTraceSignal?.aborted) { const traceTurnId = crypto.randomUUID(); + // F257 #2: native-L0 cats build session identity pack-only (no session pipeline), + // so pipelineSessionTrace.session is null → L1-L7 never observed → segment lifeline + // blank for every native-L0 cat. Recompute the L-series session trace (trace-only, + // prompt discarded) so per-segment L1-L7 land in the bridge. + const bridgeSessionTrace = hasNativeL0 + ? collectNativeL0SessionTrace(catId, { packBlocks }) + : pipelineSessionTrace.session; // F257: try pipeline bridge first — richer per-hook data - const bridgeResult = buildFromPipeline(pipelineSessionTrace.session, pipelineTurnTrace.turn, { + const bridgeResult = buildFromPipeline(bridgeSessionTrace, pipelineTurnTrace.turn, { turnId: traceTurnId, threadId, catId: catId as string, diff --git a/packages/api/src/domains/cats/services/agents/routing/route-serial.ts b/packages/api/src/domains/cats/services/agents/routing/route-serial.ts index 68fe50ac2e..170bbbbff5 100644 --- a/packages/api/src/domains/cats/services/agents/routing/route-serial.ts +++ b/packages/api/src/domains/cats/services/agents/routing/route-serial.ts @@ -79,7 +79,11 @@ import { prepareGuideContext, } from '../../../../guides/GuideRoutingInterceptor.js'; import { triggerRecallCorrelation } from '../../../../memory/recall-correlation-hook.js'; -import { drainCapturedTraces, refreshOverrideSnapshot } from '../../../../prompt-hooks/PipelinePromptBuilder.js'; +import { + collectNativeL0SessionTrace, + drainCapturedTraces, + refreshOverrideSnapshot, +} from '../../../../prompt-hooks/PipelinePromptBuilder.js'; import { getTraceStore } from '../../../../prompt-hooks/trace-bootstrap.js'; // F257: Pipeline trace bridge — richer per-hook traces, replaces redundant v0 re-collection import { buildFromPipeline } from '../../../../prompt-hooks/trace-bridge.js'; @@ -918,8 +922,15 @@ export async function* routeSerial( const traceStore = getTraceStore(); if (traceStore) { const traceTurnId = crypto.randomUUID(); + // F257 #2: native-L0 cats build session identity pack-only (no session pipeline), + // so pipelineSessionTrace.session is null → L1-L7 never observed → segment lifeline + // blank for every native-L0 cat. Recompute the L-series session trace (trace-only, + // prompt discarded) so per-segment L1-L7 land in the bridge. + const bridgeSessionTrace = hasNativeL0 + ? collectNativeL0SessionTrace(catId, { packBlocks }) + : pipelineSessionTrace.session; // F257: try pipeline bridge first — richer per-hook data - const bridgeResult = buildFromPipeline(pipelineSessionTrace.session, pipelineTurnTrace.turn, { + const bridgeResult = buildFromPipeline(bridgeSessionTrace, pipelineTurnTrace.turn, { turnId: traceTurnId, threadId, catId: catId as string, diff --git a/packages/api/src/domains/prompt-hooks/PipelinePromptBuilder.ts b/packages/api/src/domains/prompt-hooks/PipelinePromptBuilder.ts index 9851e4bd0b..696daf7527 100644 --- a/packages/api/src/domains/prompt-hooks/PipelinePromptBuilder.ts +++ b/packages/api/src/domains/prompt-hooks/PipelinePromptBuilder.ts @@ -46,6 +46,7 @@ import { RESOLVER_MAP } from './resolvers/index.js'; const SCOPE_S = /^S\d/; // S1-S13: buildStaticIdentity const SCOPE_D = /^D\d/; // D1-D21: buildInvocationContext +const SCOPE_L = /^L\d/; // L1-L7: native-L0 session identity (compiled by subprocess for native providers) // --------------------------------------------------------------------------- // Singleton pipeline (lazy init on first call) @@ -193,6 +194,42 @@ export function buildStaticIdentityViaHookPipelineWithTrace( return { prompt, trace }; } +/** + * F257 #2: L-series (L1-L7) session trace for native-L0 cats. + * + * Native-L0 cats deliver their non-pack identity (L1-L7 governance core) via the + * compiled native system prompt (`--system-prompt-file`, built by a subprocess), so at + * invocation time the route calls `buildStaticIdentityPackOnly()` and the session hook + * pipeline never runs — leaving `drainCapturedTraces().session` null. The trace bridge + * then persists an empty session-segment array, so L1-L7 are unobservable in the + * segment lifeline for every native-L0 cat (Claude/Codex/OpenCode). + * + * This runs the session-init pipeline in trace-only mode (the assembled prompt is + * discarded — the native prompt is authoritative) and returns the L-scoped + * `PipelineResult` so the invocation layer can feed it to `buildFromPipeline` as the + * session result. `eventsToSegments` (trace-bridge) then emits per-segment L1-L7 + * `ObservedSegment`s (id/version/fired/hash/tokens). + * + * Single source of truth: reuses the same pipeline + resolvers + override snapshot as + * the delivered prompt (callers must `refreshOverrideSnapshot()` first, as + * route-serial/parallel already do before building). Does NOT touch the module-global + * capture buffer — it is the WithTrace variant, so it is safe to call after the route + * has already drained its session/turn traces. + * + * Returns null on any error — trace collection must never break invocation. + */ +export function collectNativeL0SessionTrace(catId: CatId, options?: StaticIdentityOptions): PipelineResult | null { + try { + const { trace } = buildStaticIdentityViaHookPipelineWithTrace(catId, options); + return { + patches: trace.patches.filter((p) => SCOPE_L.test(p.hookId)), + events: trace.events.filter((ev) => SCOPE_L.test(ev.hookId)), + }; + } catch { + return null; + } +} + /** * Build per-turn prompt via HookPipeline. * Equivalent to legacy `buildInvocationContext()`. diff --git a/packages/api/test/f257-lseries-trace.test.js b/packages/api/test/f257-lseries-trace.test.js new file mode 100644 index 0000000000..cc4db6db8e --- /dev/null +++ b/packages/api/test/f257-lseries-trace.test.js @@ -0,0 +1,229 @@ +/** + * F257 #2 — native-L0 L-series (L1-L7) segment observability. + * + * Root cause (verified by reading route-parallel.ts:251-401 + trace-bridge.ts:50-52): + * native-L0 cats build session identity via buildStaticIdentityPackOnly() which does + * NOT run the hook pipeline, so drainCapturedTraces().session is null. buildFromPipeline + * then returns non-null via the per-turn (D) result with an EMPTY session-segment array — + * the collectTrace 'session-init-pack-only' fallback (trace-collector.ts:116-129) is never + * reached. Result: L1-L7 never appear as ObservedSegments → segment-lifeline is blank for + * every native-L0 cat (Claude/Codex/OpenCode). + * + * Fix: collectNativeL0SessionTrace() runs the session-init pipeline in trace-only mode + * (prompt discarded) and returns the L-scoped PipelineResult, fed as buildFromPipeline's + * sessionResult for native-L0. eventsToSegments already maps L1-L7 → ObservedSegments. + * + * This test proves L1-L7 become per-segment observed AND reachable by the segment-lifeline + * read-model (§16e full-chain reachability, not just structural shape). + */ + +import assert from 'node:assert/strict'; +import { join } from 'node:path'; +import { after, before, describe, test } from 'node:test'; + +// ── FakeRedis (ZSET + SADD/SMEMBERS) — mirrors segment-lifeline.test.js ── +class FakeRedis { + constructor() { + this.kv = new Map(); + this.sorted = new Map(); + this.sets = new Map(); + this.ttls = new Map(); + } + async set(key, value, ...args) { + this.kv.set(key, value); + if (args[0] === 'EX' && typeof args[1] === 'number') this.ttls.set(key, args[1]); + return 'OK'; + } + async get(key) { + return this.kv.get(key) ?? null; + } + async del(key) { + this.kv.delete(key); + return 1; + } + async zadd(key, score, member) { + const set = this.sorted.get(key) ?? new Map(); + set.set(member, score); + this.sorted.set(key, set); + return 1; + } + async zcard(key) { + return this.sorted.get(key)?.size ?? 0; + } + async zrevrange(key, start, stop) { + const set = this.sorted.get(key); + if (!set) return []; + const entries = [...set.entries()].sort((a, b) => b[1] - a[1]); + return entries.slice(start, stop + 1).map(([m]) => m); + } + async zrangebyscore(key, min, max) { + const set = this.sorted.get(key); + if (!set) return []; + return [...set.entries()] + .filter(([, score]) => score >= min && score <= max) + .sort((a, b) => a[1] - b[1]) + .map(([m]) => m); + } + async zrem(key, member) { + const set = this.sorted.get(key); + if (!set) return 0; + return set.delete(member) ? 1 : 0; + } + async sadd(key, ...members) { + const s = this.sets.get(key) ?? new Set(); + let added = 0; + for (const m of members) { + if (!s.has(m)) { + s.add(m); + added++; + } + } + this.sets.set(key, s); + return added; + } + async smembers(key) { + const s = this.sets.get(key); + return s ? [...s] : []; + } + async scan(_cursor, ...args) { + const matchIdx = args.indexOf('MATCH'); + const pattern = matchIdx >= 0 ? args[matchIdx + 1] : '*'; + const escaped = pattern.replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&'); + const regex = new RegExp(`^${escaped.replace(/\*/g, '.*')}$`); + const allKeys = new Set([...this.kv.keys(), ...this.sorted.keys()]); + return ['0', [...allKeys].filter((k) => regex.test(k))]; + } +} + +const L_IDS = ['L1', 'L2', 'L3', 'L4', 'L5', 'L6', 'L7']; + +describe('F257 #2: native-L0 L-series observability', () => { + let PipelineBuilder; + let TraceBridge; + let InjectionTraceStoreMod; + let HookRegistryMod; + let monorepoRoot; + let catReg; + + before(async () => { + const shared = await import('@cat-cafe/shared'); + catReg = shared.catRegistry; + catReg.reset(); + // native-L0 cat (clientId anthropic) — config drives assembleForSession lookup. + catReg.register('opus', { + displayName: '布偶猫', + nickname: '宪宪', + name: 'Ragdoll', + roleDescription: '主架构师和核心开发者', + personality: '温柔但有主见,喜欢深入分析问题', + defaultModel: 'claude-opus-4-6', + mentionPatterns: ['@opus', '@布偶猫'], + restrictions: [], + clientId: 'anthropic', + breedId: 'ragdoll', + }); + + PipelineBuilder = await import('../dist/domains/prompt-hooks/PipelinePromptBuilder.js'); + TraceBridge = await import('../dist/domains/prompt-hooks/trace-bridge.js'); + InjectionTraceStoreMod = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + HookRegistryMod = await import('../dist/domains/prompt-hooks/HookRegistry.js'); + monorepoRoot = await import('../dist/utils/monorepo-root.js'); + // Fresh scan — avoid stale override snapshot from prior tests sharing the singleton. + PipelineBuilder.resetPipelineSingleton(); + }); + + after(() => { + catReg?.reset(); + PipelineBuilder?.resetPipelineSingleton(); + }); + + test('collectNativeL0SessionTrace returns all L1-L7 fired, no S/D leakage', () => { + const result = PipelineBuilder.collectNativeL0SessionTrace('opus', { mcpAvailable: true }); + assert.ok(result, 'should return a PipelineResult (not null)'); + + const eventIds = result.events.map((e) => e.hookId).sort(); + assert.deepStrictEqual(eventIds, L_IDS, 'events scoped to L1-L7 only'); + assert.ok( + result.events.every((e) => e.status === 'fired'), + 'all L hooks fire (disableable:false, always-on)', + ); + + const patchIds = result.patches.map((p) => p.hookId).sort(); + assert.deepStrictEqual(patchIds, L_IDS, 'patches scoped to L1-L7 only'); + assert.ok( + result.patches.every((p) => p.content.length > 0), + 'each L patch carries rendered template content', + ); + + // No S/D leakage — the whole point of the L-scope filter. + assert.ok(!result.events.some((e) => /^[SD]\d/.test(e.hookId)), 'no S/D events leak into L-series trace'); + }); + + test('buildFromPipeline maps L-series to observed segments (bridge path, native-L0)', () => { + const lResult = PipelineBuilder.collectNativeL0SessionTrace('opus', { mcpAvailable: true }); + // turnResult=null models the reachability crux: pre-fix the bridge returned non-null + // with EMPTY session segments; post-fix the L-series session result carries L1-L7. + const bridge = TraceBridge.buildFromPipeline(lResult, null, { + turnId: 'turn-1', + threadId: 'thread-A', + catId: 'opus', + hasNativeL0: true, + }); + assert.ok(bridge, 'bridge non-null'); + + const lSegs = bridge.summary.segments.filter((s) => /^L\d/.test(s.segmentId)); + assert.equal(lSegs.length, 7, 'summary carries all 7 L segments'); + for (const s of lSegs) { + assert.equal(s.status, 'observed', `${s.segmentId} observed`); + assert.equal(s.pipelineStatus, 'fired', `${s.segmentId} pipelineStatus fired`); + assert.equal(s.stage, 'session-init', `${s.segmentId} session-init stage`); + assert.ok(s.contentHash, `${s.segmentId} contentHash set`); + assert.ok(s.charCount > 0, `${s.segmentId} charCount > 0`); + assert.equal(typeof s.version, 'number', `${s.segmentId} version set`); + } + assert.equal(bridge.summary.totalSegmentsObserved, 7, 'totalSegmentsObserved counts L1-L7'); + }); + + test('§16e reachability: persisted L4 is found by segment-lifeline collectObservations predicate', async () => { + const lResult = PipelineBuilder.collectNativeL0SessionTrace('opus', { mcpAvailable: true }); + const bridge = TraceBridge.buildFromPipeline(lResult, null, { + turnId: 'turn-1', + threadId: 'thread-A', + catId: 'opus', + hasNativeL0: true, + }); + + const redis = new FakeRedis(); + const store = new InjectionTraceStoreMod.InjectionTraceStore(redis); + await store.persist(bridge.summary, bridge.detail); + + // Replicate segment-lifeline.collectObservations reachability path EXACTLY + // (segment-lifeline.ts:150-158): listTracedThreadIds → queryWindow → predicate. + const threadIds = await store.listTracedThreadIds(); + assert.ok(threadIds.includes('thread-A'), 'thread discoverable via registry'); + + const summaries = await store.queryWindow('thread-A', 0, Date.now() + 1000); + const found = []; + for (const summary of summaries) { + const seg = summary.segments.find((s) => s.segmentId === 'L4' && s.status === 'observed'); + if (seg) found.push(seg); + } + assert.equal(found.length, 1, 'L4 reachable by the exact lifeline predicate (was blank pre-fix)'); + assert.equal(found[0].pipelineStatus, 'fired'); + assert.ok(found[0].charCount > 0, 'L4 charCount surfaced'); + assert.equal(found[0].version, 1, 'L4 version surfaced for lifeline chain'); + }); + + test('L4 name/version resolvable via HookRegistry (lifeline chain metadata)', () => { + const root = monorepoRoot.findMonorepoRoot(); + const registry = new HookRegistryMod.HookRegistry( + join(root, 'assets', 'prompt-hooks'), + join(root, 'assets', 'prompt-templates'), + ); + registry.scan(); + const hook = registry.getHook('L4'); + assert.ok(hook, 'L4 hook resolves in registry'); + assert.equal(hook.manifest.id, 'L4'); + assert.ok(hook.manifest.name, 'L4 has a display name for the lifeline header'); + }); +}); From 6c7010e6d3e9d915c57eb817b4c9fb922669d597 Mon Sep 17 00:00:00 2001 From: "Cat8zfu14fb-Opus-4.8" Date: Tue, 21 Jul 2026 15:56:06 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(F257):=20#2=20(2b)=20R1=20=E2=80=94=20L?= =?UTF-8?q?-series=20trace=20from=20the=20actual=20L0=20compiler=20manifes?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the rejected parallel producer per sol 2b R1 (all four findings). ## P1-2 (root) — source the trace from the ACTUAL compiled artifact, not a reconstruction Deleted collectNativeL0SessionTrace (reran the API hook pipeline — a separate, override-respecting code path that could report L content the override-blind native compiler never delivered). Instead: - scripts/compile-system-prompt-l0.mjs: compileL0WithManifest() captures each L1-L7 section at the one loop where per-segment content still exists (before flattening + label-stripping) + new --manifest-out CLI flag (orthogonal to --out; stdout still carries the prompt for Codex/OpenCode). - l0-compiler.ts: l0ManifestCache populated in LOCKSTEP with l0Cache inside doCompileL0 (same subprocess, same generation guard, cleared together); getL0ManifestViaSubprocess() is cache-first and, on a cold cache, shares the provider's own compile via in-flight dedup — no redundant full-stage run. Manifest read is best-effort (never fails the fail-closed string compile). - l0-manifest-trace.ts: adapts the manifest → session PipelineResult (hash/token via the existing conventions; version from the hook registry) → bridged through buildFromPipeline. Verified end-to-end: real mjs emits all 7 L segments with byte-accurate content (L4 = "1. **Runtime data safety**…"). ## P1-1 — native-l0 delivery channel (not pack-only) trace-bridge buildDelivery emits channel 'native-l0' when the session came from the compiler manifest (new meta.sessionFromNativeCompiler); 'pack-only' stays correct for actual pack blocks (no global relabel — trace-bridge.test.js:155 still green). Test asserts it. ## P2-1 — not a pre-invoke tax + visible failure native-l0-trace.ts persistNativeL0SessionTrace() is called fire-and-forget (`void …`) from both routes, so it never blocks the model critical path; the redundant full-stage run is gone (cache-first manifest). An empty manifest emits a visible producer warning instead of silently persisting D-only — tested (warn fired, zero fabricated L segments). ## P2-2 — one producer seam (de-duplicated), unit-tested Wiring centralized in persistNativeL0SessionTrace (was duplicated in two large route functions); both routes call the single shared helper. Seam unit-tested (success + failure). NOTE: a full routeSerial/routeParallel driver integration test is not yet added — flagged. ## Evidence - f257-lseries-trace 6/0; f257-l0-manifest 5/0; l0-compiler 17/0 (updated for --manifest-out). - Regression green: trace-bridge / segment-lifeline / injection-trace-store / hook-pipeline / l0-pipeline-equivalence / system-prompt-builder / segment-judgment-engine / transport-boundary-l0-equivalence / codex+claude-bg+opencode L0. - Real mjs manifest smoke: 7 segments byte-accurate. tsc clean; biome changed files clean. Co-Authored-By: Claude Opus 4.8 --- .../services/agents/providers/l0-compiler.ts | 87 ++++- .../services/agents/routing/route-parallel.ts | 77 +++-- .../services/agents/routing/route-serial.ts | 77 +++-- .../prompt-hooks/PipelinePromptBuilder.ts | 37 -- .../domains/prompt-hooks/l0-manifest-trace.ts | 53 +++ .../domains/prompt-hooks/native-l0-trace.ts | 68 ++++ .../src/domains/prompt-hooks/trace-bridge.ts | 26 +- packages/api/test/f257-l0-manifest.test.js | 120 +++++++ packages/api/test/f257-lseries-trace.test.js | 325 +++++++++--------- packages/api/test/l0-compiler.test.js | 13 +- scripts/compile-system-prompt-l0.mjs | 49 ++- 11 files changed, 648 insertions(+), 284 deletions(-) create mode 100644 packages/api/src/domains/prompt-hooks/l0-manifest-trace.ts create mode 100644 packages/api/src/domains/prompt-hooks/native-l0-trace.ts create mode 100644 packages/api/test/f257-l0-manifest.test.js diff --git a/packages/api/src/domains/cats/services/agents/providers/l0-compiler.ts b/packages/api/src/domains/cats/services/agents/providers/l0-compiler.ts index 551e3831fd..8c273d13d7 100644 --- a/packages/api/src/domains/cats/services/agents/providers/l0-compiler.ts +++ b/packages/api/src/domains/cats/services/agents/providers/l0-compiler.ts @@ -21,13 +21,25 @@ */ import { spawn as nodeSpawn } from 'node:child_process'; -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveProfileDir } from '../../profile/profile-dir.js'; const SCRIPT_BASENAME = 'compile-system-prompt-l0.mjs'; +/** + * F257 #2 — one L-series (L1-L7) segment as rendered by the actual L0 compiler. + * The raw per-segment content is the authoritative artifact the provider delivers; + * hash/token/version formatting is derived downstream (see l0-manifest-trace.ts). + */ +export interface L0SegmentContent { + segmentId: string; + content: string; +} + // ── L0 cache ──────────────────────────────────────────────────────── // The compiled L0 depends on static inputs (shared-rules.md, cat config, // teammate roster) that don't change during a session. Caching avoids @@ -35,6 +47,12 @@ const SCRIPT_BASENAME = 'compile-system-prompt-l0.mjs'; // startup via warmL0Cache() and invalidated on hot-reload via clearL0Cache(). const l0Cache = new Map(); +// F257 #2 — per-segment L1-L7 manifest, populated in LOCKSTEP with l0Cache inside +// doCompileL0() (same subprocess, same generation guard, cleared together). This +// lets the injection trace be sourced from the SAME compiled artifact the provider +// delivers, instead of an out-of-band reconstruction that can diverge. +const l0ManifestCache = new Map(); + // In-flight Promise dedup — Phase G AC-G10 (砚砚 Design Gate position 1). // Without this, two concurrent calls on a cold cache (e.g. invoke provider // + Prompt X-Ray capture inside the same invocation hot path) both spawn @@ -70,6 +88,7 @@ function isL0GenerationCurrent(catId: string, generation: { global: number; cat: export function clearL0Cache(catId?: string): void { if (catId) { l0Cache.delete(catId); + l0ManifestCache.delete(catId); bumpL0Generation(catId); // Also drop any in-flight promise — next call will re-spawn fresh. The // generation guard prevents the older promise from repopulating l0Cache @@ -77,6 +96,7 @@ export function clearL0Cache(catId?: string): void { l0InflightPromises.delete(catId); } else { l0Cache.clear(); + l0ManifestCache.clear(); bumpL0Generation(); l0InflightPromises.clear(); } @@ -228,7 +248,20 @@ async function doCompileL0( // write (routes) MUST resolve identically or the nurturing loop silently breaks (a primer // written to one path while the injector reads another). const profileDir = resolveProfileDir(cwd, scriptPath); - const args = [scriptPath, '--cat', catId, '--profile-dir', profileDir, ...(outPath ? ['--out', outPath] : [])]; + // F257 #2: always request the per-segment L1-L7 manifest to a temp file so the + // injection trace can be sourced from this exact compiled artifact. Orthogonal to + // --out; the string compile stays authoritative + fail-closed. + const manifestPath = join(tmpdir(), `cat-cafe-l0-manifest-${catId}-${randomUUID()}.json`); + const args = [ + scriptPath, + '--cat', + catId, + '--profile-dir', + profileDir, + '--manifest-out', + manifestPath, + ...(outPath ? ['--out', outPath] : []), + ]; const stdout = await new Promise((resolvePromise, rejectPromise) => { const child = spawnFn(process.execPath, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }); @@ -271,8 +304,56 @@ async function doCompileL0( } } + // F257 #2: read the per-segment manifest (best-effort — a manifest failure must + // NEVER fail the critical fail-closed string compile above). An empty manifest is a + // visible "L not observed" signal downstream, not a silent healthy-zero. + const manifest = readL0Manifest(manifestPath); + if (isL0GenerationCurrent(catId, compileGeneration)) { l0Cache.set(catId, result); + l0ManifestCache.set(catId, manifest); } return result; } + +/** Parse + clean up the temp L-segment manifest written by the compiler. */ +function readL0Manifest(manifestPath: string): L0SegmentContent[] { + try { + if (!existsSync(manifestPath)) return []; + const parsed: unknown = JSON.parse(readFileSync(manifestPath, 'utf8')); + if (!Array.isArray(parsed)) return []; + return parsed + .filter( + (e): e is { id: string; content: string } => + !!e && + typeof e === 'object' && + typeof (e as { id?: unknown }).id === 'string' && + typeof (e as { content?: unknown }).content === 'string', + ) + .map((e) => ({ segmentId: e.id, content: e.content })); + } catch { + return []; + } finally { + try { + if (existsSync(manifestPath)) unlinkSync(manifestPath); + } catch { + /* temp cleanup best-effort */ + } + } +} + +/** + * F257 #2 — get the per-segment L1-L7 manifest for a cat, sourced from the SAME + * compiled artifact the provider delivers. Cache-first; on a cold cache it triggers + * the shared subprocess compile (which populates both string + manifest caches under + * the generation guard), so the provider's later compile is a cache hit — no + * redundant work. Returns [] when the compile produced no manifest (visible signal). + */ +export async function getL0ManifestViaSubprocess(options: CompileL0Options): Promise { + const cached = l0ManifestCache.get(options.catId); + if (cached) return cached; + // Manifest cold ⇒ string cold too (they are set together in doCompileL0), so this + // actually compiles rather than hitting compileL0ViaSubprocess's string cache-first. + await compileL0ViaSubprocess(options); + return l0ManifestCache.get(options.catId) ?? []; +} diff --git a/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts b/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts index 9504ded16c..9bfc5aaba8 100644 --- a/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts +++ b/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts @@ -29,11 +29,8 @@ import { prepareGuideContext, } from '../../../../guides/GuideRoutingInterceptor.js'; import { triggerRecallCorrelation } from '../../../../memory/recall-correlation-hook.js'; -import { - collectNativeL0SessionTrace, - drainCapturedTraces, - refreshOverrideSnapshot, -} from '../../../../prompt-hooks/PipelinePromptBuilder.js'; +import { persistNativeL0SessionTrace } from '../../../../prompt-hooks/native-l0-trace.js'; +import { drainCapturedTraces, refreshOverrideSnapshot } from '../../../../prompt-hooks/PipelinePromptBuilder.js'; import { getTraceStore } from '../../../../prompt-hooks/trace-bootstrap.js'; // F257: Pipeline trace bridge — richer per-hook traces, replaces redundant v0 re-collection import { buildFromPipeline } from '../../../../prompt-hooks/trace-bridge.js'; @@ -401,40 +398,48 @@ export async function* routeParallel( const traceStore = getTraceStore(); if (traceStore && !preTraceSignal?.aborted) { const traceTurnId = crypto.randomUUID(); - // F257 #2: native-L0 cats build session identity pack-only (no session pipeline), - // so pipelineSessionTrace.session is null → L1-L7 never observed → segment lifeline - // blank for every native-L0 cat. Recompute the L-series session trace (trace-only, - // prompt discarded) so per-segment L1-L7 land in the bridge. - const bridgeSessionTrace = hasNativeL0 - ? collectNativeL0SessionTrace(catId, { packBlocks }) - : pipelineSessionTrace.session; - // F257: try pipeline bridge first — richer per-hook data - const bridgeResult = buildFromPipeline(bridgeSessionTrace, pipelineTurnTrace.turn, { - turnId: traceTurnId, - threadId, - catId: catId as string, - hasNativeL0, - }); - if (bridgeResult) { - traceStore.persist(bridgeResult.summary, bridgeResult.detail).catch((err) => { - log.warn({ err, threadId, catId }, '[F257] pipeline trace persist failed (fire-and-forget)'); + if (hasNativeL0) { + // F257 #2: native-L0 identity (L1-L7) is delivered by the native L0 compiler, + // not the session pipeline. Source the trace from that ACTUAL compiled artifact + // (cache-first, shares the provider's compile) — fire-and-forget so it never + // taxes the model critical path; visible warning if the manifest is empty. + void persistNativeL0SessionTrace({ + traceStore, + catId: catId as string, + threadId, + turnId: traceTurnId, + turnResult: pipelineTurnTrace.turn, + log, }); } else { - // v0 fallback: re-collect traces via annotateSegments (legacy path) - const traceModePrompt = modeSystemPromptByCat?.[catId as string] ?? modeSystemPrompt ?? ''; - const traceTurnContent = [invocationContext, traceModePrompt, bootstrapCtx, mcpInstructions] - .filter(Boolean) - .join('\n\n---\n\n'); - const collected = collectTrace(catId as string, staticIdentity, traceTurnContent, hasNativeL0, { - mcpAvailable, - packBlocks, - }); - const traceMeta = { turnId: traceTurnId, threadId, catId: catId as string }; - const summary = buildTraceSummary(collected, traceMeta); - const detail = buildTraceDetail(collected, traceMeta); - traceStore.persist(summary, detail).catch((err) => { - log.warn({ err, threadId, catId }, '[F237] injection trace persist failed (fire-and-forget)'); + // Non-native: session identity IS pipeline-delivered (S-series captured trace). + const bridgeResult = buildFromPipeline(pipelineSessionTrace.session, pipelineTurnTrace.turn, { + turnId: traceTurnId, + threadId, + catId: catId as string, + hasNativeL0, }); + if (bridgeResult) { + traceStore.persist(bridgeResult.summary, bridgeResult.detail).catch((err) => { + log.warn({ err, threadId, catId }, '[F257] pipeline trace persist failed (fire-and-forget)'); + }); + } else { + // v0 fallback: re-collect traces via annotateSegments (legacy/unknown-cat path) + const traceModePrompt = modeSystemPromptByCat?.[catId as string] ?? modeSystemPrompt ?? ''; + const traceTurnContent = [invocationContext, traceModePrompt, bootstrapCtx, mcpInstructions] + .filter(Boolean) + .join('\n\n---\n\n'); + const collected = collectTrace(catId as string, staticIdentity, traceTurnContent, hasNativeL0, { + mcpAvailable, + packBlocks, + }); + const traceMeta = { turnId: traceTurnId, threadId, catId: catId as string }; + const summary = buildTraceSummary(collected, traceMeta); + const detail = buildTraceDetail(collected, traceMeta); + traceStore.persist(summary, detail).catch((err) => { + log.warn({ err, threadId, catId }, '[F237] injection trace persist failed (fire-and-forget)'); + }); + } } } // v0 collectTrace → buildStaticIdentity(annotateSegments: true) re-populates diff --git a/packages/api/src/domains/cats/services/agents/routing/route-serial.ts b/packages/api/src/domains/cats/services/agents/routing/route-serial.ts index 170bbbbff5..3430aa9910 100644 --- a/packages/api/src/domains/cats/services/agents/routing/route-serial.ts +++ b/packages/api/src/domains/cats/services/agents/routing/route-serial.ts @@ -79,11 +79,8 @@ import { prepareGuideContext, } from '../../../../guides/GuideRoutingInterceptor.js'; import { triggerRecallCorrelation } from '../../../../memory/recall-correlation-hook.js'; -import { - collectNativeL0SessionTrace, - drainCapturedTraces, - refreshOverrideSnapshot, -} from '../../../../prompt-hooks/PipelinePromptBuilder.js'; +import { persistNativeL0SessionTrace } from '../../../../prompt-hooks/native-l0-trace.js'; +import { drainCapturedTraces, refreshOverrideSnapshot } from '../../../../prompt-hooks/PipelinePromptBuilder.js'; import { getTraceStore } from '../../../../prompt-hooks/trace-bootstrap.js'; // F257: Pipeline trace bridge — richer per-hook traces, replaces redundant v0 re-collection import { buildFromPipeline } from '../../../../prompt-hooks/trace-bridge.js'; @@ -922,40 +919,48 @@ export async function* routeSerial( const traceStore = getTraceStore(); if (traceStore) { const traceTurnId = crypto.randomUUID(); - // F257 #2: native-L0 cats build session identity pack-only (no session pipeline), - // so pipelineSessionTrace.session is null → L1-L7 never observed → segment lifeline - // blank for every native-L0 cat. Recompute the L-series session trace (trace-only, - // prompt discarded) so per-segment L1-L7 land in the bridge. - const bridgeSessionTrace = hasNativeL0 - ? collectNativeL0SessionTrace(catId, { packBlocks }) - : pipelineSessionTrace.session; - // F257: try pipeline bridge first — richer per-hook data - const bridgeResult = buildFromPipeline(bridgeSessionTrace, pipelineTurnTrace.turn, { - turnId: traceTurnId, - threadId, - catId: catId as string, - hasNativeL0, - }); - if (bridgeResult) { - traceStore.persist(bridgeResult.summary, bridgeResult.detail).catch((err) => { - log.warn({ err, threadId, catId }, '[F257] pipeline trace persist failed (fire-and-forget)'); + if (hasNativeL0) { + // F257 #2: native-L0 identity (L1-L7) is delivered by the native L0 compiler, + // not the session pipeline. Source the trace from that ACTUAL compiled artifact + // (cache-first, shares the provider's compile) — fire-and-forget so it never + // taxes the model critical path; visible warning if the manifest is empty. + void persistNativeL0SessionTrace({ + traceStore, + catId: catId as string, + threadId, + turnId: traceTurnId, + turnResult: pipelineTurnTrace.turn, + log, }); } else { - // v0 fallback: re-collect traces via annotateSegments (legacy path) - const traceModePrompt = modeSystemPromptByCat?.[catId as string] ?? modeSystemPrompt ?? ''; - const traceTurnContent = [invocationContext, traceModePrompt, bootstrapContext, mcpInstructions] - .filter(Boolean) - .join('\n\n---\n\n'); - const traceV0 = collectTrace(catId as string, staticIdentity, traceTurnContent, hasNativeL0, { - mcpAvailable, - packBlocks, - }); - const traceMeta = { turnId: traceTurnId, threadId, catId: catId as string }; - const summary = buildTraceSummary(traceV0, traceMeta); - const detail = buildTraceDetail(traceV0, traceMeta); - traceStore.persist(summary, detail).catch((err) => { - log.warn({ err, threadId, catId }, '[F237] injection trace persist failed (fire-and-forget)'); + // Non-native: session identity IS pipeline-delivered (S-series captured trace). + const bridgeResult = buildFromPipeline(pipelineSessionTrace.session, pipelineTurnTrace.turn, { + turnId: traceTurnId, + threadId, + catId: catId as string, + hasNativeL0, }); + if (bridgeResult) { + traceStore.persist(bridgeResult.summary, bridgeResult.detail).catch((err) => { + log.warn({ err, threadId, catId }, '[F257] pipeline trace persist failed (fire-and-forget)'); + }); + } else { + // v0 fallback: re-collect traces via annotateSegments (legacy/unknown-cat path) + const traceModePrompt = modeSystemPromptByCat?.[catId as string] ?? modeSystemPrompt ?? ''; + const traceTurnContent = [invocationContext, traceModePrompt, bootstrapContext, mcpInstructions] + .filter(Boolean) + .join('\n\n---\n\n'); + const traceV0 = collectTrace(catId as string, staticIdentity, traceTurnContent, hasNativeL0, { + mcpAvailable, + packBlocks, + }); + const traceMeta = { turnId: traceTurnId, threadId, catId: catId as string }; + const summary = buildTraceSummary(traceV0, traceMeta); + const detail = buildTraceDetail(traceV0, traceMeta); + traceStore.persist(summary, detail).catch((err) => { + log.warn({ err, threadId, catId }, '[F237] injection trace persist failed (fire-and-forget)'); + }); + } } } // v0 collectTrace → buildStaticIdentity(annotateSegments: true) re-populates diff --git a/packages/api/src/domains/prompt-hooks/PipelinePromptBuilder.ts b/packages/api/src/domains/prompt-hooks/PipelinePromptBuilder.ts index 696daf7527..9851e4bd0b 100644 --- a/packages/api/src/domains/prompt-hooks/PipelinePromptBuilder.ts +++ b/packages/api/src/domains/prompt-hooks/PipelinePromptBuilder.ts @@ -46,7 +46,6 @@ import { RESOLVER_MAP } from './resolvers/index.js'; const SCOPE_S = /^S\d/; // S1-S13: buildStaticIdentity const SCOPE_D = /^D\d/; // D1-D21: buildInvocationContext -const SCOPE_L = /^L\d/; // L1-L7: native-L0 session identity (compiled by subprocess for native providers) // --------------------------------------------------------------------------- // Singleton pipeline (lazy init on first call) @@ -194,42 +193,6 @@ export function buildStaticIdentityViaHookPipelineWithTrace( return { prompt, trace }; } -/** - * F257 #2: L-series (L1-L7) session trace for native-L0 cats. - * - * Native-L0 cats deliver their non-pack identity (L1-L7 governance core) via the - * compiled native system prompt (`--system-prompt-file`, built by a subprocess), so at - * invocation time the route calls `buildStaticIdentityPackOnly()` and the session hook - * pipeline never runs — leaving `drainCapturedTraces().session` null. The trace bridge - * then persists an empty session-segment array, so L1-L7 are unobservable in the - * segment lifeline for every native-L0 cat (Claude/Codex/OpenCode). - * - * This runs the session-init pipeline in trace-only mode (the assembled prompt is - * discarded — the native prompt is authoritative) and returns the L-scoped - * `PipelineResult` so the invocation layer can feed it to `buildFromPipeline` as the - * session result. `eventsToSegments` (trace-bridge) then emits per-segment L1-L7 - * `ObservedSegment`s (id/version/fired/hash/tokens). - * - * Single source of truth: reuses the same pipeline + resolvers + override snapshot as - * the delivered prompt (callers must `refreshOverrideSnapshot()` first, as - * route-serial/parallel already do before building). Does NOT touch the module-global - * capture buffer — it is the WithTrace variant, so it is safe to call after the route - * has already drained its session/turn traces. - * - * Returns null on any error — trace collection must never break invocation. - */ -export function collectNativeL0SessionTrace(catId: CatId, options?: StaticIdentityOptions): PipelineResult | null { - try { - const { trace } = buildStaticIdentityViaHookPipelineWithTrace(catId, options); - return { - patches: trace.patches.filter((p) => SCOPE_L.test(p.hookId)), - events: trace.events.filter((ev) => SCOPE_L.test(ev.hookId)), - }; - } catch { - return null; - } -} - /** * Build per-turn prompt via HookPipeline. * Equivalent to legacy `buildInvocationContext()`. diff --git a/packages/api/src/domains/prompt-hooks/l0-manifest-trace.ts b/packages/api/src/domains/prompt-hooks/l0-manifest-trace.ts new file mode 100644 index 0000000000..28cadf1ac4 --- /dev/null +++ b/packages/api/src/domains/prompt-hooks/l0-manifest-trace.ts @@ -0,0 +1,53 @@ +/** + * F257 #2 — L0 manifest → session trace adapter. + * + * Converts the per-segment L1-L7 manifest emitted by the ACTUAL L0 compiler + * (`getL0ManifestViaSubprocess`) into a session `PipelineResult`, so the existing + * trace bridge (`buildFromPipeline` → `eventsToSegments`) persists it as per-segment + * `ObservedSegment`s — no second persistence format. + * + * Why this and not the (rejected) `collectNativeL0SessionTrace`: that reran the API + * hook pipeline (a separate code path) and could report OVERRIDDEN L content the + * override-blind native compiler never delivered. This adapter's input IS the compiled + * artifact, so hash/char/token describe exactly what the provider received. Version is + * the only field not in the artifact; it resolves from the hook registry (same source + * the segment lifeline uses), defaulting to 1 for the always-on L hooks. + */ + +import type { TraceEvent, TraceEventFired } from '@cat-cafe/shared'; +import { estimateTokens } from '../../utils/token-counter.js'; +import type { L0SegmentContent } from '../cats/services/agents/providers/l0-compiler.js'; +import type { PipelineResult } from './HookPipeline.js'; +import { getCachedRegistry } from './PipelinePromptBuilder.js'; +import { hashContent } from './trace-collector.js'; + +/** + * Build a session-stage `PipelineResult` from the real L0 compiler manifest. + * Returns null for an empty manifest so callers can emit a visible "L not observed" + * signal instead of persisting a silent healthy-zero. + */ +export function l0ManifestToSessionResult(manifest: readonly L0SegmentContent[]): PipelineResult | null { + if (manifest.length === 0) return null; + const registry = getCachedRegistry(); + const timestamp = Date.now(); + + const patches = manifest.map((seg, i) => ({ + hookId: seg.segmentId, + content: seg.content, + order: (i + 1) * 100, + })); + + const events: TraceEvent[] = manifest.map( + (seg): TraceEventFired => ({ + hookId: seg.segmentId, + stage: 'session-init', + timestamp, + status: 'fired', + version: registry?.getHook(seg.segmentId)?.manifest.version ?? 1, + contentHash: hashContent(seg.content), + tokenEstimate: estimateTokens(seg.content), + }), + ); + + return { patches, events }; +} diff --git a/packages/api/src/domains/prompt-hooks/native-l0-trace.ts b/packages/api/src/domains/prompt-hooks/native-l0-trace.ts new file mode 100644 index 0000000000..710f709989 --- /dev/null +++ b/packages/api/src/domains/prompt-hooks/native-l0-trace.ts @@ -0,0 +1,68 @@ +/** + * F257 #2 — native-L0 session trace persistence (shared by route-serial + route-parallel). + * + * Persists the L1-L7 session trace from the ACTUAL L0 compiler manifest + * (`getL0ManifestViaSubprocess`), bridged through the existing `buildFromPipeline`. + * + * Fully fire-and-forget: call WITHOUT awaiting so it never taxes the model critical + * path (sol 2b R1 P2-1). The manifest is cache-first; a cold cache shares the provider's + * own compile via the l0-compiler in-flight dedup — no redundant full-stage run. An empty + * manifest emits a visible producer warning rather than silently persisting D-only, so + * "L 系列无数据" is distinguishable from a healthy zero. + * + * Centralizing here (vs. inlining in two large route functions) is also sol 2b R1 P2-2: + * one producer seam, unit-testable without driving a whole route. + */ + +import type { InjectionTraceDetail, InjectionTraceSummary } from '@cat-cafe/shared'; +import { getL0ManifestViaSubprocess } from '../cats/services/agents/providers/l0-compiler.js'; +import type { PipelineResult } from './HookPipeline.js'; +import { l0ManifestToSessionResult } from './l0-manifest-trace.js'; +import { buildFromPipeline } from './trace-bridge.js'; + +interface TraceSink { + persist(summary: InjectionTraceSummary, detail: InjectionTraceDetail): Promise; +} + +interface TraceLogger { + warn(obj: Record, msg: string): void; +} + +export interface PersistNativeL0Params { + traceStore: TraceSink; + catId: string; + threadId: string; + turnId: string; + /** The already-drained per-turn (D-series) pipeline trace for this invocation. */ + turnResult: PipelineResult | null; + log: TraceLogger; +} + +export async function persistNativeL0SessionTrace(params: PersistNativeL0Params): Promise { + const { traceStore, catId, threadId, turnId, turnResult, log } = params; + try { + const manifest = await getL0ManifestViaSubprocess({ catId }); + const sessionResult = l0ManifestToSessionResult(manifest); + if (!sessionResult) { + log.warn( + { catId, threadId }, + '[F257] native L0 manifest empty — L1-L7 not observed this turn (compile did not run / produced no manifest)', + ); + } + const bridge = buildFromPipeline(sessionResult, turnResult, { + turnId, + threadId, + catId, + hasNativeL0: true, + sessionFromNativeCompiler: sessionResult !== null, + }); + if (bridge) { + await traceStore.persist(bridge.summary, bridge.detail); + } + } catch (err) { + log.warn( + { err: err instanceof Error ? err.message : String(err), catId, threadId }, + '[F257] native L0 session trace failed (fire-and-forget)', + ); + } +} diff --git a/packages/api/src/domains/prompt-hooks/trace-bridge.ts b/packages/api/src/domains/prompt-hooks/trace-bridge.ts index fdaadee610..5e270bcc8a 100644 --- a/packages/api/src/domains/prompt-hooks/trace-bridge.ts +++ b/packages/api/src/domains/prompt-hooks/trace-bridge.ts @@ -34,6 +34,12 @@ export interface TraceBridgeMeta { threadId: string; catId: string; hasNativeL0: boolean; + /** + * F257 #2: the session result is the native L0 compiler's L1-L7 manifest (delivered + * via `--system-prompt-file` / native carrier), so the session-stage delivery channel + * is `native-l0`, not `pack-only` (which stays correct for actual pack blocks). + */ + sessionFromNativeCompiler?: boolean; } /** @@ -61,7 +67,7 @@ export function buildFromPipeline( const sessionChars = sumChars(sessionResult); const turnChars = sumChars(turnResult); - const delivery = buildDelivery(sessionResult, turnResult, meta.hasNativeL0); + const delivery = buildDelivery(sessionResult, turnResult, meta.hasNativeL0, meta.sessionFromNativeCompiler ?? false); const timestamp = Date.now(); const summary: InjectionTraceSummary = { @@ -193,16 +199,26 @@ function buildDelivery( sessionResult: PipelineResult | null, turnResult: PipelineResult | null, hasNativeL0: boolean, + sessionFromNativeCompiler: boolean, ): StageDeliveryDecision[] { - const sessionChannel: DeliveryChannel = hasNativeL0 ? 'pack-only' : 'message-prepend'; + // F257 #2: L1-L7 sourced from the native compiler manifest → 'native-l0'. Only the + // pack-blocks path (no compiler manifest) stays 'pack-only'. + const sessionChannel: DeliveryChannel = sessionFromNativeCompiler + ? 'native-l0' + : hasNativeL0 + ? 'pack-only' + : 'message-prepend'; + const sessionReason = sessionFromNativeCompiler + ? 'Pipeline bridge: L1-L7 delivered via native L0 compiler artifact' + : hasNativeL0 + ? 'Pipeline bridge: pack-only for native L0' + : 'Pipeline bridge: content assembled for message-prepend'; return [ { stage: 'session-init' as InjectionStage, contentAssembled: sessionResult !== null && sessionResult.patches.length > 0, channel: sessionChannel, - reason: hasNativeL0 - ? 'Pipeline bridge: pack-only for native L0' - : 'Pipeline bridge: content assembled for message-prepend', + reason: sessionReason, }, { stage: 'per-turn' as InjectionStage, diff --git a/packages/api/test/f257-l0-manifest.test.js b/packages/api/test/f257-l0-manifest.test.js new file mode 100644 index 0000000000..a09a996fd7 --- /dev/null +++ b/packages/api/test/f257-l0-manifest.test.js @@ -0,0 +1,120 @@ +/** + * F257 #2 — L0 compiler manifest boundary (foundation). + * + * Proves getL0ManifestViaSubprocess() sources the per-segment L1-L7 manifest from the + * SAME subprocess compile that produces the delivered prompt string, riding the SAME + * cache/generation lifecycle (lockstep with l0Cache), and fails open (empty manifest, + * never a throw that would break the fail-closed string compile). Uses a fake spawn + * that writes the --manifest-out file exactly as the real compiler CLI does. + */ + +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { + clearL0Cache, + compileL0ViaSubprocess, + getL0ManifestViaSubprocess, +} from '../dist/domains/cats/services/agents/providers/l0-compiler.js'; + +function makeRoot() { + const root = mkdtempSync(join(tmpdir(), 'l0-manifest-')); + mkdirSync(join(root, 'scripts'), { recursive: true }); + writeFileSync(join(root, 'scripts', 'compile-system-prompt-l0.mjs'), '// fake'); + return root; +} + +/** + * Fake spawn that mimics the real CLI: writes the compiled string to --out (or + * emits it on stdout), and writes the JSON manifest to --manifest-out. + */ +function buildManifestSpawn({ compiled = 'COMPILED-L0', manifest = [], exitCode = 0 }) { + const fn = function fakeSpawn(cmd, args, opts) { + fn.calls.push({ cmd, args, opts }); + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + setImmediate(() => { + const outIdx = args.indexOf('--out'); + if (outIdx >= 0 && args[outIdx + 1]) writeFileSync(args[outIdx + 1], compiled, 'utf8'); + const mIdx = args.indexOf('--manifest-out'); + if (mIdx >= 0 && args[mIdx + 1] && manifest !== null) { + writeFileSync(args[mIdx + 1], JSON.stringify(manifest), 'utf8'); + } + if (outIdx < 0) child.stdout.emit('data', Buffer.from(compiled)); + child.emit('close', exitCode); + }); + return child; + }; + fn.calls = []; + return fn; +} + +const L_MANIFEST = [ + { id: 'L1', content: '你不是一个孤立的工具' }, + { id: 'L2', content: '客观性 carry-over' }, + { id: 'L3', content: '传球三选一' }, + { id: 'L4', content: '五条铁律' }, + { id: 'L5', content: 'MCP 工具 index' }, + { id: 'L6', content: '能力唤醒' }, + { id: 'L7', content: '协作哲学' }, +]; + +test('getL0ManifestViaSubprocess passes --manifest-out and returns parsed L1-L7', async () => { + clearL0Cache(); + const root = makeRoot(); + const spawnFn = buildManifestSpawn({ compiled: 'PROMPT', manifest: L_MANIFEST }); + const manifest = await getL0ManifestViaSubprocess({ catId: 'opus-47', cwd: root, spawnFn }); + + assert.deepEqual( + manifest.map((s) => s.segmentId), + ['L1', 'L2', 'L3', 'L4', 'L5', 'L6', 'L7'], + ); + assert.equal(manifest[3].content, '五条铁律'); + const call = spawnFn.calls[0]; + assert.ok(call.args.includes('--manifest-out'), 'compiler invoked with --manifest-out'); +}); + +test('manifest rides l0Cache lockstep — string compile is a cache hit afterward', async () => { + clearL0Cache(); + const root = makeRoot(); + const spawnFn = buildManifestSpawn({ compiled: 'PROMPT-BODY', manifest: L_MANIFEST }); + await getL0ManifestViaSubprocess({ catId: 'opus-47', cwd: root, spawnFn }); + // String compile for the same cat must NOT re-spawn (both caches set together). + const str = await compileL0ViaSubprocess({ catId: 'opus-47', cwd: root, spawnFn }); + assert.equal(str, 'PROMPT-BODY'); + assert.equal(spawnFn.calls.length, 1, 'only one subprocess for both string + manifest'); +}); + +test('second manifest read is cache-first (no re-spawn)', async () => { + clearL0Cache(); + const root = makeRoot(); + const spawnFn = buildManifestSpawn({ manifest: L_MANIFEST }); + await getL0ManifestViaSubprocess({ catId: 'codex', cwd: root, spawnFn }); + await getL0ManifestViaSubprocess({ catId: 'codex', cwd: root, spawnFn }); + assert.equal(spawnFn.calls.length, 1, 'manifest cache-first — no second spawn'); +}); + +test('clearL0Cache drops the manifest (next read re-spawns)', async () => { + clearL0Cache(); + const root = makeRoot(); + const spawnFn = buildManifestSpawn({ manifest: L_MANIFEST }); + await getL0ManifestViaSubprocess({ catId: 'opus-47', cwd: root, spawnFn }); + clearL0Cache('opus-47'); + await getL0ManifestViaSubprocess({ catId: 'opus-47', cwd: root, spawnFn }); + assert.equal(spawnFn.calls.length, 2, 'manifest cleared with string cache — re-spawned'); +}); + +test('fail-open: missing/garbage manifest → [] but string compile still succeeds', async () => { + clearL0Cache(); + const root = makeRoot(); + // manifest:null → fake does not write the manifest file at all + const spawnFn = buildManifestSpawn({ compiled: 'STILL-COMPILES', manifest: null }); + const str = await compileL0ViaSubprocess({ catId: 'opus-47', cwd: root, spawnFn }); + assert.equal(str, 'STILL-COMPILES', 'critical string compile unaffected by missing manifest'); + const manifest = await getL0ManifestViaSubprocess({ catId: 'opus-47', cwd: root, spawnFn }); + assert.deepEqual(manifest, [], 'no manifest → empty (visible signal), not a throw'); +}); diff --git a/packages/api/test/f257-lseries-trace.test.js b/packages/api/test/f257-lseries-trace.test.js index cc4db6db8e..ba7d0dc490 100644 --- a/packages/api/test/f257-lseries-trace.test.js +++ b/packages/api/test/f257-lseries-trace.test.js @@ -1,23 +1,19 @@ /** - * F257 #2 — native-L0 L-series (L1-L7) segment observability. + * F257 #2 — native-L0 L-series (L1-L7) observability via the ACTUAL L0 compiler manifest. * - * Root cause (verified by reading route-parallel.ts:251-401 + trace-bridge.ts:50-52): - * native-L0 cats build session identity via buildStaticIdentityPackOnly() which does - * NOT run the hook pipeline, so drainCapturedTraces().session is null. buildFromPipeline - * then returns non-null via the per-turn (D) result with an EMPTY session-segment array — - * the collectTrace 'session-init-pack-only' fallback (trace-collector.ts:116-129) is never - * reached. Result: L1-L7 never appear as ObservedSegments → segment-lifeline is blank for - * every native-L0 cat (Claude/Codex/OpenCode). - * - * Fix: collectNativeL0SessionTrace() runs the session-init pipeline in trace-only mode - * (prompt discarded) and returns the L-scoped PipelineResult, fed as buildFromPipeline's - * sessionResult for native-L0. eventsToSegments already maps L1-L7 → ObservedSegments. - * - * This test proves L1-L7 become per-segment observed AND reachable by the segment-lifeline - * read-model (§16e full-chain reachability, not just structural shape). + * Reworked per sol 2b R1: the trace is sourced from the compiled artifact + * (`getL0ManifestViaSubprocess`), not an out-of-band pipeline reconstruction. Covers: + * - adapter: manifest → session PipelineResult (fired L1-L7; empty → null); + * - bridge: ObservedSegments + delivery channel `native-l0` (P1-1); + * - §16e reachability: persisted L4 found by the segment-lifeline predicate; + * - producer seam (persistNativeL0SessionTrace): success persists L1-L7 with native-l0 + * channel; empty manifest → visible warning + NO false L data (P2-1 failure path). */ import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { after, before, describe, test } from 'node:test'; @@ -27,11 +23,9 @@ class FakeRedis { this.kv = new Map(); this.sorted = new Map(); this.sets = new Map(); - this.ttls = new Map(); } - async set(key, value, ...args) { + async set(key, value) { this.kv.set(key, value); - if (args[0] === 'EX' && typeof args[1] === 'number') this.ttls.set(key, args[1]); return 'OK'; } async get(key) { @@ -42,188 +36,209 @@ class FakeRedis { return 1; } async zadd(key, score, member) { - const set = this.sorted.get(key) ?? new Map(); - set.set(member, score); - this.sorted.set(key, set); + const s = this.sorted.get(key) ?? new Map(); + s.set(member, score); + this.sorted.set(key, s); return 1; } - async zcard(key) { - return this.sorted.get(key)?.size ?? 0; - } - async zrevrange(key, start, stop) { - const set = this.sorted.get(key); - if (!set) return []; - const entries = [...set.entries()].sort((a, b) => b[1] - a[1]); - return entries.slice(start, stop + 1).map(([m]) => m); - } async zrangebyscore(key, min, max) { - const set = this.sorted.get(key); - if (!set) return []; - return [...set.entries()] - .filter(([, score]) => score >= min && score <= max) + const s = this.sorted.get(key); + if (!s) return []; + return [...s.entries()] + .filter(([, sc]) => sc >= min && sc <= max) .sort((a, b) => a[1] - b[1]) .map(([m]) => m); } + async zrevrange(key, start, stop) { + const s = this.sorted.get(key); + if (!s) return []; + return [...s.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(start, stop + 1) + .map(([m]) => m); + } async zrem(key, member) { - const set = this.sorted.get(key); - if (!set) return 0; - return set.delete(member) ? 1 : 0; + return this.sorted.get(key)?.delete(member) ? 1 : 0; } async sadd(key, ...members) { const s = this.sets.get(key) ?? new Set(); - let added = 0; - for (const m of members) { - if (!s.has(m)) { - s.add(m); - added++; - } - } + for (const m of members) s.add(m); this.sets.set(key, s); - return added; + return members.length; } async smembers(key) { - const s = this.sets.get(key); - return s ? [...s] : []; + return [...(this.sets.get(key) ?? [])]; } - async scan(_cursor, ...args) { - const matchIdx = args.indexOf('MATCH'); - const pattern = matchIdx >= 0 ? args[matchIdx + 1] : '*'; - const escaped = pattern.replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&'); - const regex = new RegExp(`^${escaped.replace(/\*/g, '.*')}$`); - const allKeys = new Set([...this.kv.keys(), ...this.sorted.keys()]); - return ['0', [...allKeys].filter((k) => regex.test(k))]; + async scan(_c, ...args) { + const i = args.indexOf('MATCH'); + const pat = i >= 0 ? args[i + 1] : '*'; + const rx = new RegExp(`^${pat.replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&').replace(/\*/g, '.*')}$`); + return ['0', [...new Set([...this.kv.keys(), ...this.sorted.keys()])].filter((k) => rx.test(k))]; } } -const L_IDS = ['L1', 'L2', 'L3', 'L4', 'L5', 'L6', 'L7']; - -describe('F257 #2: native-L0 L-series observability', () => { - let PipelineBuilder; - let TraceBridge; - let InjectionTraceStoreMod; - let HookRegistryMod; - let monorepoRoot; - let catReg; +function makeRoot() { + const root = mkdtempSync(join(tmpdir(), 'l0-lseries-')); + mkdirSync(join(root, 'scripts'), { recursive: true }); + writeFileSync(join(root, 'scripts', 'compile-system-prompt-l0.mjs'), '// fake'); + return root; +} - before(async () => { - const shared = await import('@cat-cafe/shared'); - catReg = shared.catRegistry; - catReg.reset(); - // native-L0 cat (clientId anthropic) — config drives assembleForSession lookup. - catReg.register('opus', { - displayName: '布偶猫', - nickname: '宪宪', - name: 'Ragdoll', - roleDescription: '主架构师和核心开发者', - personality: '温柔但有主见,喜欢深入分析问题', - defaultModel: 'claude-opus-4-6', - mentionPatterns: ['@opus', '@布偶猫'], - restrictions: [], - clientId: 'anthropic', - breedId: 'ragdoll', +/** Fake spawn that writes the compiler manifest to --manifest-out (like the real CLI). */ +function buildManifestSpawn({ compiled = 'PROMPT', manifest = [] }) { + const fn = function fakeSpawn(_cmd, args) { + fn.calls.push(args); + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + setImmediate(() => { + const oi = args.indexOf('--out'); + if (oi >= 0 && args[oi + 1]) writeFileSync(args[oi + 1], compiled, 'utf8'); + const mi = args.indexOf('--manifest-out'); + if (mi >= 0 && args[mi + 1]) writeFileSync(args[mi + 1], JSON.stringify(manifest), 'utf8'); + if (oi < 0) child.stdout.emit('data', Buffer.from(compiled)); + child.emit('close', 0); }); + return child; + }; + fn.calls = []; + return fn; +} - PipelineBuilder = await import('../dist/domains/prompt-hooks/PipelinePromptBuilder.js'); - TraceBridge = await import('../dist/domains/prompt-hooks/trace-bridge.js'); - InjectionTraceStoreMod = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); - HookRegistryMod = await import('../dist/domains/prompt-hooks/HookRegistry.js'); - monorepoRoot = await import('../dist/utils/monorepo-root.js'); - // Fresh scan — avoid stale override snapshot from prior tests sharing the singleton. - PipelineBuilder.resetPipelineSingleton(); - }); +const RAW = [ + { id: 'L1', content: '你不是一个孤立的工具' }, + { id: 'L2', content: '客观性 carry-over' }, + { id: 'L3', content: '传球三选一' }, + { id: 'L4', content: '五条铁律:Runtime data safety…' }, + { id: 'L5', content: 'MCP 工具 index' }, + { id: 'L6', content: '能力唤醒' }, + { id: 'L7', content: '协作哲学' }, +]; +const L_IDS = ['L1', 'L2', 'L3', 'L4', 'L5', 'L6', 'L7']; +const manifestContent = RAW.map((e) => ({ segmentId: e.id, content: e.content })); + +describe('F257 #2: native-L0 L-series via compiler manifest', () => { + let adapter; + let bridge; + let StoreMod; + let l0c; + let native; - after(() => { - catReg?.reset(); - PipelineBuilder?.resetPipelineSingleton(); + before(async () => { + adapter = await import('../dist/domains/prompt-hooks/l0-manifest-trace.js'); + bridge = await import('../dist/domains/prompt-hooks/trace-bridge.js'); + StoreMod = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + l0c = await import('../dist/domains/cats/services/agents/providers/l0-compiler.js'); + native = await import('../dist/domains/prompt-hooks/native-l0-trace.js'); }); - test('collectNativeL0SessionTrace returns all L1-L7 fired, no S/D leakage', () => { - const result = PipelineBuilder.collectNativeL0SessionTrace('opus', { mcpAvailable: true }); - assert.ok(result, 'should return a PipelineResult (not null)'); + after(() => l0c?.clearL0Cache()); - const eventIds = result.events.map((e) => e.hookId).sort(); - assert.deepStrictEqual(eventIds, L_IDS, 'events scoped to L1-L7 only'); + test('l0ManifestToSessionResult → L1-L7 fired events + content patches', () => { + const r = adapter.l0ManifestToSessionResult(manifestContent); + assert.ok(r, 'non-null for a populated manifest'); + assert.deepEqual(r.events.map((e) => e.hookId).sort(), L_IDS); assert.ok( - result.events.every((e) => e.status === 'fired'), - 'all L hooks fire (disableable:false, always-on)', + r.events.every((e) => e.status === 'fired'), + 'all fired', + ); + assert.ok( + r.events.every((e) => e.contentHash && typeof e.version === 'number'), + 'hash+version set', ); - - const patchIds = result.patches.map((p) => p.hookId).sort(); - assert.deepStrictEqual(patchIds, L_IDS, 'patches scoped to L1-L7 only'); assert.ok( - result.patches.every((p) => p.content.length > 0), - 'each L patch carries rendered template content', + r.patches.every((p) => p.content.length > 0), + 'patches carry the compiled content', ); + }); - // No S/D leakage — the whole point of the L-scope filter. - assert.ok(!result.events.some((e) => /^[SD]\d/.test(e.hookId)), 'no S/D events leak into L-series trace'); + test('empty manifest → null (visible signal, not a silent empty session)', () => { + assert.equal(adapter.l0ManifestToSessionResult([]), null); }); - test('buildFromPipeline maps L-series to observed segments (bridge path, native-L0)', () => { - const lResult = PipelineBuilder.collectNativeL0SessionTrace('opus', { mcpAvailable: true }); - // turnResult=null models the reachability crux: pre-fix the bridge returned non-null - // with EMPTY session segments; post-fix the L-series session result carries L1-L7. - const bridge = TraceBridge.buildFromPipeline(lResult, null, { - turnId: 'turn-1', + test('bridge maps to observed L1-L7 with native-l0 delivery channel (P1-1)', () => { + const sessionResult = adapter.l0ManifestToSessionResult(manifestContent); + const b = bridge.buildFromPipeline(sessionResult, null, { + turnId: 't1', threadId: 'thread-A', catId: 'opus', hasNativeL0: true, + sessionFromNativeCompiler: true, }); - assert.ok(bridge, 'bridge non-null'); - - const lSegs = bridge.summary.segments.filter((s) => /^L\d/.test(s.segmentId)); - assert.equal(lSegs.length, 7, 'summary carries all 7 L segments'); - for (const s of lSegs) { - assert.equal(s.status, 'observed', `${s.segmentId} observed`); - assert.equal(s.pipelineStatus, 'fired', `${s.segmentId} pipelineStatus fired`); - assert.equal(s.stage, 'session-init', `${s.segmentId} session-init stage`); - assert.ok(s.contentHash, `${s.segmentId} contentHash set`); - assert.ok(s.charCount > 0, `${s.segmentId} charCount > 0`); - assert.equal(typeof s.version, 'number', `${s.segmentId} version set`); - } - assert.equal(bridge.summary.totalSegmentsObserved, 7, 'totalSegmentsObserved counts L1-L7'); + const lSegs = b.summary.segments.filter((s) => /^L\d/.test(s.segmentId)); + assert.equal(lSegs.length, 7); + assert.ok(lSegs.every((s) => s.status === 'observed' && s.pipelineStatus === 'fired')); + const session = b.summary.delivery.find((d) => d.stage === 'session-init'); + assert.equal(session.channel, 'native-l0', 'L1-L7 delivered via native L0, not pack-only'); }); - test('§16e reachability: persisted L4 is found by segment-lifeline collectObservations predicate', async () => { - const lResult = PipelineBuilder.collectNativeL0SessionTrace('opus', { mcpAvailable: true }); - const bridge = TraceBridge.buildFromPipeline(lResult, null, { - turnId: 'turn-1', + test('§16e reachability: persisted L4 found by segment-lifeline predicate', async () => { + const sessionResult = adapter.l0ManifestToSessionResult(manifestContent); + const b = bridge.buildFromPipeline(sessionResult, null, { + turnId: 't1', threadId: 'thread-A', catId: 'opus', hasNativeL0: true, + sessionFromNativeCompiler: true, }); - - const redis = new FakeRedis(); - const store = new InjectionTraceStoreMod.InjectionTraceStore(redis); - await store.persist(bridge.summary, bridge.detail); - - // Replicate segment-lifeline.collectObservations reachability path EXACTLY - // (segment-lifeline.ts:150-158): listTracedThreadIds → queryWindow → predicate. + const store = new StoreMod.InjectionTraceStore(new FakeRedis()); + await store.persist(b.summary, b.detail); const threadIds = await store.listTracedThreadIds(); - assert.ok(threadIds.includes('thread-A'), 'thread discoverable via registry'); - + assert.ok(threadIds.includes('thread-A')); const summaries = await store.queryWindow('thread-A', 0, Date.now() + 1000); - const found = []; - for (const summary of summaries) { - const seg = summary.segments.find((s) => s.segmentId === 'L4' && s.status === 'observed'); - if (seg) found.push(seg); - } - assert.equal(found.length, 1, 'L4 reachable by the exact lifeline predicate (was blank pre-fix)'); - assert.equal(found[0].pipelineStatus, 'fired'); - assert.ok(found[0].charCount > 0, 'L4 charCount surfaced'); - assert.equal(found[0].version, 1, 'L4 version surfaced for lifeline chain'); + const found = summaries.flatMap((s) => s.segments).filter((s) => s.segmentId === 'L4' && s.status === 'observed'); + assert.equal(found.length, 1, 'L4 reachable by the exact lifeline predicate'); + assert.ok(found[0].charCount > 0); }); - test('L4 name/version resolvable via HookRegistry (lifeline chain metadata)', () => { - const root = monorepoRoot.findMonorepoRoot(); - const registry = new HookRegistryMod.HookRegistry( - join(root, 'assets', 'prompt-hooks'), - join(root, 'assets', 'prompt-templates'), + test('seam: persistNativeL0SessionTrace persists L1-L7 (native-l0) from the compiler cache', async () => { + l0c.clearL0Cache(); + const root = makeRoot(); + const spawnFn = buildManifestSpawn({ manifest: RAW }); + // Warm the manifest cache via the fake compiler; the helper's cache-first read hits it. + await l0c.getL0ManifestViaSubprocess({ catId: 'opus-47', cwd: root, spawnFn }); + + const persisted = []; + const warns = []; + await native.persistNativeL0SessionTrace({ + traceStore: { persist: async (summary, detail) => persisted.push({ summary, detail }) }, + catId: 'opus-47', + threadId: 'thread-A', + turnId: 't1', + turnResult: null, + log: { warn: (_o, m) => warns.push(m) }, + }); + + assert.equal(persisted.length, 1, 'trace persisted'); + const lSegs = persisted[0].summary.segments.filter((s) => /^L\d/.test(s.segmentId)); + assert.equal(lSegs.length, 7, 'all L1-L7 persisted'); + const session = persisted[0].summary.delivery.find((d) => d.stage === 'session-init'); + assert.equal(session.channel, 'native-l0'); + assert.equal(warns.length, 0, 'no producer warning when manifest present'); + }); + + test('seam failure path: empty manifest → visible warning + NO false L data (P2-1)', async () => { + l0c.clearL0Cache(); + const root = makeRoot(); + const spawnFn = buildManifestSpawn({ compiled: 'STILL-COMPILES', manifest: [] }); + await l0c.getL0ManifestViaSubprocess({ catId: 'codex', cwd: root, spawnFn }); + + const persisted = []; + const warns = []; + await native.persistNativeL0SessionTrace({ + traceStore: { persist: async (summary) => persisted.push(summary) }, + catId: 'codex', + threadId: 'thread-B', + turnId: 't2', + turnResult: null, + log: { warn: (_o, m) => warns.push(m) }, + }); + + assert.ok( + warns.some((m) => /manifest empty/.test(m)), + 'empty manifest emits a visible producer warning (distinguishable from healthy zero)', ); - registry.scan(); - const hook = registry.getHook('L4'); - assert.ok(hook, 'L4 hook resolves in registry'); - assert.equal(hook.manifest.id, 'L4'); - assert.ok(hook.manifest.name, 'L4 has a display name for the lifeline header'); + const lPersisted = persisted.flatMap((s) => s.segments ?? []).filter((s) => /^L\d/.test(s.segmentId)); + assert.equal(lPersisted.length, 0, 'no fabricated L segments when the compiler produced no manifest'); }); }); diff --git a/packages/api/test/l0-compiler.test.js b/packages/api/test/l0-compiler.test.js index 1dc5546b1f..7374d2b2a2 100644 --- a/packages/api/test/l0-compiler.test.js +++ b/packages/api/test/l0-compiler.test.js @@ -98,13 +98,16 @@ test('compileL0ViaSubprocess (no outPath) returns stdout as compiled L0', async const out = await compileL0ViaSubprocess({ catId: 'opus-47', cwd: root, spawnFn }); assert.match(out, /布偶猫/); const call = spawnFn.calls[0]; - assert.deepEqual(call.args, [ + // F257 #2: --manifest-out is always passed (temp path is a UUID → match on presence). + assert.deepEqual(call.args.slice(0, 5), [ resolve(root, SCRIPT_REL), '--cat', 'opus-47', '--profile-dir', resolve(root, 'private/profile'), ]); + const mIdx = call.args.indexOf('--manifest-out'); + assert.ok(mIdx >= 0 && typeof call.args[mIdx + 1] === 'string', 'passes --manifest-out '); assert.ok(!call.args.includes('--out'), 'no --out when outPath omitted'); }); @@ -116,15 +119,17 @@ test('compileL0ViaSubprocess (outPath) passes --out and returns file content', a const out = await compileL0ViaSubprocess({ catId: 'codex', cwd: root, outPath, spawnFn }); assert.equal(out, 'COMPILED-L0-FILE-CONTENT'); const call = spawnFn.calls[0]; - assert.deepEqual(call.args, [ + // F257 #2: --manifest-out is always present; --out carries the caller's path. + assert.deepEqual(call.args.slice(0, 5), [ resolve(root, SCRIPT_REL), '--cat', 'codex', '--profile-dir', resolve(root, 'private/profile'), - '--out', - outPath, ]); + const oIdx = call.args.indexOf('--out'); + assert.equal(call.args[oIdx + 1], outPath, 'passes --out '); + assert.ok(call.args.includes('--manifest-out'), 'also passes --manifest-out'); }); test('compileL0ViaSubprocess fail-closed: unresolvable script path throws', async () => { diff --git a/scripts/compile-system-prompt-l0.mjs b/scripts/compile-system-prompt-l0.mjs index 9a7511bc0c..a36caa4704 100644 --- a/scripts/compile-system-prompt-l0.mjs +++ b/scripts/compile-system-prompt-l0.mjs @@ -449,6 +449,23 @@ export function resolveUserCapsule(profileDir, catId) { * @returns {Promise} compiled L0 ready for system-prompt injection */ export async function compileL0(options) { + const { compiled } = await compileL0WithManifest(options); + return compiled; +} + +/** + * F257 #2 — compile per-cat L0 AND return the per-segment L1-L7 manifest. + * + * The L-series (governance-core identity) is delivered natively via this compiled + * artifact. `loadL0SectionTemplate()` at the 476-479 loop is the ONLY place per-segment + * L content still exists before it is flattened into the prompt (the compiler strips + * segment labels, so the flattened string is NOT re-parseable). Capturing the manifest + * here — from the actual compile — is what makes the injection trace reflect what the + * provider truly delivers, rather than an out-of-band reconstruction that can diverge. + * + * @returns {Promise<{ compiled: string, lSegments: { id: string, content: string }[] }>} + */ +export async function compileL0WithManifest(options) { await bootstrapCatRegistry(); const { catId, runtimeModel, profileDir } = options; const entry = catRegistry.tryGet(catId); @@ -472,20 +489,25 @@ export async function compileL0(options) { const resolvedProfileDir = profileDir ?? process.env.CAT_CAFE_PROFILE_DIR ?? resolve(REPO_ROOT, 'private/profile'); const capsuleSection = resolveUserCapsule(resolvedProfileDir, catId); - // Load L1-L7 section templates (static content extracted to individual files) + // Load L1-L7 section templates (static content extracted to individual files). + // Capture each rendered section as the F257 #2 manifest before flattening. let result = template; + const lSegments = []; for (const [placeholder, filename] of Object.entries(L0_SECTION_TEMPLATES)) { - result = result.replace(`{{${placeholder}}}`, loadL0SectionTemplate(filename)); + const content = loadL0SectionTemplate(filename); + lSegments.push({ id: placeholder.replace(/_CONTENT$/, ''), content }); + result = result.replace(`{{${placeholder}}}`, content); } // Dynamic per-cat substitutions - return result + const compiled = result .replace('{{IDENTITY_BLOCK}}', buildIdentityBlock(config, runtimeModel)) .replace('{{USER_CAPSULE}}', capsuleSection) .replace('{{TEAMMATE_ROSTER}}', buildTeammateRoster(catId)) .replace('{{GOVERNANCE_L0}}', governanceL0.content) .replace('{{WORKFLOW_TRIGGERS}}', buildWorkflowTriggers(config.breedId, catId, config.displayName)) .replace('{{CVO_REF}}', renderCvoRef()); + return { compiled, lSegments }; } /** @@ -508,6 +530,9 @@ export async function writeL0File(options, outPath) { // CLI: // node scripts/compile-system-prompt-l0.mjs --cat opus-47 → stdout // node scripts/compile-system-prompt-l0.mjs --cat opus-47 --out p.md → write file +// node scripts/compile-system-prompt-l0.mjs --cat opus-47 --manifest-out m.json +// → F257 #2: write the per-segment L1-L7 manifest JSON (orthogonal to --out; +// stdout still carries the prompt in stdout mode for Codex/OpenCode) // node scripts/compile-system-prompt-l0.mjs --cat opus-47 --profile-dir /abs/path // → override profile directory (gpt52 review P1: fixes symlink/packaged layouts) if (isCliEntrypoint(import.meta.url, process.argv[1])) { @@ -515,7 +540,7 @@ if (isCliEntrypoint(import.meta.url, process.argv[1])) { const catIdx = args.indexOf('--cat'); if (catIdx < 0 || !args[catIdx + 1]) { console.error( - 'Usage: node scripts/compile-system-prompt-l0.mjs --cat [--out ] [--profile-dir ]', + 'Usage: node scripts/compile-system-prompt-l0.mjs --cat [--out ] [--manifest-out ] [--profile-dir ]', ); process.exit(2); } @@ -523,11 +548,19 @@ if (isCliEntrypoint(import.meta.url, process.argv[1])) { const profileDirIdx = args.indexOf('--profile-dir'); const profileDir = profileDirIdx >= 0 ? args[profileDirIdx + 1] : undefined; const outIdx = args.indexOf('--out'); - if (outIdx >= 0 && args[outIdx + 1]) { - const outPath = args[outIdx + 1]; - await writeL0File({ catId, profileDir }, outPath); + const outPath = outIdx >= 0 && args[outIdx + 1] ? args[outIdx + 1] : undefined; + const manifestIdx = args.indexOf('--manifest-out'); + const manifestOut = manifestIdx >= 0 && args[manifestIdx + 1] ? args[manifestIdx + 1] : undefined; + + const { compiled, lSegments } = await compileL0WithManifest({ catId, profileDir }); + if (outPath) { + writeFileSync(outPath, compiled, 'utf8'); console.error(`Wrote compiled L0 for ${catId} → ${outPath}`); } else { - process.stdout.write(await compileL0({ catId, profileDir })); + process.stdout.write(compiled); + } + if (manifestOut) { + writeFileSync(manifestOut, JSON.stringify(lSegments), 'utf8'); + console.error(`Wrote L0 manifest for ${catId} (${lSegments.length} segments) → ${manifestOut}`); } } From b2521d85ab75a93fb1fad650077cae48ddb92af6 Mon Sep 17 00:00:00 2001 From: "Cat8zfu14fb-Opus-4.8" Date: Tue, 21 Jul 2026 16:28:13 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(F257):=20#2=20(2b)=20R2=20=E2=80=94=20a?= =?UTF-8?q?tomic=20manifest=20validation=20+=20real-CLI=20+=20route-seam?= =?UTF-8?q?=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses sol's 2b R2 REQUEST CHANGES (all three; R1's four remain CLOSED). ## P1-1 (blocking) — partial/foreign/blank manifest was recorded as healthy fired data The row-filter (`readL0Manifest`) + "non-empty ⇒ success" adapter marked every row `fired`, so `[{L1:'only-one'}]` persisted 1 native-l0 segment with NO warning (L2-L7 silently gone), and `[{L4:''}]` counted an empty iron-law segment as injected — the original incident in partial form. Now `validateL0Manifest()` validates the manifest as ONE atomic artifact: exactly L1-L7 in canonical order, non-blank content, no foreign/duplicate/reordered rows. ANY violation → `l0ManifestToSessionResult` returns null → the visible producer-failure path (warning with the specific reason), never a partial success. red→green: missing / foreign / duplicate / blank-content / reordered all reject. ## P2-1 — no automated real-compiler regression Added `f257-l0-manifest-cli.test.js` (no fake spawn): invokes the real `compileL0WithManifest` + the actual CLI, proving exactly L1-L7 and each manifest content byte-for-byte in the compiled prompt, and `--manifest-out` orthogonal to `--out` (file + stdout modes). If the producer stops writing or diverges, this goes red instead of the fake-spawn tests staying green. ## P2-2 — route-seam evidence (R1 gap now filled) Added `f257-route-seam.test.js`: drives the REAL route-serial + route-parallel with a bootstrapped fake trace store + prewarmed manifest cache (sol's recipe, no full-suite driver). Native-L0 service → persisted L1-L7 with channel `native-l0` (both routes); non-native control → no compiler L segments, not native-l0 (existing path unchanged). ## Evidence - f257-lseries-trace 14/0 (incl. 6 atomic-reject cases + empty/partial seam-failure), f257-l0-manifest 5/0, f257-l0-manifest-cli 2/0, f257-route-seam 4/0. - Regression green: trace-bridge, l0-compiler, l0-pipeline-equivalence, segment-lifeline, injection-trace-store, hook-pipeline, segment-judgment-engine. tsc + biome clean. Co-Authored-By: Claude Opus 4.8 --- .../domains/prompt-hooks/l0-manifest-trace.ts | 39 ++- .../domains/prompt-hooks/native-l0-trace.ts | 13 +- .../api/test/f257-l0-manifest-cli.test.js | 74 +++++ packages/api/test/f257-lseries-trace.test.js | 94 +++++-- packages/api/test/f257-route-seam.test.js | 255 ++++++++++++++++++ 5 files changed, 444 insertions(+), 31 deletions(-) create mode 100644 packages/api/test/f257-l0-manifest-cli.test.js create mode 100644 packages/api/test/f257-route-seam.test.js diff --git a/packages/api/src/domains/prompt-hooks/l0-manifest-trace.ts b/packages/api/src/domains/prompt-hooks/l0-manifest-trace.ts index 28cadf1ac4..f06657bfcb 100644 --- a/packages/api/src/domains/prompt-hooks/l0-manifest-trace.ts +++ b/packages/api/src/domains/prompt-hooks/l0-manifest-trace.ts @@ -21,13 +21,44 @@ import type { PipelineResult } from './HookPipeline.js'; import { getCachedRegistry } from './PipelinePromptBuilder.js'; import { hashContent } from './trace-collector.js'; +/** The native L0 identity is exactly these segments, in this order (compiler-emitted). */ +const CANONICAL_L_SEGMENTS = ['L1', 'L2', 'L3', 'L4', 'L5', 'L6', 'L7'] as const; + +/** + * F257 #2 (2b R2 P1-1): validate the manifest as ONE atomic L1-L7 artifact. + * Returns null when valid, else a human-readable reason. + * + * The native L0 identity is delivered as a whole, so the trace must trust it atomically: + * a partial / empty / reordered / foreign / duplicate / blank-content manifest means the + * producer (compiler / CLI) regressed, and recording it as healthy `fired` data would + * recreate the original incident (Console shows an apparently-injected iron-law segment + * that was actually dropped or empty). Any violation → reject the WHOLE manifest into the + * visible producer-failure path, never a partial success. + */ +export function validateL0Manifest(manifest: readonly L0SegmentContent[]): string | null { + if (manifest.length !== CANONICAL_L_SEGMENTS.length) { + return `expected exactly ${CANONICAL_L_SEGMENTS.length} L segments, got ${manifest.length}`; + } + for (let i = 0; i < CANONICAL_L_SEGMENTS.length; i++) { + const seg = manifest[i]; + if (!seg || seg.segmentId !== CANONICAL_L_SEGMENTS[i]) { + // Catches missing / duplicate / foreign / reordered in one canonical-order check. + return `segment[${i}] must be ${CANONICAL_L_SEGMENTS[i]}, got "${seg?.segmentId}"`; + } + if (typeof seg.content !== 'string' || seg.content.trim().length === 0) { + return `${seg.segmentId} has blank content`; + } + } + return null; +} + /** - * Build a session-stage `PipelineResult` from the real L0 compiler manifest. - * Returns null for an empty manifest so callers can emit a visible "L not observed" - * signal instead of persisting a silent healthy-zero. + * Build a session-stage `PipelineResult` from the real L0 compiler manifest, or null when + * the manifest fails atomic validation (see validateL0Manifest) — callers then emit a + * visible "L not observed" signal instead of persisting a partial/false healthy trace. */ export function l0ManifestToSessionResult(manifest: readonly L0SegmentContent[]): PipelineResult | null { - if (manifest.length === 0) return null; + if (validateL0Manifest(manifest) !== null) return null; const registry = getCachedRegistry(); const timestamp = Date.now(); diff --git a/packages/api/src/domains/prompt-hooks/native-l0-trace.ts b/packages/api/src/domains/prompt-hooks/native-l0-trace.ts index 710f709989..2c7b40e029 100644 --- a/packages/api/src/domains/prompt-hooks/native-l0-trace.ts +++ b/packages/api/src/domains/prompt-hooks/native-l0-trace.ts @@ -17,7 +17,7 @@ import type { InjectionTraceDetail, InjectionTraceSummary } from '@cat-cafe/shared'; import { getL0ManifestViaSubprocess } from '../cats/services/agents/providers/l0-compiler.js'; import type { PipelineResult } from './HookPipeline.js'; -import { l0ManifestToSessionResult } from './l0-manifest-trace.js'; +import { l0ManifestToSessionResult, validateL0Manifest } from './l0-manifest-trace.js'; import { buildFromPipeline } from './trace-bridge.js'; interface TraceSink { @@ -42,13 +42,16 @@ export async function persistNativeL0SessionTrace(params: PersistNativeL0Params) const { traceStore, catId, threadId, turnId, turnResult, log } = params; try { const manifest = await getL0ManifestViaSubprocess({ catId }); - const sessionResult = l0ManifestToSessionResult(manifest); - if (!sessionResult) { + // 2b R2 P1-1: reject the manifest atomically. A partial/foreign/blank/reordered manifest + // is a producer regression — surface WHY (visible signal), never persist a partial success. + const rejectReason = validateL0Manifest(manifest); + if (rejectReason) { log.warn( - { catId, threadId }, - '[F257] native L0 manifest empty — L1-L7 not observed this turn (compile did not run / produced no manifest)', + { catId, threadId, reason: rejectReason }, + '[F257] native L0 manifest rejected — L1-L7 not observed this turn (producer signal)', ); } + const sessionResult = l0ManifestToSessionResult(manifest); // null iff rejectReason const bridge = buildFromPipeline(sessionResult, turnResult, { turnId, threadId, diff --git a/packages/api/test/f257-l0-manifest-cli.test.js b/packages/api/test/f257-l0-manifest-cli.test.js new file mode 100644 index 0000000000..ddd7f686cb --- /dev/null +++ b/packages/api/test/f257-l0-manifest-cli.test.js @@ -0,0 +1,74 @@ +/** + * F257 #2 (2b R2 P2-1) — REAL L0 compiler ↔ manifest contract (no fake spawn). + * + * The unit tests use a fake spawn that assumes --manifest-out writes correct JSON; this + * guards the actual producer so it can't silently stop writing or diverge while the unit + * tests stay green. Proves: compileL0WithManifest emits exactly L1-L7 and each manifest + * content appears byte-for-byte in the compiled prompt, and the CLI's --manifest-out is + * orthogonal to --out (file + stdout modes). + */ + +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { before, describe, test } from 'node:test'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const testDir = dirname(fileURLToPath(import.meta.url)); +// packages/api/test → up 3 → repo root → scripts/compile-system-prompt-l0.mjs +const scriptPath = resolve(testDir, '..', '..', '..', 'scripts', 'compile-system-prompt-l0.mjs'); +const L_IDS = ['L1', 'L2', 'L3', 'L4', 'L5', 'L6', 'L7']; +const CAT = 'opus'; + +describe('F257 #2 — real compiler manifest contract (2b R2 P2-1)', () => { + let mjs; + before(async () => { + mjs = await import(pathToFileURL(scriptPath).href); + }); + + test('compileL0WithManifest: exactly L1-L7, each content byte-for-byte in the compiled prompt', async () => { + const { compiled, lSegments } = await mjs.compileL0WithManifest({ catId: CAT }); + assert.deepEqual( + lSegments.map((s) => s.id), + L_IDS, + 'manifest is exactly L1-L7 in canonical order', + ); + for (const seg of lSegments) { + assert.ok(seg.content.trim().length > 0, `${seg.id} non-blank`); + assert.ok(compiled.includes(seg.content), `${seg.id} content appears byte-for-byte in the compiled prompt`); + } + }); + + test('CLI --manifest-out is orthogonal to --out (file mode + stdout mode)', () => { + const dir = mkdtempSync(join(tmpdir(), 'l0-cli-')); + + // File mode: --out writes the prompt, --manifest-out writes the manifest. + const outPath = join(dir, 'prompt.md'); + const mPath = join(dir, 'manifest.json'); + execFileSync(process.execPath, [scriptPath, '--cat', CAT, '--out', outPath, '--manifest-out', mPath], { + stdio: ['ignore', 'ignore', 'inherit'], + }); + const fileManifest = JSON.parse(readFileSync(mPath, 'utf8')); + assert.deepEqual( + fileManifest.map((s) => s.id), + L_IDS, + ); + const filePrompt = readFileSync(outPath, 'utf8'); + for (const seg of fileManifest) assert.ok(filePrompt.includes(seg.content), `${seg.id} in --out prompt`); + + // Stdout mode: no --out → prompt on stdout; --manifest-out still writes the manifest. + const mPath2 = join(dir, 'manifest2.json'); + const stdout = execFileSync(process.execPath, [scriptPath, '--cat', CAT, '--manifest-out', mPath2], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + }); + const stdoutManifest = JSON.parse(readFileSync(mPath2, 'utf8')); + assert.deepEqual( + stdoutManifest.map((s) => s.id), + L_IDS, + ); + for (const seg of stdoutManifest) assert.ok(stdout.includes(seg.content), `${seg.id} in stdout prompt`); + }); +}); diff --git a/packages/api/test/f257-lseries-trace.test.js b/packages/api/test/f257-lseries-trace.test.js index ba7d0dc490..a5cb044172 100644 --- a/packages/api/test/f257-lseries-trace.test.js +++ b/packages/api/test/f257-lseries-trace.test.js @@ -154,6 +154,49 @@ describe('F257 #2: native-L0 L-series via compiler manifest', () => { test('empty manifest → null (visible signal, not a silent empty session)', () => { assert.equal(adapter.l0ManifestToSessionResult([]), null); + assert.match(adapter.validateL0Manifest([]), /expected exactly 7/); + }); + + // 2b R2 P1-1: the manifest is ONE atomic L1-L7 artifact. A partial / foreign / duplicate / + // reordered / blank-content manifest is a producer regression → reject the WHOLE thing, + // never persist a partial "healthy" trace. red→green: every violation → reason + null. + describe('atomic manifest validation (P1-1)', () => { + const drop = (id) => manifestContent.filter((s) => s.segmentId !== id); + const cases = [ + ['partial (missing L4)', drop('L4'), /got 6/], + ['extra/foreign row (L1-L7 + X)', [...manifestContent, { segmentId: 'X9', content: 'foreign' }], /got 8/], + [ + 'foreign id replacing L4', + manifestContent.map((s) => (s.segmentId === 'L4' ? { segmentId: 'Z4', content: 'x' } : s)), + /must be L4/, + ], + [ + 'duplicate (L1 twice, missing L7)', + [manifestContent[0], ...manifestContent.slice(0, 6)], + /must be L2, got "L1"/, + ], + [ + 'blank content (L4 empty)', + manifestContent.map((s) => (s.segmentId === 'L4' ? { segmentId: 'L4', content: ' ' } : s)), + /L4 has blank content/, + ], + [ + 'reordered (L2 before L1)', + [manifestContent[1], manifestContent[0], ...manifestContent.slice(2)], + /must be L1, got "L2"/, + ], + ]; + for (const [name, mf, reasonRe] of cases) { + test(`rejects ${name} → null + descriptive reason`, () => { + assert.match(adapter.validateL0Manifest(mf), reasonRe, `${name} reason`); + assert.equal(adapter.l0ManifestToSessionResult(mf), null, `${name} → null (no partial persist)`); + }); + } + + test('exactly canonical L1-L7 non-blank → valid (null reason)', () => { + assert.equal(adapter.validateL0Manifest(manifestContent), null); + assert.ok(adapter.l0ManifestToSessionResult(manifestContent)); + }); }); test('bridge maps to observed L1-L7 with native-l0 delivery channel (P1-1)', () => { @@ -217,28 +260,35 @@ describe('F257 #2: native-L0 L-series via compiler manifest', () => { assert.equal(warns.length, 0, 'no producer warning when manifest present'); }); - test('seam failure path: empty manifest → visible warning + NO false L data (P2-1)', async () => { - l0c.clearL0Cache(); - const root = makeRoot(); - const spawnFn = buildManifestSpawn({ compiled: 'STILL-COMPILES', manifest: [] }); - await l0c.getL0ManifestViaSubprocess({ catId: 'codex', cwd: root, spawnFn }); + // 2b R2 P1-1/P2-1: a regressed producer (empty OR partial manifest) must hit the visible + // producer-failure path — warning fired, ZERO fabricated L segments persisted. + for (const [label, catId, manifest] of [ + ['empty manifest', 'codex', []], + ['partial manifest (only L1 — L2-L7 dropped)', 'sol', [{ id: 'L1', content: 'only-one' }]], + ]) { + test(`seam failure path: ${label} → visible warning + NO false L data`, async () => { + l0c.clearL0Cache(); + const root = makeRoot(); + const spawnFn = buildManifestSpawn({ compiled: 'STILL-COMPILES', manifest }); + await l0c.getL0ManifestViaSubprocess({ catId, cwd: root, spawnFn }); - const persisted = []; - const warns = []; - await native.persistNativeL0SessionTrace({ - traceStore: { persist: async (summary) => persisted.push(summary) }, - catId: 'codex', - threadId: 'thread-B', - turnId: 't2', - turnResult: null, - log: { warn: (_o, m) => warns.push(m) }, - }); + const persisted = []; + const warns = []; + await native.persistNativeL0SessionTrace({ + traceStore: { persist: async (summary) => persisted.push(summary) }, + catId, + threadId: 'thread-B', + turnId: 't2', + turnResult: null, + log: { warn: (_o, m) => warns.push(m) }, + }); - assert.ok( - warns.some((m) => /manifest empty/.test(m)), - 'empty manifest emits a visible producer warning (distinguishable from healthy zero)', - ); - const lPersisted = persisted.flatMap((s) => s.segments ?? []).filter((s) => /^L\d/.test(s.segmentId)); - assert.equal(lPersisted.length, 0, 'no fabricated L segments when the compiler produced no manifest'); - }); + assert.ok( + warns.some((m) => /manifest rejected/.test(m)), + 'regressed manifest emits a visible producer warning (distinguishable from healthy zero)', + ); + const lPersisted = persisted.flatMap((s) => s.segments ?? []).filter((s) => /^L\d/.test(s.segmentId)); + assert.equal(lPersisted.length, 0, 'no fabricated L segments — partial success is never persisted'); + }); + } }); diff --git a/packages/api/test/f257-route-seam.test.js b/packages/api/test/f257-route-seam.test.js new file mode 100644 index 0000000000..4601ed7639 --- /dev/null +++ b/packages/api/test/f257-route-seam.test.js @@ -0,0 +1,255 @@ +/** + * F257 #2 (2b R2 P2-2) — route seam: native-L0 identity is persisted via the compiler + * manifest through BOTH route-serial and route-parallel, and non-native routing stays on + * its existing session-trace path. + * + * The unit seam test starts at persistNativeL0SessionTrace and can't catch a route dropping + * or mis-branching the call. This drives the real routes with a bootstrapped fake trace + * store + a prewarmed manifest cache (sol's recipe — no broad full-suite driver): a + * native-L0 service must yield persisted L1-L7 with channel `native-l0`; a non-native + * service must NOT (no compiler L segments, not native-l0). + */ + +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, before, describe, test } from 'node:test'; + +class FakeRedis { + constructor() { + this.kv = new Map(); + this.sorted = new Map(); + this.sets = new Map(); + } + async set(k, v) { + this.kv.set(k, v); + return 'OK'; + } + async get(k) { + return this.kv.get(k) ?? null; + } + async del(k) { + this.kv.delete(k); + return 1; + } + async zadd(k, score, m) { + const s = this.sorted.get(k) ?? new Map(); + s.set(m, score); + this.sorted.set(k, s); + return 1; + } + async zrangebyscore(k, min, max) { + const s = this.sorted.get(k); + if (!s) return []; + return [...s.entries()] + .filter(([, sc]) => sc >= min && sc <= max) + .sort((a, b) => a[1] - b[1]) + .map(([m]) => m); + } + async zrevrange(k, a, b) { + const s = this.sorted.get(k); + if (!s) return []; + return [...s.entries()] + .sort((x, y) => y[1] - x[1]) + .slice(a, b + 1) + .map(([m]) => m); + } + async zrem(k, m) { + return this.sorted.get(k)?.delete(m) ? 1 : 0; + } + async sadd(k, ...ms) { + const s = this.sets.get(k) ?? new Set(); + for (const m of ms) s.add(m); + this.sets.set(k, s); + return ms.length; + } + async smembers(k) { + return [...(this.sets.get(k) ?? [])]; + } + async scan(_c, ...args) { + const i = args.indexOf('MATCH'); + const pat = i >= 0 ? args[i + 1] : '*'; + const rx = new RegExp(`^${pat.replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&').replace(/\*/g, '.*')}$`); + return ['0', [...new Set([...this.kv.keys(), ...this.sorted.keys()])].filter((x) => rx.test(x))]; + } +} + +const RAW = ['L1', 'L2', 'L3', 'L4', 'L5', 'L6', 'L7'].map((id) => ({ id, content: `${id} governance content` })); + +function makeRoot() { + const root = mkdtempSync(join(tmpdir(), 'l0-seam-')); + mkdirSync(join(root, 'scripts'), { recursive: true }); + writeFileSync(join(root, 'scripts', 'compile-system-prompt-l0.mjs'), '// fake'); + return root; +} + +function buildManifestSpawn(manifest) { + return function fakeSpawn(_cmd, args) { + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + setImmediate(() => { + const mi = args.indexOf('--manifest-out'); + if (mi >= 0 && args[mi + 1]) writeFileSync(args[mi + 1], JSON.stringify(manifest), 'utf8'); + child.stdout.emit('data', Buffer.from('PROMPT')); + child.emit('close', 0); + }); + return child; + }; +} + +function mockService(catId, { native }) { + return { + async *invoke() { + yield { type: 'text', catId, content: 'reply', timestamp: Date.now() }; + yield { type: 'done', catId, timestamp: Date.now() }; + }, + ...(native ? { injectsL0Natively: () => true } : {}), + }; +} + +function createMockDeps(services) { + let inv = 0; + let msg = 0; + const byId = new Map(); + return { + services, + injectionTraceStore: true, // truthy → route runs the trailing drainCapturedTraces() + invocationDeps: { + registry: { + create: () => ({ invocationId: `inv-${++inv}`, callbackToken: `tok-${inv}` }), + verify: () => ({ ok: false, reason: 'unknown_invocation' }), + }, + sessionManager: { get: async () => null, getOrCreate: async () => ({}), resolveWorkingDirectory: () => '/tmp/t' }, + threadStore: { + get: async () => null, + getParticipantsWithActivity: async () => [], + updateParticipantActivity: async () => {}, + consumeMentionRoutingFeedback: async () => null, + isRebornSession: async () => false, + }, + apiUrl: 'http://127.0.0.1:3004', + }, + messageStore: { + append: async (m) => { + const s = { id: `m-${++msg}`, ...m, threadId: m.threadId ?? 'default' }; + byId.set(s.id, s); + return s; + }, + getById: async (id) => byId.get(id) ?? null, + getRecent: () => [], + getMentionsFor: () => [], + getRecentMentionsFor: () => [], + getBefore: () => [], + getByThread: () => [], + getByThreadAfter: () => [], + getByThreadBefore: () => [], + }, + draftStore: { delete: () => Promise.resolve(), touch: () => Promise.resolve(), upsert: () => Promise.resolve() }, + socketManager: { broadcastToRoom: () => {} }, + }; +} + +async function pollTrace(store, threadId, predicate, timeoutMs = 1500) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const summaries = await store.queryWindow(threadId, 0, Date.now() + 1000); + const hit = summaries.find(predicate); + if (hit) return hit; + await new Promise((r) => setTimeout(r, 20)); + } + return null; +} + +describe('F257 #2 route seam (2b R2 P2-2)', () => { + let routeParallel; + let routeSerial; + let l0c; + let StoreMod; + let catReg; + let store; + + before(async () => { + const shared = await import('@cat-cafe/shared'); + catReg = shared.catRegistry; + catReg.reset(); + for (const id of ['nativecat', 'plaincat']) { + catReg.register(id, { + displayName: '布偶猫', + nickname: id, + name: 'Ragdoll', + roleDescription: 'x', + personality: 'y', + defaultModel: 'claude-opus-4-6', + mentionPatterns: [`@${id}`], + restrictions: [], + clientId: 'anthropic', + breedId: 'ragdoll', + }); + } + routeParallel = (await import('../dist/domains/cats/services/agents/routing/route-parallel.js')).routeParallel; + routeSerial = (await import('../dist/domains/cats/services/agents/routing/route-serial.js')).routeSerial; + l0c = await import('../dist/domains/cats/services/agents/providers/l0-compiler.js'); + StoreMod = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const traceBootstrap = await import('../dist/domains/prompt-hooks/trace-bootstrap.js'); + + const redis = new FakeRedis(); + traceBootstrap.bootstrapTraceStore(redis); + store = new StoreMod.InjectionTraceStore(redis); + + // Prewarm the manifest cache so the route's cache-first read hits it (no real subprocess). + l0c.clearL0Cache(); + await l0c.getL0ManifestViaSubprocess({ catId: 'nativecat', cwd: makeRoot(), spawnFn: buildManifestSpawn(RAW) }); + }); + + after(() => { + catReg?.reset(); + l0c?.clearL0Cache(); + }); + + async function drain(route, catId, threadId) { + for await (const _m of route( + createMockDeps({ [catId]: mockService(catId, { native: catId === 'nativecat' }) }), + [catId], + 'hi', + 'user1', + threadId, + {}, + )) { + // drain the route generator + } + } + + for (const [mode, getRoute] of [ + ['parallel', () => routeParallel], + ['serial', () => routeSerial], + ]) { + test(`${mode}: native-L0 cat persists L1-L7 via the compiler manifest (native-l0 channel)`, async () => { + const threadId = `seam-${mode}-native`; + await drain(getRoute(), 'nativecat', threadId); + const summary = await pollTrace(store, threadId, (s) => s.segments.some((x) => x.segmentId === 'L4')); + assert.ok(summary, `${mode}: native-L0 trace was persisted`); + const lSegs = summary.segments.filter((s) => /^L\d/.test(s.segmentId)); + assert.equal(lSegs.length, 7, 'all L1-L7 present'); + assert.ok(lSegs.every((s) => s.status === 'observed' && s.pipelineStatus === 'fired')); + const session = summary.delivery.find((d) => d.stage === 'session-init'); + assert.equal(session.channel, 'native-l0', 'session delivered via native L0'); + }); + + test(`${mode}: non-native cat stays on the existing path (no compiler L segments, not native-l0)`, async () => { + const threadId = `seam-${mode}-plain`; + await drain(getRoute(), 'plaincat', threadId); + // let any fire-and-forget persist settle + await new Promise((r) => setTimeout(r, 150)); + const summaries = await store.queryWindow(threadId, 0, Date.now() + 1000); + const usedNativeL0 = summaries.some( + (s) => s.delivery.find((d) => d.stage === 'session-init')?.channel === 'native-l0', + ); + assert.equal(usedNativeL0, false, 'non-native must NOT use the native-l0 channel'); + const hasCompilerL = summaries.some((s) => s.segments.some((x) => /^L\d/.test(x.segmentId))); + assert.equal(hasCompilerL, false, 'non-native session trace carries no compiler L segments'); + }); + } +}); From 724885886629b63e170ac3915c68d734e5edee86 Mon Sep 17 00:00:00 2001 From: "Cat8zfu14fb-Opus-4.8" Date: Tue, 21 Jul 2026 16:34:20 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(F257):=20#2=20(2b)=20R3=20=E2=80=94=20n?= =?UTF-8?q?on-vacuous=20non-native=20control=20+=20real-CLI=20profile=20is?= =?UTF-8?q?olation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses sol's 2b R3 REQUEST CHANGES (both test-proof/isolation gaps; production code CLOSED, no correctness finding). ## P2-1 — non-native route control passed vacuously on empty results The non-native cases only asserted "no summary uses native-l0" + "no summary has L segments" — both true when summaries=[] (e.g. if the non-native persistence path were deleted/broken), so it did not prove "existing non-native path unchanged". Now REQUIRE a persisted summary first, then assert the expected non-native session channel (message-prepend) + an existing pipeline S/D segment with a real observed/absent status. Kept for both serial and parallel. ## P2-2 — real-CLI test read potential real profile data + left temp files compileL0WithManifest({catId}) and the CLI ran without --profile-dir, so the compiler resolved options.profileDir > CAT_CAFE_PROFILE_DIR > /private/profile and could read real user capsule/primer, then left mkdtemp compiled prompts behind. Now both the direct call and the CLI are pointed at a dedicated EMPTY temp profile dir (explicit profileDir / --profile-dir), and every temp dir (profile + compile outputs) is tracked and removed in after(). Test-data isolation boundary, not cosmetic. ## Evidence - f257-route-seam 4/0 (non-native now asserts message-prepend + S/D segment), f257-l0-manifest-cli 2/0 (isolated empty profile + cleanup). biome clean. Co-Authored-By: Claude Opus 4.8 --- .../api/test/f257-l0-manifest-cli.test.js | 50 +++++++++++++++---- packages/api/test/f257-route-seam.test.js | 26 ++++++---- 2 files changed, 56 insertions(+), 20 deletions(-) diff --git a/packages/api/test/f257-l0-manifest-cli.test.js b/packages/api/test/f257-l0-manifest-cli.test.js index ddd7f686cb..c4955c46bb 100644 --- a/packages/api/test/f257-l0-manifest-cli.test.js +++ b/packages/api/test/f257-l0-manifest-cli.test.js @@ -6,14 +6,18 @@ * tests stay green. Proves: compileL0WithManifest emits exactly L1-L7 and each manifest * content appears byte-for-byte in the compiled prompt, and the CLI's --manifest-out is * orthogonal to --out (file + stdout modes). + * + * Test-data isolation (2b R2 P2-2): the compiler is ALWAYS pointed at a dedicated EMPTY + * temp profile dir (--profile-dir / options.profileDir), so it never reads real user + * capsule/primer data; every temp dir (profile + compile outputs) is tracked and removed. */ import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; -import { mkdtempSync, readFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; -import { before, describe, test } from 'node:test'; +import { after, before, describe, test } from 'node:test'; import { fileURLToPath, pathToFileURL } from 'node:url'; const testDir = dirname(fileURLToPath(import.meta.url)); @@ -22,14 +26,35 @@ const scriptPath = resolve(testDir, '..', '..', '..', 'scripts', 'compile-system const L_IDS = ['L1', 'L2', 'L3', 'L4', 'L5', 'L6', 'L7']; const CAT = 'opus'; +const tmpDirs = []; +function mkTmp(prefix) { + const d = mkdtempSync(join(tmpdir(), prefix)); + tmpDirs.push(d); + return d; +} + describe('F257 #2 — real compiler manifest contract (2b R2 P2-1)', () => { let mjs; + let profileDir; + before(async () => { mjs = await import(pathToFileURL(scriptPath).href); + // Dedicated EMPTY profile dir — isolates the compile from any real user profile data. + profileDir = mkTmp('l0-profile-'); + }); + + after(() => { + for (const d of tmpDirs.splice(0)) { + try { + rmSync(d, { recursive: true, force: true }); + } catch { + /* best-effort temp cleanup */ + } + } }); test('compileL0WithManifest: exactly L1-L7, each content byte-for-byte in the compiled prompt', async () => { - const { compiled, lSegments } = await mjs.compileL0WithManifest({ catId: CAT }); + const { compiled, lSegments } = await mjs.compileL0WithManifest({ catId: CAT, profileDir }); assert.deepEqual( lSegments.map((s) => s.id), L_IDS, @@ -42,14 +67,16 @@ describe('F257 #2 — real compiler manifest contract (2b R2 P2-1)', () => { }); test('CLI --manifest-out is orthogonal to --out (file mode + stdout mode)', () => { - const dir = mkdtempSync(join(tmpdir(), 'l0-cli-')); + const dir = mkTmp('l0-cli-'); // File mode: --out writes the prompt, --manifest-out writes the manifest. const outPath = join(dir, 'prompt.md'); const mPath = join(dir, 'manifest.json'); - execFileSync(process.execPath, [scriptPath, '--cat', CAT, '--out', outPath, '--manifest-out', mPath], { - stdio: ['ignore', 'ignore', 'inherit'], - }); + execFileSync( + process.execPath, + [scriptPath, '--cat', CAT, '--profile-dir', profileDir, '--out', outPath, '--manifest-out', mPath], + { stdio: ['ignore', 'ignore', 'inherit'] }, + ); const fileManifest = JSON.parse(readFileSync(mPath, 'utf8')); assert.deepEqual( fileManifest.map((s) => s.id), @@ -60,10 +87,11 @@ describe('F257 #2 — real compiler manifest contract (2b R2 P2-1)', () => { // Stdout mode: no --out → prompt on stdout; --manifest-out still writes the manifest. const mPath2 = join(dir, 'manifest2.json'); - const stdout = execFileSync(process.execPath, [scriptPath, '--cat', CAT, '--manifest-out', mPath2], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'inherit'], - }); + const stdout = execFileSync( + process.execPath, + [scriptPath, '--cat', CAT, '--profile-dir', profileDir, '--manifest-out', mPath2], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] }, + ); const stdoutManifest = JSON.parse(readFileSync(mPath2, 'utf8')); assert.deepEqual( stdoutManifest.map((s) => s.id), diff --git a/packages/api/test/f257-route-seam.test.js b/packages/api/test/f257-route-seam.test.js index 4601ed7639..82d083257c 100644 --- a/packages/api/test/f257-route-seam.test.js +++ b/packages/api/test/f257-route-seam.test.js @@ -238,18 +238,26 @@ describe('F257 #2 route seam (2b R2 P2-2)', () => { assert.equal(session.channel, 'native-l0', 'session delivered via native L0'); }); - test(`${mode}: non-native cat stays on the existing path (no compiler L segments, not native-l0)`, async () => { + test(`${mode}: non-native cat stays on the existing pipeline path (message-prepend, S/D segments, no compiler L)`, async () => { const threadId = `seam-${mode}-plain`; await drain(getRoute(), 'plaincat', threadId); - // let any fire-and-forget persist settle - await new Promise((r) => setTimeout(r, 150)); - const summaries = await store.queryWindow(threadId, 0, Date.now() + 1000); - const usedNativeL0 = summaries.some( - (s) => s.delivery.find((d) => d.stage === 'session-init')?.channel === 'native-l0', + // Non-vacuous: REQUIRE the existing path to have actually persisted a trace. If the + // non-native persistence were deleted/broken, summaries=[] would make the "no native-l0 + // / no L" checks pass falsely — so first prove a trace exists, then assert its shape. + const summary = await pollTrace(store, threadId, (s) => s.segments.length > 0); + assert.ok(summary, `${mode}: non-native path persisted a trace (existing pipeline ran)`); + const session = summary.delivery.find((d) => d.stage === 'session-init'); + assert.equal(session.channel, 'message-prepend', 'non-native session uses message-prepend, not native-l0'); + assert.ok( + !summary.segments.some((x) => /^L\d/.test(x.segmentId)), + 'non-native session trace carries no compiler L segments', + ); + const pipelineSeg = summary.segments.find((x) => /^[SD]\d/.test(x.segmentId)); + assert.ok(pipelineSeg, 'existing pipeline S/D segments present (path unchanged)'); + assert.ok( + ['observed', 'absent'].includes(pipelineSeg.status), + 'existing pipeline segment carries a real observed/absent status', ); - assert.equal(usedNativeL0, false, 'non-native must NOT use the native-l0 channel'); - const hasCompilerL = summaries.some((s) => s.segments.some((x) => /^L\d/.test(x.segmentId))); - assert.equal(hasCompilerL, false, 'non-native session trace carries no compiler L segments'); }); } });