klypix-mcp 1.19.0 → 1.20.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.
@@ -138,22 +138,24 @@ server.registerTool('brain_insights', {
138
138
 
139
139
  server.registerTool('brain_connect', {
140
140
  title: 'Connect related-but-unlinked brain cards (densify the graph)',
141
- description: 'Finds genuinely related cards that AREN\'T linked yet and proposes connections — semantic similarity when the on-device model is installed, else shared tags + [[mentions]]. Dry-run by default (review the suggestions); pass apply:true to draw them (ADDITIVE — never deletes; the human can remove any arrow). Use after brain_insights flags many orphans, to turn a flat list into a real knowledge graph.',
141
+ description: 'Finds genuinely related cards that AREN\'T linked yet and proposes connections — semantic similarity when the on-device model is installed, else shared tags + [[mentions]]. Dry-run by default (review the suggestions); pass apply:true to draw them (ADDITIVE — never deletes; the human can remove any arrow). Use after brain_insights flags many orphans, to turn a flat list into a real knowledge graph. To DISMISS a brain_reconcile false-positive contradiction, pass pairs:[{fromId,toId}] (the ids are in the reconcile output) with relationship:"not_contradiction" — a persisted dismissal that stops that pair ever resurfacing as a candidate.',
142
142
  inputSchema: {
143
143
  canvas: z.string().optional().describe('Canvas filename/path. Defaults to the project brain ("brain").'),
144
144
  apply: z.boolean().optional().describe('false (default) = suggest only; true = draw the connections.'),
145
145
  max: z.number().optional().describe('Max connections to propose/draw (default 24).'),
146
146
  threshold: z.number().optional().describe('Min semantic similarity 0–1 to link (default 0.45). Higher = fewer, tighter links.'),
147
+ pairs: z.array(z.object({ fromId: z.string(), toId: z.string() })).optional().describe('Explicit card-id pairs to connect (bypasses auto-proposal). Use to dismiss a reconcile false-positive: pass the two card ids with relationship:"not_contradiction".'),
148
+ relationship: z.string().optional().describe('Relationship for explicit `pairs` (e.g. "not_contradiction" to permanently dismiss a contradiction candidate, or "relates_to", "depends_on", "supports").'),
147
149
  },
148
- }, async ({ canvas, apply, max, threshold }) => toContent(await opBrainConnect({ vault: VAULT, canvas, apply, max, threshold, log })));
150
+ }, async ({ canvas, apply, max, threshold, pairs, relationship }) => toContent(await opBrainConnect({ vault: VAULT, canvas, apply, max, threshold, pairs, relationship, log })));
149
151
 
150
152
  server.registerTool('brain_reconcile', {
151
153
  title: 'Reconcile the brain — contradictions between cards + unrecorded migrations',
152
- 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.',
154
+ description: 'Truth maintenance. (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 ✓. Dismiss a FALSE positive (either kind) by connecting the two ids with brain_connect pairs + relationship:"not_contradiction" — persisted, so it never resurfaces. (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. (3) LEGACY: pre-v1.15 raw-bash ship cards to tidy. 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.',
153
155
  inputSchema: {
154
156
  canvas: z.string().optional().describe('Brain canvas filename/path. Defaults to the project brain ("brain").'),
155
157
  root: z.string().optional().describe("Project root holding the migrations dir (default: the brain file's folder)."),
156
- mode: z.enum(['all', 'contradictions', 'migrations']).optional().describe('Which pass to run (default "all").'),
158
+ mode: z.enum(['all', 'contradictions', 'migrations', 'legacy']).optional().describe('Which pass to run (default "all"): contradictions · migrations · legacy (pre-v1.15 raw-bash ship cards to tidy).'),
157
159
  },
158
160
  }, async ({ canvas, root, mode }) => toContent(await opBrainReconcile({ vault: VAULT, canvas, root, mode })));
159
161
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.19.0",
3
+ "version": "1.20.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 && node test/brain-quality.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.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 && node test/brain-ask.mjs && node test/field-report-2026-07-04.mjs"
57
57
  },
58
58
  "dependencies": {
59
59
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -307,6 +307,12 @@ function touchSession(sid, patch = {}) {
307
307
  // the per-prompt recall renders a re-hit as one headline instead of
308
308
  // re-paying the full card (a ~600-word card was injected 3× before).
309
309
  injected: patch.injected !== undefined ? patch.injected : (prev.injected ?? []),
310
+ // Sibling ledger for LARGE cards (>1KB) with a much deeper cap: the
311
+ // 100-entry `injected` set evicts old ids in a long session, so a big
312
+ // card injected early could re-inflate full-text after ~100 other
313
+ // cards scrolled it out. Big cards are rare, so this cap effectively
314
+ // never evicts one — enforcing "no >1KB card full-text twice".
315
+ injectedBig: patch.injectedBig !== undefined ? patch.injectedBig : (prev.injectedBig ?? []),
310
316
  startedAt: prev.startedAt || now, lastSeen: now,
311
317
  });
312
318
  fs.mkdirSync(SESSIONS_DIR, { recursive: true });
@@ -436,9 +442,16 @@ function messageFooter(sid, tp) {
436
442
  fs.writeFileSync(SESSIONS_FILE, JSON.stringify(d2));
437
443
  } catch { /* */ } finally { if (got) releaseLock(SESSIONS_LOCK); }
438
444
  if (!show.length) return '';
445
+ // De-dupe identical message TEXT within one delivery: the same note can reach
446
+ // the lane twice (posted via both the 🧠 MSG marker and the brain_message MCP
447
+ // twin, or re-sent) as two distinct ids — all are already acked above, so
448
+ // dropping the repeats here shows each note once without ever re-surfacing it.
449
+ const seenTxt = new Set();
450
+ const showUniq = show.filter(m => { const k = String(m.text || '').replace(/\s+/g, ' ').trim().toLowerCase(); if (!k || seenTxt.has(k)) return false; seenTxt.add(k); return true; });
451
+ if (!showUniq.length) return '';
439
452
  const ago = (ts) => { const mm = Math.max(0, Math.round((now - (ts || now)) / 60000)); return mm <= 0 ? 'just now' : `${mm}m ago`; };
440
453
  const out = ['', '## 📨 Message(s) from another session in this project (delivered once — act on or reply to them)'];
441
- for (const m of show.slice(0, 6)) out.push(`- from ${String(m.from || '?').slice(0, 8)} · ${ago(m.ts)}: ${String(m.text).replace(/\s+/g, ' ').trim().slice(0, 400)}`);
454
+ for (const m of showUniq.slice(0, 6)) out.push(`- from ${String(m.from || '?').slice(0, 8)} · ${ago(m.ts)}: ${String(m.text).replace(/\s+/g, ' ').trim().slice(0, 400)}`);
442
455
  out.push('Reply with `🧠 MSG [<their-id or all>]: <text>` — it reaches them on their next prompt.');
443
456
  return '\n' + out.join('\n');
444
457
  }
