klypix-mcp 1.4.1 → 1.6.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.
@@ -23,7 +23,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
23
23
  import {
24
24
  resolveVault, getEmbedder, buildKlypixMap, cardSchema, connSchema,
25
25
  opListCanvases, opReadCanvas, opSearchCanvases, opSearchAllBrains,
26
- opBrainInsights, opBrainConnect, opCreateCanvas, opAddToCanvas,
26
+ opBrainInsights, opBrainConnect, opBrainReconcile, opCreateCanvas, opAddToCanvas, opBrainNote,
27
27
  } from '../src/klypix-core.mjs';
28
28
 
29
29
  // IMPORTANT: stdout is the JSON-RPC channel. Never console.log — only stderr.
@@ -112,6 +112,15 @@ server.registerTool('brain_connect', {
112
112
  },
113
113
  }, async ({ canvas, apply, max, threshold }) => toContent(await opBrainConnect({ vault: VAULT, canvas, apply, max, threshold, log })));
114
114
 
115
+ server.registerTool('brain_reconcile', {
116
+ title: 'Reconcile the brain against committed migrations (find unrecorded rollouts)',
117
+ description: 'External-state check the brain otherwise CANNOT do: a brain only knows facts someone narrated (a marker, a commit body), so a DB migration APPLIED to prod — which narrates nothing — silently never lands. This lists committed migration files (Supabase / Rails / Prisma / Knex / generic) under the project and flags any that NO brain card references, so you can confirm the rollout with one marker. It reads ONLY the filesystem — never the database, never the network — and never claims a migration was applied, only that it is unrecorded. Defaults to the migrations dir beside the project brain; pass root to point elsewhere. Run it when you want to be sure the brain reflects what actually shipped.',
118
+ inputSchema: {
119
+ canvas: z.string().optional().describe('Brain canvas filename/path. Defaults to the project brain ("brain").'),
120
+ root: z.string().optional().describe("Project root holding the migrations dir (default: the brain file's folder)."),
121
+ },
122
+ }, async ({ canvas, root }) => toContent(await opBrainReconcile({ vault: VAULT, canvas, root })));
123
+
115
124
  server.registerTool('create_canvas', {
116
125
  title: 'Create a KLYPIX canvas',
117
126
  description: 'Create a new .klypix canvas from cards + connections and save it to the vault. The user opens it in KLYPIX (Canvas → Open). Prefer short, titled cards (one idea each) connected by meaningful arrows.',
@@ -138,6 +147,21 @@ server.registerTool('add_to_canvas', {
138
147
  return toContent(await opAddToCanvas({ vault: VAULT, canvas, cards, connections, via }));
139
148
  });
140
149
 
150
+ server.registerTool('brain_note', {
151
+ title: 'Write a deliberate note to the project brain (decision / question / milestone / resolve / update)',
152
+ description: 'Record something in the project brain ON DEMAND — the agent-neutral twin of the Claude-Code capture hook, so any client (Cursor / Cline / Desktop) can write the brain, not just read it. Unlike add_to_canvas (a flat append), this routes through the brain\'s capture engine, so a new decision SUPERSEDES a heavily-overlapping older one, ✓ RESOLVES/archives a matching card, closes: resolves the strategy/question a milestone fulfils, and ~ UPDATES a card in place — the full decision lifecycle, with dedup. Use it to remember a decision, ask an open question, mark a milestone, resolve a finished item, or correct a card. Defaults to the project brain ("brain").',
153
+ inputSchema: {
154
+ text: z.string().describe('The note — one concise idea; the first line becomes the card title.'),
155
+ marker: z.enum(['', '?', '!', '✓', '~']).optional().describe('(none)=decision · ?=open question · !=milestone · ✓=resolve+archive the best-matching card · ~=update the matching card in place. Default: decision.'),
156
+ area: z.string().optional().describe('Area/topic — routes the card into that titled container and becomes a #tag (e.g. "Auth", "Release").'),
157
+ closes: z.string().optional().describe('Title or [[wikilink]] of a strategy/question card this note fulfils — resolves+archives it and draws a "closed by" arrow.'),
158
+ canvas: z.string().optional().describe('Brain canvas filename/path. Defaults to the project brain ("brain").'),
159
+ },
160
+ }, async ({ text, marker, area, closes, canvas }) => {
161
+ let via; try { via = server.server.getClientVersion()?.name; } catch { /* optional */ }
162
+ return toContent(await opBrainNote({ vault: VAULT, canvas, text, area, marker: marker || '', closes, via }));
163
+ });
164
+
141
165
  const transport = new StdioServerTransport();
142
166
  await server.connect(transport);
143
167
  log(`ready · vault=${VAULT}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.4.1",
3
+ "version": "1.6.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",
@@ -24,6 +24,7 @@ import { z } from 'zod';
24
24
  import {
25
25
  parseKlypix, buildKlypix, buildKlypixMap, appendToKlypix, structToMarkdown,
26
26
  brainInsights, insightsToMarkdown, addBrainConnections, proposeStructuralConnections, atomicWrite,
27
+ findUnrecordedMigrations, captureIntoBrain, tidyBrain, noteToCaptureInput,
27
28
  } from './klypix-format.mjs';
28
29
 
29
30
  // ── Card / connection input shape (single source for every face) ─────────────
@@ -109,8 +110,16 @@ export function getEmbedder(log = () => {}) {
109
110
  let t;
110
111
  try { t = await import('@huggingface/transformers'); }
111
112
  catch {
112
- const local = path.join(PB_DIR, 'semantic', 'node_modules', '@huggingface', 'transformers', 'dist', 'transformers.mjs');
113
- t = await import(new URL('file:///' + local.replace(/\\/g, '/')).href);
113
+ // The optional dep ships dist/transformers.node.mjs on v4 (Node build) and
114
+ // dist/transformers.mjs on older lines — try both so a correct one-click
115
+ // install resolves regardless of version.
116
+ const base = path.join(PB_DIR, 'semantic', 'node_modules', '@huggingface', 'transformers', 'dist');
117
+ let lastErr;
118
+ for (const f of ['transformers.node.mjs', 'transformers.mjs']) {
119
+ try { t = await import(new URL('file:///' + path.join(base, f).replace(/\\/g, '/')).href); lastErr = null; break; }
120
+ catch (e) { lastErr = e; }
121
+ }
122
+ if (!t) throw lastErr;
114
123
  }
115
124
  t.env.cacheDir = path.join(PB_DIR, 'hf-cache');
116
125
  return await t.pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2', { dtype: 'q8' });
@@ -325,6 +334,46 @@ export async function opBrainInsights({ vault, canvas, staleDays }) {
325
334
  }
326
335
  }
327
336
 
337
+ // ── Migration reconcile (external-state omission tripwire) ────────────────────
338
+ // Lists committed migration files under a project root (Supabase / Rails / Prisma
339
+ // / Knex / generic layouts) and feeds them to the pure findUnrecordedMigrations(),
340
+ // returning the ones no live brain card records. Portable: pure fs, no DB, no
341
+ // network, no credentials — it flags "committed but unmentioned", NEVER claims
342
+ // "applied to prod". Degrades to a clean message for a project with no migrations.
343
+ const MIGRATION_DIRS = ['supabase/migrations', 'db/migrate', 'db/migrations', 'prisma/migrations', 'migrations'];
344
+ export function collectMigrationFiles(root) {
345
+ const out = [];
346
+ for (const rel of MIGRATION_DIRS) {
347
+ const abs = path.join(root, ...rel.split('/'));
348
+ let entries;
349
+ try { entries = fs.readdirSync(abs, { withFileTypes: true }); } catch { continue; }
350
+ for (const e of entries) {
351
+ if (e.isFile() && /\.sql$/i.test(e.name)) out.push(rel + '/' + e.name);
352
+ // Prisma nests each migration in its own folder holding a migration.sql.
353
+ else if (e.isDirectory()) {
354
+ try { if (fs.statSync(path.join(abs, e.name, 'migration.sql')).isFile()) out.push(rel + '/' + e.name + '/migration.sql'); } catch { /* not a prisma migration dir */ }
355
+ }
356
+ }
357
+ }
358
+ return out;
359
+ }
360
+ export async function opBrainReconcile({ vault, canvas, root }) {
361
+ const file = resolveCanvas(vault, canvas || 'brain') || resolveCanvas(vault, 'brain.klypix');
362
+ if (!file) return err(`No brain canvas found in ${vault}. Pass canvas: "<name>".`);
363
+ let struct;
364
+ try { ({ struct } = await parseKlypix(fs.readFileSync(file))); } catch (e) { return err(`Read failed: ${e.message}`); }
365
+ // Migrations live in the CODE repo (usually beside brain.klypix), not in a
366
+ // separate canvas vault — so default the root to the brain file's folder.
367
+ const repoRoot = root ? path.resolve(root) : path.dirname(file);
368
+ const files = collectMigrationFiles(repoRoot);
369
+ if (!files.length) return { blocks: [text(`No migration files under ${repoRoot} (looked in: ${MIGRATION_DIRS.join(', ')}). Nothing to reconcile.`)] };
370
+ const { gaps, total } = findUnrecordedMigrations(struct, files, { max: 20 });
371
+ if (!gaps.length) return { blocks: [text(`✓ All ${files.length} migration(s) under ${path.basename(repoRoot)} are referenced by a brain card — no unrecorded rollouts.`)] };
372
+ const lines = gaps.map(g => `- \`${g.path}\` — committed, but no brain card mentions it. If applied to prod, record it:\n \`🧠 BRAIN [DB] !: migration ${g.file.replace(/\.sql$/i, '')} applied to prod ev: ${g.path}\``);
373
+ const more = total > gaps.length ? `\n\n…and ${total - gaps.length} more.` : '';
374
+ return { blocks: [text(`# ⚠️ ${total} migration(s) committed but unrecorded in the brain\n_The brain can't see prod — it flags migrations that are in git but unmentioned, so you can confirm the rollout. It never asserts a migration was applied. To dismiss one without applying, record any card that names it (e.g. "committed, not applied")._\n\n${lines.join('\n')}${more}`)] };
375
+ }
376
+
328
377
  export async function opBrainConnect({ vault, canvas, apply = false, max = 24, threshold = 0.45, log = () => {} }) {
329
378
  const file = resolveCanvas(vault, canvas || 'brain') || resolveCanvas(vault, 'brain.klypix');
330
379
  if (!file) return err(`No brain canvas found in ${vault}.`);
@@ -424,5 +473,31 @@ export async function opAddToCanvas({ vault, canvas, cards, connections, via })
424
473
  }
425
474
  }
