Skip to content

Six hook scripts ship registered nowhere, four documented as live; unrendered {{PRINCIPAL_NAME}} reaches the model from two hooks #1817

Description

@catchingknives

Verified against danielmiessler/LifeOS main at 58381b3 (2026-08-07), clean clone fetched 2026-08-10.

Summary

Six files in LifeOS/install/hooks/ are registered under no event in hooks/hooks.json and are imported by no sibling hook. settings.system.json and settings.enhancements.json carry no hooks key at all on main, so the manifest is the only place a registration could live. These six are dead from the moment they land: the code ships, the docs describe the behavior as running, and no event ever reaches them.

Separately, a raw template placeholder reaches model-facing context from two hooks, one of which is registered and firing today.

The six

Computed by listing every *.hook.{ts,sh} in LifeOS/install/hooks/ (51 files), subtracting every basename named in hooks/hooks.json, then dropping anything imported by a sibling. Exactly six remain:

File Header declares In manifest Imported by
ModelRungGuard.hook.ts TRIGGER: UserPromptSubmit no nothing
AtlasEventCapture.hook.ts PostToolUse (Bash, Write, Edit, MultiEdit) no nothing
KnowledgeWriteGuard.hook.ts PostToolUse (Write, Edit, MultiEdit) no nothing
SpendAuditor.hook.ts its own trigger set no nothing
TimeContext.hook.ts per-turn wall-clock line no nothing
VersionDrift.hook.ts UserPromptSubmit nag no nothing

Dispatcher-imported hooks are excluded on purpose and are not part of this report. FormatGate, VerificationGate, ISACloseGate, ISAGate, ISAFoldGate and WritingGate are unregistered but reached through StopGates.hook.ts lines 30-35, which is deliberate design. LIFEOS/DOCUMENTATION/Hooks/HookSystem.md line 22 already documents that whole dispatcher-imported set.

Why this is not just dead code

Four of the six are documented as registered and running, in tables and in the ASCII wiring diagram:

  • hooks/README.md line 210 lists AtlasEventCapture.hook.ts with matchers and timeout; line 80 draws it.
  • hooks/README.md line 182 lists VersionDrift.hook.ts; line 60 draws it.
  • hooks/README.md line 237 lists SpendAuditor.hook.ts; lines 93 and 346 draw it.
  • hooks/README.md line 186 lists TimeContext.hook.ts; line 64 draws it, and line 636 names it as one of only four hooks that should write to stdout.

One is documented with a registration date it never got. HookSystem.md line 22 states KnowledgeWriteGuard was "registered 2026-07-29", line 321 shows it inside a settings.json snippet, and line 351 describes its runtime behavior in present tense. It appears in no registration file on main.

One has a consumer that can never succeed. LIFEOS/TOOLS/Reflect.ts line 49 reads MEMORY/OBSERVABILITY/spend-audit.jsonl, and line 190 states that within_budget "is derived from spend-audit.jsonl and cannot be supplied". SpendAuditor.hook.ts line 44 is that file's only writer. Unregistered, it never writes, so that derived field has no path to a true value on any install.

ModelRungGuard is the sharpest case. Its header (lines 14-16) records the incident that motivated it: a session ran a rung below the pinned model for its entire length and nothing noticed, "not a hook, not a gate". As shipped, that incident recurs undetected, because the detector is wired to no event.

The same drift shows up in the counts. HookSystem.md line 22 says "49 .hook.ts files exist on disk" against a tree that ships 51, and flags earlier stale counts already reported in #1596 and #1629. The counts drift because nothing checks them.

Second defect: a placeholder reaching the model verbatim

ModelRungGuard.hook.ts builds its off-pin advisory across lines 164-169 and hands it to emit(), which returns it as additionalContext at line 149. Line 168 is:

`{{PRINCIPAL_NAME}} to change /model.`,

That sits inside the emitted template literal, not in a comment. Nothing renders it at hook runtime, so every off-pin fire hands the model a raw {{PRINCIPAL_NAME}} token. Placeholder substitution is a tree-wide install-time pass, so whether this is ever rendered depends on the install path rather than on the hook being correct.

The same bug exists in a hook that IS registered. PromptProcessing.hook.ts lines 708 and 721 embed the literal {{PRINCIPAL_NAME}} token inside the buildContextPrompt() template literal, on lines adjacent to correct ${PRINCIPAL_NAME} interpolation. That return value is consumed as a systemPrompt for a live inference call, so this one leaks on installs today regardless of the orphan issue.

Grepping all 51 hook files for the literal token outside comments returns exactly three lines, so the fix is small and complete.

Why nothing catches this

HookHealer.hook.ts (1.0.3 on main) is the natural place for an orphan check and has none. Its functions are log, isExecutable, hasShebang, heal, expandHome, directExecPaths, sweep, readStdin, posttool, main. It heals executable bits and shebangs; it never compares the files on disk against the set of registered names.

Reproduce

From LifeOS/install/ at 58381b3:

# 1. none of the six appear in the manifest
for f in ModelRungGuard AtlasEventCapture KnowledgeWriteGuard \
         SpendAuditor TimeContext VersionDrift; do
  grep -q "$f" hooks/hooks.json || echo "$f: not in manifest"
done

# 2. none of the six are imported by a sibling
for f in ModelRungGuard AtlasEventCapture KnowledgeWriteGuard \
         SpendAuditor TimeContext VersionDrift; do
  grep -l "from \"\./$f\.hook\"" hooks/*.hook.ts || echo "$f: imported by nothing"
done

# 3. no other registration surface exists
python3 -c "import json; print('hooks' in json.load(open('settings.system.json')))"        # False
python3 -c "import json; print('hooks' in json.load(open('settings.enhancements.json')))"  # False

# 4. the placeholder, outside comments
grep -rn '{{PRINCIPAL_NAME}}' hooks/*.hook.ts \
  | grep -v ':[0-9]*: *\*' | grep -v ':[0-9]*: *//'

Step 4 returns ModelRungGuard.hook.ts:168, PromptProcessing.hook.ts:708, PromptProcessing.hook.ts:721.

For the placeholder at runtime: feed ModelRungGuard.hook.ts a payload whose transcript_path points at a one-line JSONL file whose last assistant message carries a model below the pinned tier. The emitted additionalContext ends with the raw token.

Suggested fix

Three parts, independent of each other.

  1. Decide per file: register it, or delete it and the docs that describe it as active. A file in hooks/ that no event reaches is worse than no file, because the README table and wiring diagram read as a description of live behavior. Two of the six look like genuine design questions rather than plain omissions: AtlasEventCapture only has a consumer where the Atlas tick is installed, and TimeContext overlaps whatever else injects a per-turn clock, so deletion may be the right call there rather than registration.

  2. Render or interpolate the placeholder. Three lines across two files. PromptProcessing is the urgent half, since it is registered and firing.

  3. Add an orphan lane to HookHealer's sweep so this class cannot recur. It is cheap: one directory listing, the manifest, one import scan. It should be warning-only, since deciding when a hook fires is a human call, and it should compare per event, not per bare script name. A hook registered on one event while its header declares three looks correctly wired to any check comparing basenames alone. The same lane would keep the file counts in HookSystem.md from drifting again.

I have a working implementation of the orphan check with the import-based exclusion, and can open a PR if that is welcome.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions