klypix-mcp 1.16.0 → 1.17.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.17.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"
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
  }
@@ -681,6 +681,73 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
681
681
  if (unshown > 0) hidden.push(`${unshown} older/over-budget decision${unshown === 1 ? '' : 's'}`);
682
682
  if (archivedCount > 0) hidden.push(`${archivedCount} archived/superseded`);
683
683
  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\`.*`);
684
+ // Graph health, ambient: when a third of the live brain is unlinked, say so
685
+ // once (brain_insights has the detail; auto-linking at capture works the
686
+ // backlog down going forward).
687
+ const degIds = new Set();
688
+ for (const cn of struct.connections || []) { if (cn.fromId) degIds.add(cn.fromId); if (cn.toId) degIds.add(cn.toId); }
689
+ const orphanN = live.filter(c => !degIds.has(c.id)).length;
690
+ 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.*`);
691
+ return out.join('\n') + '\n';
692
+ }
693
+
694
+ // ── Ultra brief (SessionStart stdout tier) ───────────────────────────────────
695
+ // The harness persists hook stdout to a file and shows the agent only a ~2KB
696
+ // PREVIEW — a 13.5KB brief was mostly invisible (everything after Focus/open
697
+ // questions reached the agent only if it chose to open the file; it usually
698
+ // didn't). This tier is sized to fit that preview WHOLE: Focus + conflicts +
699
+ // open questions + a pointer to the FULL brief file the hook writes alongside.
700
+ // The pointer + marker legend are reserved OUT of the budget so they always fit.
701
+ export const ULTRA_BUDGET_CHARS = 1_800; // sibling of BUDGET_CHARS above — sized for the harness preview, not token cost
702
+ export function structToUltraBrief(struct, { freshness = null, briefPath = '.claude/brain-brief.md', budgetChars = ULTRA_BUDGET_CHARS } = {}) {
703
+ const texts = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim());
704
+ const isArchived = (c) => /^archive$/i.test(c.area || '');
705
+ const isFocus = (c) => /(^|\s)focus\b/i.test(c.area || '');
706
+ const live = texts.filter(c => !isArchived(c));
707
+ const focus = live.filter(isFocus);
708
+ const open = live.filter(c => /❓|🎯/.test(c.text) && !/🛠/.test(c.text) && !isFocus(c));
709
+ const skills = live.filter(c => /🛠/.test(c.text) && !isFocus(c));
710
+ const conflicts = (struct.connections || []).filter(c => c.relationship === 'conflicts_with');
711
+ const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
712
+ const fr = (c) => (freshness && freshness[c.id]) ? freshness[c.id] + ' ' : '';
713
+ const safeCut = (t, n) => { let s = t.slice(0, n); if (/[\uD800-\uDBFF]$/.test(s)) s = s.slice(0, -1); return s.trimEnd() + '…'; };
714
+ const head = (c, max = 150) => { const t = flat(c.text); return t.length > max ? safeCut(t, max - 1) : t; };
715
+ const out = [];
716
+ let used = 0;
717
+ const push = (...ls) => { for (const l of ls) { out.push(l); used += l.length + 1; } };
718
+ // Length-aware guard: a line only lands if it FITS — one long focus card
719
+ // must not blow the tier past the preview it exists to fit inside.
720
+ const pushIf = (l) => { if (used + l.length + 1 > budget) return false; out.push(l); used += l.length + 1; return true; };
721
+ const tail = [
722
+ '',
723
+ `📖 **Full brief: \`${briefPath}\`** — skills (${skills.length}), milestones, recent decisions, connections, self-heal detail. READ IT before planning non-trivial work.`,
724
+ '🧠 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).',
725
+ ];
726
+ const budget = Math.max(400, budgetChars - tail.reduce((s, l) => s + l.length + 1, 0));
727
+ push(`# ${struct.title} — brain (ultra brief)`);
728
+ push(`*${struct.counts.cards} cards · ${struct.counts.connections} connections — this is the preview tier; the full brief is one Read away (below)*`);
729
+ // clip = surrogate-safe truncation for arbitrary strings (head() covers cards).
730
+ const clip = (s, n) => { const t = flat(s); return t.length > n ? safeCut(t, n - 1) : t; };
731
+ if (focus.length && pushIf('') && pushIf('## 📌 Human focus (act on these first)')) {
732
+ let shown = 0;
733
+ for (const c of focus) { if (!pushIf(`- ${fr(c)}${head(c, 400)}`)) break; shown++; }
734
+ // Never present a truncated "act on these first" list as complete.
735
+ if (shown < focus.length) push(`- ⚠️ …and ${focus.length - shown} MORE focus card(s) — read the full brief before acting.`);
736
+ }
737
+ if (conflicts.length && pushIf('') && pushIf('## ⚠️ Conflicts to reconcile')) {
738
+ let shown = 0;
739
+ for (const c of conflicts.slice(0, 4)) { if (!pushIf(`- ${clip(c.from, 70)} ⚔️ ${clip(c.to, 70)}`)) break; shown++; }
740
+ if (shown < conflicts.length) pushIf(`- …and ${conflicts.length - shown} more conflict(s) — in the full brief.`);
741
+ }
742
+ if (open.length && pushIf('') && pushIf(`## Open questions & goals (${open.length})`)) {
743
+ let shown = 0;
744
+ for (const c of open) { if (!pushIf(`- ${fr(c)}${head(c)}`)) break; shown++; }
745
+ if (shown < open.length) pushIf(`- …and ${open.length - shown} more — in the full brief.`);
746
+ }
747
+ const areas = struct.cards.filter(c => c.type === 'container' && !/^archive$/i.test(c.title || ''))
748
+ .map(c => `${flat(c.title)} (${texts.filter(t => t.parentId === c.id).length})`);
749
+ if (areas.length) { pushIf(''); pushIf(clip('Areas: ' + areas.join(' · '), 240)); }
750
+ push(...tail);
684
751
  return out.join('\n') + '\n';
