diff --git a/LifeOS/install/hooks/DocIntegrity.hook.ts b/LifeOS/install/hooks/DocIntegrity.hook.ts index 154235fd46..5b6a43e672 100755 --- a/LifeOS/install/hooks/DocIntegrity.hook.ts +++ b/LifeOS/install/hooks/DocIntegrity.hook.ts @@ -19,6 +19,8 @@ import { readHookInput, parseTranscriptFromInput } from './lib/hook-io'; import { handleDocCrossRefIntegrity } from './handlers/DocCrossRefIntegrity'; import { handleRebuildArchSummary } from './handlers/RebuildArchSummary'; import { handleMemoryDirIntegrity } from './handlers/MemoryDirIntegrity'; +import { handleRebuildKnowledgeSchema } from './handlers/RebuildKnowledgeSchema'; +import { handleKnowledgeConformance } from './handlers/KnowledgeConformance'; async function main() { const input = await readHookInput(); @@ -59,6 +61,18 @@ async function main() { console.error('[DocIntegrity] Memory-dir handler failed:', err); } + try { + await handleRebuildKnowledgeSchema(); + } catch (err) { + console.error('[DocIntegrity] Knowledge-schema regen failed:', err); + } + + try { + await handleKnowledgeConformance(); + } catch (err) { + console.error('[DocIntegrity] Knowledge-conformance check failed:', err); + } + process.exit(0); } diff --git a/LifeOS/install/hooks/KnowledgeWriteGuard.hook.ts b/LifeOS/install/hooks/KnowledgeWriteGuard.hook.ts new file mode 100644 index 0000000000..851dc1b433 --- /dev/null +++ b/LifeOS/install/hooks/KnowledgeWriteGuard.hook.ts @@ -0,0 +1,90 @@ +#!/usr/bin/env bun +/** + * KnowledgeWriteGuard.hook.ts — schema feedback at the moment a note is written. + * + * PURPOSE: + * `MemorySystem.renderInitialNote` is the sanctioned way a Knowledge note is + * born on the schema, but nothing prevents a writer from bypassing it. A bulk + * import did exactly that and wrote 1,744 notes off-schema; every later writer + * then read a neighbouring note, copied its frontmatter, and spread the legacy + * dialect further. The convention was documented and unenforced. + * + * KnowledgeConformance reports the damage on the Stop pass. This reports it at + * the write, while the author is still in the loop and the fix is one edit + * rather than a migration. + * + * TRIGGER: PostToolUse, matcher Write|Edit|MultiEdit + * + * CONTRACT: + * Advisory only. Emits `additionalContext` naming the violations and the + * canonical writer. NEVER blocks — a hard gate on note-writing would be worse + * than the drift it prevents, and the note is already on disk by PostToolUse. + * Silent for every path outside MEMORY/KNOWLEDGE//*.md. + */ + +import { readFileSync, existsSync } from 'fs'; +import { join, sep } from 'path'; +import { getLifeosDir } from './lib/paths'; +import { parseNote, validate, slugFromPath, DIR_TO_TYPE, type CanonicalType } from '../LIFEOS/TOOLS/KnowledgeSchema'; + +const KNOWLEDGE_DIR = join(getLifeosDir(), 'MEMORY', 'KNOWLEDGE'); + +function readStdin(): Promise { + return new Promise((resolve) => { + let data = ''; + const timer = setTimeout(() => resolve(data), 2000); + process.stdin.on('data', (c) => { data += c.toString(); }); + process.stdin.on('end', () => { clearTimeout(timer); resolve(data); }); + process.stdin.on('error', () => { clearTimeout(timer); resolve(data); }); + }); +} + +/** The note's directory decides its type; anything outside a typed dir is not a note. */ +function knowledgeDirOf(path: string): string | null { + if (!path.endsWith('.md')) return null; + if (!path.startsWith(KNOWLEDGE_DIR + sep)) return null; + const rest = path.slice(KNOWLEDGE_DIR.length + 1).split(sep); + if (rest.length !== 2) return null; // must be KNOWLEDGE//.md + if (rest[1].startsWith('_')) return null; // _index.md / _schema.md are not notes + return DIR_TO_TYPE[rest[0]] ? rest[0] : null; +} + +(async () => { + const raw = await readStdin(); + if (!raw.trim()) process.exit(0); + + let input: Record; + try { input = JSON.parse(raw); } catch { process.exit(0); } + + const path: string = input?.tool_input?.file_path ?? ''; + const dir = path ? knowledgeDirOf(path) : null; + if (!dir || !existsSync(path)) process.exit(0); + + let violations: { key: string; problem: string }[] = []; + try { + violations = validate( + parseNote(readFileSync(path, 'utf-8')), + slugFromPath(path), + DIR_TO_TYPE[dir] as CanonicalType, + ); + } catch { + violations = [{ key: 'frontmatter', problem: 'could not be parsed' }]; + } + if (violations.length === 0) process.exit(0); + + const lines = violations.slice(0, 6).map((v) => ` - \`${v.key}\` — ${v.problem}`); + const more = violations.length > 6 ? `\n …and ${violations.length - 6} more` : ''; + + console.log(JSON.stringify({ + hookSpecificOutput: { + hookEventName: input.hook_event_name || 'PostToolUse', + additionalContext: + `⚠ Knowledge note written off-schema — \`${path.split(sep).slice(-2).join('/')}\`\n` + + lines.join('\n') + more + + `\n\nNotes are born on the schema via \`MemorySystem.renderInitialNote\`. ` + + `Do not hand-write frontmatter by copying a sibling — that is how a legacy dialect spreads. ` + + `Fix this note now, or run \`bun LIFEOS/TOOLS/MigrateKnowledge.ts\` (dry-run by default) for a bulk repair.`, + }, + })); + process.exit(0); +})().catch(() => process.exit(0)); diff --git a/LifeOS/install/hooks/handlers/KnowledgeConformance.ts b/LifeOS/install/hooks/handlers/KnowledgeConformance.ts new file mode 100644 index 0000000000..ca300c32c8 --- /dev/null +++ b/LifeOS/install/hooks/handlers/KnowledgeConformance.ts @@ -0,0 +1,119 @@ +#!/usr/bin/env bun +/** + * KnowledgeConformance.ts — Knowledge Archive schema conformance checker + * + * PURPOSE: + * RebuildKnowledgeSchema keeps `_schema.md` honest against `KnowledgeSchema.ts`, + * so the DOC can no longer drift from the CODE. Nothing, however, checks whether + * the NOTES conform to either. That gap is how an archive reached 0.9% + * conformance without a single warning: a bulk import wrote 1,744 notes off + * schema, later writers copied their frontmatter, and the only tool that could + * have said so (KnowledgeLint) was never invoked by anything. + * + * This closes the detection half. It reports; it never edits or blocks. + * + * TRIGGER: Stop hook (called from DocIntegrity.hook.ts) + * + * READS: + * MEMORY/KNOWLEDGE/{People,Companies,Ideas,Research,Blogs,Books}/*.md + * + * WRITES: + * stderr (audit log with [KnowledgeConformance] tag) + * STATE/events.jsonl (typed event: doc.integrity.knowledge_conformance) + * + * SIDE EFFECTS: + * None — read-only check. Non-conformance is a soft warning. Never blocks. + */ + +import { readdirSync, readFileSync, existsSync, appendFileSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { getLifeosDir } from '../lib/paths'; +import { parseNote, validate, slugFromPath, DIR_TO_TYPE, type CanonicalType } from '../../LIFEOS/TOOLS/KnowledgeSchema'; + +const TAG = '[KnowledgeConformance]'; +const LIFEOS_DIR = getLifeosDir(); +const MEMORY_DIR = join(LIFEOS_DIR, 'MEMORY'); +const KNOWLEDGE_DIR = join(MEMORY_DIR, 'KNOWLEDGE'); +const EVENTS_FILE = join(MEMORY_DIR, 'STATE', 'events.jsonl'); +const DIRS = ['People', 'Companies', 'Ideas', 'Research', 'Blogs', 'Books'] as const; + +function emitEvent(payload: Record): void { + try { + mkdirSync(join(MEMORY_DIR, 'STATE'), { recursive: true }); + appendFileSync(EVENTS_FILE, JSON.stringify({ timestamp: new Date().toISOString(), ...payload }) + '\n', 'utf-8'); + } catch { + /* non-fatal */ + } +} + +export async function handleKnowledgeConformance(): Promise { + const startTime = Date.now(); + if (!existsSync(KNOWLEDGE_DIR)) return; + + let total = 0; + let conformant = 0; + const byDir: Record = {}; + const violationCounts: Record = {}; + + for (const dir of DIRS) { + const dirPath = join(KNOWLEDGE_DIR, dir); + if (!existsSync(dirPath)) continue; + const dirType = DIR_TO_TYPE[dir] as CanonicalType; + byDir[dir] = { n: 0, ok: 0 }; + + for (const file of readdirSync(dirPath)) { + if (!file.endsWith('.md') || file.startsWith('_')) continue; + const path = join(dirPath, file); + total++; + byDir[dir].n++; + try { + const violations = validate(parseNote(readFileSync(path, 'utf-8')), slugFromPath(path), dirType); + if (violations.length === 0) { + conformant++; + byDir[dir].ok++; + } else { + for (const v of violations) { + const key = `${v.key} — ${v.problem}`; + violationCounts[key] = (violationCounts[key] || 0) + 1; + } + } + } catch { + violationCounts['unparseable frontmatter'] = (violationCounts['unparseable frontmatter'] || 0) + 1; + } + } + } + + const nonConformant = total - conformant; + const pct = total > 0 ? ((conformant / total) * 100).toFixed(1) : '0.0'; + + emitEvent({ + event: 'doc.integrity.knowledge_conformance', + total, + conformant, + non_conformant: nonConformant, + conformance_pct: Number(pct), + per_dir: byDir, + top_violations: Object.entries(violationCounts) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([problem, count]) => ({ problem, count })), + ok: nonConformant === 0, + }); + + const elapsed = Date.now() - startTime; + if (nonConformant > 0) { + console.error(`${TAG} ${nonConformant}/${total} notes off-schema (${pct}% conformant). Repair: bun LIFEOS/TOOLS/MigrateKnowledge.ts`); + for (const [problem, count] of Object.entries(violationCounts).sort((a, b) => b[1] - a[1]).slice(0, 3)) { + console.error(`${TAG} ${count}× ${problem}`); + } + } + console.error(`${TAG} === Check complete (${elapsed}ms, ${pct}% conformant) ===`); +} + +// Allow running standalone for verification. +if (import.meta.main) { + handleKnowledgeConformance().catch((err) => { + console.error(`${TAG} Fatal:`, err); + process.exit(1); + }); +} diff --git a/LifeOS/install/hooks/handlers/RebuildKnowledgeSchema.ts b/LifeOS/install/hooks/handlers/RebuildKnowledgeSchema.ts new file mode 100644 index 0000000000..2beee6d878 --- /dev/null +++ b/LifeOS/install/hooks/handlers/RebuildKnowledgeSchema.ts @@ -0,0 +1,21 @@ +/** + * RebuildKnowledgeSchema — regenerate `MEMORY/KNOWLEDGE/_schema.md` from + * `KnowledgeSchema.ts` (the pure-data source of truth) on the doc-integrity + * pass, so the schema doc never drifts from the schema and exists on every + * install. Fire-and-forget; a failure is non-fatal (logged by the caller). + * + * Runs the standalone generator as a subprocess rather than importing it, + * because `GenerateKnowledgeSchemaDoc.ts` executes `main()` on import. + */ +import { spawn } from "child_process"; +import { join } from "path"; +import { getLifeosDir } from "../lib/paths"; + +export async function handleRebuildKnowledgeSchema(): Promise { + const generator = join(getLifeosDir(), "TOOLS", "GenerateKnowledgeSchemaDoc.ts"); + await new Promise((resolve) => { + const child = spawn("bun", [generator], { stdio: "ignore" }); + child.on("close", () => resolve()); + child.on("error", () => resolve()); + }); +} diff --git a/LifeOS/install/hooks/hooks.json b/LifeOS/install/hooks/hooks.json index ff8b570a12..b1389ff35d 100644 --- a/LifeOS/install/hooks/hooks.json +++ b/LifeOS/install/hooks/hooks.json @@ -212,6 +212,16 @@ "timeout": 8 } ] + }, + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "$HOME/.claude/hooks/KnowledgeWriteGuard.hook.ts", + "timeout": 5 + } + ] } ], "PostToolUseFailure": [