klypix-mcp 1.76.0 → 1.78.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/README.md CHANGED
@@ -1,12 +1,14 @@
1
1
  # Every project gets a brain.
2
2
 
3
- **A portable project workspace with a shared brain.**
3
+ **Active state management for multi-agent coding — a local-first active context engine with a shared brain.**
4
4
  *One project. One shared understanding.*
5
5
 
6
- **One shared project brain for multi-agent coding.** `klypix-mcp` gives supported coding agents
7
- and humans one versioned `brain.klypix` in your repo, containing current decisions, corrections,
8
- evidence anchors, open questions, active work, and handoffs. Agents read it and write to it over
9
- MCP. You read it and correct it in the [KLYPIX app](https://klypix.com).
6
+ **One actively managed project brain for multi-agent coding.** `klypix-mcp` keeps one versioned
7
+ `brain.klypix` in your repo: the project's active state — current decisions, corrections, evidence
8
+ anchors, open questions, active work, and handoffs. Corrections supersede stale decisions,
9
+ `brain_challenge` tests proposed decisions against standing rules and reversed approaches, and
10
+ sessions declare their scope and get warned about same-machine file overlap. Agents read it and
11
+ write to it over MCP. You read it and correct it in the [KLYPIX app](https://klypix.com).
10
12
 
11
13
  > **One project. Many agents. One current understanding.**
12
14
 
@@ -246,7 +246,15 @@ const flatten = (code) => code
246
246
  .replace(/\.\.\/src\/klypix-(core|format)\.mjs/g, './klypix-$1.mjs')
247
247
  // brain-doctor + agent-rules (the server's lazy `import('../src/brain-doctor.mjs')`
248
248
  // for the brain_doctor tool) → flat sibling refs in the runtime layout.
249
- .replace(/\.\.\/src\/(bench|brain-doctor|agent-presence|agent-rules|finding-routing|mcp-presence|mcp-supervisor|mcp-auto-update|presence-relay|semantic-memory|runtime-inspector|project-graph|git-capture-install)\.mjs/g, './$1.mjs')
249
+ // capture-gap: the worker imports it from BOTH brain_note (the per-session
250
+ // capture receipt) and brain_sync (the uncaptured-work check). Both are
251
+ // wrapped in try/catch by design, so an unflattened path here would not
252
+ // crash — it would make the agent-neutral half silently never fire in the
253
+ // deployed runtime. test/cli-args.mjs "G: no unresolved ../src/*.mjs" is the
254
+ // only thing standing between that and the field; it caught exactly this.
255
+ // (remote-client deliberately absent: the Remote feature was removed in
256
+ // 1.73.x, and this cherry-pick must not resurrect it — recorded rule.)
257
+ .replace(/\.\.\/src\/(bench|brain-doctor|agent-presence|agent-rules|capture-gap|enrichment|finding-routing|mcp-presence|mcp-supervisor|mcp-auto-update|presence-relay|semantic-memory|runtime-inspector|project-graph|git-capture-install)\.mjs/g, './$1.mjs')
250
258
  .replace(/klypix-worker\.mjs/g, 'klypix-mcp-worker.mjs')
251
259
  .replace(/const PKG_VERSION = \(\(\) => \{[\s\S]*?\}\)\(\);/, `const PKG_VERSION = '${VERSION}'; // baked at install (flat layout has no package.json)`);
252
260
 
@@ -291,12 +299,15 @@ try {
291
299
  // (`git tag --points-at HEAD` semantics inside collectRepoState): the
292
300
  // release tag names the exact evidence commit, so any non-HEAD comparison
293
301
  // would certify code the tag never covered.
294
- const checkout = exists(path.join(PKG_ROOT, '.git')) ? collectRepoState(PKG_ROOT) : null;
295
- const sourceDecision = deploySourceDecision({ checkout, allowUntagged: ALLOW_UNTAGGED });
302
+ const gitPresent = exists(path.join(PKG_ROOT, '.git'));
303
+ const checkout = gitPresent ? collectRepoState(PKG_ROOT) : null;
304
+ const sourceDecision = deploySourceDecision({ checkout, allowUntagged: ALLOW_UNTAGGED, gitPresent });
296
305
  const checkoutLabel = `v${VERSION}, branch ${checkout?.branch || '(detached)'}, head ${checkout?.headShort || '?'}`;
297
306
  if (sourceDecision.action === 'refuse') {
298
307
  releaseInstallLockSync(installLock);
299
- console.error(`✗ refusing to deploy an UNRELEASED source checkout machine-globally: ${checkoutLabel} — no release tag v${VERSION} at HEAD; no files were changed.`);
308
+ console.error(sourceDecision.source === 'unverifiable-git-state'
309
+ ? `✗ refusing to deploy from a git checkout whose state could NOT be verified: ${checkoutLabel} — the git probes failed (busy machine?); no files were changed. Retry, or acknowledge a dev deploy explicitly.`
310
+ : `✗ refusing to deploy an UNRELEASED source checkout machine-globally: ${checkoutLabel} — no release tag v${VERSION} at HEAD; no files were changed.`);
300
311
  console.error(' Released installs come from the registry: npx -y klypix-mcp@latest install');
301
312
  console.error(' To deliberately deploy this working tree (a dev deploy), acknowledge it: re-run with --allow-untagged or KLYPIX_MCP_ALLOW_UNTAGGED=1.');
302
313
  console.error(' An acknowledged dev deploy is stamped dev-owned, so brain_doctor shows it and auto-update will not silently replace it.');
@@ -380,7 +391,7 @@ try {
380
391
  // canvas-view-app.html is the canvas_view MCP App UI — staged raw (an HTML
381
392
  // file must never get a JS-comment banner) beside the flat server, which
382
393
  // resolves it via its ./canvas-view-app.html candidate path.
383
- for (const f of ['global-brain-hook.mjs', 'brain-semantic.mjs', 'semantic-memory.mjs', 'brain-note.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', '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']) {
394
+ for (const f of ['global-brain-hook.mjs', 'capture-gap.mjs', 'brain-semantic.mjs', 'semantic-memory.mjs', 'enrichment.mjs', 'brain-note.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', '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']) {
384
395
  const s = path.join(SRC, f); if (exists(s)) staged.push({ dst: f, content: fs.readFileSync(s, 'utf8') });
385
396
  }
386
397
  for (const [src, dst] of [
@@ -402,8 +413,19 @@ try {
402
413
  let n = 0;
403
414
  for (const st of renameOrder) { renameSyncWithBackoff(path.join(BRAIN_DIR, st.dst + '.klypix-new'), path.join(BRAIN_DIR, st.dst)); n++; }
404
415
 
405
- // 3) mark the dir an ESM package
406
- fs.writeFileSync(path.join(BRAIN_DIR, 'package.json'), JSON.stringify({ name: 'klypix-project-brain', private: true, type: 'module' }, null, 2));
416
+ // 3) mark the dir an ESM package — WITH the brain-core version in it. The
417
+ // version was only ever discoverable from .brain-version.json or by regexing
418
+ // the baked PKG_VERSION out of klypix-mcp-server.mjs; an agent asked "what
419
+ // version is this brain?", read package.json (the one place everybody looks),
420
+ // got `version: undefined`, and reported the install unidentifiable
421
+ // (2026-08-16 field report). This is provenance, never the gate: the
422
+ // never-downgrade decision still reads .brain-version.json / .mcp-runtime.json.
423
+ fs.writeFileSync(path.join(BRAIN_DIR, 'package.json'), JSON.stringify({
424
+ name: 'klypix-project-brain',
425
+ ...(VERSION ? { version: VERSION } : {}),
426
+ private: true,
427
+ type: 'module',
428
+ }, null, 2));
407
429
 
408
430
  // 5) wire the 4 hooks into settings.json (refuse on invalid JSON; back up;
409
431
  // atomic). A background runtime-only update refreshes the scripts while
@@ -686,7 +686,19 @@ server.registerTool('brain_note', {
686
686
  canvas: z.string().optional().describe('Brain canvas filename/path. Defaults to the project brain ("brain").'),
687
687
  },
688
688
  }, async ({ text, marker, area, closes, canvas }, extra) => {
689
- return toContent(await opBrainNote({ vault: mcpPresence.vault, canvas: boundBrainCanvas(canvas), text, area, marker: marker || '', closes, via: extra.klypixClientName }));
689
+ // Both 1.77 and 1.78 ride this call: the enrichment question (the asker's
690
+ // vocabulary for retrieval) AND the per-session capture receipt below.
691
+ const result = await opBrainNote({ vault: mcpPresence.vault, canvas: boundBrainCanvas(canvas), text, area, marker: marker || '', closes, via: extra.klypixClientName, enrichmentQuestion: mcpPresence.declaredIntent });
692
+ // Per-session capture receipt — this is what stops the uncaptured-work nudge
693
+ // from firing at a session that DID record its reasoning, just through MCP
694
+ // rather than a 🧠 marker. The Stop hook and this server share one session-id
695
+ // space (the same presence lane), so the receipt written here is the one the
696
+ // hook reads. Best-effort: a receipt failure must never fail the note.
697
+ try {
698
+ const { recordSessionCapture } = await import('../src/capture-gap.mjs');
699
+ recordSessionCapture(extra?.klypixRequestIdentity?.sessionId || mcpPresence.id);
700
+ } catch { /* receipt is best-effort */ }
701
+ return toContent(result);
690
702
  });
691
703
 
692
704
  server.registerTool('brain_message', {
@@ -877,6 +889,77 @@ server.registerTool('brain_sync', {
877
889
  }
878
890
  } catch { /* observation is best-effort — never fail a sync */ }
879
891
  }
892
+ // ── Uncaptured-work check, host-neutral half ────────────────────────────────
893
+ // The Stop hook can REFUSE a stop; every other host has no lifecycle hook at
894
+ // all, so brain_sync is the only place the same question can be asked. Stamp
895
+ // the git HEAD at "start", and at "complete" compare it against HEAD: commits
896
+ // that landed during this task with nothing recorded about WHY is the same gap
897
+ // the hook catches, and it produces the same drafted card. Advisory only — it
898
+ // never changes status/mutation, so completion semantics are untouched.
899
+ let captureGapText = '';
900
+ {
901
+ const projectDir = report.structured?.project || mcpPresence.vault;
902
+ const sid = String(extra?.klypixRequestIdentity?.sessionId || mcpPresence.id || '');
903
+ // `require` does not exist in ESM — the child_process binding has to be
904
+ // imported, and gitOut must stay synchronous for the ancestry/count chain.
905
+ let execFileSync = null;
906
+ try { ({ execFileSync } = await import('child_process')); } catch { /* no child_process → no check */ }
907
+ const gitOut = (args) => {
908
+ if (!execFileSync) return '';
909
+ try {
910
+ return String(execFileSync('git', args, { cwd: projectDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000 })).trim();
911
+ } catch { return ''; }
912
+ };
913
+ // `git merge-base --is-ancestor` answers with its EXIT CODE and prints
914
+ // nothing, so it must be read as ok/not-ok — an output check would treat
915
+ // "not an ancestor" and "success" as the same empty string.
916
+ const gitOk = (args) => {
917
+ if (!execFileSync) return false;
918
+ try {
919
+ execFileSync('git', args, { cwd: projectDir, stdio: 'ignore', timeout: 4000 });
920
+ return true;
921
+ } catch { return false; }
922
+ };
923
+ try {
924
+ const gap = await import('../src/capture-gap.mjs');
925
+ if (phase === 'start' && projectDir && sid) {
926
+ const head = gitOut(['rev-parse', 'HEAD']);
927
+ if (head) gap.recordTaskBaseline(sid, { head, project: projectDir });
928
+ } else if (phase === 'complete' && projectDir && sid) {
929
+ const baseline = gap.readTaskBaseline(sid);
930
+ if (baseline?.head) {
931
+ // An ancestry check first: a rebase/reset makes the range meaningless,
932
+ // and reporting a rewritten history as "commits you didn't record" is
933
+ // exactly the cry-wolf that gets a nudge switched off.
934
+ const reachable = gitOk(['merge-base', '--is-ancestor', baseline.head, 'HEAD']);
935
+ const count = reachable ? Number(gitOut(['rev-list', '--count', '--no-merges', `${baseline.head}..HEAD`]) || 0) : 0;
936
+ if (count > 0) {
937
+ const subjects = gitOut(['log', '--no-merges', '--format=%s', `${baseline.head}..HEAD`])
938
+ .split('\n').map((s) => s.trim()).filter(Boolean).slice(0, 5);
939
+ // Bodies decide whether ANY rationale was recorded — the same rule
940
+ // the hook uses, so the two halves never disagree about one session.
941
+ const withRationale = gitOut(['log', '--no-merges', '--format=%x1e%b', `${baseline.head}..HEAD`])
942
+ .split('\x1e').map((b) => b.replace(/\s+/g, ' ').trim()).filter((b) => b.length >= 12).length;
943
+ const decision = gap.captureGapDecision({
944
+ commitTotal: count,
945
+ commitCards: withRationale,
946
+ sessionCaptured: gap.sessionHasCaptured(sid),
947
+ });
948
+ if (decision) {
949
+ const changed = gitOut(['diff', '--name-only', `${baseline.head}..HEAD`]).split('\n').filter(Boolean).slice(0, 20);
950
+ const draft = gap.draftCaptureMarker({
951
+ commits: subjects.map((subject) => ({ subject })),
952
+ filesTouched: changed,
953
+ });
954
+ captureGapText = gap.captureGapReason({ ...decision, draft, mode: 'advise' });
955
+ gap.recordCaptureGapNudge(sid);
956
+ }
957
+ }
958
+ }
959
+ gap.clearTaskBaseline(sid);
960
+ }
961
+ } catch { /* the check never fails a sync */ }
962
+ }
880
963
  let taskContext = null;
881
964
  if (phase !== 'complete' && include_context !== false && (intent || files?.length)) {
882
965
  taskContext = await opBrainTaskContext({
@@ -927,7 +1010,7 @@ server.registerTool('brain_sync', {
927
1010
  return {
928
1011
  content: [{
929
1012
  type: 'text',
930
- text: [report.text, harnessText, shipNotice, contextText, timingText].filter(Boolean).join('\n\n'),
1013
+ text: [report.text, harnessText, shipNotice, captureGapText, contextText, timingText].filter(Boolean).join('\n\n'),
931
1014
  }],
932
1015
  structuredContent,
933
1016
  ...(report.isError ? { isError: true } : {}),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.76.0",
4
- "description": "Shared project brain and MCP coordination server for multi-agent coding.",
3
+ "version": "1.78.0",
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",
7
7
  "keywords": [
@@ -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/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/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/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"
@@ -0,0 +1,258 @@
1
+ // Uncaptured-work detection — ONE implementation, every host.
2
+ //
3
+ // Capture is opt-in: something has to CHOOSE to emit a 🧠 marker or call
4
+ // brain_note. That fails hardest exactly where it matters most — a long, loaded
5
+ // session is both the least likely to remember and the most likely to have
6
+ // produced something worth keeping. The 2026-08-16 field report is the canonical
7
+ // instance: a multi-session workstream took a file format to a REGISTERED IANA
8
+ // media type (spec written, 8 schema-drift bugs fixed, PR merged, registration
9
+ // landed) and the brain recorded NONE of it — the agent had been writing to its
10
+ // own private per-project memory dir the whole time, which no peer can read.
11
+ //
12
+ // This module holds the parts that must behave identically whether the caller is
13
+ // the Claude Code Stop hook (which can REFUSE the stop) or an MCP host calling
14
+ // brain_sync(phase:"complete") (which can only advise). Both ask the same
15
+ // question of the same facts and hand back the same drafted card.
16
+ //
17
+ // Three deliberate properties, each learned the hard way:
18
+ //
19
+ // • A DRAFT, NOT AN ASSIGNMENT. Asking a drained session to "record the
20
+ // decision" buys a one-line receipt ("merged PR #406") — the event, never
21
+ // the reasoning, which is the half that was actually lost. So the draft
22
+ // pre-fills every mechanical fact from the artifacts themselves (ship, commit
23
+ // subjects, files, area) and leaves exactly ONE slot: why it matters. The
24
+ // agent supplies the only sentence it alone knows, or discards the card.
25
+ //
26
+ // • PER-SESSION PROVENANCE, NOT FILE MTIME. The first cut stayed silent when
27
+ // brain.klypix changed during the session — which, on a shared brain with
28
+ // ten live sessions, means a PEER's card silences the session that shipped
29
+ // and recorded nothing. That is backwards: it goes quiet exactly in the
30
+ // multi-agent case. Silence now requires THIS session id to have captured.
31
+ //
32
+ // • NARROW ON PURPOSE. A nudge that cries wolf gets ignored, then disabled.
33
+ // It needs a real artifact and a total absence of recorded rationale — a
34
+ // good commit body IS capture and buys silence.
35
+
36
+ import fs from 'fs';
37
+ import os from 'os';
38
+ import path from 'path';
39
+
40
+ // Shared sidecar: which sessions have been nudged (once each) and which have
41
+ // captured something (so a later turn in the same session stays quiet). Lives
42
+ // beside the other brain-home state; the Claude Code session id and the MCP
43
+ // presence session id are the SAME id space (the hook and the server share one
44
+ // lane), so a brain_note through MCP correctly silences the Stop hook.
45
+ export function captureGapStatePath(home = os.homedir()) {
46
+ return path.join(home, '.claude', 'project-brain', '.capture-gap.json');
47
+ }
48
+
49
+ export function readCaptureGapState(file = captureGapStatePath()) {
50
+ try {
51
+ const j = JSON.parse(fs.readFileSync(file, 'utf8'));
52
+ return {
53
+ nudged: Array.isArray(j?.nudged) ? j.nudged.map(String) : [],
54
+ captured: (j && typeof j.captured === 'object' && !Array.isArray(j.captured)) ? j.captured : {},
55
+ baselines: (j && typeof j.baselines === 'object' && !Array.isArray(j.baselines)) ? j.baselines : {},
56
+ };
57
+ } catch { return { nudged: [], captured: {}, baselines: {} }; }
58
+ }
59
+
60
+ // Best-effort, never throws. A torn write only loses the memo — `stop_hook_active`
61
+ // remains the primary loop guard and a re-nudge is bounded to one extra turn.
62
+ function writeCaptureGapState(state, file) {
63
+ try {
64
+ fs.mkdirSync(path.dirname(file), { recursive: true });
65
+ fs.writeFileSync(file, JSON.stringify(state));
66
+ } catch { /* best-effort */ }
67
+ }
68
+
69
+ export function recordCaptureGapNudge(sessionId, file = captureGapStatePath()) {
70
+ const sid = String(sessionId || ''); if (!sid) return;
71
+ const state = readCaptureGapState(file);
72
+ state.nudged = [...state.nudged.filter(s => s !== sid), sid].slice(-300);
73
+ writeCaptureGapState(state, file);
74
+ }
75
+
76
+ // Called by EVERY capture path that knows its session: the Stop hook after an
77
+ // authored capture, and the brain_note MCP verb. This is what makes the silence
78
+ // rule per-session instead of per-file.
79
+ export function recordSessionCapture(sessionId, file = captureGapStatePath(), now = Date.now()) {
80
+ const sid = String(sessionId || ''); if (!sid) return;
81
+ const state = readCaptureGapState(file);
82
+ state.captured[sid] = now;
83
+ // Bound the map: keep the 300 most recent sessions.
84
+ const entries = Object.entries(state.captured).sort((a, b) => Number(b[1]) - Number(a[1])).slice(0, 300);
85
+ state.captured = Object.fromEntries(entries);
86
+ writeCaptureGapState(state, file);
87
+ }
88
+
89
+ export function sessionHasCaptured(sessionId, file = captureGapStatePath()) {
90
+ const sid = String(sessionId || ''); if (!sid) return false;
91
+ return Boolean(readCaptureGapState(file).captured[sid]);
92
+ }
93
+
94
+ // ── Task baseline, for hosts with no lifecycle hook ──────────────────────────
95
+ // The Stop hook gets its commit window from the hook's own per-project baseline.
96
+ // An MCP host has no such thing, so brain_sync stamps the git HEAD at phase
97
+ // "start" and reads it back at phase "complete" — the same "what landed during
98
+ // this task" question, answered from the only two calls every host makes. Kept
99
+ // in the same sidecar so there is one file to reason about.
100
+ export function recordTaskBaseline(sessionId, { head = '', project = '' } = {}, file = captureGapStatePath(), now = Date.now()) {
101
+ const sid = String(sessionId || ''); if (!sid || !head) return;
102
+ const state = readCaptureGapState(file);
103
+ state.baselines = (state.baselines && typeof state.baselines === 'object' && !Array.isArray(state.baselines)) ? state.baselines : {};
104
+ state.baselines[sid] = { head: String(head), project: String(project || ''), at: now };
105
+ const entries = Object.entries(state.baselines).sort((a, b) => Number(b[1]?.at || 0) - Number(a[1]?.at || 0)).slice(0, 300);
106
+ state.baselines = Object.fromEntries(entries);
107
+ writeCaptureGapState(state, file);
108
+ }
109
+
110
+ export function readTaskBaseline(sessionId, file = captureGapStatePath()) {
111
+ const sid = String(sessionId || ''); if (!sid) return null;
112
+ const b = readCaptureGapState(file).baselines?.[sid];
113
+ return (b && b.head) ? b : null;
114
+ }
115
+
116
+ export function clearTaskBaseline(sessionId, file = captureGapStatePath()) {
117
+ const sid = String(sessionId || ''); if (!sid) return;
118
+ const state = readCaptureGapState(file);
119
+ if (state.baselines?.[sid]) { delete state.baselines[sid]; writeCaptureGapState(state, file); }
120
+ }
121
+
122
+ // ── The decision ─────────────────────────────────────────────────────────────
123
+ // Returns null to stay silent, or the observed evidence. Pure: every input is
124
+ // supplied by the caller, so the Stop hook (transcript + git) and brain_sync
125
+ // (git + presence) can answer the same question from what each can see.
126
+ export function captureGapDecision({
127
+ authored = 0, commitTotal = 0, commitCards = 0, shipped = [], pushed = false,
128
+ filesTouched = 0, sessionCaptured = false, stopHookActive = false,
129
+ alreadyNudged = false, env = process.env,
130
+ } = {}) {
131
+ if (String(env.KLYPIX_BRAIN_NUDGE || '').toLowerCase() === 'off') return null;
132
+ if (stopHookActive || alreadyNudged) return null;
133
+ if (authored > 0 || commitCards > 0) return null; // rationale exists somewhere
134
+ if (sessionCaptured) return null; // THIS session already fed the brain
135
+ const significant = shipped.length > 0 || commitTotal >= 2
136
+ || (pushed && commitTotal >= 1) || filesTouched >= 6;
137
+ if (!significant) return null;
138
+ const undocumented = Math.max(0, commitTotal - commitCards);
139
+ const evidence = [];
140
+ if (shipped.length) evidence.push(shipped.slice(0, 3).join(' · '));
141
+ if (commitTotal) evidence.push(`${commitTotal} new commit${commitTotal === 1 ? '' : 's'}${undocumented === commitTotal ? ' (no rationale body on any of them)' : ''}`);
142
+ else if (pushed) evidence.push('pushed to a remote');
143
+ if (filesTouched) evidence.push(`${filesTouched} file${filesTouched === 1 ? '' : 's'} edited`);
144
+ return evidence.length ? { evidence } : null;
145
+ }
146
+
147
+ // ── The draft ────────────────────────────────────────────────────────────────
148
+ const slug = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
149
+ const CC_SCOPE = /^(?:feat|fix|perf|refactor|chore|docs|test|build|ci)(?:\(([^)]+)\))?!?:\s*(.+)$/i;
150
+
151
+ // The area a drafted card should land in, inferred from what the session
152
+ // actually touched: a conventional-commit scope first (the author's own label),
153
+ // then the dominant source directory, then a neutral fallback. Never invents a
154
+ // topic — an area guessed from prose is worse than "Ship".
155
+ export function inferArea({ commits = [], filesTouched = [] } = {}) {
156
+ for (const c of commits) {
157
+ const m = CC_SCOPE.exec(String(c?.subject || ''));
158
+ if (m && m[1] && m[1].trim()) return m[1].trim();
159
+ }
160
+ const counts = new Map();
161
+ for (const p of filesTouched) {
162
+ const parts = String(p).replace(/\\/g, '/').split('/').filter(Boolean);
163
+ // Skip a leading src/lib/app wrapper — "src" is never a useful area.
164
+ const seg = parts.length >= 2
165
+ ? (/^(src|lib|app|packages|apps)$/i.test(parts[0]) ? parts[1] : parts[0])
166
+ : '';
167
+ if (seg && !/\./.test(seg)) counts.set(seg, (counts.get(seg) || 0) + 1);
168
+ }
169
+ let best = '', n = 0;
170
+ for (const [k, v] of counts) if (v > n) { best = k; n = v; }
171
+ return best || 'Ship';
172
+ }
173
+
174
+ const shortFileList = (files, max = 3) => {
175
+ const uniq = [...new Set(files.map(f => String(f).replace(/\\/g, '/').split('/').pop()).filter(Boolean))];
176
+ if (!uniq.length) return '';
177
+ const head = uniq.slice(0, max).join(', ');
178
+ return uniq.length > max ? `${head} (+${uniq.length - max} more)` : head;
179
+ };
180
+
181
+ // Build the ready-to-emit marker. Everything mechanical is filled in from the
182
+ // artifacts; the ONE thing left blank is the sentence only the session can
183
+ // supply. That asymmetry is the whole design — a fully pre-written card would
184
+ // just be approved, and we would be back to capturing the event without the why.
185
+ export const WHY_SLOT = '<one sentence: the decision, obligation, or discovery a future session needs — if there is none, say so and discard this>';
186
+ // A draft pasted back with the slot STILL IN IT is a placeholder wearing a
187
+ // card's clothes: it would sit in the brain reading, to every future session, as
188
+ // though the reasoning had been recorded. That is strictly worse than no card,
189
+ // so the marker guard refuses it and says why. The sentinel deliberately matches
190
+ // the head of WHY_SLOT above — test/capture-gap.mjs asserts the two agree, so
191
+ // they cannot drift apart in silence.
192
+ export const UNFILLED_DRAFT_RE = /WHY THIS MATTERS:\s*<\s*one sentence/i;
193
+ export const looksLikeUnfilledDraft = (text) => UNFILLED_DRAFT_RE.test(String(text || ''));
194
+ export function draftCaptureMarker({ shipped = [], commits = [], filesTouched = [], area = '' } = {}) {
195
+ const resolvedArea = area || inferArea({ commits, filesTouched });
196
+ const facts = [];
197
+ if (shipped.length) facts.push(shipped.slice(0, 2).join(' · '));
198
+ const subjects = commits
199
+ .map(c => { const m = CC_SCOPE.exec(String(c?.subject || '')); return (m ? m[2] : String(c?.subject || '')).trim(); })
200
+ .filter(Boolean).slice(0, 3);
201
+ if (subjects.length) facts.push(subjects.map(s => `"${s}"`).join('; '));
202
+ const files = shortFileList(filesTouched);
203
+ if (files) facts.push(files);
204
+ // A ship with no subjects and no files still deserves a draft — the marker
205
+ // shape matters more than the richness of the pre-fill.
206
+ const head = facts.length ? facts.join(' — ') : 'work completed this session';
207
+ const marker = shipped.length ? '!' : ''; // a ship is a milestone; everything else a decision
208
+ return {
209
+ area: resolvedArea,
210
+ marker,
211
+ text: `${head}. WHY THIS MATTERS: ${WHY_SLOT}`,
212
+ line: `🧠 BRAIN [${resolvedArea}]${marker ? ' ' + marker : ''}: ${head}. WHY THIS MATTERS: ${WHY_SLOT}`,
213
+ tags: [`#${slug(resolvedArea)}`].filter(Boolean),
214
+ };
215
+ }
216
+
217
+ // ── The message ──────────────────────────────────────────────────────────────
218
+ // `mode` picks the register: 'refuse' is the Stop hook, which has just declined
219
+ // to end the turn; 'advise' is brain_sync(complete) on a host with no lifecycle
220
+ // hook, where this is the only channel available and nothing is being blocked.
221
+ export function captureGapReason({ evidence = [], draft = null, mode = 'refuse' } = {}) {
222
+ const lead = mode === 'refuse'
223
+ ? '[brain] 🧠 UNCAPTURED WORK — this session produced durable artifacts and recorded NOTHING in the project brain.'
224
+ : '🧠 UNCAPTURED WORK — this task produced durable artifacts and recorded nothing in the project brain.';
225
+ const out = [
226
+ lead,
227
+ ` Observed: ${evidence.join(' · ')}.`,
228
+ ' Nothing recorded the reasoning: no 🧠 marker, no ✓/~, no rationale-bearing commit body, and this',
229
+ ' session has not written the brain through any channel.',
230
+ ];
231
+ if (draft) {
232
+ out.push(
233
+ '',
234
+ ' A card is DRAFTED from what this session actually did. The facts are filled in; supply the one',
235
+ ' sentence only you know, then emit it verbatim (or correct it first):',
236
+ '',
237
+ ` ${draft.line}`,
238
+ '',
239
+ mode === 'refuse'
240
+ ? ' Emitting that line in your reply is all it takes — the Stop hook harvests it. Or call brain_note.'
241
+ : ' Pass it to brain_note (text + area), or emit it as a marker if your host harvests them.',
242
+ );
243
+ } else {
244
+ out.push(
245
+ ' Record it now — one line in your reply:',
246
+ ' 🧠 BRAIN [Area]: <the decision and why> (`!` milestone · `?` open question · `+` reusable rule)',
247
+ ' — or call the brain_note MCP verb.',
248
+ );
249
+ }
250
+ out.push(
251
+ mode === 'refuse'
252
+ ? ' If there is genuinely nothing durable to keep, say so in one sentence and stop; this will not ask again this session.'
253
+ : ' If there is genuinely nothing durable to keep, ignore this; it will not be raised again for this session.',
254
+ ' NOTE: your own memory directory (~/.claude/projects/<project>/memory/) is NOT the project brain. Writing',
255
+ ' there is private to this host — no other session, agent, or human can read it. Only brain.klypix is shared.',
256
+ );
257
+ return out.join('\n') + '\n';
258
+ }
@@ -0,0 +1,167 @@
1
+ // Retrieval enrichment — the question that produced a card becomes searchable
2
+ // text for it.
3
+ //
4
+ // The measured failure of this brain's retrieval is a VOCABULARY gap inside a
5
+ // compressed cosine space: paraphrase questions ("grab-and-move navigation")
6
+ // share no words with the cards that answer them ("Pan = dedicated hand tool"),
7
+ // and the 2026-08-17 A/B falsified structural enrichment (area/tags/neighbour
8
+ // titles) a second time — prepending more project jargon compresses the space
9
+ // further (recall@5 62% → 52%). What a card is missing is the ASKER'S language,
10
+ // and the capture pipeline holds it for free: the human prompt (Claude hook,
11
+ // machine-turn-guarded) or the session's declared intent (MCP brain_note) that
12
+ // was live when the card was captured. Recording that alongside the card and
13
+ // feeding it to the embedder widens the vocabulary bridge without a model call,
14
+ // a format change, or anything new on the canvas.
15
+ //
16
+ // SIDECAR, DELIBERATELY. Card-shape changes are the expensive kind (merge
17
+ // driver, sync, renderer, read_canvas all must learn them — recorded blast-
18
+ // radius rule), and this data is a retrieval-quality signal with the same
19
+ // machine-local scope as the vector cache it feeds. It lives beside that cache
20
+ // in ~/.claude/project-brain/enrichment/, keyed per brain.
21
+ //
22
+ // KEYED BY BODY PREFIX, NOT CARD ID. Capture does not learn the id the engine
23
+ // assigns, and ids change across merge twins. The stored card TEXT always
24
+ // embeds the marker body verbatim, so a normalized body prefix is a stable,
25
+ // id-free join key: the read side substring-matches it against normalized card
26
+ // text — only for cards being (re)embedded, so the scan cost rides the
27
+ // embedding cost it amortizes into.
28
+ import crypto from 'crypto';
29
+ import fs from 'fs';
30
+ import os from 'os';
31
+ import path from 'path';
32
+
33
+ export const ENRICHMENT_VERSION = 1;
34
+ export const ENRICHMENT_MAX_ENTRIES = 4096;
35
+ export const ENRICHMENT_MAX_QUESTIONS = 3;
36
+ export const ENRICHMENT_MAX_QUESTION_CHARS = 240;
37
+ export const ENRICHMENT_TTL_MS = 60 * 24 * 60 * 60 * 1000; // 60 days
38
+ export const ENRICHMENT_KEY_CHARS = 160;
39
+ const ENRICHMENT_APPLY_CAP_CHARS = 400; // max enrichment text appended per card at embed time
40
+
41
+ const sha16 = (value) => crypto.createHash('sha1').update(String(value)).digest('hex').slice(0, 16);
42
+
43
+ // One normalization for BOTH sides of the join. Lowercase + collapsed
44
+ // whitespace survives the decorations capture adds around the body (area
45
+ // prefix, emoji, #tags) because the body itself is embedded verbatim.
46
+ export const normalizeForKey = (text) => String(text || '').toLowerCase().replace(/\s+/g, ' ').trim();
47
+
48
+ export const enrichmentKeyFor = (bodyText) => normalizeForKey(bodyText).slice(0, ENRICHMENT_KEY_CHARS);
49
+
50
+ export function enrichmentFileFor(brainPath, home = os.homedir()) {
51
+ const key = sha16(path.resolve(String(brainPath || '')).replace(/\\/g, '/').toLowerCase());
52
+ return path.join(home, '.claude', 'project-brain', 'enrichment', `${key}.json`);
53
+ }
54
+
55
+ function readFile(file) {
56
+ try {
57
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
58
+ if (!parsed || parsed.v !== ENRICHMENT_VERSION || typeof parsed.entries !== 'object') return { v: ENRICHMENT_VERSION, entries: {} };
59
+ return parsed;
60
+ } catch {
61
+ // Corrupt or absent both start empty: enrichment is an additive quality
62
+ // signal, never load-bearing state — losing it costs recall, not truth.
63
+ return { v: ENRICHMENT_VERSION, entries: {} };
64
+ }
65
+ }
66
+
67
+ const cleanQuestion = (q) => String(q || '').replace(/\s+/g, ' ').trim().slice(0, ENRICHMENT_MAX_QUESTION_CHARS);
68
+
69
+ /**
70
+ * Record question/intent text for captured card bodies. `items` is
71
+ * [{ body, question }]; entries merge per body key (deduped, newest kept,
72
+ * capped). Bounded overall: past ENRICHMENT_MAX_ENTRIES the OLDEST entries are
73
+ * pruned — enrichment is a rolling quality window, not an archive, and unlike
74
+ * the claims lane nothing downstream depends on any single entry existing.
75
+ */
76
+ export function recordEnrichment(brainPath, items, { home = os.homedir(), now = Date.now() } = {}) {
77
+ const list = (Array.isArray(items) ? items : [])
78
+ .map((item) => ({ key: enrichmentKeyFor(item?.body), q: cleanQuestion(item?.question) }))
79
+ .filter((item) => item.key.length >= 24 && item.q.length >= 8);
80
+ if (!list.length) return { recorded: 0 };
81
+ const file = enrichmentFileFor(brainPath, home);
82
+ fs.mkdirSync(path.dirname(file), { recursive: true });
83
+ const data = readFile(file);
84
+ let recorded = 0;
85
+ for (const { key, q } of list) {
86
+ const entry = data.entries[key] || { q: [], ts: 0 };
87
+ if (!entry.q.includes(q)) {
88
+ entry.q = [q, ...entry.q].slice(0, ENRICHMENT_MAX_QUESTIONS);
89
+ recorded++;
90
+ }
91
+ entry.ts = now;
92
+ data.entries[key] = entry;
93
+ }
94
+ // TTL + size prune, oldest first.
95
+ const keys = Object.keys(data.entries);
96
+ for (const key of keys) {
97
+ if (now - Number(data.entries[key].ts || 0) > ENRICHMENT_TTL_MS) delete data.entries[key];
98
+ }
99
+ const remaining = Object.keys(data.entries);
100
+ if (remaining.length > ENRICHMENT_MAX_ENTRIES) {
101
+ remaining.sort((a, b) => Number(data.entries[a].ts || 0) - Number(data.entries[b].ts || 0));
102
+ for (const key of remaining.slice(0, remaining.length - ENRICHMENT_MAX_ENTRIES)) delete data.entries[key];
103
+ }
104
+ const tmp = `${file}.tmp-${process.pid}`;
105
+ fs.writeFileSync(tmp, JSON.stringify(data), 'utf8');
106
+ fs.renameSync(tmp, file);
107
+ return { recorded };
108
+ }
109
+
110
+ /**
111
+ * Load the enrichment map for a brain: [{ key, q: [...] }]. Memoized on the
112
+ * file's mtime: the retrieval hot path hashes EVERY card on every call, so it
113
+ * must reuse one parsed array (and, via the join memo below, one join result
114
+ * per card) until the sidecar actually changes.
115
+ */
116
+ const readMemo = new Map(); // file -> { stamp, entries }
117
+ export function readEnrichment(brainPath, { home = os.homedir(), now = Date.now() } = {}) {
118
+ const file = enrichmentFileFor(brainPath, home);
119
+ // Invalidation stamp = mtime AND size. mtime alone is not enough: under load
120
+ // two writes can land inside one mtime tick, and the memo then served the
121
+ // PRE-rewrite parse — caught by EN2 failing in the full chain (same-tick
122
+ // corrupt-file rewrite read back as the old healthy entries) while passing
123
+ // standalone, where the writes never clustered.
124
+ let stamp = '';
125
+ try { const st = fs.statSync(file); stamp = `${st.mtimeMs}|${st.size}`; } catch { stamp = ''; }
126
+ const memo = readMemo.get(file);
127
+ if (memo && memo.stamp === stamp) return memo.entries;
128
+ const data = stamp ? readFile(file) : { entries: {} };
129
+ const entries = Object.entries(data.entries)
130
+ .filter(([, entry]) => now - Number(entry.ts || 0) <= ENRICHMENT_TTL_MS)
131
+ .map(([key, entry]) => ({ key, q: (entry.q || []).map(cleanQuestion).filter(Boolean) }));
132
+ readMemo.set(file, { stamp, entries });
133
+ if (readMemo.size > 8) readMemo.delete(readMemo.keys().next().value);
134
+ return entries;
135
+ }
136
+
137
+ // Join results memoized per entries-array identity (readEnrichment keeps the
138
+ // array stable until the file changes), bounded so a pathological brain cannot
139
+ // grow the memo without limit.
140
+ const joinMemo = new WeakMap(); // entries[] -> Map(memoKey -> enrichment text)
141
+
142
+ /**
143
+ * The enrichment text to append to ONE card's embed input: the questions of
144
+ * every entry whose body-prefix occurs in the card's normalized text. Linear
145
+ * in enrichment entries on a memo miss; a hit is one Map lookup.
146
+ */
147
+ export function enrichmentTextFor(entries, cardText) {
148
+ if (!entries?.length) return '';
149
+ const haystack = normalizeForKey(String(cardText || '').slice(0, 1500));
150
+ if (haystack.length < 24) return '';
151
+ let cache = joinMemo.get(entries);
152
+ if (!cache) { cache = new Map(); joinMemo.set(entries, cache); }
153
+ const memoKey = `${haystack.slice(0, 64)}|${haystack.length}`;
154
+ const hit = cache.get(memoKey);
155
+ if (hit !== undefined) return hit;
156
+ const questions = [];
157
+ for (const entry of entries) {
158
+ if (haystack.includes(entry.key)) {
159
+ for (const q of entry.q) {
160
+ if (!questions.includes(q)) questions.push(q);
161
+ }
162
+ }
163
+ }
164
+ const result = questions.length ? questions.join('\n').slice(0, ENRICHMENT_APPLY_CAP_CHARS) : '';
165
+ if (cache.size < 8192) cache.set(memoKey, result);
166
+ return result;
167
+ }
@@ -10,8 +10,12 @@
10
10
  //
11
11
  // Bulletproof by contract: it runs on EVERY session/turn in EVERY project, so
12
12
  // it must be an INSTANT no-op when there's no ./brain.klypix, must NEVER throw,
13
- // and must ALWAYS exit 0. The format/IO work is lazy-imported only when a brain
14
- // is actually present, keeping non-brain projects to a bare existsSync.
13
+ // and must ALWAYS exit 0 with exactly ONE deliberate exception: the
14
+ // uncaptured-work nudge (captureGapDecision) exits 2 on the Stop hook to refuse
15
+ // a stop that would have thrown away a session's reasoning. No FAILURE ever
16
+ // exits non-zero; only that one guarded, once-per-session decision does.
17
+ // The format/IO work is lazy-imported only when a brain is actually present,
18
+ // keeping non-brain projects to a bare existsSync.
15
19
  //
16
20
  // This is the source-of-truth copy (lives in the KLYPIX repo, version
17
21
  // controlled); it is copied to ~/.claude/project-brain/ alongside
@@ -261,10 +265,24 @@ function transcriptSizeBytes(file) {
261
265
  // LEDGER (per-project): every capture DECISION — added / skipped-seen /
262
266
  // skipped-example / resolve / update — so you can see exactly what
263
267
  // the harvester did (and didn't) ingest, and why.
264
- // HEALTH (global): one line per hook run — mode, ok/err, brain + brief
268
+ // HEALTH (per-project): one line per hook run — mode, ok/err, brain + brief
265
269
  // bytes — so a dead/stale/unsynced live copy stops being invisible.
266
270
  const LEDGER = path.resolve(CWD, '.claude', 'brain-capture-log.jsonl');
267
- const HEALTH = path.join(os.homedir(), '.claude', 'project-brain', '.hook-health.jsonl');
271
+ // HEALTH was ONE global file interleaving every project on the machine. Two ways
272
+ // that misled (2026-08-16 field report, both reproduced): the 500-line cap is
273
+ // GLOBAL, so a busy neighbour evicts a quiet project's whole history long before
274
+ // it has 500 lines of its own; and `tail`-ing it from inside a project — which
275
+ // the self-check footer's own pointer invites — answers a DIFFERENT project's
276
+ // question (a KLYPIX line was read as AgentLit's, right down to a brainBytes that
277
+ // matched neither). Now one file per project directory, keyed by basename + a
278
+ // short hash of the absolute path so two checkouts both named `docs` stay apart.
279
+ // The legacy global file is still READ as a fallback (project-filtered) so an
280
+ // upgraded install isn't blind on its first session, and is never written again.
281
+ const HEALTH_LEGACY = path.join(os.homedir(), '.claude', 'project-brain', '.hook-health.jsonl');
282
+ const HEALTH = path.join(
283
+ os.homedir(), '.claude', 'project-brain', 'health',
284
+ `${(String(path.basename(CWD) || 'project').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'project')}-${sha(CWD.toLowerCase()).slice(0, 8)}.jsonl`,
285
+ );
268
286
  // npm-currency cache — the Stop hook refreshes this at most once/day (best-effort,
269
287
  // failure-silent); the SessionStart footer reads ONLY this file (zero network) to
270
288
  // surface a stale install. {pkg, latest, checkedAt, lastError?}.
@@ -272,6 +290,11 @@ const NPM_CURRENCY = path.join(os.homedir(), '.claude', 'project-brain', '.npm-c
272
290
  const NPM_CURRENCY_TTL = 24 * 60 * 60 * 1000; // ≤ once/day refresh throttle
273
291
  const LOCK = path.resolve(CWD, '.claude', 'brain-capture.lock'); // serialize concurrent captures
274
292
  const DRY = process.argv.includes('--dry-run'); // inspect a capture without writing
293
+ // The hook exits 0 by contract. The ONE deliberate exception is the
294
+ // uncaptured-work nudge (see captureGapDecision below), which uses the Stop
295
+ // hook's documented exit-2 path to refuse a stop and hand the reason back to the
296
+ // model. Nothing else ever sets this — a thrown error still exits 0.
297
+ let EXIT_CODE = 0;
275
298
  const nowIso = () => { try { return new Date().toISOString(); } catch { return ''; } };
276
299
  const brainBytes = () => { try { return fs.statSync(BRAIN).size; } catch { return 0; } };
277
300
  function appendJsonl(file, obj, maxLines = 0) {
@@ -1787,16 +1810,23 @@ function commitToCard(c) {
1787
1810
  ...(closes ? { closes } : {}),
1788
1811
  };
1789
1812
  }
1813
+ // `total` is the RAW new-commit count and `entries` the raw commits themselves,
1814
+ // both independent of how many became cards — the counts differ exactly when
1815
+ // commits shipped without rationale (a bare `chore:`, a subject-only commit),
1816
+ // which is the signal the uncaptured-work nudge needs, and the entries are what
1817
+ // its DRAFT is built from (subjects the session already wrote beat anything the
1818
+ // hook could invent).
1819
+ const NO_COMMITS = (newLastCommit) => ({ cards: [], total: 0, entries: [], newLastCommit });
1790
1820
  async function gatherCommitCards(prevCommit) {
1791
1821
  let head = '';
1792
- try { head = git('rev-parse HEAD'); } catch { return { cards: [], newLastCommit: prevCommit }; }
1793
- if (!head) return { cards: [], newLastCommit: prevCommit };
1794
- if (!prevCommit) return { cards: [], newLastCommit: head }; // BASELINE: record HEAD, capture nothing
1795
- if (head === prevCommit) return { cards: [], newLastCommit: head }; // no new commits
1822
+ try { head = git('rev-parse HEAD'); } catch { return NO_COMMITS(prevCommit); }
1823
+ if (!head) return NO_COMMITS(prevCommit);
1824
+ if (!prevCommit) return NO_COMMITS(head); // BASELINE: record HEAD, capture nothing
1825
+ if (head === prevCommit) return NO_COMMITS(head); // no new commits
1796
1826
  try { execSync(`git merge-base --is-ancestor ${prevCommit} HEAD`, { cwd: CWD, stdio: 'ignore', timeout: 2000 }); }
1797
- catch { return { cards: [], newLastCommit: head }; } // history rewritten → re-baseline, don't dump
1827
+ catch { return NO_COMMITS(head); } // history rewritten → re-baseline, don't dump
1798
1828
  let raw = '';
1799
- try { raw = git(`log ${prevCommit}..HEAD --no-merges --format=%x1e%H%x1f%s%x1f%b`); } catch { return { cards: [], newLastCommit: head }; }
1829
+ try { raw = git(`log ${prevCommit}..HEAD --no-merges --format=%x1e%H%x1f%s%x1f%b`); } catch { return NO_COMMITS(head); }
1800
1830
  const entries = parseCommitLog(raw);
1801
1831
  // Revert retraction (same batch): a "feat: X" shipped-and-reverted before
1802
1832
  // this hook ran must not close the open card X claimed to fulfil. Reverts
@@ -1827,9 +1857,45 @@ async function gatherCommitCards(prevCommit) {
1827
1857
  try { appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode: 'commit-closes', ok: true, err: `closes stripped (branch ${branch || '?'} is not default)` }, 500); } catch { /* best-effort */ }
1828
1858
  }
1829
1859
  }
1830
- return { cards, newLastCommit: head };
1860
+ return { cards, total: entries.length, entries: entries.slice(0, 15), newLastCommit: head };
1831
1861
  }
1832
1862
 
1863
+ // ── Uncaptured-work nudge — silence must not be enough to end a shipping session ──
1864
+ // Capture is OPT-IN: something has to CHOOSE to emit a 🧠 marker or call
1865
+ // brain_note. That fails hardest exactly where it matters most — a long, loaded
1866
+ // session is both the least likely to remember and the most likely to have
1867
+ // produced something worth keeping. The 2026-08-16 field report is the canonical
1868
+ // instance: a multi-session workstream took a file format to a REGISTERED IANA
1869
+ // media type (spec written, 8 schema-drift bugs fixed, PR merged, registration
1870
+ // landed) and the brain recorded NONE of it — the agent had been writing to its
1871
+ // own private per-project memory dir the whole time, which no peer can read.
1872
+ // Nothing noticed that a session had merged a PR and completed an external
1873
+ // registration while contributing zero cards; a human caught it by intuition.
1874
+ //
1875
+ // Closed with the escalation this product already relies on elsewhere: a warning
1876
+ // a model may relay or may not is worth little, so the Stop hook REFUSES the stop
1877
+ // (exit 2 — the documented "prevents Claude from stopping, continues the
1878
+ // conversation" path) and hands back what it observed. The session can still end
1879
+ // in one line ("nothing durable — the commit body covers it"); it can no longer
1880
+ // end by saying NOTHING.
1881
+ //
1882
+ // Deliberately narrow, because a nudge that cries wolf gets ignored and then
1883
+ // switched off:
1884
+ // • fires only when NOTHING recorded rationale — no 🧠 marker, no ✓/~, and no
1885
+ // rationale-bearing commit card either. A good commit body IS capture.
1886
+ // • needs a real artifact: a ship event (PR merge / release / publish / tag),
1887
+ // a push, ≥2 new commits, or ≥6 files edited this session.
1888
+ // • silent if brain.klypix itself changed inside the session window — brain_note,
1889
+ // an MCP verb, or a peer already fed it. A busy multi-session project therefore
1890
+ // nudges rarely, which is the right direction: false silence beats false nagging.
1891
+ // • at most ONCE per session (stop_hook_active, plus a durable session list for
1892
+ // hosts that don't set it), and off entirely with KLYPIX_BRAIN_NUDGE=off.
1893
+ // The decision, the draft and the per-session bookkeeping live in
1894
+ // src/capture-gap.mjs so the Stop hook and brain_sync(complete) answer the same
1895
+ // question the same way. Imported LAZILY at the call site (typeof-guarded), so a
1896
+ // stale flat deployment missing the file degrades to no nudge rather than
1897
+ // crashing the capture — the same contract every other optional module here has.
1898
+
1833
1899
  // ── Live cross-session ledger (in-flight ship/version/milestone, 2026-06-28) ─────
1834
1900
  // The brain is ASYNC: a session's decisions/ships land in brain.klypix only at Stop
1835
1901
  // (capture() below), so CONCURRENT sessions read PAST state and are BLIND to what a
@@ -2516,6 +2582,13 @@ async function capture(lib) {
2516
2582
  };
2517
2583
  const shellCmds = []; // shell commands seen in the transcript (ship-event capture)
2518
2584
  const errorIds = new Set(); // tool_use ids whose result errored — skip those ship-events
2585
+ // Question enrichment (1.77): the HUMAN prompt nearest above a marker is
2586
+ // the natural-language question that produced the card — recorded to the
2587
+ // retrieval sidecar so the card becomes findable in the asker's own words.
2588
+ // deriveIntentFromPrompt is the machine-turn guard: harness-injected "user"
2589
+ // turns (task notifications, hook output) never pollute the vocabulary.
2590
+ let lastUserPrompt = '';
2591
+ const enrichmentPairs = [];
2519
2592
  for (let transcriptIndex = 0; transcriptIndex < lines.length; transcriptIndex++) {
2520
2593
  const ln = lines[transcriptIndex];
2521
2594
  let e; try { e = JSON.parse(ln); } catch { continue; }
@@ -2531,6 +2604,14 @@ async function capture(lib) {
2531
2604
  );
2532
2605
  if (entryInScope) noteFiles(filesInEntry(e));
2533
2606
  scanToolBlocks(e, shellCmds, errorIds);
2607
+ const um = e?.message ?? e;
2608
+ if (um?.role === 'user') {
2609
+ const rawUser = typeof um.content === 'string'
2610
+ ? um.content
2611
+ : Array.isArray(um.content) ? um.content.filter(part => part?.type === 'text' && typeof part.text === 'string').map(part => part.text).join('\n') : '';
2612
+ const human = deriveIntentFromPrompt(rawUser);
2613
+ if (human) lastUserPrompt = human.slice(0, 240);
2614
+ }
2534
2615
  const text = textOf(e);
2535
2616
  if (!text.includes('🧠')) continue;
2536
2617
  for (const raw of text.split('\n')) {
@@ -2593,6 +2674,21 @@ async function capture(lib) {
2593
2674
  ledger.push({ action: 'skipped-example', area, preview });
2594
2675
  continue;
2595
2676
  }
2677
+ // Fourth shape, same principle: a DRAFT (from the uncaptured-work
2678
+ // nudge) pasted back with its "WHY THIS MATTERS:" slot unfilled. The
2679
+ // draft deliberately pre-fills every mechanical fact and leaves one
2680
+ // blank, so an unfilled one carries the event and none of the
2681
+ // reasoning — and would read to every future session as though the
2682
+ // reasoning HAD been recorded. Banking that is worse than banking
2683
+ // nothing, so refuse it and say exactly why. The sentinel mirrors
2684
+ // WHY_SLOT in capture-gap.mjs (inlined: this is the hot marker loop,
2685
+ // which must not depend on an optional module being deployed).
2686
+ if (/WHY THIS MATTERS:\s*<\s*one sentence/i.test(body)) {
2687
+ ledger.push({ action: 'skipped-unfilled-draft', area, preview });
2688
+ process.stderr.write('[brain] ⚠️ drafted card NOT captured — its "WHY THIS MATTERS:" slot is still the placeholder. '
2689
+ + 'Replace it with the one sentence only you know, then re-emit; or drop the card if there is nothing durable.\n');
2690
+ continue;
2691
+ }
2596
2692
  // Dedup is for ADDITIVE markers only (decision / ? / !): re-capturing
2597
2693
  // one would stack a duplicate card. Type is in the key so a self-heal
2598
2694
  // ~ / ✓ on the SAME text isn't confused with the original decision.
@@ -2625,6 +2721,7 @@ async function capture(lib) {
2625
2721
  const tagLine = [areaTag, ...fileTags].filter(Boolean).join(' ');
2626
2722
  const card = (area ? `${area}: ${prefix}${body}` : `${prefix}${body}`) + (tagLine ? `\n${tagLine}` : '');
2627
2723
  cards.push({ text: card, area, borderColor, ...(closes ? { closes } : {}), ...(evidence ? { evidence } : {}), ...(verify ? { verify } : {}) });
2724
+ if (lastUserPrompt) enrichmentPairs.push({ body, question: lastUserPrompt });
2628
2725
  ledger.push({ action: type === '?' ? 'add-question' : type === '!' ? 'add-milestone' : isSkill ? 'add-skill' : 'add-decision', area, preview, files: fileTags, ...(closes ? { closes } : {}), ...(evidence ? { ev: evidence.map(e => e.ref) } : {}) });
2629
2726
  }
2630
2727
  }
@@ -2725,7 +2822,7 @@ async function capture(lib) {
2725
2822
  // Commit-body auto-capture: rationale-bearing feat/fix/perf commits since
2726
2823
  // the last run (independent of markers), pushed into the SAME capture batch.
2727
2824
  const prevCommit = readLastCommit();
2728
- const { cards: commitCards, newLastCommit } = await gatherCommitCards(prevCommit);
2825
+ const { cards: commitCards, total: commitTotal, entries: commitEntries, newLastCommit } = await gatherCommitCards(prevCommit);
2729
2826
  for (const cc of commitCards) {
2730
2827
  // Auto-skill from the flow: a rule-stating commit ("fix: imports must stay
2731
2828
  // at top — TDZ") is a reusable gotcha, not a one-time event. Re-glyph it as a
@@ -2760,6 +2857,49 @@ async function capture(lib) {
2760
2857
  // is this turn's real touch-set — the other half of "own scope".
2761
2858
  await draftFindingsFromCards(cards, verified, sid, recentPaths);
2762
2859
  }
2860
+ // Uncaptured-work nudge. Runs BEFORE the nothing-to-capture early return,
2861
+ // because "the batch is empty" is precisely the case it exists for. It only
2862
+ // writes stderr + sets the exit code, so the rest of capture() proceeds
2863
+ // untouched. Lazy, typeof-guarded import: a stale flat deployment without
2864
+ // capture-gap.mjs simply doesn't nudge.
2865
+ const AUTHORED_ACTIONS = new Set(['add-decision', 'add-question', 'add-milestone', 'add-skill', 'resolve', 'update', 'skipped-seen']);
2866
+ const authoredCount = ledger.filter(d => AUTHORED_ACTIONS.has(d.action)).length;
2867
+ if (!DRY) {
2868
+ try {
2869
+ const gapLib = await import(new URL('./capture-gap.mjs', import.meta.url).href);
2870
+ if (typeof gapLib.captureGapDecision === 'function') {
2871
+ const sid = String(input.session_id || '');
2872
+ const state = gapLib.readCaptureGapState();
2873
+ const gap = gapLib.captureGapDecision({
2874
+ authored: authoredCount,
2875
+ commitTotal,
2876
+ commitCards: commitCards.length,
2877
+ shipped: shipSummaries,
2878
+ pushed: shellCmds.some(({ id, cmd }) => !errorIds.has(id) && /\bgit\s+push\b/i.test(cmd)),
2879
+ filesTouched: recentPaths.length,
2880
+ // Per-SESSION, not per-file: a peer writing the shared brain
2881
+ // must never silence the session that shipped and said nothing.
2882
+ sessionCaptured: Boolean(sid && state.captured[sid]),
2883
+ stopHookActive: input.stop_hook_active === true,
2884
+ alreadyNudged: Boolean(sid && state.nudged.includes(sid)),
2885
+ });
2886
+ if (gap) {
2887
+ const draft = typeof gapLib.draftCaptureMarker === 'function'
2888
+ ? gapLib.draftCaptureMarker({ shipped: shipSummaries, commits: commitEntries, filesTouched: recentPaths })
2889
+ : null;
2890
+ gapLib.recordCaptureGapNudge(sid);
2891
+ // fs.writeSync, not process.stderr.write — this is followed by
2892
+ // an immediate exit, and a piped stderr write can be async.
2893
+ try { fs.writeSync(2, gapLib.captureGapReason({ ...gap, draft, mode: 'refuse' })); } catch { /* */ }
2894
+ EXIT_CODE = 2;
2895
+ appendJsonl(HEALTH, {
2896
+ ts: nowIso(), project: path.basename(CWD), mode: 'capture-gap', ok: true,
2897
+ evidence: gap.evidence.join(' · '), drafted: Boolean(draft), area: draft?.area || null,
2898
+ }, 500);
2899
+ }
2900
+ }
2901
+ } catch { /* the nudge is never allowed to break a capture */ }
2902
+ }
2763
2903
  // A queued batch from a prior lock-refused capture counts as work to do —
2764
2904
  // the authoritative drain happens INSIDE the brain lock (doCapture), so two
2765
2905
  // concurrent sessions can never both land the same queued batch.
@@ -2907,6 +3047,15 @@ async function capture(lib) {
2907
3047
  try { stats = await doCapture(gotLock); } finally { if (gotLock) releaseLock(LOCK); }
2908
3048
  }
2909
3049
  if (!stats) return;
3050
+ // Enrichment write rides ONLY a successful capture: cards that never landed
3051
+ // must not acquire question text. Lazy + skew-safe — a stale deployment
3052
+ // without enrichment.mjs just skips, costing recall, never correctness.
3053
+ if (enrichmentPairs.length && stats.added > 0) {
3054
+ try {
3055
+ const enrich = await import(new URL('./enrichment.mjs', import.meta.url).href);
3056
+ enrich.recordEnrichment(BRAIN, enrichmentPairs.map(pair => ({ body: pair.body, question: pair.question })));
3057
+ } catch { /* stale deployment or unwritable sidecar — additive signal only */ }
3058
+ }
2910
3059
  const bits = [`${stats.added} added`];
2911
3060
  if (stats.resolved) bits.push(`${stats.resolved} resolved`);
2912
3061
  if (stats.updated) bits.push(`${stats.updated} updated`);
@@ -2953,6 +3102,16 @@ async function capture(lib) {
2953
3102
  }
2954
3103
  appendJsonl(LEDGER, { ts: nowIso(), mode: 'capture', stats, decisions: ledger }, 1000);
2955
3104
  appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode: 'capture', ok: true, brainBytes: brainBytes(), added: stats.added, skipped: ledger.filter(d => d.action.startsWith('skipped')).length }, 500);
3105
+ // Per-session capture receipt — the silence rule for a LATER turn of this same
3106
+ // session. Only AUTHORED capture counts: a session whose only cards were
3107
+ // machine-harvested ships/commits has still recorded no reasoning, and must
3108
+ // stay nudgeable. brain_note (MCP) writes the same receipt for the same id.
3109
+ if (authoredCount > 0 && stats.added + (stats.resolved || 0) + (stats.updated || 0) > 0) {
3110
+ try {
3111
+ const gapLib = await import(new URL('./capture-gap.mjs', import.meta.url).href);
3112
+ gapLib.recordSessionCapture?.(String(input.session_id || ''));
3113
+ } catch { /* receipt is best-effort */ }
3114
+ }
2956
3115
  }
2957
3116
 
2958
3117
  // Keep a compact brain-brief block inside AGENTS.md so agents that read
@@ -3356,11 +3515,14 @@ function selfCheckFooter() {
3356
3515
  try {
3357
3516
  const probs = [];
3358
3517
  // (1) most-recent FAILED run per mode for THIS project, from the HEALTH log.
3359
- if (fs.existsSync(HEALTH)) {
3518
+ // The per-project log needs no filter; the legacy global one (read only
3519
+ // until this project has written its own file) still does.
3520
+ {
3360
3521
  const proj = path.basename(CWD);
3361
3522
  const mine = [];
3362
- for (const ln of fs.readFileSync(HEALTH, 'utf8').split('\n').slice(-400)) {
3363
- if (!ln) continue; try { const o = JSON.parse(ln); if (o.project === proj) mine.push(o); } catch { /* skip */ }
3523
+ const src = fs.existsSync(HEALTH) ? HEALTH : (fs.existsSync(HEALTH_LEGACY) ? HEALTH_LEGACY : null);
3524
+ for (const ln of (src ? fs.readFileSync(src, 'utf8').split('\n').slice(-400) : [])) {
3525
+ if (!ln) continue; try { const o = JSON.parse(ln); if (src === HEALTH || o.project === proj) mine.push(o); } catch { /* skip */ }
3364
3526
  }
3365
3527
  const latest = (mode) => { for (let i = mine.length - 1; i >= 0; i--) if (mine[i].mode === mode) return mine[i]; return null; };
3366
3528
  // Surface a mode's failure only if its latest run is ok:false AND RECENT.
@@ -3395,7 +3557,7 @@ function selfCheckFooter() {
3395
3557
  return '\n\n---\n## ⚠️ Brain self-check — the brain reported a problem with ITSELF\n'
3396
3558
  + 'The hook exits 0 by contract, so this is otherwise invisible (you\'d only notice by missing cards):\n'
3397
3559
  + probs.map(p => `- ${p}`).join('\n')
3398
- + `\n_Log: \`~/.claude/project-brain/.hook-health.jsonl\`. Re-deploy with \`node scripts/deploy-brain.mjs\`._\n`;
3560
+ + `\n_Log (THIS project only): \`${HEALTH.replace(os.homedir(), '~')}\`. Re-deploy with \`node scripts/deploy-brain.mjs\`._\n`;
3399
3561
  } catch { return ''; }
3400
3562
  }
3401
3563
 
@@ -3467,7 +3629,22 @@ function bakedBrainVersion(brainDir = path.dirname(NPM_CURRENCY)) {
3467
3629
  // compare against — no nag, no noise.
3468
3630
  function versionCurrencyFooter({ file = NPM_CURRENCY, brainDir = path.dirname(NPM_CURRENCY), env = process.env } = {}) {
3469
3631
  try {
3470
- let cache; try { cache = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return ''; }
3632
+ // TWO independent channels already fetch npm `latest` onto this machine and
3633
+ // neither knew about the other: this cache (refreshed by the Claude Code Stop
3634
+ // hook) and the MCP auto-updater's .autoupdate-status.json. On a machine driven
3635
+ // mostly through another host the Stop hook rarely runs, so THIS cache can sit
3636
+ // months behind while the updater's is current — the field report's "latest"
3637
+ // was ~29 minor versions stale. Take the FRESHER of the two.
3638
+ let cache; try { cache = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { cache = null; }
3639
+ try {
3640
+ const au = JSON.parse(fs.readFileSync(path.join(brainDir, '.autoupdate-status.json'), 'utf8'));
3641
+ const auAt = Date.parse(au?.checkedAt);
3642
+ if (/^\d+\.\d+\.\d+/.test(String(au?.latestVersion || '')) && Number.isFinite(auAt)
3643
+ && (!cache || !Number.isFinite(cache.checkedAt) || auAt > cache.checkedAt)) {
3644
+ cache = { pkg: 'klypix-mcp', latest: String(au.latestVersion), checkedAt: auAt };
3645
+ }
3646
+ } catch { /* no auto-update stamp → the Stop-hook cache stands alone */ }
3647
+ if (!cache) return '';
3471
3648
  const latest = cache && cache.latest;
3472
3649
  // Require a well-formed semver before comparing — rejects missing, the
3473
3650
  // "(offline)" sentinel, and any hand-corrupted cache value (e.g. `123`,
@@ -3475,11 +3652,27 @@ function versionCurrencyFooter({ file = NPM_CURRENCY, brainDir = path.dirname(NP
3475
3652
  if (!latest || !/^\d+\.\d+\.\d+/.test(String(latest))) return '';
3476
3653
  const baked = bakedBrainVersion(brainDir);
3477
3654
  if (!baked) return ''; // nothing to compare → silent
3478
- if (cmpSemver(latest, baked) <= 0) return ''; // current or ahead silent
3655
+ if (cmpSemver(latest, baked) <= 0) return ''; // current or ahead => silent
3656
+ // DATE the cached figure. This line reads as a live registry fact, and it
3657
+ // isn't — it's whatever the Stop hook last fetched. A field report (2026-08-16)
3658
+ // hit the failure mode: the notice claimed latest was one minor ahead while
3659
+ // the registry was ~29 ahead, because the refresh had not run in a long time
3660
+ // and nothing in the wording said so. Age is always shown; a cache older than
3661
+ // the refresh TTL also says the refresh itself has stopped running, which is
3662
+ // the real defect to chase (the number being wrong is only its symptom).
3663
+ const ageMs = Number.isFinite(cache.checkedAt) ? Math.max(0, Date.now() - cache.checkedAt) : null;
3664
+ const ageLabel = ageMs === null ? 'age unknown'
3665
+ : ageMs < 90 * 60_000 ? 'checked just now'
3666
+ : ageMs < 36 * 3_600_000 ? `checked ${Math.round(ageMs / 3_600_000)}h ago`
3667
+ : `checked ${Math.floor(ageMs / 86_400_000)}d ago`;
3668
+ const staleCache = ageMs === null || ageMs > NPM_CURRENCY_TTL * 1.5;
3669
+ const caveat = staleCache
3670
+ ? ` The registry check is overdue (${ageLabel}), so \`v${latest}\` is a FLOOR — the real gap may be larger. Confirm with \`npm view klypix-mcp version\`.`
3671
+ : ` (npm ${ageLabel}.)`;
3479
3672
  if (!autoUpdateEnabled(env)) {
3480
- return `\n\n---\n⚠️ **Brain update available** — installed brain core \`v${baked}\` < npm latest \`v${latest}\`. Automatic updates are off; run \`npx klypix-mcp install\`.\n`;
3673
+ return `\n\n---\n⚠️ **Brain update available** — installed brain core \`v${baked}\` < npm latest \`v${latest}\`.${caveat} Automatic updates are off; run \`npx klypix-mcp install\`.\n`;
3481
3674
  }
3482
- return `\n\n---\n⬆️ **Brain update available** — installed brain core \`v${baked}\` < npm latest \`v${latest}\`. KLYPIX will install it automatically in the background; no action required.\n`;
3675
+ return `\n\n---\n⬆️ **Brain update available** — installed brain core \`v${baked}\` < npm latest \`v${latest}\`.${caveat} KLYPIX will install it automatically in the background; no action required.\n`;
3483
3676
  } catch { return ''; }
3484
3677
  }
3485
3678
 
@@ -3545,7 +3738,8 @@ function legendFooter() {
3545
3738
  + '**Correcting a stale card:** include the word `CORRECTION` (or "was WRONG" / "OBSOLETE" — UPPERCASE; casing is the deliberate-signal, casual prose never fires it) in the decision — the capture then hunts the stale card across ALL areas at a lower match bar and supersedes it (archived + arrowed, with a receipt; restore from Archive if it grabbed the wrong one). A rephrased duplicate `?` merges into the existing open question instead of stacking a twin.\n'
3546
3739
  + '**Verified-fix rule drafts:** when a session FIXES + VERIFIES something trap-shaped that landed as a one-off note, the Stop hook auto-DRAFTS a candidate 🛠️ rule (a per-project sidecar — never a brain card). Approve a real recurring trap with the `+` marker the nudge shows you and it becomes a standing rule that fires EVERY session (like the release-naming rule); ignore the rest and they age out. Draft-only, no blind auto-capture.\n'
3547
3740
  + '**Session brief:** the SessionStart hook prints a ≤2KB ultra brief and writes the FULL brief to `.claude/brain-brief.md` — read that file when planning non-trivial work.\n'
3548
- + '**Routing:** capture project decisions / milestones / open questions / gotchas HERE, *at the moment you decide* — this brain is the shared, portable memory that survives context resets and the next agent reads. A host memory store (if any) is for *user* preferences; never leave project state only in a private scratchpad.\n'
3741
+ + '**Routing:** capture project decisions / milestones / open questions / gotchas HERE, *at the moment you decide* — this brain is the shared, portable memory that survives context resets and the next agent reads.\n'
3742
+ + '⚠️ **Your own memory directory is NOT this brain.** Claude Code\'s `~/.claude/projects/<project>/memory/` (and any equivalent host store) is PRIVATE to this host: no other session, agent, or human can read it. Both feel like "saving"; only `brain.klypix` is shared. A host store is for *user* preferences — never leave project state only there. (2026-08-16 field report: a whole workstream, ending in a registered IANA media type, went to the private store and reached the brain only because a human happened to ask.)\n'
3549
3743
  + 'Coordinate with a concurrent session: `🧠 MSG [<their-id or all>]: <text>` — a queued note (NOT a brain card), offered/acknowledged on supported model-context actions and replayed until the receiving model calls `brain_message_receipt` with its exact token.\n';
3550
3744
  }
3551
3745
 
@@ -3813,7 +4007,7 @@ if (!process.env.KLYPIX_BRAIN_NO_MAIN) {
3813
4007
  const mode = process.argv.includes('--prompt') ? 'prompt' : process.argv.includes('--capture') ? 'capture' : 'read';
3814
4008
  appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode, ok: false, err: String((e && e.message) || e).slice(0, 200) }, 500);
3815
4009
  } catch { /* even the breadcrumb is best-effort */ }
3816
- }).finally(() => process.exit(0));
4010
+ }).finally(() => process.exit(EXIT_CODE));
3817
4011
  }
3818
4012
 
3819
4013
  // Exported for hermetic unit tests only (gated by KLYPIX_BRAIN_NO_MAIN above so the
@@ -72,8 +72,21 @@ export function highestInstalledBrainVersion({ stamp, runtime } = {}) {
72
72
  // without a readable package version also proceeds — the
73
73
  // installer's invalid-candidate refusal already fails closed on
74
74
  // the version itself; this policy never double-guesses it.
75
- export function deploySourceDecision({ checkout, allowUntagged = false } = {}) {
76
- if (!checkout) return { action: 'proceed', source: 'released-artifact' };
75
+ export function deploySourceDecision({ checkout, allowUntagged = false, gitPresent = false } = {}) {
76
+ // `checkout` is null in TWO very different worlds, and conflating them was a
77
+ // fail-open found by five consecutive evidence-gate refusals (2026-08-18):
78
+ // an npm/npx tarball has no .git and IS the released artifact — but a real
79
+ // git checkout whose probes TIMED OUT under machine load also yields null,
80
+ // and the guard then deployed an untagged working tree as if it were the
81
+ // registry channel. The caller already knows whether .git exists; when it
82
+ // does, an unreadable git state must REFUSE — an honest, retryable refusal
83
+ // beats a silent unreleased deploy, which is the exact incident class this
84
+ // guard was built for.
85
+ if (!checkout) {
86
+ if (gitPresent && !allowUntagged) return { action: 'refuse', source: 'unverifiable-git-state' };
87
+ if (gitPresent) return { action: 'proceed', source: 'unverifiable-git-state', acknowledged: true };
88
+ return { action: 'proceed', source: 'released-artifact' };
89
+ }
77
90
  if (!checkout.packageVersion) return { action: 'proceed', source: 'unversioned-source' };
78
91
  if (checkout.isReleaseTag) return { action: 'proceed', source: 'released-tag' };
79
92
  return {
@@ -1103,7 +1103,7 @@ export async function opAddToCanvas({ vault, canvas, cards, connections, via })
1103
1103
  // open file any agent reads AND writes": a hookless client (Cursor/Cline/Desktop)
1104
1104
  // can now record a decision, ask an open question, mark a milestone, resolve a card,
1105
1105
  // or correct one — with the full lifecycle, not just a flat append.
1106
- export async function opBrainNote({ vault, canvas, text: noteText, area, marker = '', closes, via }) {
1106
+ export async function opBrainNote({ vault, canvas, text: noteText, area, marker = '', closes, via, enrichmentQuestion = '' }) {
1107
1107
  const t = brainTarget(vault, canvas);
1108
1108
  if (t.ambiguous) return ambiguousBrainErr(t.ambiguous);
1109
1109
  if (!t.file) return err(`No brain found — looked for ./brain.klypix in the project, then ${vault}. Pass canvas: "<name>".`);
@@ -1134,6 +1134,16 @@ export async function opBrainNote({ vault, canvas, text: noteText, area, marker
1134
1134
  let out = res.buffer; try { out = (await tidyBrain(res.buffer)).buffer; } catch { /* keep append result if tidy fails */ }
1135
1135
  await atomicWrite(file, out);
1136
1136
  if (pendingShips.length) clearPendingShips(projectDir); // durable now — safe to consume
1137
+ // Question enrichment (1.77): the caller session's declared intent is the
1138
+ // natural-language question that produced this card — recorded to the
1139
+ // retrieval sidecar so brain_ask finds the card in the asker's vocabulary.
1140
+ // Additive: any failure costs recall, never the write above.
1141
+ if (enrichmentQuestion && (res.stats?.added || 0) > 0) {
1142
+ try {
1143
+ const enrich = await import('./enrichment.mjs');
1144
+ enrich.recordEnrichment(file, [{ body: noteText, question: enrichmentQuestion }]);
1145
+ } catch { /* sidecar unavailable — additive signal only */ }
1146
+ }
1137
1147
  const s = res.stats || {};
1138
1148
  const bits = [`${s.added || 0} added`];
1139
1149
  for (const k of ['resolved', 'updated', 'merged', 'closed', 'superseded']) if (s[k]) bits.push(`${s[k]} ${k}`);
@@ -3524,5 +3524,13 @@ export function createMcpPresence({
3524
3524
  decorateToolResult,
3525
3525
  get brainPath() { return brainPath; },
3526
3526
  get vault() { return vault; },
3527
+ // The session's own declared intent (or ''), read fresh from the lane —
3528
+ // the enrichment question source for MCP-side brain_note captures.
3529
+ get declaredIntent() {
3530
+ try {
3531
+ const row = listActiveSessions(brainPath).find((s) => s.id === sessionId);
3532
+ return String(row?.intent || '').slice(0, 240);
3533
+ } catch { return ''; }
3534
+ },
3527
3535
  };
3528
3536
  }
@@ -83,9 +83,15 @@ function sanitizeDeletionReceipt(raw) {
83
83
  //
84
84
  // VOLATILE = written by the act of saving, not by a human/agent decision:
85
85
  // updatedAt — touch timestamp zIndex — display order derived from zKey
86
+ // editedAt — the desktop app's authored-edit stamp (2026-08-22): advances on
87
+ // content-level edits only, but an edit-then-undo cycle leaves the
88
+ // content identical while the stamp differs — exactly the
89
+ // same-meaning-different-bytes shape that spawned the updatedAt
90
+ // twins above. A card whose only difference is WHEN it was last
91
+ // edited has not diverged.
86
92
  // Everything else (content, colors, geometry, evidence, author…) stays load-
87
93
  // bearing: a real edit to any of them is still a real conflict.
88
- const VOLATILE_ITEM_FIELDS = ['updatedAt', 'zIndex'];
94
+ const VOLATILE_ITEM_FIELDS = ['updatedAt', 'zIndex', 'editedAt'];
89
95
 
90
96
  const sortedStable = (v) => JSON.stringify(v, (_k, val) =>
91
97
  (val && typeof val === 'object' && !Array.isArray(val))
@@ -8,6 +8,7 @@ import fs from 'fs';
8
8
  import os from 'os';
9
9
  import path from 'path';
10
10
  import crypto from 'crypto';
11
+ import { enrichmentTextFor, readEnrichment } from './enrichment.mjs';
11
12
  import { createRequire } from 'module';
12
13
 
13
14
  const PB_DIR = path.join(os.homedir(), '.claude', 'project-brain');
@@ -577,13 +578,28 @@ function readCache(brainPath, desiredHashes) {
577
578
 
578
579
  async function vectorsForBrainUnlocked(pipe, brainPath, cards) {
579
580
  const want = cards.filter((card) => card.type !== 'container' && (card.text || '').trim());
580
- const desiredHashes = new Map(want.map((card) => [card.id, sha1(String(card.text))]));
581
+ // Question enrichment (1.77): the prompt/intent that produced a card is
582
+ // appended to its EMBED input — the asker's own vocabulary bridging the
583
+ // paraphrase gap the 2026-08-17 error analysis measured. Additive and
584
+ // machine-local like this cache itself; an empty sidecar changes nothing,
585
+ // and the hash covers the enrichment so its arrival re-embeds exactly the
586
+ // cards it touches. (Structural context was measured HARMFUL twice — 62%→52%
587
+ // recall@5 — so only asker-language rides here, never area/tag jargon.)
588
+ let enrichmentEntries = [];
589
+ try { enrichmentEntries = readEnrichment(brainPath); } catch { enrichmentEntries = []; }
590
+ const embedInputFor = (card) => {
591
+ const base = String(card.text).slice(0, 1500);
592
+ const extra = enrichmentEntries.length ? enrichmentTextFor(enrichmentEntries, card.text) : '';
593
+ return extra ? `${base}
594
+ ${extra}` : base;
595
+ };
596
+ const desiredHashes = new Map(want.map((card) => [card.id, sha1(embedInputFor(card))]));
581
597
  const loaded = readCache(brainPath, desiredHashes);
582
598
  const { file, cache } = loaded;
583
599
  let dirty = loaded.dirty;
584
600
  const missing = want.filter((card) => cache.cards[card.id]?.h !== desiredHashes.get(card.id));
585
601
  if (missing.length) {
586
- const vectors = await embedTexts(pipe, missing.map((card) => String(card.text).slice(0, 1500)));
602
+ const vectors = await embedTexts(pipe, missing.map((card) => embedInputFor(card)));
587
603
  missing.forEach((card, index) => {
588
604
  cache.cards[card.id] = { h: desiredHashes.get(card.id), v: vectors[index] };
589
605
  });