klypix-mcp 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ // klypix-doctor — `npx klypix-mcp doctor`. The brain's self-check: is THIS machine's
3
+ // brain current, are the 4 Claude Code hooks wired, what verbs does it expose, who else
4
+ // is live on this project's brain, and is the harness projection in sync? ONE fact, ONE
5
+ // reconcile block. Read-only (never writes). Exits 0 = ALIGNED, 1 = DRIFTED — so it
6
+ // doubles as a pre-commit / CI readiness gate.
7
+ //
8
+ // npx klypix-mcp doctor # this project + this machine's brain
9
+ // npx klypix-mcp doctor --npm # also fetch npm 'latest' and flag a stale brain
10
+ // npx klypix-mcp doctor --all # + every registered brain on this machine + vault wiring
11
+ // npx klypix-mcp doctor --project <dir> | --json | --no-color
12
+ //
13
+ // Synchronous-ish top-level by design: the dispatcher does `await import(this);
14
+ // process.exit(...)`, so all work completes during module evaluation.
15
+ import path from 'path';
16
+ import { execSync } from 'child_process';
17
+ import { inspect, render, inspectAll } from '../src/brain-doctor.mjs';
18
+
19
+ try {
20
+ const argv = process.argv.slice(2);
21
+ const has = (f) => argv.includes(f);
22
+ const val = (f) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : undefined; };
23
+ const color = !has('--no-color') && process.stdout.isTTY !== false;
24
+ const projectDir = path.resolve(val('--project') || process.cwd());
25
+
26
+ let npmLatest = null;
27
+ if (has('--npm')) {
28
+ try { npmLatest = execSync('npm view klypix-mcp version', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 8000 }).trim(); }
29
+ catch { npmLatest = '(offline)'; }
30
+ }
31
+
32
+ const report = inspect({ projectDir, npmLatest });
33
+
34
+ if (has('--json')) {
35
+ const all = has('--all') ? inspectAll() : undefined;
36
+ console.log(JSON.stringify({ ...report, ...(all ? { crossProject: all } : {}) }, null, 2));
37
+ process.exit(report.verdict === 'DRIFTED' ? 1 : 0);
38
+ }
39
+
40
+ console.log(render(report, { color }));
41
+
42
+ let extraDrift = 0;
43
+ if (has('--all')) {
44
+ const all = inspectAll();
45
+ extraDrift = all.drift;
46
+ const dim = color ? '\x1b[2m' : '', red = color ? '\x1b[31m' : '', grn = color ? '\x1b[32m' : '', rst = color ? '\x1b[0m' : '';
47
+ console.log(`\n# registered brains (${all.brains.length})`);
48
+ for (const b of all.brains) {
49
+ const tag = b.exists ? grn + '●' + rst : red + '○ missing' + rst;
50
+ const v = !b.hasMcp ? dim + 'no .mcp.json (global config)' + rst : b.vaultOk ? grn + `vault ✓ (${b.vault})` + rst : red + `vault ⚠ ${b.vault} — not this project!` + rst;
51
+ console.log(` ${tag} ${b.project} · ${v}\n ${dim}${b.path}${rst}`);
52
+ }
53
+ }
54
+
55
+ const drifted = report.verdict === 'DRIFTED' || extraDrift > 0;
56
+ console.log(drifted ? (color ? '\x1b[33m' : '') + `\n✗ drift found — see reconcile above.` + (color ? '\x1b[0m' : '')
57
+ : report.verdict === 'NOT-INSTALLED' ? '\n• brain not installed on this machine.'
58
+ : (color ? '\x1b[32m' : '') + '\n✓ aligned.' + (color ? '\x1b[0m' : ''));
59
+ process.exit(drifted ? 1 : 0);
60
+ } catch (e) {
61
+ console.error(`✗ doctor failed: ${e?.message || e}`);
62
+ process.exit(1);
63
+ }
@@ -141,9 +141,18 @@ try {
141
141
  // 6) stamp the install (unified brain version → never-downgrade across channels)
142
142
  fs.writeFileSync(path.join(BRAIN_DIR, '.brain-version.json'), JSON.stringify({ brainVersion: VERSION, via: 'npm', dirty: false, installedAt: new Date().toISOString() }, null, 2));
143
143
 
144
+ // 7) READINESS check — re-read what we just wrote and confirm all 4 hooks actually
145
+ // took (a malformed pre-existing group, a partial merge, or a later hand-edit can
146
+ // leave the brain LIVE but not LEARNING — liveness ≠ readiness). Warn, don't fail.
147
+ const verify = (() => { try { return JSON.parse(fs.readFileSync(SETTINGS, 'utf8')); } catch { return null; } })();
148
+ const wiredFor = (evt) => Array.isArray(verify?.hooks?.[evt]) && verify.hooks[evt].some(g => Array.isArray(g?.hooks) && g.hooks.some(h => typeof h?.command === 'string' && h.command.includes(HOOK_MARK)));
149
+ const notWired = ['SessionStart', 'UserPromptSubmit', 'Stop', 'PostToolUse'].filter(e => !wiredFor(e));
150
+
144
151
  console.log(`✓ installed klypix brain v${VERSION} → ${BRAIN_DIR} (${n} scripts, ${deps} dep packages)`);
145
- console.log('✓ wired 4 hooks: SessionStart · UserPromptSubmit (--prompt) · Stop (--capture) · PostToolUse (--live) → settings.json');
152
+ if (!notWired.length) console.log('✓ wired 4 hooks: SessionStart · UserPromptSubmit (--prompt) · Stop (--capture) · PostToolUse (--live) → settings.json');
153
+ else console.error(`⚠ readiness: ${notWired.length} hook(s) did NOT take (${notWired.join(', ')}) — the brain will read but not capture/sync. Re-run \`npx klypix-mcp install --force\` or check ${SETTINGS}.`);
146
154
  console.log(' Every project with a ./brain.klypix now auto-reads its brief + captures decisions. Restart open Claude Code sessions to load the hooks.');
155
+ console.log(' Verify anytime: `npx klypix-mcp doctor` (is the brain current + wired + in sync, who else is live).');
147
156
  } catch (e) {
148
157
  console.error(`✗ install failed: ${e?.message || e}`);
149
158
  process.exit(1);
@@ -1,12 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  // klypix-link — make THIS project's brain automatic for EVERY agent tool.
3
3
  // `npx klypix-mcp install` gives Claude Code the brain via hooks; `link` extends the
4
- // same automatic read+capture to Cursor, Cline, Windsurf, Copilot/VS Code, and any
5
- // AGENTS.md-reading agent, by dropping each tool's native MCP config + rules file in
6
- // the current project. Idempotent — re-run anytime to refresh. Project-scoped (cwd);
7
- // touches only files inside the project. Reports what it did; exits non-zero on hard fail.
4
+ // same automatic read+capture to Cursor, Cline, Windsurf, Copilot/VS Code, Gemini CLI,
5
+ // Aider, and any AGENTS.md-reading agent, by dropping each tool's native MCP config +
6
+ // rules file in the current project. Idempotent — re-run anytime to refresh. The managed
7
+ // block carries the brain version + a content hash, so:
8
8
  //
9
- // cd your-project && npx klypix-mcp link
9
+ // npx klypix-mcp link # (re)project every managed block (write)
10
+ // npx klypix-mcp link --check # AUDIT only — classify each file ok/stale/hand-edited/
11
+ // # missing WITHOUT writing; exits 1 on drift (CI/pre-commit gate)
12
+ //
13
+ // Project-scoped (cwd); touches only files inside the project. Reports what it did;
14
+ // exits non-zero on hard fail / on detected drift in --check.
10
15
  //
11
16
  // Synchronous top-level by design: the dispatcher does `await import(this); process.exit(0)`,
12
17
  // so all work (and its logs) must complete during module evaluation, before exit.
@@ -14,12 +19,28 @@ import path from 'path';
14
19
  import { linkProject } from '../src/agent-rules.mjs';
15
20
 
16
21
  try {
17
- const dirArg = process.argv.slice(3).find(a => !a.startsWith('-'));
22
+ const args = process.argv.slice(3);
23
+ const check = args.includes('--check');
24
+ const dirArg = args.find(a => !a.startsWith('-'));
18
25
  const projectDir = path.resolve(dirArg || process.cwd());
19
- const { rules, mcp, hasBrain } = linkProject(projectDir);
26
+ const { rules, mcp, hasBrain, version } = linkProject(projectDir, { check });
27
+
28
+ if (check) {
29
+ // Audit-only: classify, report, exit 1 on any drift.
30
+ const mark = (s) => s === 'ok' ? '✓' : '⚠';
31
+ console.log(`klypix — auditing the brain projection in ${projectDir} (brain v${version})\n`);
32
+ console.log(' MCP server config:');
33
+ for (const r of mcp) console.log(` ${mark(r.status)} ${r.tool.padEnd(26)} ${r.file} — ${r.status.toUpperCase()}${r.why ? ' (' + r.why + ')' : ''}`);
34
+ console.log('\n Rules / instructions:');
35
+ for (const r of rules) console.log(` ${mark(r.status)} ${r.tool.padEnd(26)} ${r.file} — ${r.status.toUpperCase()}${r.stampedVersion ? ' (stamped v' + r.stampedVersion + ')' : ''}`);
36
+ const drift = [...rules, ...mcp].filter(r => r.status !== 'ok');
37
+ if (!drift.length) { console.log(`\n✓ in sync — all ${rules.length + mcp.length} managed file(s) match brain v${version}.`); process.exit(0); }
38
+ console.log(`\n✗ ${drift.length} file(s) drifted (stale / hand-edited / missing) — run \`npx klypix-mcp link\` to re-project.`);
39
+ process.exit(1);
40
+ }
20
41
 
21
42
  const mark = (a) => a === 'unchanged' ? '·' : a === 'skipped' ? '⚠' : '✓';
22
- console.log(`klypix — linking the brain to every agent tool in ${projectDir}\n`);
43
+ console.log(`klypix — linking the brain to every agent tool in ${projectDir} (brain v${version})\n`);
23
44
  console.log(' MCP server (so each tool can reach the brain):');
24
45
  for (const r of mcp) console.log(` ${mark(r.action)} ${r.tool.padEnd(26)} ${r.file}${r.why ? ' (' + r.why + ')' : ''}`);
25
46
  console.log('\n Rules (so each tool auto-reads + captures the brain):');
@@ -28,6 +49,7 @@ try {
28
49
  const changed = [...rules, ...mcp].filter(r => r.action && !['unchanged', 'skipped'].includes(r.action)).length;
29
50
  console.log(`\n✓ ${changed} file(s) written/updated — every agent opened in this project now reads + captures ./brain.klypix.`);
30
51
  console.log(' Cline & Windsurf MCP servers live in their global config; the rules file points them at the brain regardless.');
52
+ console.log(' Verify anytime with `npx klypix-mcp link --check` (or `npx klypix-mcp doctor`).');
31
53
  if (!hasBrain) {
32
54
  console.log('\n⚠ No ./brain.klypix here yet — the rules reference it for when you create one.');
33
55
  console.log(' Make one in the KLYPIX app (Canvas → Save as brain) or with the create_canvas MCP tool.');
@@ -47,6 +47,11 @@ if (process.argv[2] === 'install') { await import('./klypix-install.mjs'); proce
47
47
  // brain on its own. Project-scoped (cwd); idempotent. Runs before any server setup.
48
48
  if (process.argv[2] === 'link') { await import('./klypix-link.mjs'); process.exit(0); }
49
49
 
50
+ // `npx klypix-mcp doctor` — the brain's READ-ONLY self-check: is this machine's brain
51
+ // current, are the 4 hooks wired, what verbs does it expose, who's live, is the harness
52
+ // projection in sync? One verdict, one reconcile block. Exits 1 on drift (CI gate).
53
+ if (process.argv[2] === 'doctor') { await import('./klypix-doctor.mjs'); process.exit(0); }
54
+
50
55
  // `npx klypix-mcp init` — 60-second onboarding: seed a starter project brain in
51
56
  // the current folder so a new user's FIRST contact isn't an empty vault, then
52
57
  // print a paste-ready MCP config. Runs before any server setup.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "description": "An open, local-first, agent-neutral canvas file your AI reads and writes over MCP — works with Claude, Cursor, Cline, any model.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,6 +27,7 @@
27
27
  "bin": {
28
28
  "klypix-mcp": "bin/klypix-mcp.mjs",
29
29
  "klypix-link": "bin/klypix-link.mjs",
30
+ "klypix-doctor": "bin/klypix-doctor.mjs",
30
31
  "klypix-a2a": "bin/klypix-a2a.mjs",
31
32
  "klypix-read": "bin/klypix-read.mjs",
32
33
  "klypix-write": "bin/klypix-write.mjs",
@@ -1,7 +1,8 @@
1
1
  // agent-rules — make a project's brain.klypix AUTOMATIC for EVERY agent tool, not
2
2
  // just Claude Code. Claude Code gets the brain via hooks (settings.json, see
3
- // klypix-install). Other agents (Cursor, Cline, Windsurf, Copilot/VS Code, and the
4
- // AGENTS.md cross-tool standard) have no hook system — so we drop their NATIVE files:
3
+ // klypix-install). Other agents (Cursor, Cline, Windsurf, Copilot/VS Code, Gemini
4
+ // CLI, Aider, and the AGENTS.md cross-tool standard) have no hook system — so we
5
+ // drop their NATIVE files:
5
6
  //
6
7
  // • an MCP server config → the agent CAN reach the brain's tools
7
8
  // • a rules / instructions → the agent is TOLD to read the brain at task start and
@@ -9,14 +10,39 @@
9
10
  //
10
11
  // Agent-neutral by construction: the brain DATA is one shared brain.klypix; this just
11
12
  // teaches each tool to use it. Idempotent — owned files are rewritten wholesale; shared
12
- // files (AGENTS.md, copilot-instructions.md) get a fenced block merged in place, never
13
- // clobbering the user's own content. Pure fs/path; never throws for one bad target.
13
+ // files (AGENTS.md, copilot-instructions.md, GEMINI.md) get a fenced block merged in
14
+ // place, never clobbering the user's own content. Pure fs/path; never throws for one bad
15
+ // target.
16
+ //
17
+ // DRIFT-AWARE (added 1.13.0): the managed fence now carries `v=<brainVersion>` +
18
+ // `hash=<contentHash>` so a projected block is CLASSIFIABLE without re-writing it —
19
+ // ok / stale (older brain) / hand-edited (content changed inside the block) / missing.
20
+ // `linkProject(dir, { check:true })` returns that audit without touching disk; it's what
21
+ // `npx klypix-mcp link --check` and `brain_doctor`'s harness layer read. Closes the
22
+ // audited "harness projection is write-once, drift is undetectable" gap.
14
23
  import fs from 'fs';
15
24
  import path from 'path';
16
-
17
- const FENCE_START = '<!-- klypix-brain:start (managed by klypix-mcp — re-run `npx klypix-mcp link`) -->';
18
- const FENCE_END = '<!-- klypix-brain:end -->';
19
- const FENCE_RE = /<!--\s*klypix-brain:start[\s\S]*?klypix-brain:end\s*-->/;
25
+ import crypto from 'crypto';
26
+ import { fileURLToPath } from 'url';
27
+
28
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
29
+ const sha8 = (s) => crypto.createHash('sha1').update(String(s)).digest('hex').slice(0, 8);
30
+ const cmpSemver = (a, b) => { const pa = String(a || '').split('.').map(n => parseInt(n, 10) || 0), pb = String(b || '').split('.').map(n => parseInt(n, 10) || 0); for (let i = 0; i < 3; i++) { if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0); } return 0; };
31
+
32
+ // The klypix-mcp version this projection was written from — stamped into the fence so a
33
+ // later session can tell a stale block (older brain) from a hand-edited one. Read from
34
+ // package.json walking up from this file (the flat ~/.claude runtime has no versioned
35
+ // package.json, but `link` always runs from the npm package, where it does). Overridable
36
+ // via linkProject(dir, { version }).
37
+ export function resolveVersion() {
38
+ let dir = HERE;
39
+ for (; ;) {
40
+ try { const v = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).version; if (v) return v; } catch { /* keep walking */ }
41
+ const parent = path.dirname(dir);
42
+ if (parent === dir) return '0.0.0';
43
+ dir = parent;
44
+ }
45
+ }
20
46
 
21
47
  // The one canonical instruction every agent gets. Tool-and-CLI dual so it works whether
22
48
  // or not the agent has the klypix-canvas MCP wired.
@@ -39,17 +65,55 @@ Capture **sparingly** — real decisions/milestones, not routine steps. Link rel
39
65
 
40
66
  **Don't** hand-edit \`brain.klypix\` (it's a packaged canvas — use the tools) or dump file contents into it; capture the *decision*, not the file.`;
41
67
 
42
- const fencedBlock = () => `${FENCE_START}\n${BRAIN_INSTRUCTIONS}\n${FENCE_END}`;
68
+ // Content fingerprint of the canonical instructions — stamped into the fence so a
69
+ // hand-edit INSIDE the block (body != what its hash claims) is detectable.
70
+ const INSTRUCTIONS_HASH = sha8(BRAIN_INSTRUCTIONS);
71
+
72
+ const FENCE_END = '<!-- klypix-brain:end -->';
73
+ // Versioned + hashed start marker. The attrs are between `start` and the closing `-->`,
74
+ // so the BROAD FENCE_RE (which matches any start…end) still replaces an OLD unstamped
75
+ // block on the next `link` — the upgrade is idempotent.
76
+ const fenceStart = (ver) => `<!-- klypix-brain:start v=${ver} hash=${INSTRUCTIONS_HASH} (managed by klypix-mcp — re-run \`npx klypix-mcp link\`) -->`;
77
+ const FENCE_RE = /<!--\s*klypix-brain:start[\s\S]*?klypix-brain:end\s*-->/;
78
+ // Capturing parse: v=(group1) hash=(group2) inner-body(group3). Attrs optional so a
79
+ // legacy unstamped block parses too (version/hash come back undefined → treated as stale).
80
+ const FENCE_PARSE_RE = /<!--\s*klypix-brain:start(?:\s+v=([0-9][0-9.]*))?(?:\s+hash=([0-9a-f]+))?[\s\S]*?-->([\s\S]*?)<!--\s*klypix-brain:end\s*-->/;
81
+
82
+ const fencedBlock = (ver) => `${fenceStart(ver)}\n${BRAIN_INSTRUCTIONS}\n${FENCE_END}`;
43
83
 
44
84
  const exists = (p) => { try { fs.statSync(p); return true; } catch { return false; } };
45
85
  const ensureDir = (p) => fs.mkdirSync(path.dirname(p), { recursive: true });
46
86
 
47
- // Shared markdown (AGENTS.md, copilot-instructions.md): merge our fenced block in place.
48
- function fenceMerge(file) {
87
+ // Parse a managed block out of arbitrary file text → { version, hash, body } | null.
88
+ function parseFence(text) {
89
+ const m = String(text || '').match(FENCE_PARSE_RE);
90
+ if (!m) return null;
91
+ return { version: m[1] || null, hash: m[2] || null, body: m[3] || '' };
92
+ }
93
+
94
+ // Classify a projected fenced file WITHOUT writing (the drift audit).
95
+ // missing — file absent, or present but carries no managed block
96
+ // hand-edited — block body no longer matches the hash it was stamped with
97
+ // stale — block intact but stamped from an older brain (version < current),
98
+ // or a legacy unstamped block (no version) → a re-link refreshes it
99
+ // ok — block present, current version, body matches its hash
100
+ function classifyFenced(file, version) {
101
+ if (!exists(file)) return { status: 'missing' };
102
+ const fence = parseFence(fs.readFileSync(file, 'utf8'));
103
+ if (!fence) return { status: 'missing' };
104
+ if (fence.hash && sha8(fence.body.trim()) !== fence.hash) return { status: 'hand-edited', stampedVersion: fence.version };
105
+ if (!fence.version) return { status: 'stale', stampedVersion: null };
106
+ if (cmpSemver(fence.version, version) < 0) return { status: 'stale', stampedVersion: fence.version };
107
+ return { status: 'ok', stampedVersion: fence.version };
108
+ }
109
+
110
+ // Shared markdown (AGENTS.md, copilot-instructions.md, GEMINI.md): merge our fenced
111
+ // block in place, preserving the user's own prose outside the fence.
112
+ function fenceMerge(file, version) {
49
113
  let cur = '';
50
114
  let had = false;
51
115
  if (exists(file)) { cur = fs.readFileSync(file, 'utf8'); had = true; }
52
- const block = fencedBlock();
116
+ const block = fencedBlock(version);
53
117
  let next;
54
118
  let action;
55
119
  if (FENCE_RE.test(cur)) { next = cur.replace(FENCE_RE, block); action = 'updated'; }
@@ -61,11 +125,12 @@ function fenceMerge(file) {
61
125
  }
62
126
 
63
127
  // Owned dedicated rules file: rewrite wholesale (optional frontmatter for always-apply).
64
- function writeDedicated(file, frontmatter) {
65
- const body = (frontmatter ? frontmatter + '\n' : '') + fencedBlock() + '\n';
66
- if (exists(file) && fs.readFileSync(file, 'utf8') === body) return { action: 'unchanged' };
128
+ function writeDedicated(file, frontmatter, version) {
129
+ const body = (frontmatter ? frontmatter + '\n' : '') + fencedBlock(version) + '\n';
130
+ const had = exists(file);
131
+ if (had && fs.readFileSync(file, 'utf8') === body) return { action: 'unchanged' };
67
132
  ensureDir(file); fs.writeFileSync(file, body, 'utf8');
68
- return { action: exists(file) ? 'updated' : 'created' };
133
+ return { action: had ? 'updated' : 'created' };
69
134
  }
70
135
 
71
136
  // Project-level MCP config: add the klypix-canvas server, preserving any sibling servers.
@@ -87,40 +152,81 @@ function mergeMcpJson(file, wrapKey, withType) {
87
152
  return { action: before === undefined ? 'created' : 'updated' };
88
153
  }
89
154
 
155
+ // Check-only classifier for an MCP json: is the klypix-canvas server wired?
156
+ function classifyMcp(file, wrapKey) {
157
+ if (!exists(file)) return { status: 'missing' };
158
+ try {
159
+ const cfg = JSON.parse(fs.readFileSync(file, 'utf8') || '{}');
160
+ return cfg?.[wrapKey]?.['klypix-canvas'] ? { status: 'ok' } : { status: 'missing' };
161
+ } catch { return { status: 'hand-edited', why: 'invalid JSON' }; }
162
+ }
163
+
164
+ // The projection map — the single source of truth shared by WRITE (linkProject) and
165
+ // CHECK (auditProject), so the two can never disagree about what's projected where.
166
+ function targets(projectDir) {
167
+ const j = (...p) => path.join(projectDir, ...p);
168
+ return {
169
+ rules: [
170
+ { tool: 'AGENTS.md (cross-tool standard)', file: j('AGENTS.md'), kind: 'merge' },
171
+ { tool: 'Cursor', file: j('.cursor', 'rules', 'klypix-brain.mdc'), kind: 'dedicated', frontmatter: '---\ndescription: KLYPIX project brain — read at task start, capture decisions\nalwaysApply: true\n---' },
172
+ { tool: 'Windsurf', file: j('.windsurf', 'rules', 'klypix-brain.md'), kind: 'dedicated', frontmatter: '---\ntrigger: always_on\n---' },
173
+ { tool: 'Cline', file: j('.clinerules', 'klypix-brain.md'), kind: 'dedicated', frontmatter: '' },
174
+ { tool: 'GitHub Copilot', file: j('.github', 'copilot-instructions.md'), kind: 'merge' },
175
+ // Added 1.13.0 — close the "not generated at all" coverage gap the audit flagged.
176
+ { tool: 'Gemini CLI', file: j('GEMINI.md'), kind: 'merge' },
177
+ { tool: 'Aider', file: j('CONVENTIONS.md'), kind: 'dedicated', frontmatter: '' },
178
+ ],
179
+ mcp: [
180
+ { tool: 'Cursor', file: j('.cursor', 'mcp.json'), wrapKey: 'mcpServers', withType: false },
181
+ { tool: 'VS Code (Copilot/Continue)', file: j('.vscode', 'mcp.json'), wrapKey: 'servers', withType: true },
182
+ ],
183
+ };
184
+ }
185
+
186
+ const relFile = (projectDir, abs) => path.relative(projectDir, abs).replace(/\\/g, '/');
187
+
90
188
  /**
91
- * Wire a project so EVERY agent tool reads + captures its brain automatically.
189
+ * Wire a project so EVERY agent tool reads + captures its brain automatically — or,
190
+ * with { check:true }, AUDIT the projection without touching disk.
92
191
  * @param {string} projectDir absolute project root (holds ./brain.klypix)
93
- * @returns {{ rules: Array, mcp: Array, hasBrain: boolean }}
192
+ * @param {{ version?: string, check?: boolean }} [opts]
193
+ * @returns {{ rules: Array, mcp: Array, hasBrain: boolean, version: string, check: boolean }}
94
194
  */
95
- export function linkProject(projectDir) {
96
- const j = (...p) => path.join(projectDir, ...p);
97
- const hasBrain = exists(j('brain.klypix')) || exists(j('brain.any'));
98
-
99
- // ── Rules / instructions (the "automatically use the brain" half) ──────────────
100
- const rules = [
101
- // AGENTS.md — the emerging cross-tool standard (Codex, Jules, Zed, Cursor-also-reads…)
102
- { tool: 'AGENTS.md (cross-tool standard)', file: 'AGENTS.md', ...fenceMerge(j('AGENTS.md')) },
103
- // Cursor — dedicated always-applied rule
104
- { tool: 'Cursor', file: '.cursor/rules/klypix-brain.mdc', ...writeDedicated(j('.cursor', 'rules', 'klypix-brain.mdc'),
105
- '---\ndescription: KLYPIX project brain — read at task start, capture decisions\nalwaysApply: true\n---') },
106
- // Windsurf — dedicated always-on rule
107
- { tool: 'Windsurf', file: '.windsurf/rules/klypix-brain.md', ...writeDedicated(j('.windsurf', 'rules', 'klypix-brain.md'),
108
- '---\ntrigger: always_on\n---') },
109
- // Cline — drops a file in .clinerules/ (Cline reads every file there)
110
- { tool: 'Cline', file: '.clinerules/klypix-brain.md', ...writeDedicated(j('.clinerules', 'klypix-brain.md'), '') },
111
- // GitHub Copilot — repo-wide custom instructions
112
- { tool: 'GitHub Copilot', file: '.github/copilot-instructions.md', ...fenceMerge(j('.github', 'copilot-instructions.md')) },
113
- ];
114
-
115
- // ── MCP server config (the "can reach the brain's tools" half) ─────────────────
116
- // Project-level files only; Claude Code is covered by `install` (hooks + bundled MCP),
117
- // so we skip .mcp.json to avoid double-registering its server.
118
- const mcp = [
119
- { tool: 'Cursor', file: '.cursor/mcp.json', ...mergeMcpJson(j('.cursor', 'mcp.json'), 'mcpServers', false) },
120
- { tool: 'VS Code (Copilot/Continue)', file: '.vscode/mcp.json', ...mergeMcpJson(j('.vscode', 'mcp.json'), 'servers', true) },
121
- ];
122
-
123
- return { rules, mcp, hasBrain };
195
+ export function linkProject(projectDir, opts = {}) {
196
+ const version = opts.version || resolveVersion();
197
+ const check = !!opts.check;
198
+ const t = targets(projectDir);
199
+ const hasBrain = exists(path.join(projectDir, 'brain.klypix')) || exists(path.join(projectDir, 'brain.any'));
200
+
201
+ const rules = t.rules.map((r) => {
202
+ const file = relFile(projectDir, r.file);
203
+ if (check) return { tool: r.tool, file, ...classifyFenced(r.file, version) };
204
+ const res = r.kind === 'merge' ? fenceMerge(r.file, version) : writeDedicated(r.file, r.frontmatter, version);
205
+ return { tool: r.tool, file, ...res };
206
+ });
207
+
208
+ // Project-level MCP files only; Claude Code is covered by `install` (hooks + bundled
209
+ // MCP), so we skip .mcp.json to avoid double-registering its server.
210
+ const mcp = t.mcp.map((m) => {
211
+ const file = relFile(projectDir, m.file);
212
+ if (check) return { tool: m.tool, file, ...classifyMcp(m.file, m.wrapKey) };
213
+ return { tool: m.tool, file, ...mergeMcpJson(m.file, m.wrapKey, m.withType) };
214
+ });
215
+
216
+ return { rules, mcp, hasBrain, version, check };
217
+ }
218
+
219
+ /**
220
+ * Check-only harness audit (no writes) — the harness layer of brain_doctor / the body
221
+ * of `npx klypix-mcp link --check`. Rolls the per-file classification into drift sets.
222
+ * @returns {{ files, drift, unprojected, ok, version }}
223
+ */
224
+ export function auditProject(projectDir, opts = {}) {
225
+ const { rules, mcp, version } = linkProject(projectDir, { ...opts, check: true });
226
+ const files = [...rules, ...mcp];
227
+ const ACTIONABLE = new Set(['missing', 'stale', 'hand-edited']);
228
+ const drift = files.filter((f) => ACTIONABLE.has(f.status));
229
+ return { files, drift, ok: drift.length === 0, version };
124
230
  }
125
231
 
126
- export { BRAIN_INSTRUCTIONS };
232
+ export { BRAIN_INSTRUCTIONS, INSTRUCTIONS_HASH, FENCE_RE, parseFence, cmpSemver };
@@ -0,0 +1,248 @@
1
+ // brain-doctor — the brain reads its OWN state and reports it as ONE fact.
2
+ // ============================================================================
3
+ // The audited gap: klypix had a dozen stamps and footers but no single surface that
4
+ // answers "is THIS brain current, are my hooks wired, is the harness projection in
5
+ // sync, and who else is live?". Liveness ("a process is up") was conflated with
6
+ // readiness ("fully wired + consistent"). This is that surface — a pure, read-only
7
+ // inspection over seams that already exist:
8
+ //
9
+ // • VERSION — the BAKED brain-core version (PKG_VERSION in the installed
10
+ // klypix-mcp-server.mjs) is the source of truth, because the install
11
+ // stamp's version key is channel-dependent (npm writes `brainVersion`,
12
+ // the desktop app writes `appVersion`). + the deploy `dirty` flag.
13
+ // • HOOKS — are all 4 Claude Code hooks actually wired (HOOK_MARK present)?
14
+ // SessionStart firing proves liveness; the other 3 prove readiness.
15
+ // • TOOLS — the discoverable MCP verb manifest (what the installed server
16
+ // REALLY registers) so a caller can't assume a phantom tool.
17
+ // • PEERS — live sessions on this project's brain (the alignment seam), so
18
+ // "who else is editing right now?" is answerable, not just footer-passive.
19
+ // • HARNESS — per-file projection drift (ok/stale/hand-edited/missing) via the
20
+ // versioned fence in agent-rules.
21
+ //
22
+ // Pure node builtins + agent-rules (both in the published package). Never throws on a
23
+ // missing seam — an absent file is a fact to report, not an error.
24
+ import fs from 'fs';
25
+ import os from 'os';
26
+ import path from 'path';
27
+ import crypto from 'crypto';
28
+ import { fileURLToPath } from 'url';
29
+ import { auditProject, resolveVersion } from './agent-rules.mjs';
30
+
31
+ const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
32
+
33
+ const sha = (s) => crypto.createHash('sha1').update(String(s)).digest('hex').slice(0, 16);
34
+ // Same normalization the hook uses to key the sessions lane: forward slashes + a
35
+ // lowercased drive letter, so the CLI computes the SAME sessions filename the hook wrote.
36
+ const normBrainPath = (p) => String(p).replace(/\\/g, '/').replace(/^[a-zA-Z]:/, (m) => m.toLowerCase());
37
+ const readJson = (p, fb = null) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return fb; } };
38
+ const readText = (p) => { try { return fs.readFileSync(p, 'utf8'); } catch { return null; } };
39
+ const cmpSemver = (a, b) => { const pa = String(a || '').split('.').map(n => parseInt(n, 10) || 0), pb = String(b || '').split('.').map(n => parseInt(n, 10) || 0); for (let i = 0; i < 3; i++) { if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0); } return 0; };
40
+
41
+ const HOOK_MARK = 'global-brain-hook';
42
+ const HOOK_EVENTS = ['SessionStart', 'UserPromptSubmit', 'Stop', 'PostToolUse'];
43
+ const SESSION_FRESH_MS = 10 * 60 * 1000; // matches the hook's lane-freshness window
44
+
45
+ // ── VERSION layer ────────────────────────────────────────────────────────────
46
+ function inspectVersion(brainDir) {
47
+ // The baked version in the DEPLOYED server is authoritative (channel-independent).
48
+ const serverSrc = readText(path.join(brainDir, 'klypix-mcp-server.mjs'));
49
+ const m = serverSrc && serverSrc.match(/const PKG_VERSION = '([^']+)'/);
50
+ const baked = m ? m[1] : null;
51
+ const stamp = readJson(path.join(brainDir, '.brain-version.json'), null);
52
+ // The stamp's version is `brainVersion` (npm) OR `appVersion` (desktop app) — read both.
53
+ const stampVersion = stamp ? (stamp.brainVersion || stamp.appVersion || null) : null;
54
+ return {
55
+ installed: !!serverSrc || !!stamp,
56
+ baked, // real brain-core version (or null if not deployed)
57
+ channel: stamp?.via || null, // 'npm' | 'app' | 'dev' | null
58
+ stampVersion, // provenance only (namespace varies by channel)
59
+ dirty: !!(stamp && stamp.dirty),
60
+ dev: !!(stamp && stamp.dev),
61
+ sourceSha: stamp?.sourceSha || null,
62
+ installedAt: stamp?.installedAt || stamp?.deployedAt || null,
63
+ };
64
+ }
65
+
66
+ // ── HOOKS (readiness) layer ──────────────────────────────────────────────────
67
+ function inspectHooks(home) {
68
+ const settings = readJson(path.join(home, '.claude', 'settings.json'), null);
69
+ const wiredFor = (evt) => {
70
+ const groups = settings?.hooks?.[evt];
71
+ return Array.isArray(groups) && groups.some(g => Array.isArray(g?.hooks)
72
+ && g.hooks.some(h => typeof h?.command === 'string' && h.command.includes(HOOK_MARK)));
73
+ };
74
+ const present = !!settings;
75
+ const wired = present ? HOOK_EVENTS.filter(wiredFor) : [];
76
+ const missing = present ? HOOK_EVENTS.filter(e => !wired.includes(e)) : HOOK_EVENTS.slice();
77
+ return { settingsPresent: present, wired, missing };
78
+ }
79
+
80
+ // ── TOOLS (discoverable manifest) layer ──────────────────────────────────────
81
+ function inspectTools(brainDir, pkgRoot) {
82
+ // Prefer the DEPLOYED server (what this machine's brain actually exposes); fall back
83
+ // to the running package's server file. Regex the registration list — no import, no
84
+ // spawning the stdio server.
85
+ const candidates = [path.join(brainDir, 'klypix-mcp-server.mjs'), path.join(pkgRoot, 'bin', 'klypix-mcp.mjs')];
86
+ for (const f of candidates) {
87
+ const src = readText(f);
88
+ if (!src) continue;
89
+ const names = [];
90
+ const re = /server\.registerTool\(\s*['"]([^'"]+)['"]/g;
91
+ let mm; while ((mm = re.exec(src))) names.push(mm[1]);
92
+ if (names.length) return { names, count: names.length, source: f === candidates[0] ? 'deployed' : 'package', hash: sha(names.slice().sort().join(',')).slice(0, 8) };
93
+ }
94
+ return { names: [], count: 0, source: null, hash: null };
95
+ }
96
+
97
+ // ── PEERS (alignment) layer ──────────────────────────────────────────────────
98
+ function inspectPeers(brainDir, brainPath, now) {
99
+ const file = path.join(brainDir, 'sessions', `${sha(normBrainPath(brainPath))}.json`);
100
+ const data = readJson(file, null);
101
+ const sessions = Array.isArray(data?.sessions) ? data.sessions : [];
102
+ const live = sessions.filter(s => s && now - (s.lastSeen || 0) < SESSION_FRESH_MS)
103
+ .map(s => ({ id: String(s.id || '').slice(0, 8), branch: s.branch || null, intent: s.intent || '', files: Array.isArray(s.files) ? s.files : [], lastSeenMin: Math.round((now - (s.lastSeen || 0)) / 60000) }));
104
+ return { file, live, count: live.length };
105
+ }
106
+
107
+ /**
108
+ * Inspect this machine's brain (+ a project's harness projection) as one report.
109
+ * @param {{ projectDir?: string, home?: string, now?: number, npmLatest?: string|null }} [opts]
110
+ */
111
+ export function inspect(opts = {}) {
112
+ const home = opts.home || os.homedir();
113
+ const projectDir = opts.projectDir || process.cwd();
114
+ const now = opts.now || Date.now();
115
+ const brainDir = path.join(home, '.claude', 'project-brain');
116
+ const brainPath = path.join(projectDir, 'brain.klypix');
117
+ const hasBrain = fs.existsSync(brainPath) || fs.existsSync(path.join(projectDir, 'brain.any'));
118
+
119
+ const version = inspectVersion(brainDir);
120
+ const hooks = inspectHooks(home);
121
+ const tools = inspectTools(brainDir, PKG_ROOT);
122
+ const peers = inspectPeers(brainDir, brainPath, now);
123
+
124
+ // Harness drift only counts toward the verdict for a real brain project; auditProject
125
+ // against the BAKED brain version (the deployed truth) when available.
126
+ const harnessVer = version.baked || resolveVersion();
127
+ const harness = hasBrain ? auditProject(projectDir, { version: harnessVer }) : { files: [], drift: [], ok: true, version: harnessVer };
128
+
129
+ // npm currency (caller fetches it; we just compare to the baked truth).
130
+ const npm = (opts.npmLatest && !String(opts.npmLatest).startsWith('('))
131
+ ? { latest: opts.npmLatest, matches: version.baked ? cmpSemver(opts.npmLatest, version.baked) <= 0 : null }
132
+ : (opts.npmLatest ? { latest: opts.npmLatest, matches: null } : null);
133
+
134
+ // ── verdict ──────────────────────────────────────────────────────────────
135
+ const layers = {
136
+ version: version.installed ? ((version.dirty || (npm && npm.matches === false)) ? 'drift' : 'ok') : 'absent',
137
+ hooks: !hooks.settingsPresent ? 'absent' : (hooks.missing.length ? 'drift' : 'ok'),
138
+ harness: hasBrain ? (harness.ok ? 'ok' : 'drift') : 'n/a',
139
+ };
140
+ const drifted = Object.values(layers).filter(s => s === 'drift').length;
141
+ const verdict = !version.installed ? 'NOT-INSTALLED' : (drifted ? 'DRIFTED' : 'ALIGNED');
142
+
143
+ // ── one reconciliation block ──────────────────────────────────────────────
144
+ const actions = [];
145
+ if (!version.installed) actions.push('npx klypix-mcp install # no brain installed on this machine');
146
+ else {
147
+ if (version.dirty) actions.push('node scripts/deploy-brain.mjs # running uncommitted (dirty) hook code — commit + re-deploy');
148
+ if (npm && npm.matches === false) actions.push(`npx klypix-mcp install # installed brain v${version.baked} < npm latest v${npm.latest}`);
149
+ if (hooks.missing.length) actions.push(`npx klypix-mcp install # half-wired: hooks not active — ${hooks.missing.join(', ')}`);
150
+ if (hasBrain && !harness.ok) actions.push('npx klypix-mcp link # harness configs drifted — re-project managed blocks');
151
+ }
152
+
153
+ return { verdict, layers, drifted, version, hooks, tools, peers, harness, npm, project: { dir: projectDir, brainPath, hasBrain }, brainDir, actions };
154
+ }
155
+
156
+ // One-line drift summary (empty when clean) — for a footer / status line.
157
+ export function driftLine(r) {
158
+ if (r.verdict === 'ALIGNED') return '';
159
+ const bits = [];
160
+ if (!r.version.installed) return 'brain NOT installed — run `npx klypix-mcp install`';
161
+ if (r.version.dirty) bits.push('dirty deploy');
162
+ if (r.npm && r.npm.matches === false) bits.push(`v${r.version.baked}<${r.npm.latest}`);
163
+ if (r.hooks.missing.length) bits.push(`${r.hooks.missing.length} hook(s) unwired`);
164
+ if (r.project.hasBrain && !r.harness.ok) bits.push(`${r.harness.drift.length} harness file(s) drifted`);
165
+ return bits.length ? `⚠️ brain DRIFTED: ${bits.join(' · ')}` : '';
166
+ }
167
+
168
+ // ── human report ──────────────────────────────────────────────────────────────
169
+ const C = { dim: '\x1b[2m', red: '\x1b[31m', grn: '\x1b[32m', yel: '\x1b[33m', rst: '\x1b[0m', bold: '\x1b[1m' };
170
+ export function render(r, opts = {}) {
171
+ const color = opts.color !== false;
172
+ const c = color ? C : new Proxy({}, { get: () => '' });
173
+ const ok = color ? '✅' : '[ok]', warn = color ? '⚠️ ' : '[!]';
174
+ const L = [];
175
+ const head = r.verdict === 'ALIGNED' ? `${ok} ALIGNED` : r.verdict === 'NOT-INSTALLED' ? `${warn}NOT INSTALLED` : `${warn}DRIFTED (${r.drifted} layer${r.drifted === 1 ? '' : 's'})`;
176
+ L.push(`${c.bold}# brain_doctor${c.rst} — ${head}`);
177
+ L.push('');
178
+
179
+ // VERSION
180
+ const vmark = r.layers.version === 'ok' ? ok : r.layers.version === 'absent' ? warn : warn;
181
+ L.push(`${vmark} ${c.bold}VERSION${c.rst} brain core ${c.bold}v${r.version.baked || '(not deployed)'}${c.rst}${r.version.channel ? ` ${c.dim}via ${r.version.channel}${c.rst}` : ''}${r.version.dev ? ` ${c.yel}dev${c.rst}` : ''}`);
182
+ if (r.version.dirty) L.push(` ${c.red}DIRTY — running uncommitted hook code (source ${String(r.version.sourceSha || '?').slice(0, 12)})${c.rst}`);
183
+ if (r.npm) L.push(` npm latest v${r.npm.latest} ${r.npm.matches === false ? c.yel + '⚠ installed brain is behind' + c.rst : r.npm.matches === true ? c.grn + '✓ current' + c.rst : c.dim + '(no baked version to compare)' + c.rst}`);
184
+
185
+ // HOOKS
186
+ const hmark = r.layers.hooks === 'ok' ? ok : warn;
187
+ if (!r.hooks.settingsPresent) L.push(`${hmark} ${c.bold}HOOKS${c.rst} no ~/.claude/settings.json found`);
188
+ else if (r.hooks.missing.length) L.push(`${hmark} ${c.bold}HOOKS${c.rst} half-wired — missing: ${c.yel}${r.hooks.missing.join(', ')}${c.rst} ${c.dim}(liveness up, readiness no)${c.rst}`);
189
+ else L.push(`${hmark} ${c.bold}HOOKS${c.rst} all 4 wired: ${r.hooks.wired.join(', ')}`);
190
+
191
+ // TOOLS
192
+ L.push(`${ok} ${c.bold}TOOLS${c.rst} ${r.tools.count} MCP verb(s)${r.tools.hash ? ` ${c.dim}[#${r.tools.hash}, ${r.tools.source}]${c.rst}` : ''}${r.tools.count ? `: ${c.dim}${r.tools.names.join(', ')}${c.rst}` : ''}`);
193
+
194
+ // PEERS
195
+ if (!r.peers.count) L.push(`${ok} ${c.bold}PEERS${c.rst} solo — no other live session ${c.dim}(the brain is shared, NOT live-merged)${c.rst}`);
196
+ else {
197
+ L.push(`${warn}${c.bold}PEERS${c.rst} ${r.peers.count} live ${c.dim}(shared, NOT live-merged — coordinate before committing)${c.rst}`);
198
+ for (const p of r.peers.live) L.push(` · ${p.id}${p.branch ? ' @' + p.branch : ''}${p.intent ? ` “${p.intent.slice(0, 50)}”` : ''} ${c.dim}(${p.lastSeenMin}m ago)${c.rst}`);
199
+ }
200
+
201
+ // HARNESS
202
+ if (!r.project.hasBrain) L.push(`${c.dim}· HARNESS no ./brain.klypix in ${r.project.dir} — projection n/a${c.rst}`);
203
+ else {
204
+ const cmark = r.layers.harness === 'ok' ? ok : warn;
205
+ if (r.harness.ok) L.push(`${cmark} ${c.bold}HARNESS${c.rst} all ${r.harness.files.length} projected file(s) in sync`);
206
+ else {
207
+ L.push(`${cmark} ${c.bold}HARNESS${c.rst} ${r.harness.drift.length} of ${r.harness.files.length} drifted:`);
208
+ for (const h of r.harness.drift) L.push(` · ${h.file} — ${c.yel}${h.status.toUpperCase()}${c.rst}${h.stampedVersion ? ` (stamped v${h.stampedVersion})` : ''}`);
209
+ }
210
+ }
211
+
212
+ if (r.actions.length) {
213
+ L.push('');
214
+ L.push(`${c.bold}reconcile:${c.rst}`);
215
+ for (const a of r.actions) L.push(' ' + a);
216
+ }
217
+ return L.join('\n');
218
+ }
219
+
220
+ // ── cross-project / cross-channel audit (--all) ─────────────────────────────────
221
+ // Superset of the legacy scripts/brain-doctor.mjs: every registered brain on this
222
+ // machine + its .mcp.json vault wiring (the SS2-class trap: a foreign absolute vault).
223
+ export function inspectAll(opts = {}) {
224
+ const home = opts.home || os.homedir();
225
+ const brainDir = path.join(home, '.claude', 'project-brain');
226
+ const reg = readJson(path.join(brainDir, 'registry.json'), null);
227
+ const brains = (Array.isArray(reg?.brains) ? reg.brains : []).filter(b => b && b.path);
228
+ let drift = 0;
229
+ const out = [];
230
+ for (const b of brains) {
231
+ const exists = fs.existsSync(b.path);
232
+ const dir = path.dirname(b.path);
233
+ const proj = b.project || path.basename(dir);
234
+ let vault = '(none → defaults)', vaultOk = true;
235
+ const cfg = readJson(path.join(dir, '.mcp.json'), null);
236
+ if (cfg) {
237
+ const args = cfg?.mcpServers?.['klypix-canvas']?.args || [];
238
+ const vi = args.indexOf('--vault');
239
+ vault = vi >= 0 ? args[vi + 1] : '(none → defaults)';
240
+ const norm = (p) => path.resolve(p).replace(/\\/g, '/').toLowerCase();
241
+ vaultOk = vault === '.' || (vi >= 0 && norm(path.resolve(dir, vault)) === norm(dir));
242
+ if (!vaultOk) drift++;
243
+ }
244
+ if (!exists) drift++;
245
+ out.push({ project: proj, path: b.path, exists, vault, vaultOk, hasMcp: !!cfg });
246
+ }
247
+ return { brains: out, drift };
248
+ }
@@ -844,8 +844,16 @@ async function capture(lib) {
844
844
  // 🧠 MSG [to]: text — an async note to another session (not a brain card).
845
845
  const mg = MSG_RE.exec(trimmed);
846
846
  if (mg) {
847
+ const to = (mg[1] || '').trim();
847
848
  const txt = (mg[2] || '').trim();
848
- if (txt) messages.push({ id: sha(sid + '|' + txt + '|' + Date.now() + '|' + Math.random()), from: sid, to: (mg[1] || '').trim() || 'all', text: txt.slice(0, 400), ts: Date.now(), seen: [] });
849
+ // Skip the feature's OWN documentation/examples, not a real note: a
850
+ // placeholder target/text (<to>, <text>, <their-id…> — real targets are
851
+ // id-prefixes/branches and never contain <>) or a marker quoted inside an
852
+ // inline-code span (`…🧠 MSG…`, i.e. a backtick precedes the marker).
853
+ const isExample = /[<>\s]/.test(to) // real targets are ONE token (id/branch/all/*) — <>, spaces ⇒ a doc example
854
+ || /^\s*<[^>]+>/.test(txt)
855
+ || /`/.test(trimmed.slice(0, trimmed.indexOf('🧠')));
856
+ if (txt && !isExample) messages.push({ id: sha(sid + '|' + txt + '|' + Date.now() + '|' + Math.random()), from: sid, to: to || 'all', text: txt.slice(0, 400), ts: Date.now(), seen: [] });
849
857
  continue;
850
858
  }
851
859
  const m = MARKER.exec(trimmed); if (!m) continue;
@@ -1242,6 +1250,35 @@ function selfCheckFooter() {
1242
1250
  } catch { return ''; }
1243
1251
  }
1244
1252
 
1253
+ // ── Readiness footer — catch a HALF-WIRED install (liveness ≠ readiness) ─────
1254
+ // SessionStart firing proves the brain is ALIVE; but the OTHER three hooks are what
1255
+ // make it LEARN — UserPromptSubmit (recall), Stop (capture), PostToolUse (live sync).
1256
+ // A settings.json edit, a partial install, or a manual hook-prune can drop any of them,
1257
+ // leaving the brain reading-but-not-capturing — invisible until cards silently go
1258
+ // missing. This reads settings.json and says so in ONE line when drifted. Cheap +
1259
+ // namespace-safe (no version compare, no network); never throws. The full picture
1260
+ // (version currency, harness-projection drift, live peers) lives in `npx klypix-mcp
1261
+ // doctor` — the footer only carries the one signal worth interrupting every session for.
1262
+ function doctorFooter() {
1263
+ try {
1264
+ const SETTINGS = path.join(os.homedir(), '.claude', 'settings.json');
1265
+ if (!fs.existsSync(SETTINGS)) return '';
1266
+ let settings; try { settings = JSON.parse(fs.readFileSync(SETTINGS, 'utf8')); } catch { return ''; }
1267
+ const wiredFor = (evt) => {
1268
+ const groups = settings?.hooks?.[evt];
1269
+ return Array.isArray(groups) && groups.some(g => Array.isArray(g?.hooks)
1270
+ && g.hooks.some(h => typeof h?.command === 'string' && h.command.includes('global-brain-hook')));
1271
+ };
1272
+ const missing = [['UserPromptSubmit', 'per-prompt recall'], ['Stop', 'decision capture'], ['PostToolUse', 'live sync']]
1273
+ .filter(([evt]) => !wiredFor(evt));
1274
+ if (!missing.length) return '';
1275
+ return '\n\n---\n## ⚠️ Brain half-wired — readiness\n'
1276
+ + `SessionStart fired (the brain is alive), but ${missing.length} hook(s) that make it LEARN are not wired: `
1277
+ + missing.map(([evt, what]) => `**${evt}** (${what})`).join(', ') + '.\n'
1278
+ + 'The brain will read but silently stop capturing/syncing decisions. Re-wire: `npx klypix-mcp install`, then restart this session.\n';
1279
+ } catch { return ''; }
1280
+ }
1281
+
1245
1282
  // ── Self-healing brain (decision lifecycle, part 5) ──────────────────────────
1246
1283
  // Open ❓/🎯 cards a later shipped 🏁 milestone appears to have fulfilled —
1247
1284
  // surfaced so the agent CLOSES them (✓ / closes:) instead of recall surfacing
@@ -1289,7 +1326,7 @@ async function read(lib) {
1289
1326
  : lib.structToMarkdown(struct);
1290
1327
  // ⚡ In-flight footer goes RIGHT AFTER the brief (highest signal: what a peer
1291
1328
  // shipped seconds ago, before it's in the brain) — closes the 1.3.17-blindness gap.
1292
- process.stdout.write(outStr + inflightFooter(input.session_id, struct) + selfHealFooter(drifted) + reconcileFooter(lib, struct) + staleOpenFooter(lib, struct) + selfCheckFooter() + messageFooter(input.session_id || '') + legendFooter() + memoryFooter());
1329
+ process.stdout.write(outStr + inflightFooter(input.session_id, struct) + selfHealFooter(drifted) + reconcileFooter(lib, struct) + staleOpenFooter(lib, struct) + selfCheckFooter() + doctorFooter() + messageFooter(input.session_id || '') + legendFooter() + memoryFooter());
1293
1330
  // Heartbeat: prove the brief actually injected (and how big) so a dead or
1294
1331
  // stale live-copy of the hook stops being a silent no-op.
1295
1332
  appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode: 'read', ok: true, briefBytes: Buffer.byteLength(outStr), cards: struct?.counts?.cards ?? null }, 500);