klypix-mcp 1.16.0 → 1.18.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.
@@ -137,13 +137,14 @@ server.registerTool('brain_connect', {
137
137
  }, async ({ canvas, apply, max, threshold }) => toContent(await opBrainConnect({ vault: VAULT, canvas, apply, max, threshold, log })));
138
138
 
139
139
  server.registerTool('brain_reconcile', {
140
- title: 'Reconcile the brain against committed migrations (find unrecorded rollouts)',
141
- 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.',
140
+ title: 'Reconcile the brain — contradictions between cards + unrecorded migrations',
141
+ description: 'Truth maintenance in two passes. (1) CONTRADICTIONS: finds same-subject live card pairs where one carries an explicit correction cue (uppercase "CORRECTION", "was WRONG", "OBSOLETE" — that side is the presumed truth) or the two use opposite polarity words (deferred↔wired, broken↔fixed, dead↔live), i.e. stale facts whose correction never got linked — candidates only, YOU confirm each: retire the stale card via brain_note ✓; a false POLARITY pair is dismissed by deliberately connecting the pair (brain_connect), while a correction-cue pair clears only when the stale card is retired. (2) MIGRATIONS: lists committed migration files (Supabase / Rails / Prisma / Knex / generic) that NO brain card references, so an applied-but-unnarrated rollout can be recorded. Reads ONLY the filesystem — never the database, never the network — and changes nothing. Run it periodically, or when recall surfaces something you believe is stale.',
142
142
  inputSchema: {
143
143
  canvas: z.string().optional().describe('Brain canvas filename/path. Defaults to the project brain ("brain").'),
144
144
  root: z.string().optional().describe("Project root holding the migrations dir (default: the brain file's folder)."),
145
+ mode: z.enum(['all', 'contradictions', 'migrations']).optional().describe('Which pass to run (default "all").'),
145
146
  },
146
- }, async ({ canvas, root }) => toContent(await opBrainReconcile({ vault: VAULT, canvas, root })));
147
+ }, async ({ canvas, root, mode }) => toContent(await opBrainReconcile({ vault: VAULT, canvas, root, mode })));
147
148
 
148
149
  server.registerTool('brain_garden', {
149
150
  title: 'Garden the brain — consolidate over-grown areas (sleep-time compute)',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.16.0",
3
+ "version": "1.18.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",
@@ -53,7 +53,7 @@
53
53
  "node": ">=18"
54
54
  },
55
55
  "scripts": {
56
- "test": "node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs"
56
+ "test": "node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs"
57
57
  },
58
58
  "dependencies": {
59
59
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -54,6 +54,7 @@ Treat it as authoritative project context, and as **the place project knowledge
54
54
  survives across sessions, agents, and context resets.
55
55
 
56
56
  **At the start of a task — read it** so you know the project's state and past decisions:
57
+ - if \`.claude/brain-brief.md\` exists, read it — it is the full session brief the brain hook regenerates at every session start (Focus, open questions, skills, recent decisions).
57
58
  - with the \`klypix-canvas\` MCP server: call \`search_canvases\` / \`read_canvas\` (canvas: \`"brain"\`), or \`brain_insights\` for the load-bearing cards.
58
59
  - or via CLI: \`npx klypix-read brain.klypix\`
59
60
 
@@ -64,7 +65,11 @@ survives across sessions, agents, and context resets.
64
65
 
65
66
  Capture **sparingly** — real decisions / milestones / open questions / reusable gotchas, not routine
66
67
  steps — and capture it **at the moment you decide** (a one-line marker inline), not batched or left in
67
- a scratch file to rot. Link related cards with \`[[other-card]]\`.
68
+ a scratch file to rot. Link related cards with \`[[other-card]]\`. To **correct a stale card**, include
69
+ the word \`CORRECTION\` (or "was WRONG" / "OBSOLETE" — UPPERCASE, the deliberate signal) in the
70
+ decision — capture then supersedes the stale card across ALL areas (archived with an arrow + a
71
+ receipt, restorable if wrong). Suspect stale facts survive somewhere? Run \`brain_reconcile\` — it
72
+ surfaces contradiction candidates to confirm.
68
73
 
69
74
  **Memory routing (important).** If your host has its OWN memory/notes store, that is for *user*
70
75
  preferences and how to work with this person — keep using it for that. But **project** knowledge
@@ -303,6 +303,10 @@ function touchSession(sid, patch = {}) {
303
303
  // ships ACCUMULATE across turns (deduped, last 5) so a peer sees the
304
304
  // session's recent ship-events, not just the latest turn's.
305
305
  ships: patch.ships !== undefined ? [...new Set([...(prev.ships || []), ...patch.ships])].slice(-5) : (prev.ships ?? []),
306
+ // Card ids already injected full-text into THIS session's prompts —
307
+ // the per-prompt recall renders a re-hit as one headline instead of
308
+ // re-paying the full card (a ~600-word card was injected 3× before).
309
+ injected: patch.injected !== undefined ? patch.injected : (prev.injected ?? []),
306
310
  startedAt: prev.startedAt || now, lastSeen: now,
307
311
  });
308
312
  fs.mkdirSync(SESSIONS_DIR, { recursive: true });
@@ -348,7 +352,7 @@ function peerFooter(sid) {
348
352
  else if (p.branch && myBranch && p.branch === myBranch) warn = ' · ⚠️ same branch — pull/rebase before you commit';
349
353
  lines.push(`- session ${String(p.id).slice(0, 8)} · ${bits.join(' · ')}${warn}`);
350
354
  }
351
- lines.push('Ping the other session before touching shared files; check the brain for what they decided/shipped.');
355
+ lines.push('Coordinate BEFORE touching shared files: reply with `🧠 MSG [<their id-prefix or branch>]: <text>` (or call the `brain_message` MCP tool) — delivered once at their next prompt. Check the brain for what they decided/shipped.');
352
356
  return lines.join('\n');
353
357
  }
354
358
 
@@ -972,7 +976,10 @@ async function capture(lib) {
972
976
  const key = sha(('ship|' + p.area + '|' + summary).toLowerCase());
973
977
  if (seen.has(key)) break; // already captured this ship
974
978
  seen.add(key);
975
- cards.push({ text: `${p.area}: 🏁 ${summary}\n#${slugify(p.area)}`, area: p.area, borderColor: 'rgba(59,130,246,0.8)', createdVia: 'ship-event' });
979
+ // #auto marks machine-harvested provenance: the repeat-detector demands an
980
+ // entity-token match on these (they're dense with generic ship verbs) and
981
+ // the gardener consolidates them at a shorter age.
982
+ cards.push({ text: `${p.area}: 🏁 ${summary}\n#${slugify(p.area)} #auto`, area: p.area, borderColor: 'rgba(59,130,246,0.8)', createdVia: 'ship-event' });
976
983
  shipSummaries.push(summary);
977
984
  ledger.push({ action: 'ship-event', area: p.area, preview: summary });
978
985
  break; // one pattern per command
@@ -1047,10 +1054,17 @@ async function capture(lib) {
1047
1054
  const bits = [`${stats.added} added`];
1048
1055
  if (stats.resolved) bits.push(`${stats.resolved} resolved`);
1049
1056
  if (stats.updated) bits.push(`${stats.updated} updated`);
1057
+ if (stats.merged) bits.push(`${stats.merged} merged`);
1050
1058
  if (stats.closed) bits.push(`${stats.closed} closed`);
1051
1059
  if (stats.superseded) bits.push(`${stats.superseded} superseded`);
1052
1060
  if (stats.linked) bits.push(`${stats.linked} linked`);
1053
1061
  process.stderr.write(`[brain] capture: ${bits.join(' · ')} → brain.klypix\n`);
1062
+ // Receipt for correction-driven (cross-area / low-bar) supersedes — the
1063
+ // confirmation channel: say WHAT was archived and how to undo a wrong grab.
1064
+ if (Array.isArray(stats.corrections) && stats.corrections.length) {
1065
+ process.stderr.write(`[brain] correction supersede: ${stats.corrections.map(c => `"${c.old}" (${c.overlap})`).join('; ')} — archived + arrowed; restore from Archive or ~ update if wrong\n`);
1066
+ }
1067
+ if (stats.added > 0 && !stats.linked) process.stderr.write(`[brain] note: ${stats.added} card(s) landed unlinked — \`brain_connect\` (or [[wikilinks]] next time) wires them into the graph\n`);
1054
1068
  appendJsonl(LEDGER, { ts: nowIso(), mode: 'capture', stats, decisions: ledger }, 1000);
1055
1069
  appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode: 'capture', ok: true, brainBytes: brainBytes(), added: stats.added, skipped: ledger.filter(d => d.action.startsWith('skipped')).length }, 500);
1056
1070
  }