685
752
  }
686
753
 
@@ -712,11 +779,20 @@ export function scoreCardsAgainstQuery(struct, query, { topK = 6, minScore = 2,
712
779
  const titleW = wordsOf(c.title);
713
780
  const bodyW = wordsOf(c.text);
714
781
  const tagStems = new Set((c.tags || []).map(t => String(t).toLowerCase().replace(/^#/, '').replace(/^(file|dir)-/, '')).filter(Boolean));
782
+ // Log-length normalization: a flat 1pt/word-hit made LONG cards outrank
783
+ // short ones purely by having more vocabulary to hit (one ~600-word card
784
+ // was re-injected into 3 prompts on those cheap hits). Body hits are
785
+ // scaled by log2 of the body's distinct-word count — ≤64 words is EXACTLY
786
+ // unpenalized (log2(64)=6), a 600-word card scores ~0.65/hit, so a
787
+ // body-only match on a big card needs 5 distinct hits to clear the recall
788
+ // minScore of 3 — by design. Title/tag hits (the precise signals) are
789
+ // untouched.
790
+ const lenNorm = Math.min(1, 6 / Math.max(6, Math.log2(bodyW.size || 1)));
715
791
  let score = 0;
716
792
  for (const tok of tokens) {
717
793
  if (titleW.has(tok)) score += 3;
718
794
  else if (tagStems.has(tok)) score += 3;
719
- else if (bodyW.has(tok)) score += 1;
795
+ else if (bodyW.has(tok)) score += lenNorm;
720
796
  }
721
797
  if (score <= 0) continue;
722
798
  if ((c.createdAt || 0) >= cutoff) score += 0.5; // gentle recency tiebreak, never dominant
@@ -785,6 +861,23 @@ export function findUnrecordedMigrations(struct, files, { max = 6 } = {}) {
785
861
  // recall list still shows below). Returns each card's `kind` so the caller can
786
862
  // say "reuse it" (shipped/resolved) vs "see what replaced it" (superseded).
787
863
  // Pure + node-runnable; reuses the one shared tokenizer — no divergent scorer.
864
+ // Generic work-verbs establish that a prompt is WORK, not WHICH work — two of
865
+ // them shared with a 🏁 ship-card title used to clear the floor ("deploy it"
866
+ // flagged two unrelated PR-merge cards; zero true positives all session).
867
+ // Excluded from repeat scoring entirely; the loose recall list still sees them.
868
+ const REPEAT_VERB_STOP = new Set([
869
+ 'deploy', 'deployed', 'deploying', 'deploys', 'ship', 'shipped', 'shipping', 'ships',
870
+ 'merge', 'merged', 'merges', 'merging', 'release', 'released', 'releases', 'releasing',
871
+ 'publish', 'published', 'publishes', 'publishing', 'push', 'pushed', 'land', 'landed',
872
+ 'build', 'builds', 'building', 'built', 'check', 'checked', 'checking', 'checks',
873
+ 'plan', 'planned', 'planning', 'plans', 'best', 'class', 'cut', 'hand', 'handoff',
874
+ 'report', 'reports', 'live', 'latest', 'done', 'complete', 'completed', 'finish', 'finished',
875
+ 'work', 'task', 'feature',
876
+ ]);
877
+ // Entity-shaped token — the kind that pins WHICH work: carries a digit (version,
878
+ // PR#), or a kebab/snake identifier. A #file-/#dir- tag-stem match counts as an
879
+ // entity at match time regardless of shape (tags are capture-stamped anchors).
880
+ const isEntityToken = (t) => /\d/.test(t) || t.includes('-') || t.includes('_');
788
881
  export function detectRepeatWork(struct, query, { topK = 2, minScore = 5, minTokens = 2 } = {}) {
789
882
  const tokens = Array.isArray(query) ? query.filter(Boolean) : queryTokens(query);
790
883
  if (tokens.length < minTokens || !struct || !Array.isArray(struct.cards)) return [];
@@ -802,13 +895,19 @@ export function detectRepeatWork(struct, query, { topK = 2, minScore = 5, minTok
802
895
  const titleW = wordsOf(firstMeaningful || c.title);
803
896
  const bodyW = wordsOf(c.text);
804
897
  const tagStems = new Set((c.tags || []).map(t => String(t).toLowerCase().replace(/^#/, '').replace(/^(file|dir)-/, '')).filter(Boolean));
805
- let score = 0, matched = 0;
898
+ // Auto-harvested ship cards (#auto, one-line merge/release events) are
899
+ // dense with exactly the verbs above — they additionally need ≥1 ENTITY
900
+ // token matched before a nudge is worth showing.
901
+ const isAuto = (c.tags || []).some(t => String(t).toLowerCase().replace(/^#/, '') === 'auto');
902
+ let score = 0, matched = 0, entities = 0;
806
903
  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++; }
904
+ if (REPEAT_VERB_STOP.has(tok)) continue; // "that it's work" ≠ "which work"
905
+ if (titleW.has(tok)) { score += 3; matched++; if (isEntityToken(tok)) entities++; }
906
+ else if (tagStems.has(tok)) { score += 3; matched++; entities++; }
907
+ else if (bodyW.has(tok)) { score += 1; matched++; if (isEntityToken(tok)) entities++; }
810
908
  }
811
909
  if (matched < minTokens || score < minScore) continue; // precision-first floor
910
+ if (isAuto && entities < 1) continue; // auto cards: entity anchor required
812
911
  out.push({ card: c, score, kind });
813
912
  }
814
913
  out.sort((a, b) => b.score - a.score || (rank[b.kind] - rank[a.kind]) || (b.card.createdAt || 0) - (a.card.createdAt || 0));
@@ -855,6 +954,84 @@ export function detectConflicts(struct, { minOverlap = 0.45, topK = 12 } = {}) {
855
954
  return out.slice(0, topK);
856
955
  }
857
956
 
957
+ // ── Contradiction candidates (reconcile-proper) ──────────────────────────────
958
+ // The retroactive cleaner for stale/correction pairs that slipped past capture
959
+ // (cross-area + reworded → no supersede possible by construction). Unlike
960
+ // detectConflicts (a pre-filter for an LLM verifier), this is precision-scoped
961
+ // enough to surface DIRECTLY for human/agent confirmation: same-subject live
962
+ // pairs where (a) exactly ONE side carries an explicit correction cue
963
+ // (CORRECTION / OBSOLETE / was WRONG / stale note resolved) — that side is the
964
+ // presumed truth — or (b) the two sides use OPPOSITE polarity words about the
965
+ // same subject (deferred↔wired, broken↔fixed, dead↔live …). Pairs already
966
+ // settled by a supersede/close/conflict arrow are excluded; a plain relates_to
967
+ // arrow is NOT a settlement. Suggestion-only — never writes. Pure, node-runnable.
968
+ const POLARITY_PAIRS = [
969
+ ['deferred', 'wired'], ['deferred', 'shipped'], ['deferred', 'live'], ['deferred', 'enabled'],
970
+ ['broken', 'fixed'], ['broken', 'working'], ['dead', 'live'], ['dead', 'alive'],
971
+ ['disabled', 'enabled'], ['blocked', 'unblocked'], ['wrong', 'correct'], ['removed', 'restored'],
972
+ ];
973
+ // WORD-boundary matchers, precompiled — substring includes() made 'deadline'
974
+ // carry the 'dead' pole and 'delivery' carry 'live', and blocked↔unblocked
975
+ // could never fire ('unblocked' contains 'blocked').
976
+ const POLARITY_RES = POLARITY_PAIRS.map(([x, y]) => ({ x, y, rx: new RegExp(`\\b${x}\\b`), ry: new RegExp(`\\b${y}\\b`) }));
977
+ export function detectContradictions(struct, { minOverlap = 0.45, topK = 12 } = {}) {
978
+ if (!struct || !Array.isArray(struct.cards)) return [];
979
+ const isArchived = (c) => /^archive$/i.test(c.area || '');
980
+ const live = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !isArchived(c));
981
+ // Cue meta words stripped up front — same-subject matching must compare the
982
+ // SUBJECT, not the vocabulary of the correction act (see stripCueMeta above).
983
+ const wset = new Map(live.map(c => [c.id, stripCueMeta(new Set(queryTokens(c.text)))]));
984
+ const lower = new Map(live.map(c => [c.id, String(c.text).toLowerCase()]));
985
+ // settled — fully reconciled (supersede/close/conflict arrow): excludes the
986
+ // pair regardless of kind.
987
+ // linked — ANY deliberate edge (label !== 'auto'): dismisses a POLARITY pair
988
+ // ("I looked, they relate, not a contradiction"), but never a cue pair —
989
+ // a correction that [[wikilinks]] its stale card must still surface until
990
+ // the stale card is actually retired. Auto-drawn edges dismiss nothing.
991
+ const settled = new Set(), linked = new Set();
992
+ for (const e of struct.connections || []) {
993
+ const k1 = e.fromId + '|' + e.toId, k2 = e.toId + '|' + e.fromId;
994
+ if (e.label === 'superseded by' || e.label === 'closed by' || e.relationship === 'conflicts_with') { settled.add(k1); settled.add(k2); }
995
+ if (e.label !== 'auto') { linked.add(k1); linked.add(k2); }
996
+ }
997
+ const out = [];
998
+ for (let i = 0; i < live.length; i++) {
999
+ for (let j = i + 1; j < live.length; j++) {
1000
+ const a = live[i], b = live[j];
1001
+ if (settled.has(a.id + '|' + b.id)) continue;
1002
+ const A = wset.get(a.id), B = wset.get(b.id);
1003
+ if (A.size < 4 || B.size < 4) continue;
1004
+ let inter = 0; for (const t of A) if (B.has(t)) inter++;
1005
+ const overlap = inter / Math.min(A.size, B.size); // same subject?
1006
+ const aCue = hasCorrectionCue(a.text), bCue = hasCorrectionCue(b.text);
1007
+ // Cue-asymmetric pairs may also match on absolute subject mass (long
1008
+ // cards — see cueMatch above); cue-less pairs keep the strict ratio.
1009
+ const subjectHit = overlap >= minOverlap
1010
+ || (aCue !== bCue && inter >= CUE_STRONG_SHARED && overlap >= CUE_RELAXED_COEF);
1011
+ if (!subjectHit) continue;
1012
+ let why = null, staleC = null, freshC = null;
1013
+ if (aCue !== bCue) {
1014
+ why = 'correction-cue'; // one side explicitly corrects — it is the presumed truth
1015
+ freshC = aCue ? a : b; staleC = aCue ? b : a;
1016
+ } else if (!aCue && !linked.has(a.id + '|' + b.id)) {
1017
+ const la = lower.get(a.id), lb = lower.get(b.id);
1018
+ for (const { x, y, rx, ry } of POLARITY_RES) {
1019
+ // each side must carry ONE pole only (word-level) — a card
1020
+ // narrating "from deferred to wired" holds both and
1021
+ // contradicts neither.
1022
+ if ((rx.test(la) && !ry.test(la) && ry.test(lb) && !rx.test(lb))
1023
+ || (ry.test(la) && !rx.test(la) && rx.test(lb) && !ry.test(lb))) { why = `polarity: ${x} ↔ ${y}`; break; }
1024
+ }
1025
+ if (why) [staleC, freshC] = (a.createdAt || 0) <= (b.createdAt || 0) ? [a, b] : [b, a]; // later card = presumed truth
1026
+ }
1027
+ if (!why) continue;
1028
+ out.push({ stale: staleC, fresh: freshC, why, overlap: Math.round(overlap * 100) / 100, cue: aCue || bCue });
1029
+ }
1030
+ }
1031
+ out.sort((x, y) => (Number(y.cue) - Number(x.cue)) || (y.overlap - x.overlap));
1032
+ return out.slice(0, topK);
1033
+ }
1034
+
858
1035
  // ── Brain insights ───────────────────────────────────────────────────────────
859
1036
  // "What matters here, and what am I forgetting?" — a deterministic structural
860
1037
  // read of the brain (borrowed from graphify's GRAPH_REPORT, applied to the
@@ -948,9 +1125,12 @@ export function proposeStructuralConnections(struct, { maxPerCard = 2 } = {}) {
948
1125
  const live = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !/^archive$/i.test(c.area || ''));
949
1126
  const linked = new Set(struct.connections.map(c => [c.fromId, c.toId].sort().join('|')));
950
1127
  const titleIx = live.filter(c => (c.title || '').trim()).map(c => ({ id: c.id, t: c.title.trim().toLowerCase() }));
1128
+ // 'auto' is PROVENANCE, not topic — as a shared tag it would link every
1129
+ // harvested ship card to every other one (junk edges that then push their
1130
+ // degree past the garden's dormancy guard).
951
1131
  const tagsOf = (c) => (c.tags || [])
952
1132
  .map(t => String(t).toLowerCase().replace(/^#/, ''))
953
- .filter(t => t && t !== 'area' && t !== String(c.area || '').toLowerCase());
1133
+ .filter(t => t && t !== 'area' && t !== 'auto' && t !== String(c.area || '').toLowerCase());
954
1134
  const cand = [];
955
1135
  for (const c of live) {
956
1136
  for (const link of (c.links || [])) {
@@ -1005,6 +1185,83 @@ const overlapScore = (a, b) => {
1005
1185
  // failed to fire.)
1006
1186
  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
1187
 
1188
+ // ── Truth decay (P1) — corrections must never lose to the cards they correct ─
1189
+ // A correction-cue note explicitly declares an older fact stale ("CORRECTION:",
1190
+ // "was WRONG", "OBSOLETE", "stale note resolved"). The same-area ≥0.6 supersede
1191
+ // can miss it BY CONSTRUCTION (the correction often lands in a different area,
1192
+ // reworded) — the stale card then stays live and recall serves it alone. Shared
1193
+ // by: the capture-side widened supersede, the recall-side overlay below, and
1194
+ // detectContradictions.
1195
+ // DELIBERATE cue only: the uppercase forms are the documented convention, and
1196
+ // case-sensitivity is what keeps casual prose ("the floor calc was wrong",
1197
+ // "remove obsolete helper", "color-correction") from firing a cross-area
1198
+ // supersede on an innocent card. "stale note resolved" is the one
1199
+ // natural-language phrase, accepted in any case (it is never incidental).
1200
+ export const CORRECTION_RE = /\bCORRECTIONS?\b|\bOBSOLETE\b|\bwas WRONG\b/;
1201
+ const CORRECTION_PHRASE_RE = /\bstale note (?:is )?resolved\b/i;
1202
+ export const hasCorrectionCue = (t) => CORRECTION_RE.test(String(t || '')) || CORRECTION_PHRASE_RE.test(String(t || ''));
1203
+ export const CORRECTION_SUPERSEDE_AT = 0.4; // widened cross-area bar (vs same-area SUPERSEDE_AT 0.6)
1204
+ // Cue META words describe the act of correcting, not the subject — left in, they
1205
+ // dilute the overlap denominator and push real correction pairs just under the
1206
+ // bar (the field fixture lands at 0.375 with them, 0.5 without). Stripped before
1207
+ // every correction-overlap comparison.
1208
+ const CORRECTION_META = new Set(['correction', 'corrections', 'obsolete', 'stale', 'note', 'notes', 'resolved', 'wrong']);
1209
+ const stripCueMeta = (set) => { const out = new Set(); for (const w of set) if (!CORRECTION_META.has(w)) out.add(w); return out; };
1210
+ // Long-card reality: a correction's SUBJECT is a fraction of each card — the
1211
+ // overlap COEFFICIENT alone punishes long↔long pairs (the real field pair
1212
+ // measures 0.33 with 17 shared subject tokens, under every per-ratio bar). A
1213
+ // cue-gated match therefore also fires on ABSOLUTE subject mass: ≥10 shared
1214
+ // meaningful tokens at ≥0.25 coefficient. Cue-gated ONLY — plain supersede and
1215
+ // polarity pairs keep their strict ratio bars (no cue prior to lean on).
1216
+ const CUE_STRONG_SHARED = 10, CUE_RELAXED_COEF = 0.25;
1217
+ // Floor 3 (not overlapScore's 4): after stripCueMeta a terse deliberate
1218
+ // correction ("CORRECTION: the vault default was WRONG — use cwd") keeps only
1219
+ // 3-ish subject tokens; at 4 it silently no-oped. ≤2-token corrections still
1220
+ // no-op (too little signal to archive on) — they land as a new card; use ~ to
1221
+ // edit a card in place instead.
1222
+ const cueMatch = (a, b, bar) => {
1223
+ if (a.size < 3 || b.size < 3) return 0;
1224
+ let inter = 0; for (const w of a) if (b.has(w)) inter++;
1225
+ const coef = inter / Math.min(a.size, b.size);
1226
+ return (coef >= bar || (inter >= CUE_STRONG_SHARED && coef >= CUE_RELAXED_COEF)) ? coef : 0;
1227
+ };
1228
+
1229
+ // Recall-side guard: given the cards recall is about to inject, return for each
1230
+ // one the card that CORRECTS it, found two ways:
1231
+ // • edge — an outgoing "superseded by"/"closed by" arrow (drawn by capture or
1232
+ // a confirmed reconcile) whose successor still has text;
1233
+ // • cue — a LIVE correction-cue card that lexically overlaps it ≥ `at`, ANY
1234
+ // area (the un-edged pair the capture-time supersede missed).
1235
+ // The caller injects the corrector FIRST (labeled) and reduces the stale hit to
1236
+ // a headline — the stale text never stands alone. Pure + cheap: correction-cue
1237
+ // cards are rare and the hit list is ≤topK.
1238
+ export function correctionOverlaysFor(struct, cards, { at = CORRECTION_SUPERSEDE_AT } = {}) {
1239
+ const out = new Map();
1240
+ if (!struct || !Array.isArray(struct.cards) || !Array.isArray(cards) || !cards.length) return out;
1241
+ const byId = new Map(struct.cards.map(c => [c.id, c]));
1242
+ const isArchived = (c) => /^archive$/i.test(c.area || '');
1243
+ const successorOf = new Map();
1244
+ for (const cn of struct.connections || []) {
1245
+ if (cn.label === 'superseded by' || cn.label === 'closed by') successorOf.set(cn.fromId, cn.toId);
1246
+ }
1247
+ const cues = struct.cards.filter(c => c.type !== 'container' && !isArchived(c) && (c.text || '').trim() && hasCorrectionCue(c.text));
1248
+ for (const card of cards) {
1249
+ if (!card || !card.id) continue;
1250
+ const succ = successorOf.has(card.id) ? byId.get(successorOf.get(card.id)) : null;
1251
+ if (succ && (succ.text || '').trim()) { out.set(card.id, { kind: 'edge', by: succ }); continue; }
1252
+ if (hasCorrectionCue(card.text)) continue; // the hit IS a correction — nothing to overlay
1253
+ const cTok = tokenSet(card.text);
1254
+ let best = null, bestS = 0;
1255
+ for (const cue of cues) {
1256
+ if (cue.id === card.id) continue;
1257
+ const s = cueMatch(cTok, stripCueMeta(tokenSet(cue.text)), at);
1258
+ if (s > bestS) { bestS = s; best = cue; }
1259
+ }
1260
+ if (best && bestS > 0) out.set(card.id, { kind: 'cue', by: best, overlap: Math.round(bestS * 100) / 100 });
1261
+ }
1262
+ return out;
1263
+ }
1264
+
1008
1265
  // ── Auto-skill classifier (skills emerge from the flow, not just the '+' marker) ─
1009
1266
  // A REUSABLE skill (how-to / gotcha / convention) reads as a GENERAL RULE that
1010
1267
  // applies next time — distinct from a one-time decision ("we shipped X"). This is
@@ -1024,9 +1281,12 @@ export function looksLikeSkill(text) {
1024
1281
  }
1025
1282
 
1026
1283
  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;
1284
+ const SUPERSEDE_AT = 0.6, RESOLVE_AT = 0.3, UPDATE_AT = 0.45, CLOSE_COVER_AT = 0.6, QUESTION_MERGE_AT = 0.6;
1028
1285
  let work = buffer;
1029
- const stats = { added: 0, superseded: 0, resolved: 0, linked: 0, updated: 0, closed: 0 };
1286
+ // corrections[] lists cross-area/low-bar supersedes driven by a correction
1287
+ // cue, so the surface (brain_note result / capture stderr) can say WHAT was
1288
+ // archived and how to undo — the confirmation channel for the widened match.
1289
+ const stats = { added: 0, superseded: 0, resolved: 0, linked: 0, updated: 0, closed: 0, merged: 0, corrections: [] };
1030
1290
 
1031
1291
  // Pass 1 — resolutions + supersede marking operate on EXISTING cards.
1032
1292
  if (resolutions.length || cards.length || updates.length) {
@@ -1093,24 +1353,36 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
1093
1353
  };
1094
1354
  canvas.connections = Array.isArray(canvas.connections) ? canvas.connections : [];
1095
1355
 
1096
- // RESOLVE (✓ markers) — best live match in the area; ❓ cards preferred.
1356
+ // RESOLVE (✓ markers) — best live match in the area, PLUS its near-tie
1357
+ // twins: a rephrased duplicate ❓ scores within a hair of its sibling, and
1358
+ // resolving only the first left the twin open forever (it kept surfacing
1359
+ // in every brief as still-to-do). Precision-kept: the set is the best
1360
+ // match ± 0.1, never everything above the loose 0.3 floor. ❓ preferred.
1097
1361
  const milestonesFallback = [];
1098
1362
  for (const r of resolutions) {
1099
1363
  const rTok = tokenSet(r.text);
1100
- let best = null, bestScore = 0;
1364
+ const cands = [];
1101
1365
  for (const c of liveTextCards()) {
1102
1366
  if (r.area && (c.area || '').toLowerCase() !== r.area.toLowerCase()) continue;
1367
+ if (/🛠/.test(c.text)) continue; // skills are standing reference — a ✓ must never archive one (mirror the supersede guard)
1103
1368
  const s = overlapScore(rTok, tokenSet(c.text)) + (/❓|🎯/.test(c.text) ? 0.15 : 0);
1104
- if (s > bestScore) { bestScore = s; best = c; }
1369
+ if (s > 0) cands.push({ c, s });
1105
1370
  }
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++;
1371
+ cands.sort((a, b) => b.s - a.s);
1372
+ const bestScore = cands.length ? cands[0].s : 0;
1373
+ const set = bestScore >= RESOLVE_AT
1374
+ ? cands.filter(x => x.s >= Math.max(RESOLVE_AT, bestScore - 0.1)).slice(0, 3)
1375
+ : [];
1376
+ if (set.length) {
1377
+ for (const { c: best } of set) {
1378
+ await rewriteCard(best.id, j => {
1379
+ j.content = `${j.content}\n✅ ${today}: ${r.text}`;
1380
+ j.borderColor = 'rgba(16,185,129,0.35)';
1381
+ });
1382
+ await archiveCard(best.id);
1383
+ best.text += ` ✅ ${r.text}`; // keep in-memory struct honest for later matching
1384
+ stats.resolved++;
1385
+ }
1114
1386
  } else {
1115
1387
  milestonesFallback.push({ text: (r.area ? `${r.area}: ` : '') + `🏁 ${r.text}`, area: r.area, borderColor: 'rgba(59,130,246,0.8)' });
1116
1388
  }
@@ -1146,6 +1418,34 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
1146
1418
  }
1147
1419
  }
1148
1420
 
1421
+ // MERGE-ON-CAPTURE for duplicate open questions — a rephrased ❓ that
1422
+ // heavily overlaps an EXISTING live ❓ updates that card in place (fresh
1423
+ // wording + createdAt) instead of stacking a twin the close-pass would
1424
+ // later miss. (Supersede deliberately skips ? cards, so without this
1425
+ // twins could never merge at capture at all.)
1426
+ for (let i = cards.length - 1; i >= 0; i--) {
1427
+ const card = cards[i];
1428
+ if (!/❓/.test(card.text) || /🏁|🛠/.test(card.text)) continue;
1429
+ const nTok = tokenSet(card.text);
1430
+ let best = null, bestScore = 0;
1431
+ for (const c of liveTextCards()) {
1432
+ if (!/❓/.test(c.text)) continue;
1433
+ const s = overlapScore(nTok, tokenSet(c.text));
1434
+ if (s > bestScore) { bestScore = s; best = c; }
1435
+ }
1436
+ if (best && bestScore >= QUESTION_MERGE_AT) {
1437
+ await rewriteCard(best.id, j => {
1438
+ j.content = String(card.text);
1439
+ j.createdAt = now;
1440
+ if (card.createdVia) j.createdVia = String(card.createdVia);
1441
+ if (Array.isArray(card.evidence) && card.evidence.length) j.evidence = card.evidence;
1442
+ });
1443
+ best.text = String(card.text);
1444
+ cards.splice(i, 1);
1445
+ stats.merged++;
1446
+ }
1447
+ }
1448
+
1149
1449
  // SUPERSEDE — pre-mark old cards that a NEW decision replaces. The arrow
1150
1450
  // to the new card is drawn in pass 2 (after the new ids exist), matched
1151
1451
  // back by remembering which old card each new card displaced.
@@ -1153,22 +1453,36 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
1153
1453
  if (/❓|🎯|🏁|🛠/.test(card.text)) continue; // only plain decisions supersede (not questions/goals/milestones/skills)
1154
1454
  const nTok = tokenSet(card.text);
1155
1455
  const area = (card.area || '').toLowerCase();
1456
+ // A correction-cue note ("CORRECTION: … was WRONG") declares it
1457
+ // replaces something — widen the search to ALL areas and lower the
1458
+ // bar: a cross-area reworded correction could never fire the
1459
+ // same-area 0.6 path by construction, which is exactly how stale
1460
+ // cards outlived their corrections in the field.
1461
+ const isCorrection = hasCorrectionCue(card.text);
1462
+ const nTokCmp = isCorrection ? stripCueMeta(nTok) : nTok; // cue meta words dilute the denominator
1156
1463
  let best = null, bestScore = 0;
1157
1464
  for (const c of liveTextCards()) {
1158
- if (area && (c.area || '').toLowerCase() !== area) continue;
1465
+ if (!isCorrection && area && (c.area || '').toLowerCase() !== area) continue;
1159
1466
  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));
1467
+ // cueMatch returns 0 unless it clears the widened bar (ratio OR
1468
+ // absolute subject mass) — so for corrections, any non-zero fires.
1469
+ const s = isCorrection ? cueMatch(nTokCmp, tokenSet(c.text), CORRECTION_SUPERSEDE_AT) : overlapScore(nTok, tokenSet(c.text));
1161
1470
  if (s > bestScore) { bestScore = s; best = c; }
1162
1471
  }
1163
- if (best && bestScore >= SUPERSEDE_AT) {
1472
+ if (best && (isCorrection ? bestScore > 0 : bestScore >= SUPERSEDE_AT)) {
1164
1473
  await rewriteCard(best.id, j => {
1165
1474
  j.content = `↩︎ superseded ${today}\n${j.content}`;
1166
1475
  j.borderColor = 'rgba(120,120,135,0.5)';
1167
1476
  });
1168
1477
  await archiveCard(best.id);
1478
+ const wasCross = isCorrection && (bestScore < SUPERSEDE_AT || (area && (best.area || '').toLowerCase() !== area));
1169
1479
  best.text = `↩︎ ${best.text}`;
1170
1480
  card.__supersedes = best.id;
1171
1481
  stats.superseded++;
1482
+ // Surface the widened match for confirmation: the caller tells the
1483
+ // agent what was archived and how to undo (restore from Archive /
1484
+ // re-run with ~) — the widened bar acts WITH a visible receipt.
1485
+ 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
1486
  }
1173
1487
  }
1174
1488
 
@@ -1184,25 +1498,41 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
1184
1498
  if (!target) continue;
1185
1499
  const wantTitle = target.replace(/^\[\[/, '').replace(/\]\]$/, '').trim().toLowerCase();
1186
1500
  const tTok = tokenSet(target);
1187
- let best = null, bestScore = 0;
1501
+ // Collect EVERY live card the close-target covers — near-duplicate ❓
1502
+ // twins score together, and the old first-match-and-break resolved one
1503
+ // while its twin stayed "open" in every brief forever. Capped for
1504
+ // safety: a close-target is deliberate, so >4 matches means it was too
1505
+ // generic to trust beyond the strongest few.
1506
+ const matches = [];
1188
1507
  for (const c of liveTextCards()) {
1508
+ if (/🛠/.test(c.text)) continue; // skills are standing reference — a closes: must never archive one (mirror the supersede guard)
1189
1509
  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; }
1510
+ // Title fast-path: exact / prefix (≥6 chars), or the card title
1511
+ // CONTAINS the target — the contains variant needs a LONGER target
1512
+ // (≥10) because a short generic word ("sandbox") appears in many
1513
+ // unrelated titles and the multi-close below would sweep them all.
1514
+ if (ct && wantTitle.length >= 6 && (ct === wantTitle || ct.startsWith(wantTitle) || wantTitle.startsWith(ct))) { matches.push({ c, cov: 1 }); continue; }
1515
+ if (ct && wantTitle.length >= 10 && ct.includes(wantTitle)) { matches.push({ c, cov: 1 }); continue; }
1193
1516
  // Else target-coverage (≥2 tokens, no floor): a short deliberate
1194
1517
  // 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; } }
1518
+ if (tTok.size >= 2) { const cov = coverageOf(tTok, tokenSet(c.text)); if (cov >= CLOSE_COVER_AT) matches.push({ c, cov }); }
1196
1519
  }
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);
1520
+ matches.sort((a, b) => b.cov - a.cov);
1521
+ // >4 matches means the target was too GENERIC to trust a sweep —
1522
+ // fall back to the single best match (the pre-1.17 behavior) rather
1523
+ // than archive four semi-related cards in iteration order.
1524
+ const chosen = matches.length > 4 ? matches.slice(0, 1) : matches;
1525
+ if (!chosen.length) continue;
1526
+ const ship = String(card.text).replace(/\s+/g, ' ').replace(/^[^:\n]{1,40}:\s*/, '').replace(/^🏁\s*/, '').trim().slice(0, 80);
1527
+ card.__closesIds = [];
1528
+ for (const { c: best } of chosen) {
1199
1529
  await rewriteCard(best.id, j => {
1200
1530
  j.content = `${j.content}\n✅ ${today}: closed by → ${ship}`;
1201
1531
  j.borderColor = 'rgba(16,185,129,0.35)';
1202
1532
  });
1203
1533
  await archiveCard(best.id);
1204
1534
  best.text = `✅ ${best.text}`;
1205
- card.__closes = best.id;
1535
+ card.__closesIds.push(best.id);
1206
1536
  stats.closed++;
1207
1537
  }
1208
1538
  }
@@ -1238,11 +1568,13 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
1238
1568
  const titleIndex = struct.cards
1239
1569
  .filter(c => (c.title || '').trim())
1240
1570
  .map(c => ({ id: c.id, t: c.title.trim().toLowerCase() }));
1571
+ const newIds = new Set();
1241
1572
  for (const card of cards) {
1242
1573
  const created = findNew(card.text);
1243
1574
  if (!created) continue;
1575
+ newIds.add(created.id);
1244
1576
  if (card.__supersedes) addConn(card.__supersedes, created.id, 'superseded by', undefined);
1245
- if (card.__closes) addConn(card.__closes, created.id, 'closed by', undefined);
1577
+ for (const cid of (card.__closesIds || [])) addConn(cid, created.id, 'closed by', undefined);
1246
1578
  for (const link of (created.links || [])) {
1247
1579
  const want = String(link).trim().toLowerCase();
1248
1580
  if (!want) continue;
@@ -1250,6 +1582,21 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
1250
1582
  if (target) addConn(created.id, target.id, undefined, 'relates_to');
1251
1583
  }
1252
1584
  }
1585
+ // Structural auto-link for the NEW cards — the graph used to form only
1586
+ // when someone explicitly ran brain_connect (never, in practice: 66%
1587
+ // orphans in the field). Run the cheap tag/[[title]] proposer over the
1588
+ // post-append struct and keep only edges touching a just-added card
1589
+ // (≤2 per card via the proposer's own cap), labeled 'auto' so they're
1590
+ // distinguishable from deliberate arrows. Best-effort: an auto-link
1591
+ // failure must never fail a capture.
1592
+ if (newIds.size) {
1593
+ try {
1594
+ for (const e of proposeStructuralConnections(struct, { maxPerCard: 2 })) {
1595
+ if (!newIds.has(e.fromId) && !newIds.has(e.toId)) continue;
1596
+ addConn(e.fromId, e.toId, 'auto', 'relates_to');
1597
+ }
1598
+ } catch { /* auto-linking is opportunistic */ }
1599
+ }
1253
1600
  work = await finalizeBrainZip(zip, canvas, manifest, now);
1254
1601
  }
1255
1602
 
@@ -1265,6 +1612,7 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
1265
1612
  // to Archive, and arrowed → the synthesis. Nothing is deleted (archived verbatim).
1266
1613
  const GARDEN_KEEP_NEWEST = 8; // per area, never consolidate the newest N
1267
1614
  const GARDEN_MIN_AGE_DAYS = 14; // only cards older than this are candidates
1615
+ 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
1616
  const GARDEN_MIN_CANDIDATES = 3; // don't bother merging fewer than this
1269
1617
  const GARDEN_MAX_DEGREE = 1; // SMART guard: protect load-bearing cards —
1270
1618
  // only consolidate cards with ≤ this many connections (orphans + leaves). A
@@ -1311,9 +1659,11 @@ export function selectGardenCandidates(struct, { keepNewest = GARDEN_KEEP_NEWEST
1311
1659
  const children = struct.cards
1312
1660
  .filter(c => c.type === 'text' && c.parentId === ctn.id && (c.text || '').trim() && !/⤵|↩|✅|🛠/.test(c.text)) // 🛠️ skills are standing reference — never consolidate them away
1313
1661
  .sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
1662
+ const autoCutoff = now - GARDEN_AUTO_MIN_AGE_DAYS * 86_400_000;
1663
+ const isAuto = (c) => (c.tags || []).some(t => String(t).toLowerCase().replace(/^#/, '') === 'auto');
1314
1664
  const old = children
1315
1665
  .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
1666
+ .filter(c => (c.createdAt || 0) < (isAuto(c) ? autoCutoff : cutoff) && (degree.get(c.id) || 0) <= maxDegree); // dormant: old AND peripheral (#auto ages faster)
1317
1667
  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
1668
  }
1319
1669
  return out;