klypix-mcp 1.44.0 β†’ 1.45.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.44.0",
3
+ "version": "1.45.0",
4
4
  "description": "Shared project brain and MCP coordination server for multi-agent coding.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -65,7 +65,7 @@
65
65
  "node": ">=18"
66
66
  },
67
67
  "scripts": {
68
- "test": "node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/codex-hooks.mjs && node test/agent-presence.mjs && node test/context-gateway.mjs && node test/conformance.mjs && 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 && node test/semantic-gate.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/presence-visibility.mjs && node test/cli-args.mjs && node test/uninstall.mjs"
68
+ "test": "node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/codex-hooks.mjs && node test/agent-presence.mjs && node test/context-gateway.mjs && node test/conformance.mjs && 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/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-gate.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/presence-visibility.mjs && node test/cli-args.mjs && node test/uninstall.mjs"
69
69
  },
70
70
  "dependencies": {
71
71
  "@modelcontextprotocol/ext-apps": "^1.7.4",
@@ -1667,6 +1667,15 @@ async function capture(lib) {
1667
1667
  process.stderr.write(`[brain] ⏳ likely fulfilled (${f.cov}): "${f.item.slice(0, 70)}"${rest} ${act}\n`);
1668
1668
  }
1669
1669
  }
1670
+ // πŸ› οΈ staleness receipts (2026-08-01): a captured 🏁 that appears to REMOVE a
1671
+ // limitation a standing skill still asserts. The skill is NEVER archived β€”
1672
+ // the receipt hands the session a ready-to-fill ~ amendment, because a rule
1673
+ // whose limitation half died usually keeps a trap half worth saving.
1674
+ if (Array.isArray(stats.skillStale) && stats.skillStale.length) {
1675
+ for (const f of stats.skillStale.slice(0, 3)) {
1676
+ process.stderr.write(`[brain] ⚠️ rule may be obsolete (${f.cov}): skill "${f.skill.slice(0, 70)}" asserts "${String(f.clause || '').slice(0, 60)}" β€” this ship appears to remove it. If so, amend: ${f.marker} (retire by naming it in closes:, or dismiss via brain_connect relationship:"not_fulfilled")\n`);
1677
+ }
1678
+ }
1670
1679
  appendJsonl(LEDGER, { ts: nowIso(), mode: 'capture', stats, decisions: ledger }, 1000);
1671
1680
  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);
1672
1681
  }
@@ -1343,10 +1343,57 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
1343
1343
  }
1344
1344
  if (skills.length) {
1345
1345
  push('', '## πŸ› οΈ Skills β€” how we do things here (reusable; applies every session)');
1346
+ // πŸ› οΈβ†”πŸ staleness suffix (2026-08-01 incident): the brief is the ONE
1347
+ // surface every session reads, and skills never age out of it β€” so a
1348
+ // rule encoding a since-removed limitation lies here every morning
1349
+ // with a freshness badge next to it. Persisted 'may obsolete' edges
1350
+ // apply first (capture-time detection); the fresh scan is bounded to
1351
+ // the SHOWN clause-bearing skills Γ— milestones newer than the oldest
1352
+ // of them (milestone token sets built once, lazily β€” most briefs have
1353
+ // zero clause-bearing skills and pay nothing).
1354
+ const staleBySkill = new Map();
1355
+ try {
1356
+ const shownSkills = skills.slice(0, maxSkills);
1357
+ const persisted = obsolescenceOverlaysFor(struct, shownSkills);
1358
+ for (const [id, o] of persisted) staleBySkill.set(id, o.by);
1359
+ const clauseSkills = shownSkills
1360
+ .filter(c => !staleBySkill.has(c.id))
1361
+ .map(c => ({ c, claims: extractLimitationClaims(c.text) }))
1362
+ .filter(x => x.claims.length);
1363
+ if (clauseSkills.length) {
1364
+ const oldest = Math.min(...clauseSkills.map(x => x.c.createdAt || 0));
1365
+ const newerMiles = miles.filter(m => (m.createdAt || 0) > oldest);
1366
+ if (newerMiles.length) {
1367
+ const settled = new Set();
1368
+ for (const cn of struct.connections || []) {
1369
+ if (cn.label === 'may obsolete' || DISMISSAL_RELS.has(cn.relationship)) settled.add(`${cn.fromId}|${cn.toId}`);
1370
+ }
1371
+ const mPre = newerMiles.map(m => ({ m, idx: stemIndex(tokenSet(m.text)) }));
1372
+ let df = null;
1373
+ const dfMap = () => (df ??= buildStemDf(struct));
1374
+ for (const { c, claims } of clauseSkills) {
1375
+ let best = null, bestScore = 0;
1376
+ for (const { m, idx: mIdx } of mPre) {
1377
+ if ((m.createdAt || 0) <= (c.createdAt || 0) || settled.has(`${c.id}|${m.id}`)) continue;
1378
+ for (const cl of claims) {
1379
+ const clIdx = stemIndex(cl.tokens);
1380
+ const cov = coverageOf(new Set(clIdx.keys()), new Set(mIdx.keys()));
1381
+ const anchors = sharedAnchors(clIdx, mIdx, dfMap(), { exclude: structuralStems(c.area, m.area) });
1382
+ if (!serveTimeAccepts({ cov, anchors, size: clIdx.size }, (c.area || '') === (m.area || ''))) continue;
1383
+ const score = cov + anchors.length * 0.2;
1384
+ if (score > bestScore) { bestScore = score; best = m; }
1385
+ }
1386
+ }
1387
+ if (best) staleBySkill.set(c.id, flat(best.text).slice(0, 70));
1388
+ }
1389
+ }
1390
+ }
1391
+ } catch { /* staleness suffix is best-effort β€” never break the brief */ }
1346
1392
  let shown = 0;
1347
1393
  for (const c of skills.slice(0, maxSkills)) {
1348
1394
  if (used > BUDGET_CHARS * 0.85) break;
1349
- push(`- ${fr(c)}${flat(c.text)}`);
1395
+ const ob = staleBySkill.get(c.id);
1396
+ push(`- ${fr(c)}${flat(c.text)}${ob ? ` ⚠️ a newer 🏁 may have removed this limitation (β€œ${ob}…”) β€” verify before applying; ~ amend, closes:<its title> to retire, or dismiss via brain_connect not_fulfilled` : ''}`);
1350
1397
  shown++;
1351
1398
  }
1352
1399
  if (shown < skills.length) push(`- …and ${skills.length - shown} more skill(s) β€” search the brain.`);
@@ -1725,6 +1772,64 @@ export function rankForQuestion(struct, question, { semantic = null, k = 10, as_
1725
1772
  }
1726
1773
  } catch { /* pairing is a best-effort overlay β€” never fail the answer */ }
1727
1774
  }