@@ -1236,15 +1249,30 @@ async function promptRetrieve(lib) {
1236
1249
  if (struct && typeof lib.correctionOverlaysFor === 'function') {
1237
1250
  try { overlays = lib.correctionOverlaysFor(struct, freshHits.map(h => h.card)); } catch { /* best-effort */ }
1238
1251
  }
1239
- // Per-session injection dedup: a card already shown full-text this
1240
- // session renders as one headline, not another ~600 words of context.
1252
+ // Awaits-merge decay: a hit saying "PR #N awaits merge" gets a merged-overlay
1253
+ // when a harvested ship event already recorded #N MERGED (deterministic, no
1254
+ // retirement — the fact is right, only its status decayed).
1255
+ let mergeOv = new Map();
1256
+ if (struct && typeof lib.mergeOverlaysFor === 'function') {
1257
+ try { mergeOv = lib.mergeOverlaysFor(struct, freshHits.map(h => h.card)); } catch { /* best-effort */ }
1258
+ }
1259
+ const mergeTag = (id) => { const m = mergeOv.get(id); return m ? `\n ↳ ⚠️ PR #${m.num} is since MERGED${m.date ? ` (ship event ${m.date})` : ''} — this "awaits merge" note is stale; nothing to do.` : ''; };
1260
+ // Per-session injection dedup: a card already shown full-text this session
1261
+ // renders as one headline, not another ~600 words of context. LARGE cards
1262
+ // (>1KB) are tracked in a separate, deep-capped ledger so the 100-entry
1263
+ // `injected` set's eviction can never re-inflate one (the observed 3× bug).
1264
+ const BIG_CHARS = 1000;
1241
1265
  let me = null; try { me = readSessions().find(s => s.id === sid) || null; } catch { /* */ }
1242
1266
  const injected = new Set((me && Array.isArray(me.injected)) ? me.injected : []);
1267
+ const injectedBig = new Set((me && Array.isArray(me.injectedBig)) ? me.injectedBig : []);
1268
+ const isBig = (c) => flat(c.text).length > BIG_CHARS;
1269
+ const wasInjected = (c) => injected.has(c.id) || (isBig(c) && injectedBig.has(c.id));
1243
1270
  const shownNow = new Set();
1244
1271
  lines.push(semMode === 'sem-hit'
1245
1272
  ? "# Related prior decisions (semantic match — no exact keyword overlap; full brain via the klypix-canvas MCP)"
1246
1273
  : "# Relevant prior decisions from this project's brain (task-matched; full brain via the klypix-canvas MCP)");
1247
- const newlyInjected = [];
1274
+ const newlyInjected = [], newlyBig = [];
1275
+ const noteInjected = (c) => { newlyInjected.push(c.id); if (isBig(c)) newlyBig.push(c.id); };
1248
1276
  for (const h of freshHits) {
1249
1277
  if (shownNow.has(h.card.id)) continue; // already rendered this turn (e.g. as a corrector)
1250
1278
  const ov = overlays.get(h.card.id);
@@ -1256,11 +1284,11 @@ async function promptRetrieve(lib) {
1256
1284
  let correctorLine = false;
1257
1285
  if (!shownNow.has(ov.by.id)) {
1258
1286
  correctorLine = true;
1259
- if (injected.has(ov.by.id)) {
1287
+ if (wasInjected(ov.by)) {
1260
1288
  lines.push(`- ⚠️ CORRECTED — current (already shown this session): ${head(ov.by, 110)}`);
1261
1289
  } else {
1262
1290
  lines.push(`- ⚠️ CORRECTED — current: ${flat(ov.by.text)}`);
1263
- newlyInjected.push(ov.by.id);
1291
+ noteInjected(ov.by);
1264
1292
  }
1265
1293
  shownNow.add(ov.by.id);
1266
1294
  }
@@ -1268,16 +1296,20 @@ async function promptRetrieve(lib) {
1268
1296
  shownNow.add(h.card.id);
1269
1297
  continue;
1270
1298
  }
1271
- if (injected.has(h.card.id)) {
1272
- lines.push(`- (already shown this session) ${head(h.card, 110)}`);
1299
+ if (wasInjected(h.card)) {
1300
+ lines.push(`- (already shown this session) ${head(h.card, 110)}${mergeTag(h.card.id)}`);
1273
1301
  shownNow.add(h.card.id);
1274
1302
  continue;
1275
1303
  }
1276
- lines.push(`- ${flat(h.card.text)}`);
1304
+ lines.push(`- ${flat(h.card.text)}${mergeTag(h.card.id)}`);
1277
1305
  shownNow.add(h.card.id);
1278
- newlyInjected.push(h.card.id);
1306
+ noteInjected(h.card);
1307
+ }
1308
+ if (newlyInjected.length) {
1309
+ const patch = { injected: [...new Set([...injected, ...newlyInjected])].slice(-100) };
1310
+ if (newlyBig.length) patch.injectedBig = [...new Set([...injectedBig, ...newlyBig])].slice(-400);
1311
+ touchSession(sid, patch);
1279
1312
  }
1280
- if (newlyInjected.length) touchSession(sid, { injected: [...new Set([...injected, ...newlyInjected])].slice(-100) });
1281
1313
  }
1282
1314
  const parts = [];
1283
1315
  if (lines.length) parts.push(lines.join('\n'));
@@ -26,7 +26,7 @@ import {
26
26
  brainInsights, insightsToMarkdown, addBrainConnections, proposeStructuralConnections, atomicWrite,
27
27
  findUnrecordedMigrations, captureIntoBrain, tidyBrain, noteToCaptureInput,
28
28
  selectGardenCandidates, applyGarden, detectContradictions,
29
- rankForQuestion, questionContextToMarkdown,
29
+ rankForQuestion, questionContextToMarkdown, findLegacyShipCards,
30
30
  } from './klypix-format.mjs';
31
31
 
32
32
  // ── Card / connection input shape (single source for every face) ─────────────
@@ -462,14 +462,30 @@ export async function opBrainReconcile({ vault, canvas, root, mode = 'all' }) {
462
462
  const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
463
463
  const lines = pairs.map((p, i) =>
464
464
  `${i + 1}. ${p.why} · overlap ${p.overlap}\n`
465
- + ` · likely STALE [${p.stale.area || '?'}] ${flat(p.stale.text).slice(0, 180)}\n`
466
- + ` · likely CURRENT [${p.fresh.area || '?'}] ${flat(p.fresh.text).slice(0, 180)}`);
467
- 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')}`);
465
+ + ` · likely STALE [${p.stale.area || '?'}] (id ${p.stale.id}) ${flat(p.stale.text).slice(0, 180)}\n`
466
+ + ` · likely CURRENT [${p.fresh.area || '?'}] (id ${p.fresh.id}) ${flat(p.fresh.text).slice(0, 180)}`);
467
+ 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 (either kind — polarity OR correction-cue): \`brain_connect\` with \`pairs:[{fromId, toId}]\` and \`relationship:"not_contradiction"\` using the ids above — the dismissal is persisted, so that pair never resurfaces here again._\n\n${lines.join('\n')}`);
468
468
  } else if (mode === 'contradictions') {
469
469
  sections.push('✓ No contradiction candidates — no live card pair shows a correction cue or a polarity flip over the same subject.');
470
470
  }
471
471
  }