426
475
 
476
+ // brain_note — the DELIBERATE, marker-aware write every agent (not just the
477
+ // Claude-Code Stop hook) can make on demand. Routes through the SAME captureInto-
478
+ // Brain engine the hook uses, so supersede / resolve / close-link / dedup behave
479
+ // identically to a harvested 🧠 marker. The agent-neutral half of "the brain is an
480
+ // open file any agent reads AND writes": a hookless client (Cursor/Cline/Desktop)
481
+ // can now record a decision, ask an open question, mark a milestone, resolve a card,
482
+ // or correct one — with the full lifecycle, not just a flat append.
483
+ export async function opBrainNote({ vault, canvas, text: noteText, area, marker = '', closes, via }) {
484
+ const file = resolveCanvas(vault, canvas || 'brain') || resolveCanvas(vault, 'brain.klypix');
485
+ if (!file) return err(`No brain canvas found in ${vault}. Pass canvas: "<name>".`);
486
+ if (!noteText || !String(noteText).trim()) return err('brain_note needs a non-empty text.');
487
+ if (!['', '?', '!', '✓', '~'].includes(marker)) return err(`Invalid marker "${marker}" — use: (none)=decision · ?=open question · !=milestone · ✓=resolve a matching card · ~=update a matching card.`);
488
+ const input = noteToCaptureInput({ text: noteText, area, marker, closes: closes || '', createdVia: via || 'mcp' });
489
+ try {
490
+ const res = await captureIntoBrain(fs.readFileSync(file), input);
491
+ let out = res.buffer; try { out = (await tidyBrain(res.buffer)).buffer; } catch { /* keep append result if tidy fails */ }
492
+ await atomicWrite(file, out);
493
+ const s = res.stats || {};
494
+ const bits = [`${s.added || 0} added`];
495
+ for (const k of ['resolved', 'updated', 'closed', 'superseded', 'linked']) if (s[k]) bits.push(`${s[k]} ${k}`);
496
+ return { blocks: [text(`✓ brain_note → ${path.relative(vault, file)} (${bits.join(' · ')}). Reopen the brain in KLYPIX to see it.`)] };
497
+ } catch (e) {
498
+ return err(`brain_note failed (brain unchanged): ${e.message}`);
499
+ }
500
+ }
501
+
427
502
  // Re-export the format helpers the bins need for non-op work (init onboarding).
