klypix-mcp 1.78.0 → 1.80.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.
@@ -632,9 +632,9 @@ server.registerTool('brain_reconcile', {
632
632
  inputSchema: {
633
633
  canvas: z.string().optional().describe('Brain canvas filename/path. Defaults to the project brain ("brain").'),
634
634
  root: z.string().optional().describe("Project root holding the migrations dir (default: the brain file's folder)."),
635
- mode: z.enum(['all', 'contradictions', 'migrations', 'legacy', 'claims']).optional().describe('Which pass to run (default "all"): contradictions · migrations · legacy (pre-v1.15 raw-bash ship cards to tidy) · claims (open "remaining:/next:" clauses a later milestone likely fulfilled — receipts + ✓ markers, never auto-archived).'),
635
+ mode: z.enum(['all', 'contradictions', 'migrations', 'legacy', 'claims', 'plans']).optional().describe('Which pass to run (default "all"): contradictions · migrations · legacy (pre-v1.15 raw-bash ship cards to tidy) · claims (open "remaining:/next:" clauses a later milestone likely fulfilled — receipts + ✓ markers, never auto-archived) · plans (plan / proposal / "design decided" cards a LATER 🏁 appears to have built — embedding-first because the ship is usually renamed; receipts + ✓ markers, never auto-archived).'),
636
636
  },
637
- }, async ({ canvas, root, mode }) => toContent(await opBrainReconcile({ vault: mcpPresence.vault, canvas: boundBrainCanvas(canvas), root, mode })));
637
+ }, async ({ canvas, root, mode }) => toContent(await opBrainReconcile({ vault: mcpPresence.vault, canvas: boundBrainCanvas(canvas), root, mode, log })));
638
638
 
639
639
  server.registerTool('brain_garden', {
640
640
  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.78.0",
3
+ "version": "1.80.0",
4
4
  "description": "Active state management for multi-agent coding: a shared, versioned project brain over MCP.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -83,7 +83,7 @@
83
83
  "bench": "node bin/klypix-mcp.mjs bench",
84
84
  "test:bench": "node test/bench.mjs",
85
85
  "pretest": "node test/publish-workflow.mjs",
86
- "test": "node test/publish-verdict.mjs && node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/install-version.mjs && node test/install-rename-backoff.mjs && node test/project-binding-rebind.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/capture-gap.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/orphan-gardener.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/retrieval-fusion.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-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/enrichment.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/undeclared-active.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/release-lease.mjs && node test/release-ancestry.mjs && node test/release-claim-join.mjs && node test/release-claims.mjs && node test/release-handshake.mjs && node test/completion-guard.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/one-command-setup.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
86
+ "test": "node test/publish-verdict.mjs && node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/install-version.mjs && node test/install-rename-backoff.mjs && node test/project-binding-rebind.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/capture-gap.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/orphan-gardener.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/retrieval-fusion.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/plan-fulfillment.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/enrichment.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/undeclared-active.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/release-lease.mjs && node test/release-ancestry.mjs && node test/release-claim-join.mjs && node test/release-claims.mjs && node test/release-handshake.mjs && node test/completion-guard.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/one-command-setup.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
87
87
  "test:memory": "node test/memory-runtime.mjs",
88
88
  "test:memory:soak": "node --expose-gc test/memory-soak.mjs",
89
89
  "runtime": "node bin/klypix-runtime.mjs"
@@ -141,10 +141,20 @@ function readCachedVecs(brainPath, cards) {
141
141
  } catch { /* try next variant */ }
142
142
  }
143
143
  const map = new Map();
144
- if (cache && cache.cards) for (const c of cards) { const e = cache.cards[c.id]; if (e && e.v) map.set(c.id, e.v); }
144
+ // A vector is accepted only for the text it was embedded from (the cache
145
+ // stores sha1(text) as `h`) — an edited card must fall back to lexical, never
146
+ // pair on a stale embedding (parity with semantic-memory.cachedVectorsForBrain).
147
+ if (cache && cache.cards) for (const c of cards) { const e = cache.cards[c.id]; if (e && e.v && (!e.h || e.h === sha1(String(c.text)))) map.set(c.id, e.v); }
145
148
  return map;
146
149
  }
147
150
 
151
+ // Card vectors ONLY, from the warm cache — for card↔card pairing (plan ↔ 🏁)
152
+ // that needs no query embedding and therefore no model at all. Same read-only
153
+ // contract: never embeds, never writes, never throws; empty Map when no cache.
154
+ export function cachedCardVecs(brainPath, cards) {
155
+ try { return readCachedVecs(brainPath, Array.isArray(cards) ? cards : []); } catch { return new Map(); }
156
+ }
157
+
148
158
  // Entry point: embed the QUERY (needs the model) + read cached card vectors.
149
159
  // Returns { qv, vecsMap, dot } or null (not installed / timeout / any failure →
150
160
  // the hook keeps its exact lexical behavior). NEVER throws, NEVER embeds cards.
@@ -3399,6 +3399,21 @@ async function promptRetrieve(lib) {
3399
3399
  try { mergeOv = lib.mergeOverlaysFor(struct, freshHits.map(h => h.card)); } catch { /* best-effort */ }
3400
3400
  }
3401
3401
  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.` : ''; };
3402
+ // Plan→🏁 decay (2026-08-23 AgentLit incident): a recalled PLAN/PROPOSAL
3403
+ // card whose feature a newer 🏁 appears to have shipped gets a hedged
3404
+ // POSSIBLY BUILT line. Brain-wide — the ship card is usually RENAMED, so
3405
+ // it is rarely in this hit set — embedding-first from the READ-ONLY warm
3406
+ // vector cache (cards only; this one-shot process never loads the model),
3407
+ // strict lexical bars without it. Paid only when a plan-shaped card is a
3408
+ // hit. Never retires anything. Version-skew guarded like every lib call.
3409
+ let planOv = new Map();
3410
+ if (struct && typeof lib.planFulfillmentFor === 'function' && typeof lib.isPlanCard === 'function') {
3411
+ try {
3412
+ const planCards = freshHits.map(h => h.card).filter(c => lib.isPlanCard(c));
3413
+ if (planCards.length) planOv = lib.planFulfillmentFor(struct, planCards, { pairSim: await cachedPairSimFor(struct), scope: 'brain' });
3414
+ } catch { planOv = new Map(); }
3415
+ }
3416
+ const planTag = (id) => { const p = planOv.get(id); return p ? `\n ↳ ⏳ POSSIBLY BUILT — this card reads as a plan/proposal, but a newer 🏁 appears to have shipped it: “${head({ text: p.by }, 110)}”. Do NOT report it as "only a proposal" or still-to-do without checking the repo; if built, confirm with a ✓ marker (or \`closes:\` on the milestone); if not, dismiss via brain_connect pairs:[{fromId:"${id}", toId:"${p.byId}"}] relationship:"not_fulfilled".` : ''; };
3402
3417
  // Per-session injection dedup: a card already shown full-text this session
3403
3418
  // renders as one headline, not another ~600 words of context. LARGE cards
3404
3419
  // (>1KB) are tracked in a separate, deep-capped ledger so the 100-entry
@@ -3439,7 +3454,7 @@ async function promptRetrieve(lib) {
3439
3454
  continue;
3440
3455
  }
3441
3456
  if (wasInjected(h.card)) {
3442
- lines.push(`- (already shown this session) ${head(h.card, 110)}${mergeTag(h.card.id)}`);
3457
+ lines.push(`- (already shown this session) ${head(h.card, 110)}${mergeTag(h.card.id)}${planTag(h.card.id)}`);
3443
3458
  shownNow.add(h.card.id);
3444
3459
  continue;
3445
3460
  }
@@ -3452,7 +3467,7 @@ async function promptRetrieve(lib) {
3452
3467
  // reads this as "uncapped" and overstates it. Clipping here also
3453
3468
  // breaks the contract F5a asserts — first sight must be complete, or
3454
3469
  // the one chance to deliver a long decision intact is lost.
3455
- lines.push(`- ${flat(h.card.text)}${mergeTag(h.card.id)}`);
3470
+ lines.push(`- ${flat(h.card.text)}${mergeTag(h.card.id)}${planTag(h.card.id)}`);
3456
3471
  shownNow.add(h.card.id);
3457
3472
  noteInjected(h.card);
3458
3473
  }