1775
+ // SERVE-TIME πŸ› οΈβ†”πŸ OBSOLESCENCE (2026-08-01 incident): the ❓ pass above
1776
+ // cannot see the class where a SKILL encodes a since-removed limitation β€”
1777
+ // skills are excluded from claim sources by design, get a +1 ranking boost,
1778
+ // and never age out, so "Chat has no tools" kept outranking the same-day
1779
+ // milestone that shipped chat tools, and an agent asserted the limitation
1780
+ // to the founder as current fact. Persisted 'may obsolete' edges apply
1781
+ // first; then the same in-answer pairing discipline as the ❓ pass, but
1782
+ // coverage runs against the skill's LIMITATION CLAUSES (a skill is long;
1783
+ // its whole text would drown the claim). Negative capability claims are
1784
+ // the highest-risk kind β€” the fix that falsifies one is exactly the card
1785
+ // that will not match a question phrased around the limitation β€” so the
1786
+ // hedge renders even when the milestone barely made the hit set.
1787
+ if (!timeTravel) {
1788
+ try {
1789
+ const skillHits = hits.filter(h => !h.correction && !h.archived && isSkillCard(h.card));
1790
+ if (skillHits.length) {
1791
+ const persisted = obsolescenceOverlaysFor(struct, skillHits.map(h => h.card));
1792
+ for (const sh of skillHits) if (persisted.has(sh.card.id)) sh.obsolescence = persisted.get(sh.card.id);
1793
+ const fresh = skillHits.filter(h => !h.obsolescence);
1794
+ const mileHits = hits.filter(h => isMilestoneCard(h.card) && !/^archive$/i.test(h.card.area || ''));
1795
+ if (fresh.length && mileHits.length) {
1796
+ const settled = new Set();
1797
+ for (const cn of struct.connections || []) {
1798
+ if (cn.label === 'may obsolete' || DISMISSAL_RELS.has(cn.relationship)) settled.add(`${cn.fromId}|${cn.toId}`);
1799
+ }
1800
+ let df = null;
1801
+ const dfMap = () => (df ??= buildStemDf(struct));
1802
+ const mPre = mileHits.map(mh => ({ mh, idx: stemIndex(tokenSet(mh.card.text)) }));
1803
+ for (const sh of fresh) {
1804
+ const claims = extractLimitationClaims(sh.card.text);
1805
+ if (!claims.length) continue; // no state claim β†’ advice; never flagged
1806
+ let best = null, bestScore = 0, bestClause = null;
1807
+ for (const { mh, idx: mIdx } of mPre) {
1808
+ const s = sh.card, m = mh.card;
1809
+ if (m.id === s.id || (m.createdAt || 0) <= (s.createdAt || 0)) continue;
1810
+ if (settled.has(`${s.id}|${m.id}`)) continue;
1811
+ const sim = typeof pairSim === 'function' ? pairSim(s.id, m.id) : null;
1812
+ for (const cl of claims) {
1813
+ const clIdx = stemIndex(cl.tokens);
1814
+ const cov = coverageOf(new Set(clIdx.keys()), new Set(mIdx.keys()));
1815
+ const anchors = sharedAnchors(clIdx, mIdx, dfMap(), { exclude: structuralStems(s.area, m.area) });
1816
+ const lex = { cov, anchors, size: clIdx.size };
1817
+ const accept = (sim != null && sim >= 0.55 && lex.size >= SERVE_MIN_STEMS && (anchors.length >= 1 || cov >= 0.2))
1818
+ || serveTimeAccepts(lex, (s.area || '') === (m.area || ''));
1819
+ if (!accept) continue;
1820
+ const score = (sim ?? 0) + cov + anchors.length * 0.2;
1821
+ if (score > bestScore) { bestScore = score; best = m; bestClause = cl.clause; }
1822
+ }
1823
+ }
1824
+ if (best) {
1825
+ const head = String(best.text || '').replace(/\s+/g, ' ').trim().slice(0, 100);
1826
+ sh.obsolescence = { by: head, byId: best.id, clause: bestClause, unconfirmed: true };
1827
+ }
1828
+ }
1829
+ }
1830
+ }
1831
+ } catch { /* best-effort overlay β€” never fail the answer */ }
1832
+ }
1728
1833
  return { hits, total: scored.length, tokens, statusShaped, statusStrong };
1729
1834
  }
1730
1835
 