428
503
  export { buildKlypixMap, parseKlypix };
@@ -573,8 +573,11 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
573
573
  const live = texts.filter(c => !isArchived(c));
574
574
  const focus = live.filter(isFocus);
575
575
  const rest = live.filter(c => !isFocus(c));
576
- const open = rest.filter(c => /❓/.test(c.text));
577
- const miles = rest.filter(c => /🏁/.test(c.text) && !/❓/.test(c.text));
576
+ // 🎯 (goal/target) reads as an OPEN item alongside ❓ — a goal card is
577
+ // still-to-do until a ✓/closes: or a covering milestone closes it (so it
578
+ // must NOT masquerade as a plain decision that quietly ages out of view).
579
+ const open = rest.filter(c => /❓|🎯/.test(c.text));
580
+ const miles = rest.filter(c => /🏁/.test(c.text) && !/❓|🎯/.test(c.text));
578
581
  const plain = rest.filter(c => !open.includes(c) && !miles.includes(c));
579
582
  const recent = plain.filter(c => c.createdAt >= cutoff).sort((a, b) => b.createdAt - a.createdAt).slice(0, maxRecent);
580
583
  const archivedCount = texts.length - live.length;
@@ -607,7 +610,7 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
607
610
  push('', '## 📌 Human focus (cards the human placed here — act on these first)');
