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.
- package/bin/klypix-worker.mjs +2 -2
- package/package.json +2 -2
- package/src/brain-semantic.mjs +11 -1
- package/src/global-brain-hook.mjs +75 -15
- package/src/klypix-core.mjs +122 -3
- package/src/klypix-format.mjs +352 -18
- package/src/semantic-memory.mjs +33 -0
package/bin/klypix-worker.mjs
CHANGED
|
@@ -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.
|
|
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"
|
package/src/brain-semantic.mjs
CHANGED
|
@@ -141,10 +141,20 @@ function readCachedVecs(brainPath, cards) {
|
|
|
141
141
|
} catch { /* try next variant */ }
|
|
142
142
|
}
|
|
143
143
|
const map = new Map();
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
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
|
-
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
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(
|
|
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
|
-
|
|
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).
|
package/src/klypix-core.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/src/klypix-format.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
1328
|
-
const 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ─────────────────────────────────────────
|
package/src/semantic-memory.mjs
CHANGED
|
@@ -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);
|