klypix-mcp 1.75.0 → 1.77.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', {
@@ -796,6 +808,7 @@ server.registerTool('brain_sync', {
796
808
  shas: z.array(z.string().max(40)).max(20).optional().describe('Commit shas that MUST ride the next release. Stake after committing work a user was promised — the claim OUTLIVES this session (14d), and every future releaseIntent must contain these commits or acknowledge them by name.'),
797
809
  note: z.string().max(160).optional().describe('One line of why — shown verbatim in any refusal that names this claim ("founder was told the Arrow tool ships in the next build").'),
798
810
  withdraw: z.union([z.array(z.string().max(40)).max(20), z.boolean()]).optional().describe('Shas to withdraw from this session\'s claim; [] or true withdraws the whole claim. Only the staking session (or its logical continuation) can withdraw.'),
811
+ publish: z.boolean().optional().describe('Also write the claim as .klypix/claims/<owner>.json in the project (or delete that file when withdrawing). Commit it and the promise TRAVELS WITH THE REPO: every clone\'s release gate reads it, it is reviewable in PRs, and its history is auditable — team-wide claims over plain git, zero infrastructure.'),
799
812
  }).optional().describe('Stake a durable claim that specific commits ride the NEXT release — the promise "you\'ll see it in the next build" made machine-readable. Unlike presence rows (which age out ~10min after a session ends), a claim persists until fulfilled (the release ref contains the shas — auto-retired with a courtesy note), withdrawn, or expired (14d). A release that would drop claimed shas is REFUSED until they are acknowledged BY NAME, and acknowledging them away notifies the owner. Use exactly one of shas (stake/extend) or withdraw.'),
800
813
  },
801
814
  }, async ({ project, intent, files, phase, include_context, results, releaseIntent, releaseClaim }, extra) => {
@@ -876,6 +889,77 @@ server.registerTool('brain_sync', {
876
889
  }
877
890
  } catch { /* observation is best-effort — never fail a sync */ }
878
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
+ }
879
963
  let taskContext = null;
880
964
  if (phase !== 'complete' && include_context !== false && (intent || files?.length)) {
881
965
  taskContext = await opBrainTaskContext({
@@ -926,7 +1010,7 @@ server.registerTool('brain_sync', {
926
1010
  return {
927
1011
  content: [{
928
1012
  type: 'text',
929
- 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'),
930
1014
  }],
931
1015
  structuredContent,
932
1016
  ...(report.isError ? { isError: true } : {}),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.75.0",
4
- "description": "Shared project brain and MCP coordination server for multi-agent coding.",
3
+ "version": "1.77.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
+ }