608
611
  for (const c of focus) push(`- ${fr(c)}${flat(c.text)}`);
609
612
  }
610
- if (open.length) { push('', '## Open questions'); for (const c of open) push(`- ${fr(c)}${flat(c.text)}`); }
613
+ if (open.length) { push('', '## Open questions & goals'); for (const c of open) push(`- ${fr(c)}${flat(c.text)}`); }
611
614
  // ⚠️ Conflicts — pairs flagged conflicts_with (e.g. by parallel sessions);
612
615
  // surfaced HIGH so the next session reconciles them, not buries them.
613
616
  const conflicts = (struct.connections || []).filter(c => c.relationship === 'conflicts_with');
@@ -688,6 +691,53 @@ export function scoreCardsAgainstQuery(struct, query, { topK = 6, minScore = 2,
688
691
  return scored.filter(s => s.score >= minScore).slice(0, topK);
689
692
  }
690
693
 
694
+ // ── External-state reconcile — migration omission tripwire ───────────────────
695
+ // The brain is a NARRATION-capture system: a fact exists only if someone wrote a
696
+ // 🧠 marker or a rationale-bearing commit body. Applying a DB migration to prod
697
+ // is an OBSERVED side-effect that narrates nothing, so it silently never lands —
698
+ // and the brain can't tell a *committed* migration from an *applied* one. These
699
+ // two pure functions are the portable seam that closes that blind spot WITHOUT
700
+ // making the brain omniscient: no I/O, no DB probe, no network, no credentials. A
701
+ // collector (the Claude-Code hook, or the brain_reconcile MCP tool) hands them
702
+ // the migration FILES found on disk; they return the ones NO LIVE card references,
703
+ // so the surface can PROMPT the human to confirm the rollout — never assert it.
704
+ // Recall-first by design: ANY plausible hit counts as "recorded", erring toward
705
+ // silence over a false nag.
706
+ const MIG_STOP = new Set(['migration', 'migrations', 'sql', 'create', 'alter', 'table', 'drop', 'add', 'update', 'init', 'schema', 'public', 'new', 'fix', 'set', 'col', 'column', 'index']);
707
+ // Split on EVERY non-alphanumeric (unlike the shared wordsOf, which keeps _- glued)
708
+ // so `20260620000000_canvas_blob_size_limit` and the `#file-…` tag both tokenize
709
+ // into the same words a card's prose carries — the precise match signal.
710
+ const migWords = (s) => new Set(String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').split(' ').filter(Boolean));
711
+ export function migrationSignature(file) {
712
+ const orig = String(file || '').replace(/\\/g, '/');
713
+ const base = orig.split('/').pop() || '';
714
+ const stem = base.replace(/\.[a-z0-9]+$/i, '');
715
+ const ts = (stem.match(/^\d{8,}/) || [''])[0]; // leading timestamp id (Supabase/Prisma/Knex)
716
+ const distinctive = stem.replace(/^\d{8,}[_-]?/, '') // drop the timestamp prefix → the descriptive name
717
+ .split(/[^a-z0-9]+/i).map(t => t.toLowerCase())
718
+ .filter(t => t.length >= 3 && !MIG_STOP.has(t));
719
+ return { path: orig, file: base, ts, distinctive };
720
+ }
721
+ // A migration is "recorded" if some LIVE (non-archived) card mentions its timestamp
722
+ // id OR every distinctive word of its name (so "canvas" alone never claims to record
723
+ // canvas_blob_size_limit, but a card carrying the #file-<stem> tag or the applied-
724
+ // marker prose does). Returns the UNrecorded ones (capped); [] when there are no
725
+ // migrations (plain projects stay silent).
726
+ export function findUnrecordedMigrations(struct, files, { max = 6 } = {}) {
727
+ const empty = { gaps: [], total: 0, scanned: 0 };
728
+ const sigs = (files || []).map(migrationSignature).filter(s => s.ts || s.distinctive.length);
729
+ if (!sigs.length || !struct || !Array.isArray(struct.cards)) return empty;
730
+ const isArchived = (c) => /^archive$/i.test(c.area || '');
731
+ const cardWords = struct.cards
732
+ .filter(c => c && c.type !== 'container' && !isArchived(c))
733
+ .map(c => migWords(String(c.text || '') + ' ' + (c.tags || []).join(' ')));
734
+ const recorded = (sig) => cardWords.some(ws =>
735
+ (sig.ts && ws.has(sig.ts))
736
+ || (sig.distinctive.length > 0 && sig.distinctive.every(t => ws.has(t))));
737
+ const unrecorded = sigs.filter(s => !recorded(s));
738
+ return { gaps: unrecorded.slice(0, max).map(s => ({ file: s.file, path: s.path, ts: s.ts })), total: unrecorded.length, scanned: sigs.length };
739
+ }
740
+
691
741
  // ── Repeat / redundancy detection ("you already did this in another session") ─
692
742
  // The PRECISION-first sibling of scoreCardsAgainstQuery. Instead of "related
693
743
  // context" it answers a sharper question: is the user about to REDO work that's
@@ -790,7 +840,7 @@ export function brainInsights(struct, { staleDays = 21, topHubs = 6 } = {}) {
790
840
  if (cn.toId) deg.set(cn.toId, (deg.get(cn.toId) || 0) + 1);
791
841
  }
792
842
  const headline = (c) => String(c.text || '').replace(/\s+/g, ' ').trim().replace(/^(.*?)([.!?](\s|$)|$)/, '$1').slice(0, 120);
793
- const isQuestion = (c) => /❓/.test(c.text);
843
+ const isQuestion = (c) => /❓|🎯/.test(c.text); // ❓ open question + 🎯 goal both read as "open"
794
844
  const hubs = live
795
845
  .map(c => ({ id: c.id, area: c.area, degree: deg.get(c.id) || 0, headline: headline(c) }))
796
846
  .filter(x => x.degree > 0)
@@ -995,7 +1045,7 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
995
1045
  let best = null, bestScore = 0;
996
1046
  for (const c of liveTextCards()) {
997
1047
  if (r.area && (c.area || '').toLowerCase() !== r.area.toLowerCase()) continue;
998
- const s = overlapScore(rTok, tokenSet(c.text)) + (/❓/.test(c.text) ? 0.15 : 0);
1048
+ const s = overlapScore(rTok, tokenSet(c.text)) + (/❓|🎯/.test(c.text) ? 0.15 : 0);
999
1049
  if (s > bestScore) { bestScore = s; best = c; }
1000
1050
  }
1001
1051
  if (best && bestScore >= RESOLVE_AT) {
@@ -1045,7 +1095,7 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
1045
1095
  // to the new card is drawn in pass 2 (after the new ids exist), matched
1046
1096
  // back by remembering which old card each new card displaced.
1047
1097
  for (const card of cards) {
1048
- if (/❓|🏁/.test(card.text)) continue; // only plain decisions supersede
1098
+ if (/❓|🎯|🏁/.test(card.text)) continue; // only plain decisions supersede (not questions/goals/milestones)
1049
1099
  const nTok = tokenSet(card.text);
1050
1100
  const area = (card.area || '').toLowerCase();
1051
1101
  let best = null, bestScore = 0;
@@ -1150,6 +1200,60 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
1150
1200
  return { buffer: work, stats };
1151
1201
  }
1152
1202
 
1203
+ // ── Stale-open reconcile ("marked open, but a milestone says it's done") ─────
1204
+ // The READ-side twin of the closes: write path. An open ❓/🎯 card lingers as
1205
+ // "still to do" forever unless someone emits a ✓/closes: for it — so a goal that
1206
+ // quietly SHIPPED keeps surfacing in recall as a "next move". This pure pass
1207
+ // finds open cards a LATER live 🏁 milestone appears to fulfil (its text COVERS
1208
+ // the open card's distinctive tokens) and returns them so the surface can PROMPT
1209
+ // the human to close them — never auto-archives (precision-first, suggestion-only,
1210
+ // like the migration tripwire). Requires the milestone to post-date the goal so a
1211
+ // pre-existing milestone can't "fulfil" a newer goal. No I/O, node-runnable.
1212
+ export function findStaleOpenCards(struct, { coverAt = 0.6, max = 5 } = {}) {
1213
+ const empty = { gaps: [], total: 0 };
1214
+ if (!struct || !Array.isArray(struct.cards)) return empty;
1215
+ const isArchived = (c) => /^archive$/i.test(c.area || '');
1216
+ const isOpen = (c) => /❓|🎯/.test(c.text);
1217
+ const live = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !isArchived(c) && !/↩|✅/.test(c.text));
1218
+ const opens = live.filter(isOpen);
1219
+ const miles = live.filter(c => /🏁/.test(c.text) && !isOpen(c));
1220
+ if (!opens.length || !miles.length) return empty;
1221
+ const out = [];
1222
+ for (const o of opens) {
1223
+ const oTok = tokenSet(o.text);
1224
+ if (oTok.size < 3) continue; // too vague to match safely → leave it
1225
+ let best = null, bestCov = 0;
1226
+ for (const m of miles) {
1227
+ if ((m.createdAt || 0) <= (o.createdAt || 0)) continue; // only a milestone shipped AFTER the goal
1228
+ const cov = coverageOf(oTok, tokenSet(m.text)); // how much of the goal the milestone covers
1229
+ if (cov > bestCov) { bestCov = cov; best = m; }
1230
+ }
1231
+ if (best && bestCov >= coverAt) out.push({ open: o, by: best, cov: Math.round(bestCov * 100) / 100 });
1232
+ }
1233
+ out.sort((a, b) => b.cov - a.cov);
1234
+ return { gaps: out.slice(0, max), total: out.length };
1235
+ }
1236
+
1237
+ // ── Deliberate note → capture input ──────────────────────────────────────────
1238
+ // Turn ONE structured note into captureIntoBrain's input shape — the deliberate
1239
+ // twin of the Stop hook's transcript marker parser. This is what lets an ON-DEMAND
1240
+ // write (the brain_note MCP tool, the brain-note CLI — any agent, not just the
1241
+ // Claude-Code hook) get IDENTICAL supersede / resolve / close / dedup semantics as
1242
+ // a harvested 🧠 marker. marker ∈ '' (decision) | '?' (open question) | '!'
1243
+ // (milestone) | '✓' (resolve+archive a match) | '~' (update a match in place).
1244
+ export function noteToCaptureInput({ text = '', area = '', marker = '', closes = '', evidence = null, createdVia = 'mcp' } = {}) {
1245
+ const body = String(text).trim();
1246
+ if (!body) return { cards: [], resolutions: [], updates: [] };
1247
+ const a = String(area || '').trim();
1248
+ if (marker === '✓') return { cards: [], resolutions: [{ area: a, text: body }], updates: [] };
1249
+ if (marker === '~') return { cards: [], resolutions: [], updates: [{ area: a, text: body, createdVia, ...(evidence ? { evidence } : {}) }] };
1250
+ const prefix = marker === '?' ? '❓ ' : marker === '!' ? '🏁 ' : '';
1251
+ const borderColor = marker === '?' ? 'rgba(245,166,35,0.8)' : marker === '!' ? 'rgba(59,130,246,0.8)' : 'rgba(16,185,129,0.6)';
1252
+ const tag = a ? `\n#${a.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` : '';
1253
+ const cardText = (a ? `${a}: ${prefix}${body}` : `${prefix}${body}`) + tag;
1254
+ return { cards: [{ text: cardText, area: a, color: '#e8e8ed', borderColor, createdVia, ...(closes ? { closes } : {}), ...(evidence ? { evidence } : {}) }], resolutions: [], updates: [] };
1255
+ }
1256
+
1153
1257
  /**
1154
1258
  * Build a RICH "map" .klypix: areas become titled containers, their cards
1155
1259
  * stack inside, connections draw across. Produces a real spatial board (used by