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.
- package/bin/klypix-doctor.mjs +63 -0
- package/bin/klypix-install.mjs +10 -1
- package/bin/klypix-link.mjs +30 -8
- package/bin/klypix-mcp.mjs +5 -0
- package/package.json +2 -1
- package/src/agent-rules.mjs +154 -48
- package/src/brain-doctor.mjs +248 -0
- package/src/global-brain-hook.mjs +39 -2
|
@@ -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
|
+
}
|
package/bin/klypix-install.mjs
CHANGED
|
@@ -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);
|
package/bin/klypix-link.mjs
CHANGED
|
@@ -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,
|
|
5
|
-
// AGENTS.md-reading agent, by dropping each tool's native MCP config +
|
|
6
|
-
// the current project. Idempotent — re-run anytime to refresh.
|
|
7
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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.');
|
package/bin/klypix-mcp.mjs
CHANGED
|
@@ -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.
|
|
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",
|
package/src/agent-rules.mjs
CHANGED
|
@@ -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,
|
|
4
|
-
// AGENTS.md cross-tool standard) have no hook system — so we
|
|
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
|
|
13
|
-
// clobbering the user's own content. Pure fs/path; never throws for one bad
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
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
|
-
|
|
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
|
-
//
|
|
48
|
-
function
|
|
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
|
-
|
|
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:
|
|
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
|
-
* @
|
|
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
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
{ tool:
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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);
|