@@ -3710,19 +3725,51 @@ function doctorFooter() {
3710
3725
  // surfaced so the agent CLOSES them (✓ / closes:) instead of recall surfacing
3711
3726
  // already-done goals as "next". Never auto-archives (precision-first; the human
3712
3727
  // confirms). Version-skew guarded like the migration footer.
3713
- function staleOpenFooter(lib, struct) {
3728
+ // Card↔card similarity from the READ-ONLY warm vector cache the MCP host fills
3729
+ // (never loads the model, never embeds, never writes): lets the one-shot hook
3730
+ // pair a plan card with its RENAMED ship ("Capability Forge proposal" ↔ "🏁
3731
+ // Capability builder shipped" — cosine 0.81, lexical coverage 0.20). Null when
3732
+ // there is no cache → the engine's strict lexical bars apply. One cache read per
3733
+ // process; the read is skipped entirely when nothing plan-shaped needs it.
3734
+ let _pairSim;
3735
+ async function cachedPairSimFor(struct) {
3736
+ if (_pairSim !== undefined) return _pairSim;
3737
+ _pairSim = null;
3714
3738
  try {
3715
- if (typeof lib.findStaleOpenCards !== 'function') return ''; // version-skew guard (stale live klypix-format)
3716
- const { gaps, total } = lib.findStaleOpenCards(struct, { max: 5 });
3717
- if (!gaps || !gaps.length) return '';
3739
+ const semlib = await import(new URL('./brain-semantic.mjs', import.meta.url).href);
3740
+ if (typeof semlib.cachedCardVecs !== 'function') return null;
3741
+ const vecs = semlib.cachedCardVecs(BRAIN, (struct && struct.cards) || []);
3742
+ if (!vecs || !vecs.size) return null;
3743
+ _pairSim = (a, b) => { const va = vecs.get(a), vb = vecs.get(b); return va && vb ? semlib.dot(va, vb) : null; };
3744
+ } catch { _pairSim = null; }
3745
+ return _pairSim;
3746
+ }
3747
+ function staleOpenFooter(stale) {
3748
+ try {
3749
+ if (!stale) return '';
3750
+ const { gaps = [], total = 0, plans = [], plansTotal = 0 } = stale;
3718
3751
  const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
3719
- const lines = ['', '---',
3720
- `## 🔧 Self-heal ${total} open card(s) look DONE (a later milestone covers them)`,
3721
- `These ❓/🎯 cards still read as open, but a shipped 🏁 milestone appears to fulfil them — so recall keeps surfacing already-done goals as "next". Confirm + close each:`,
3722
- done `🧠 BRAIN [Area] ✓: <what it resolved to>` stamps ✅ + archives the open card (or add `closes: <its title>` to the milestone marker).',
3723
- ];
3724
- for (const g of gaps) lines.push(`- ⚠️ [${flat(g.open.area) || '?'}] ${flat(g.open.text).slice(0, 90)} · likely closed by ${flat(g.by.text).slice(0, 70)}`);
3725
- return '\n' + lines.join('\n') + '\n';
3752
+ const lines = [];
3753
+ if (gaps && gaps.length) {
3754
+ lines.push('', '---',
3755
+ `## 🔧 Self-heal${total} open card(s) look DONE (a later milestone covers them)`,
3756
+ `These ❓/🎯 cards still read as open, but a shipped 🏁 milestone appears to fulfil them — so recall keeps surfacing already-done goals as "next". Confirm + close each:`,
3757
+ done `🧠 BRAIN [Area] ✓: <what it resolved to>` — stamps ✅ + archives the open card (or add `closes: <its title>` to the milestone marker).');
3758
+ for (const g of gaps) lines.push(`- ⚠️ [${flat(g.open.area) || '?'}] ${flat(g.open.text).slice(0, 90)} · likely closed by → ${flat(g.by.text).slice(0, 70)}`);
3759
+ }
3760
+ // Plan-shaped cards (2026-08-23): the same leak for proposals that never
3761
+ // carried a ❓ — recall serves them as current intent ("only a proposal")
3762
+ // while the feature is live. Hedged: verify, then ✓ or dismiss (the
3763
+ // dismissal names the exact pair — the engine honours it either way).
3764
+ if (plans && plans.length) {
3765
+ lines.push('', '---',
3766
+ `## 🔧 Self-heal — ${plansTotal} plan/proposal card(s) look BUILT (a later 🏁 appears to ship them)`,
3767
+ `These cards read as plans, so recall keeps serving them as current intent — but a shipped 🏁 appears to cover each (the ship is often RENAMED, which is why no link exists). Verify against the repo, then:`,
3768
+ '· built → `🧠 BRAIN [Area] ✓: <what shipped>` — archives the plan as fulfilled history (still retrievable, flagged), or add `closes: <its title>` to the milestone marker.',
3769
+ '· not built → `brain_connect` with the pairs shown and relationship:"not_fulfilled" — dismissed for good.');
3770
+ for (const p of plans) lines.push(`- ⏳ [${flat(p.open.area) || '?'}] ${flat(p.open.text).slice(0, 90)} · likely built by → ${flat(p.by.text).slice(0, 70)}${p.sim != null ? ` (sim ${p.sim})` : ''} · dismiss: pairs:[{fromId:"${p.open.id}", toId:"${p.by.id}"}]`);
3771
+ }
3772
+ return lines.length ? '\n' + lines.join('\n') + '\n' : '';
3726
3773
  } catch { return ''; }
3727
3774
  }
3728
3775
 
@@ -3870,11 +3917,21 @@ async function read(lib) {
3870
3917
  })();
3871
3918
  const { struct } = await lib.parseKlypix(fs.readFileSync(BRAIN));
3872
3919
  const { freshness, drifted } = computeFreshness(struct);
3920
+ // Plan↔🏁 self-heal (2026-08-23): card↔card similarity from the warm vector
3921
+ // cache (read-only, no model) lets the self-heal pair plan cards with
3922
+ // their RENAMED ships. Read ONLY when a plan-shaped card exists (review:
3923
+ // an unconditional read cost every SessionStart ~150ms for nothing), and
3924
+ // the stale-open/plan report is computed ONCE — shared by the brief's
3925
+ // footer and the preview's heal line.
3926
+ const hasPlans = typeof lib.isPlanCard === 'function' && (struct.cards || []).some(c => lib.isPlanCard(c));
3927
+ const pairSim = hasPlans ? await cachedPairSimFor(struct) : null;
3928
+ let stale = null;
3929
+ try { if (typeof lib.findStaleOpenCards === 'function') stale = lib.findStaleOpenCards(struct, { max: 5, pairSim }); } catch { stale = null; }
3873
3930
  // The FULL brief: tiered brief + every self-heal/health footer. Messages are
3874
3931
  // deliberately NOT part of it: messageFooter advances durable offer/ack state
3875
3932
  // and must only go to stdout where the receiving model can see the exact token.
3876
3933
  const full = ((typeof lib.structToBrief === 'function') ? lib.structToBrief(struct, { freshness }) : lib.structToMarkdown(struct))
3877
- + inflightFooter(input.session_id, struct) + selfHealFooter(drifted) + reconcileFooter(lib, struct) + staleOpenFooter(lib, struct)
3934
+ + inflightFooter(input.session_id, struct) + selfHealFooter(drifted) + reconcileFooter(lib, struct) + staleOpenFooter(stale)
3878
3935
  + ruleDraftsFooter(input.session_id, struct, { markShown: false })
3879
3936
  + receiptLine + selfCheckFooter() + doctorFooter() + versionCurrencyFooter() + legendFooter() + memoryFooter();
3880
3937
  const emitFull = () => {
@@ -3909,7 +3966,10 @@ async function read(lib) {
3909
3966
  if (files.length) { const { total } = lib.findUnrecordedMigrations(struct, files, { max: 6 }); if (total) heals.push(`${total} unrecorded migration(s)`); }
3910
3967
  }
3911
3968
  } catch { /* */ }
3912
- 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 { /* */ }
3969
+ if (stale) {
3970
+ if (stale.total) heals.push(`${stale.total} open card(s) look already done`);
3971
+ if (stale.plansTotal) heals.push(`${stale.plansTotal} plan/proposal card(s) look BUILT`);
3972
+ }
3913
3973
  const healLine = heals.length ? `\n🔧 Self-heal: ${heals.join(' · ')} — detail + fix markers in ${briefRel}.` : '';
3914
3974
  // Rule-draft nudge (capture-coverage): a one-line count in the preview; the full
3915
3975
  // promote-markers live in the brief file (read-only here — no shown-mark).
@@ -32,7 +32,8 @@ import {
32
32
  brainLensData, lensToMarkdown, deathDateOfCard,
33
33
  statusContextToMarkdown, findFulfillmentCandidates,
34
34
  splitQueryTokens, scoreCardsAgainstQuery, correctionOverlaysFor,
35
- isFastDecayCard, isUnresolvedOpenCard, DECAY_STALE_MS, formatDecayAge,
35
+ isFastDecayCard, isUnresolvedOpenCard, isSkillCard, DECAY_STALE_MS, formatDecayAge,
36
+ isPlanCard, planFulfillmentFor, PLAN_PAIR_SIM_BRAIN, isAgconfTwinId,
36
37
  readPendingShips, clearPendingShips, pendingShipCards, formatCaptureReceipts,
37
38
  } from './klypix-format.mjs';
38
39
  import { findProjectBrain, postPresenceMessage } from './agent-presence.mjs';
@@ -41,6 +42,7 @@ import {
41
42
  dot, embedTexts, getEmbedder, getEmbedderForUse, withRerankerForUse,
42
43
  rerankHits, semanticFallbackNotice, semanticMemorySnapshot,
43
44
  semanticRuntimeInstalled, shouldPrewarmSemantic, vectorsForBrain,
45
+ cachedVectorsForBrain,
44
46
  } from './semantic-memory.mjs';
45
47
 
46
48
  // The MCP and A2A faces prewarm through this protocol-neutral module. Bounded
@@ -443,6 +445,23 @@ export async function opBrainTaskContext({
443
445
  let overlays = new Map();
444
446
  try { overlays = correctionOverlaysFor(struct, hits.map((hit) => hit.card)); }
445
447
  catch { /* context remains useful without overlays */ }
448
+ // Plan→🏁 hints (2026-08-23): a recalled PLAN/PROPOSAL whose feature a newer
449
+ // 🏁 appears to have shipped. Brain-wide (the ship card is usually renamed
450
+ // and not in this hit set), embedding-first from the READ-ONLY warm vector
451
+ // cache — this fast path never loads the model — strict lexical bars when
452
+ // no cache exists. Only paid when a plan-shaped card is among the hits.
453
+ let planHints = new Map();
454
+ try {
455
+ const planCards = hits.map((hit) => hit.card).filter((card) => isPlanCard(card));
456
+ if (planCards.length) {
457
+ let pairSim = null;
458
+ try {
459
+ const vecs = cachedVectorsForBrain(t.file, struct.cards);
460
+ if (vecs && vecs.size) pairSim = (a, b) => { const va = vecs.get(a), vb = vecs.get(b); return va && vb ? dot(va, vb) : null; };
461
+ } catch { pairSim = null; }
462
+ planHints = planFulfillmentFor(struct, planCards, { pairSim, scope: 'brain' });
463
+ }
464
+ } catch { planHints = new Map(); }
446
465
 
447
466
  const flat = (value) => String(value || '').replace(/\s+/g, ' ').trim();
448
467
  const clip = (value, limit) => {
@@ -457,6 +476,7 @@ export async function opBrainTaskContext({
457
476
  const nowTs = Date.now();
458
477
  const entries = hits.map((hit) => {
459
478
  const correction = overlays.get(hit.card.id)?.by || null;
479
+ const plan = planHints.get(hit.card.id) || null;
460
480
  let decayAge = null;
461
481
  try {
462
482
  const ageMs = (hit.card.createdAt || 0) > 0 ? nowTs - hit.card.createdAt : 0;
@@ -470,12 +490,33 @@ export async function opBrainTaskContext({
470
490
  correctedBy: correction ? clip(correction.text, 420) : null,
471
491
  ...(recentOpenIds.has(hit.card.id) ? { recentOpen: true } : {}),
472
492
  ...(decayAge ? { lastKnown: true, age: decayAge } : {}),
493
+ ...(plan ? { possiblyBuilt: { by: clip(plan.by, 200), byId: plan.byId, ...(plan.sim != null ? { sim: plan.sim } : {}) } } : {}),
473
494
  };
474
495
  });
475
496
  const maxChars = Math.max(800, Math.min(5000, Number(budgetChars) || 2800));
497
+ // Standing rules (2026-08-24 audit): 🛠️ skills are "apply every session",
498
+ // but the ranker's `score <= 0` gate drops a zero-overlap rule BEFORE its +1
499
+ // skill boost applies — so the capsule delivered task hits and zero standing
500
+ // rules, and the rule that would have warned the founder about a same-day
501
+ // billing trap never reached any session. This block is UNCONDITIONAL:
502
+ // relevance-ordered when the ranker scored a rule, newest-first otherwise,
503
+ // deduped against the hit list, and prepended so the maxChars tail-cut can
504
+ // never be the reason a rule silently vanished.
505
+ const skillPool = struct.cards.filter((c) => c.type !== 'container'
506
+ && !/^archive$/i.test(c.area || '') && isSkillCard(c));
507
+ const scoreById = new Map(candidatePool.map((hit) => [hit.card.id, hit.score]));
508
+ const standing = skillPool
509
+ .filter((c) => !hitIds.has(c.id))
510
+ .sort((a, b) => (scoreById.get(b.id) || 0) - (scoreById.get(a.id) || 0)
511
+ || (b.createdAt || 0) - (a.createdAt || 0))
512
+ .slice(0, 3);
476
513
  const lines = [
477
514
  `## Compact task context (${entries.length} relevant brain card${entries.length === 1 ? '' : 's'} · lexical-fast)`,
478
515
  ];
516
+ if (standing.length) {
517
+ lines.push(`### 🛠️ Standing rules (${skillPool.length} in the brain — apply always; full set in the brief)`);
518
+ for (const c of standing) lines.push(`- [${flat(c.area) || 'Notes'}] ${clip(c.text, 220)}`);
519
+ }
479
520
  if (!entries.length) {
480
521
  lines.push('No high-confidence task-specific card matched. Continue from repository evidence; use brain_ask only if deeper project history is needed.');
481
522
  } else {
@@ -486,7 +527,10 @@ export async function opBrainTaskContext({
486
527
  // (v1.32.0 law — a claim may truncate, its warning may not).
487
528
  const stamp = entry.lastKnown ? ` ⏱️ LAST KNOWN (${entry.age} old — verify live before reporting):` : '';
488
529
  const openStamp = entry.recentOpen ? ' ❓ RECENT OPEN:' : '';
489
- lines.push(`- [${entry.area}]${entry.correctedBy ? ' superseded context:' : ''}${openStamp}${stamp} ${entry.text}`);
530
+ // Same prefix discipline as LAST KNOWN: the hint can never be the part a
531
+ // budget cut removes. It names the ship so the reader can verify it.
532
+ const planStamp = entry.possiblyBuilt ? ` ⏳ POSSIBLY BUILT (a newer 🏁 appears to ship this plan: “${entry.possiblyBuilt.by}” — verify before treating it as only a plan/proposal):` : '';
533
+ lines.push(`- [${entry.area}]${entry.correctedBy ? ' superseded context:' : ''}${openStamp}${stamp}${planStamp} ${entry.text}`);
490
534
  if (lines.join('\n').length >= maxChars) break;
491
535
  }
492
536
  lines.push('This is a bounded start-of-task capsule, not the whole brain; use brain_ask for broad status/history questions.');
@@ -497,6 +541,9 @@ export async function opBrainTaskContext({
497
541
  context: {
498
542
  mode: 'lexical-fast',
499
543
  hits: entries,
544
+ ...(standing.length ? {
545
+ standingRules: standing.map((c) => ({ id: c.id, area: flat(c.area) || 'Notes', text: clip(c.text, 220) })),
546
+ } : {}),
500
547
  sufficient: entries.length > 0,
501
548
  durationMs,
502
549
  brain: path.basename(t.file),
@@ -707,7 +754,7 @@ export function collectMigrationFiles(root) {
707
754
  }
708
755
  return out;
709
756
  }
710
- export async function opBrainReconcile({ vault, canvas, root, mode = 'all' }) {
757
+ export async function opBrainReconcile({ vault, canvas, root, mode = 'all', log = () => {} }) {
711
758
  const t = brainTarget(vault, canvas);
712
759
  if (t.ambiguous) return ambiguousBrainErr(t.ambiguous);
713
760
  if (!t.file) return err(`No brain found — looked for ./brain.klypix in the project, then ${vault}. Pass canvas: "<name>".`);
@@ -782,6 +829,78 @@ export async function opBrainReconcile({ vault, canvas, root, mode = 'all' }) {
782
829
  }
783
830
  }
784
831
 
832
+ // (1d) PLANS (2026-08-23, AgentLit incident) — plan / proposal / "design
833
+ // decided" cards that never carried a ❓, reconciled against LATER live 🏁
834
+ // milestones — embedding-first (the ship is usually RENAMED, so lexical
835
+ // coverage alone misses it: the incident pair measured cov 0.20, 0 anchors,
836
+ // cosine 0.81), strict lexical bars when no vectors exist. The retroactive
837
+ // plan-vs-shipped sweep: every hit is a suggestion with its receipt; the ✓
838
+ // is a human act and archives the plan as fulfilled HISTORY (still
839
+ // retrievable, flagged) — nothing is deleted or hidden.
840
+ // Mode 'all' (the default) stays READ-ONLY and side-effect free: cached
841
+ // vectors only — no model load, no cross-process cache lock, no cache write
842
+ // (review 2026-08-23). Only an explicit mode:"plans" may embed, with the
843
+ // bounded model load; a cold cache is reported as such, never as "no model".
844
+ if (mode === 'all' || mode === 'plans') {
845
+ try {
846
+ const isArchived = (c) => /^archive$/i.test(c.area || '');
847
+ const textCards = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim());
848
+ const planCards = textCards.filter(c => !isArchived(c) && isPlanCard(c));
849
+ if (!planCards.length) {
850
+ if (mode === 'plans') sections.push('✓ No plan-shaped cards (proposal / planned / design decided …) are live — nothing to reconcile against the ships.');
851
+ } else {
852
+ let vecs = null, embedErr = null;
853
+ try { vecs = cachedVectorsForBrain(file, struct.cards); } catch { vecs = null; }
854
+ const installed = (() => { try { return semanticRuntimeInstalled(); } catch { return false; } })();
855
+ const cold = !vecs || vecs.size < Math.ceil(0.5 * textCards.length);
856
+ if (mode === 'plans' && cold && installed) {
857
+ try {
858
+ const pipe = await getEmbedderForUse(log, 20_000);
859
+ if (pipe) vecs = await vectorsForBrain(pipe, file, struct.cards);
860
+ } catch (e) { embedErr = e; }
861
+ }
862
+ let pairSim = null, how;
863
+ if (vecs && vecs.size) {
864
+ pairSim = (a, b) => { const va = vecs.get(a), vb = vecs.get(b); return va && vb ? dot(va, vb) : null; };
865
+ how = `on-device embedding ≥ ${PLAN_PAIR_SIM_BRAIN} + lexical corroboration (${vecs.size} of ${textCards.length} cards vectorized), lexical strict bars for the rest`;
866
+ } else if (!installed) {
867
+ how = 'lexical strict bars (no on-device model installed — coverage ≥ 0.6 or rare shared anchors)';
868
+ } else if (mode === 'plans') {
869
+ how = `lexical strict bars (embedding unavailable${embedErr ? `: ${embedErr.code || embedErr.message}` : ' — model still warming or the inference queue is saturated; retry shortly'})`;
870
+ } else {
871
+ how = 'lexical strict bars (vector cache cold — run brain_ask once, or mode:"plans", to vectorize this brain)';
872
+ }
873
+ const hints = planFulfillmentFor(struct, planCards, { pairSim, scope: 'brain' });
874
+ if (!hints.size) {
875
+ if (mode === 'plans') sections.push(`✓ No plan/proposal card looks built by a later milestone (${planCards.length} plan-shaped card(s) checked · ${how}).`);
876
+ } else {
877
+ const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
878
+ const byId = new Map(struct.cards.map(c => [c.id, c]));
879
+ // Identical-text twins (pre-1.49 merge residue) collapse to one row,
880
+ // the original's id preferred — a brain awaiting its Arrange heal
881
+ // must not list the same plan twice.
882
+ const byText = new Map();
883
+ for (const [id, h] of hints) {
884
+ const plan = byId.get(id), by = byId.get(h.byId);
885
+ if (!plan || !by) continue;
886
+ const k = String(plan.text || '').toLowerCase().replace(/\s+/g, ' ').trim();
887
+ const prev = byText.get(k);
888
+ if (!prev || (isAgconfTwinId(prev.plan.id) && !isAgconfTwinId(plan.id))) byText.set(k, { plan, by, h });
889
+ }
890
+ const all = [...byText.values()].sort((a, b) => ((b.h.sim ?? 0) + b.h.cov) - ((a.h.sim ?? 0) + a.h.cov));
891
+ const rows = all.slice(0, 20);
892
+ const lines = rows.map((r, i) =>
893
+ `${i + 1}. [${r.plan.area || '?'}] (id ${r.plan.id}) PLAN: ${flat(r.plan.text).slice(0, 150)}\n`
894
+ + ` · looks BUILT by [${r.by.area || '?'}] (id ${r.by.id}) ${flat(r.by.text).slice(0, 150)}\n`
895
+ + ` · receipt: ${r.h.sim != null ? `sim ${r.h.sim} · ` : ''}coverage ${r.h.cov} · via ${r.h.via}\n`
896
+ + ` · if built: \`🧠 BRAIN [${r.plan.area || 'Notes'}] ✓: ${flat(r.plan.text).replace(/^[^:\n]{1,40}:\s*/, '').slice(0, 80)}\` · if not: brain_connect pairs:[{fromId:"${r.plan.id}", toId:"${r.by.id}"}] relationship:"not_fulfilled"`);
897
+ const more = all.length > rows.length ? `\n\n…and ${all.length - rows.length} more.` : '';
898
+ sections.push(`# 🧩 ${all.length} plan/proposal card(s) a later 🏁 appears to have BUILT\n_Suggestions with receipts (${how}) — nothing was changed. These cards read as plans, so recall keeps serving them as current intent ("it's only a proposal") while the feature is live. VERIFY each against the repo, then confirm with the ✓ marker (archives the plan as fulfilled history — it stays retrievable, flagged) or add \`closes: <plan title>\` to the milestone; dismiss a wrong pair permanently with the brain_connect call shown._\n\n${lines.join('\n')}${more}`);
899
+ }
900
+ }
901
+ } catch { /* best-effort section — the rest of reconcile stands */ }
902
+ }
903
+
785
904
  // (2) MIGRATIONS — the brain reconciled against committed external state.
786
905
  // Migrations live in the CODE repo (usually beside brain.klypix), not in a
787
906
  // separate canvas vault — so default the root to the brain file's folder.
@@ -1161,6 +1161,64 @@ export async function tidyBrain(buffer, opts = {}) {
1161
1161
  const normTitleKey = (t) => String(t || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
1162
1162
  const normTextKey = (t) => String(t || '').toLowerCase().replace(/\s+/g, ' ').trim();
1163
1163
  const connDupKey = (c) => `${c.fromId}|${c.toId}|${c.relationship || ''}|${c.label || ''}`;
1164
+ // Merge-engine conflict twins ("<id>__agconf_<rand>") whose text is IDENTICAL
1165
+ // to their original are serialization residue (the pre-1.49.0 byte-compare
1166
+ // merge), never a deliberate placement — so unlike ordinary same-text cards
1167
+ // they collapse ACROSS areas. The live twin of an archived/superseded original
1168
+ // is exactly the zombie that outranked its own CORRECTION with no overlay
1169
+ // (AgentLit, 2026-08-23: 37 such pairs; 518 twins in total). The original is
1170
+ // the survivor; the twin's edges re-point onto it. Text that DIFFERS (a real
1171
+ // two-sided edit) is untouched — that twin is a genuine conflict record.
1172
+ const AGCONF_TWIN_RE = /^(.+?)__agconf_[a-z0-9]+$/i;
1173
+ export const isAgconfTwinId = (id) => AGCONF_TWIN_RE.test(String(id || ''));
1174
+ // Engine-authored RETIREMENT stamps — the exact formats the lifecycle writers
1175
+ // put on a card: "↩︎ superseded <date>\n" / "⤵ consolidated <date>\n" prepended,
1176
+ // "\n✅ <date>: …" / "\n✔ partial <date>: …" appended (the ✅ text itself wraps
1177
+ // across lines, so the suffix runs to the end of the card; it must START a
1178
+ // line — a dated ✅ cited mid-sentence is prose, not a stamp). A twin born
1179
+ // BEFORE its original was retired carries the original's pre-retirement text
1180
+ // exactly — the AgentLit zombie was this: "Capability Forge proposal"
1181
+ // superseded on 2026-08-22, its live twin byte-identical minus the "↩︎
1182
+ // superseded" line. Comparing with the stamps stripped recognizes that pair;
1183
+ // the stamped copy is the survivor so the lifecycle record is never lost.
1184
+ // Measured on AgentLit (518 twins): 3 live cross-area zombies of this shape,
1185
+ // 34 same-area stamped twins beside their original in Archive, 481 plain
1186
+ // byte-identical twins the same-area rule already folded.
1187
+ const RETIRE_PREFIX_RE = /^(?:↩︎? superseded|⤵ consolidated) \d{4}-\d{2}-\d{2}[ \t]*\n?/u;
1188
+ const RETIRE_SUFFIX_RE = /\n(?:✅|✔ partial) \d{4}-\d{2}-\d{2}:[\s\S]*$/u;
1189
+ const stripRetirement = (t) => String(t || '').replace(RETIRE_PREFIX_RE, '').replace(RETIRE_SUFFIX_RE, '');
1190
+ const hasRetirementStamp = (t) => RETIRE_PREFIX_RE.test(String(t || '')) || RETIRE_SUFFIX_RE.test(String(t || ''));
1191
+ const retiredTextKey = (t) => normTextKey(stripRetirement(t));
1192
+ // The ROOT a twin folds onto: strip trailing __agconf_<rand> segments to the
1193
+ // deepest EXISTING ancestor. A nested twin (X__agconf_a__agconf_b) folds onto
1194
+ // X, never onto the intermediate twin — grouping by the immediate parent put
1195
+ // one card in two collapse groups and dropped a valid edge as "dangling"
1196
+ // (review 2026-08-23).
1197
+ const agconfRootOf = (id, byId) => {
1198
+ let cur = String(id || ''), root = null;
1199
+ for (let guard = 0; guard < 8; guard++) {
1200
+ const m = AGCONF_TWIN_RE.exec(cur);
1201
+ if (!m) break;
1202
+ cur = m[1];
1203
+ if (byId.has(cur)) root = cur;
1204
+ }
1205
+ return root;
1206
+ };
1207
+ function foldIdenticalTwins(cardGroups, items, byId, keyOf) {
1208
+ for (const c of items) {
1209
+ if (c.type !== 'text' || !isAgconfTwinId(c.id)) continue;
1210
+ const rootId = agconfRootOf(c.id, byId);
1211
+ const orig = rootId ? byId.get(rootId) : null;
1212
+ if (!orig || orig.type !== 'text' || !retiredTextKey(orig.text) || retiredTextKey(orig.text) !== retiredTextKey(c.text)) continue;
1213
+ const ok = keyOf(orig), tk = keyOf(c);
1214
+ if (!ok || !tk || ok === tk) continue; // same area → the plain same-text rule already groups them
1215
+ const rest = (cardGroups.get(tk) || []).filter(id => id !== c.id);
1216
+ if (rest.length) cardGroups.set(tk, rest); else cardGroups.delete(tk);
1217
+ if (!cardGroups.has(ok)) cardGroups.set(ok, [orig.id]);
1218
+ if (!cardGroups.get(ok).includes(orig.id)) cardGroups.get(ok).unshift(orig.id);
1219
+ if (!cardGroups.get(ok).includes(c.id)) cardGroups.get(ok).push(c.id);
1220
+ }
1221
+ }
1164
1222
 
1165
1223
  // Read-only layout report over a parsed brain: duplicate containers/cards,
1166
1224
  // overlapping boxes (same layer: roots together, siblings per container),
@@ -1181,14 +1239,14 @@ function layoutReportOf({ canvas, struct }) {
1181
1239
  const dupContainers = [...ctnGroups.entries()].filter(([, ids]) => ids.length > 1).map(([key, ids]) => ({ key, ids }));
1182
1240
 
1183
1241
  const cardGroups = new Map();
1242
+ const cardKeyOf = (c) => { const tk = normTextKey(c.text); if (!tk) return ''; const ak = c.parentId ? normTitleKey(byId.get(c.parentId)?.title) : ''; return ak + '|' + tk; };
1184
1243
  for (const c of items) {
1185
1244
  if (c.type !== 'text') continue;
1186
- const tk = normTextKey(c.text); if (!tk) continue;
1187
- const ak = c.parentId ? normTitleKey(byId.get(c.parentId)?.title) : '';
1188
- const k = ak + '|' + tk;
1245
+ const k = cardKeyOf(c); if (!k) continue;
1189
1246
  if (!cardGroups.has(k)) cardGroups.set(k, []);
1190
1247
  cardGroups.get(k).push(c.id);
1191
1248
  }
1249
+ foldIdenticalTwins(cardGroups, items, byId, cardKeyOf);
1192
1250
  const dupCards = [...cardGroups.values()].filter(ids => ids.length > 1).map(ids => ({ ids, text: labelOf(byId.get(ids[0])) }));
1193
1251
 
1194
1252
  // Overlaps within each layer (root layer + one layer per container).
@@ -1247,6 +1305,8 @@ export async function arrangeBrain(buffer, opts = {}) {
1247
1305
  const beforeReport = layoutReportOf(first);
1248
1306
  // Lossless baselines, snapshotted BEFORE any mutation.
1249
1307
  const beforeTexts = new Set(first.struct.cards.filter(c => c.type === 'text').map(c => normTextKey(c.text)).filter(Boolean));
1308
+ const beforeById = new Map(first.struct.cards.map(c => [c.id, c]));
1309
+ const beforeRetiredOf = new Map(first.struct.cards.filter(c => c.type === 'text').map(c => [normTextKey(c.text), retiredTextKey(c.text)]));
1250
1310
  const beforeTitles = new Set(first.struct.cards.filter(c => c.type === 'container').map(c => normTitleKey(c.title)).filter(Boolean));
1251
1311
  const stats = {
1252
1312
  before: { items: beforeReport.items, containers: beforeReport.containers, connections: beforeReport.connections, overlaps: beforeReport.overlaps.length, dupCardGroups: beforeReport.dupCards.length, dupContainerGroups: beforeReport.dupContainers.length },
@@ -1304,17 +1364,21 @@ export async function arrangeBrain(buffer, opts = {}) {
1304
1364
  // container → one survivor. Same text in DIFFERENT areas is kept — that
1305
1365
  // placement may be deliberate.
1306
1366
  const cardGroups = new Map();
1367
+ const dedupeKeyOf = (c) => { const tk = normTextKey(c.text); if (!tk) return ''; const parent = c.parentId ? (remap.get(c.parentId) || c.parentId) : ''; return parent + '|' + tk; };
1307
1368
  for (const c of struct.cards) {
1308
1369
  if (c.type !== 'text' || removed.has(c.id)) continue;
1309
- const tk = normTextKey(c.text); if (!tk) continue;
1310
- const parent = c.parentId ? (remap.get(c.parentId) || c.parentId) : '';
1311
- const k = parent + '|' + tk;
1370
+ const k = dedupeKeyOf(c); if (!k) continue;
1312
1371
  if (!cardGroups.has(k)) cardGroups.set(k, []);
1313
1372
  cardGroups.get(k).push(c.id);
1314
1373
  }
1374
+ // Identical __agconf twins fold onto their original across areas (see
1375
+ // foldIdenticalTwins); the original always survives the collapse.
1376
+ foldIdenticalTwins(cardGroups, struct.cards.filter(c => !removed.has(c.id)), byId, dedupeKeyOf);
1315
1377
  for (const ids of cardGroups.values()) {
1316
1378
  if (ids.length < 2) continue;
1317
- const survivor = pickSurvivor(ids);
1379
+ // Survivor: a copy carrying a retirement stamp (the lifecycle record)
1380
+ // beats a bare one; the original id beats a twin id.
1381
+ const survivor = pickSurvivor(ids, (id) => (hasRetirementStamp(byId.get(id)?.text) ? 2 : 0) + (isAgconfTwinId(id) ? 0 : 1));
1318
1382
  for (const loser of ids) { if (loser !== survivor) { remap.set(loser, survivor); removed.add(loser); } }
1319
1383
  stats.collapsedCards.push({ kept: survivor, removed: ids.filter(id => id !== survivor), text: String(byId.get(survivor)?.text || '').split('\n')[0].slice(0, 60) });
1320
1384
  }
@@ -1323,9 +1387,12 @@ export async function arrangeBrain(buffer, opts = {}) {
1323
1387
  // duplicates, self-loops born from the collapse, and (already-broken)
1324
1388
  // dangling edges. Everything else is preserved verbatim.
1325
1389
  const seen = new Set(); const conns = [];
1390
+ // Transitive: a loser may itself be the survivor another loser mapped to
1391
+ // (a nested twin chain) — follow the chain to the final survivor.
1392
+ const resolve = (id) => { let cur = id; for (let guard = 0; guard < 16 && remap.has(cur); guard++) cur = remap.get(cur); return cur; };
1326
1393
  for (const cn of (canvas.connections || [])) {
1327
- const fromId = remap.get(cn.fromId) || cn.fromId;
1328
- const toId = remap.get(cn.toId) || cn.toId;
1394
+ const fromId = resolve(cn.fromId);
1395
+ const toId = resolve(cn.toId);
1329
1396
  if (fromId !== cn.fromId || toId !== cn.toId) stats.connectionsRepointed++;
1330
1397
  if (!byId.has(fromId) || !byId.has(toId) || removed.has(fromId) || removed.has(toId)) { stats.danglingConnectionsDropped++; continue; }
1331
1398
  if (fromId === toId) { stats.selfLoopConnectionsDropped++; continue; }
@@ -1376,13 +1443,31 @@ export async function arrangeBrain(buffer, opts = {}) {
1376
1443
  if (afterReport.items < beforeReport.items - collapsedCount)
1377
1444
  throw new Error(`arrange lost items (${beforeReport.items} before − ${collapsedCount} collapsed > ${afterReport.items} after) — aborted, original untouched`);
1378
1445
  const afterTexts = new Set(after.struct.cards.filter(c => c.type === 'text').map(c => normTextKey(c.text)).filter(Boolean));
1379
- for (const t of beforeTexts) if (!afterTexts.has(t)) throw new Error(`arrange lost a unique card text ("${t.slice(0, 60)}…") — aborted, original untouched`);
1446
+ // A folded twin's text may survive only INSIDE its stamped original (the
1447
+ // pre-retirement text plus a "↩︎ superseded" / "✅ closed by" stamp) — that
1448
+ // is content preserved, not lost. The escape is tied to the ids THIS pass
1449
+ // logged as collapsed (review 2026-08-23: an open-ended retired-key
1450
+ // escape would have hidden any other loss that happened to alias a
1451
+ // stamped survivor). Everything else must still be verbatim.
1452
+ const removedIds = new Set(stats.collapsedCards.flatMap(g => g.removed));
1453
+ const removedTexts = new Set([...removedIds].map(id => beforeById.get(id)).filter(c => c && c.type === 'text').map(c => normTextKey(c.text)).filter(Boolean));
1454
+ const afterRetiredKeys = new Set(after.struct.cards.filter(c => c.type === 'text').map(c => retiredTextKey(c.text)).filter(Boolean));
1455
+ for (const t of beforeTexts) {
1456
+ if (afterTexts.has(t)) continue;
1457
+ if (removedTexts.has(t) && afterRetiredKeys.has(beforeRetiredOf.get(t))) continue;
1458
+ throw new Error(`arrange lost a unique card text ("${t.slice(0, 60)}…") — aborted, original untouched`);
1459
+ }
1380
1460
  const afterTitles = new Set(after.struct.cards.filter(c => c.type === 'container').map(c => normTitleKey(c.title)).filter(Boolean));
1381
1461
  for (const t of beforeTitles) if (!afterTitles.has(t)) throw new Error(`arrange lost an area container ("${t}") — aborted, original untouched`);
1382
1462
  if (beforeReport.focusPresent && !afterReport.focusPresent) throw new Error('arrange lost the 📌 Focus container — aborted, original untouched');
1383
1463
  if (afterReport.overlaps.length) throw new Error(`arrange left ${afterReport.overlaps.length} overlapping pair(s) (e.g. ${afterReport.overlaps[0].aLabel} × ${afterReport.overlaps[0].bLabel}) — aborted`);
1384
1464
  if (afterReport.outOfBounds.length) throw new Error(`arrange left ${afterReport.outOfBounds.length} card(s) outside their container — aborted`);
1385
1465
  if (afterReport.danglingConnections) throw new Error(`arrange produced ${afterReport.danglingConnections} dangling connection(s) — aborted`);
1466
+ // No edge may vanish except as a logged duplicate / self-loop or a
1467
+ // pre-existing dangling edge (review 2026-08-23: a nested twin's edges were
1468
+ // being dropped as "dangling" with nothing to notice).
1469
+ const minConnections = beforeReport.connections - stats.duplicateConnectionsDropped - stats.selfLoopConnectionsDropped - beforeReport.danglingConnections;
1470
+ if (afterReport.connections < minConnections) throw new Error(`arrange lost ${minConnections - afterReport.connections} connection(s) beyond the logged duplicates/self-loops — aborted, original untouched`);
1386
1471
  if (dedupe && (afterReport.dupCards.length || afterReport.dupContainers.length || afterReport.dupConnections))
1387
1472
  throw new Error(`arrange left duplicates behind (${afterReport.dupContainers.length} container group(s), ${afterReport.dupCards.length} card group(s), ${afterReport.dupConnections} twin edge(s)) — aborted`);
1388
1473
  stats.after = { items: afterReport.items, containers: afterReport.containers, connections: afterReport.connections, overlaps: 0 };
@@ -1436,6 +1521,48 @@ export const isMilestoneCard = (c) => lifecycleEligible(c) && !isSkillCard(c) &&
1436
1521
  // extra guard so a ✅/↩/⤵-stamped card is never reported as plainly still-open.
1437
1522
  export const isUnresolvedOpenCard = (c) => isOpenCard(c) && !RESOLVED_GLYPH.test(String(c?.text || ''));
1438
1523
 
1524
+ // ── Plan-shaped plain cards (2026-08-23 AgentLit incident) ──────────────────
1525
+ // A proposal / plan / "design decided" card written WITHOUT a ❓/🎯 glyph sits
1526
+ // outside every lifecycle mechanism above — no close-pass, no fulfillment
1527
+ // pairing, no self-heal, no ⏳ hint. So when the thing it describes ships under
1528
+ // a 🏁 (often RENAMED: "Capability Forge proposal" → "🏁 Capability builder
1529
+ // shipped", same day), the plan keeps rendering as current intent and an agent
1530
+ // answers "it's only a proposal" about a feature that is live. Measured before
1531
+ // this landed (with the shipped classifier, 2026-08-23): 25 such cards in the
1532
+ // KLYPIX brain, 10 distinct in AgentLit, most with a real later 🏁 (a wider
1533
+ // draft regex had counted 61/9). This classifier admits them to the SAME pairing machinery as
1534
+ // ❓ cards — as hedged HINTS, never as a close.
1535
+ // PRECISION-FIRST like every sibling: the HEADLINE (area prefix stripped,
1536
+ // wrap-normalized, first ~220 chars) must carry a future-work cue AND no ship
1537
+ // pin ("Phase 1 BUILT + verified" is an event, not a plan — "not built" / "not
1538
+ // yet built" stay plan cues via the negation lookbehind). Glyphed cards keep
1539
+ // their own lifecycle; correction-cue cards assert present truth, never plans.
1540
+ // Cue shapes (review 2026-08-23 tightened the bare nouns): "plan"/"proposal"
1541
+ // fire only as the card's own intent — not inside an identifier (users.plan),
1542
+ // a pipeline arrow (plan→act), a pricing tier (paid plan), a quotation, or a
1543
+ // negation ("don't plan to"). "to be built" is exempt from the ship pin, and
1544
+ // "Design:" may be followed by a space (both were dead alternatives before).
1545
+ const PLAN_NOUN_GUARD = String.raw`(?<![.\w\/_-])(?<!\b(?:don'?t|do not|no|never|not|paid|free|pricing|subscription|beta|pro|cloud|billing|current)\s)`;
1546
+ const PLAN_CUE_RE = new RegExp(String.raw`\b(?:${PLAN_NOUN_GUARD}(?:proposals?|proposed|proposes?|plan(?:ned|ning)?(?![→\/.\-]))|roadmap|next\s+steps?|to\s+be\s+(?:built|shipped|implemented|wired|added)|not\s+(?:yet\s+)?(?:built|designed|implemented|shipped|started|wired)|will\s+(?:build|ship|implement|wire|add|land)|will\s+be\s+(?:built|shipped|implemented|wired|added)|should\s+(?:be|go|default|use|become|move|live|ship|land)|design\b[^.\n]{0,30}?\b(?:decided|locked|approved|agreed|chosen)|(?:decided|approved|locked|agreed)\b[^.\n]{0,20}?\b(?:build|ship|implement|wire)|spec(?:ification)?\s+(?:written|drafted|locked))\b|\bdesign\s*:(?=\s|$)`, 'i');
1547
+ const PLAN_SHIP_PIN_RE = /(?<!\bnot\s)(?<!\bnot\s+yet\s)(?<!\bto\s+be\s)(?<!\bwill\s+be\s)\b(?:shipped|merged|published|released|deployed|landed|built|implemented|went\s+live|is\s+live|now\s+live)\b/i;
1548
+ // An UPPERCASE verdict that the plan is dead (the same deliberate-signal casing
1549
+ // as CORRECTION): a rejected proposal is history, never a plan awaiting a ship.
1550
+ const PLAN_DEAD_RE = /\b(?:REJECTED|DECLINED|DROPPED|ABANDONED|WITHDRAWN|CANCELLED|CANCELED)\b/;
1551
+ const planHeadline = (text) => normalizeWrappedProse(String(text || ''))
1552
+ .replace(/^[^:\n]{1,40}:\s*/, '')
1553
+ .slice(0, 220)
1554
+ .replace(/`[^`]*`/g, ' ') // code spans carry identifiers, not intent
1555
+ .replace(/["“”„][^"“”„]{0,120}["“”„]/g, ' '); // quoted strings are someone else's words
1556
+ export const isPlanCard = (c) => {
1557
+ if (!lifecycleEligible(c) || c?.type === 'container') return false;
1558
+ const t = String(c?.text || '');
1559
+ if (!t.trim() || STATE_GLYPH.test(t) || SKILL_GLYPH.test(t) || RESOLVED_GLYPH.test(t)) return false;
1560
+ if (hasCorrectionCue(t)) return false;
1561
+ const head = planHeadline(t);
1562
+ if (PLAN_DEAD_RE.test(head)) return false;
1563
+ return PLAN_CUE_RE.test(head) && !PLAN_SHIP_PIN_RE.test(head);
1564
+ };
1565
+
1439
1566
  // ── Per-area status digest (2026-07-23 field incident) ───────────────────────
1440
1567
  // ONE computed current-state line per ACTIVE area: newest 🏁 headline + open
1441
1568
  // count. This is the fact whose tier-eviction let a stale "remaining:" claim
@@ -1503,8 +1630,11 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
1503
1630
  // 🛠️ Skills — reusable how-tos / gotchas / procedures (the '+' marker).
1504
1631
  // Standing reference, NOT a point-in-time event: always shown, never
1505
1632
  // recency-decayed, and excluded from open/milestones/recent so a skill never
1506
- // masquerades as (or ages out like) a decision.
1507
- const skills = rest.filter(isSkillCard);
1633
+ // masquerades as (or ages out like) a decision. Newest first: the render
1634
+ // below can only show maxSkills of them, and an unsorted slice pinned the
1635
+ // OLDEST rules forever while every rule learned since reached no session
1636
+ // (2026-08-24 audit — the founder's same-day billing rule was invisible).
1637
+ const skills = rest.filter(isSkillCard).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
1508
1638
  const open = rest.filter(isOpenCard);
1509
1639
  const miles = rest.filter(isMilestoneCard);
1510
1640
  const plain = rest.filter(c => !open.includes(c) && !miles.includes(c) && !skills.includes(c));
@@ -1789,6 +1919,32 @@ export function structToUltraBrief(struct, { freshness = null, briefPath = '.cla
1789
1919
  // line that falls off the bottom of the preview-sized budget.
1790
1920
  const overdueById = findOverdueOpenCards(struct).byId;
1791
1921
  const openSorted = open.slice().sort((a, b) => (overdueById.has(b.id) ? 1 : 0) - (overdueById.has(a.id) ? 1 : 0));
1922
+ // 🛠️ Standing rules tier (2026-08-24): the ultra brief used to render
1923
+ // skills as a COUNT in the tail — so the one surface every session reads
1924
+ // carried zero of the rules that are supposed to "fire every session".
1925
+ // Newest first (the most recently learned trap is the likeliest live one),
1926
+ // BEFORE the open list: opens are greedy to the budget floor, so anything
1927
+ // placed after them never lands. Small cap — this is a reminder tier, the
1928
+ // full set stays in the brief file. BUDGET FENCE (adversarial review of
1929
+ // 10f43e1, reproduced live at the 1800-char SessionStart default): this
1930
+ // tier must never be the reason a ⏰ OVERDUE line fell off — price the
1931
+ // opens header, every overdue line, and the overflow line FIRST, and hold
1932
+ // that budget back from the skills tier. Rules yield to deadlines.
1933
+ if (skills.length) {
1934
+ const openHeader = `## Open questions & goals (${open.length}${overdueById.size ? `, ${overdueById.size} ⏰ overdue` : ''})`;
1935
+ const overdueLines = openSorted.filter(c => overdueById.has(c.id)).map(c => `- ⏰ OVERDUE ${fr(c)}${head(c)}`);
1936
+ const reserve = open.length
1937
+ ? ['', openHeader, ...overdueLines, `- …and ${open.length} more — in the full brief.`]
1938
+ .reduce((s, l) => s + l.length + 1, 0)
1939
+ : 0;
1940
+ const pushIfFenced = (l) => (used + l.length + 1 > budget - reserve) ? false : (push(l), true);
1941
+ if (pushIfFenced('') && pushIfFenced(`## 🛠️ Standing rules (${skills.length} — newest first, apply always)`)) {
1942
+ const newest = skills.slice().sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
1943
+ let shown = 0;
1944
+ for (const c of newest.slice(0, 5)) { if (!pushIfFenced(`- ${fr(c)}${head(c)}`)) break; shown++; }
1945
+ if (shown < skills.length) pushIfFenced(`- …and ${skills.length - shown} more standing rule(s) — in the full brief.`);
1946
+ }
1947
+ }
1792
1948
  if (open.length && pushIf('') && pushIf(`## Open questions & goals (${open.length}${overdueById.size ? `, ${overdueById.size} ⏰ overdue` : ''})`)) {
1793
1949
  let shown = 0;
1794
1950
  for (const c of openSorted) { if (!pushIf(`- ${overdueById.has(c.id) ? '⏰ OVERDUE ' : ''}${fr(c)}${head(c)}`)) break; shown++; }
@@ -2120,6 +2276,40 @@ export function rankForQuestion(struct, question, { semantic = null, k = 10, as_
2120
2276
  }
2121
2277
  }
2122
2278
  } catch { /* pairing is a best-effort overlay — never fail the answer */ }
2279
+ // PLAN↔🏁 (2026-08-23 AgentLit incident) — ONE scorer for plan cards.
2280
+ // In-answer first (planFulfillmentFor scope 'answer': the ❓ pass's bars
2281
+ // plus the order-independent near-tie → EARLIEST-ship rule), then the
2282
+ // strict brain-wide tier for plan hits still unpaired: a proposal's
2283
+ // ship is usually RENAMED ("Capability Forge proposal" → "🏁 Capability
2284
+ // builder shipped"), so for a question phrased in the plan's words it
2285
+ // is often not in this hit set at all. Then, when the pairing card IS
2286
+ // in the answer but ranks BELOW the plan it fulfilled — whether the
2287
+ // pairing came from these passes or from a persisted 'likely closed
2288
+ // by' edge (the renderer's own predicate; review 2026-08-23 found the
2289
+ // lift silently off once an edge existed) — it is lifted to directly
2290
+ // above it: the newest truth takes the slot, the plan stays (history),
2291
+ // and its hint names the ship. Hedged; never retires anything.
2292
+ try {
2293
+ const mileCards = hits.filter(h => isMilestoneCard(h.card) && !/^archive$/i.test(h.card.area || '')).map(h => h.card);
2294
+ const unpairedPlans = () => hits.filter(h => !h.correction && !h.fulfillment && !h.archived && isPlanCard(h.card));
2295
+ let left = unpairedPlans();
2296
+ if (left.length && mileCards.length) {
2297
+ const inAnswer = planFulfillmentFor(struct, left.map(h => h.card), { pairSim, scope: 'answer', milestones: mileCards });
2298
+ for (const h of left) if (inAnswer.has(h.card.id)) h.fulfillment = inAnswer.get(h.card.id);
2299
+ left = unpairedPlans();
2300
+ }
2301
+ if (left.length) {
2302
+ const brainWide = planFulfillmentFor(struct, left.map(h => h.card), { pairSim, scope: 'brain' });
2303
+ for (const h of left) if (brainWide.has(h.card.id)) h.fulfillment = brainWide.get(h.card.id);
2304
+ }
2305
+ for (let i = 0; i < hits.length; i++) {
2306
+ const h = hits[i];
2307
+ const isPlanHint = h.fulfillment && h.fulfillment.byId && (h.fulfillment.kind === 'plan' || isPlanCard(h.card));
2308
+ if (!isPlanHint) continue;
2309
+ const j = hits.findIndex(x => x.card.id === h.fulfillment.byId);
2310
+ if (j > i) { const [m] = hits.splice(j, 1); hits.splice(i, 0, m); i++; }
2311
+ }
2312
+ } catch { /* best-effort — never fail the answer */ }
2123
2313
  }
2124
2314
  // SERVE-TIME 🛠️↔🏁 OBSOLESCENCE (2026-08-01 incident): the ❓ pass above
2125
2315
  // cannot see the class where a SKILL encodes a since-removed limitation —
@@ -2207,6 +2397,13 @@ export function questionContextToMarkdown(question, result, { mode = 'lexical',
2207
2397
  let block = `## [${flat(c.area) || 'Notes'}] ${day(c.createdAt)}${status}${rel}\n${flat(c.text)}`;
2208
2398
  if (h.correction) {
2209
2399
  block += `\n\n ⚠️ CORRECTED — this card is STALE; the current truth is:\n ${flat(h.correction.by.text).slice(0, 600)}`;
2400
+ } else if (h.fulfillment && (h.fulfillment.kind === 'plan' || isPlanCard(c))) {
2401
+ // A PLAN/PROPOSAL card a newer 🏁 appears to have shipped (2026-08-23
2402
+ // incident: "it's only a proposal" answered about a live feature).
2403
+ // The direction of trust differs from an open item: the reader must
2404
+ // NOT report the plan as unbuilt — and must not assert it built
2405
+ // either. Verify, then confirm or dismiss.
2406
+ block += `\n\n ⏳ POSSIBLY BUILT${h.fulfillment.unconfirmed ? ' (hint — no confirmed link)' : ''}: this card reads as a PLAN/PROPOSAL, and a newer 🏁 appears to have shipped it: “${flat(h.fulfillment.by).slice(0, 200)}”. Do NOT answer "only a proposal" or "still to do" from this card — for current state trust the newer card and VERIFY against the repo. If built: confirm with a ✓ marker (archives the plan as fulfilled history; it stays retrievable here) or add closes: to the milestone; if not: dismiss via brain_connect relationship:"not_fulfilled".`;
2210
2407
  } else if (h.fulfillment) {
2211
2408
  // Precedence: a correction outranks a fulfills-hint (never stack both).
2212
2409
  // Serve-time pairs (detected inside THIS answer's hit set, no
@@ -4680,6 +4877,112 @@ const SERVE_MIN_STEMS = 3;
4680
4877
  export const serveTimeAccepts = (lex, sameArea) =>
4681
4878
  anchorsSufficient(lex.anchors, sameArea, lex.cov)
4682
4879
  || ((lex.size || 0) >= SERVE_MIN_STEMS && lex.cov >= SERVE_COV_BAR);
4880
+ // ── Plan↔🏁 pairing (2026-08-23) — ONE scorer for every plan-card surface ───
4881
+ // Two acceptance tiers, because the surfaces differ in how much the QUESTION
4882
+ // already constrains the pair:
4883
+ // · 'answer' — inside one brain_ask hit set, where both cards already matched
4884
+ // the same question: the ❓ pass's own bars (embedding ≥ PLAN_PAIR_SIM_ANSWER
4885
+ // plus any lexical corroboration, or lexical alone at the serve bars).
4886
+ // · 'brain' — against EVERY live newer 🏁 (per-prompt recall, brain_sync
4887
+ // context, SessionStart self-heal, reconcile), where nothing constrains the
4888
+ // pair but the two texts: near-duplicate similarity (PLAN_PAIR_SIM_BRAIN)
4889
+ // plus lexical corroboration, or the self-heal's strict lexical bars
4890
+ // (coverage ≥ 0.6, or rare shared anchors) without embeddings.
4891
+ // MEASURED on the KLYPIX brain (2026-08-23, 61 plan-shaped cards under the
4892
+ // wider draft classifier × 890 🏁, BGE-small cosines; the shipped classifier
4893
+ // keeps 25 of them and reproduces 18 pairs at 0.80–0.93): true plan→ship
4894
+ // pairs sat at 0.81–0.93; the false pairs
4895
+ // the looser answer tier admitted brain-wide sat at 0.68–0.77 ("Marketing piece
4896
+ // #2 planned" ↔ an npm publish, a lock-plan doc ↔ an image-decode fix). The
4897
+ // incident pair itself — a RENAMED feature: cov 0.20, zero anchors — measures
4898
+ // 0.811 against its ship card, so 0.80 is the floor this tier must keep, and
4899
+ // the 0.77→0.81 gap is thin: the constant is exported for RE-MEASUREMENT
4900
+ // (scripts/brain-eval in the KLYPIX repo), never tuned by feel.
4901
+ // Best milestone = highest score, a near-tie (≤ 0.1) broken toward the EARLIEST
4902
+ // 🏁: a plan ships once, and later 🏁s that reuse its vocabulary are follow-ups
4903
+ // (the Forge feasibility card's top-score pair was a later unrelated ship
4904
+ // until this rule). Bounded O(plans × milestones) with milestones tokenized
4905
+ // once; returns Map<planId, { kind:'plan', by, byId, cov, sim, via, unconfirmed }>.
4906
+ // Suggestion-only by construction — nothing here writes or retires.
4907
+ export const PLAN_PAIR_SIM_ANSWER = 0.55;
4908
+ export const PLAN_PAIR_SIM_BRAIN = 0.80;
4909
+ export function planFulfillmentFor(struct, cards, { pairSim = null, scope = 'brain', milestones = null, df = null } = {}) {
4910
+ const out = new Map();
4911
+ if (!struct || !Array.isArray(struct.cards) || !Array.isArray(cards) || !cards.length) return out;
4912
+ const isArchived = (c) => /^archive$/i.test(c.area || '');
4913
+ const plans = cards.filter(c => c && isPlanCard(c) && !isArchived(c));
4914
+ if (!plans.length) return out;
4915
+ const miles = Array.isArray(milestones) ? milestones.filter(Boolean)
4916
+ : struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !isArchived(c) && isMilestoneCard(c));
4917
+ if (!miles.length) return out;
4918
+ // Settled pairs: an existing hint edge or close (plan → ship), and human
4919
+ // dismissals in EITHER direction — a person draws "this ship is not that
4920
+ // plan" as naturally as the reverse (review 2026-08-23).
4921
+ const settled = new Set();
4922
+ for (const cn of struct.connections || []) {
4923
+ if (cn.label === 'likely closed by' || cn.label === 'closed by') settled.add(`${cn.fromId}|${cn.toId}`);
4924
+ if (DISMISSAL_RELS.has(cn.relationship)) { settled.add(`${cn.fromId}|${cn.toId}`); settled.add(`${cn.toId}|${cn.fromId}`); }
4925
+ }
4926
+ const dfMap = () => (df ??= buildStemDf(struct));
4927
+ const simBar = scope === 'answer' ? PLAN_PAIR_SIM_ANSWER : PLAN_PAIR_SIM_BRAIN;
4928
+ const mPre = miles.map(m => { const idx = stemIndex(tokenSet(m.text)); return { m, idx, keys: new Set(idx.keys()) }; });
4929
+ const structural = new Map();
4930
+ const excludeFor = (a, b) => { const k = `${a || ''}|${b || ''}`; if (!structural.has(k)) structural.set(k, structuralStems(a, b)); return structural.get(k); };
4931
+ for (const o of plans) {
4932
+ const oIdx = stemIndex(claimTokens(normalizeWrappedProse(o.text)));
4933
+ const oKeys = new Set(oIdx.keys());
4934
+ if (oKeys.size < SERVE_MIN_STEMS) continue; // too vague to pair safely
4935
+ // TWO-PASS selection (order-independent — review 2026-08-23 showed the
4936
+ // single-pass near-tie rule picked a different ship per card order):
4937
+ // collect every accepted candidate, take the top score, then among the
4938
+ // candidates within 0.1 of it choose the EARLIEST ship; remaining ties
4939
+ // → the original id over a twin id, then the higher score, then the id.
4940
+ const accepted = [];
4941
+ for (const { m, idx: mIdx, keys: mKeys } of mPre) {
4942
+ if (!m || m.id === o.id || (m.createdAt || 0) <= (o.createdAt || 0)) continue; // a ship must post-date the plan
4943
+ if (settled.has(`${o.id}|${m.id}`)) continue;
4944
+ const sim = typeof pairSim === 'function' ? pairSim(o.id, m.id) : null;
4945
+ const cov = coverageOf(oKeys, mKeys);
4946
+ const sameArea = (o.area || '') === (m.area || '');
4947
+ // Rare shared anchors are only worth computing when a tier can use them.
4948
+ const wantAnchors = (sim != null && sim >= simBar) || scope === 'answer' || cov >= ANCHOR_COV_FLOOR;
4949
+ const anchors = wantAnchors ? sharedAnchors(oIdx, mIdx, dfMap(), { exclude: excludeFor(o.area, m.area) }) : [];
4950
+ const lex = { cov: Math.round(cov * 100) / 100, anchors, size: oKeys.size };
4951
+ // Corroboration reads the ROUNDED coverage the receipt reports (the
4952
+ // incident pair measures 0.20 = 10 of 51 stems; a raw 0.196 must not
4953
+ // fail a bar that was set from the rounded measurement).
4954
+ const corroborated = anchors.length >= 1 || lex.cov >= 0.2;
4955
+ // A MEASURED cosine decides the brain tier. The lexical bars exist
4956
+ // for hosts with no vectors; when the embedding has already
4957
+ // measured a pair as NOT near-duplicate, rare shared words must not
4958
+ // overrule it. Measured on the KLYPIX brain sweep (23 pairs): four
4959
+ // of the five false pairs came through the anchor path at cosines
4960
+ // 0.64–0.74 ("core roadmap" ↔ a desktop build, a freeze plan ↔ the
4961
+ // emoji picker); the veto costs one true pair at 0.748 (the single-
4962
+ // writer architecture ↔ the one-write-lock release) — precision-
4963
+ // first, as every sibling surface. The answer tier keeps its own
4964
+ // question-constrained lexical acceptance.
4965
+ const lexOk = scope === 'answer'
4966
+ ? serveTimeAccepts(lex, sameArea)
4967
+ : (sim == null && (cov >= 0.6 || anchorsSufficient(anchors, sameArea, cov)));
4968
+ const embedOk = sim != null && sim >= simBar && corroborated;
4969
+ if (!embedOk && !lexOk) continue;
4970
+ const via = embedOk ? 'embed' : (cov >= 0.6 || lex.cov >= SERVE_COV_BAR ? 'coverage' : 'anchor');
4971
+ accepted.push({ m, score: (sim ?? 0) + cov + anchors.length * 0.2, lex, sim, via });
4972
+ }
4973
+ if (!accepted.length) continue;
4974
+ const top = Math.max(...accepted.map(a => a.score));
4975
+ const band = accepted.filter(a => a.score >= top - 0.1);
4976
+ band.sort((a, b) => ((a.m.createdAt || 0) - (b.m.createdAt || 0))
4977
+ || ((isAgconfTwinId(a.m.id) ? 1 : 0) - (isAgconfTwinId(b.m.id) ? 1 : 0))
4978
+ || (b.score - a.score)
4979
+ || String(a.m.id).localeCompare(String(b.m.id)));
4980
+ const best = band[0];
4981
+ const head = String(best.m.text || '').replace(/\s+/g, ' ').trim().slice(0, 100);
4982
+ out.set(o.id, { kind: 'plan', by: head, byId: best.m.id, cov: best.lex.cov, sim: best.sim == null ? null : Math.round(best.sim * 1000) / 1000, via: best.via, unconfirmed: true, scope });
4983
+ }
4984
+ return out;
4985
+ }
4683
4986
  // Imperative-ask cue (2026-07-29): a narrative ❓ card often carries no
4684
4987
  // colon-anchored "remaining:" clause — its ask is an imperative sentence
4685
4988
  // ("Narrow the prune … and verify a packaged answer E2E"). For OPEN-shaped
@@ -4763,7 +5066,10 @@ export function findFulfillmentCandidates(struct, milestones, { coverAt = 0.6, r
4763
5066
  const clauses = extractOpenClauses(o.text);
4764
5067
  // A whole ❓/🎯 card with no prose clause IS the claim (glyph-gated
4765
5068
  // path; never for 🏁 cards — their claim is only the explicit clause).
4766
- if (!clauses.length && /❓|🎯/.test(o.text) && !/🏁/.test(o.text)) {
5069
+ // A plan-shaped plain card (isPlanCard, 2026-08-23) is the same claim
5070
+ // shape without the glyph: "we will build X" is fulfilled by "🏁 X".
5071
+ const planShaped = isPlanCard(o);
5072
+ if (!clauses.length && (/❓|🎯/.test(o.text) || planShaped) && !/🏁/.test(o.text)) {
4767
5073
  const tk = claimTokens(o.text);
4768
5074
  if (tk.size >= 4) clauses.push({ clause: null, items: [{ text: flat(o.text).slice(0, 120), tokens: tk }] });
4769
5075
  }
@@ -4791,7 +5097,7 @@ export function findFulfillmentCandidates(struct, milestones, { coverAt = 0.6, r
4791
5097
  viaAnchor = true;
4792
5098
  }
4793
5099
  const uncovered = cl.items.filter(x => x !== it && coverageOf(stemSet(x.tokens), mTok) < coverAt).map(x => x.text);
4794
- out.push({ open: o, clause: cl.clause, item: it.text, uncovered, milestone: m, cov: Math.round(cov * 100) / 100, resolvable: !viaAnchor && it.tokens.size >= 4, ...(viaAnchor ? { via: 'anchor' } : {}) });
5100
+ out.push({ open: o, clause: cl.clause, item: it.text, uncovered, milestone: m, cov: Math.round(cov * 100) / 100, resolvable: !viaAnchor && it.tokens.size >= 4, ...(viaAnchor ? { via: 'anchor' } : {}), ...(planShaped ? { kind: 'plan' } : {}) });
4795
5101
  }
4796
5102
  }
4797
5103
  }
@@ -5087,14 +5393,20 @@ export function corpseRate(struct, { k = 5, maxPairs = 40 } = {}) {
5087
5393
  // the human to close them — never auto-archives (precision-first, suggestion-only,
5088
5394
  // like the migration tripwire). Requires the milestone to post-date the goal so a
5089
5395
  // pre-existing milestone can't "fulfil" a newer goal. No I/O, node-runnable.
5090
- export function findStaleOpenCards(struct, { coverAt = 0.6, max = 5 } = {}) {
5091
- const empty = { gaps: [], total: 0 };
5396
+ export function findStaleOpenCards(struct, { coverAt = 0.6, max = 5, pairSim = null } = {}) {
5397
+ const empty = { gaps: [], total: 0, plans: [], plansTotal: 0 };
5092
5398
  if (!struct || !Array.isArray(struct.cards)) return empty;
5093
5399
  const isArchived = (c) => /^archive$/i.test(c.area || '');
5094
5400
  const live = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !isArchived(c) && !/↩|✅/.test(c.text));
5095
5401
  const opens = live.filter(isOpenCard);
5096
5402
  const miles = live.filter(isMilestoneCard);
5097
- if (!opens.length || !miles.length) return empty;
5403
+ // Plan-shaped plain cards (2026-08-23) ask the same "looks done?" question
5404
+ // for proposals that never carried a ❓ — listed separately (plans) so the
5405
+ // footer can word them as BUILT, embedding-first when the caller can pay for
5406
+ // card↔card similarity (pairSim from the warm vector cache), strict lexical
5407
+ // bars without it.
5408
+ const plansLive = live.filter(isPlanCard);
5409
+ if ((!opens.length && !plansLive.length) || !miles.length) return empty;
5098
5410
  // Human dismissals + existing hint edges suppress a pair here exactly as in
5099
5411
  // findFulfillmentCandidates — a rejected hint must never resurface in the
5100
5412
  // self-heal footer either (parity fix, 2026-07-29).
@@ -5145,7 +5457,29 @@ export function findStaleOpenCards(struct, { coverAt = 0.6, max = 5 } = {}) {
5145
5457
  if (best) out.push({ open: o, by: best, cov: Math.round(bestCov * 100) / 100, ...(bestVia === 'anchor' ? { via: 'anchor' } : {}) });
5146
5458
  }
5147
5459
  out.sort((a, b) => b.cov - a.cov);
5148
- return { gaps: out.slice(0, max), total: out.length };
5460
+ const plans = [];
5461
+ if (plansLive.length) {
5462
+ try {
5463
+ const byId = new Map(live.map(c => [c.id, c]));
5464
+ const hints = planFulfillmentFor(struct, plansLive, { pairSim, scope: 'brain', milestones: miles, df });
5465
+ for (const [id, h] of hints) {
5466
+ const o = byId.get(id), by = byId.get(h.byId);
5467
+ if (o && by) plans.push({ open: o, by, cov: h.cov, sim: h.sim, via: h.via, kind: 'plan' });
5468
+ }
5469
+ // Identical-text twins (merge residue) collapse to ONE row — the
5470
+ // original's id when both exist — so a brain awaiting its Arrange
5471
+ // heal does not list the same plan twice.
5472
+ const byText = new Map();
5473
+ for (const p of plans) {
5474
+ const k = String(p.open.text || '').toLowerCase().replace(/\s+/g, ' ').trim();
5475
+ const prev = byText.get(k);
5476
+ if (!prev || (isAgconfTwinId(prev.open.id) && !isAgconfTwinId(p.open.id))) byText.set(k, p);
5477
+ }
5478
+ plans.length = 0; plans.push(...byText.values());
5479
+ plans.sort((a, b) => ((b.sim ?? 0) + b.cov) - ((a.sim ?? 0) + a.cov));
5480
+ } catch { /* best-effort — the open-card report stands on its own */ }
5481
+ }
5482
+ return { gaps: out.slice(0, max), total: out.length, plans: plans.slice(0, max), plansTotal: plans.length };
5149
5483
  }
5150
5484
 
5151
5485
  // ── Open-question deadline awareness ─────────────────────────────────────────
@@ -624,6 +624,39 @@ ${extra}` : base;
624
624
  return map;
625
625
  }
626
626
 
627
+ // READ-ONLY card vectors from the warm cache: never loads the model, never
628
+ // embeds a missing card, never writes. For fast paths (brain_sync task context)
629
+ // that may USE card↔card similarity when it is already paid for — plan ↔ 🏁
630
+ // pairing — and must degrade to lexical bars when it is not. Cards whose text
631
+ // changed since they were embedded are simply absent from the result.
632
+ // Single-entry memo for the fast path (review 2026-08-23: brain_sync re-parsed
633
+ // a ~36 MB cache on every plan-shaped hit). Keyed by the canonical cache file
634
+ // + mtime + size + the card-hash digest, so a changed card or a rewritten
635
+ // cache invalidates it; ONE brain at a time, vectors only (no parsed JSON
636
+ // retained), so the long-lived worker's heap grows by one vector map, not by
637
+ // every brain it ever touched.
638
+ let _cachedVecMemo = null;
639
+ export function cachedVectorsForBrain(brainPath, cards) {
640
+ const map = new Map();
641
+ try {
642
+ const want = (cards || []).filter((card) => card && card.type !== 'container' && (card.text || '').trim());
643
+ if (!want.length) return map;
644
+ const desiredHashes = new Map(want.map((card) => [card.id, sha1(String(card.text))]));
645
+ const file = cacheCandidates(brainPath)[0].file;
646
+ let stamp = null;
647
+ try { const st = fs.statSync(file); stamp = `${st.mtimeMs}|${st.size}`; } catch { stamp = null; }
648
+ const digest = sha1([...desiredHashes.entries()].map(([id, h]) => `${id}:${h}`).sort().join('\n'));
649
+ if (stamp && _cachedVecMemo && _cachedVecMemo.file === file && _cachedVecMemo.stamp === stamp && _cachedVecMemo.digest === digest) return _cachedVecMemo.map;
650
+ const loaded = readCache(brainPath, desiredHashes);
651
+ for (const card of want) {
652
+ const entry = loaded?.cache?.cards?.[card.id];
653
+ if (entry?.v && entry.h === desiredHashes.get(card.id)) map.set(card.id, entry.v);
654
+ }
655
+ if (stamp) _cachedVecMemo = { file, stamp, digest, map };
656
+ } catch { /* cache is best-effort — lexical stays the floor */ }
657
+ return map;
658
+ }
659
+
627
660
  export async function vectorsForBrain(pipe, brainPath, cards) {
628
661
  if (!BOUNDED) return vectorsForBrainUnlocked(pipe, brainPath, cards);
629
662
  const key = canonicalBrainKey(brainPath);