Skip to content

DocCrossRefIntegrity: the LIFEOS_DIR arm of isSystemFileModified is unreachable, so editing LIFEOS/DOCUMENTATION never runs the cross-ref check — and found drift only ever reaches SessionEnd stderr #2043

Description

@MatiasBarboza

Version: 7.40.4 (verified identical on main today — LifeOS/install/hooks/handlers/DocCrossRefIntegrity.ts at main is byte-identical to the file shipped in the release I run).

DocIntegrity is the check that keeps documentation cross-references honest. On a stock install it never runs on the edit class that breaks cross-references, and when it does run, everything it finds goes to a surface nobody reads. Three independent causes, all in hooks/handlers/DocCrossRefIntegrity.ts. I have all three patched locally with tests; happy to open a PR.


1. The LIFEOS_DIR arm of isSystemFileModified is unreachable code

isSystemFileModified tests two roots in this order:

// L191
if (filePath.startsWith(CLAUDE_DIR + '/')) {  continue; }
// L207
if (filePath.startsWith(LIFEOS_DIR + '/')) {  continue; }

But getLifeosDir() defaults to join(homedir(), '.claude', 'LIFEOS') (hooks/lib/paths.ts L54) — LIFEOS_DIR is inside CLAUDE_DIR. So every LifeOS path matches the shorter prefix first, gets relPath = "LIFEOS/…", matches none of the hooks/ / skills/ / settings.json / CLAUDE.md / agents/ / commands/ branches, and hits continue at L204 — never reaching the block written for it. On a default install the entire L207–L217 arm is dead code.

