Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 37 additions & 16 deletions LifeOS/install/LIFEOS/DOCUMENTATION/Hooks/HookSystem.md
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion LifeOS/install/LIFEOS/DOCUMENTATION/Memory/MemorySystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
18 changes: 17 additions & 1 deletion LifeOS/install/LIFEOS/PULSE/Observability/observability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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()
Expand Down
33 changes: 31 additions & 2 deletions LifeOS/install/hooks/LoadContext.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 = `<system-reminder>
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.
</system-reminder>`;
Expand Down
4 changes: 3 additions & 1 deletion LifeOS/install/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
26 changes: 25 additions & 1 deletion LifeOS/install/hooks/handlers/DocCrossRefIntegrity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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) {
Expand Down
Loading