@@ -1196,20 +1210,74 @@ async function promptRetrieve(lib) {
1196
1210
  const head = (c, n = 120) => { const t = flat(c.text); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
1197
1211
  const lines = [];
1198
1212
  if (repeats.length) {
1213
+ // A nudged "already shipped" card may itself have been CORRECTED since —
1214
+ // nudging the agent to reuse a stale fact would be worse than no nudge.
1215
+ let repOverlays = new Map();
1216
+ if (struct && typeof lib.correctionOverlaysFor === 'function') {
1217
+ try { repOverlays = lib.correctionOverlaysFor(struct, repeats.map(r => r.card)); } catch { /* best-effort */ }
1218
+ }
1199
1219
  lines.push('## ⚠️ Possible repeat — this may already be done (reuse/supersede, don’t silently redo)');
1200
1220
  for (const r of repeats) {
1201
1221
  const verb = r.kind === 'superseded'
1202
1222
  ? `you moved OFF this ${day(r.card.createdAt)} — check what replaced it before redoing`
1203
1223
  : `already ${r.kind} ${day(r.card.createdAt)} — reuse/build on it, or supersede it deliberately`;
1204
- lines.push(`- [${flat(r.card.area) || '?'}] ${head(r.card)} · ${verb}`);
1224
+ const ov = repOverlays.get(r.card.id);
1225
+ const corr = ov ? ` · ⚠️ a live CORRECTION exists — read it first: “${head(ov.by, 90)}”` : '';
1226
+ lines.push(`- [${flat(r.card.area) || '?'}] ${head(r.card)} · ${verb}${corr}`);
1205
1227
  }
1206
1228
  lines.push('Read the matching card (klypix-canvas MCP / brain) BEFORE redoing. Other project? use search_all_brains.');
1207
1229
  }
1208
1230
  if (freshHits.length) {
1231
+ // Truth-decay guard: for each hit with a supersede/close edge or an
1232
+ // overlapping live correction-cue card, inject the CORRECTOR full-text
1233
+ // FIRST and reduce the stale hit to a labeled headline — a stale card
1234
+ // must never stand alone (the worst failure mode a memory can have).
1235
+ let overlays = new Map();
1236
+ if (struct && typeof lib.correctionOverlaysFor === 'function') {
1237
+ try { overlays = lib.correctionOverlaysFor(struct, freshHits.map(h => h.card)); } catch { /* best-effort */ }
1238
+ }
1239
+ // Per-session injection dedup: a card already shown full-text this
1240
+ // session renders as one headline, not another ~600 words of context.
1241
+ let me = null; try { me = readSessions().find(s => s.id === sid) || null; } catch { /* */ }
1242
+ const injected = new Set((me && Array.isArray(me.injected)) ? me.injected : []);
1243
+ const shownNow = new Set();
1209
1244
  lines.push(semMode === 'sem-hit'
1210
1245
  ? "# Related prior decisions (semantic match — no exact keyword overlap; full brain via the klypix-canvas MCP)"
1211
1246
  : "# Relevant prior decisions from this project's brain (task-matched; full brain via the klypix-canvas MCP)");
1212
- for (const h of freshHits) lines.push(`- ${flat(h.card.text)}`);
1247
+ const newlyInjected = [];
1248
+ for (const h of freshHits) {
1249
+ if (shownNow.has(h.card.id)) continue; // already rendered this turn (e.g. as a corrector)
1250
+ const ov = overlays.get(h.card.id);
1251
+ if (ov) {
1252
+ // The STALE demotion is UNCONDITIONAL — even when the corrector
1253
+ // already rendered (as its own hit, or for a twin), the stale
1254
+ // card must never fall through to the plain full-text branch.
1255
+ // The corrector prints full-text at most once per session.
1256
+ let correctorLine = false;
1257
+ if (!shownNow.has(ov.by.id)) {
1258
+ correctorLine = true;
1259
+ if (injected.has(ov.by.id)) {
1260
+ lines.push(`- ⚠️ CORRECTED — current (already shown this session): ${head(ov.by, 110)}`);
1261
+ } else {
1262
+ lines.push(`- ⚠️ CORRECTED — current: ${flat(ov.by.text)}`);
1263
+ newlyInjected.push(ov.by.id);
1264
+ }
1265
+ shownNow.add(ov.by.id);
1266
+ }
1267
+ lines.push(`${correctorLine ? ' ↳' : '-'} ⚠️ recall matched a STALE card${ov.kind === 'edge' ? ' (superseded)' : ''}: “${head(h.card, 110)}” — do NOT act on it${correctorLine ? '' : ' (its CORRECTION is listed above)'}. Reconcile: \`brain_reconcile\` (contradictions) or a ✓/~ marker.`);
1268
+ shownNow.add(h.card.id);
1269
+ continue;
1270
+ }
1271
+ if (injected.has(h.card.id)) {
1272
+ lines.push(`- (already shown this session) ${head(h.card, 110)}`);
1273
+ shownNow.add(h.card.id);
1274
+ continue;
1275
+ }
1276
+ lines.push(`- ${flat(h.card.text)}`);
1277
+ shownNow.add(h.card.id);
1278
+ newlyInjected.push(h.card.id);
1279
+ }
1280
+ if (newlyInjected.length) touchSession(sid, { injected: [...new Set([...injected, ...newlyInjected])].slice(-100) });
1213
1281
  }
1214
1282
  const parts = [];
1215
1283
  if (lines.length) parts.push(lines.join('\n'));
@@ -1437,6 +1505,8 @@ function legendFooter() {
1437
1505
  + '🧠 **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'
1438
1506
  + '`🧠 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'
1439
1507
  + 'Optional suffixes: `closes: <card title / [[wikilink]]>` (resolve the strategy/question this fulfils) · `ev: <file[:line]>, PR#<n>` (anchor to code → auto drift-badge).\n'
1508
+ + '**Correcting a stale card:** include the word `CORRECTION` (or "was WRONG" / "OBSOLETE" — UPPERCASE; casing is the deliberate-signal, casual prose never fires it) in the decision — the capture then hunts the stale card across ALL areas at a lower match bar and supersedes it (archived + arrowed, with a receipt; restore from Archive if it grabbed the wrong one). A rephrased duplicate `?` merges into the existing open question instead of stacking a twin.\n'
1509
+ + '**Session brief:** the SessionStart hook prints a ≤2KB ultra brief and writes the FULL brief to `.claude/brain-brief.md` — read that file when planning non-trivial work.\n'
1440
1510
  + '**Routing:** capture project decisions / milestones / open questions / gotchas HERE, *at the moment you decide* — this brain is the shared, portable memory that survives context resets and the next agent reads. A host memory store (if any) is for *user* preferences; never leave project state only in a private scratchpad.\n'
1441
1511
  + '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';
1442
1512
  }
@@ -1447,18 +1517,55 @@ async function read(lib) {
1447
1517
  // immediately. Files/ships come from Stop (what this session actually edits).
1448
1518
  touchSession(input.session_id, { branch: gitBranch() });
1449
1519
  const { struct } = await lib.parseKlypix(fs.readFileSync(BRAIN));
1450
- // Default = TIERED brief (open questions + recent + area map) so the
1451
- // session-start cost stays flat as the brain grows. --full = everything.
1452
1520
  const { freshness, drifted } = computeFreshness(struct);
1453
- const outStr = (!process.argv.includes('--full') && typeof lib.structToBrief === 'function')
1454
- ? lib.structToBrief(struct, { freshness })
1455
- : lib.structToMarkdown(struct);
1456
- // ⚡ In-flight footer goes RIGHT AFTER the brief (highest signal: what a peer
1457
- // shipped seconds ago, before it's in the brain) — closes the 1.3.17-blindness gap.
1458
- process.stdout.write(outStr + inflightFooter(input.session_id, struct) + selfHealFooter(drifted) + reconcileFooter(lib, struct) + staleOpenFooter(lib, struct) + selfCheckFooter() + doctorFooter() + versionCurrencyFooter() + messageFooter(input.session_id || '', input.transcript_path) + legendFooter() + memoryFooter());
1521
+ // The FULL brief: tiered brief + every self-heal/health footer. Messages are
1522
+ // deliberately NOT part of it (messageFooter ACKS on read — it must only ever
1523
+ // go to stdout, where the agent actually sees it, exactly once).
1524
+ const full = ((typeof lib.structToBrief === 'function') ? lib.structToBrief(struct, { freshness }) : lib.structToMarkdown(struct))
1525
+ + inflightFooter(input.session_id, struct) + selfHealFooter(drifted) + reconcileFooter(lib, struct) + staleOpenFooter(lib, struct)
1526
+ + selfCheckFooter() + doctorFooter() + versionCurrencyFooter() + legendFooter() + memoryFooter();
1527
+ const emitFull = () => {
1528
+ process.stdout.write(full + messageFooter(input.session_id || '', input.transcript_path));
1529
+ appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode: 'read', ok: true, briefBytes: Buffer.byteLength(full), cards: struct?.counts?.cards ?? null }, 500);
1530
+ };
1531
+ // --full = everything to stdout (manual runs); also the fallback when the
1532
+ // live klypix-format predates the ultra tier (version skew) or the brief
1533
+ // file can't be written (stdout is then the only channel).
1534
+ if (process.argv.includes('--full') || typeof lib.structToUltraBrief !== 'function') return emitFull();
1535
+ // Default = ULTRA tier. The harness persists hook stdout and shows only a
1536
+ // ~2KB preview, so a 13KB brief was mostly invisible — write the FULL brief
1537
+ // to a stable project-local file and print a tier that fits the preview
1538
+ // whole: Focus + conflicts + open questions + alerts + the file's path.
1539
+ const briefRel = '.claude/brain-brief.md';
1540
+ try {
1541
+ fs.mkdirSync(path.resolve(CWD, '.claude'), { recursive: true });
1542
+ fs.writeFileSync(path.resolve(CWD, briefRel),
1543
+ '<!-- auto-generated by the brain hook at session start — read it, don\'t edit it; regenerated next session -->\n' + full, 'utf8');
1544
+ } catch { return emitFull(); }
1545
+ const ultra = lib.structToUltraBrief(struct, { freshness, briefPath: briefRel });
1546
+ // Self-heal tiers compress to ONE line up here; the actionable detail (which
1547
+ // cards, which markers to emit) lives in the brief file.
1548
+ const heals = [];
1549
+ if (drifted && drifted.length) heals.push(`${drifted.length} code-anchored fact(s) DRIFTED`);
1550
+ try {
1551
+ if (typeof lib.findUnrecordedMigrations === 'function') {
1552
+ const files = collectMigrationFiles(CWD);
1553
+ if (files.length) { const { total } = lib.findUnrecordedMigrations(struct, files, { max: 6 }); if (total) heals.push(`${total} unrecorded migration(s)`); }
1554
+ }
1555
+ } catch { /* */ }
1556
+ try { if (typeof lib.findStaleOpenCards === 'function') { const { total } = lib.findStaleOpenCards(struct, { max: 5 }); if (total) heals.push(`${total} open card(s) look already done`); } } catch { /* */ }
1557
+ const healLine = heals.length ? `\n🔧 Self-heal: ${heals.join(' · ')} — detail + fix markers in ${briefRel}.` : '';
1558
+ // 📨 messages are delivered-ONCE (acked the moment this reads them) — they
1559
+ // go right after the ultra brief, at the top of the visible window, never
1560
+ // after a stack of footers that could push them past a preview cut.
1561
+ const messages = messageFooter(input.session_id || '', input.transcript_path);
1562
+ const out = ultra + messages + healLine
1563
+ + inflightFooter(input.session_id, struct)
1564
+ + selfCheckFooter() + doctorFooter() + versionCurrencyFooter();
1565
+ process.stdout.write(out);
1459
1566
  // Heartbeat: prove the brief actually injected (and how big) so a dead or
1460
1567
  // stale live-copy of the hook stops being a silent no-op.
1461
- appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode: 'read', ok: true, briefBytes: Buffer.byteLength(outStr), cards: struct?.counts?.cards ?? null }, 500);
1568
+ appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode: 'read', ok: true, briefBytes: Buffer.byteLength(out), fullBriefBytes: Buffer.byteLength(full), cards: struct?.counts?.cards ?? null }, 500);
1462
1569
  }
1463
1570
 
1464
1571
  // Registry of every brain this machine has touched — written on each hook run,
@@ -25,7 +25,7 @@ import {
25
25
  parseKlypix, buildKlypix, buildKlypixMap, appendToKlypix, structToMarkdown,
26
26
  brainInsights, insightsToMarkdown, addBrainConnections, proposeStructuralConnections, atomicWrite,
27
27
  findUnrecordedMigrations, captureIntoBrain, tidyBrain, noteToCaptureInput,
28
- selectGardenCandidates, applyGarden,
28
+ selectGardenCandidates, applyGarden, detectContradictions,
29
29
  } from './klypix-format.mjs';
30
30
 
31
31
  // ── Card / connection input shape (single source for every face) ─────────────
@@ -405,7 +405,7 @@ export function collectMigrationFiles(root) {
405
405
  }
406
406
  return out;
407
407
  }
