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

Expand Down
90 changes: 90 additions & 0 deletions LifeOS/install/hooks/KnowledgeWriteGuard.hook.ts
Original file line number Diff line number Diff line change
@@ -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/<Type>/*.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<string> {
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/<Dir>/<file>.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<string, any>;
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));
119 changes: 119 additions & 0 deletions LifeOS/install/hooks/handlers/KnowledgeConformance.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): 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<void> {
const startTime = Date.now();
if (!existsSync(KNOWLEDGE_DIR)) return;

let total = 0;
let conformant = 0;
const byDir: Record<string, { n: number; ok: number }> = {};
const violationCounts: Record<string, number> = {};

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);
});
}
21 changes: 21 additions & 0 deletions LifeOS/install/hooks/handlers/RebuildKnowledgeSchema.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
const generator = join(getLifeosDir(), "TOOLS", "GenerateKnowledgeSchemaDoc.ts");
await new Promise<void>((resolve) => {
const child = spawn("bun", [generator], { stdio: "ignore" });
child.on("close", () => resolve());
child.on("error", () => resolve());
});
}
10 changes: 10 additions & 0 deletions LifeOS/install/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,16 @@
"timeout": 8
}
]
},
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/KnowledgeWriteGuard.hook.ts",
"timeout": 5
}
]
}
],
"PostToolUseFailure": [
Expand Down