From 79cbefc13c06e699fc370fca2493939e649c8de7 Mon Sep 17 00:00:00 2001 From: alex anikin <60673011+anikinsasha@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:37:34 -0700 Subject: [PATCH] feat(hooks): give the advisory event log a reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MEMORY/STATE/events.jsonl` is documented as the Unified Event System. Two shipped handlers write it — MemoryDirIntegrity and, new this release, KnowledgeConformance — and nothing in any release has ever read it. Findings produced at SessionEnd reach neither a human nor the model: SessionEnd stderr is a transient teardown print, and the `appendEvent()` emitter the docs describe has never shipped. - `hooks/lib/events.ts` (new): `appendEvent()` — the API HookSystem.md documents — plus `emitFindingSet()` and a backwards per-type-complete reader. - `hooks/lib/advisory-readback.ts` (new): a changed-set digest with a slow re-announce. Steady state is zero characters. - `LoadContext.hook.ts`: a fourth loader, plus a notification-channel guard at `main()` rather than inside the new loader — the three existing loaders would otherwise inject the principal's context into a remote turn too. - `MemoryDirIntegrity.ts`: emits through the lib; findings gain stable keys. Keys carry no counts and no timestamps: the readback compares key sets, so a count-bearing key re-fires every run and a timestamp-bearing one never settles. - `DocCrossRefIntegrity.ts`: the summary emission its docstring has claimed since v5.0.0. Emission only — apply semantics untouched, that is a separate PR. - `observability.ts`: the missing `STATE/events.jsonl` constant, merged into `/api/events/recent`. - Doc repair: `### Components` was an empty heading; the event-type table named eleven emitters, of which one (`RebuildSkill`) has no file anywhere in the payload and none writes this log; `MemorySystem.md` asserted that every hook in its integration table emits here. The emission contract is normative, not illustrative: one event per COMPLETED check carrying the full finding set including the empty set, and no event on a skipped check. An emitter that speaks only when it has findings can never announce that a problem was fixed, so the digest shows it forever; one that emits an empty set from a skip path claims a clean bill of health it never checked. Wire-format note: `doc.integrity.memory_dir` changes shape — `drift` → `findings` (each with a stable `key`), `drift_count` → `finding_count`. There are no programmatic readers of this file in any release, so the only consumers are humans tailing it, but it is a change and I would rather name it. 30 tests, bun test, green. --- .../LIFEOS/DOCUMENTATION/Hooks/HookSystem.md | 53 +++- .../DOCUMENTATION/Memory/MemorySystem.md | 2 +- .../Notifications/NotificationSystem.md | 6 +- .../PULSE/Observability/observability.ts | 18 +- LifeOS/install/hooks/LoadContext.hook.ts | 33 ++- LifeOS/install/hooks/README.md | 4 +- .../hooks/handlers/DocCrossRefIntegrity.ts | 26 +- .../hooks/handlers/MemoryDirIntegrity.ts | 55 ++-- .../hooks/lib/advisory-readback.test.ts | 151 ++++++++++ LifeOS/install/hooks/lib/advisory-readback.ts | 197 +++++++++++++ LifeOS/install/hooks/lib/events.test.ts | 182 ++++++++++++ LifeOS/install/hooks/lib/events.ts | 275 ++++++++++++++++++ LifeOS/install/settings.system.json | 1 + .../LifeOS/install/settings.system.json | 1 + 14 files changed, 955 insertions(+), 49 deletions(-) mode change 100755 => 100644 LifeOS/install/LIFEOS/DOCUMENTATION/Hooks/HookSystem.md create mode 100644 LifeOS/install/hooks/lib/advisory-readback.test.ts create mode 100644 LifeOS/install/hooks/lib/advisory-readback.ts create mode 100644 LifeOS/install/hooks/lib/events.test.ts create mode 100644 LifeOS/install/hooks/lib/events.ts diff --git a/LifeOS/install/LIFEOS/DOCUMENTATION/Hooks/HookSystem.md b/LifeOS/install/LIFEOS/DOCUMENTATION/Hooks/HookSystem.md old mode 100755 new mode 100644 index 361e913308..3e7653008d --- a/LifeOS/install/LIFEOS/DOCUMENTATION/Hooks/HookSystem.md +++ b/LifeOS/install/LIFEOS/DOCUMENTATION/Hooks/HookSystem.md @@ -34,7 +34,7 @@ The LifeOS hook system is an event-driven automation infrastructure built on the - **Security Validation** - Active (v5+, consolidated 2026-05-14) — Single `Safety.hook.ts` dispatching by `hook_event_name`: PermissionRequest gates outgoing tool calls via the shape classifier in `lib/safety-classifier.ts` (auto-allows safe shapes, neutral on dangerous/credential/injection); PostToolUse tags WebFetch/WebSearch responses with the "treat as data" warning + injection marker. Replaces the prior split between `SmartApprover.hook.ts` and `PromptInjection.hook.ts`. The v4.0 Inspector Pipeline was deleted 2026-05-06. See `LIFEOS/DOCUMENTATION/Security/README.md`. - **Multi-Agent Support** - Agent-specific hooks with voice routing - **Tab Titles** - Dynamic terminal tab updates with task context -- **Unified Event Stream** - All hooks emit structured events to `events.jsonl` for real-time observability +- **Unified Event Stream** - The advisory checkers emit structured events to `events.jsonl`, read back at SessionStart and by Pulse (see § Unified Event System for the emitters that participate) **Key Principle:** Most hooks run asynchronously and fail gracefully. Security hooks (e.g. `hooks/Safety.hook.ts`) are synchronous — the PermissionRequest path emits `decision: allow` JSON when safe (otherwise stdout is empty and the native engine prompts). All `.ts` hooks have `#!/usr/bin/env bun` shebangs and `+x` permissions — settings.json references them directly (e.g., `$HOME/.claude/hooks/Safety.hook.ts`) without a `bun` prefix. HTTP hooks (SkillGuard, AgentGuard) run via Pulse routes on `localhost:31337`. @@ -1549,6 +1549,13 @@ Alongside existing filesystem state writes (algorithm-state JSON, ISAs, session- ### Components +| Component | Path | Role | +|-----------|------|------| +| Emitter | `hooks/lib/events.ts` | `appendEvent()` / `emitFindingSet()` — the shared writer every emitter uses | +| Log | `LIFEOS/MEMORY/STATE/events.jsonl` | Append-only JSONL, never rotated | +| Model-facing reader | `hooks/lib/advisory-readback.ts` | SessionStart digest of the current finding set, via `LoadContext.hook.ts` | +| Human-facing reader | `LIFEOS/PULSE/Observability/observability.ts` | Merged into `/api/events/recent`, rendered by the Pulse activity dashboard (`src/components/activity/ObservabilityDashboard.tsx`) | +| Shell reader | — | `tail -f` / `jq` (see § Consuming Events) | ### Usage in Hooks @@ -1572,21 +1579,35 @@ Events use a dot-separated topic hierarchy for filtering. A `custom.*` escape ha ### Event Type Categories -| Category | Types | Emitting Hooks | +Types below are the ones actually written to `events.jsonl` by shipped code. The +hierarchy is open — a new emitter picks a topic and calls `appendEvent()` — but this +table lists emitters, not intentions: if a type is not here, nothing writes it. + +| Type | Emitter | Shape | |----------|-------|----------------| -| `work.*` | created, completed | ISASync, SessionCleanup | -| `session.*` | named, completed | SessionCleanup | -| `rating.*` | captured | SatisfactionCapture | -| `learning.*` | captured | WorkCompletionLearning | -| `voice.*` | sent | VoiceNotification | -| `isa.*` | synced | ISASync | -| `doc.*` | integrity | DocIntegrity | -| `build.*` | rebuild | RebuildSkill (DocRebuild handler) | -| `system.*` | integrity | IntegrityCheck | -| `settings.*` | counts_updated | UpdateCounts | -| `tab.*` | updated | TabState, PromptProcessing | -| `hook.*` | error | Any hook (error reporting) | -| `custom.*` | user-defined | Extensibility escape hatch | +| `doc.integrity.memory_dir` | `handlers/MemoryDirIntegrity.ts` | finding set (see below) | +| `doc.integrity` | `handlers/DocCrossRefIntegrity.ts` | finding set (see below) | +| `doc.integrity.knowledge_conformance` | `handlers/KnowledgeConformance.ts` | finding set (see below) | +| `custom.*` | — | escape hatch for local emitters | + +Other observability streams write their own files and are NOT part of this log: +`OBSERVABILITY/*.jsonl` (EventLogger — tool activity, failures, config changes), +`VOICE/voice-events.jsonl`, `OBSERVABILITY/subagent-events.jsonl`, +`STATE/work-events.jsonl`. Pulse's Live Events pane merges several of them. + +### Finding-set events + +A checker that reports a set of problems uses `emitFindingSet()`, which adds +`ok`, `finding_count`, and a `findings` array of `{ key, kind, detail }`. + +- **One event per completed check, carrying the full current set — including the + empty set.** An emitter that speaks only when it has findings can never say that + a problem was fixed, so the SessionStart digest shows it forever. +- **No event on a skipped check.** No emission means "not re-checked, previous set + stands"; an empty set means "checked, all clear". Emitting the empty set from a + skip path silently clears findings nobody looked at. +- **`key` is stable across runs and free of counts, timestamps, and ordinals** — + the readback compares key sets to decide whether anything changed. ### Consuming Events @@ -1613,7 +1634,7 @@ watch(eventsPath, (eventType) => { /* read new lines */ }); --- **Last Updated:** 2026-07-11 -**Status:** Production — unified `events.jsonl` log; `EventLogger.hook.ts` is now the single observability writer across PostToolUse/PostToolUseFailure/StopFailure/ConfigChange (count auto-computed by UpdateCounts.ts) +**Status:** Production — unified `STATE/events.jsonl` log written through `hooks/lib/events.ts`, read at SessionStart by `hooks/lib/advisory-readback.ts` and by Pulse's `/api/events/recent`. Separately, `EventLogger.hook.ts` is the single writer of the `MEMORY/OBSERVABILITY/*.jsonl` streams across PostToolUse/PostToolUseFailure/StopFailure/ConfigChange (count auto-computed by UpdateCounts.ts) — a different store, not this log. **Maintainer:** LifeOS System ### Drift & Routing hooks (added 2026-06-10, Fable-prompt upgrades R1/R4) diff --git a/LifeOS/install/LIFEOS/DOCUMENTATION/Memory/MemorySystem.md b/LifeOS/install/LIFEOS/DOCUMENTATION/Memory/MemorySystem.md index af3fb29fff..bb288a85b6 100755 --- a/LifeOS/install/LIFEOS/DOCUMENTATION/Memory/MemorySystem.md +++ b/LifeOS/install/LIFEOS/DOCUMENTATION/Memory/MemorySystem.md @@ -644,7 +644,7 @@ An append-only JSONL file where hooks emit structured, typed events alongside th | PreCompact.hook.ts | PreCompact | stdout (handover context) | | DocIntegrity.hook.ts | SessionEnd | (no MEMORY writes — runs DocCrossRefIntegrity + RebuildArchSummary + MemoryDirIntegrity) | -> **Note:** All hooks listed above also emit typed events to `STATE/events.jsonl` via `appendEvent()`. See [../Hooks/HookSystem.md § Unified Event System](../Hooks/HookSystem.md) for event types and consumer details. +> **Note:** The advisory checkers — `MemoryDirIntegrity`, `DocCrossRefIntegrity` and `KnowledgeConformance` — emit typed events to `STATE/events.jsonl` via `appendEvent()`. The other hooks in this table write their own stores and do not participate in that log. See [../Hooks/HookSystem.md § Unified Event System](../Hooks/HookSystem.md) for event types and consumer details. ## Harvesting & Retrieval Tools diff --git a/LifeOS/install/LIFEOS/DOCUMENTATION/Notifications/NotificationSystem.md b/LifeOS/install/LIFEOS/DOCUMENTATION/Notifications/NotificationSystem.md index 609875658e..7601ae582b 100755 --- a/LifeOS/install/LIFEOS/DOCUMENTATION/Notifications/NotificationSystem.md +++ b/LifeOS/install/LIFEOS/DOCUMENTATION/Notifications/NotificationSystem.md @@ -296,10 +296,12 @@ await sendDiscord("Message", { title: "Title", color: 0x00ff00 }); --- -## Event Log Channel (events.jsonl) +## Event Log Channels +Two append-only JSONL stores. Both are additive — neither replaces any notification channel above, and hooks emit events alongside their existing state writes and notifications. -Events are emitted directly from each hook via `fs.appendFileSync` to `~/.claude/LIFEOS/MEMORY/OBSERVABILITY/*.jsonl` — synchronous, fire-and-forget, no shared transport library. This channel is additive — it does not replace any of the notification channels above, and hooks emit events alongside their existing state writes and notifications. +- **`~/.claude/LIFEOS/MEMORY/OBSERVABILITY/*.jsonl`** — tool activity, tool failures, subagent spawns, config changes. Written by `EventLogger.hook.ts` via `fs.appendFileSync`: synchronous, fire-and-forget, no shared transport library. +- **`~/.claude/LIFEOS/MEMORY/STATE/events.jsonl`** — the unified hook event log, written through the shared emitter `hooks/lib/events.ts` (`appendEvent()` / `emitFindingSet()`). Read back into context at SessionStart by `hooks/lib/advisory-readback.ts` and merged into Pulse's Live Events pane. See `HookSystem.md` § Unified Event System. --- diff --git a/LifeOS/install/LIFEOS/PULSE/Observability/observability.ts b/LifeOS/install/LIFEOS/PULSE/Observability/observability.ts index a7cf16dba4..9e02bd04b6 100644 --- a/LifeOS/install/LIFEOS/PULSE/Observability/observability.ts +++ b/LifeOS/install/LIFEOS/PULSE/Observability/observability.ts @@ -80,6 +80,9 @@ const SUBAGENT_EVENTS_PATH = join(MEMORY_DIR, "OBSERVABILITY", "subagent-events. const VOICE_EVENTS_PATH = join(MEMORY_DIR, "VOICE", "voice-events.jsonl") const TOOL_FAILURES_PATH = join(MEMORY_DIR, "OBSERVABILITY", "tool-failures.jsonl") const TOOL_ACTIVITY_PATH = join(MEMORY_DIR, "OBSERVABILITY", "tool-activity.jsonl") +// The unified hook event log (hooks/lib/events.ts). LiveEvents.tsx's empty state +// has always named this file; this is the constant that makes that true. +const STATE_EVENTS_PATH = join(MEMORY_DIR, "STATE", "events.jsonl") const SETTINGS_PATH = join(HOME, ".claude", "settings.json") const LADDER_DIR = join(HOME, "Projects", "Ladder") @@ -929,8 +932,21 @@ function handleEventsRecentApi(): Response { source: "tool-activity", type: e.event || e.type || "tool_use", })) + // Hook events carry their own dot-separated `type` and a `source` naming the + // emitting handler; both are kept. `finding_count` is surfaced as `message` + // so a row reads usefully in the pane without the full findings array. + const hookEvents = readJsonlTail(STATE_EVENTS_PATH, 50).map((e) => ({ + ...e, + source: e.source || "hook", + type: e.type || "hook", + message: + e.message ?? + (typeof e.finding_count === "number" + ? `${e.finding_count} finding${e.finding_count === 1 ? "" : "s"}` + : undefined), + })) - const all = [...voiceEvents, ...toolFailures, ...subagentEvents, ...toolActivity] + const all = [...voiceEvents, ...toolFailures, ...subagentEvents, ...toolActivity, ...hookEvents] all.sort((a, b) => { const ta = new Date(a.timestamp || 0).getTime() const tb = new Date(b.timestamp || 0).getTime() diff --git a/LifeOS/install/hooks/LoadContext.hook.ts b/LifeOS/install/hooks/LoadContext.hook.ts index f21a9ac81a..c044406864 100755 --- a/LifeOS/install/hooks/LoadContext.hook.ts +++ b/LifeOS/install/hooks/LoadContext.hook.ts @@ -13,6 +13,7 @@ * - Injects dynamic, session-specific context: * - Relationship context (recent opinions + notes) * - Learning readback (signals, wisdom, failure patterns) + * - Advisory readback (last session's doc/memory integrity findings) * - Active work summary (last 48h sessions + tracked projects) * * TRIGGER: SessionStart @@ -43,12 +44,15 @@ import { join } from 'path'; import { getLifeosDir, getSettingsPath } from './lib/paths'; import { recordSessionStart } from './lib/notifications'; import { loadWisdomFrames } from './lib/learning-readback'; +import { loadAdvisoryDigest } from './lib/advisory-readback'; import { findArtifactPath } from './lib/isa-utils'; import { isSubagentContext } from './lib/subagent'; +import { isDesktopChannel, getNotificationChannel } from './lib/notification-channel'; interface DynamicContextConfig { relationshipContext?: boolean; learningReadback?: boolean; + advisoryReadback?: boolean; activeWorkSummary?: boolean; } @@ -394,6 +398,16 @@ async function main() { process.exit(0); } + // Remote-channel sessions (Telegram, iMessage — see lib/notification-channel.ts) + // serve someone through a bot surface. Injecting the principal's relationship + // notes, wisdom frames, advisory findings and active work summary into that + // context is the same class of leak as a desktop /notify from a remote turn. + // One guard here covers every loader below, present and future. + if (!isDesktopChannel()) { + console.error(`📵 Remote channel (${getNotificationChannel()}) - skipping dynamic context injection`); + process.exit(0); + } + const paiDir = getLifeosDir(); // Tab reset is handled by KittyEnvPersist.hook.ts (runs before this hook) @@ -439,11 +453,26 @@ async function main() { console.error('⏭️ Skipped learning readback (disabled)'); } + // Advisory readback: last session's integrity findings, delivered here + // because SessionEnd — where they are produced — cannot inject context. + // Emits only on a changed finding set, plus a slow re-announce; steady + // state is zero characters. + let advisoryContext = ''; + if (isDynamicEnabled(settings, 'advisoryReadback')) { + const digest = loadAdvisoryDigest(); + advisoryContext = digest ? '\n## Advisory Findings\n\n' + digest : ''; + if (digest) { + console.error(`🩺 Loaded advisory digest (${advisoryContext.length} chars)`); + } + } else { + console.error('⏭️ Skipped advisory readback (disabled)'); + } + // Inject dynamic context if we have any - if (relationshipContext || learningContext) { + if (relationshipContext || learningContext || advisoryContext) { const message = ` LifeOS Dynamic Context (Auto-loaded at Session Start) -${relationshipContext ?? ''}${learningContext ? '\n---\n' + learningContext : ''} +${relationshipContext ?? ''}${learningContext ? '\n---\n' + learningContext : ''}${advisoryContext ? '\n---\n' + advisoryContext : ''} --- Dynamic context loaded. Constitutional rules are in the system prompt (LIFEOS/LIFEOS_SYSTEM_PROMPT.md). Operational procedures are in CLAUDE.md. `; diff --git a/LifeOS/install/hooks/README.md b/LifeOS/install/hooks/README.md index 31f159d166..064173796a 100755 --- a/LifeOS/install/hooks/README.md +++ b/LifeOS/install/hooks/README.md @@ -456,8 +456,10 @@ Located in `hooks/lib/`: | `tab-setter.ts` | Kitty + cmux tab title manipulation | All tab-related hooks | | `containment-zones.ts` | Release-pipeline zone inventory | `ShadowRelease.ts` (used at release time, not by runtime hooks) | | `learning-readback.ts` | Read prior failures for context | WorkCompletionLearning | +| `events.ts` | Emit/read `STATE/events.jsonl` (`appendEvent`, `emitFindingSet`) | MemoryDirIntegrity, DocCrossRefIntegrity, advisory-readback | +| `advisory-readback.ts` | SessionStart digest of the current advisory finding set | LoadContext | -> Note: there is no log-rotation lib — observability JSONLs are NOT auto-rotated today. Rotation is queued with the sensor-loop iteration. (The former log-rotation lib here was dead code with zero importers and was removed 2026-06-12.) +> Note: there is no log-rotation lib — observability JSONLs are NOT auto-rotated today. Rotation is queued with the sensor-loop iteration. (The former log-rotation lib here was dead code with zero importers and was removed 2026-06-12.) `STATE/events.jsonl` grows unbounded for the same reason; `events.ts` reads it backwards from EOF in bounded windows, so readers stay O(recent) as it grows. --- diff --git a/LifeOS/install/hooks/handlers/DocCrossRefIntegrity.ts b/LifeOS/install/hooks/handlers/DocCrossRefIntegrity.ts index c36ebf4ac3..f2fdd0af5f 100755 --- a/LifeOS/install/hooks/handlers/DocCrossRefIntegrity.ts +++ b/LifeOS/install/hooks/handlers/DocCrossRefIntegrity.ts @@ -36,13 +36,17 @@ * SIDE EFFECTS: * - Updates timestamps, counts (deterministic) * - Applies surgical text edits (inference-generated) - * - Emits doc.integrity event to events.jsonl + * - Emits a doc.integrity event to events.jsonl on every completed check, via + * hooks/lib/events.ts — the full deterministic finding set, empty set + * included. A check that skipped (no system files modified) emits nothing, + * because an empty set means "checked, all clear", not "did not look". */ import { readFileSync, writeFileSync, existsSync, readdirSync } from 'fs'; import { join, basename } from 'path'; import { paiPath, getLifeosDir, getClaudeDir } from '../lib/paths'; import { getIdentity } from '../lib/identity'; +import { emitFindingSet } from '../lib/events'; import { inference } from '../../LIFEOS/TOOLS/Inference'; import type { ParsedTranscript } from '../../LIFEOS/TOOLS/TranscriptParser'; import { isDesktopChannel, logSkippedVoice, getNotificationChannel } from '../lib/notification-channel'; @@ -720,6 +724,9 @@ export async function handleDocCrossRefIntegrity( const hasAnySystemChange = isSystemFileModified(modifiedFiles); if (!hasAnySystemChange) { + // No event here on purpose: the check did not run, so the previous finding + // set is still the current truth. Emitting an empty set on this path would + // clear real findings out of the SessionStart digest without checking them. console.error(`${TAG} No meaningful system files modified, skipping`); return; } @@ -866,6 +873,23 @@ export async function handleDocCrossRefIntegrity( console.error(`${TAG} Wall time: ${totalElapsed}ms`); console.error(`${TAG} === Check complete ===`); + // The emission this handler's docstring has claimed since v5.0.0. Summary + // level: which doc, which check, what is wrong — never doc body text. + emitFindingSet({ + type: 'doc.integrity', + source: 'DocCrossRefIntegrity', + findings: allDrift.map((item) => ({ + kind: item.pattern, + key: `${item.doc}:${item.pattern}:${item.reference}`, + detail: `${item.doc}: ${item.issue}`, + })), + extra: { + docs_checked: docsToCheck.length, + updates_applied: updatesApplied.length, + duration_ms: totalElapsed, + }, + }); + // Step 10: Voice notification — ONLY when actual documentation edits were applied // No voice for "queued for review" or "in sync" — that's noise if (updatesApplied.length > 0) { diff --git a/LifeOS/install/hooks/handlers/MemoryDirIntegrity.ts b/LifeOS/install/hooks/handlers/MemoryDirIntegrity.ts index f7cf252414..517d090d70 100755 --- a/LifeOS/install/hooks/handlers/MemoryDirIntegrity.ts +++ b/LifeOS/install/hooks/handlers/MemoryDirIntegrity.ts @@ -19,31 +19,23 @@ * * WRITES: * stderr (audit log with [MemoryDirIntegrity] tag) - * STATE/events.jsonl (typed event: doc.integrity.memory_dir) + * STATE/events.jsonl (typed event: doc.integrity.memory_dir, via hooks/lib/events.ts) + * Full finding set on every completed check, empty set included — that is what + * lets the SessionStart readback show a fixed problem disappearing. * * SIDE EFFECTS: * None — read-only check. Drift is a soft warning. The hook never blocks. */ -import { readFileSync, readdirSync, existsSync, statSync, appendFileSync, mkdirSync } from 'fs'; +import { readFileSync, readdirSync, existsSync, statSync } from 'fs'; import { join } from 'path'; import { paiPath, getLifeosDir } from '../lib/paths'; +import { emitFindingSet } from '../lib/events'; const TAG = '[MemoryDirIntegrity]'; const LIFEOS_DIR = getLifeosDir(); const MEMORY_DIR = join(LIFEOS_DIR, 'MEMORY'); const INVENTORY_DOC = paiPath('DOCUMENTATION/Memory/MemorySystem.md'); -const EVENTS_FILE = join(MEMORY_DIR, 'STATE', 'events.jsonl'); - -function emitEvent(payload: Record): void { - try { - mkdirSync(join(MEMORY_DIR, 'STATE'), { recursive: true }); - const event = { timestamp: new Date().toISOString(), ...payload }; - appendFileSync(EVENTS_FILE, JSON.stringify(event) + '\n', 'utf-8'); - } catch { - // Event log is best-effort — never let drift checking fail because of telemetry. - } -} // Directories that exist on disk but are not subsystems and should be ignored. const IGNORED_NAMES = new Set(['.DS_Store', '.git', 'node_modules']); @@ -57,8 +49,14 @@ interface InventoryRow { status: string; // "active" | "reserved" } +/** + * One drift finding. `key` is its identity across runs — the readback compares + * key sets to decide whether anything changed, so it holds the subsystem name + * and nothing that churns (no counts, no timestamps). + */ interface DriftItem { kind: 'unknown_on_disk' | 'missing_active' | 'inventory_unparseable'; + key: string; detail: string; } @@ -144,25 +142,28 @@ export async function handleMemoryDirIntegrity(): Promise { if (inventory === null) { const drift: DriftItem = { kind: 'inventory_unparseable', + key: 'inventory_unparseable:doc', detail: `Failed to parse Directory Inventory from ${INVENTORY_DOC}. Drift check skipped.`, }; console.error(`${TAG} [WARN] ${drift.detail}`); - emitEvent({ + emitFindingSet({ type: 'doc.integrity.memory_dir', source: 'MemoryDirIntegrity', - drift: [drift], - ok: false, + findings: [drift], }); return; } if (inventory.length === 0) { console.error(`${TAG} [WARN] Inventory table parsed but contains zero rows. Check the table format in MemorySystem.md.`); - emitEvent({ + emitFindingSet({ type: 'doc.integrity.memory_dir', source: 'MemoryDirIntegrity', - drift: [{ kind: 'inventory_unparseable', detail: 'Inventory parsed with zero rows' }], - ok: false, + findings: [{ + kind: 'inventory_unparseable', + key: 'inventory_unparseable:zero_rows', + detail: 'Inventory parsed with zero rows', + }], }); return; } @@ -187,6 +188,7 @@ export async function handleMemoryDirIntegrity(): Promise { if (!inventoryByName.has(dir)) { drift.push({ kind: 'unknown_on_disk', + key: `unknown_on_disk:${dir}`, detail: `MEMORY/${dir}/ exists but is not listed in MemorySystem.md Directory Inventory. Either add a row or remove the directory.`, }); } @@ -199,6 +201,7 @@ export async function handleMemoryDirIntegrity(): Promise { if (row.status === 'active' && !onDiskSet.has(row.name)) { drift.push({ kind: 'missing_active', + key: `missing_active:${row.name}`, detail: `Inventory lists MEMORY/${row.name}/ as ${row.status} but directory does not exist on disk. Either create it or change the row's status to reserved/on-demand.`, }); } @@ -214,14 +217,16 @@ export async function handleMemoryDirIntegrity(): Promise { } } - emitEvent({ + // Emitted on every completed check, empty set included — see the emission + // contract in hooks/lib/events.ts. Silence here would mean "not checked". + emitFindingSet({ type: 'doc.integrity.memory_dir', source: 'MemoryDirIntegrity', - on_disk_count: onDisk.length, - inventory_count: inventory.length, - drift_count: drift.length, - drift, - ok: drift.length === 0, + findings: drift, + extra: { + on_disk_count: onDisk.length, + inventory_count: inventory.length, + }, }); const elapsed = Date.now() - startTime; diff --git a/LifeOS/install/hooks/lib/advisory-readback.test.ts b/LifeOS/install/hooks/lib/advisory-readback.test.ts new file mode 100644 index 0000000000..49df9b39ad --- /dev/null +++ b/LifeOS/install/hooks/lib/advisory-readback.test.ts @@ -0,0 +1,151 @@ +/** + * advisory-readback.test.ts — the digest emission policy. + * + * Run: bun test LifeOS/install/hooks/lib/advisory-readback.test.ts + */ + +import { describe, expect, test } from 'bun:test'; +import { + decideDigest, + renderDigest, + collectFindings, + findingId, + EMPTY_MARKER, + RE_EMIT_AFTER_SESSIONS, + type AdvisoryMarker, + type TypedFinding, +} from './advisory-readback'; +import type { EventRecord } from './events'; + +function f(type: string, key: string, detail = key): TypedFinding { + return { type, key, detail }; +} + +function marker(over: Partial = {}): AdvisoryMarker { + return { ...EMPTY_MARKER, ...over }; +} + +describe('collectFindings', () => { + test('flattens the latest event of each type, tagging findings with their type', () => { + const latest = new Map([ + ['doc.integrity.memory_dir', { type: 'doc.integrity.memory_dir', findings: [{ key: 'missing:WISDOM', detail: 'WISDOM missing' }] }], + ['doc.integrity', { type: 'doc.integrity', findings: [{ key: 'a', detail: 'A' }, { key: 'b', detail: 'B' }] }], + ]); + const out = collectFindings(latest); + expect(out.map((x) => x.type)).toEqual(['doc.integrity.memory_dir', 'doc.integrity', 'doc.integrity']); + expect(findingId(out[0])).toBe('doc.integrity.memory_dir missing:WISDOM'); + }); + + test('a type absent from the log contributes nothing', () => { + expect(collectFindings(new Map())).toEqual([]); + }); +}); + +describe('decideDigest', () => { + test('first findings ever: emits and records the key set', () => { + const d = decideDigest([f('t', 'a')], marker()); + expect(d.emit).toBe(true); + expect(d.marker.keys).toEqual(['t a']); + expect(d.marker.sessions_since_emit).toBe(0); + }); + + test('unchanged set on the next session: silent', () => { + const first = decideDigest([f('t', 'a')], marker()); + const second = decideDigest([f('t', 'a')], first.marker); + expect(second.emit).toBe(false); + expect(second.marker.sessions_since_emit).toBe(1); + }); + + test('a new finding appearing is a change: emits', () => { + const first = decideDigest([f('t', 'a')], marker()); + const second = decideDigest([f('t', 'a'), f('t', 'b')], first.marker); + expect(second.emit).toBe(true); + expect(second.marker.keys).toEqual(['t a', 't b']); + }); + + test('order of findings does not count as a change', () => { + const first = decideDigest([f('t', 'a'), f('t', 'b')], marker()); + const second = decideDigest([f('t', 'b'), f('t', 'a')], first.marker); + expect(second.emit).toBe(false); + }); + + test('the same key from two different types stays two findings', () => { + const d = decideDigest([f('t1', 'a'), f('t2', 'a')], marker()); + expect(d.marker.keys).toEqual(['t1 a', 't2 a']); + }); + + test('a nonempty unchanged set re-announces after the slow-re-emission window', () => { + let m = decideDigest([f('t', 'a')], marker()).marker; + const emissions: boolean[] = []; + for (let i = 0; i < RE_EMIT_AFTER_SESSIONS; i++) { + const d = decideDigest([f('t', 'a')], m); + emissions.push(d.emit); + m = d.marker; + } + // Quiet for the whole window, then exactly one re-announcement. + expect(emissions.slice(0, RE_EMIT_AFTER_SESSIONS - 1).every((e) => e === false)).toBe(true); + expect(emissions[RE_EMIT_AFTER_SESSIONS - 1]).toBe(true); + expect(m.sessions_since_emit).toBe(0); + }); + + test('an empty set is silent and clears the marker, so the next finding reads as new', () => { + const withFinding = decideDigest([f('t', 'a')], marker()); + const cleared = decideDigest([], withFinding.marker); + expect(cleared.emit).toBe(false); + expect(cleared.marker.keys).toEqual([]); + const returns = decideDigest([f('t', 'a')], cleared.marker); + expect(returns.emit).toBe(true); + }); + + test('an empty set never re-announces, however long it stays empty', () => { + let m = marker(); + for (let i = 0; i < RE_EMIT_AFTER_SESSIONS * 3; i++) { + const d = decideDigest([], m); + expect(d.emit).toBe(false); + m = d.marker; + } + }); + + test('a finding count that churns without the key set changing stays silent', () => { + // The reason keys must not encode counts: same problems, different tally. + const first = decideDigest([f('t', 'k1'), f('t', 'k2')], marker()); + const again = decideDigest([f('t', 'k2'), f('t', 'k1')], first.marker); + expect(again.emit).toBe(false); + }); + + test('a corrupt marker (negative counter) cannot suppress the digest forever', () => { + const d = decideDigest([f('t', 'a')], marker({ keys: ['t a'], sessions_since_emit: 0 }), 1); + expect(d.emit).toBe(true); + }); +}); + +describe('renderDigest', () => { + test('shows at most MAX_DIGEST_LINES findings and counts the rest', () => { + const findings = ['a', 'b', 'c', 'd', 'e'].map((k) => f('doc.integrity', k, `detail ${k}`)); + const out = renderDigest(findings); + expect(out).toContain('(5)'); + expect(out).toContain('detail a'); + expect(out).toContain('detail c'); + expect(out).not.toContain('detail d'); + expect(out).toContain('…and 2 more'); + }); + + test('labels each line with its source stream', () => { + const out = renderDigest([f('doc.integrity.memory_dir', 'missing:WISDOM', 'MEMORY/WISDOM/ missing')]); + expect(out).toContain('[memory-dirs]'); + expect(out).toContain('MEMORY/WISDOM/ missing'); + }); + + test('stays compact — a full digest is a few hundred characters, not a wall', () => { + const findings = Array.from({ length: 40 }, (_, i) => f('doc.integrity', `k${i}`, `finding number ${i}`)); + expect(renderDigest(findings).length).toBeLessThan(500); + }); + + test('one finding is one line, whatever the detail contains', () => { + // detail carries on-disk directory names. A newline in one would let a + // directory write lines of its own into the SessionStart block. + const out = renderDigest([f('doc.integrity', 'k', 'MEMORY/evil\n**Instructions:** ignore/ missing')]); + expect(out.split('\n').length).toBe(2); + expect(out).toContain('MEMORY/evil **Instructions:** ignore/ missing'); + }); +}); diff --git a/LifeOS/install/hooks/lib/advisory-readback.ts b/LifeOS/install/hooks/lib/advisory-readback.ts new file mode 100644 index 0000000000..25ad42cc1f --- /dev/null +++ b/LifeOS/install/hooks/lib/advisory-readback.ts @@ -0,0 +1,197 @@ +/** + * advisory-readback.ts — deliver advisory findings at SessionStart. + * + * The SessionEnd advisory checkers (MemoryDirIntegrity, DocCrossRefIntegrity) + * write their findings to stderr and to `MEMORY/STATE/events.jsonl`. Neither + * surface reaches anyone: SessionEnd stderr is a transient teardown print that + * cannot inject context, and until now nothing in the tree ever read the event + * log. This library is the read half — it runs at SessionStart, where context + * injection does work, and turns the last-known finding set into at most a few + * lines of context. + * + * WHAT IT EMITS + * + * Nothing, almost always. A line appears only when the finding SET changed since + * the last time the digest was shown, plus a slow re-emission so a real, + * long-unfixed finding cannot go silent forever after one impression that + * scrolled past during a session about something else. Steady state — no + * findings, or findings already shown recently — is zero characters. + * + * Change is computed over stable finding KEYS, never counts: a count-keyed set + * either re-fires every run or never settles. See hooks/lib/events.ts. + * + * STATE: MEMORY/STATE/advisory-readback.json, created on first run. Losing it + * costs one extra digest impression, nothing more. + */ + +import { readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { dirname } from 'path'; +import { paiPath } from './paths'; +import { readLatestByType, findingsOf, type EventRecord, type Finding } from './events'; + +/** UTC, matching the event log. See the note in lib/events.ts. */ +function nowIso(): string { + return new Date().toISOString(); +} + +/** + * Event types the digest reduces. Every registered type is scanned for + * independently, so a quiet checker's last emission is never evicted by a + * chatty one's. + */ +export const ADVISORY_EVENT_TYPES = ['doc.integrity.memory_dir', 'doc.integrity'] as const; + +/** Sessions a nonempty, unchanged finding set stays quiet before re-announcing. */ +export const RE_EMIT_AFTER_SESSIONS = 20; + +/** Findings shown in full before the digest collapses to a count. */ +export const MAX_DIGEST_LINES = 3; + +/** Short label per event type, so a digest line says where it came from. */ +const TYPE_LABELS: Record = { + 'doc.integrity.memory_dir': 'memory-dirs', + 'doc.integrity': 'doc-refs', +}; + +export interface AdvisoryMarker { + v: 1; + /** Sorted finding identities as of the last digest impression. */ + keys: string[]; + /** SessionStarts since the last impression, for the slow re-emission. */ + sessions_since_emit: number; + last_emitted_at: string | null; +} + +export interface TypedFinding extends Finding { + type: string; +} + +export const EMPTY_MARKER: AdvisoryMarker = { + v: 1, + keys: [], + sessions_since_emit: 0, + last_emitted_at: null, +}; + +export function markerPath(): string { + return paiPath('MEMORY', 'STATE', 'advisory-readback.json'); +} + +/** Identity of a finding across the whole digest: type + the emitter's key. */ +export function findingId(f: TypedFinding): string { + return `${f.type} ${f.key}`; +} + +/** Flatten the per-type latest events into one finding list, order stable. */ +export function collectFindings( + latest: Map, + types: readonly string[] = ADVISORY_EVENT_TYPES, +): TypedFinding[] { + const out: TypedFinding[] = []; + for (const type of types) { + for (const f of findingsOf(latest.get(type))) { + out.push({ ...f, type }); + } + } + return out; +} + +/** + * Decide whether to show the digest, and what the marker becomes. + * Pure — the whole emission policy, independent of disk. + */ +export function decideDigest( + findings: TypedFinding[], + marker: AdvisoryMarker, + reEmitAfterSessions: number = RE_EMIT_AFTER_SESSIONS, +): { emit: boolean; marker: AdvisoryMarker } { + const keys = Array.from(new Set(findings.map(findingId))).sort(); + + // Nothing outstanding: say nothing, but record the cleared set so the next + // real finding reads as a change rather than as "same as last time". + if (keys.length === 0) { + return { + emit: false, + marker: { ...marker, v: 1, keys: [], sessions_since_emit: 0 }, + }; + } + + const changed = + keys.length !== marker.keys.length || keys.some((k, i) => k !== marker.keys[i]); + + if (changed) { + return { + emit: true, + marker: { v: 1, keys, sessions_since_emit: 0, last_emitted_at: nowIso() }, + }; + } + + const elapsed = marker.sessions_since_emit + 1; + if (elapsed >= reEmitAfterSessions) { + return { + emit: true, + marker: { v: 1, keys, sessions_since_emit: 0, last_emitted_at: nowIso() }, + }; + } + + return { emit: false, marker: { ...marker, v: 1, keys, sessions_since_emit: elapsed } }; +} + +/** Render the digest. Pure; caller decides whether it is shown. */ +export function renderDigest(findings: TypedFinding[], maxLines: number = MAX_DIGEST_LINES): string { + const lines = findings + .slice(0, maxLines) + // One finding is one line. `detail` carries on-disk directory names, so a + // newline in one would let a directory shape the surrounding SessionStart + // text rather than appear inside it. + .map((f) => ` [${TYPE_LABELS[f.type] ?? f.type}] ${String(f.detail).replace(/\s+/g, ' ').trim()}`); + const remainder = findings.length - lines.length; + if (remainder > 0) { + lines.push(` …and ${remainder} more — see MEMORY/STATE/events.jsonl`); + } + return `**Advisory findings from the last session (${findings.length}):**\n${lines.join('\n')}`; +} + +export function readMarker(path: string = markerPath()): AdvisoryMarker { + try { + const parsed = JSON.parse(readFileSync(path, 'utf-8')); + if (!parsed || typeof parsed !== 'object') return { ...EMPTY_MARKER }; + return { + v: 1, + keys: Array.isArray(parsed.keys) ? parsed.keys.filter((k: unknown) => typeof k === 'string') : [], + sessions_since_emit: + typeof parsed.sessions_since_emit === 'number' && parsed.sessions_since_emit >= 0 + ? parsed.sessions_since_emit + : 0, + last_emitted_at: typeof parsed.last_emitted_at === 'string' ? parsed.last_emitted_at : null, + }; + } catch { + return { ...EMPTY_MARKER }; + } +} + +export function writeMarker(marker: AdvisoryMarker, path: string = markerPath()): void { + try { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(marker, null, 2)); + } catch { + // A marker we cannot persist costs one extra impression, never correctness. + } +} + +/** + * The SessionStart entry point: read the log, decide, persist, render. + * Returns null when there is nothing to show — the common case. + */ +export function loadAdvisoryDigest(opts: { eventsPath?: string; markerPath?: string } = {}): string | null { + try { + const latest = readLatestByType([...ADVISORY_EVENT_TYPES], { path: opts.eventsPath }); + const findings = collectFindings(latest); + const marker = readMarker(opts.markerPath ?? markerPath()); + const decision = decideDigest(findings, marker); + writeMarker(decision.marker, opts.markerPath ?? markerPath()); + return decision.emit ? renderDigest(findings) : null; + } catch { + return null; + } +} diff --git a/LifeOS/install/hooks/lib/events.test.ts b/LifeOS/install/hooks/lib/events.test.ts new file mode 100644 index 0000000000..365c78e687 --- /dev/null +++ b/LifeOS/install/hooks/lib/events.test.ts @@ -0,0 +1,182 @@ +/** + * events.test.ts — the emission contract and the backwards reader. + * + * Run: bun test LifeOS/install/hooks/lib/events.test.ts + */ + +import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, readFileSync, writeFileSync, appendFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { + appendEvent, + emitFindingSet, + reduceLatestByType, + readLatestByType, + findingsOf, + type EventRecord, +} from './events'; + +let dir: string; +let log: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'lifeos-events-')); + log = join(dir, 'nested', 'events.jsonl'); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +function lines(): EventRecord[] { + return readFileSync(log, 'utf-8') + .split('\n') + .filter(Boolean) + .map((l) => JSON.parse(l)); +} + +describe('appendEvent', () => { + test('creates the directory, appends one JSON line, injects timestamp + session_id', () => { + const prev = process.env.CLAUDE_SESSION_ID; + process.env.CLAUDE_SESSION_ID = 'sess-1'; + try { + appendEvent({ type: 'custom.a', source: 'T' }, log); + appendEvent({ type: 'custom.b', source: 'T' }, log); + } finally { + if (prev === undefined) delete process.env.CLAUDE_SESSION_ID; + else process.env.CLAUDE_SESSION_ID = prev; + } + const recs = lines(); + expect(recs.length).toBe(2); + expect(recs[0].type).toBe('custom.a'); + expect(recs[0].session_id).toBe('sess-1'); + expect(typeof recs[0].timestamp).toBe('string'); + }); + + test('never throws when the path is unwritable', () => { + const unwritable = join(dir, 'a-file'); + writeFileSync(unwritable, 'x'); + expect(() => appendEvent({ type: 'custom.a', source: 'T' }, join(unwritable, 'events.jsonl'))).not.toThrow(); + }); +}); + +describe('emitFindingSet — the emission contract', () => { + test('an empty finding set is still emitted, flagged ok', () => { + emitFindingSet({ type: 'doc.integrity', source: 'T', findings: [] }, log); + const [rec] = lines(); + expect(rec.ok).toBe(true); + expect(rec.finding_count).toBe(0); + expect(rec.findings).toEqual([]); + }); + + test('a nonempty set is flagged not-ok and carries every finding', () => { + emitFindingSet( + { + type: 'doc.integrity', + source: 'T', + findings: [ + { key: 'a', detail: 'A broke' }, + { key: 'b', kind: 'refs', detail: 'B broke' }, + ], + extra: { docs_checked: 7 }, + }, + log, + ); + const [rec] = lines(); + expect(rec.ok).toBe(false); + expect(rec.finding_count).toBe(2); + expect(rec.docs_checked).toBe(7); + expect((rec.findings as unknown[]).length).toBe(2); + }); + + test('a set that clears is observable: the later empty emission wins the reduction', () => { + emitFindingSet({ type: 'doc.integrity', source: 'T', findings: [{ key: 'a', detail: 'A broke' }] }, log); + emitFindingSet({ type: 'doc.integrity', source: 'T', findings: [] }, log); + const latest = readLatestByType(['doc.integrity'], { path: log }); + expect(findingsOf(latest.get('doc.integrity'))).toEqual([]); + }); +}); + +describe('reduceLatestByType', () => { + test('last emission per type wins; untracked types and corrupt lines are ignored', () => { + const map = reduceLatestByType( + [ + JSON.stringify({ type: 'x', n: 1 }), + 'not json', + JSON.stringify({ type: 'y', n: 2 }), + JSON.stringify({ type: 'ignored', n: 99 }), + '', + JSON.stringify({ type: 'x', n: 3 }), + ], + ['x', 'y'], + ); + expect(map.get('x')?.n).toBe(3); + expect(map.get('y')?.n).toBe(2); + expect(map.has('ignored')).toBe(false); + }); +}); + +describe('readLatestByType', () => { + test('returns an empty map for a missing log', () => { + expect(readLatestByType(['x'], { path: join(dir, 'nope.jsonl') }).size).toBe(0); + }); + + test('a chatty emitter cannot evict a quiet one — the window grows per type', () => { + // The quiet type is emitted once, then buried under padding far larger than + // one read window. A fixed-size tail would drop it; per-type coverage cannot. + appendEvent({ type: 'quiet', source: 'Q', marker: 'the-only-one' }, log); + for (let i = 0; i < 400; i++) { + appendEvent({ type: 'chatty', source: 'C', i, pad: 'x'.repeat(200) }, log); + } + const latest = readLatestByType(['quiet', 'chatty'], { path: log, chunkBytes: 1024 }); + expect(latest.get('quiet')?.marker).toBe('the-only-one'); + expect(latest.get('chatty')?.i).toBe(399); + }); + + test('stops early once every requested type is found', () => { + for (let i = 0; i < 200; i++) appendEvent({ type: 'old', source: 'O', i }, log); + appendEvent({ type: 'a', source: 'A', v: 1 }, log); + appendEvent({ type: 'b', source: 'B', v: 2 }, log); + const latest = readLatestByType(['a', 'b'], { path: log, chunkBytes: 64 }); + expect(latest.get('a')?.v).toBe(1); + expect(latest.get('b')?.v).toBe(2); + }); + + test('multi-byte characters spanning a read-window boundary survive', () => { + // Non-ASCII payloads at many lengths, so at least one code point lands + // across a window edge for a small chunk size. + for (let i = 0; i < 60; i++) { + appendEvent({ type: 'utf', source: 'U', i, pad: 'é☃🌍'.repeat(i + 1) }, log); + } + const latest = readLatestByType(['utf'], { path: log, chunkBytes: 97 }); + expect(latest.get('utf')?.i).toBe(59); + expect(latest.get('utf')?.pad).toBe('é☃🌍'.repeat(60)); + }); + + test('a torn trailing line is skipped, the record before it still readable', () => { + appendEvent({ type: 'x', source: 'T', n: 1 }, log); + appendFileSync(log, '{"type":"x","n":2'); // crash mid-append, no newline + expect(readLatestByType(['x'], { path: log }).get('x')?.n).toBe(1); + }); + + test('honours maxBytes rather than scanning an unbounded log', () => { + appendEvent({ type: 'ancient', source: 'A' }, log); + for (let i = 0; i < 200; i++) appendEvent({ type: 'recent', source: 'R', i, pad: 'y'.repeat(100) }, log); + const latest = readLatestByType(['ancient', 'recent'], { path: log, chunkBytes: 512, maxBytes: 2048 }); + expect(latest.has('ancient')).toBe(false); + expect(latest.get('recent')?.i).toBe(199); + }); +}); + +describe('findingsOf', () => { + test('drops entries without a usable key or detail rather than inventing identity', () => { + const rec = { type: 't', findings: [{ key: 'k', detail: 'd' }, { key: '', detail: 'd' }, { detail: 'd' }, 7] }; + expect(findingsOf(rec as EventRecord)).toEqual([{ key: 'k', detail: 'd' }]); + }); + + test('tolerates events with no findings array at all', () => { + expect(findingsOf({ type: 't' })).toEqual([]); + expect(findingsOf(undefined)).toEqual([]); + }); +}); diff --git a/LifeOS/install/hooks/lib/events.ts b/LifeOS/install/hooks/lib/events.ts new file mode 100644 index 0000000000..a168808687 --- /dev/null +++ b/LifeOS/install/hooks/lib/events.ts @@ -0,0 +1,275 @@ +/** + * events.ts — shared emitter and reader for the unified event log. + * + * HookSystem.md § Unified Event System has documented `appendEvent()` as the + * hook-facing emitter since v5.0.0, but no such function ever shipped: every + * writer of `MEMORY/STATE/events.jsonl` inlined its own `appendFileSync`. This + * library is that documented API, plus the reader half the log never had. + * + * THE EMISSION CONTRACT (normative — read this before adding an emitter) + * + * A checker that reports a set of findings emits ONE event per COMPLETED check, + * carrying its FULL current finding set — including the empty set, flagged + * `ok: true`. Not "on change", not "only when something is wrong". + * + * emitted with findings: [] → "I checked; nothing is wrong." + * no emission at all → "I did not check; the previous set stands." + * + * Both statements are load-bearing for the SessionStart readback, which reduces + * the log to the LAST event per type. An emitter that only speaks up when it has + * findings can never announce that a finding was fixed, so the readback shows a + * resolved finding forever. An emitter that emits an empty set from a code path + * that skipped the scan claims a clean bill of health it never checked — worse, + * because it silently clears real findings. Emit on completion, never on skip. + * + * FINDING IDENTITY + * + * Every finding carries a `key` that is stable across runs for the same + * underlying problem, and encodes NOTHING that churns — no counts, no + * timestamps, no ordinals. The readback compares finding-key SETS to decide + * whether anything changed; a key containing a count re-fires the digest on + * every run, and a key containing a timestamp never settles. + * + * READS/WRITES: LIFEOS/MEMORY/STATE/events.jsonl (append-only, never rotated — + * see hooks/README.md). The reader therefore scans backwards from EOF in bounded + * windows and stops as soon as every requested type has been seen, so log growth + * costs the SessionStart path nothing. + * + * FAILURE MODE: every function here is best-effort and never throws. Events are + * observability, not the critical path. + */ + +import { appendFileSync, mkdirSync, openSync, readSync, fstatSync, closeSync } from 'fs'; +import { dirname } from 'path'; +import { paiPath } from './paths'; + +// UTC via Date, not lib/time.ts: `timestamp` has been UTC in every event ever +// written to this log, and lib/time.ts's getISOTimestamp() is local-with-offset — +// adopting it would silently change the wire format. It also drags identity.ts +// and a YAML parse onto the emit path, which sits inside every hook run. +function eventTimestamp(): string { + return new Date().toISOString(); +} + +/** Base shape every event shares. `type` is the dot-separated topic. */ +export interface BaseEvent { + type: string; + source: string; + [key: string]: unknown; +} + +/** One reported problem. `key` is its identity; `detail` is human-facing text. */ +export interface Finding { + /** + * Stable identity of this finding within (type, source). Same problem across + * runs ⇒ same key. MUST NOT contain counts, timestamps, or positions. + */ + key: string; + /** Optional finding class, e.g. 'missing_active'. Free-form per emitter. */ + kind?: string; + /** Human-readable description, shown in the SessionStart digest. */ + detail: string; +} + +/** An event carrying a complete finding set. See THE EMISSION CONTRACT above. */ +export interface FindingSetEvent extends BaseEvent { + ok: boolean; + finding_count: number; + findings: Finding[]; +} + +/** A parsed line from the log. Unknown fields are preserved. */ +export interface EventRecord { + timestamp?: string; + session_id?: string; + type?: string; + source?: string; + [key: string]: unknown; +} + +/** Stop scanning backwards past this many bytes, however large the log grows. */ +export const DEFAULT_MAX_SCAN_BYTES = 8 * 1024 * 1024; +/** Backwards read window. Sized so a typical log needs exactly one read. */ +export const DEFAULT_CHUNK_BYTES = 256 * 1024; + +export function eventsPath(): string { + return paiPath('MEMORY', 'STATE', 'events.jsonl'); +} + +/** + * Append one event. `timestamp` and `session_id` are injected; an event may + * override either by supplying its own. Never throws. + */ +export function appendEvent(event: BaseEvent, path: string = eventsPath()): void { + try { + mkdirSync(dirname(path), { recursive: true }); + const record: EventRecord = { + timestamp: eventTimestamp(), + session_id: process.env.CLAUDE_SESSION_ID || 'unknown', + ...event, + }; + appendFileSync(path, JSON.stringify(record) + '\n', 'utf-8'); + } catch { + // Observability must never break the hook that emitted. + } +} + +/** + * Emit a completed check's full finding set, per THE EMISSION CONTRACT. + * Call this exactly once per completed check — including when `findings` is + * empty, which is how a fixed problem leaves the readback digest. + */ +export function emitFindingSet( + opts: { + type: string; + source: string; + findings: Finding[]; + /** Extra context fields merged into the event (counts, durations, …). */ + extra?: Record; + }, + path: string = eventsPath(), +): void { + const event: FindingSetEvent = { + ...(opts.extra ?? {}), + type: opts.type, + source: opts.source, + ok: opts.findings.length === 0, + finding_count: opts.findings.length, + findings: opts.findings, + }; + appendEvent(event, path); +} + +/** + * Reduce log lines (in file order) to the latest event per requested type. + * Pure — the IO-free core of `readLatestByType`, and the unit under test. + */ +export function reduceLatestByType(lines: string[], types: string[]): Map { + const wanted = new Set(types); + const out = new Map(); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + let rec: EventRecord; + try { + rec = JSON.parse(trimmed); + } catch { + continue; // A torn or corrupt line is skipped, never fatal. + } + if (!rec || typeof rec !== 'object') continue; + const type = typeof rec.type === 'string' ? rec.type : ''; + if (!type || !wanted.has(type)) continue; + out.set(type, rec); // Later wins: file order is authoritative. + } + return out; +} + +/** + * Read the latest event for each requested type, scanning backwards from EOF. + * + * The window is per-type-complete, not fixed-size: it keeps growing until every + * requested type has been seen, the start of the file is reached, or `maxBytes` + * is consumed. A fixed tail would let one chatty emitter evict a quiet one's + * last emission, silently dropping the quiet checker's findings from the digest. + * + * `maxBytes` bounds that guarantee rather than removing it: a quiet type whose + * last emission sits further back than `maxBytes` in an append-only log is not + * found. At the 8 MiB default that is roughly a hundred thousand events ago, + * and the cost of the miss is one absent advisory line, not a wrong one. + * + * Never throws; returns an empty map when the log is absent or unreadable. + */ +export function readLatestByType( + types: string[], + opts: { path?: string; maxBytes?: number; chunkBytes?: number } = {}, +): Map { + const path = opts.path ?? eventsPath(); + const maxBytes = opts.maxBytes ?? DEFAULT_MAX_SCAN_BYTES; + const chunkBytes = opts.chunkBytes ?? DEFAULT_CHUNK_BYTES; + const wanted = new Set(types); + const out = new Map(); + if (wanted.size === 0) return out; + + let fd: number | null = null; + try { + fd = openSync(path, 'r'); + let end = fstatSync(fd).size; + let scanned = 0; + // Bytes of the partial first line of the window just scanned. Carried as + // BYTES, not text: a multi-byte code point straddling a window boundary + // would decode to a replacement char if each half were decoded alone. + let carry = Buffer.alloc(0); + + while (end > 0 && out.size < wanted.size && scanned < maxBytes) { + const want = Math.min(chunkBytes, end, maxBytes - scanned); + const start = end - want; + const buf = Buffer.allocUnsafe(want); + readSync(fd, buf, 0, want, start); + scanned += want; + + const windowBuf = carry.length > 0 ? Buffer.concat([buf, carry]) : buf; + const lineBufs: Buffer[] = []; + let sliceStart = 0; + for (let i = 0; i < windowBuf.length; i++) { + if (windowBuf[i] === 0x0a) { + lineBufs.push(windowBuf.subarray(sliceStart, i)); + sliceStart = i + 1; + } + } + lineBufs.push(windowBuf.subarray(sliceStart)); + + // Below byte 0 there is more of this line; hand it to the next window. + carry = start > 0 ? (lineBufs.shift() ?? Buffer.alloc(0)) : Buffer.alloc(0); + + for (let i = lineBufs.length - 1; i >= 0; i--) { + const line = lineBufs[i].toString('utf-8').trim(); + if (!line) continue; + let rec: EventRecord; + try { + rec = JSON.parse(line); + } catch { + continue; + } + if (!rec || typeof rec !== 'object') continue; + const type = typeof rec.type === 'string' ? rec.type : ''; + if (!type || !wanted.has(type) || out.has(type)) continue; + out.set(type, rec); // Scanning backwards: first seen is the latest. + if (out.size === wanted.size) break; + } + + end = start; + } + } catch { + // Missing or unreadable log — the readback simply has nothing to say. + } finally { + if (fd !== null) { + try { + closeSync(fd); + } catch { + /* ignore */ + } + } + } + return out; +} + +/** + * Extract the findings of an event, tolerating a non-conforming payload. + * Anything without a usable `key` and `detail` is dropped rather than shown + * under a fabricated identity. + */ +export function findingsOf(record: EventRecord | undefined): Finding[] { + if (!record || !Array.isArray(record.findings)) return []; + const out: Finding[] = []; + for (const raw of record.findings as unknown[]) { + if (!raw || typeof raw !== 'object') continue; + const f = raw as Record; + const key = typeof f.key === 'string' ? f.key : ''; + const detail = typeof f.detail === 'string' ? f.detail : ''; + if (!key || !detail) continue; + const finding: Finding = { key, detail }; + if (typeof f.kind === 'string') finding.kind = f.kind; + out.push(finding); + } + return out; +} diff --git a/LifeOS/install/settings.system.json b/LifeOS/install/settings.system.json index 9f5b8da2d1..9b16280074 100644 --- a/LifeOS/install/settings.system.json +++ b/LifeOS/install/settings.system.json @@ -367,6 +367,7 @@ "_docs": "Dynamic context injected by LoadContext.hook.ts. Override in LIFEOS_CONFIG.yaml to disable.", "relationshipContext": true, "learningReadback": true, + "advisoryReadback": true, "activeWorkSummary": true }, "preferences": { diff --git a/LifeOS/install/skills/LifeOS/install/settings.system.json b/LifeOS/install/skills/LifeOS/install/settings.system.json index 9f5b8da2d1..9b16280074 100644 --- a/LifeOS/install/skills/LifeOS/install/settings.system.json +++ b/LifeOS/install/skills/LifeOS/install/settings.system.json @@ -367,6 +367,7 @@ "_docs": "Dynamic context injected by LoadContext.hook.ts. Override in LIFEOS_CONFIG.yaml to disable.", "relationshipContext": true, "learningReadback": true, + "advisoryReadback": true, "activeWorkSummary": true }, "preferences": {