@@ -1761,6 +1866,12 @@ export function questionContextToMarkdown(question, result, { mode = 'lexical',
1761
1866
  block += h.fulfillment.unconfirmed
1762
1867
  ? `\n\n ⏳ POSSIBLY FULFILLED (serve-time hint β€” detected within this answer, no confirmed link): a newer milestone in this same answer may cover this open item: β€œ${flat(h.fulfillment.by).slice(0, 200)}”. VERIFY before treating it as still-to-do; confirm with a βœ“ marker if done, or dismiss via brain_connect relationship:"not_fulfilled".`
1763
1868
  : `\n\n ⏳ LIKELY FULFILLED β€” a newer milestone appears to cover this open item: β€œ${flat(h.fulfillment.by).slice(0, 200)}”. Verify before treating it as still-to-do; confirm with a βœ“ marker if done.`;
1869
+ } else if (h.obsolescence) {
1870
+ // A πŸ› οΈ rule asserting a since-removed limitation (2026-08-01
1871
+ // incident). Same hedging discipline as ⏳: a hint instructs the
1872
+ // reader to verify, never to inherit the machine's guess β€” but the
1873
+ // DIRECTION flips: for current capability, trust the NEWER card.
1874
+ block += `\n\n ⚠️ RULE MAY BE OBSOLETE${h.obsolescence.unconfirmed ? ' (serve-time hint β€” no confirmed link)' : ''}: this skill asserts a limitation${h.obsolescence.clause ? ` β€” β€œ${flat(h.obsolescence.clause).slice(0, 140)}”` : ''} that a newer milestone appears to REMOVE: β€œ${flat(h.obsolescence.by).slice(0, 200)}”. For current capability trust the newer card; VERIFY before citing this limitation as still true. If obsolete, amend the skill (~ marker), or retire it deliberately by NAMING it in a closes: (fuzzy βœ“/supersede never touch a πŸ› οΈ); if it still holds, dismiss via brain_connect relationship:"not_fulfilled".`;
1764
1875
  }
1765
1876
  if (used + block.length + 2 > budgetChars && shown > 0) { out.push(`\n_…and ${hits.length - shown} more matched card(s) omitted for length β€” narrow the question or use search for the rest._`); break; }
1766
1877
  out.push(block, '');
@@ -3040,7 +3151,7 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
3040
3151
  // corrections[] lists cross-area/low-bar supersedes driven by a correction
3041
3152
  // cue, so the surface (brain_note result / capture stderr) can say WHAT was
3042
3153
  // archived and how to undo β€” the confirmation channel for the widened match.
3043
- const stats = { added: 0, superseded: 0, resolved: 0, linked: 0, updated: 0, closed: 0, merged: 0, corrections: [], fulfillCandidates: [] };
3154
+ const stats = { added: 0, superseded: 0, resolved: 0, linked: 0, updated: 0, closed: 0, merged: 0, corrections: [], fulfillCandidates: [], skillStale: [] };
3044
3155
 
3045
3156
  // Pass 1 β€” resolutions + supersede marking operate on EXISTING cards.
3046
3157
  if (resolutions.length || cards.length || updates.length) {
@@ -3340,8 +3451,18 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
3340
3451
  // generic to trust beyond the strongest few.
3341
3452
  const matches = [];
3342
3453
  for (const c of liveTextCards()) {
3343
- if (/πŸ› /.test(c.text)) continue; // skills are standing reference β€” a closes: must never archive one (mirror the supersede guard)
3344
3454
  const ct = (c.title || '').trim().toLowerCase();
3455
+ // A πŸ› οΈ retires ONLY by being NAMED: exact/prefix match of its
3456
+ // glyph-and-area-stripped title. Never the contains path (titles
3457
+ // are derived from prose, so a skill that merely MENTIONS the
3458
+ // target span mid-sentence would be swept β€” review-B's exact
3459
+ // trap), and never token coverage (2026-08-01: naming is a
3460
+ // deliberate human act; overlap is not).
3461
+ if (/πŸ› /.test(c.text)) {
3462
+ const core = ct.replace(/^[^:\n]{1,40}:\s*/, '').replace(/^[πŸ› οΈβ“πŸŽ―πŸβœ…\s]+/u, '');
3463
+ if (core && wantTitle.length >= 6 && (core === wantTitle || core.startsWith(wantTitle) || wantTitle.startsWith(core))) matches.push({ c, cov: 1 });
3464
+ continue;
3465
+ }
3345
3466
  // Title fast-path: exact / prefix (β‰₯6 chars), or the card title
3346
3467
  // CONTAINS the target β€” the contains variant needs a LONGER target
3347
3468
  // (β‰₯10) because a short generic word ("sandbox") appears in many
@@ -3409,6 +3530,35 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
3409
3530
  });
3410
3531
  }
3411
3532
  }
3533
+
3534
+ // πŸ› οΈβ†”πŸ OBSOLESCENCE CROSS-CHECK (2026-08-01 incident) β€” the write-side
3535
+ // twin of the serve-time skill pass, mirroring T6 exactly one class
3536
+ // over: a new 🏁 is reconciled against live SKILLS' limitation clauses
3537
+ // ("chat has no tools") the way T6 reconciles it against open claims.
3538
+ // Output is a receipt (stats.skillStale, printed by the hook with a
3539
+ // ready-to-emit ~ amendment) plus a dashed amber 'may obsolete' edge
3540
+ // for coverage-grade pairs. NEVER archives β€” the supersede and closes:
3541
+ // passes deliberately refuse to retire a πŸ› οΈ, and this pass inherits
3542
+ // that: a rule leaves the brain by human hand or not at all.
3543
+ for (const card of cards) {
3544
+ if (!/🏁/.test(card.text) || card.__fromResolve) continue;
3545
+ const cands = findSkillObsolescenceCandidates(struct, [{ text: card.text, createdAt: now }], { maxPerMilestone: 2 });
3546
+ if (!cands.length) continue;
3547
+ card.__obsoletes = cands.filter(c => c.via !== 'anchor').map(c => c.skill.id);
3548
+ for (const c of cands) {
3549
+ stats.skillStale.push({
3550
+ skill: String(c.skill.text || '').replace(/\s+/g, ' ').slice(0, 90),
3551
+ area: c.skill.area || null,
3552
+ clause: c.clause,
3553
+ cov: c.cov,
3554
+ ...(c.via ? { via: c.via } : {}),
3555
+ // The suggested act is an AMENDMENT (~), not an archive: most
3556
+ // stale rules want the correction written into them β€” the
3557
+ // trap half often survives even when the limitation half died.
3558
+ marker: `🧠 BRAIN [${c.skill.area || 'Notes'}] ~: ${String(c.skill.title || c.skill.text).replace(/\s+/g, ' ').replace(/^πŸ› οΈ?\s*/, '').slice(0, 60)} β€” CORRECTION: <what this ship changed>`,
3559
+ });
3560
+ }
3561
+ }
3412
3562
  stampBrainKind(manifest);
