klypix-mcp 1.10.1 → 1.12.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,38 @@
1
+ #!/usr/bin/env node
2
+ // klypix-link — make THIS project's brain automatic for EVERY agent tool.
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.
8
+ //
9
+ // cd your-project && npx klypix-mcp link
10
+ //
11
+ // Synchronous top-level by design: the dispatcher does `await import(this); process.exit(0)`,
12
+ // so all work (and its logs) must complete during module evaluation, before exit.
13
+ import path from 'path';
14
+ import { linkProject } from '../src/agent-rules.mjs';
15
+
16
+ try {
17
+ const dirArg = process.argv.slice(3).find(a => !a.startsWith('-'));
18
+ const projectDir = path.resolve(dirArg || process.cwd());
19
+ const { rules, mcp, hasBrain } = linkProject(projectDir);
20
+
21
+ const mark = (a) => a === 'unchanged' ? '·' : a === 'skipped' ? '⚠' : '✓';
22
+ console.log(`klypix — linking the brain to every agent tool in ${projectDir}\n`);
23
+ console.log(' MCP server (so each tool can reach the brain):');
24
+ for (const r of mcp) console.log(` ${mark(r.action)} ${r.tool.padEnd(26)} ${r.file}${r.why ? ' (' + r.why + ')' : ''}`);
25
+ console.log('\n Rules (so each tool auto-reads + captures the brain):');
26
+ for (const r of rules) console.log(` ${mark(r.action)} ${r.tool.padEnd(26)} ${r.file}${r.why ? ' (' + r.why + ')' : ''}`);
27
+
28
+ const changed = [...rules, ...mcp].filter(r => r.action && !['unchanged', 'skipped'].includes(r.action)).length;
29
+ console.log(`\n✓ ${changed} file(s) written/updated — every agent opened in this project now reads + captures ./brain.klypix.`);
30
+ console.log(' Cline & Windsurf MCP servers live in their global config; the rules file points them at the brain regardless.');
31
+ if (!hasBrain) {
32
+ console.log('\n⚠ No ./brain.klypix here yet — the rules reference it for when you create one.');
33
+ console.log(' Make one in the KLYPIX app (Canvas → Save as brain) or with the create_canvas MCP tool.');
34
+ }
35
+ } catch (e) {
36
+ console.error(`✗ link failed: ${e?.message || e}`);
37
+ process.exit(1);
38
+ }
@@ -41,6 +41,12 @@ const log = (...a) => console.error('[klypix-mcp]', ...a);
41
41
  // server setup; delegates to the dedicated bin so `npx klypix-install` also works.
42
42
  if (process.argv[2] === 'install') { await import('./klypix-install.mjs'); process.exit(0); }
43
43
 
44
+ // `npx klypix-mcp link` — make THIS project's brain automatic for EVERY agent tool,
45
+ // not just Claude Code: drop each tool's native MCP config + rules file (Cursor, Cline,
46
+ // Windsurf, Copilot/VS Code, AGENTS.md) so any agent opened here reads + captures the
47
+ // brain on its own. Project-scoped (cwd); idempotent. Runs before any server setup.
48
+ if (process.argv[2] === 'link') { await import('./klypix-link.mjs'); process.exit(0); }
49
+
44
50
  // `npx klypix-mcp init` — 60-second onboarding: seed a starter project brain in
45
51
  // the current folder so a new user's FIRST contact isn't an empty vault, then
46
52
  // 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.10.1",
3
+ "version": "1.12.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",
@@ -26,6 +26,7 @@
26
26
  },