408
- export async function opBrainReconcile({ vault, canvas, root }) {
408
+ export async function opBrainReconcile({ vault, canvas, root, mode = 'all' }) {
409
409
  const t = brainTarget(vault, canvas);
410
410
  if (t.ambiguous) return ambiguousBrainErr(t.ambiguous);
411
411
  if (!t.file) return err(`No brain found — looked for ./brain.klypix in the project, then ${vault}. Pass canvas: "<name>".`);
@@ -413,16 +413,49 @@ export async function opBrainReconcile({ vault, canvas, root }) {
413
413
  let struct;
414
414
  try { ({ struct } = await parseKlypix(fs.readFileSync(file))); } catch (e) { return err(`Read failed: ${e.message}`); }
415
415
  const stamp = brainStamp(file, struct, t.how);
416
+ const sections = [];
417
+
418
+ // (1) CONTRADICTIONS — the brain reconciled against ITSELF. Same-subject live
419
+ // pairs where one side carries an explicit correction cue (that side is the
420
+ // presumed truth) or the two use opposite polarity words (deferred↔wired,
421
+ // broken↔fixed …). Candidates only — nothing is changed here; the agent/human
422
+ // confirms each. This is the retroactive cleaner for stale/correction pairs
423
+ // that slipped past capture (cross-area + reworded → no supersede possible).
424
+ if (mode === 'all' || mode === 'contradictions') {
425
+ const pairs = detectContradictions(struct);
426
+ if (pairs.length) {
427
+ const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
428
+ const lines = pairs.map((p, i) =>
429
+ `${i + 1}. ${p.why} · overlap ${p.overlap}\n`
430
+ + ` · likely STALE [${p.stale.area || '?'}] ${flat(p.stale.text).slice(0, 180)}\n`
431
+ + ` · likely CURRENT [${p.fresh.area || '?'}] ${flat(p.fresh.text).slice(0, 180)}`);
432
+ sections.push(`# ⚔️ ${pairs.length} contradiction candidate(s) — confirm, then reconcile\n_Candidates only — nothing was changed. For each REAL contradiction: retire the stale card with \`brain_note\` marker \`✓\` (text = what it resolved to), or record a correction-cue decision ("CORRECTION: …", uppercase) — capture auto-supersedes it across areas. Dismissing a FALSE positive: a **polarity** pair is dismissed by deliberately connecting the two cards (\`brain_connect\`); a **correction-cue** pair only clears when the stale card is retired (✓ / supersede) — a mere link does not settle a declared correction._\n\n${lines.join('\n')}`);
433
+ } else if (mode === 'contradictions') {
434
+ sections.push('✓ No contradiction candidates — no live card pair shows a correction cue or a polarity flip over the same subject.');
435
+ }
436
+ }
437
+
438
+ // (2) MIGRATIONS — the brain reconciled against committed external state.
416
439
  // Migrations live in the CODE repo (usually beside brain.klypix), not in a
417
440
  // separate canvas vault — so default the root to the brain file's folder.
418
- const repoRoot = root ? path.resolve(root) : path.dirname(file);
419
- const files = collectMigrationFiles(repoRoot);
420
- if (!files.length) return { blocks: [text(stamp + `No migration files under ${repoRoot} (looked in: ${MIGRATION_DIRS.join(', ')}). Nothing to reconcile.`)] };
421
- const { gaps, total } = findUnrecordedMigrations(struct, files, { max: 20 });
422
- if (!gaps.length) return { blocks: [text(stamp + `✓ All ${files.length} migration(s) under ${path.basename(repoRoot)} are referenced by a brain card — no unrecorded rollouts.`)] };
423
- 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}\``);
424
- const more = total > gaps.length ? `\n\n…and ${total - gaps.length} more.` : '';
425
- return { blocks: [text(stamp + `# ⚠️ ${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}`)] };
441
+ if (mode === 'all' || mode === 'migrations') {
442
+ const repoRoot = root ? path.resolve(root) : path.dirname(file);
443
+ const files = collectMigrationFiles(repoRoot);
444
+ if (!files.length) {
445
+ if (mode === 'migrations') sections.push(`No migration files under ${repoRoot} (looked in: ${MIGRATION_DIRS.join(', ')}). Nothing to reconcile.`);
446
+ } else {
447
+ const { gaps, total } = findUnrecordedMigrations(struct, files, { max: 20 });
448
+ if (!gaps.length) sections.push(`✓ All ${files.length} migration(s) under ${path.basename(repoRoot)} are referenced by a brain card — no unrecorded rollouts.`);
449
+ else {
450
+ 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}\``);
451
+ const more = total > gaps.length ? `\n\n…and ${total - gaps.length} more.` : '';
452
+ sections.push(`# ⚠️ ${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}`);
453
+ }
454
+ }
455
+ }
456
+
457
+ if (!sections.length) sections.push('✓ Nothing to reconcile — no contradiction candidates, and no unrecorded migrations.');
458
+ return { blocks: [text(stamp + sections.join('\n\n---\n\n'))] };
426
459
  }
427
460
 
428
461
  // ── Brain gardener (two-phase: select → agent synthesizes → apply) ───────────