3413
3563
  work = await finalizeBrainZip(zip, canvas, manifest, now);
3414
3564
  }
@@ -3450,6 +3600,10 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
3450
3600
  // Dashed, muted hint β€” a suggestion must never render pixel-identical
3451
3601
  // to a confirmed 'closed by' verdict edge on the human's canvas.
3452
3602
  for (const cid of (card.__fulfills || [])) addConn(cid, created.id, 'likely closed by', undefined, { style: 'dashed', width: 1.5, color: 'rgba(16,185,129,0.55)' });
3603
+ // πŸ› οΈ staleness hints are AMBER, never the fulfilment emerald β€” "this
3604
+ // ship may retire that rule" and "this ship may close that task" must
3605
+ // stay visually distinct on the human's canvas.
3606
+ for (const sid of (card.__obsoletes || [])) addConn(sid, created.id, 'may obsolete', undefined, { style: 'dashed', width: 1.5, color: 'rgba(245,158,11,0.6)' });
3453
3607
  for (const link of (created.links || [])) {
3454
3608
  const want = String(link).trim().toLowerCase();
3455
3609
  if (!want) continue;
@@ -4047,6 +4201,149 @@ export function fulfillmentOverlaysFor(struct, cards) {
4047
4201
  return map;
4048
4202
  }
4049
4203
 
4204
+ // ── πŸ› οΈβ†”πŸ obsolescence (2026-08-01 field incident) ──────────────────────────
4205
+ // A πŸ› οΈ skill that encodes a TEMPORARY limitation is more dangerous stale than
4206
+ // an open ❓: skills are BUILT to resurface every session, never age out, and
4207
+ // carry a ranking boost β€” so when a later 🏁 removes the limitation, the wrong
4208
+ // half keeps outranking the fix for any question phrased around the limitation.
4209
+ // Field case: an agent told the founder "Chat can talk but not act" off skill
4210
+ // 32d1c2a ("Chat (Gemini Flash) has no tools…") while milestone 2440361 β€” SAME
4211
+ // DAY β€” had shipped native tool-use in chat. An open ❓ at least reads as
4212
+ // unsettled; a πŸ› οΈ reads as settled law. The β“β†”πŸ machinery cannot see this
4213
+ // class (skills are excluded from claim sources BY DESIGN, and must stay
4214
+ // excluded β€” a milestone must never archive advice), so this is its sibling:
4215
+ // detect and HEDGE, never retire. Retiring a rule stays a human act.
4216
+ //
4217
+ // The extractor is the precision gate. It matches STATE claims β€” sentences
4218
+ // asserting what the system currently does or lacks ("has no tools", "returns
4219
+ // mock streams", "not implemented") β€” and refuses IMPERATIVE advice ("never
4220
+ // set backgroundThrottling:false", "always dedup zKeys"), which is the
4221
+ // evergreen content skills exist for. A skill with no state claim can never be
4222
+ // flagged, so the failure mode of an over-eager cue is a missed hint, not a
4223
+ // slandered rule.
4224
+ const LIMITATION_CUE_RE = new RegExp([
4225
+ /\b(?:has|have|had) no \p{L}/u.source,
4226
+ /\bno \p{L}+ (?:yet|at all|anywhere)\b/u.source,
4227
+ /\bcan(?:not|['’]t) \p{L}/u.source,
4228
+ /\b(?:does|do)(?:es)?(?: not|n['’]t) (?:support|have|act|work|exist|persist|stream|fire|write|read|speak|run|apply|reach|see|know|check|enforce)\b/u.source,
4229
+ /\bnot (?:implemented|wired|supported|available|built|shipped|published|deployed|enforced|persisted|possible|reachable|exposed|functional)\b/u.source,
4230
+ /\breturns? (?:a )?(?:mock|stub|null|nothing)\b/u.source,
4231
+ /\bis (?:dead|unused|missing|absent|unreachable|unwired|talk[- ]?only)\b/u.source,
4232
+ /\bonly (?:works|runs|streams|answers|covers|supports|reads)\b/u.source,
4233
+ /\bstill (?:missing|absent|manual|unwired|blocked|mocked?)\b/u.source,
4234
+ ].join('|'), 'iu');
4235
+ // Imperative openers = advice, not state. Anchored to the CLAUSE start so
4236
+ // "Chat has no tools" (subject-first) passes while "never set X" is refused.
4237
+ const ADVICE_OPENER_RE = /^(?:never|always|do not|don['’]t|avoid|prefer|remember|must|treat|use|keep|before|when|if)\b/i;
4238
+ export function extractLimitationClaims(text) {
4239
+ // Same unwrap discipline as extractOpenClauses: stored hard-wraps would
4240
+ // otherwise truncate every sentence at the first wrap point. Same
4241
+ // version-literal-safe sentence split ('1.3.28' survives).
4242
+ const t = normalizeWrappedProse(String(text || ''));
4243
+ const out = [];
4244
+ for (const raw of t.split(/[!?]\s+|\.(?=\s|$)/)) {
4245
+ let clause = raw.replace(/^[\s\-–—‒·"β€œβ€'β€˜β€™()[\]]+/, '').trim();
4246
+ if (!clause || clause.length < 12) continue;
4247
+ if (ADVICE_OPENER_RE.test(clause)) continue;
4248
+ if (!LIMITATION_CUE_RE.test(clause)) continue;
4249
+ // CONTRAST CUT: the limitation is the pre-contrast span β€” "Chat has no
4250
+ // tools, but the system prompt claimed…" claims only "Chat has no
4251
+ // tools"; the tail is elaboration whose tokens drown the coverage
4252
+ // denominator (measured on the incident card: whole-sentence cov 0.31
4253
+ // β†’ cut cov 0.75). Keep the cut only if it still carries the cue.
4254
+ const cut = clause.split(/,?\s+(?:but|however|yet|whereas|--+|β€”)\s+/i)[0].trim();
4255
+ if (cut.length >= 12 && LIMITATION_CUE_RE.test(cut) && claimTokens(cut).size >= 3) clause = cut;
4256
+ const tokens = claimTokens(clause);
4257
+ if (tokens.size < 3) continue; // tiny claims match everything
4258
+ out.push({ clause: clause.slice(0, 160), tokens });
4259
+ if (out.length >= 4) break; // a skill is one lesson, not a spec sheet
4260
+ }
4261
+ return out;
4262
+ }
4263
+
4264
+ // One-sided scan: which live πŸ› οΈ limitation claims do these milestones appear to
4265
+ // REMOVE? Mirrors findFulfillmentCandidates (bounded, receipt-bearing, archives
4266
+ // nothing): a milestone must post-date the skill, acceptance is clause coverage
4267
+ // OR rare-anchor corroboration, dismissals (`not_fulfilled`) and existing hint
4268
+ // edges are never re-suggested, and one generic ship is capped so it can never
4269
+ // spray edges across the rulebook.
4270
+ export function findSkillObsolescenceCandidates(struct, milestones, { coverAt = 0.6, maxPerMilestone = 3 } = {}) {
4271
+ if (!struct || !Array.isArray(struct.cards) || !Array.isArray(milestones) || !milestones.length) return [];
4272
+ const isArchived = (c) => /^archive$/i.test(c.area || '');
4273
+ const liveSkills = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim()
4274
+ && !isArchived(c) && isSkillCard(c) && !/↩|‡/.test(c.text));
4275
+ if (!liveSkills.length) return [];
4276
+ const settled = new Set();
4277
+ for (const cn of struct.connections || []) {
4278
+ if (cn.label === 'may obsolete' || DISMISSAL_RELS.has(cn.relationship)) settled.add(`${cn.fromId}|${cn.toId}`);
4279
+ }
4280
+ const mPre = milestones.map(m => ({ m, idx: stemIndex(tokenSet(m.text)) }));
4281
+ let df = null;
4282
+ const dfMap = () => (df ??= buildStemDf(struct));
4283
+ const out = [];
4284
+ for (const s of liveSkills) {
4285
+ const claims = extractLimitationClaims(s.text);
4286
+ for (const cl of claims) {
4287
+ const clIdx = stemIndex(cl.tokens);
4288
+ const clStems = new Set(clIdx.keys());
4289
+ for (const { m, idx: mIdx } of mPre) {
4290
+ if (m.id && m.id === s.id) continue;
4291
+ if (m.id && settled.has(`${s.id}|${m.id}`)) continue;
4292
+ if (Number.isFinite(m.createdAt) && m.createdAt <= (s.createdAt || 0)) continue; // a rule written AFTER the ship is post-fix knowledge, not stale
4293
+ const cov = coverageOf(clStems, new Set(mIdx.keys()));
4294
+ let viaAnchor = false;
4295
+ if (cov < coverAt) {
4296
+ const anchors = sharedAnchors(clIdx, mIdx, dfMap(), { exclude: structuralStems(s.area, m.area) });
4297
+ if (!anchorsSufficient(anchors, (s.area || '') === (m.area || ''), cov)) continue;
4298
+ viaAnchor = true;
4299
+ }
4300
+ out.push({ skill: s, clause: cl.clause, milestone: m, cov: Math.round(cov * 100) / 100, ...(viaAnchor ? { via: 'anchor' } : {}) });
4301
+ }
4302
+ }
4303
+ }
4304
+ // Best candidate per (skill, milestone) pair, then per-milestone cap.
4305
+ const byPair = new Map();
4306
+ for (const c of out) {
4307
+ const key = `${c.skill.id}|${c.milestone.id || milestones.indexOf(c.milestone)}`;
4308
+ if (!byPair.has(key) || byPair.get(key).cov < c.cov) byPair.set(key, c);
4309
+ }
4310
+ const byMile = new Map();
4311
+ for (const c of byPair.values()) {
4312
+ const k = c.milestone.id || String(milestones.indexOf(c.milestone));
4313
+ if (!byMile.has(k)) byMile.set(k, []);
4314
+ byMile.get(k).push(c);
4315
+ }
4316
+ const capped = [];
4317
+ for (const list of byMile.values()) {
4318
+ list.sort((a, b) => b.cov - a.cov);
4319
+ capped.push(...list.slice(0, maxPerMilestone));
4320
+ }
4321
+ return capped;
4322
+ }
4323
+
4324
+ // Persisted-edge overlay reader for 'may obsolete' hints β€” the πŸ› οΈ twin of
4325
+ // fulfillmentOverlaysFor, same rules: dismissals win, a since-archived
4326
+ // milestone no longer vouches, machine-written hints stay hedged forever.
4327
+ export function obsolescenceOverlaysFor(struct, cards) {
4328
+ const map = new Map();
4329
+ if (!struct || !Array.isArray(struct.connections)) return map;
4330
+ const byId = new Map(struct.cards.map(c => [c.id, c]));
4331
+ const want = new Set((cards || []).map(c => c.id));
4332
+ const dismissed = new Set();
4333
+ for (const cn of struct.connections) {
4334
+ if (DISMISSAL_RELS.has(cn.relationship)) { dismissed.add(`${cn.fromId}|${cn.toId}`); dismissed.add(`${cn.toId}|${cn.fromId}`); }
4335
+ }
4336
+ for (const cn of struct.connections) {
4337
+ if (cn.label !== 'may obsolete' || !want.has(cn.fromId)) continue;
4338
+ if (dismissed.has(`${cn.fromId}|${cn.toId}`)) continue;
4339
+ const m = byId.get(cn.toId);
4340
+ if (!m || /^archive$/i.test(m.area || '') || /↩|‡/.test(m.text || '')) continue;
4341
+ const head = String(m.text || '').replace(/\s+/g, ' ').trim().slice(0, 100);
4342
+ if (!map.has(cn.fromId)) map.set(cn.fromId, { by: head, byId: m.id, unconfirmed: cn.hintVia !== 'human' });
4343
+ }
4344
+ return map;
4345
+ }
4346
+
4050
4347
  // Corpse-rate (T10): the release-gated staleness metric. Fixtures are mined
4051
4348
  // from the brain's OWN supersede/close edges: for each archived card with a
4052
4349
  // live successor, build the ambiguous shared-subject question both could