klypix-mcp 1.86.3 → 1.87.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.
@@ -395,7 +395,7 @@ try {
395
395
  // canvas-view-app.html is the canvas_view MCP App UI — staged raw (an HTML
396
396
  // file must never get a JS-comment banner) beside the flat server, which
397
397
  // resolves it via its ./canvas-view-app.html candidate path.
398
- for (const f of ['global-brain-hook.mjs', 'capture-gap.mjs', 'brain-semantic.mjs', 'semantic-memory.mjs', 'enrichment.mjs', 'brain-note.mjs', 'brain-evidence.mjs', 'brain-git-hook.mjs', 'git-capture-install.mjs', 'brain-history.mjs', 'brain-graveyard.mjs', 'klypix-format.mjs', 'klypix-core.mjs', 'brain-write-lock.mjs', 'agent-rules.mjs', 'brain-doctor.mjs', 'editor-detect.mjs', 'agent-presence.mjs', 'mcp-presence.mjs', 'repo-state.mjs', 'result-reconcile.mjs', 'finding-routing.mjs', 'presence-relay.mjs', 'mcp-supervisor.mjs', 'mcp-auto-update.mjs', 'runtime-inspector.mjs', 'project-graph.mjs', 'bench.mjs', 'codex-brain-hook.mjs', 'codex-hooks.mjs', 'canvas-view-app.html']) {
398
+ for (const f of ['global-brain-hook.mjs', 'capture-gap.mjs', 'brain-semantic.mjs', 'semantic-memory.mjs', 'enrichment.mjs', 'provenance.mjs', 'brain-note.mjs', 'brain-evidence.mjs', 'brain-git-hook.mjs', 'git-capture-install.mjs', 'brain-history.mjs', 'brain-graveyard.mjs', 'klypix-format.mjs', 'klypix-core.mjs', 'brain-write-lock.mjs', 'agent-rules.mjs', 'brain-doctor.mjs', 'editor-detect.mjs', 'agent-presence.mjs', 'mcp-presence.mjs', 'repo-state.mjs', 'result-reconcile.mjs', 'finding-routing.mjs', 'presence-relay.mjs', 'mcp-supervisor.mjs', 'mcp-auto-update.mjs', 'runtime-inspector.mjs', 'project-graph.mjs', 'bench.mjs', 'codex-brain-hook.mjs', 'codex-hooks.mjs', 'canvas-view-app.html']) {
399
399
  const s = path.join(SRC, f); if (exists(s)) staged.push({ dst: f, content: fs.readFileSync(s, 'utf8') });
400
400
  }
401
401
  for (const [src, dst] of [
@@ -621,7 +621,7 @@ server.registerTool('brain_lens', {
621
621
 
622
622
  server.registerTool('brain_connect', {
623
623
  title: 'Connect related-but-unlinked brain cards (densify the graph)',
624
- description: 'Repairs orphaned decision/milestone cards first (scope:"orphans", the default) by proposing genuinely related unlinked pairs — semantic similarity at a conservative 0.55 threshold when the on-device model is installed, else shared tags + [[mentions]]. The dry run includes a before→projected orphan receipt; apply:true draws only additive, removable arrows and reports the measured after count. It NEVER archives or rewrites cards. Use scope:"all" for deliberate whole-graph densification. To DISMISS a brain_reconcile false-positive contradiction, pass pairs:[{fromId,toId}] with relationship:"not_contradiction".',
624
+ description: 'Repairs orphaned decision/milestone cards first (scope:"orphans", the default) by proposing genuinely related unlinked pairs — semantic similarity at a conservative 0.55 threshold when the on-device model is installed, else shared tags + [[mentions]]. The dry run includes a before→projected orphan receipt; apply:true draws only additive, removable arrows and reports the measured after count. It NEVER archives or rewrites cards. Use scope:"all" for deliberate whole-graph densification. To DISMISS a brain_reconcile false-positive contradiction, pass pairs:[{fromId,toId}] with relationship:"not_contradiction". Every applied not_fulfilled / not_contradiction dismissal is also recorded, with its surface and client, to the machine-local provenance sidecar (nothing extra is written into the brain file).',
625
625
  inputSchema: {
626
626
  canvas: z.string().optional().describe('Canvas filename/path. Defaults to the project brain ("brain").'),
627
627
  apply: z.boolean().optional().describe('false (default) = suggest only; true = draw the connections.'),
@@ -631,11 +631,11 @@ server.registerTool('brain_connect', {
631
631
  pairs: z.array(z.object({ fromId: z.string(), toId: z.string() })).optional().describe('Explicit card-id pairs to connect (bypasses auto-proposal). Use to dismiss a reconcile false-positive: pass the two card ids with relationship:"not_contradiction".'),
632
632
  relationship: z.string().optional().describe('Relationship for explicit `pairs` (e.g. "not_contradiction" to permanently dismiss a contradiction candidate, or "relates_to", "depends_on", "supports").'),
633
633
  },
634
- }, async ({ canvas, apply, max, threshold, scope, pairs, relationship }) => toContent(await opBrainConnect({ vault: mcpPresence.vault, canvas: boundBrainCanvas(canvas), apply, max, threshold, scope, pairs, relationship, log })));
634
+ }, async ({ canvas, apply, max, threshold, scope, pairs, relationship }, extra) => toContent(await opBrainConnect({ vault: mcpPresence.vault, canvas: boundBrainCanvas(canvas), apply, max, threshold, scope, pairs, relationship, via: extra.klypixClientName, log })));
635
635
 
636
636
  server.registerTool('brain_reconcile', {
637
637
  title: 'Reconcile the brain — contradictions, unrecorded migrations, and what a release already closed',
638
- description: 'Truth maintenance. (1) CONTRADICTIONS: finds same-subject live card pairs where one carries an explicit correction cue (uppercase "CORRECTION", "was WRONG", "OBSOLETE" — that side is the presumed truth, UNLESS the cue predates its counterpart: then the pair is marked "presumed superseded" and the newer card is presumed current — verify before retiring) or the two use opposite polarity words (deferred↔wired, broken↔fixed, dead↔live), i.e. stale facts whose correction never got linked — candidates only, YOU confirm each: retire the stale card via brain_note ✓. Dismiss a FALSE positive (either kind) by connecting the two ids with brain_connect pairs + relationship:"not_contradiction" — persisted, so it never resurfaces (and its cue stops overlaying recall/ask for that pair). (2) MIGRATIONS: lists committed migration files (Supabase / Rails / Prisma / Knex / generic) that NO brain card references, so an applied-but-unnarrated rollout can be recorded. (3) LEGACY: pre-v1.15 raw-bash ship cards to tidy. (4) RELEASE: which open cards look fulfilled by the commits a release ref already carries (subject+body coverage, the card\'s own #commit- receipt, or a hint edge whose milestone is in the ref). READ-ONLY by default and on every other mode. THE ONE EXCEPTION: on mode "claims" and mode "release" you may pass confirm/dismiss to actually close what you verified — confirm names exact card ids, so nothing is matched by prose; covering only part of a multi-item clause writes "✔ partial" and KEEPS the card open unless you pass whole:true; a call whose every entry is refused leaves the brain byte-identical. Never reads the database or the network. Run it periodically, when recall surfaces something you believe is stale, or right before cutting a release.',
638
+ description: 'Truth maintenance. (1) CONTRADICTIONS: finds same-subject live card pairs where one carries an explicit correction cue (uppercase "CORRECTION", "was WRONG", "OBSOLETE" — that side is the presumed truth, UNLESS the cue predates its counterpart: then the pair is marked "presumed superseded" and the newer card is presumed current — verify before retiring) or the two use opposite polarity words (deferred↔wired, broken↔fixed, dead↔live), i.e. stale facts whose correction never got linked — candidates only, YOU confirm each: retire the stale card via brain_note ✓. Dismiss a FALSE positive (either kind) by connecting the two ids with brain_connect pairs + relationship:"not_contradiction" — persisted, so it never resurfaces (and its cue stops overlaying recall/ask for that pair). (2) MIGRATIONS: lists committed migration files (Supabase / Rails / Prisma / Knex / generic) that NO brain card references, so an applied-but-unnarrated rollout can be recorded. (3) LEGACY: pre-v1.15 raw-bash ship cards to tidy. (4) RELEASE: which open cards look fulfilled by the commits a release ref already carries (subject+body coverage, the card\'s own #commit- receipt, or a hint edge whose milestone is in the ref). READ-ONLY by default and on every other mode. THE ONE EXCEPTION: on mode "claims" and mode "release" you may pass confirm/dismiss to actually close what you verified — confirm names exact card ids, so nothing is matched by prose; covering only part of a multi-item clause writes "✔ partial" and KEEPS the card open unless you pass whole:true; a call whose every entry is refused leaves the brain byte-identical. Never reads the database or the network. Every applied confirm/dismiss is also recorded to the machine-local provenance sidecar (actor, surface, exact card ids — nothing extra is written into the brain file), and a claims confirm settles the pair\'s dashed "likely closed by" hint into a solid confirmed edge. Run it periodically, when recall surfaces something you believe is stale, or right before cutting a release.',
639
639
  inputSchema: {
640
640
  canvas: z.string().optional().describe('Brain canvas filename/path. Defaults to the project brain ("brain").'),
641
641
  root: z.string().optional().describe("Project root holding the migrations dir / git repo (default: the brain file's folder)."),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.86.3",
3
+ "version": "1.87.0",
4
4
  "mcpName": "io.github.dahshanlabs/klypix-mcp",
5
5
  "description": "Active state management for multi-agent coding: a shared, versioned project brain over MCP.",
6
6
  "type": "module",
@@ -84,7 +84,7 @@
84
84
  "bench": "node bin/klypix-mcp.mjs bench",
85
85
  "test:bench": "node test/bench.mjs",
86
86
  "pretest": "node test/publish-workflow.mjs",
87
- "test": "node test/publish-verdict.mjs && node test/npx-owned-names.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/guard-cards.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/retrieval-fusion.mjs && node test/eval-retrieval.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/partial-notes.mjs && node test/lifecycle-prefix.mjs && node test/close-link-safety.mjs && node test/resolve-ledger.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/semantic-hash-parity.mjs && node test/enrichment.mjs && node test/hook-fallback.mjs && node test/eval-hook-lane.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/marker-suffix-grammar.mjs && node test/evidence-anchors.mjs && node test/brain-evidence.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-reconcile.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/canvas-groups.mjs && node test/git-tools.mjs && node test/uninstall.mjs && node test/current-guidance.mjs && node test/status-shape.mjs && node test/status-hook.mjs",
87
+ "test": "node test/publish-verdict.mjs && node test/npx-owned-names.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/guard-cards.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/retrieval-fusion.mjs && node test/eval-retrieval.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/partial-notes.mjs && node test/lifecycle-prefix.mjs && node test/close-link-safety.mjs && node test/resolve-ledger.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/semantic-hash-parity.mjs && node test/enrichment.mjs && node test/provenance.mjs && node test/confirm-trail.mjs && node test/hook-fallback.mjs && node test/eval-hook-lane.mjs && node test/hook-unified-lane.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/marker-suffix-grammar.mjs && node test/evidence-anchors.mjs && node test/brain-evidence.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-reconcile.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/canvas-groups.mjs && node test/git-tools.mjs && node test/uninstall.mjs && node test/current-guidance.mjs && node test/status-shape.mjs && node test/status-hook.mjs",
88
88
  "test:memory": "node test/memory-runtime.mjs",
89
89
  "test:memory:soak": "node --expose-gc test/memory-soak.mjs",
90
90
  "runtime": "node bin/klypix-runtime.mjs",
@@ -57,6 +57,11 @@ try { repoStateLib = await import('./repo-state.mjs'); } catch { repoStateLib =
57
57
  // (the exact drift that once produced three different live-session counts).
58
58
  let presenceLib = null;
59
59
  try { presenceLib = await import('./agent-presence.mjs'); } catch { presenceLib = null; }
60
+ // provenance powers the informational JUDGMENTS line (confirm/dismiss verdict
61
+ // counts + rejected-prompt pool). Same failure-tolerant idiom; absence of the
62
+ // module OR of any records is a fact, never drift.
63
+ let provenanceLib = null;
64
+ try { provenanceLib = await import('./provenance.mjs'); } catch { provenanceLib = null; }
60
65
 
61
66
  const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
62
67
 
@@ -497,6 +502,13 @@ export function inspect(opts = {}) {
497
502
  history = { available: true, count: points.length, newestAt: points[0]?.ts || null };
498
503
  } catch { history = { available: true, count: 0, newestAt: null }; }
499
504
  }
505
+ // Judgment provenance (2026-09-29). Informational only: a brain with zero
506
+ // recorded verdicts is NEW, not drifted (same doctrine as history above).
507
+ let provenance = null;
508
+ if (hasBrain && provenanceLib && typeof provenanceLib.provenanceCounts === 'function') {
509
+ try { provenance = { available: true, ...provenanceLib.provenanceCounts(brainPath, { home }) }; }
510
+ catch { provenance = null; }
511
+ }
500
512
  const tools = inspectTools(brainDir, PKG_ROOT);
501
513
  // ── CHECKOUT (release-state visibility, 2026-08-14 incident) ──────────────
502
514
  // Advisory, never a verdict layer: when the PROJECT itself is a versioned
@@ -669,7 +681,7 @@ export function inspect(opts = {}) {
669
681
 
670
682
  // `checkout` is additive (schema-stable): downstream renderers keep parsing
671
683
  // every existing field; it never feeds layers/verdict/actions by design.
672
- return { verdict, layers, drifted, readinessWarnings, version, running, supervisors, autoUpdate, hooks, codexSmart, codexHooks, gitCapture, history, tools, peers, sessions: peers, receipts: peers.receipts, receiptSessionId, harness, npm, decayGuard, checkout, project: { dir: projectDir, brainPath, hasBrain }, brainDir, actions };
684
+ return { verdict, layers, drifted, readinessWarnings, version, running, supervisors, autoUpdate, hooks, codexSmart, codexHooks, gitCapture, history, provenance, tools, peers, sessions: peers, receipts: peers.receipts, receiptSessionId, harness, npm, decayGuard, checkout, project: { dir: projectDir, brainPath, hasBrain }, brainDir, actions };
673
685
  }
674
686
 
675
687
  // One-line drift summary (empty when clean) — for a footer / status line.
@@ -840,6 +852,17 @@ export function render(r, opts = {}) {
840
852
  const age = r.history.newestAt ? `${Math.max(0, Math.round((Date.now() - r.history.newestAt) / 60000))}m ago` : 'none yet';
841
853
  L.push(`${ok} ${c.bold}HISTORY${c.rst} ${r.history.count} restore point(s) · newest ${age} ${c.dim}(npx klypix-mcp brain-history list)${c.rst}`);
842
854
  }
855
+ // Judgment provenance — say what confirm/dismiss verdicts this machine has
856
+ // recorded, by verdict and surface, because "the trail exists" is only
857
+ // believable with the counts on the table. Zero records = a new sidecar,
858
+ // never a warning.
859
+ if (r.provenance?.available) {
860
+ const j = r.provenance.judgments || { total: 0, byVerdict: {}, bySource: {} };
861
+ const rej = r.provenance.rejected || { total: 0 };
862
+ const since = j.firstTs ? ` · since ${new Date(j.firstTs).toISOString().slice(0, 10)}` : '';
863
+ const srcBits = Object.entries(j.bySource || {}).map(([source, n]) => `${source} ${n}`).join(' · ');
864
+ L.push(`${ok} ${c.bold}JUDGMENTS${c.rst} ${j.total} recorded (${j.byVerdict?.yes || 0} yes / ${j.byVerdict?.no || 0} no)${since} · rejected-prompt pool ${rej.total}${srcBits ? ` ${c.dim}(${srcBits})${c.rst}` : ''}`);
865
+ }
843
866
 
844
867
  // TOOLS
845
868
  L.push(`${ok} ${c.bold}TOOLS${c.rst} ${r.tools.count} MCP verb(s)${r.tools.hash ? ` ${c.dim}[#${r.tools.hash}, ${r.tools.source}]${c.rst}` : ''}${r.tools.count ? `: ${c.dim}${r.tools.names.join(', ')}${c.rst}` : ''}`);
@@ -120,18 +120,34 @@ export function enrichmentQuestionQuality(question) {
120
120
  * pruned — enrichment is a rolling quality window, not an archive, and unlike
121
121
  * the claims lane nothing downstream depends on any single entry existing.
122
122
  * Returns { recorded, rejected } — `rejected` counts texts the quality gate
123
- * refused (see enrichmentQuestionQuality); they are never written.
123
+ * refused (see enrichmentQuestionQuality); they never reach the enrichment
124
+ * sidecar or the embedder. Since 2026-09-29 each refusal is forwarded, with its
125
+ * reason, to the privacy-screened rejected-log in provenance.mjs (best-effort).
124
126
  */
125
127
  export function recordEnrichment(brainPath, items, { home = os.homedir(), now = Date.now() } = {}) {
126
128
  const list = [];
129
+ const discarded = [];
127
130
  let rejected = 0;
128
131
  for (const item of (Array.isArray(items) ? items : [])) {
129
132
  const key = enrichmentKeyFor(item?.body);
130
133
  if (key.length < 24) continue;
131
134
  const quality = enrichmentQuestionQuality(item?.question);
132
- if (!quality.ok) { rejected++; continue; }
135
+ if (!quality.ok) { rejected++; discarded.push({ text: quality.text, reason: quality.reason }); continue; }
133
136
  list.push({ key, q: quality.text });
134
137
  }
138
+ // A discard is still SIGNAL — the acknowledgement class is exactly the
139
+ // negative pool a judge study needs — so the text goes, WITH its refusal
140
+ // reason, to the bounded rejected-log sidecar (provenance.mjs applies the
141
+ // privacy screen: machine/console/pasted-doc classes store hash+length only).
142
+ // Lazy fire-and-forget import, never-throw: a stale deployment without
143
+ // provenance.mjs just skips, costing training data, never a capture.
144
+ if (discarded.length) {
145
+ try {
146
+ import('./provenance.mjs')
147
+ .then((prov) => { try { prov.recordRejectedEnrichment(brainPath, discarded, { home, now }); } catch { /* additive */ } })
148
+ .catch(() => { /* stale deployment — additive signal only */ });
149
+ } catch { /* dynamic import unavailable — additive signal only */ }
150
+ }
135
151
  if (!list.length) return { recorded: 0, rejected };
136
152
  const file = enrichmentFileFor(brainPath, home);
137
153
  fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -2743,13 +2743,19 @@ function draftRulesFromFixes(lib, cards, verified, sid) {
2743
2743
  candidates.push({ area: c.area || '', text: seed, source: c.createdVia || 'marker' });
2744
2744
  }
2745
2745
  }
2746
- if (!candidates.length && !skillCards.length) return { drafted: 0, approved: 0 };
2746
+ if (!candidates.length && !skillCards.length) return { drafted: 0, approved: 0, approvedDrafts: [] };
2747
2747
  let added = 0, approved = 0;
2748
+ const approvedDrafts = []; // which draft each skill retired — the shown-then-promoted provenance signal
2748
2749
  persistDrafts((drafts, now) => {
2749
2750
  if (skillCards.length) { // approval: a captured 🛠️ skill retires the draft it fulfils
2750
- const before = drafts.length;
2751
- drafts = drafts.filter(d => !skillCards.some(sc => draftMatches(d, String(sc.text || ''))));
2752
- approved = before - drafts.length;
2751
+ const kept = [];
2752
+ for (const d of drafts) {
2753
+ const sc = skillCards.find(sc => draftMatches(d, String(sc.text || '')));
2754
+ if (sc) approvedDrafts.push({ area: d.area || '', text: String(d.text || ''), skillText: String(sc.text || '') });
2755
+ else kept.push(d);
2756
+ }
2757
+ approved = drafts.length - kept.length;
2758
+ drafts = kept;
2753
2759
  }
2754
2760
  const handled = new Set();
2755
2761
  for (const cand of candidates) {
@@ -2764,8 +2770,8 @@ function draftRulesFromFixes(lib, cards, verified, sid) {
2764
2770
  }
2765
2771
  return drafts;
2766
2772
  });
2767
- return { drafted: added, approved };
2768
- } catch { return { drafted: 0, approved: 0 }; }
2773
+ return { drafted: added, approved, approvedDrafts };
2774
+ } catch { return { drafted: 0, approved: 0, approvedDrafts: [] }; }
2769
2775
  }
2770
2776
  // Pending drafts: TTL-fresh, not already covered by a live 🛠️ skill, and NOT decayed
2771
2777
  // (a draft surfaced-and-ignored across ≥ RULE_DRAFT_MAX_SURFACINGS distinct sessions
@@ -3642,7 +3648,38 @@ async function capture(lib) {
3642
3648
  {
3643
3649
  const hadFixCommit = commitCards.some(cc => cc.createdVia === 'commit' && !/🏁/.test(String(cc.text || '')));
3644
3650
  const verified = sessionVerified(shellCmds, errorIds, hadFixCommit);
3645
- draftRulesFromFixes(lib, cards, verified, sid);
3651
+ const draftRes = draftRulesFromFixes(lib, cards, verified, sid);
3652
+ // Draft-rule provenance (2026-09-29): the shown-then-promoted (and
3653
+ // shown-then-ignored) loop is the one DETERMINISTIC yes/no signal the
3654
+ // hook owns — record both verdicts in the sidecar. Best-effort and
3655
+ // skew-safe: a bundle without provenance.mjs just skips. Repeat
3656
+ // observations of the same decayed draft dedup into count++ (jid).
3657
+ try {
3658
+ const judgments = [];
3659
+ for (const a of (draftRes.approvedDrafts || [])) {
3660
+ judgments.push({
3661
+ kind: 'rule', direction: 'rule-covers', verdict: 'yes', source: 'draft-promotion',
3662
+ actor: 'agent', session: sid, from: { text: a.text }, to: { text: a.skillText },
3663
+ });
3664
+ }
3665
+ // A draft surfaced across RULE_DRAFT_MAX_SURFACINGS distinct
3666
+ // sessions and still unpromoted has EARNED its silence — that "no"
3667
+ // is a verdict too (the decay itself only stops the nagging).
3668
+ const nowTs = Date.now();
3669
+ for (const d of readRuleDrafts()) {
3670
+ if (!d || !d.id || !d.text) continue;
3671
+ if ((nowTs - (d.lastSeen || d.firstSeen || 0)) >= RULE_DRAFT_TTL_MS) continue;
3672
+ if ((Array.isArray(d.shownSessions) ? d.shownSessions.length : 0) < RULE_DRAFT_MAX_SURFACINGS) continue;
3673
+ judgments.push({
3674
+ kind: 'rule', direction: 'silence', verdict: 'no', source: 'draft-decay',
3675
+ actor: 'agent', session: sid, from: { text: String(d.text || '') },
3676
+ });
3677
+ }
3678
+ if (judgments.length) {
3679
+ const prov = await import(new URL('./provenance.mjs', import.meta.url).href);
3680
+ prov.recordJudgments(BRAIN, judgments);
3681
+ }
3682
+ } catch { /* stale deployment or unwritable sidecar — additive signal only */ }
3646
3683
  // Cross-lane FINDING routing rides the SAME verify gate: a finding this
3647
3684
  // session verified about a file outside its own scope is drafted and
3648
3685
  // routed to whoever declared that file. Nothing is sent. `recentPaths`
@@ -3888,7 +3925,13 @@ async function capture(lib) {
3888
3925
  if (drainedLanded) clearDrained();
3889
3926
  reportBatch();
3890
3927
  try { await refreshAgentsBrief(lib, out); } catch { /* AGENTS.md refresh is best-effort */ }
3891
- return { ...res.stats, batchScope: scope, ...(batchError ? { batchError: String(batchError.message || batchError).slice(0, 160) } : {}), ...(quarantined.length ? { quarantined } : {}) };
3928
+ // ownCounts marks the OWN/DRAINED boundary for the judgment writer
3929
+ // below: drained batches are APPENDED to the shared cards/resolutions
3930
+ // arrays (and land at the same tail indices), so "index < own length"
3931
+ // is exactly "this session's marker". Without it, scope 'all' stamped
3932
+ // THIS session's prompt hash and actor grade onto verdicts whose
3933
+ // markers came from ANOTHER session's queued batch (2026-09-29 review).
3934
+ return { ...res.stats, batchScope: scope, ownCounts: { cards: ownCards.length, resolutions: ownResolutions.length }, ...(batchError ? { batchError: String(batchError.message || batchError).slice(0, 160) } : {}), ...(quarantined.length ? { quarantined } : {}) };
3892
3935
  };
3893
3936
  // Canonical cross-process lock (heartbeat + token-checked release, shared
3894
3937
  // with the MCP engine and the desktop app). Stale bundles missing the module
@@ -3942,6 +3985,82 @@ async function capture(lib) {
3942
3985
  else if (outcome === 'fallback-milestone') d.action = 'resolve-unmatched';
3943
3986
  }
3944
3987
  }
3988
+ // Marker-verdict provenance (2026-09-29): what this Stop's markers ACTUALLY
3989
+ // retired — closes: fulfillments (stats.closedCards), archived ✓ resolves
3990
+ // (per-outcome cardIds), and the hint edges a full resolve settled — goes
3991
+ // to the machine-local provenance sidecar with an honest actor grade: a
3992
+ // terminal marker is agent-emittable, so the strongest attainable class is
3993
+ // 'agent-human-adjacent' (a machine-turn-guarded human prompt sat above
3994
+ // it), never 'human'. The prompt itself rides as a hash only.
3995
+ //
3996
+ // ATTRIBUTION BOUNDARY (2026-09-29 review): scope 'all' — the NORMAL
3997
+ // success path — means the landed arrays are this session's markers (the
3998
+ // prefix) PLUS queued batches from EARLIER sessions appended by the drain.
3999
+ // This session's prompt hash, session id and human-adjacency grade may
4000
+ // only ever stamp the own prefix; a drained batch's verdict is recorded
4001
+ // with the honest bare 'agent' grade and NO session/prompt — its human,
4002
+ // if one existed, sat above a different transcript this hook never saw.
4003
+ // Scope 'drained' (own half failed entirely) is skipped outright, as
4004
+ // before. Best-effort + skew-safe.
4005
+ if (stats.batchScope !== 'drained') {
4006
+ try {
4007
+ const ownCardCount = stats.ownCounts ? stats.ownCounts.cards : cards.length;
4008
+ const ownResolutionCount = stats.ownCounts ? stats.ownCounts.resolutions : resolutions.length;
4009
+ const hasDrained = cards.length > ownCardCount || resolutions.length > ownResolutionCount;
4010
+ const ownStamp = { actor: lastUserPrompt ? 'agent-human-adjacent' : 'agent', humanPromptText: lastUserPrompt, session: sid };
4011
+ const drainedStamp = { actor: 'agent' }; // another session's marker: agent-asserted, nothing stronger provable
4012
+ const judgments = [];
4013
+ for (const cc of (stats.closedCards || [])) {
4014
+ // The closing card is the captured card whose closes: named this
4015
+ // target (stats.closedCards carries the closed card, not the
4016
+ // closer). findIndex against the shared array places the closer
4017
+ // on the own/drained side of the boundary; an own closer with
4018
+ // the same closes text as a drained one wins the prefix scan,
4019
+ // which is correct — this session really did carry the marker.
4020
+ const closerIdx = cards.findIndex(c => c && c.closes && String(c.closes).trim().slice(0, 80) === cc.target);
4021
+ if (closerIdx === -1) continue; // unidentifiable closer (engine skew) — never guess attribution
4022
+ judgments.push({
4023
+ kind: 'pair', direction: 'closes', verdict: 'yes', source: 'marker-closes',
4024
+ ...(closerIdx < ownCardCount ? ownStamp : drainedStamp),
4025
+ from: { id: cc.id, text: cc.title || '' },
4026
+ to: { text: cards[closerIdx].text },
4027
+ });
4028
+ }
4029
+ const seenResolved = new Set();
4030
+ for (const o of (stats.resolutionOutcomes || [])) {
4031
+ if (o.outcome !== 'archived') continue;
4032
+ const cardId = o.cardId || o.id;
4033
+ if (!cardId || seenResolved.has(cardId)) continue;
4034
+ seenResolved.add(cardId);
4035
+ const r = resolutions[o.i] || {};
4036
+ judgments.push({
4037
+ kind: 'pair', direction: 'closes', verdict: 'yes', source: 'marker-resolve',
4038
+ ...(o.i < ownResolutionCount ? ownStamp : drainedStamp),
4039
+ from: { id: cardId },
4040
+ to: { text: r.text || '' },
4041
+ });
4042
+ }
4043
+ for (const s of (stats.settledHints || [])) {
4044
+ if (s.via !== 'resolve') continue; // a byId settle is the reconcile-confirm's record, not the hook's
4045
+ // `ri` joins the settle to the resolution that caused it (the
4046
+ // engine mirrors resolutionOutcomes' indexing). Without the
4047
+ // join — a stale engine bundle — a merged capture cannot place
4048
+ // the settle on either side of the boundary, so it is skipped
4049
+ // rather than guessed.
4050
+ if (!Number.isInteger(s.ri) && hasDrained) continue;
4051
+ judgments.push({
4052
+ kind: 'pair', direction: 'closes', verdict: 'yes', source: 'hint-settle',
4053
+ ...((Number.isInteger(s.ri) ? s.ri < ownResolutionCount : true) ? ownStamp : drainedStamp),
4054
+ from: { id: s.fromId, text: s.fromText || '' },
4055
+ to: { id: s.toId, text: s.toText || '' },
4056
+ });
4057
+ }
4058
+ if (judgments.length) {
4059
+ const prov = await import(new URL('./provenance.mjs', import.meta.url).href);
4060
+ prov.recordJudgments(BRAIN, judgments);
4061
+ }
4062
+ } catch { /* stale deployment or unwritable sidecar — additive signal only */ }
4063
+ }
3945
4064
  // Same honesty for ~: a thin update was APPENDED to its card, not a
3946
4065
  // replacement, and one the card already says changed nothing — a ledger
3947
4066
  // that still read `update` would claim a rewrite that never happened.
@@ -4128,6 +4247,31 @@ async function cachedStruct(lib) {
4128
4247
  // SessionStart already paid for. typeof-guarded: an older engine falls back
4129
4248
  // to the uncached pure function, then to null (renderers compute their own).
4130
4249
  const STATUS_CACHE = CACHE.replace(/\.json$/, '.status.json');
4250
+ // ── Unified-lane index cache (1.87) ──────────────────────────────────────────
4251
+ // rankHookWords / rankHookFused each build a per-struct index that the engine
4252
+ // caches in a WeakMap — useless to THIS one-shot process, which would rebuild
4253
+ // both on every prompt (measured on the 2,984-card brain: ~250-300ms of a
4254
+ // ~740ms end-to-end prompt; ~470ms with this cache — machine-dependent, the
4255
+ // review probe saw ~1s of builds). So the built indexes are persisted next to
4256
+ // the struct cache,
4257
+ // keyed on the brain file's mtimeMs AND size, and re-seeded into the engine's
4258
+ // WeakMaps before ranking. Behaviour-neutral: the doc holds only derived
4259
+ // stats re-linked to live cards by id, and ANY problem (stale key, unknown
4260
+ // id, parse error, version-skewed engine) falls back to an in-memory rebuild.
4261
+ // Named .brief-cache-…hookix.json so pruneCacheDir covers it too.
4262
+ const HOOK_IX_CACHE = CACHE.replace(/\.json$/, '.hookix.json');
4263
+ function seedHookIndexes(lib, struct) {
4264
+ if (typeof lib.seedHookIndexCache !== 'function' || typeof lib.hookIndexCacheDoc !== 'function') return;
4265
+ let st = null; try { st = fs.statSync(BRAIN); } catch { return; }
4266
+ try {
4267
+ const doc = JSON.parse(fs.readFileSync(HOOK_IX_CACHE, 'utf8'));
4268
+ if (doc && doc.mtimeMs === st.mtimeMs && doc.size === st.size && lib.seedHookIndexCache(struct, doc)) return;
4269
+ } catch { /* miss/corrupt → rebuild below */ }
4270
+ try {
4271
+ const doc = lib.hookIndexCacheDoc(struct); // builds both indexes once, warming the WeakMaps
4272
+ fs.writeFileSync(HOOK_IX_CACHE, JSON.stringify({ mtimeMs: st.mtimeMs, size: st.size, ...doc }));
4273
+ } catch { /* cache is best-effort — the rankers rebuild in memory */ }
4274
+ }
4131
4275
  function cachedOpenStatusSummary(lib, struct, opts = {}) {
4132
4276
  if (!struct) return null;
4133
4277
  try {
@@ -4406,13 +4550,30 @@ async function promptRetrieve(lib) {
4406
4550
  // nothing (a peer's presence/ship is itself the signal). Empty string when solo.
4407
4551
  const peers = peerFooter(sid);
4408
4552
  const messages = messageFooter(sid, input.transcript_path, lib); // 📨 durable notes: offer, acknowledge, replay until explicit consumption
4409
- let hits = [], repeats = [], struct = null;
4410
- if (tokens.length) {
4553
+ // ── Unified recall (1.87) — the bench-measured two-lane policy ──────────
4554
+ // Ported line-for-line from the 2026-09-28 "unified balanced" bench winner
4555
+ // (dev/held-out split of real founder prompts; parity-proven against the
4556
+ // reference policy). Routing: a STRONG status prompt keeps the computed
4557
+ // digest below; a prompt whose UNIQUE CONTENT tokens reach
4558
+ // HOOK_MODEL_MIN_TOKENS takes the MODEL lane (rankHookFused over the warm
4559
+ // vector cache) when the embedder answers in budget and any card vector
4560
+ // exists, else the WORDS lane (rankHookWords, BM25-idf rarity with an
4561
+ // evidence bar) decides. Git-diff fileToks still feed the WORDS lane for
4562
+ // terse prompts but never count toward the model gate — they are not the
4563
+ // asker's words. A repeat nudge suppresses the model lane (and its
4564
+ // embedder cost) exactly as it suppressed the old semantic fallback.
4565
+ let repeats = [], struct = null, freshHits = [];
4566
+ // lane + its deciding evidence are logged for every HUMAN decision — the
4567
+ // silent rows are precisely the data the bars get re-fitted from in the
4568
+ // field (the 2026-09-16 audit had only "hits: 5" ×165 to go on); machine
4569
+ // turns are skipped at the write below (no refit signal, and they would
4570
+ // rotate real rows out of the cap). Note the health log keeps its 500-row
4571
+ // cap, and per-prompt rows still fill it faster than the old sem-only
4572
+ // rows did — a field audit sees a shorter window per file, and the
4573
+ // vocabulary changed: `lane`/`evidence` succeed `top1` (`sem` still lands).
4574
+ let lane = statusStrong ? 'status-digest' : 'no-tokens', laneEvidence = null;
4575
+ if (!statusStrong && tokens.length) {
4411
4576
  struct = await cachedStruct(lib);
4412
- // The lexical bar is the engine's measured export (1.86: 4 — one title
4413
- // word alone no longer injects; see HOOK_LEXICAL_MIN_SCORE). An older
4414
- // engine keeps the pre-1.86 bar of 3.
4415
- hits = lib.scoreCardsAgainstQuery(struct, tokens, { topK: 5, minScore: Number.isFinite(lib.HOOK_LEXICAL_MIN_SCORE) ? lib.HOOK_LEXICAL_MIN_SCORE : 3 });
4416
4577
  // PRECISION-first repeat nudge ("you already did this in another session"):
4417
4578
  // only on a do/build request, only completed-work cards, only high confidence.
4418
4579
  // Matched on the PROMPT's stated intent (ptoks), not the git-diff fallback. A
@@ -4420,66 +4581,64 @@ async function promptRetrieve(lib) {
4420
4581
  if (looksLikeWorkRequest(humanText || '') && typeof lib.detectRepeatWork === 'function') {
4421
4582
  try { repeats = lib.detectRepeatWork(struct, ptoks, { topK: 2 }); } catch { /* best-effort */ }
4422
4583
  }
4423
- }
4424
- const repeatIds = new Set(repeats.map(r => r.card.id));
4425
- let freshHits = hits.filter(h => !repeatIds.has(h.card.id));
4426
- // SEMANTIC fallback — runs ONLY on a lexical MISS (paraphrase / no keyword
4427
- // overlap, where you'd otherwise get zero recall). Gated behind the OPTIONAL
4428
- // on-device model, timeout-bounded (~1.2s), READ-ONLY (never embeds cards in
4429
- // this one-shot process — that's a 10s–195s stall). The common lexical-HIT
4430
- // prompt never enters this lane → zero added latency. Bulletproof: not installed
4431
- // / timeout / any failure → stays exactly today's pure-lexical behavior. The
4432
- // helper is optional+deploy-gated, so a missing copy degrades cleanly to lexical.
4433
- let semMode = 'lexical', semTop = null;
4434
- // Prompt-side admission (1.86): the fallback is an UNCORROBORATED five-card
4435
- // guess, and the measured cosines cannot tell an acknowledgement from a
4436
- // real question — but the prompt can. Fewer than FALLBACK_MIN_CONTENT_TOKENS
4437
- // content tokens ("build best in class", "continue where needed") skips the
4438
- // lane; the git-diff file tokens do not count, they are not the asker's words.
4439
- const fallbackEligible = typeof lib.lexicalMissFallbackEligible === 'function' ? lib.lexicalMissFallbackEligible(ptoks) : true;
4440
- if (!repeats.length && !freshHits.length && tokens.length && struct && !fallbackEligible) semMode = 'sem-skipped';
4441
- if (!repeats.length && !freshHits.length && tokens.length && struct && fallbackEligible) {
4442
- try {
4443
- const semlib = await import(new URL('./brain-semantic.mjs', import.meta.url).href);
4444
- if (typeof semlib.semanticVecs === 'function') {
4445
- // Embed the DERIVED human text, never the raw prompt — a mixed
4446
- // turn's stripped machine block must not re-contaminate the
4447
- // semantic query (parity with the lexical path above).
4448
- const sem = await semlib.semanticVecs(BRAIN, struct, humanText || '', { timeoutMs: 1500 });
4449
- if (!sem) semMode = 'sem-unavailable';
4450
- else {
4451
- // ONE production primitive for this lane (1.86). The ranking
4452
- // used to live inline here, which meant the surface every
4453
- // session actually receives was the one no harness could
4454
- // measure — an eval must IMPORT the ranker, never re-implement
4455
- // it. rankLexicalMissFallback in klypix-format.mjs is that
4456
- // primitive (scripts/eval-hook-lane.mjs measures it); its
4457
- // defaults are the measured ones. An older engine without the
4458
- // export degrades to the pre-1.86 inline ranking.
4459
- let ranked;
4460
- if (typeof lib.rankLexicalMissFallback === 'function') {
4461
- const r = lib.rankLexicalMissFallback(struct, sem, { topK: 5 });
4462
- ranked = r.hits; semTop = r.topCos;
4463
- } else {
4464
- const fresh = Date.now() - 30 * 86_400_000;
4465
- ranked = struct.cards
4466
- .filter(c => c.type !== 'container' && (c.text || '').trim() && !/^archive$/i.test(c.area || ''))
4467
- .map(c => { const v = sem.vecsMap.get(c.id); return { card: c, s: v ? sem.dot(sem.qv, v) : null }; })
4468
- .filter(x => x.s != null && x.s >= 0.30)
4469
- .map(x => { let score = x.s * 10; if ((x.card.createdAt || 0) >= fresh) score += 0.5; return { card: x.card, score }; })
4470
- .sort((a, b) => b.score - a.score).slice(0, 5);
4584
+ const repeatIds = new Set(repeats.map(r => r.card.id));
4585
+ if (typeof lib.rankHookWords === 'function' && typeof lib.rankHookFused === 'function') {
4586
+ seedHookIndexes(lib, struct);
4587
+ let decided = false;
4588
+ // MODEL lane gate, in this order: repeat-nudge check, then the
4589
+ // unique-content-token bar — semanticVecs (and its embedder load)
4590
+ // is never paid for a terse prompt or a nudged repeat.
4591
+ if (!repeats.length && new Set(ptoks).size >= lib.HOOK_MODEL_MIN_TOKENS) {
4592
+ try {
4593
+ const semlib = await import(new URL('./brain-semantic.mjs', import.meta.url).href);
4594
+ if (typeof semlib.semanticVecs === 'function') {
4595
+ // Embed the DERIVED human text, never the raw prompt — a
4596
+ // mixed turn's stripped machine block must not
4597
+ // re-contaminate the semantic query. semanticVecs
4598
+ // lowercases, reads the warm card-vector cache read-only,
4599
+ // and returns null on ANY failure (not installed /
4600
+ // timeout / empty cache) → the words lane decides.
4601
+ const sem = await semlib.semanticVecs(BRAIN, struct, humanText || '', { timeoutMs: 1500 });
4602
+ if (sem) {
4603
+ const fused = lib.rankHookFused(struct, sem, tokens, { topK: 5 });
4604
+ if (fused.hits.length) { // pool > 0; an empty pool is "no vectors" → words lane
4605
+ decided = true;
4606
+ laneEvidence = fused.best;
4607
+ if (fused.best >= fused.bar) { lane = 'model-shown'; freshHits = fused.hits; }
4608
+ else lane = 'model-silent'; // below-bar is a DECISION, never rescued by words
4609
+ }
4610
+ }
4471
4611
  }
4472
- freshHits = ranked;
4473
- semMode = ranked.length ? 'sem-hit' : 'sem-empty';
4474
- }
4612
+ } catch { /* any model-lane failure degrades to the words lane */ }
4613
+ }
4614
+ if (!decided) {
4615
+ const w = lib.rankHookWords(struct, tokens, { topK: 5 });
4616
+ laneEvidence = w.evidence;
4617
+ if (w.hits.length && w.evidence >= w.bar) { lane = 'words-shown'; freshHits = w.hits; }
4618
+ else lane = 'words-silent';
4475
4619
  }
4476
- } catch { semMode = 'sem-error'; }
4620
+ } else {
4621
+ // Version-skew fallback (older klypix-format without the 1.87
4622
+ // exports): the pre-unified lexical lane at HOOK_LEXICAL_MIN_SCORE.
4623
+ freshHits = lib.scoreCardsAgainstQuery(struct, tokens, { topK: 5, minScore: Number.isFinite(lib.HOOK_LEXICAL_MIN_SCORE) ? lib.HOOK_LEXICAL_MIN_SCORE : 3 });
4624
+ lane = freshHits.length ? 'lexical' : 'lexical-silent';
4625
+ }
4626
+ // Repeat ids are filtered from the SHOWN hits — the nudge block renders
4627
+ // those cards itself, with their correction overlays.
4628
+ freshHits = freshHits.filter(h => !repeatIds.has(h.card.id));
4629
+ }
4630
+ // Header choice downstream: only a model-lane show is a "semantic match".
4631
+ const semMode = lane === 'model-shown' ? 'sem-hit' : 'lexical';
4632
+ // Health row: HUMAN decisions only. A machine turn (humanText === null —
4633
+ // harness-injected reminders, task notifications) always lands 'no-tokens'
4634
+ // and carries zero refit signal, but an agent-team session generates
4635
+ // hundreds of them a day — logged, they would rotate the evidence-bearing
4636
+ // words-silent/model-silent rows out of the 500-row cap (2026-09-29
4637
+ // review). `sem` is kept for grep continuity with the pre-1.87 rows; the
4638
+ // old `top1` field's successor is `evidence` (the deciding lane value).
4639
+ if (humanText !== null) {
4640
+ try { appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode: 'prompt', sem: semMode, lane, hits: freshHits.length, mtok: new Set(ptoks).size, ...(Number.isFinite(laneEvidence) ? { evidence: Math.round(laneEvidence * 1000) / 1000 } : {}) }, 500); } catch { /* health log is best-effort */ }
4477
4641
  }
4478
- // The health row carries the best cosine the lane saw (`top1`) so the
4479
- // admission rule can be re-fitted from the field later without a
4480
- // transcript — the 2026-09-16 audit had only "hits: 5" ×165 to go on. A
4481
- // skipped lane is logged too (`sem-skipped`, with the content-token count).
4482
- if (semMode !== 'lexical') { try { appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode: 'prompt', sem: semMode, hits: freshHits.length, ...(Number.isFinite(semTop) ? { top1: Math.round(semTop * 1000) / 1000 } : {}), ...(semMode === 'sem-skipped' ? { content: ptoks.length } : {}) }, 500); } catch { /* */ } }
4483
4642
  // T8 STATUS-DIGEST INJECTION (2026-07-23): a status-shaped prompt gets the
4484
4643
  // COMPUTED current-state digest INSTEAD of card hits — so an agent that
4485
4644
  // never queried the brain still answers "what is remaining?" from state,
@@ -4958,11 +5117,15 @@ function staleOpenFooter(stale) {
4958
5117
  const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
4959
5118
  const lines = [];
4960
5119
  if (gaps && gaps.length) {
5120
+ // The id-addressed confirm is the PREFERRED channel (2026-09-29):
5121
+ // it names exact ids, settles the dashed hint to a solid 'closed
5122
+ // by', and records the confirmation trail — the bare ✓ marker
5123
+ // resolves by prose match and stays the human-typed fallback.
4961
5124
  lines.push('', '---',
4962
5125
  `## 🔧 Self-heal — ${total} open card(s) look DONE (a later milestone covers them)`,
4963
- `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:`,
4964
- '· done → `🧠 BRAIN [Area] ✓: <what it resolved to>` — stamps ✅ + archives the open card (or add `closes: <its title>` to the milestone marker).');
4965
- 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)}`);
5126
+ `These ❓/🎯 cards still read as open, but a shipped 🏁 milestone appears to fulfil them — so recall keeps surfacing already-done goals as "next". Verify, then confirm + close each:`,
5127
+ '· done → the per-card `brain_reconcile mode:"claims" confirm:[{ id, milestoneId }]` call below — stamps ✅, archives the open card, and settles its hint with a recorded confirmation. Human-typed fallback: `🧠 BRAIN [Area] ✓: <what it resolved to>` (or add `closes: <its title>` to the milestone marker).');
5128
+ 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)}\n confirm: \`brain_reconcile mode:"claims" confirm:[{ id:"${g.open.id}", milestoneId:"${g.by.id}" }]\``);
4966
5129
  }
4967
5130
  // Plan-shaped cards (2026-08-23): the same leak for proposals that never
4968
5131
  // carried a ❓ — recall serves them as current intent ("only a proposal")