klypix-mcp 1.31.1 → 1.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.31.1",
3
+ "version": "1.32.0",
4
4
  "description": "Every project gets a brain — one open .klypix file your AI agents read, write, and argue from, over MCP. Works with Claude, Cursor, Cline, any model.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -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 && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/canvas-view.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 && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs"
57
57
  },
58
58
  "dependencies": {
59
59
  "@modelcontextprotocol/ext-apps": "^1.7.4",
@@ -1502,13 +1502,38 @@ async function promptRetrieve(lib) {
1502
1502
  if (struct) {
1503
1503
  try {
1504
1504
  const digest = lib.areaStatusDigest(struct, { maxAreas: 12 });
1505
+ // The digest alone is per-area COUNTS ("· 8 open ·") — it never
1506
+ // names a single open card, yet this block also clears freshHits,
1507
+ // so a status prompt used to arrive with the instruction "answer
1508
+ // from THIS" and nothing to answer FROM. An agent then reports
1509
+ // whatever the session brief happened to show, which is itself a
1510
+ // truncated tier — the 2026-07-25 field incident, where 8 live
1511
+ // opens (including a founder-ranked #1 bug) were invisible on
1512
+ // both surfaces at once. Prefer the full computed view — digest
1513
+ // AND every open card, honestly overflow-marked — and keep the
1514
+ // bare digest only as the fallback for an older engine.
1515
+ let body = null;
1516
+ if (typeof lib.statusContextToMarkdown === 'function') {
1517
+ try {
1518
+ // Sized so a real brain fits BOTH a full area digest and
1519
+ // every open at readable width (~140 chars) — this block
1520
+ // IS the answer to the question that triggered it, it is
1521
+ // hash-deduped per session, and it replaces freshHits
1522
+ // rather than adding to them. Paying ~1.2k tokens once
1523
+ // per status conversation beats answering it wrong.
1524
+ const md = lib.statusContextToMarkdown(struct, { budgetChars: 5200 });
1525
+ // Drop its own H1; the hook's stronger header replaces it.
1526
+ if (md && md.trim()) body = md.split('\n').slice(1).join('\n').trimEnd();
1527
+ } catch { body = null; }
1528
+ }
1529
+ const content = body || digest.join('\n');
1505
1530
  if (digest.length) {
1506
- const h = crypto.createHash('sha1').update(digest.join('\n')).digest('hex').slice(0, 12);
1531
+ const h = crypto.createHash('sha1').update(content).digest('hex').slice(0, 12);
1507
1532
  let lane = null; try { lane = readSessions().find(s => s.id === sid) || null; } catch { /* */ }
1508
1533
  if (lane && lane.statusDigestHash === h) {
1509
1534
  statusMd = '## 📊 Current state — unchanged since the digest shown earlier this session (`brain_ask` gives the full computed status view).';
1510
1535
  } else {
1511
- statusMd = ['## 📊 Computed current state (status-shaped question detected — answer from THIS + `brain_ask`, never from memory of past sessions)', ...digest].join('\n');
1536
+ statusMd = ['## 📊 Computed current state (status-shaped question detected — answer from THIS + `brain_ask`, never from memory of past sessions)', content].join('\n');
1512
1537
  try { touchSession(sid, { statusDigestHash: h }); } catch { /* best-effort */ }
1513
1538
  }
1514
1539
  freshHits = [];
@@ -1114,6 +1114,47 @@ export async function arrangeBrain(buffer, opts = {}) {
1114
1114
  return { buffer: work, stats };
1115
1115
  }
1116
1116
 
1117
+ // ── Lifecycle classification — ONE definition, every surface ────────────────
1118
+ // A capture marker writes the lifecycle glyph into LINE 1 ("Area: ❓ text").
1119
+ // PRECEDENCE: when line 1 already carries a state glyph, line 1 DECIDES — so a
1120
+ // 🏁 milestone whose BODY quotes "❓/🎯" stays a milestone instead of ranking as
1121
+ // an open question forever (the glyph-in-prose trap; 6 live false-positives on
1122
+ // the KLYPIX brain, four of them the same shipped 1.30.0 card re-read as "still
1123
+ // to do"). v1.31.1 stripped glyphs at CAPTURE time for engine-authored text —
1124
+ // that could not help cards already written, nor a human card that legitimately
1125
+ // quotes a glyph. This is the read-side half, and it needs no rewrite of stored
1126
+ // text (a rewrite's failure mode is silent: an over-matching strip turns a real
1127
+ // open card invisible with nothing to notice).
1128
+ //
1129
+ // Two deliberate constraints, both measured before shipping:
1130
+ // 1. FALLBACK — if line 1 carries no state glyph at all we scan the whole
1131
+ // text exactly as before, so a brain that writes its marker under a title
1132
+ // line is classified identically. Zero regression by construction.
1133
+ // 2. 🛠 IS EXEMPT — a skill is ADDITIVE (a milestone can also carry a standing
1134
+ // rule) and skills never age out, so 🛠 keeps whole-text matching. Applying
1135
+ // precedence to it demoted 7 live skill cards to milestones, silently
1136
+ // retiring 7 standing rules. Precedence governs the ❓/🎯-vs-🏁 STATE
1137
+ // distinction only.
1138
+ // Measured on the KLYPIX brain: open 26→20 (6 false-positives, 0 lost),
1139
+ // skills 97→97, milestones 303→309, 0 cards left with no lifecycle at all.
1140
+ const STATE_GLYPH = /❓|🎯|🏁/;
1141
+ const OPEN_GLYPH = /❓|🎯/;
1142
+ const SKILL_GLYPH = /🛠/;
1143
+ const MILE_GLYPH = /🏁/;
1144
+ const RESOLVED_GLYPH = /↩|✅|⤵/;
1145
+ // The span line 1 governs when it carries a state glyph; else the whole card.
1146
+ const lifecycleScope = (text) => {
1147
+ const t = String(text || '');
1148
+ const l1 = t.split('\n', 1)[0];
1149
+ return STATE_GLYPH.test(l1) ? l1 : t;
1150
+ };
1151
+ export const isSkillCard = (c) => SKILL_GLYPH.test(String(c?.text || ''));
1152
+ export const isOpenCard = (c) => !isSkillCard(c) && OPEN_GLYPH.test(lifecycleScope(c?.text));
1153
+ export const isMilestoneCard = (c) => !isSkillCard(c) && !isOpenCard(c) && MILE_GLYPH.test(lifecycleScope(c?.text));
1154
+ // "Open AND not already resolved in prose" — the status surfaces carry this
1155
+ // extra guard so a ✅/↩/⤵-stamped card is never reported as plainly still-open.
1156
+ export const isUnresolvedOpenCard = (c) => isOpenCard(c) && !RESOLVED_GLYPH.test(String(c?.text || ''));
1157
+
1117
1158
  // ── Per-area status digest (2026-07-23 field incident) ───────────────────────
1118
1159
  // ONE computed current-state line per ACTIVE area: newest 🏁 headline + open
1119
1160
  // count. This is the fact whose tier-eviction let a stale "remaining:" claim
@@ -1138,8 +1179,8 @@ export function areaStatusDigest(struct, { activeDays = 30, maxAreas = 20, now =
1138
1179
  for (const [area, cs] of byArea) {
1139
1180
  const newest = Math.max(...cs.map(c => c.createdAt || 0));
1140
1181
  if (newest < cutoff) continue; // dormant area — not "current state"
1141
- const miles = cs.filter(c => /🏁/.test(c.text) && !/❓|🎯|🛠/.test(c.text)).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
1142
- const opens = cs.filter(c => /❓|🎯/.test(c.text) && !/🛠/.test(c.text));
1182
+ const miles = cs.filter(isMilestoneCard).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
1183
+ const opens = cs.filter(isOpenCard);
1143
1184
  const m = miles[0];
1144
1185
  const mileTxt = m ? `last 🏁 ${day(m.createdAt)} “${cut(flat(m.text).replace(/^[^:\n]{1,40}:\s*/, '').replace(/^🏁\s*/, ''), 70)}”` : 'no 🏁 yet';
1145
1186
  rows.push({ newest, line: `- ${area} — ${mileTxt} · ${opens.length} open · latest ${day(newest)}` });
@@ -1174,9 +1215,9 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
1174
1215
  // Standing reference, NOT a point-in-time event: always shown, never
1175
1216
  // recency-decayed, and excluded from open/milestones/recent so a skill never
1176
1217
  // masquerades as (or ages out like) a decision.
1177
- const skills = rest.filter(c => /🛠/.test(c.text));
1178
- const open = rest.filter(c => /❓|🎯/.test(c.text) && !/🛠/.test(c.text));
1179
- const miles = rest.filter(c => /🏁/.test(c.text) && !/❓|🎯|🛠/.test(c.text));
1218
+ const skills = rest.filter(isSkillCard);
1219
+ const open = rest.filter(isOpenCard);
1220
+ const miles = rest.filter(isMilestoneCard);
1180
1221
  const plain = rest.filter(c => !open.includes(c) && !miles.includes(c) && !skills.includes(c));
1181
1222
  const recent = plain.filter(c => c.createdAt >= cutoff).sort((a, b) => b.createdAt - a.createdAt).slice(0, maxRecent);
1182
1223
  const archivedCount = texts.length - live.length;
@@ -1237,16 +1278,29 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
1237
1278
  }
1238
1279
  if (open.length) {
1239
1280
  push('', `## Open questions & goals (${open.length}${overdueById.size ? `, ${overdueById.size} ⏰ overdue` : ''})`);
1240
- // Overdue first, then newest — the card that falls off the bottom must
1241
- // never be a passed deadline.
1242
- const sorted = open.slice().sort((a, b) => (overdueById.has(b.id) ? 1 : 0) - (overdueById.has(a.id) ? 1 : 0) || (b.createdAt || 0) - (a.createdAt || 0));
1281
+ // Overdue first, then OLDEST first. This tier used to render every open
1282
+ // in FULL TEXT while every other tier rendered 160-char headlines, so a
1283
+ // handful of verbose cards spent the whole allowance: 14,401 chars of
1284
+ // opens against a 7,425-char tier meant 15 of 27 were dropped — and
1285
+ // sorted newest-first, the dropped 15 were ALWAYS the oldest, i.e. the
1286
+ // long-deferred items a "what is still open?" question is mostly about.
1287
+ // Now: the newest few keep their detail, the rest get a headline, and
1288
+ // the whole list fits. Age-ascending makes eviction safe permanently —
1289
+ // if the brain triples and something must go, it is the newest (already
1290
+ // shown in Recent decisions), never the item nobody has looked at.
1291
+ const sorted = open.slice().sort((a, b) => (overdueById.has(b.id) ? 1 : 0) - (overdueById.has(a.id) ? 1 : 0) || (a.createdAt || 0) - (b.createdAt || 0));
1292
+ // Detail tier mirrors the Recent-decisions idiom: a few in depth, the
1293
+ // rest as scannable headlines. Completeness beats depth for a list whose
1294
+ // job is to say what EXISTS — full text is one MCP read away.
1295
+ const detailOpen = new Set(sorted.slice(-detailRecent).map(c => c.id)); // newest few keep full detail
1243
1296
  let shown = 0;
1244
1297
  for (const c of sorted) {
1245
1298
  if (shown >= 40 || used > BUDGET_CHARS * 0.55) break;
1246
- push(`- ${fr(c)}${flat(c.text)}${odBadge(c)}`);
1299
+ const body = (overdueById.has(c.id) || detailOpen.has(c.id)) ? excerpt(c) : headline(c, 200);
1300
+ push(`- ${fr(c)}${body}${odBadge(c)}`);
1247
1301
  shown++;
1248
1302
  }
1249
- if (shown < open.length) push(`- …and ${open.length - shown} more open item(s) — ask the brain (brain_ask) rather than assuming this list is complete.`);
1303
+ if (shown < open.length) push(`- ⚠️ …and ${open.length - shown} more open item(s) — ask the brain (brain_ask) rather than assuming this list is complete.`);
1250
1304
  }
1251
1305
  if (skills.length) {
1252
1306
  push('', '## 🛠️ Skills — how we do things here (reusable; applies every session)');
@@ -1299,6 +1353,9 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
1299
1353
  if (miles.length) {
1300
1354
  push('', '## Milestones');
1301
1355
  for (const c of miles.sort((a, b) => b.createdAt - a.createdAt).slice(0, maxMilestones)) push(`- ${fr(c)}${headline(c)}`);
1356
+ // Same honesty rule as every other tier: this printed 8 of 302 with no
1357
+ // notice at all, so the newest-8 read as "the milestones".
1358
+ if (miles.length > maxMilestones) push(`- …and ${miles.length - maxMilestones} older milestone(s) — search the brain.`);
1302
1359
  }
1303
1360
  let shownRecent = 0;
1304
1361
  if (recent.length) {
@@ -1327,6 +1384,7 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
1327
1384
  if (struct.connections.length && used <= BUDGET_CHARS) {
1328
1385
  push('', '## Connections');
1329
1386
  for (const cn of struct.connections.slice(0, maxConnections)) push(`- ${cn.from} → ${cn.to}${cn.label || cn.relationship ? ` (${cn.label || cn.relationship})` : ''}`);
1387
+ if (struct.connections.length > maxConnections) push(`- …and ${struct.connections.length - maxConnections} more connection(s) — search the brain.`);
1330
1388
  }
1331
1389
  const hidden = [];
1332
1390
  const unshown = plain.length - shownRecent;
@@ -1357,8 +1415,8 @@ export function structToUltraBrief(struct, { freshness = null, briefPath = '.cla
1357
1415
  const isFocus = (c) => /(^|\s)focus\b/i.test(c.area || '');
1358
1416
  const live = texts.filter(c => !isArchived(c));
1359
1417
  const focus = live.filter(isFocus);
1360
- const open = live.filter(c => /❓|🎯/.test(c.text) && !/🛠/.test(c.text) && !isFocus(c));
1361
- const skills = live.filter(c => /🛠/.test(c.text) && !isFocus(c));
1418
+ const open = live.filter(c => isOpenCard(c) && !isFocus(c));
1419
+ const skills = live.filter(c => isSkillCard(c) && !isFocus(c));
1362
1420
  const conflicts = (struct.connections || []).filter(c => c.relationship === 'conflicts_with');
1363
1421
  const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
1364
1422
  const fr = (c) => (freshness && freshness[c.id]) ? freshness[c.id] + ' ' : '';
@@ -1627,7 +1685,10 @@ export function questionContextToMarkdown(question, result, { mode = 'lexical',
1627
1685
  // the question). Deterministic O(cards) assembly, no embeddings: per-area
1628
1686
  // digest + open items (overdue first, with correction/fulfillment flags) +
1629
1687
  // likely-fulfilled hints + newest milestones. Rendered ABOVE the ranked hits.
1630
- export function statusContextToMarkdown(struct, { maxOpen = 12, budgetChars = 3200 } = {}) {
1688
+ // maxOpen defaults to NO cap: the open list is the answer to a status question,
1689
+ // so it is sized to fit (per-card width adapts) rather than sliced. Callers can
1690
+ // still pass a cap explicitly.
1691
+ export function statusContextToMarkdown(struct, { maxOpen = Infinity, budgetChars = 4200 } = {}) {
1631
1692
  if (!struct || !Array.isArray(struct.cards)) return '';
1632
1693
  const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
1633
1694
  const day = (ts) => ts ? new Date(ts).toISOString().slice(0, 10) : '';
@@ -1635,17 +1696,38 @@ export function statusContextToMarkdown(struct, { maxOpen = 12, budgetChars = 32
1635
1696
  const live = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !isArchived(c));
1636
1697
  const out = [];
1637
1698
  let used = 0;
1638
- const push = (l) => { if (used < budgetChars) { out.push(l); used += l.length + 1; } };
1639
- push('# Computed current state (status mode)');
1640
- push('_A status-shaped question answers from THIS computed section first — the ranked cards below are supporting context only. A card marked ⏳ or ⚠️ must not be reported as plainly still-open._');
1641
- push('');
1642
- for (const l of areaStatusDigest(struct, { maxAreas: 14 })) push(l);
1699
+ // A truncation notice must NEVER be subject to the budget it is warning
1700
+ // about. It used to be: the "…and N more" line was emitted through the same
1701
+ // guarded push as the content, so once the budget was spent the notice was
1702
+ // dropped too — and a one-third-complete list rendered as a complete one,
1703
+ // under a header that tells the agent to answer from this section first.
1704
+ // (2026-07-25 field incident: 9 of 27 opens shown, no notice, answer wrong.)
1705
+ // Structural lines — headers, counts, overflow notices — bypass the budget.
1706
+ const pushAlways = (l) => { out.push(l); used += l.length + 1; };
1707
+ const cut = (t, n) => { let s = String(t).slice(0, n); if (/[\uD800-\uDBFF]$/.test(s)) s = s.slice(0, -1); return String(t).length > n ? s.trimEnd() + '…' : s; };
1708
+ pushAlways('# Computed current state (status mode)');
1709
+ pushAlways('_A status-shaped question answers from THIS computed section first — the ranked cards below are supporting context only. A card marked ⏳ or ⚠️ must not be reported as plainly still-open._');
1710
+ pushAlways('');
1643
1711
  // Resolved-but-not-archived cards (✅/↩/⤵ in text) are NOT open — every
1644
1712
  // sibling lifecycle matcher carries this guard (review parity fix).
1645
- const opens = live.filter(c => /❓|🎯/.test(c.text) && !/🛠/.test(c.text) && !/↩|✅|⤵/.test(c.text));
1713
+ const opens = live.filter(isUnresolvedOpenCard);
1714
+ // OPENS ARE THE PRIORITY TIER — reserve their share BEFORE the area digest
1715
+ // spends it. The digest used to run first and unbounded at 14 areas, eating
1716
+ // 1,886 of 3,200 chars (59%) before a single open card printed, so `maxOpen`
1717
+ // never even bound: the budget cut the list at 9 first. Derive the area cap
1718
+ // from what's actually left instead of a fixed number — areaStatusDigest
1719
+ // prints its own honest "…and N more active area(s)" line when it trims.
1720
+ const openReserve = opens.length ? Math.floor(budgetChars * 0.6) : 0;
1721
+ const maxAreas = Math.max(4, Math.min(14, Math.floor((budgetChars - openReserve - used) / 100)));
1722
+ for (const l of areaStatusDigest(struct, { maxAreas })) pushAlways(l);
1646
1723
  if (opens.length) {
1647
1724
  const overdueById = findOverdueOpenCards(struct).byId;
1648
- const sorted = opens.slice().sort((a, b) => (overdueById.has(b.id) ? 1 : 0) - (overdueById.has(a.id) ? 1 : 0) || (b.createdAt || 0) - (a.createdAt || 0));
1725
+ // Overdue first, then OLDEST first. Age IS an open item's urgency signal,
1726
+ // and the newest opens already appear in the brief's Recent tier — so if
1727
+ // anything must be cut it should be the newest, never the long-deferred.
1728
+ // (The brief evicted oldest-first: every dropped card was older than
1729
+ // every printed one, hiding a founder-ranked #1 bug for two weeks.)
1730
+ const sorted = opens.slice().sort((a, b) => (overdueById.has(b.id) ? 1 : 0) - (overdueById.has(a.id) ? 1 : 0) || (a.createdAt || 0) - (b.createdAt || 0));
1649
1731
  // Slice FIRST, overlay the slice — running correction/fulfillment
1650
1732
  // overlays across ALL opens was a cards×cards-shaped pass per status
1651
1733
  // question (review scale finding).
@@ -1653,24 +1735,33 @@ export function statusContextToMarkdown(struct, { maxOpen = 12, budgetChars = 32
1653
1735
  const fulfills = fulfillmentOverlaysFor(struct, top);
1654
1736
  let overlays = new Map();
1655
1737
  try { overlays = correctionOverlaysFor(struct, top); } catch { /* best-effort */ }
1656
- push('');
1657
- push(`## Open (${opens.length})`);
1738
+ pushAlways('');
1739
+ pushAlways(`## Open (${opens.length})`);
1740
+ // COMPLETENESS OVER DEPTH: scale each line to fit rather than dropping
1741
+ // items. A 60-char headline still proves an item EXISTS and can be
1742
+ // pulled in full; a missing line asserts it doesn't. Only if every open
1743
+ // at the floor width still overflows do we cut — and then we say so.
1744
+ const room = Math.max(0, budgetChars - used - 200); // 200 ≈ milestones tail
1745
+ // Budget the PREFIX too ("- [Area] ⏰OVERDUE ⏳likely-fulfilled "), or the
1746
+ // section overruns by prefix×cards — measured 17% over before this term.
1747
+ const PREFIX = 30;
1748
+ const perCard = Math.max(60, Math.min(140, Math.floor(room / Math.max(1, top.length)) - PREFIX));
1658
1749
  let shown = 0;
1659
1750
  for (const c of top) {
1660
1751
  if (used > budgetChars) break;
1661
1752
  const flags = `${overdueById.has(c.id) ? ' ⏰OVERDUE' : ''}${overlays.has(c.id) ? ' ⚠️CORRECTED' : ''}${fulfills.has(c.id) ? ' ⏳likely-fulfilled' : ''}`;
1662
- push(`- [${flat(c.area) || 'Notes'}]${flags} ${flat(c.text).slice(0, 140)}`);
1753
+ pushAlways(`- [${flat(c.area) || 'Notes'}]${flags} ${cut(flat(c.text), perCard)}`);
1663
1754
  shown++;
1664
1755
  }
1665
- if (shown < opens.length) push(`- …and ${opens.length - shown} more open item(s).`);
1756
+ if (shown < opens.length) pushAlways(`- ⚠️ …and ${opens.length - shown} more open item(s) — this list is NOT complete; call brain_ask before reporting what remains.`);
1666
1757
  }
1667
- const miles = live.filter(c => /🏁/.test(c.text) && !/❓|🎯|🛠/.test(c.text)).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
1758
+ const miles = live.filter(isMilestoneCard).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
1668
1759
  if (miles.length) {
1669
- push('');
1670
- push('## Newest milestones');
1671
- for (const m of miles.slice(0, 3)) push(`- [${flat(m.area) || '?'}] ${day(m.createdAt)} ${flat(m.text).slice(0, 130)}`);
1760
+ pushAlways('');
1761
+ pushAlways('## Newest milestones');
1762
+ for (const m of miles.slice(0, 3)) pushAlways(`- [${flat(m.area) || '?'}] ${day(m.createdAt)} ${cut(flat(m.text), 130)}`);
1672
1763
  }
1673
- push('');
1764
+ pushAlways('');
1674
1765
  return out.join('\n') + '\n';
1675
1766
  }
1676
1767
 
@@ -1978,7 +2069,7 @@ export function brainInsights(struct, { staleDays = 21, topHubs = 6 } = {}) {
1978
2069
  if (cn.toId) deg.set(cn.toId, (deg.get(cn.toId) || 0) + 1);
1979
2070
  }
1980
2071
  const headline = (c) => String(c.text || '').replace(/\s+/g, ' ').trim().replace(/^(.*?)([.!?](\s|$)|$)/, '$1').slice(0, 120);
1981
- const isQuestion = (c) => /❓|🎯/.test(c.text); // ❓ open question + 🎯 goal both read as "open"
2072
+ const isQuestion = isOpenCard; // ❓ open question + 🎯 goal both read as "open" (line-1 precedence)
1982
2073
  const hubs = live
1983
2074
  .map(c => ({ id: c.id, area: c.area, degree: deg.get(c.id) || 0, headline: headline(c) }))
1984
2075
  .filter(x => x.degree > 0)
@@ -2233,7 +2324,7 @@ export function challengeBrain(struct, claim, { semantic = null, k = 8, now = Da
2233
2324
  const liveCards = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !isArchived(c));
2234
2325
  res.checked.liveCards = liveCards.length;
2235
2326
  res.checked.cueCards = liveCards.filter(c => hasCorrectionCue(c.text)).length;
2236
- res.checked.openQuestions = liveCards.filter(c => /❓|🎯/.test(c.text)).length;
2327
+ res.checked.openQuestions = liveCards.filter(isOpenCard).length;
2237
2328
  if (!text) { res.shortClaim = true; return res; }
2238
2329
 
2239
2330
  const claimQTok = stripCueMeta(new Set(queryTokens(text)));
@@ -3392,10 +3483,9 @@ export function findStaleOpenCards(struct, { coverAt = 0.6, max = 5 } = {}) {
3392
3483
  const empty = { gaps: [], total: 0 };
3393
3484
  if (!struct || !Array.isArray(struct.cards)) return empty;
3394
3485
  const isArchived = (c) => /^archive$/i.test(c.area || '');
3395
- const isOpen = (c) => /❓|🎯/.test(c.text);
3396
3486
  const live = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !isArchived(c) && !/↩|✅/.test(c.text));
3397
- const opens = live.filter(isOpen);
3398
- const miles = live.filter(c => /🏁/.test(c.text) && !isOpen(c));
3487
+ const opens = live.filter(isOpenCard);
3488
+ const miles = live.filter(isMilestoneCard);
3399
3489
  if (!opens.length || !miles.length) return empty;
3400
3490
  const out = [];
3401
3491
  for (const o of opens) {
@@ -3451,7 +3541,7 @@ export function findOverdueOpenCards(struct, { now = Date.now() } = {}) {
3451
3541
  for (const c of struct.cards) {
3452
3542
  if (c.type === 'container' || !(c.text || '').trim() || isArchived(c)) continue;
3453
3543
  if (/↩|✅/.test(c.text)) continue; // already superseded/resolved
3454
- if (!/❓|🎯/.test(c.text) || /🛠/.test(c.text)) continue; // open questions & goals only
3544
+ if (!isOpenCard(c)) continue; // open questions & goals only
3455
3545
  const d = parseDeadline(c.text);
3456
3546
  // Overdue gate is LENIENT (end of the named day, d.ts) — a card isn't overdue
3457
3547
  // at 00:01 on its deadline day. The DISPLAY count is a whole-calendar-day