Effect: editing LIFEOS/DOCUMENTATION/**.md, the edit most likely to create a broken cross-reference, does not trigger the check at all.

Two of the patterns inside that dead arm are also stale independent of the ordering, and would still miss after fixing it:

  • relPath.startsWith('PAI/') (L213) — a v4 prefix; no v6+ tree has a PAI/ directory under LIFEOS_DIR.
  • relPath.includes('/Tools/') (L214) — lowercase, while the shipped directory is LIFEOS/TOOLS/.

So after reordering, DOCUMENTATION/ and TOOLS/ still need to be named explicitly.

Repro (stock tree, nothing modified):

cd ~/.claude
# a transcript whose only tool_use is an Edit of a shipped doc
cat > /tmp/doc-edit.jsonl <<'JSON'
{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Edit","input":{"file_path":"HOME/.claude/LIFEOS/DOCUMENTATION/Hooks/HookSystem.md"}}]}}
JSON
sed -i "s|HOME|$HOME|" /tmp/doc-edit.jsonl

cat > /tmp/run.ts <<'TS'
import { handleDocCrossRefIntegrity } from `${process.env.HOME}/.claude/hooks/handlers/DocCrossRefIntegrity`;
await handleDocCrossRefIntegrity({} as any, {
  session_id: 'repro', transcript_path: process.argv[2], hook_event_name: 'SessionEnd',
} as any);
TS
bun /tmp/run.ts /tmp/doc-edit.jsonl 2>&1 | head -4

Observed:

[DocAutoUpdate] === Starting hybrid doc integrity check (deterministic + inference) ===
[DocAutoUpdate] Modified files in session: 1
[DocAutoUpdate] No meaningful system files modified, skipping

Negative control — same script, same tree, only the edited path changed to $HOME/.claude/hooks/DocIntegrity.hook.ts (a branch that is reachable):

[DocAutoUpdate] Checking 56 SYSTEM docs for cross-reference drift
[DocAutoUpdate] [DRIFT] Hook file references: 83 broken refs found

Same tree, same docs, same drift — visible only because the edit happened to land on a reachable branch. That contrast is the whole bug.

Suggested fix: test the more specific prefix first, and name the two directories:

if (filePath.startsWith(LIFEOS_DIR + '/')) {
  const relPath = filePath.slice(LIFEOS_DIR.length + 1);
  if (LIFEOS_EXCLUDED.some(ex => relPath.includes(ex))) continue;
  if (relPath.startsWith('DOCUMENTATION/') && relPath.endsWith('.md')) return true;
  if (relPath.startsWith('TOOLS/') && relPath.endsWith('.ts')) return true;
  
  continue;
}
if (filePath.startsWith(CLAUDE_DIR + '/')) {  }

2. Found drift has no surface: it is printed to SessionEnd stderr and nothing else

When the check does run, every drift item goes to console.error (L750–L795), and the summary at L865 prints WARNING: N cross-reference drift items need manual attention. SessionEnd stderr is a teardown print — it cannot inject context, and the session is already ending.

The one non-stderr surface is gated on the wrong condition:

// L873
if (updatesApplied.length > 0) {
   await notifyVoice(`Updated ${docNames} documentation …`);
}

That fires when the handler applied an edit, never when it found a problem. A run with N drift items and zero applied updates is indistinguishable from a clean run.

hooks/lib/advisory-readback.ts (from #1758) is exactly the reader this needs, and its own comment reserves the slot:

This list holds SHIPPED emitters only. doc.integrity (DocCrossRefIntegrity) is deliberately absent: that handler does not emit a finding set today […] Add the type in the same change that adds the emission.

Suggested fix: emit a finding set on every completed check (empty set included, per the emission contract in hooks/lib/events.ts) and register the type:

emitFindingSet({
  type: 'doc.integrity.cross_ref',
  source: 'DocCrossRefIntegrity',
  findings,                       // one per stable key, deduped
  extra: { docs_scanned, docs_on_disk, raw_drift_count },
});

Two details that mattered when I did it:

  • Dedupe by a stable key (${pattern}:${doc}:${reference}). One shipped doc names the same missing hook six times; that is one broken reference, not six, and a repeat count in the key would re-fire the digest forever. hook_count findings must key on the doc alone, since their reference embeds the number that moves.
  • renderDigest needs to lead with what changed. It renders MAX_DIGEST_LINES (3) and collapses the rest, while collectFindings walks ADVISORY_EVENT_TYPES in registration order — so the first registered type's standing backlog owns every visible line and a finding that appeared today renders as the total moving by one. I measured this: with the cross-ref emitter added, a reference I broke on purpose came out as finding 70 of 70 and was invisible. Sorting findings absent from marker.keys to the front fixes it for every emitter at once (the marker is read before it is overwritten, so "new" means new to the reader).

3. checkSystemDocRefs matches the skills/ prefix and then discards it

// L321
const sysDocRefRegex = /(?:`|'|")(?:~\/\.(?:claude|config\/PAI)\/)?(?:skills\/)?LifeOS\/([\w/]+\.md)(?:`|'|")/g;

The skills/ prefix is non-capturing, so refTarget comes out as INSTALL.md and is resolved against SYSTEM_DIR and DOCS_DIR only. A reference written as `skills/LifeOS/INSTALL.md` therefore reports as broken even though the file ships. Two shipped docs hit this in a stock tree (DOCUMENTATION/Services/BackgroundServices.md L116 names skills/LifeOS/INSTALL.md and skills/LifeOS/Workflows/Setup.md; both exist).

Suggested fix: capture the prefix and let it choose the resolution root — skills/LifeOS/<rest> under getClaudeDir()/skills/LifeOS/, bare LifeOS/<rest> under SYSTEM_DIR then DOCS_DIR. Worth pairing with a test that a missing skills/LifeOS/ path is still reported, so the fix does not become a blindfold.


Why this is worth fixing rather than tuning

Once (1) and (3) are fixed and (2) has a surface, a stock 7.40.4 tree reports 84 broken hook/lib references across 33 distinct hook names (TheRouter.hook.ts ×19, ULWorkSync.hook.ts ×11, PreCompact.hook.ts ×4, SmartApprover.hook.ts ×4, …) in the shipped LIFEOS/DOCUMENTATION/ tree — hooks the release documents but does not ship. I assume those exist in your private tree and the public release scrubs them, so this is probably not doc drift on your side; it is what a stock installation sees, and it is the reason the checker's silence is easy to mistake for health. I am not proposing anything about those docs here — only that the checker be able to say so.

Happy to send a PR with the three fixes plus the tests (trigger-gate coverage including negative cases, the digest-ordering assertions, and the resolve/absence pair for the skills/ prefix). Just say the word.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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