472
472
 
473
+ // (1b) LEGACY SHIP CARDS — pre-v1.15 auto-captured "merged PR — auto-captured
474
+ // (`gh …`)" cards with raw shell text / path-scraped junk PR numbers. Already
475
+ // excluded from repeat-matching (so they no longer cry wolf); this surfaces them
476
+ // for optional one-time cleanup. Suggestion-only — retire each with a ✓ marker.
477
+ if (mode === 'all' || mode === 'legacy') {
478
+ const { cards: legacy, total } = findLegacyShipCards(struct);
479
+ if (legacy.length) {
480
+ const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
481
+ const lines = legacy.map(c => `- (id ${c.id}) [${c.area || '?'}] ${flat(c.text).slice(0, 140)}`);
482
+ const more = total > legacy.length ? `\n\n…and ${total - legacy.length} more.` : '';
483
+ sections.push(`# 🧹 ${total} legacy raw-bash ship card(s) — optional cleanup\n_Pre-v1.15 auto-capture residue (raw shell command / path-scraped PR numbers). They are ALREADY excluded from repeat-detection, so this is cosmetic hygiene, not a correctness fix. To tidy: retire each with \`brain_note\` marker \`✓\`, or leave them — they no longer trigger false repeat warnings._\n\n${lines.join('\n')}${more}`);
484
+ } else if (mode === 'legacy') {
485
+ sections.push('✓ No legacy raw-bash ship cards — every ship card is a clean fact.');
486
+ }
487
+ }
488
+
473
489
  // (2) MIGRATIONS — the brain reconciled against committed external state.
474
490
  // Migrations live in the CODE repo (usually beside brain.klypix), not in a
475
491
  // separate canvas vault — so default the root to the brain file's folder.
@@ -530,7 +546,7 @@ export async function opBrainGarden({ vault, canvas, apply = false, syntheses })
530
546
  }
531
547
  }
532
548
 
