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.
- package/bin/klypix-mcp.mjs +25 -1
- package/package.json +1 -1
- package/src/klypix-core.mjs +77 -2
- package/src/klypix-format.mjs +110 -6
package/bin/klypix-mcp.mjs
CHANGED
|
@@ -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
package/src/klypix-core.mjs
CHANGED
|
@@ -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
|
-
|
|
113
|
-
|
|
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 };
|
package/src/klypix-format.mjs
CHANGED
|
@@ -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
|
-
|
|
577
|
-
|
|
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) =>
|
|
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)) + (
|
|
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 (
|
|
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
|