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": {