533
- export async function opBrainConnect({ vault, canvas, apply = false, max = 24, threshold = 0.45, log = () => {} }) {
549
+ export async function opBrainConnect({ vault, canvas, apply = false, max = 24, threshold = 0.45, pairs = null, relationship = null, label = null, log = () => {} }) {
534
550
  const tgt = brainTarget(vault, canvas);
535
551
  if (tgt.ambiguous) return ambiguousBrainErr(tgt.ambiguous);
536
552
  if (!tgt.file) return err(`No brain found — looked for ./brain.klypix in the project, then ${vault}.`);
@@ -542,6 +558,28 @@ export async function opBrainConnect({ vault, canvas, apply = false, max = 24, t
542
558
  const live = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !/^archive$/i.test(c.area || ''));
543
559
  const linked = new Set(struct.connections.map(c => [c.fromId, c.toId].sort().join('|')));
544
560
 
561
+ // ── Explicit-pairs mode ─────────────────────────────────────────────────────
562
+ // Draw EXACTLY the given card-id pairs with a chosen relationship, bypassing the
563
+ // auto-proposer. This is the persisted DISMISS path for a reconcile false
564
+ // positive: pass relationship:"not_contradiction" and detectContradictions will
565
+ // treat the pair as settled forever (the escape hatch a correction-cue false
566
+ // positive — which has no stale card to retire — previously lacked).
567
+ if (Array.isArray(pairs) && pairs.length) {
568
+ const rel = typeof relationship === 'string' && relationship ? relationship : 'relates_to';
569
+ const lbl = (typeof label === 'string' && label) ? label : (rel === 'not_contradiction' ? 'not a contradiction' : undefined);
570
+ const explicit = pairs
571
+ .filter(p => p && p.fromId && p.toId && byId.has(p.fromId) && byId.has(p.toId) && p.fromId !== p.toId)
572
+ .map(p => ({ fromId: p.fromId, toId: p.toId, relationship: rel, ...(lbl ? { label: lbl } : {}) }));
573
+ if (!explicit.length) return err('pairs needs [{fromId, toId}, …] where both ids are real cards in this brain (see the ids in brain_reconcile output).');
574
+ const render2 = (e) => `- ${flat(byId.get(e.fromId)?.text)} ↔ ${flat(byId.get(e.toId)?.text)} (${e.relationship}${e.label ? `: ${e.label}` : ''})`;
575
+ if (!apply) return { blocks: [text(`# ${explicit.length} explicit connection(s) to draw\n_Re-run with apply:true to draw them.${rel === 'not_contradiction' ? ' These pairs will then be permanently dismissed as contradiction candidates.' : ''}_\n\n${explicit.map(render2).join('\n')}`)] };
576
+ try {
577
+ const { buffer, added } = await addBrainConnections(fs.readFileSync(file), explicit);
578
+ await atomicWrite(file, buffer);
579
+ return { blocks: [text(`✓ Drew ${added} connection(s)${rel === 'not_contradiction' ? ' — these pair(s) are now dismissed and will NOT resurface as brain_reconcile contradiction candidates' : ''}.\n\n${explicit.slice(0, added).map(render2).join('\n')}`)] };
580
+ } catch (e) { return err(`Apply failed (brain unchanged): ${e.message}`); }
581
+ }
582
+
545
583
  let edges = [];
546
584
  let mode = 'structural (shared tags + [[mentions]])';
547
585
  const pipe = await Promise.race([getEmbedder(log), new Promise(r => setTimeout(() => r(null), 20_000))]);
@@ -127,7 +127,11 @@ export async function parseKlypix(buffer) {
127
127
  return { struct, zip, assetPaths, isV4, canvas, manifest };
128
128
  }
129
129
 
130
- const REL = new Set(['leads_to', 'depends_on', 'relates_to', 'conflicts_with', 'supports', 'questions', 'costs', 'blocks']);
130
+ // 'not_contradiction' is a DISMISSAL edge, not a topical relation: an agent draws
131
+ // it between two cards a reconcile pass flagged as a FALSE contradiction, and
132
+ // detectContradictions then treats the pair as settled forever (the persisted
133
+ // dismiss path the correction-cue false-positive previously lacked).
134
+ const REL = new Set(['leads_to', 'depends_on', 'relates_to', 'conflicts_with', 'supports', 'questions', 'costs', 'blocks', 'not_contradiction']);
131
135
 
132
136
  /**
133
137
  * Build a real .klypix v4 file (nodebuffer) from a simple spec:
@@ -827,13 +831,17 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
827
831
  const out = [];
828
832
  const push = (...lines) => { for (const l of lines) { out.push(l); used += l.length + 1; } };
829
833
 
834
+ // Overdue open cards (self-declared deadline passed) — badged inline so a
835
+ // stale-dated reminder is flagged the next session instead of decaying silently.
836
+ const overdueById = findOverdueOpenCards(struct).byId;
837
+ const odBadge = (c) => { const o = overdueById.get(c.id); return o ? ` · ⏰ OVERDUE — deadline ${o.date} passed ${o.daysOverdue}d ago; verify or close (✓)` : ''; };
830
838
  push(`# ${struct.title} — brain brief`);
831
839
  push(`*${struct.format} · ${struct.counts.cards} cards · ${struct.counts.connections} connections · tiered brief (focus + open + last ${recentDays}d headlines); full cards via klypix-canvas MCP search*`);
832
840
  if (focus.length) {
833
841
  push('', '## 📌 Human focus (cards the human placed here — act on these first)');
834
- for (const c of focus) push(`- ${fr(c)}${flat(c.text)}`);
842
+ for (const c of focus) push(`- ${fr(c)}${flat(c.text)}${odBadge(c)}`);
835
843
  }
836
- if (open.length) { push('', '## Open questions & goals'); for (const c of open) push(`- ${fr(c)}${flat(c.text)}`); }
844
+ if (open.length) { push('', '## Open questions & goals'); for (const c of open) push(`- ${fr(c)}${flat(c.text)}${odBadge(c)}`); }
837
845
  if (skills.length) {
838
846
  push('', '## 🛠️ Skills — how we do things here (reusable; applies every session)');
839
847
  for (const c of skills.slice(0, maxSkills)) push(`- ${fr(c)}${flat(c.text)}`);
@@ -942,9 +950,13 @@ export function structToUltraBrief(struct, { freshness = null, briefPath = '.cla
942
950
  for (const c of conflicts.slice(0, 4)) { if (!pushIf(`- ${clip(c.from, 70)} ⚔️ ${clip(c.to, 70)}`)) break; shown++; }
943
951
  if (shown < conflicts.length) pushIf(`- …and ${conflicts.length - shown} more conflict(s) — in the full brief.`);
944
952
  }
945
- if (open.length && pushIf('') && pushIf(`## Open questions & goals (${open.length})`)) {
953
+ // Overdue opens lead (and get a ⏰ prefix) so a passed deadline is never the
954
+ // line that falls off the bottom of the preview-sized budget.
955
+ const overdueById = findOverdueOpenCards(struct).byId;
956
+ const openSorted = open.slice().sort((a, b) => (overdueById.has(b.id) ? 1 : 0) - (overdueById.has(a.id) ? 1 : 0));
957
+ if (open.length && pushIf('') && pushIf(`## Open questions & goals (${open.length}${overdueById.size ? `, ${overdueById.size} ⏰ overdue` : ''})`)) {
946
958
  let shown = 0;
947
- for (const c of open) { if (!pushIf(`- ${fr(c)}${head(c)}`)) break; shown++; }
959
+ for (const c of openSorted) { if (!pushIf(`- ${overdueById.has(c.id) ? '⏰ OVERDUE ' : ''}${fr(c)}${head(c)}`)) break; shown++; }
948
960
  if (shown < open.length) pushIf(`- …and ${open.length - shown} more — in the full brief.`);
949
961
  }
950
962
  const areas = struct.cards.filter(c => c.type === 'container' && !/^archive$/i.test(c.title || ''))
@@ -1193,6 +1205,35 @@ const REPEAT_VERB_STOP = new Set([
1193
1205
  // PR#), or a kebab/snake identifier. A #file-/#dir- tag-stem match counts as an
1194
1206
  // entity at match time regardless of shape (tags are capture-stamped anchors).
1195
1207
  const isEntityToken = (t) => /\d/.test(t) || t.includes('-') || t.includes('_');
1208
+ // ── Legacy raw-bash ship cards (pre-v1.15 auto-capture residue) ──────────────
1209
+ // Before the v1.15 ship-capture rewrite, auto-harvest dumped the RAW shell command
1210
+ // into the card ("🏁 merged PR #850 — auto-captured (`cd /c/Users/…/8db42`)") and
1211
+ // scraped stray path digits as PR numbers ("merged PR #238886"). These are dense
1212
+ // with generic ship verbs + junk numbers, so the possible-repeat warner kept
1213
+ // matching them and crying wolf — training agents to ignore repeat warnings. They
1214
+ // are (a) excluded from detectRepeatWork below and (b) surfaced by findLegacyShip-
1215
+ // Cards for a one-time cleanup. Detection is SIGNATURE-based (not just "old"), so a
1216
+ // CLEAN ship card ("Ship: 🏁 merged PR #286") is never touched.
1217
+ export const isLegacyRawShipCard = (text) => {
1218
+ const t = String(text || '');
1219
+ if (!/🏁|\bmerged\b|\brelease|\bpublish|\bshipped\b|\btagged\b/i.test(t)) return false; // ship-shaped only
1220
+ return /auto-captured/i.test(t) // the explicit pre-v1.15 stamp
1221
+ || /`[^`]*\b(?:cd|gh|git|npm|node|npx)\b[^`]*`/i.test(t) // a raw shell command left inside the card
1222
+ || /(?:\/c\/users\/|[a-z]:[\\/]+users[\\/]|\/(?:home|users|mnt|tmp)\/)/i.test(t) // a filesystem-path fragment
1223
+ || /\b(?:PR|pull request|#)\s*#?\d{6,}\b/i.test(t); // a 6+ digit "PR number" = scraped path digits, not a real PR
1224
+ };
1225
+ // One-time cleanup surface: the legacy raw-bash ship cards still live in a brain.
1226
+ // Suggestion-only (retire each with a ✓ marker) — they're already excluded from
1227
+ // repeat-matching, so this is cosmetic hygiene, not a correctness fix.
1228
+ export function findLegacyShipCards(struct, { max = 20 } = {}) {
1229
+ const empty = { cards: [], total: 0 };
1230
+ if (!struct || !Array.isArray(struct.cards)) return empty;
1231
+ const isArchived = (c) => /^archive$/i.test(c.area || '');
1232
+ const hits = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim()
1233
+ && !isArchived(c) && !/↩|✅/.test(c.text) && isLegacyRawShipCard(c.text));
1234
+ return { cards: hits.slice(0, max), total: hits.length };
1235
+ }
1236
+
1196
1237
  export function detectRepeatWork(struct, query, { topK = 2, minScore = 5, minTokens = 2 } = {}) {
1197
1238
  const tokens = Array.isArray(query) ? query.filter(Boolean) : queryTokens(query);
1198
1239
  if (tokens.length < minTokens || !struct || !Array.isArray(struct.cards)) return [];
@@ -1203,6 +1244,7 @@ export function detectRepeatWork(struct, query, { topK = 2, minScore = 5, minTok
1203
1244
  if (c.type === 'container' || !(c.text || '').trim()) continue;
1204
1245
  const kind = kindOf(c.text);
1205
1246
  if (!kind) continue; // only COMPLETED-work cards qualify
1247
+ if (isLegacyRawShipCard(c.text)) continue; // pre-v1.15 raw-bash residue — never a repeat candidate (nonsense scraped PR numbers)
1206
1248
  // Score the first MEANINGFUL line, not a marker stamp: supersede/resolve
1207
1249
  // prepend "↩︎ superseded <date>" / lead with "✅ …", which would otherwise
1208
1250
  // become the title and hide the real content from title-weighted matching.
@@ -1307,6 +1349,12 @@ export function detectContradictions(struct, { minOverlap = 0.45, topK = 12 } =
1307
1349
  for (const e of struct.connections || []) {
1308
1350
  const k1 = e.fromId + '|' + e.toId, k2 = e.toId + '|' + e.fromId;
1309
1351
  if (e.label === 'superseded by' || e.label === 'closed by' || e.relationship === 'conflicts_with') { settled.add(k1); settled.add(k2); }
1352
+ // Explicit "not a contradiction" dismissal — settles BOTH a cue pair and a
1353
+ // polarity pair. This is the persisted escape hatch for a correction-cue
1354
+ // FALSE positive: it had no stale card to retire, so a plain link never
1355
+ // cleared it and it re-reported on every run forever. A deliberate
1356
+ // not_contradiction edge (brain_connect pairs:…) now permanently dismisses it.
1357
+ if (e.relationship === 'not_contradiction' || e.label === 'not a contradiction') { settled.add(k1); settled.add(k2); }
1310
1358
  if (e.label !== 'auto') { linked.add(k1); linked.add(k2); }
1311
1359
  }
1312
1360
  const out = [];
@@ -1577,6 +1625,50 @@ export function correctionOverlaysFor(struct, cards, { at = CORRECTION_SUPERSEDE
1577
1625
  return out;
1578
1626
  }
1579
1627
 
1628
+ // ── Awaits-merge decay — the deterministic twin of the correction overlay ────
1629
+ // A milestone written minutes before the human merges ("PR #332 awaits founder
1630
+ // merge") stays stale forever, even though ship-event auto-capture DOES record
1631
+ // "merged PR #332" — nothing linked the two. Matching a "PR #N" + awaits-cue card
1632
+ // against a harvested "merged PR #N" is EXACT-STRING, zero-inference work. Given
1633
+ // the cards recall is about to inject, return for each one carrying an unmet
1634
+ // merge that the ship event contradicts a merged-overlay {num, by, date}. The
1635
+ // caller annotates the card (never retires it — the fact is right, only its
1636
+ // status decayed). Pure + cheap. PR numbers are capped at 5 digits — a 6+ digit
1637
+ // "#N" is a path-scraped junk number (see isLegacyRawShipCard), never a real PR.
1638
+ const PR_MERGE_AWAIT_RE = /\b(?:await(?:s|ing)?|pending|not\s+yet|yet\s+to\s+be|to\s+be|unmerged|needs?)\b[^.\n]{0,24}?\bmerg/i;
1639
+ const PR_MERGED_RE = /\bmerged\b[^.\n]{0,16}?(?:PR|pull\s+request|#)\s*#?(\d{1,5})\b/i;
1640
+ const PR_REFS_RE = /\b(?:PR|pull\s+request|#)\s*#?(\d{1,5})\b/ig;
1641
+ function prNumbersIn(text) {
1642
+ const out = new Set(); const s = String(text || '');
1643
+ PR_REFS_RE.lastIndex = 0; let m;
1644
+ while ((m = PR_REFS_RE.exec(s))) out.add(m[1]);
1645
+ return out;
1646
+ }
1647
+ export function mergeOverlaysFor(struct, cards) {
1648
+ const out = new Map();
1649
+ if (!struct || !Array.isArray(struct.cards) || !Array.isArray(cards) || !cards.length) return out;
1650
+ const isArchived = (c) => /^archive$/i.test(c.area || '');
1651
+ // Index PR number → the NEWEST live card that says it merged (ship event or hand marker).
1652
+ const mergedBy = new Map();
1653
+ for (const c of struct.cards) {
1654
+ if (c.type === 'container' || isArchived(c) || !(c.text || '').trim()) continue;
1655
+ const mm = PR_MERGED_RE.exec(c.text); if (!mm) continue;
1656
+ const prev = mergedBy.get(mm[1]);
1657
+ if (!prev || (c.createdAt || 0) >= (prev.createdAt || 0)) mergedBy.set(mm[1], c);
1658
+ }
1659
+ if (!mergedBy.size) return out;
1660
+ for (const card of cards) {
1661
+ if (!card || !card.id || !(card.text || '').trim()) continue;
1662
+ if (PR_MERGED_RE.test(card.text)) continue; // the hit IS a merge note — nothing to overlay
1663
+ if (!PR_MERGE_AWAIT_RE.test(card.text)) continue; // must actually claim it's awaiting a merge
1664
+ for (const num of prNumbersIn(card.text)) {
1665
+ const by = mergedBy.get(num);
1666
+ if (by && by.id !== card.id) { out.set(card.id, { kind: 'merged', num, by, date: by.createdAt ? new Date(by.createdAt).toISOString().slice(0, 10) : null }); break; }
1667
+ }
1668
+ }
1669
+ return out;
1670
+ }
1671
+
1580
1672
  // ── Auto-skill classifier (skills emerge from the flow, not just the '+' marker) ─
1581
1673
  // A REUSABLE skill (how-to / gotcha / convention) reads as a GENERAL RULE that
1582
1674
  // applies next time — distinct from a one-time decision ("we shipped X"). This is
@@ -2124,6 +2216,45 @@ export function findStaleOpenCards(struct, { coverAt = 0.6, max = 5 } = {}) {
2124
2216
  return { gaps: out.slice(0, max), total: out.length };
2125
2217
  }
2126
2218
 
2219
+ // ── Open-question deadline awareness ─────────────────────────────────────────
2220
+ // An open card can carry a self-declared deadline ("Rotate NPM_TOKEN before
2221
+ // ~2026-07-03"). Once that day passes the card is OVERDUE — but nothing surfaced
2222
+ // it, so it decayed silently and served the same stale reminder a day late. This
2223
+ // parses an EXPLICIT, cue-anchored ISO date only (a bare date elsewhere in the
2224
+ // card — "shipped 2026-06-30" — is NOT a deadline), so a false overdue flag can't
2225
+ // fire. Deadline = end of the named UTC day. Used to badge overdue opens in both
2226
+ // brief tiers (the "flag it in the next session's brief" acceptance criterion).
2227
+ const DEADLINE_RE = /\b(?:before|by|due(?:\s+(?:date|by|on))?|deadline|until|no\s+later\s+than|not\s+later\s+than|eod|end\s+of(?:\s+day)?)\b[^\n.]{0,32}?~?\s*(\d{4}-\d{2}-\d{2})\b/i;
2228
+ export function parseDeadline(text) {
2229
+ const m = DEADLINE_RE.exec(String(text || ''));
2230
+ if (!m) return null;
2231
+ const ts = Date.parse(m[1] + 'T23:59:59Z'); // due at the END of the named day (UTC)
2232
+ return Number.isFinite(ts) ? { date: m[1], ts } : null;
2233
+ }
2234
+ // Live open (❓/🎯) cards whose parsed deadline has passed, newest-overdue-last so
2235
+ // the most-overdue lead. Injectable `now` for hermetic tests. Skips already
2236
+ // resolved/superseded cards (they carry ✅/↩︎) and skills (standing reference).
2237
+ export function findOverdueOpenCards(struct, { now = Date.now() } = {}) {
2238
+ const empty = { overdue: [], total: 0, byId: new Map() };
2239
+ if (!struct || !Array.isArray(struct.cards)) return empty;
2240
+ const isArchived = (c) => /^archive$/i.test(c.area || '');
2241
+ const dayMs = (iso) => Date.parse(iso + 'T00:00:00Z');
2242
+ const nowDay = dayMs(new Date(now).toISOString().slice(0, 10));
2243
+ const out = [];
2244
+ for (const c of struct.cards) {
2245
+ if (c.type === 'container' || !(c.text || '').trim() || isArchived(c)) continue;
2246
+ if (/↩|✅/.test(c.text)) continue; // already superseded/resolved
2247
+ if (!/❓|🎯/.test(c.text) || /🛠/.test(c.text)) continue; // open questions & goals only
2248
+ const d = parseDeadline(c.text);
2249
+ // Overdue gate is LENIENT (end of the named day, d.ts) — a card isn't overdue
2250
+ // at 00:01 on its deadline day. The DISPLAY count is a whole-calendar-day
2251
+ // difference, so "before 2026-07-03" seen on 2026-07-04 reads "passed 1d ago".
2252
+ if (d && d.ts < now) out.push({ card: c, date: d.date, daysOverdue: Math.max(0, Math.round((nowDay - dayMs(d.date)) / 86_400_000)) });
2253
+ }
2254
+ out.sort((a, b) => b.daysOverdue - a.daysOverdue);
2255
+ return { overdue: out, total: out.length, byId: new Map(out.map(o => [o.card.id, o])) };
2256
+ }
2257
+
2127
2258
  // ── Deliberate note → capture input ──────────────────────────────────────────
2128
2259
  // Turn ONE structured note into captureIntoBrain's input shape — the deliberate
2129
2260
  // twin of the Stop hook's transcript marker parser. This is what lets an ON-DEMAND