27
27
  "bin": {
28
28
  "klypix-mcp": "bin/klypix-mcp.mjs",
29
+ "klypix-link": "bin/klypix-link.mjs",
29
30
  "klypix-a2a": "bin/klypix-a2a.mjs",
30
31
  "klypix-read": "bin/klypix-read.mjs",
31
32
  "klypix-write": "bin/klypix-write.mjs",
@@ -0,0 +1,126 @@
1
+ // agent-rules — make a project's brain.klypix AUTOMATIC for EVERY agent tool, not
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:
5
+ //
6
+ // • an MCP server config → the agent CAN reach the brain's tools
7
+ // • a rules / instructions → the agent is TOLD to read the brain at task start and
8
+ // capture decisions, on every session, automatically
9
+ //
10
+ // Agent-neutral by construction: the brain DATA is one shared brain.klypix; this just
11
+ // 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.
14
+ import fs from 'fs';
15
+ 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*-->/;
20
+
21
+ // The one canonical instruction every agent gets. Tool-and-CLI dual so it works whether
22
+ // or not the agent has the klypix-canvas MCP wired.
23
+ const BRAIN_INSTRUCTIONS = `## KLYPIX project brain
24
+
25
+ This project has a **spatial brain** at \`./brain.klypix\` — the living memory of its
26
+ decisions, open questions, and findings (the shared human↔agent memory for this repo).
27
+ Treat it as authoritative project context.
28
+
29
+ **At the start of a task — read it** so you know the project's state and past decisions:
30
+ - with the \`klypix-canvas\` MCP server: call \`search_canvases\` / \`read_canvas\` (canvas: \`"brain"\`), or \`brain_insights\` for the load-bearing cards.
31
+ - or via CLI: \`npx klypix-read brain.klypix\`
32
+
33
+ **When you make a real decision, finding, or milestone — capture it** so it persists for the next session/agent:
34
+ - with MCP: call \`brain_note\` with a one-line decision, or
35
+ - emit a marker line in your output: \`🧠 BRAIN [Area]: <one-line decision>\`, or
36
+ - via CLI: \`echo "🧠 BRAIN [Area]: <decision>" | npx klypix-append brain.klypix\`
37
+
38
+ Capture **sparingly** — real decisions/milestones, not routine steps. Link related cards with \`[[other-card]]\`.
39
+
40
+ **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
+
42
+ const fencedBlock = () => `${FENCE_START}\n${BRAIN_INSTRUCTIONS}\n${FENCE_END}`;
43
+
44
+ const exists = (p) => { try { fs.statSync(p); return true; } catch { return false; } };
45
+ const ensureDir = (p) => fs.mkdirSync(path.dirname(p), { recursive: true });
46
+
47
+ // Shared markdown (AGENTS.md, copilot-instructions.md): merge our fenced block in place.
48
+ function fenceMerge(file) {
49
+ let cur = '';
50
+ let had = false;
51
+ if (exists(file)) { cur = fs.readFileSync(file, 'utf8'); had = true; }
52
+ const block = fencedBlock();
53
+ let next;
54
+ let action;
55
+ if (FENCE_RE.test(cur)) { next = cur.replace(FENCE_RE, block); action = 'updated'; }
56
+ else if (cur.trim()) { next = cur.replace(/\s*$/, '') + '\n\n' + block + '\n'; action = 'merged'; }
57
+ else { next = block + '\n'; action = had ? 'merged' : 'created'; }
58
+ if (next === cur) return { action: 'unchanged' };
59
+ ensureDir(file); fs.writeFileSync(file, next, 'utf8');
60
+ return { action };
61
+ }
62
+
63
+ // 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' };
67
+ ensureDir(file); fs.writeFileSync(file, body, 'utf8');
68
+ return { action: exists(file) ? 'updated' : 'created' };
69
+ }
70
+
71
+ // Project-level MCP config: add the klypix-canvas server, preserving any sibling servers.
72
+ // wrapKey differs by tool: Cursor/Claude use "mcpServers"; VS Code uses "servers".
73
+ function mergeMcpJson(file, wrapKey, withType) {
74
+ const entry = withType
75
+ ? { type: 'stdio', command: 'npx', args: ['-y', 'klypix-mcp', '--vault', '.'] }
76
+ : { command: 'npx', args: ['-y', 'klypix-mcp', '--vault', '.'] };
77
+ let cfg = {};
78
+ if (exists(file)) {
79
+ const raw = fs.readFileSync(file, 'utf8');
80
+ if (raw.trim()) { try { cfg = JSON.parse(raw); } catch { return { action: 'skipped', why: 'invalid JSON — left untouched' }; } }
81
+ }
82
+ if (!cfg[wrapKey] || typeof cfg[wrapKey] !== 'object' || Array.isArray(cfg[wrapKey])) cfg[wrapKey] = {};
83
+ const before = JSON.stringify(cfg[wrapKey]['klypix-canvas']);
84
+ cfg[wrapKey]['klypix-canvas'] = entry;
85
+ if (JSON.stringify(cfg[wrapKey]['klypix-canvas']) === before) return { action: 'unchanged' };
86
+ ensureDir(file); fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
87
+ return { action: before === undefined ? 'created' : 'updated' };
88
+ }
89
+
90
+ /**
91
+ * Wire a project so EVERY agent tool reads + captures its brain automatically.
92
+ * @param {string} projectDir absolute project root (holds ./brain.klypix)
93
+ * @returns {{ rules: Array, mcp: Array, hasBrain: boolean }}
94
+ */
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 };
124
+ }
125
+
126
+ export { BRAIN_INSTRUCTIONS };
@@ -278,7 +278,8 @@ function touchSession(sid, patch = {}) {
278
278
  const now = Date.now();
279
279
  const got = acquireLock(SESSIONS_LOCK, { tries: 20, waitMs: 25 }); // ~0.5s budget then best-effort
280
280
  try {
281
- const all = pruneSessions(readSessions(), now);
281
+ let data0 = {}; try { data0 = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf8')); } catch { /* fresh */ }
282
+ const all = pruneSessions(Array.isArray(data0.sessions) ? data0.sessions : [], now);
282
283
  const prev = all.find(s => s.id === sid) || {};
283
284
  const list = all.filter(s => s.id !== sid);
284
285
  list.push({
@@ -292,7 +293,10 @@ function touchSession(sid, patch = {}) {
292
293
  startedAt: prev.startedAt || now, lastSeen: now,
293
294
  });
294
295
  fs.mkdirSync(SESSIONS_DIR, { recursive: true });
295
- fs.writeFileSync(SESSIONS_FILE, JSON.stringify({ sessions: list.slice(-20) }));
296
+ // Preserve the messages lane — touchSession runs AFTER postMessages in a
297
+ // capture, and dropping the field here would clobber a just-posted note.
298
+ const keptMsgs = (Array.isArray(data0.messages) ? data0.messages : []).filter(m => m && (now - (m.ts || 0) < MSG_FRESH_MS));
299
+ fs.writeFileSync(SESSIONS_FILE, JSON.stringify({ sessions: list.slice(-20), messages: keptMsgs }));
296
300
  } finally { if (got) releaseLock(SESSIONS_LOCK); }
297
301
  } catch { /* coordination is best-effort */ }
298
302
  }
@@ -335,6 +339,64 @@ function peerFooter(sid) {
335
339
  return lines.join('\n');
336
340
  }
337
341
 
342
+ // ── Brain messaging — async agent↔agent notes via the shared per-project lane ─
343
+ // The COMPLEMENT to the live-ledger: the ledger surfaces a peer's AUTOMATIC high-
344
+ // signal events (ships, version bumps); this lets a session leave a DELIBERATE,
345
+ // TARGETED note for another ("merged the hook refactor — rebase your PR first").
346
+ // SEND by emitting `🧠 MSG [<to>]: <text>` in a reply (to = a peer's id-prefix or
347
+ // branch, omitted = all); the Stop hook posts it to the lane, and the recipient's
348
+ // next prompt/session-start surfaces it ONCE (ack'd under the lane lock). Still
349
+ // async (delivered at the peer's next hook event), by design — not real-time.
350
+ const MSG_RE = /🧠\s*MSG\s*(?:\[([^\]]*)\])?\s*:\s*(.+)$/i;
351
+ const MSG_FRESH_MS = 24 * 60 * 60 * 1000;
352
+ function postMessages(msgs) {
353
+ if (!msgs || !msgs.length) return;
354
+ const got = acquireLock(SESSIONS_LOCK, { tries: 20, waitMs: 25 });
355
+ try {
356
+ let data = {}; try { data = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf8')); } catch { /* fresh */ }
357
+ const sessions = Array.isArray(data.sessions) ? data.sessions : [];
358
+ const now = Date.now();
359
+ const kept = (Array.isArray(data.messages) ? data.messages : []).filter(m => m && (now - (m.ts || 0) < MSG_FRESH_MS));
360
+ for (const m of msgs) kept.push(m);
361
+ fs.mkdirSync(SESSIONS_DIR, { recursive: true });
362
+ fs.writeFileSync(SESSIONS_FILE, JSON.stringify({ sessions, messages: kept.slice(-30) }));
363
+ } catch { /* best-effort */ } finally { if (got) releaseLock(SESSIONS_LOCK); }
364
+ }
365
+ // to==='all'/'' → everyone; else the hint must appear in my id-prefix / branch / intent.
366
+ function msgTargetsMe(m, me, sid) {
367
+ const to = String(m.to || '').trim().toLowerCase();
368
+ if (!to || to === 'all' || to === '*') return true;
369
+ return [String(sid || '').slice(0, 8), me?.branch || '', me?.intent || ''].join(' ').toLowerCase().includes(to);
370
+ }
371
+ // Surface unseen messages addressed to me, mark them seen (delivered once) under lock.
372
+ function messageFooter(sid) {
373
+ if (!sid) return '';
374
+ let data = {}; try { data = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf8')); } catch { return ''; }
375
+ const all = Array.isArray(data.messages) ? data.messages : [];
376
+ if (!all.length) return '';
377
+ const now = Date.now();
378
+ const me = (Array.isArray(data.sessions) ? data.sessions : []).find(s => s.id === sid);
379
+ const unseen = all.filter(m => m && (now - (m.ts || 0) < MSG_FRESH_MS) && m.from !== sid
380
+ && !(Array.isArray(m.seen) && m.seen.includes(sid)) && msgTargetsMe(m, me, sid));
381
+ if (!unseen.length) return '';
382
+ const got = acquireLock(SESSIONS_LOCK, { tries: 15, waitMs: 25 }); // ack under lock so a peer read can't lose it
383
+ try {
384
+ let d2 = {}; try { d2 = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf8')); } catch { /* */ }
385
+ const ids = new Set(unseen.map(u => u.id));
386
+ for (const m of (Array.isArray(d2.messages) ? d2.messages : [])) {
387
+ if (!ids.has(m.id)) continue;
388
+ if (!Array.isArray(m.seen)) m.seen = [];
389
+ if (!m.seen.includes(sid)) m.seen.push(sid);
390
+ }
391
+ fs.writeFileSync(SESSIONS_FILE, JSON.stringify(d2));
392
+ } catch { /* */ } finally { if (got) releaseLock(SESSIONS_LOCK); }
393
+ const ago = (ts) => { const mm = Math.max(0, Math.round((now - (ts || now)) / 60000)); return mm <= 0 ? 'just now' : `${mm}m ago`; };
394
+ const out = ['', '## 📨 Message(s) from another session in this project (delivered once — act on or reply to them)'];
395
+ for (const m of unseen.slice(0, 6)) out.push(`- from ${String(m.from || '?').slice(0, 8)} · ${ago(m.ts)}: ${String(m.text).replace(/\s+/g, ' ').trim().slice(0, 200)}`);
396
+ out.push('Reply with `🧠 MSG [<their-id or all>]: <text>` — it reaches them on their next prompt.');
397
+ return '\n' + out.join('\n');
398
+ }
399
+
338
400
  // Pull assistant text out of one transcript line (Claude Code JSONL: content is
339
401
  // a string or an array of {type:'text',text}).
340
402
  function textOf(entry) {
@@ -755,6 +817,8 @@ async function capture(lib) {
755
817
  const resolutions = [];
756
818
  const updates = [];
757
819
  const ledger = []; // one entry per marker decision (observability)
820
+ const messages = []; // 🧠 MSG markers → posted to the per-project lane
821
+ const sid = input.session_id || '';
758
822
  // Rolling set of the most-recently-touched file/dir tags (deduped, newest
759
823
  // last). A marker emitted after some edits gets tagged with the files that
760
824
  // work touched — captured by scanning EVERY entry's tool_use, not just the
@@ -776,7 +840,15 @@ async function capture(lib) {
776
840
  const text = textOf(e);
777
841
  if (!text.includes('🧠')) continue;
778
842
  for (const raw of text.split('\n')) {
779
- const m = MARKER.exec(raw.trim()); if (!m) continue;
843
+ const trimmed = raw.trim();
844
+ // 🧠 MSG [to]: text — an async note to another session (not a brain card).
845
+ const mg = MSG_RE.exec(trimmed);
846
+ if (mg) {
847
+ 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
+ continue;
850
+ }
851
+ const m = MARKER.exec(trimmed); if (!m) continue;
780
852
  const area = (m[1] || '').trim(), type = m[2] || '';
781
853
  let body = m[3].trim(); if (!body) continue;
782
854
  // Strip optional `closes:` / `ev:` suffixes off the body so they don't
@@ -833,6 +905,7 @@ async function capture(lib) {
833
905
  ledger.push({ action: type === '?' ? 'add-question' : type === '!' ? 'add-milestone' : isSkill ? 'add-skill' : 'add-decision', area, preview, files: fileTags, ...(closes ? { closes } : {}), ...(evidence ? { ev: evidence.map(e => e.ref) } : {}) });
834
906
  }
835
907
  }
908
+ postMessages(messages); // deliver any 🧠 MSG notes to peers' lanes (even if no cards this turn)
836
909
  // Ship-event auto-capture: deterministic high-signal events (PR merges, releases,
837
910
  // npm publishes, tags) from SUCCESSFUL shell calls in the transcript — no marker
838
911
  // required (the gap a concurrent session hit: 3 releases + 6 PRs → zero cards).
@@ -1015,6 +1088,7 @@ async function promptRetrieve(lib) {
1015
1088
  // Other live sessions in THIS repo — surfaced even when the prompt retrieves
1016
1089
  // nothing (a peer's presence/ship is itself the signal). Empty string when solo.
1017
1090
  const peers = peerFooter(sid);
1091
+ const messages = messageFooter(sid); // 📨 deliberate notes another session left for me (delivered once)
1018
1092
  let hits = [], repeats = [], struct = null;
1019
1093
  if (tokens.length) {
1020
1094
  struct = await cachedStruct(lib);
@@ -1061,7 +1135,7 @@ async function promptRetrieve(lib) {
1061
1135
  // Other live sessions' in-flight events (struct may be null on a token-less
1062
1136
  // prompt — then representedInBrain just keeps entries; they're rarely in-brain yet).
1063
1137
  const inflight = inflightFooter(sid, struct);
1064
- if (!repeats.length && !freshHits.length && !peers && !inflight) return; // nothing → zero output, zero added context
1138
+ if (!repeats.length && !freshHits.length && !peers && !inflight && !messages) return; // nothing → zero output, zero added context
1065
1139
  const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
1066
1140
  const day = (ts) => ts ? new Date(ts).toISOString().slice(0, 10) : '';
1067
1141
  const head = (c, n = 120) => { const t = flat(c.text); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
@@ -1086,6 +1160,7 @@ async function promptRetrieve(lib) {
1086
1160
  if (lines.length) parts.push(lines.join('\n'));
1087
1161
  if (inflight) parts.push(inflight.replace(/^\n+/, '')); // ⚡ in-flight peers' events (its own block)
1088
1162
  if (peers) parts.push(peers.replace(/^\n+/, '')); // live-session presence footer (its own block)
1163
+ if (messages) parts.push(messages.replace(/^\n+/, '')); // 📨 inbound deliberate peer notes
1089
1164
  process.stdout.write(parts.join('\n\n') + '\n'); // UserPromptSubmit injects stdout as context
1090
1165
  }
1091
1166
 
@@ -1196,7 +1271,8 @@ function legendFooter() {
1196
1271
  return '\n\n---\n'
1197
1272
  + '🧠 **Capture markers** — write these in your reply; the Stop hook harvests them into the brain (no separate log step). Use sparingly, for real decisions / milestones / discoveries:\n'
1198
1273
  + '`🧠 BRAIN [Area]: decision` · `[Area] ?: open question` · `[Area] !: milestone` · `[Area] +: 🛠️ skill (reusable how-to / gotcha — resurfaces every session, never ages out)` · `[Area] ✓: resolves+archives the matching card` · `[Area] ~: updates it in place` · 🎯 in text = a goal (reads as open).\n'
1199
- + 'Optional suffixes: `closes: <card title / [[wikilink]]>` (resolve the strategy/question this fulfils) · `ev: <file[:line]>, PR#<n>` (anchor to code → auto drift-badge).\n';
1274
+ + 'Optional suffixes: `closes: <card title / [[wikilink]]>` (resolve the strategy/question this fulfils) · `ev: <file[:line]>, PR#<n>` (anchor to code → auto drift-badge).\n'
1275
+ + 'Coordinate with a concurrent session: `🧠 MSG [<their-id or all>]: <text>` — a one-time note (NOT a brain card) delivered to that session on its next prompt.\n';
1200
1276
  }
1201
1277
 
1202
1278
  async function read(lib) {
@@ -1213,7 +1289,7 @@ async function read(lib) {
1213
1289
  : lib.structToMarkdown(struct);
1214
1290
  // ⚡ In-flight footer goes RIGHT AFTER the brief (highest signal: what a peer
1215
1291
  // shipped seconds ago, before it's in the brain) — closes the 1.3.17-blindness gap.
1216
- process.stdout.write(outStr + inflightFooter(input.session_id, struct) + selfHealFooter(drifted) + reconcileFooter(lib, struct) + staleOpenFooter(lib, struct) + selfCheckFooter() + legendFooter() + memoryFooter());
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());
1217
1293
  // Heartbeat: prove the brief actually injected (and how big) so a dead or
1218
1294
  // stale live-copy of the hook stops being a silent no-op.
1219
1295
  appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode: 'read', ok: true, briefBytes: Buffer.byteLength(outStr), cards: struct?.counts?.cards ?? null }, 500);