@@ -584,10 +617,16 @@ export async function opBrainNote({ vault, canvas, text: noteText, area, marker
584
617
  await atomicWrite(file, out);
585
618
  const s = res.stats || {};
586
619
  const bits = [`${s.added || 0} added`];
587
- for (const k of ['resolved', 'updated', 'closed', 'superseded', 'linked']) if (s[k]) bits.push(`${s[k]} ${k}`);
620
+ for (const k of ['resolved', 'updated', 'merged', 'closed', 'superseded', 'linked']) if (s[k]) bits.push(`${s[k]} ${k}`);
621
+ // Correction receipt — a correction-cue note superseded a card cross-area /
622
+ // below the plain 0.6 bar; say WHAT was archived and how to undo, so the
623
+ // widened match is always confirmable rather than silent.
624
+ const corr = (Array.isArray(s.corrections) && s.corrections.length)
625
+ ? `\n↩︎ correction supersede: ${s.corrections.map(c => `"${c.old}"${c.area ? ` [${c.area}]` : ''} (overlap ${c.overlap})`).join('; ')} — archived + arrowed to your note. If it grabbed the wrong card, restore it from Archive or re-run with marker "~".`
626
+ : '';
588
627
  // Name the resolved brain explicitly (basename + how) so a write never lands
589
628
  // in a surprise file silently — the write-side twin of the read-op stamp.
590
- return { blocks: [text(`✓ brain_note → ${path.basename(file)} (via ${t.how}) · ${bits.join(' · ')}. Reopen the brain in the KLYPIX app to see it.`)] };
629
+ return { blocks: [text(`✓ brain_note → ${path.basename(file)} (via ${t.how}) · ${bits.join(' · ')}. Reopen the brain in the KLYPIX app to see it.${corr}`)] };
591
630
  } catch (e) {
592
631
  return err(`brain_note failed (brain unchanged): ${e.message}`);
593
632
  }
@@ -518,6 +518,7 @@ export async function tidyBrain(buffer) {
518
518
  for (const c of rootText) { const a = areaOfCard(c); const k = a.toLowerCase(); if (!groups.has(k)) groups.set(k, { title: a, ids: [] }); groups.get(k).ids.push(c.id); }
519
519
 
520
520
  let moved = 0;
521
+ const createdNow = new Set(); // containers born in THIS pass have no meaningful previous position
521
522
  const assignTo = (ctnId, ids) => { for (const id of ids) { const p = canvas.positions[id]; canvas.positions[id] = { ...p, parentId: ctnId, zKey: (p && p.zKey && isValidZKey(p.zKey)) ? p.zKey : nextZKey() }; moved++; } };
522
523
  // Ensure a container exists for each area (create if missing); route root cards in.
523
524
  for (const grp of groups.values()) {
@@ -525,6 +526,7 @@ export async function tidyBrain(buffer) {
525
526
  let ctnId = byTitle.get(key);
526
527
  if (!ctnId) {
527
528
  ctnId = `ctn_${rand()}`;
529
+ createdNow.add(ctnId);
528
530
  zip.file(`items/${shard(ctnId)}/${ctnId}.json`, JSON.stringify({ type: 'container', locked: false, createdAt: now, createdBy: 'agent', title: grp.title, collapsed: false, scopeLocked: false, borderColor: '#10b981' }));
529
531
  canvas.order.push(ctnId);
530
532
  canvas.positions[ctnId] = { x: G.START, y: G.START, w: G.AREA_W, h: G.TITLE_BAR + G.PAD * 2, zKey: nextZKey(), zIndex: canvas.order.length, parentId: null };
@@ -533,23 +535,211 @@ export async function tidyBrain(buffer) {
533
535
  assignTo(ctnId, grp.ids);
534
536
  }
535
537
 
536
- // UNIFIED LAYOUT — re-flow each container's children (chronological, compact
537
- // heights) AND shelf-pack ALL containers into one grid by their TRUE height, so
538
- // a container that grew (new captures) never overlaps its neighbor below.
538
+ // ── CLUSTER LAYOUT ────────────────────────────────────────────────────────
539
+ // The old shelf-grid stacked every area's cards in ONE column and packed
540
+ // containers in creation order — at 400+ cards the brain rendered as an
541
+ // unreadable strip and position encoded nothing. Now:
542
+ // • MASONRY inside each container: cards flow into 1-5 balanced columns
543
+ // targeting a ~1.15 height/width ratio → areas are squarish tiles.
544
+ // • CLUSTERS across containers: each area is placed greedily beside the
545
+ // already-placed area it shares the most connections with — related
546
+ // knowledge is literally near, cross-area arrows stay short.
547
+ // • 📌 Focus (the human steering surface) anchors the map center; Archive
548
+ // is pinned to the cold rim (its supersede/close arrows touch every
549
+ // area, so edge weights would otherwise drag it central).
550
+ // Deterministic (no RNG, stable ordering): the same brain always maps the
551
+ // same way, and one new card nudges its own cluster instead of reshuffling
552
+ // the world. At map zoom the app's capsule/dot tiers render this as the
553
+ // cluster-galaxy view; membership steering (dragging a card into an area)
554
+ // is preserved — tidy re-flows coordinates, never parentage.
555
+ // Containers are never masonry "kids" — a nested container would otherwise
556
+ // be committed twice (as its parent's 300×40 pseudo-card AND as its own
557
+ // box), stranding its children at abandoned coordinates.
539
558
  const childrenOf = (cid) => canvas.order
540
- .filter(id => canvas.positions[id] && canvas.positions[id].parentId === cid)
559
+ .filter(id => !containerIds.has(id) && canvas.positions[id] && canvas.positions[id].parentId === cid)
541
560
  .sort((a, b) => (meta.get(a)?.createdAt || 0) - (meta.get(b)?.createdAt || 0));
542
- const heightOf = (cid) => { const inner = childrenOf(cid).reduce((s, id) => s + (meta.get(id)?.h || 40) + G.CARD_GAP, 0); return Math.max(G.TITLE_BAR + G.PAD * 2, G.TITLE_BAR + G.PAD + inner + G.PAD); };
543
561
  const orderedCtns = canvas.order.filter(id => containerIds.has(id) && canvas.positions[id]);
544
- const cols = Math.max(1, Math.min(4, Math.ceil(Math.sqrt(Math.max(1, orderedCtns.length)))));
545
- let colIdx = 0, rowTopY = G.START, rowMaxH = 0, colX = G.START;
546
- for (const cid of orderedCtns) {
547
- const h = heightOf(cid);
548
- if (colIdx >= cols) { rowTopY += rowMaxH + G.COL_GAP; rowMaxH = 0; colIdx = 0; colX = G.START; }
549
- canvas.positions[cid] = { ...canvas.positions[cid], x: colX, y: rowTopY, w: G.AREA_W, h };
550
- let cy = rowTopY + G.TITLE_BAR + G.PAD;
551
- for (const kid of childrenOf(cid)) { const kh = meta.get(kid)?.h || 40; canvas.positions[kid] = { ...canvas.positions[kid], x: colX + G.PAD, y: cy, w: G.CARD_W, h: kh }; cy += kh + G.CARD_GAP; }
552
- colX += G.AREA_W + G.COL_GAP; colIdx++; rowMaxH = Math.max(rowMaxH, h);
562
+ const ctnTitle = new Map();
563
+ for (const c of struct.cards) if (c.type === 'container') ctnTitle.set(c.id, c.title || '');
564
+ for (const [t, id] of byTitle) if (!ctnTitle.has(id)) ctnTitle.set(id, t);
565
+ const isArchiveCtn = (cid) => /^archive$/i.test(String(ctnTitle.get(cid) || '').trim());
566
+ const isFocusCtn = (cid) => /(^|\s)focus\b/i.test(String(ctnTitle.get(cid) || ''));
567
+
568
+ if (orderedCtns.length) {
569
+ // 0. Flatten human-nested containers to root. The capture toolchain
570
+ // never nests; a hand-nested area would be committed twice (as its
571
+ // parent's 300×40 pseudo-card AND as its own box), stranding its
572
+ // children. Promoted areas keep their absolute spot via the
573
+ // incremental anchor below, so visually nothing jumps.
574
+ for (const cid of orderedCtns) {
575
+ const p = canvas.positions[cid];
576
+ if (p && p.parentId != null && containerIds.has(p.parentId)) canvas.positions[cid] = { ...p, parentId: null };
577
+ }
578
+
579
+ // 1. Masonry plan per container: pick the column count whose resulting
580
+ // box is closest to the target aspect, then flow cards (chronological)
581
+ // into the currently-shortest column.
582
+ const plans = new Map(); // cid -> { w, h, kids: [{id, dx, dy, h}] }
583
+ for (const cid of orderedCtns) {
584
+ const kids = childrenOf(cid);
585
+ const totalH = kids.reduce((s, id) => s + (meta.get(id)?.h || 40) + G.CARD_GAP, 0);
586
+ let k = 1, best = Infinity;
587
+ for (let n = 1; n <= 5 && n <= Math.max(1, kids.length); n++) {
588
+ const w = G.PAD * 2 + n * G.CARD_W + (n - 1) * G.CARD_GAP;
589
+ const h = G.TITLE_BAR + G.PAD * 2 + Math.max(40, totalH / n);
590
+ const score = Math.abs((h / w) - 1.15);
591
+ if (score < best) { best = score; k = n; }
592
+ }
593
+ const colY = new Array(k).fill(G.TITLE_BAR + G.PAD);
594
+ const placedKids = [];
595
+ for (const id of kids) {
596
+ const h = meta.get(id)?.h || 40;
597
+ let col = 0; for (let c = 1; c < k; c++) if (colY[c] < colY[col]) col = c;
598
+ placedKids.push({ id, dx: G.PAD + col * (G.CARD_W + G.CARD_GAP), dy: colY[col], h });
599
+ colY[col] += h + G.CARD_GAP;
600
+ }
601
+ plans.set(cid, {
602
+ w: G.PAD * 2 + k * G.CARD_W + (k - 1) * G.CARD_GAP,
603
+ h: Math.max(G.TITLE_BAR + G.PAD * 2, Math.max(...colY) + G.PAD),
604
+ kids: placedKids,
605
+ });
606
+ }
607
+
608
+ // 2. Inter-area connection weights (Archive excluded — rim-pinned).
609
+ const parentOf = (id) => canvas.positions[id]?.parentId ?? null;
610
+ const weights = new Map();
611
+ for (const cn of (canvas.connections || [])) {
612
+ const pa = parentOf(cn.fromId), pb = parentOf(cn.toId);
613
+ if (!pa || !pb || pa === pb || !plans.has(pa) || !plans.has(pb)) continue;
614
+ if (isArchiveCtn(pa) || isArchiveCtn(pb)) continue;
615
+ const key = pa < pb ? pa + '|' + pb : pb + '|' + pa;
616
+ weights.set(key, (weights.get(key) || 0) + 1);
617
+ }
618
+ const wOf = (a, b) => weights.get(a < b ? a + '|' + b : b + '|' + a) || 0;
619
+ const degree = new Map(orderedCtns.map(cid => [cid, 0]));
620
+ for (const [key, n] of weights) { const [a, b] = key.split('|'); degree.set(a, (degree.get(a) || 0) + n); degree.set(b, (degree.get(b) || 0) + n); }
621
+
622
+ // 3. INCREMENTAL by default, full cluster pass only on migration.
623
+ // The full pass orders by (mutable) connectivity — running it per
624
+ // capture reshuffled the entire map the moment one cross-area arrow
625
+ // landed (field-measured: 45/45 containers teleported ~4.4k px on one
626
+ // wikilink). A memory's map must be as stable as the memory: normally
627
+ // every container ANCHORS to its previous spot (taken verbatim when
628
+ // nothing grew into it), so a capture moves at most the areas whose
629
+ // size actually changed. The full pass runs only when the previous
630
+ // layout isn't this engine's (legacy strip, foreign grid, degenerate
631
+ // aspect) — detected via the settings stamp + geometry heuristics.
632
+ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0); // code-unit compare — locale-independent, unlike bare localeCompare
633
+ const prev = new Map();
634
+ for (const cid of orderedCtns) {
635
+ if (createdNow.has(cid)) continue;
636
+ const p = canvas.positions[cid];
637
+ if (p && Number.isFinite(p.x) && Number.isFinite(p.y)) prev.set(cid, { x: p.x, y: p.y, w: p.w || 0, h: p.h || 0 });
638
+ }
639
+ let fullPass = !(canvas.settings && canvas.settings.brainLayout === 'cluster-v1');
640
+ if (fullPass && prev.size >= 2 && [...prev.values()].some(b => b.w > 400)) fullPass = false; // stamp lost (e.g. app re-save) but geometry is clearly cluster-made
641
+ if (!fullPass && prev.size >= 2) {
642
+ const xs = [...prev.values()];
643
+ const w = Math.max(...xs.map(b => b.x + b.w)) - Math.min(...xs.map(b => b.x));
644
+ const h = Math.max(...xs.map(b => b.y + b.h)) - Math.min(...xs.map(b => b.y));
645
+ const aspect = h / Math.max(1, w);
646
+ if (aspect > 4 || aspect < 0.1) fullPass = true; // degenerate strip → re-map
647
+ }
648
+ if (prev.size < 2) fullPass = true;
649
+
650
+ const focusCtns = orderedCtns.filter(isFocusCtn);
651
+ const isRim = (c) => !isFocusCtn(c) && isArchiveCtn(c);
652
+ let placeOrder, rimCtns;
653
+ if (fullPass) {
654
+ // Hubs early so satellites attach to them; Focus anchors the map.
655
+ rimCtns = orderedCtns.filter(isRim);
656
+ const middle = orderedCtns.filter(c => !isFocusCtn(c) && !isRim(c))
657
+ .sort((a, b) => ((degree.get(b) || 0) - (degree.get(a) || 0))
658
+ || (plans.get(b).kids.length - plans.get(a).kids.length)
659
+ || cmp(String(ctnTitle.get(a) || ''), String(ctnTitle.get(b) || ''))
660
+ || cmp(a, b));
661
+ placeOrder = [...focusCtns, ...middle];
662
+ } else {
663
+ // Previous spatial order (top-left claims its spot first) — an
664
+ // IMMUTABLE ordering, so new arrows can't reshuffle the queue.
665
+ const anchored = orderedCtns.filter(c => prev.has(c))
666
+ .sort((a, b) => (prev.get(a).y - prev.get(b).y) || (prev.get(a).x - prev.get(b).x) || cmp(a, b));
667
+ const fresh = orderedCtns.filter(c => !prev.has(c) && !isRim(c))
668
+ .sort((a, b) => ((degree.get(b) || 0) - (degree.get(a) || 0)) || cmp(a, b));
669
+ rimCtns = orderedCtns.filter(c => !prev.has(c) && isRim(c));
670
+ placeOrder = [...anchored, ...fresh];
671
+ }
672
+
673
+ // 4. Greedy placement. Anchored containers try their previous spot
674
+ // verbatim first (zero movement when nothing grew into it), else the
675
+ // nearest free side/corner slot. Unanchored ones score by
676
+ // connection-weighted distance + a gentle compactness pull.
677
+ const GAP = G.COL_GAP;
678
+ const boxes = new Map();
679
+ const overlapsAny = (b) => { for (const o of boxes.values()) if (b.x < o.x + o.w + GAP && o.x < b.x + b.w + GAP && b.y < o.y + o.h + GAP && o.y < b.y + b.h + GAP) return true; return false; };
680
+ const cxOf = (b) => b.x + b.w / 2, cyOf = (b) => b.y + b.h / 2;
681
+ const distC = (a, b) => Math.hypot(cxOf(a) - cxOf(b), cyOf(a) - cyOf(b));
682
+ for (const cid of placeOrder) {
683
+ const plan = plans.get(cid);
684
+ const anchor = !fullPass && prev.has(cid) ? prev.get(cid) : null;
685
+ if (anchor) {
686
+ const b0 = { x: anchor.x, y: anchor.y, w: plan.w, h: plan.h };
687
+ if (!overlapsAny(b0)) { boxes.set(cid, b0); continue; }
688
+ }
689
+ if (!boxes.size) { boxes.set(cid, { x: anchor ? anchor.x : 0, y: anchor ? anchor.y : 0, w: plan.w, h: plan.h }); continue; }
690
+ let mx = 0, my = 0;
691
+ for (const b of boxes.values()) { mx += cxOf(b); my += cyOf(b); }
692
+ mx /= boxes.size; my /= boxes.size;
693
+ const anchorBox = anchor ? { x: anchor.x, y: anchor.y, w: plan.w, h: plan.h } : null;
694
+ let bestPos = null, bestScore = Infinity;
695
+ for (const o of boxes.values()) {
696
+ const cands = [
697
+ { x: o.x + o.w + GAP, y: o.y }, { x: o.x, y: o.y + o.h + GAP },
698
+ { x: o.x - GAP - plan.w, y: o.y }, { x: o.x, y: o.y - GAP - plan.h },
699
+ { x: o.x + o.w + GAP, y: o.y + o.h + GAP }, { x: o.x - GAP - plan.w, y: o.y + o.h + GAP },
700
+ { x: o.x + o.w + GAP, y: o.y - GAP - plan.h }, { x: o.x - GAP - plan.w, y: o.y - GAP - plan.h },
701
+ ];
702
+ for (const c of cands) {
703
+ const b = { x: c.x, y: c.y, w: plan.w, h: plan.h };
704
+ if (overlapsAny(b)) continue;
705
+ let score;
706
+ if (anchorBox) {
707
+ score = distC(b, anchorBox); // reclaim the old neighborhood
708
+ } else {
709
+ let pull = 0, wsum = 0;
710
+ for (const [pid, pb] of boxes) { const w = wOf(cid, pid); if (w) { pull += w * distC(b, pb); wsum += w; } }
711
+ const toCentroid = Math.hypot(cxOf(b) - mx, cyOf(b) - my);
712
+ score = (wsum ? pull / wsum : toCentroid) + 0.05 * toCentroid;
713
+ }
714
+ if (score < bestScore) { bestScore = score; bestPos = b; }
715
+ }
716
+ }
717
+ boxes.set(cid, bestPos || { x: 0, y: Math.max(...[...boxes.values()].map(b => b.y + b.h)) + GAP, w: plan.w, h: plan.h });
718
+ }
719
+ // Rim containers (Archive without a previous spot / full pass): the
720
+ // cold right edge, each right of the last so they never stack.
721
+ for (const cid of rimCtns) {
722
+ const plan = plans.get(cid);
723
+ const maxX = boxes.size ? Math.max(...[...boxes.values()].map(b => b.x + b.w)) : 0;
724
+ const topY = boxes.size ? Math.min(...[...boxes.values()].map(b => b.y)) : 0;
725
+ boxes.set(cid, { x: maxX + GAP * 3, y: topY, w: plan.w, h: plan.h });
726
+ }
727
+
728
+ // 5. Commit: normalize to START only when the map would drift off-origin
729
+ // (incremental runs keep absolute coordinates → anchored spots stay
730
+ // byte-identical), write containers + kids, stamp the layout engine.
731
+ const minX = Math.min(...[...boxes.values()].map(b => b.x));
732
+ const minY = Math.min(...[...boxes.values()].map(b => b.y));
733
+ const dx = (fullPass || minX < 0) ? G.START - minX : 0;
734
+ const dy = (fullPass || minY < 0) ? G.START - minY : 0;
735
+ for (const [cid, b] of boxes) {
736
+ const x = b.x + dx, y = b.y + dy;
737
+ canvas.positions[cid] = { ...canvas.positions[cid], x, y, w: b.w, h: b.h };
738
+ for (const kid of plans.get(cid).kids) {
739
+ canvas.positions[kid.id] = { ...canvas.positions[kid.id], x: x + kid.dx, y: y + kid.dy, w: G.CARD_W, h: kid.h };
740
+ }
741
+ }
742
+ canvas.settings = { ...(canvas.settings || {}), brainLayout: 'cluster-v1' };
553
743
  }
554
744
 
555
745
  const out = await finalizeBrainZip(zip, canvas, manifest, now);
@@ -681,6 +871,73 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
681
871
  if (unshown > 0) hidden.push(`${unshown} older/over-budget decision${unshown === 1 ? '' : 's'}`);
682
872
  if (archivedCount > 0) hidden.push(`${archivedCount} archived/superseded`);
683
873
  if (hidden.length) push('', `*${hidden.join(' + ')} not shown — search the full brain via the klypix-canvas MCP (search/read tools) or \`node ~/.claude/project-brain/global-brain-hook.mjs --full\`.*`);
874
+ // Graph health, ambient: when a third of the live brain is unlinked, say so
875
+ // once (brain_insights has the detail; auto-linking at capture works the
876
+ // backlog down going forward).
877
+ const degIds = new Set();
878
+ for (const cn of struct.connections || []) { if (cn.fromId) degIds.add(cn.fromId); if (cn.toId) degIds.add(cn.toId); }
879
+ const orphanN = live.filter(c => !degIds.has(c.id)).length;
880
+ if (live.length >= 10 && orphanN / live.length > 0.3) push('', `*graph: ${orphanN}/${live.length} live cards have no connections — \`brain_connect\` (or capturing with [[wikilinks]]) densifies the graph.*`);
881
+ return out.join('\n') + '\n';
882
+ }
883
+
884
+ // ── Ultra brief (SessionStart stdout tier) ───────────────────────────────────
885
+ // The harness persists hook stdout to a file and shows the agent only a ~2KB
886
+ // PREVIEW — a 13.5KB brief was mostly invisible (everything after Focus/open
887
+ // questions reached the agent only if it chose to open the file; it usually
888
+ // didn't). This tier is sized to fit that preview WHOLE: Focus + conflicts +
889
+ // open questions + a pointer to the FULL brief file the hook writes alongside.
890
+ // The pointer + marker legend are reserved OUT of the budget so they always fit.
891
+ export const ULTRA_BUDGET_CHARS = 1_800; // sibling of BUDGET_CHARS above — sized for the harness preview, not token cost
892
+ export function structToUltraBrief(struct, { freshness = null, briefPath = '.claude/brain-brief.md', budgetChars = ULTRA_BUDGET_CHARS } = {}) {
893
+ const texts = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim());
894
+ const isArchived = (c) => /^archive$/i.test(c.area || '');
895
+ const isFocus = (c) => /(^|\s)focus\b/i.test(c.area || '');
896
+ const live = texts.filter(c => !isArchived(c));
897
+ const focus = live.filter(isFocus);
898
+ const open = live.filter(c => /❓|🎯/.test(c.text) && !/🛠/.test(c.text) && !isFocus(c));
899
+ const skills = live.filter(c => /🛠/.test(c.text) && !isFocus(c));
900
+ const conflicts = (struct.connections || []).filter(c => c.relationship === 'conflicts_with');
901
+ const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
902
+ const fr = (c) => (freshness && freshness[c.id]) ? freshness[c.id] + ' ' : '';
903
+ const safeCut = (t, n) => { let s = t.slice(0, n); if (/[\uD800-\uDBFF]$/.test(s)) s = s.slice(0, -1); return s.trimEnd() + '…'; };
904
+ const head = (c, max = 150) => { const t = flat(c.text); return t.length > max ? safeCut(t, max - 1) : t; };
905
+ const out = [];
906
+ let used = 0;
907
+ const push = (...ls) => { for (const l of ls) { out.push(l); used += l.length + 1; } };
908
+ // Length-aware guard: a line only lands if it FITS — one long focus card
909
+ // must not blow the tier past the preview it exists to fit inside.
910
+ const pushIf = (l) => { if (used + l.length + 1 > budget) return false; out.push(l); used += l.length + 1; return true; };
911
+ const tail = [
912
+ '',
913
+ `📖 **Full brief: \`${briefPath}\`** — skills (${skills.length}), milestones, recent decisions, connections, self-heal detail. READ IT before planning non-trivial work.`,
914
+ '🧠 Capture: `🧠 BRAIN [Area]: <decision>` · `?` question · `!` milestone · `+` skill · `✓` resolve · `~` update · a "CORRECTION: …" decision supersedes its stale card across areas · suffixes `closes:` / `ev:` (full legend in the brief file).',
915
+ ];
916
+ const budget = Math.max(400, budgetChars - tail.reduce((s, l) => s + l.length + 1, 0));
917
+ push(`# ${struct.title} — brain (ultra brief)`);
918
+ push(`*${struct.counts.cards} cards · ${struct.counts.connections} connections — this is the preview tier; the full brief is one Read away (below)*`);
919
+ // clip = surrogate-safe truncation for arbitrary strings (head() covers cards).
920
+ const clip = (s, n) => { const t = flat(s); return t.length > n ? safeCut(t, n - 1) : t; };
921
+ if (focus.length && pushIf('') && pushIf('## 📌 Human focus (act on these first)')) {
922
+ let shown = 0;
923
+ for (const c of focus) { if (!pushIf(`- ${fr(c)}${head(c, 400)}`)) break; shown++; }
924
+ // Never present a truncated "act on these first" list as complete.
925
+ if (shown < focus.length) push(`- ⚠️ …and ${focus.length - shown} MORE focus card(s) — read the full brief before acting.`);
926
+ }
927
+ if (conflicts.length && pushIf('') && pushIf('## ⚠️ Conflicts to reconcile')) {
928
+ let shown = 0;
929
+ for (const c of conflicts.slice(0, 4)) { if (!pushIf(`- ${clip(c.from, 70)} ⚔️ ${clip(c.to, 70)}`)) break; shown++; }
930
+ if (shown < conflicts.length) pushIf(`- …and ${conflicts.length - shown} more conflict(s) — in the full brief.`);
931
+ }
932
+ if (open.length && pushIf('') && pushIf(`## Open questions & goals (${open.length})`)) {
933
+ let shown = 0;
934
+ for (const c of open) { if (!pushIf(`- ${fr(c)}${head(c)}`)) break; shown++; }
935
+ if (shown < open.length) pushIf(`- …and ${open.length - shown} more — in the full brief.`);
936
+ }
937
+ const areas = struct.cards.filter(c => c.type === 'container' && !/^archive$/i.test(c.title || ''))
938
+ .map(c => `${flat(c.title)} (${texts.filter(t => t.parentId === c.id).length})`);
939
+ if (areas.length) { pushIf(''); pushIf(clip('Areas: ' + areas.join(' · '), 240)); }
940
+ push(...tail);
684
941
  return out.join('\n') + '\n';
685
942
  }
686
943
 
@@ -712,11 +969,20 @@ export function scoreCardsAgainstQuery(struct, query, { topK = 6, minScore = 2,
712
969
  const titleW = wordsOf(c.title);
713
970
  const bodyW = wordsOf(c.text);
714
971
  const tagStems = new Set((c.tags || []).map(t => String(t).toLowerCase().replace(/^#/, '').replace(/^(file|dir)-/, '')).filter(Boolean));
972
+ // Log-length normalization: a flat 1pt/word-hit made LONG cards outrank
973
+ // short ones purely by having more vocabulary to hit (one ~600-word card
974
+ // was re-injected into 3 prompts on those cheap hits). Body hits are
975
+ // scaled by log2 of the body's distinct-word count — ≤64 words is EXACTLY
976
+ // unpenalized (log2(64)=6), a 600-word card scores ~0.65/hit, so a
977
+ // body-only match on a big card needs 5 distinct hits to clear the recall
978
+ // minScore of 3 — by design. Title/tag hits (the precise signals) are
979
+ // untouched.
980
+ const lenNorm = Math.min(1, 6 / Math.max(6, Math.log2(bodyW.size || 1)));
715
981
  let score = 0;
716
982
  for (const tok of tokens) {
717
983
  if (titleW.has(tok)) score += 3;
718
984
  else if (tagStems.has(tok)) score += 3;
719
- else if (bodyW.has(tok)) score += 1;
985
+ else if (bodyW.has(tok)) score += lenNorm;
720
986
  }
721
987
  if (score <= 0) continue;
722
988
  if ((c.createdAt || 0) >= cutoff) score += 0.5; // gentle recency tiebreak, never dominant
@@ -785,6 +1051,23 @@ export function findUnrecordedMigrations(struct, files, { max = 6 } = {}) {
785
1051
  // recall list still shows below). Returns each card's `kind` so the caller can
786
1052
  // say "reuse it" (shipped/resolved) vs "see what replaced it" (superseded).
787
1053
  // Pure + node-runnable; reuses the one shared tokenizer — no divergent scorer.
1054
+ // Generic work-verbs establish that a prompt is WORK, not WHICH work — two of
1055
+ // them shared with a 🏁 ship-card title used to clear the floor ("deploy it"
1056
+ // flagged two unrelated PR-merge cards; zero true positives all session).
1057
+ // Excluded from repeat scoring entirely; the loose recall list still sees them.
1058
+ const REPEAT_VERB_STOP = new Set([
1059
+ 'deploy', 'deployed', 'deploying', 'deploys', 'ship', 'shipped', 'shipping', 'ships',
1060
+ 'merge', 'merged', 'merges', 'merging', 'release', 'released', 'releases', 'releasing',
1061
+ 'publish', 'published', 'publishes', 'publishing', 'push', 'pushed', 'land', 'landed',
1062
+ 'build', 'builds', 'building', 'built', 'check', 'checked', 'checking', 'checks',
1063
+ 'plan', 'planned', 'planning', 'plans', 'best', 'class', 'cut', 'hand', 'handoff',
1064
+ 'report', 'reports', 'live', 'latest', 'done', 'complete', 'completed', 'finish', 'finished',
1065
+ 'work', 'task', 'feature',
1066
+ ]);
1067
+ // Entity-shaped token — the kind that pins WHICH work: carries a digit (version,
1068
+ // PR#), or a kebab/snake identifier. A #file-/#dir- tag-stem match counts as an
1069
+ // entity at match time regardless of shape (tags are capture-stamped anchors).
1070
+ const isEntityToken = (t) => /\d/.test(t) || t.includes('-') || t.includes('_');
788
1071
  export function detectRepeatWork(struct, query, { topK = 2, minScore = 5, minTokens = 2 } = {}) {
789
1072
  const tokens = Array.isArray(query) ? query.filter(Boolean) : queryTokens(query);
790
1073
  if (tokens.length < minTokens || !struct || !Array.isArray(struct.cards)) return [];
@@ -802,13 +1085,19 @@ export function detectRepeatWork(struct, query, { topK = 2, minScore = 5, minTok
802
1085
  const titleW = wordsOf(firstMeaningful || c.title);
803
1086
  const bodyW = wordsOf(c.text);
804
1087
  const tagStems = new Set((c.tags || []).map(t => String(t).toLowerCase().replace(/^#/, '').replace(/^(file|dir)-/, '')).filter(Boolean));
805
- let score = 0, matched = 0;
1088
+ // Auto-harvested ship cards (#auto, one-line merge/release events) are
1089
+ // dense with exactly the verbs above — they additionally need ≥1 ENTITY
1090
+ // token matched before a nudge is worth showing.
1091
+ const isAuto = (c.tags || []).some(t => String(t).toLowerCase().replace(/^#/, '') === 'auto');
1092
+ let score = 0, matched = 0, entities = 0;
806
1093
  for (const tok of tokens) {
807
- if (titleW.has(tok)) { score += 3; matched++; }
808
- else if (tagStems.has(tok)) { score += 3; matched++; }
809
- else if (bodyW.has(tok)) { score += 1; matched++; }
1094
+ if (REPEAT_VERB_STOP.has(tok)) continue; // "that it's work" ≠ "which work"
1095
+ if (titleW.has(tok)) { score += 3; matched++; if (isEntityToken(tok)) entities++; }
1096
+ else if (tagStems.has(tok)) { score += 3; matched++; entities++; }
1097
+ else if (bodyW.has(tok)) { score += 1; matched++; if (isEntityToken(tok)) entities++; }
810
1098
  }
811
1099
  if (matched < minTokens || score < minScore) continue; // precision-first floor
1100
+ if (isAuto && entities < 1) continue; // auto cards: entity anchor required
812
1101
  out.push({ card: c, score, kind });
813
1102
  }
814
1103
  out.sort((a, b) => b.score - a.score || (rank[b.kind] - rank[a.kind]) || (b.card.createdAt || 0) - (a.card.createdAt || 0));
@@ -855,6 +1144,84 @@ export function detectConflicts(struct, { minOverlap = 0.45, topK = 12 } = {}) {
855
1144
  return out.slice(0, topK);
856
1145
  }
857
1146
 
1147
+ // ── Contradiction candidates (reconcile-proper) ──────────────────────────────
1148
+ // The retroactive cleaner for stale/correction pairs that slipped past capture
1149
+ // (cross-area + reworded → no supersede possible by construction). Unlike
1150
+ // detectConflicts (a pre-filter for an LLM verifier), this is precision-scoped
1151
+ // enough to surface DIRECTLY for human/agent confirmation: same-subject live
1152
+ // pairs where (a) exactly ONE side carries an explicit correction cue
1153
+ // (CORRECTION / OBSOLETE / was WRONG / stale note resolved) — that side is the
1154
+ // presumed truth — or (b) the two sides use OPPOSITE polarity words about the
1155
+ // same subject (deferred↔wired, broken↔fixed, dead↔live …). Pairs already
1156
+ // settled by a supersede/close/conflict arrow are excluded; a plain relates_to
1157
+ // arrow is NOT a settlement. Suggestion-only — never writes. Pure, node-runnable.
1158
+ const POLARITY_PAIRS = [
1159
+ ['deferred', 'wired'], ['deferred', 'shipped'], ['deferred', 'live'], ['deferred', 'enabled'],
1160
+ ['broken', 'fixed'], ['broken', 'working'], ['dead', 'live'], ['dead', 'alive'],
1161
+ ['disabled', 'enabled'], ['blocked', 'unblocked'], ['wrong', 'correct'], ['removed', 'restored'],
1162
+ ];
1163
+ // WORD-boundary matchers, precompiled — substring includes() made 'deadline'
1164
+ // carry the 'dead' pole and 'delivery' carry 'live', and blocked↔unblocked
1165
+ // could never fire ('unblocked' contains 'blocked').
1166
+ const POLARITY_RES = POLARITY_PAIRS.map(([x, y]) => ({ x, y, rx: new RegExp(`\\b${x}\\b`), ry: new RegExp(`\\b${y}\\b`) }));
1167
+ export function detectContradictions(struct, { minOverlap = 0.45, topK = 12 } = {}) {
1168
+ if (!struct || !Array.isArray(struct.cards)) return [];
1169
+ const isArchived = (c) => /^archive$/i.test(c.area || '');
1170
+ const live = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !isArchived(c));
1171
+ // Cue meta words stripped up front — same-subject matching must compare the
1172
+ // SUBJECT, not the vocabulary of the correction act (see stripCueMeta above).
1173
+ const wset = new Map(live.map(c => [c.id, stripCueMeta(new Set(queryTokens(c.text)))]));
1174
+ const lower = new Map(live.map(c => [c.id, String(c.text).toLowerCase()]));
1175
+ // settled — fully reconciled (supersede/close/conflict arrow): excludes the
1176
+ // pair regardless of kind.
1177
+ // linked — ANY deliberate edge (label !== 'auto'): dismisses a POLARITY pair
1178
+ // ("I looked, they relate, not a contradiction"), but never a cue pair —
1179
+ // a correction that [[wikilinks]] its stale card must still surface until
1180
+ // the stale card is actually retired. Auto-drawn edges dismiss nothing.
1181
+ const settled = new Set(), linked = new Set();
1182
+ for (const e of struct.connections || []) {
1183
+ const k1 = e.fromId + '|' + e.toId, k2 = e.toId + '|' + e.fromId;
1184
+ if (e.label === 'superseded by' || e.label === 'closed by' || e.relationship === 'conflicts_with') { settled.add(k1); settled.add(k2); }
1185
+ if (e.label !== 'auto') { linked.add(k1); linked.add(k2); }
1186
+ }
1187
+ const out = [];
1188
+ for (let i = 0; i < live.length; i++) {
1189
+ for (let j = i + 1; j < live.length; j++) {
1190
+ const a = live[i], b = live[j];
1191
+ if (settled.has(a.id + '|' + b.id)) continue;
1192
+ const A = wset.get(a.id), B = wset.get(b.id);
1193
+ if (A.size < 4 || B.size < 4) continue;
1194
+ let inter = 0; for (const t of A) if (B.has(t)) inter++;
1195
+ const overlap = inter / Math.min(A.size, B.size); // same subject?
1196
+ const aCue = hasCorrectionCue(a.text), bCue = hasCorrectionCue(b.text);
1197
+ // Cue-asymmetric pairs may also match on absolute subject mass (long
1198
+ // cards — see cueMatch above); cue-less pairs keep the strict ratio.
1199
+ const subjectHit = overlap >= minOverlap
1200
+ || (aCue !== bCue && inter >= CUE_STRONG_SHARED && overlap >= CUE_RELAXED_COEF);
1201
+ if (!subjectHit) continue;
1202
+ let why = null, staleC = null, freshC = null;
1203
+ if (aCue !== bCue) {
1204
+ why = 'correction-cue'; // one side explicitly corrects — it is the presumed truth
1205
+ freshC = aCue ? a : b; staleC = aCue ? b : a;
1206
+ } else if (!aCue && !linked.has(a.id + '|' + b.id)) {
1207
+ const la = lower.get(a.id), lb = lower.get(b.id);
1208
+ for (const { x, y, rx, ry } of POLARITY_RES) {
1209
+ // each side must carry ONE pole only (word-level) — a card
1210
+ // narrating "from deferred to wired" holds both and
1211
+ // contradicts neither.
1212
+ if ((rx.test(la) && !ry.test(la) && ry.test(lb) && !rx.test(lb))
1213
+ || (ry.test(la) && !rx.test(la) && rx.test(lb) && !ry.test(lb))) { why = `polarity: ${x} ↔ ${y}`; break; }
1214
+ }
1215
+ if (why) [staleC, freshC] = (a.createdAt || 0) <= (b.createdAt || 0) ? [a, b] : [b, a]; // later card = presumed truth
1216
+ }
1217
+ if (!why) continue;
1218
+ out.push({ stale: staleC, fresh: freshC, why, overlap: Math.round(overlap * 100) / 100, cue: aCue || bCue });
1219
+ }
1220
+ }
1221
+ out.sort((x, y) => (Number(y.cue) - Number(x.cue)) || (y.overlap - x.overlap));
1222
+ return out.slice(0, topK);
1223
+ }
1224
+
858
1225
  // ── Brain insights ───────────────────────────────────────────────────────────
859
1226
  // "What matters here, and what am I forgetting?" — a deterministic structural
860
1227
  // read of the brain (borrowed from graphify's GRAPH_REPORT, applied to the
@@ -948,9 +1315,12 @@ export function proposeStructuralConnections(struct, { maxPerCard = 2 } = {}) {
948
1315
  const live = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !/^archive$/i.test(c.area || ''));
949
1316
  const linked = new Set(struct.connections.map(c => [c.fromId, c.toId].sort().join('|')));
950
1317
  const titleIx = live.filter(c => (c.title || '').trim()).map(c => ({ id: c.id, t: c.title.trim().toLowerCase() }));
1318
+ // 'auto' is PROVENANCE, not topic — as a shared tag it would link every
1319
+ // harvested ship card to every other one (junk edges that then push their
1320
+ // degree past the garden's dormancy guard).
951
1321
  const tagsOf = (c) => (c.tags || [])
952
1322
  .map(t => String(t).toLowerCase().replace(/^#/, ''))
953
- .filter(t => t && t !== 'area' && t !== String(c.area || '').toLowerCase());
1323
+ .filter(t => t && t !== 'area' && t !== 'auto' && t !== String(c.area || '').toLowerCase());
954
1324
  const cand = [];
955
1325
  for (const c of live) {
956
1326
  for (const link of (c.links || [])) {
@@ -1005,6 +1375,83 @@ const overlapScore = (a, b) => {
1005
1375
  // failed to fire.)
1006
1376
  const coverageOf = (target, hay) => { if (!target.size) return 0; let h = 0; for (const w of target) if (hay.has(w)) h++; return h / target.size; };
1007
1377
 
1378
+ // ── Truth decay (P1) — corrections must never lose to the cards they correct ─
1379
+ // A correction-cue note explicitly declares an older fact stale ("CORRECTION:",
1380
+ // "was WRONG", "OBSOLETE", "stale note resolved"). The same-area ≥0.6 supersede
1381
+ // can miss it BY CONSTRUCTION (the correction often lands in a different area,
1382
+ // reworded) — the stale card then stays live and recall serves it alone. Shared
1383
+ // by: the capture-side widened supersede, the recall-side overlay below, and
1384
+ // detectContradictions.
1385
+ // DELIBERATE cue only: the uppercase forms are the documented convention, and
1386
+ // case-sensitivity is what keeps casual prose ("the floor calc was wrong",
1387
+ // "remove obsolete helper", "color-correction") from firing a cross-area
1388
+ // supersede on an innocent card. "stale note resolved" is the one
1389
+ // natural-language phrase, accepted in any case (it is never incidental).
1390
+ export const CORRECTION_RE = /\bCORRECTIONS?\b|\bOBSOLETE\b|\bwas WRONG\b/;
1391
+ const CORRECTION_PHRASE_RE = /\bstale note (?:is )?resolved\b/i;
1392
+ export const hasCorrectionCue = (t) => CORRECTION_RE.test(String(t || '')) || CORRECTION_PHRASE_RE.test(String(t || ''));
1393
+ export const CORRECTION_SUPERSEDE_AT = 0.4; // widened cross-area bar (vs same-area SUPERSEDE_AT 0.6)
1394
+ // Cue META words describe the act of correcting, not the subject — left in, they
1395
+ // dilute the overlap denominator and push real correction pairs just under the
1396
+ // bar (the field fixture lands at 0.375 with them, 0.5 without). Stripped before
1397
+ // every correction-overlap comparison.
1398
+ const CORRECTION_META = new Set(['correction', 'corrections', 'obsolete', 'stale', 'note', 'notes', 'resolved', 'wrong']);
1399
+ const stripCueMeta = (set) => { const out = new Set(); for (const w of set) if (!CORRECTION_META.has(w)) out.add(w); return out; };
1400
+ // Long-card reality: a correction's SUBJECT is a fraction of each card — the
1401
+ // overlap COEFFICIENT alone punishes long↔long pairs (the real field pair
1402
+ // measures 0.33 with 17 shared subject tokens, under every per-ratio bar). A
1403
+ // cue-gated match therefore also fires on ABSOLUTE subject mass: ≥10 shared
1404
+ // meaningful tokens at ≥0.25 coefficient. Cue-gated ONLY — plain supersede and
1405
+ // polarity pairs keep their strict ratio bars (no cue prior to lean on).
1406
+ const CUE_STRONG_SHARED = 10, CUE_RELAXED_COEF = 0.25;
1407
+ // Floor 3 (not overlapScore's 4): after stripCueMeta a terse deliberate
1408
+ // correction ("CORRECTION: the vault default was WRONG — use cwd") keeps only
1409
+ // 3-ish subject tokens; at 4 it silently no-oped. ≤2-token corrections still
1410
+ // no-op (too little signal to archive on) — they land as a new card; use ~ to
1411
+ // edit a card in place instead.
1412
+ const cueMatch = (a, b, bar) => {
1413
+ if (a.size < 3 || b.size < 3) return 0;
1414
+ let inter = 0; for (const w of a) if (b.has(w)) inter++;
1415
+ const coef = inter / Math.min(a.size, b.size);
1416
+ return (coef >= bar || (inter >= CUE_STRONG_SHARED && coef >= CUE_RELAXED_COEF)) ? coef : 0;
1417
+ };
1418
+
1419
+ // Recall-side guard: given the cards recall is about to inject, return for each
1420
+ // one the card that CORRECTS it, found two ways:
1421
+ // • edge — an outgoing "superseded by"/"closed by" arrow (drawn by capture or
1422
+ // a confirmed reconcile) whose successor still has text;
1423
+ // • cue — a LIVE correction-cue card that lexically overlaps it ≥ `at`, ANY
1424
+ // area (the un-edged pair the capture-time supersede missed).
1425
+ // The caller injects the corrector FIRST (labeled) and reduces the stale hit to
1426
+ // a headline — the stale text never stands alone. Pure + cheap: correction-cue
1427
+ // cards are rare and the hit list is ≤topK.
1428
+ export function correctionOverlaysFor(struct, cards, { at = CORRECTION_SUPERSEDE_AT } = {}) {
1429
+ const out = new Map();
1430
+ if (!struct || !Array.isArray(struct.cards) || !Array.isArray(cards) || !cards.length) return out;
1431
+ const byId = new Map(struct.cards.map(c => [c.id, c]));
1432
+ const isArchived = (c) => /^archive$/i.test(c.area || '');
1433
+ const successorOf = new Map();
1434
+ for (const cn of struct.connections || []) {
1435
+ if (cn.label === 'superseded by' || cn.label === 'closed by') successorOf.set(cn.fromId, cn.toId);
1436
+ }
1437
+ const cues = struct.cards.filter(c => c.type !== 'container' && !isArchived(c) && (c.text || '').trim() && hasCorrectionCue(c.text));
1438
+ for (const card of cards) {
1439
+ if (!card || !card.id) continue;
1440
+ const succ = successorOf.has(card.id) ? byId.get(successorOf.get(card.id)) : null;
1441
+ if (succ && (succ.text || '').trim()) { out.set(card.id, { kind: 'edge', by: succ }); continue; }
1442
+ if (hasCorrectionCue(card.text)) continue; // the hit IS a correction — nothing to overlay
1443
+ const cTok = tokenSet(card.text);
1444
+ let best = null, bestS = 0;
1445
+ for (const cue of cues) {
1446
+ if (cue.id === card.id) continue;
1447
+ const s = cueMatch(cTok, stripCueMeta(tokenSet(cue.text)), at);
1448
+ if (s > bestS) { bestS = s; best = cue; }
1449
+ }
1450
+ if (best && bestS > 0) out.set(card.id, { kind: 'cue', by: best, overlap: Math.round(bestS * 100) / 100 });
1451
+ }
1452
+ return out;
1453
+ }
1454
+
1008
1455
  // ── Auto-skill classifier (skills emerge from the flow, not just the '+' marker) ─
1009
1456
  // A REUSABLE skill (how-to / gotcha / convention) reads as a GENERAL RULE that
1010
1457
  // applies next time — distinct from a one-time decision ("we shipped X"). This is
@@ -1024,9 +1471,12 @@ export function looksLikeSkill(text) {
1024
1471
  }
1025
1472
 
1026
1473
  export async function captureIntoBrain(buffer, { cards = [], resolutions = [], updates = [] } = {}) {
1027
- const SUPERSEDE_AT = 0.6, RESOLVE_AT = 0.3, UPDATE_AT = 0.45, CLOSE_COVER_AT = 0.6;
1474
+ const SUPERSEDE_AT = 0.6, RESOLVE_AT = 0.3, UPDATE_AT = 0.45, CLOSE_COVER_AT = 0.6, QUESTION_MERGE_AT = 0.6;
1028
1475
  let work = buffer;
1029
- const stats = { added: 0, superseded: 0, resolved: 0, linked: 0, updated: 0, closed: 0 };
1476
+ // corrections[] lists cross-area/low-bar supersedes driven by a correction
1477
+ // cue, so the surface (brain_note result / capture stderr) can say WHAT was
1478
+ // archived and how to undo — the confirmation channel for the widened match.
1479
+ const stats = { added: 0, superseded: 0, resolved: 0, linked: 0, updated: 0, closed: 0, merged: 0, corrections: [] };
1030
1480
 
1031
1481
  // Pass 1 — resolutions + supersede marking operate on EXISTING cards.
1032
1482
  if (resolutions.length || cards.length || updates.length) {
@@ -1093,24 +1543,36 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
1093
1543
  };
1094
1544
  canvas.connections = Array.isArray(canvas.connections) ? canvas.connections : [];
1095
1545
 
1096
- // RESOLVE (✓ markers) — best live match in the area; ❓ cards preferred.
1546
+ // RESOLVE (✓ markers) — best live match in the area, PLUS its near-tie
1547
+ // twins: a rephrased duplicate ❓ scores within a hair of its sibling, and
1548
+ // resolving only the first left the twin open forever (it kept surfacing
1549
+ // in every brief as still-to-do). Precision-kept: the set is the best
1550
+ // match ± 0.1, never everything above the loose 0.3 floor. ❓ preferred.
1097
1551
  const milestonesFallback = [];
1098
1552
  for (const r of resolutions) {
1099
1553
  const rTok = tokenSet(r.text);
1100
- let best = null, bestScore = 0;
1554
+ const cands = [];
1101
1555
  for (const c of liveTextCards()) {
1102
1556
  if (r.area && (c.area || '').toLowerCase() !== r.area.toLowerCase()) continue;
1557
+ if (/🛠/.test(c.text)) continue; // skills are standing reference — a ✓ must never archive one (mirror the supersede guard)
1103
1558
  const s = overlapScore(rTok, tokenSet(c.text)) + (/❓|🎯/.test(c.text) ? 0.15 : 0);
1104
- if (s > bestScore) { bestScore = s; best = c; }
1559
+ if (s > 0) cands.push({ c, s });
1105
1560
  }
1106
- if (best && bestScore >= RESOLVE_AT) {
1107
- await rewriteCard(best.id, j => {
1108
- j.content = `${j.content}\n✅ ${today}: ${r.text}`;
1109
- j.borderColor = 'rgba(16,185,129,0.35)';
1110
- });
1111
- await archiveCard(best.id);
1112
- best.text += ` ✅ ${r.text}`; // keep in-memory struct honest for later matching
1113
- stats.resolved++;
1561
+ cands.sort((a, b) => b.s - a.s);
1562
+ const bestScore = cands.length ? cands[0].s : 0;
1563
+ const set = bestScore >= RESOLVE_AT
1564
+ ? cands.filter(x => x.s >= Math.max(RESOLVE_AT, bestScore - 0.1)).slice(0, 3)
1565
+ : [];
1566
+ if (set.length) {
1567
+ for (const { c: best } of set) {
1568
+ await rewriteCard(best.id, j => {
1569
+ j.content = `${j.content}\n✅ ${today}: ${r.text}`;
1570
+ j.borderColor = 'rgba(16,185,129,0.35)';
1571
+ });
1572
+ await archiveCard(best.id);
1573
+ best.text += ` ✅ ${r.text}`; // keep in-memory struct honest for later matching
1574
+ stats.resolved++;
1575
+ }
1114
1576
  } else {
1115
1577
  milestonesFallback.push({ text: (r.area ? `${r.area}: ` : '') + `🏁 ${r.text}`, area: r.area, borderColor: 'rgba(59,130,246,0.8)' });
1116
1578
  }
@@ -1146,6 +1608,34 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
1146
1608
  }
1147
1609
  }
1148
1610
 
1611
+ // MERGE-ON-CAPTURE for duplicate open questions — a rephrased ❓ that
1612
+ // heavily overlaps an EXISTING live ❓ updates that card in place (fresh
1613
+ // wording + createdAt) instead of stacking a twin the close-pass would
1614
+ // later miss. (Supersede deliberately skips ? cards, so without this
1615
+ // twins could never merge at capture at all.)
1616
+ for (let i = cards.length - 1; i >= 0; i--) {
1617
+ const card = cards[i];
1618
+ if (!/❓/.test(card.text) || /🏁|🛠/.test(card.text)) continue;
1619
+ const nTok = tokenSet(card.text);
1620
+ let best = null, bestScore = 0;
1621
+ for (const c of liveTextCards()) {
1622
+ if (!/❓/.test(c.text)) continue;
1623
+ const s = overlapScore(nTok, tokenSet(c.text));
1624
+ if (s > bestScore) { bestScore = s; best = c; }
1625
+ }
1626
+ if (best && bestScore >= QUESTION_MERGE_AT) {
1627
+ await rewriteCard(best.id, j => {
1628
+ j.content = String(card.text);
1629
+ j.createdAt = now;
1630
+ if (card.createdVia) j.createdVia = String(card.createdVia);
1631
+ if (Array.isArray(card.evidence) && card.evidence.length) j.evidence = card.evidence;
1632
+ });
1633
+ best.text = String(card.text);
1634
+ cards.splice(i, 1);
1635
+ stats.merged++;
1636
+ }
1637
+ }
1638
+
1149
1639
  // SUPERSEDE — pre-mark old cards that a NEW decision replaces. The arrow
1150
1640
  // to the new card is drawn in pass 2 (after the new ids exist), matched
1151
1641
  // back by remembering which old card each new card displaced.
@@ -1153,22 +1643,36 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
1153
1643
  if (/❓|🎯|🏁|🛠/.test(card.text)) continue; // only plain decisions supersede (not questions/goals/milestones/skills)
1154
1644
  const nTok = tokenSet(card.text);
1155
1645
  const area = (card.area || '').toLowerCase();
1646
+ // A correction-cue note ("CORRECTION: … was WRONG") declares it
1647
+ // replaces something — widen the search to ALL areas and lower the
1648
+ // bar: a cross-area reworded correction could never fire the
1649
+ // same-area 0.6 path by construction, which is exactly how stale
1650
+ // cards outlived their corrections in the field.
1651
+ const isCorrection = hasCorrectionCue(card.text);
1652
+ const nTokCmp = isCorrection ? stripCueMeta(nTok) : nTok; // cue meta words dilute the denominator
1156
1653
  let best = null, bestScore = 0;
1157
1654
  for (const c of liveTextCards()) {
1158
- if (area && (c.area || '').toLowerCase() !== area) continue;
1655
+ if (!isCorrection && area && (c.area || '').toLowerCase() !== area) continue;
1159
1656
  if (/🛠/.test(c.text)) continue; // never auto-archive a 🛠️ skill via a decision's supersede — skills are standing reference (correct with ~)
1160
- const s = overlapScore(nTok, tokenSet(c.text));
1657
+ // cueMatch returns 0 unless it clears the widened bar (ratio OR
1658
+ // absolute subject mass) — so for corrections, any non-zero fires.
1659
+ const s = isCorrection ? cueMatch(nTokCmp, tokenSet(c.text), CORRECTION_SUPERSEDE_AT) : overlapScore(nTok, tokenSet(c.text));
1161
1660
  if (s > bestScore) { bestScore = s; best = c; }
1162
1661
  }
1163
- if (best && bestScore >= SUPERSEDE_AT) {
1662
+ if (best && (isCorrection ? bestScore > 0 : bestScore >= SUPERSEDE_AT)) {
1164
1663
  await rewriteCard(best.id, j => {
1165
1664
  j.content = `↩︎ superseded ${today}\n${j.content}`;
1166
1665
  j.borderColor = 'rgba(120,120,135,0.5)';
1167
1666
  });
1168
1667
  await archiveCard(best.id);
1668
+ const wasCross = isCorrection && (bestScore < SUPERSEDE_AT || (area && (best.area || '').toLowerCase() !== area));
1169
1669
  best.text = `↩︎ ${best.text}`;
1170
1670
  card.__supersedes = best.id;
1171
1671
  stats.superseded++;
1672
+ // Surface the widened match for confirmation: the caller tells the
1673
+ // agent what was archived and how to undo (restore from Archive /
1674
+ // re-run with ~) — the widened bar acts WITH a visible receipt.
1675
+ if (wasCross) stats.corrections.push({ old: (best.title || String(best.text).replace(/^↩︎\s*/, '').slice(0, 80)), area: best.area || null, overlap: Math.round(bestScore * 100) / 100 });
1172
1676
  }
1173
1677
  }
1174
1678
 
@@ -1184,25 +1688,41 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
1184
1688
  if (!target) continue;
1185
1689
  const wantTitle = target.replace(/^\[\[/, '').replace(/\]\]$/, '').trim().toLowerCase();
1186
1690
  const tTok = tokenSet(target);
1187
- let best = null, bestScore = 0;
1691
+ // Collect EVERY live card the close-target covers — near-duplicate ❓
1692
+ // twins score together, and the old first-match-and-break resolved one
1693
+ // while its twin stayed "open" in every brief forever. Capped for
1694
+ // safety: a close-target is deliberate, so >4 matches means it was too
1695
+ // generic to trust beyond the strongest few.
1696
+ const matches = [];
1188
1697
  for (const c of liveTextCards()) {
1698
+ if (/🛠/.test(c.text)) continue; // skills are standing reference — a closes: must never archive one (mirror the supersede guard)
1189
1699
  const ct = (c.title || '').trim().toLowerCase();
1190
- // Title fast-path: exact / prefix / or the card title CONTAINS the
1191
- // target (handles the common "Area: <title> (extra…)" card title).
1192
- if (ct && wantTitle.length >= 6 && (ct === wantTitle || ct.startsWith(wantTitle) || wantTitle.startsWith(ct) || ct.includes(wantTitle))) { best = c; bestScore = 1; break; }
1700
+ // Title fast-path: exact / prefix (≥6 chars), or the card title
1701
+ // CONTAINS the target — the contains variant needs a LONGER target
1702
+ // (≥10) because a short generic word ("sandbox") appears in many
1703
+ // unrelated titles and the multi-close below would sweep them all.
1704
+ if (ct && wantTitle.length >= 6 && (ct === wantTitle || ct.startsWith(wantTitle) || wantTitle.startsWith(ct))) { matches.push({ c, cov: 1 }); continue; }
1705
+ if (ct && wantTitle.length >= 10 && ct.includes(wantTitle)) { matches.push({ c, cov: 1 }); continue; }
1193
1706
  // Else target-coverage (≥2 tokens, no floor): a short deliberate
1194
1707
  // close-target whose tokens are present in a card is a precise hit.
1195
- if (tTok.size >= 2) { const cov = coverageOf(tTok, tokenSet(c.text)); if (cov > bestScore) { bestScore = cov; best = c; } }
1708
+ if (tTok.size >= 2) { const cov = coverageOf(tTok, tokenSet(c.text)); if (cov >= CLOSE_COVER_AT) matches.push({ c, cov }); }
1196
1709
  }
1197
- if (best && bestScore >= CLOSE_COVER_AT) {
1198
- const ship = String(card.text).replace(/\s+/g, ' ').replace(/^[^:\n]{1,40}:\s*/, '').replace(/^🏁\s*/, '').trim().slice(0, 80);
1710
+ matches.sort((a, b) => b.cov - a.cov);
1711
+ // >4 matches means the target was too GENERIC to trust a sweep —
1712
+ // fall back to the single best match (the pre-1.17 behavior) rather
1713
+ // than archive four semi-related cards in iteration order.
1714
+ const chosen = matches.length > 4 ? matches.slice(0, 1) : matches;
1715
+ if (!chosen.length) continue;
1716
+ const ship = String(card.text).replace(/\s+/g, ' ').replace(/^[^:\n]{1,40}:\s*/, '').replace(/^🏁\s*/, '').trim().slice(0, 80);
1717
+ card.__closesIds = [];
1718
+ for (const { c: best } of chosen) {
1199
1719
  await rewriteCard(best.id, j => {
1200
1720
  j.content = `${j.content}\n✅ ${today}: closed by → ${ship}`;
1201
1721
  j.borderColor = 'rgba(16,185,129,0.35)';
1202
1722
  });
1203
1723
  await archiveCard(best.id);
1204
1724
  best.text = `✅ ${best.text}`;
1205
- card.__closes = best.id;
1725
+ card.__closesIds.push(best.id);
1206
1726
  stats.closed++;
1207
1727
  }
1208
1728
  }
@@ -1238,11 +1758,13 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
1238
1758
  const titleIndex = struct.cards
1239
1759
  .filter(c => (c.title || '').trim())
1240
1760
  .map(c => ({ id: c.id, t: c.title.trim().toLowerCase() }));
1761
+ const newIds = new Set();
1241
1762
  for (const card of cards) {
1242
1763
  const created = findNew(card.text);
1243
1764
  if (!created) continue;
1765
+ newIds.add(created.id);
1244
1766
  if (card.__supersedes) addConn(card.__supersedes, created.id, 'superseded by', undefined);
1245
- if (card.__closes) addConn(card.__closes, created.id, 'closed by', undefined);
1767
+ for (const cid of (card.__closesIds || [])) addConn(cid, created.id, 'closed by', undefined);
1246
1768
  for (const link of (created.links || [])) {
1247
1769
  const want = String(link).trim().toLowerCase();
1248
1770
  if (!want) continue;
@@ -1250,6 +1772,21 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
1250
1772
  if (target) addConn(created.id, target.id, undefined, 'relates_to');
1251
1773
  }
1252
1774
  }
1775
+ // Structural auto-link for the NEW cards — the graph used to form only
1776
+ // when someone explicitly ran brain_connect (never, in practice: 66%
1777
+ // orphans in the field). Run the cheap tag/[[title]] proposer over the
1778
+ // post-append struct and keep only edges touching a just-added card
1779
+ // (≤2 per card via the proposer's own cap), labeled 'auto' so they're
1780
+ // distinguishable from deliberate arrows. Best-effort: an auto-link
1781
+ // failure must never fail a capture.
1782
+ if (newIds.size) {
1783
+ try {
1784
+ for (const e of proposeStructuralConnections(struct, { maxPerCard: 2 })) {
1785
+ if (!newIds.has(e.fromId) && !newIds.has(e.toId)) continue;
1786
+ addConn(e.fromId, e.toId, 'auto', 'relates_to');
1787
+ }
1788
+ } catch { /* auto-linking is opportunistic */ }
1789
+ }
1253
1790
  work = await finalizeBrainZip(zip, canvas, manifest, now);
1254
1791
  }
1255
1792
 
@@ -1265,6 +1802,7 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
1265
1802
  // to Archive, and arrowed → the synthesis. Nothing is deleted (archived verbatim).
1266
1803
  const GARDEN_KEEP_NEWEST = 8; // per area, never consolidate the newest N
1267
1804
  const GARDEN_MIN_AGE_DAYS = 14; // only cards older than this are candidates
1805
+ const GARDEN_AUTO_MIN_AGE_DAYS = 7; // #auto ship-event cards age out sooner — the durable fact usually also exists as a hand-written milestone
1268
1806
  const GARDEN_MIN_CANDIDATES = 3; // don't bother merging fewer than this
1269
1807
  const GARDEN_MAX_DEGREE = 1; // SMART guard: protect load-bearing cards —
1270
1808
  // only consolidate cards with ≤ this many connections (orphans + leaves). A
@@ -1311,9 +1849,11 @@ export function selectGardenCandidates(struct, { keepNewest = GARDEN_KEEP_NEWEST
1311
1849
  const children = struct.cards
1312
1850
  .filter(c => c.type === 'text' && c.parentId === ctn.id && (c.text || '').trim() && !/⤵|↩|✅|🛠/.test(c.text)) // 🛠️ skills are standing reference — never consolidate them away
1313
1851
  .sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
1852
+ const autoCutoff = now - GARDEN_AUTO_MIN_AGE_DAYS * 86_400_000;
1853
+ const isAuto = (c) => (c.tags || []).some(t => String(t).toLowerCase().replace(/^#/, '') === 'auto');
1314
1854
  const old = children
1315
1855
  .slice(0, Math.max(0, children.length - keepNewest))
1316
- .filter(c => (c.createdAt || 0) < cutoff && (degree.get(c.id) || 0) <= maxDegree); // dormant: old AND peripheral
1856
+ .filter(c => (c.createdAt || 0) < (isAuto(c) ? autoCutoff : cutoff) && (degree.get(c.id) || 0) <= maxDegree); // dormant: old AND peripheral (#auto ages faster)
1317
1857
  if (old.length >= minCandidates) out.push({ containerId: ctn.id, title, candidates: old.map(c => ({ id: c.id, text: c.text, createdAt: c.createdAt || 0, degree: degree.get(c.id) || 0 })) });
1318
1858
  }
1319
1859
  return out;