klypix-mcp 1.82.2 → 1.84.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/A2A.md CHANGED
@@ -113,6 +113,12 @@ If a write (`make_board`/`remember`) returns `input-required`, reply with a mess
113
113
  carrying the same `taskId` plus the missing input to **continue that task** (the
114
114
  server resumes it with a stable id and accumulated history).
115
115
 
116
+ For `remember` or `learn_skill`, one-card requests may also provide `args.evidence`
117
+ and `args.verify` using the [brain capture schema](README.md#capture-and-corrections).
118
+ Such requests use the shared capture engine even without a marker. References and
119
+ verification text are preserved; malformed metadata is rejected instead of discarded.
120
+ Verification text is never executed.
121
+
116
122
  ## Notes
117
123
 
118
124
  - Tasks complete synchronously (the work is local file I/O), so `message/send`
package/FORMAT.md CHANGED
@@ -132,6 +132,18 @@ strings: `text`, `box`, `image`, `file`, `container`, `approval`, `link`,
132
132
  `createdBy` (`user` | `agent`) and the optional `createdVia` (which agent/channel
133
133
  captured it) are the provenance bits the brain surfaces as badges and lenses.
134
134
 
135
+ Brain cards may also carry `evidence` and `verify`. An evidence reference has `kind`
136
+ (`file`, `pr`, `url`, `commit`, or `run`), `ref`, optional caller-supplied file blob `oid`,
137
+ and optional caller-reported ISO `verifiedAt`. The host-neutral capture API rejects
138
+ unknown input fields, unsafe file paths, and malformed metadata before writing.
139
+ It adds `capturedAt`; readable local files up to 2 MiB also receive a SHA-256 `sha256`
140
+ and `sourceBasis: "working-tree"`. Optional `headRevision` identifies HEAD at capture,
141
+ which does not imply the captured working bytes were committed. Legacy `oid`-only
142
+ references are read with both HEAD and working-file changes considered. These fields
143
+ describe source provenance and change detection, never factual verification.
144
+ `verify` is retained text, not executable configuration. All these optional fields
145
+ survive the format codec and capture lifecycle; an explicit empty amendment clears them.
146
+
135
147
  The optional **`author`** answers the question a team actually asks: `createdBy` says
136
148
  *what* wrote a card, `author` says *whose*. It is resolved from `git config user.name`
137
149
  so brain attribution matches commit attribution with no configuration (override with
package/README.md CHANGED
@@ -346,6 +346,30 @@ there are no lifecycle hooks on those hosts.
346
346
 
347
347
  ## Capture and corrections
348
348
 
349
+ `brain_note` accepts structured supporting references and inert verification text:
350
+
351
+ ```json
352
+ {
353
+ "text": "Retry failed uploads with a bounded backoff to preserve queued work.",
354
+ "area": "Storage",
355
+ "evidence": [{ "kind": "file", "ref": "src/uploads.mjs:42" }],
356
+ "verify": "node test/uploads.mjs"
357
+ }
358
+ ```
359
+
360
+ File references must stay inside the project. The capture records a fingerprint of the
361
+ working file and, when available, the repository HEAD revision. An unchanged fingerprint
362
+ means **source unchanged**, not that the remembered claim is correct or that tests passed.
363
+ Dirty working files are fingerprinted as they are; HEAD alone does not describe those bytes.
364
+ Read results distinguish changed, missing, and unverified sources. External references
365
+ (`pr`, `url`, `commit`, `run`) are retained without fetching or verifying them. `verify` is
366
+ shown as recorded text and never executed. Optional `verifiedAt` is explicitly caller-reported.
367
+
368
+ On an amendment (`marker: "~"`), omitted metadata is preserved; `evidence: []` and
369
+ `verify: ""` clear obsolete metadata. A resolve (`✓`) archives existing evidence; attach
370
+ new evidence with a milestone and `closes`, or amend before resolving. The CLI accepts the
371
+ same JSON on stdin, or `--evidence '<JSON array>'` and `--verify '<text>'`.
372
+
349
373
  On Claude Code, decisions are captured automatically at turn end from inline `🧠 BRAIN [Area]:`
350
374
  markers in the transcript, deduped, under a capture lock.
351
375
 
@@ -32,7 +32,7 @@ import crypto from 'crypto';
32
32
  import { fileURLToPath } from 'url';
33
33
  import { z } from 'zod';
34
34
  import {
35
- resolveVault, resolveCanvas, getEmbedder, shouldPrewarmSemantic, cardSchema, connSchema,
35
+ resolveVault, resolveCanvas, getEmbedder, shouldPrewarmSemantic, cardSchema, connSchema, groupSchema,
36
36
  opListCanvases, opReadCanvas, opSearchCanvases, opSearchAllBrains,
37
37
  opBrainInsights, opBrainConnect, opCreateCanvas, opAddToCanvas, opBrainNote,
38
38
  } from '../src/klypix-core.mjs';
@@ -221,6 +221,7 @@ const a2aCardSchema = cardSchema.extend({
221
221
  });
222
222
  const cardsArg = z.array(a2aCardSchema).min(1).max(500);
223
223
  const connsArg = z.array(connSchema).max(1_000).optional();
224
+ const groupsArg = z.array(groupSchema).max(100).optional();
224
225
 
225
226
  // Compatibility spellings accepted by the dispatcher but deliberately omitted
226
227
  // from the Agent Card. The smoke test asserts every switch case is either
@@ -312,7 +313,9 @@ async function runSkill(skill, args, text, via) {
312
313
  }
313
314
  const conns = connsArg.safeParse(args.connections);
314
315
  if (!conns.success) return needInput('connections must be `[{ "from": <index|title>, "to": <index|title> }]`.');
315
- return await opCreateCanvas({ vault: VAULT, title: args.title ?? 'Untitled board', cards: parsed.data, connections: conns.data, filename: args.filename });
316
+ const groups = groupsArg.safeParse(args.groups);
317
+ if (!groups.success) return needInput('groups must be `[{ "title": "…", "cards": [<index|title|id>, …] }]` — cards listed in reading order.');
318
+ return await opCreateCanvas({ vault: VAULT, title: args.title ?? 'Untitled board', cards: parsed.data, connections: conns.data, groups: groups.data, filename: args.filename });
316
319
  }
317
320
  case 'remember':
318
321
  case 'learn_skill': {
@@ -324,14 +327,15 @@ async function runSkill(skill, args, text, via) {
324
327
  if (skill === 'learn_skill') marker = '+';
325
328
  const single = (!args.cards && text.trim()) ? stripVerb(text) : null;
326
329
  if (!marker && single && looksLikeSkill(single)) marker = '+'; // NL "remember this gotcha: always…" → skill
327
- if (marker) {
330
+ if (marker || args.evidence !== undefined || args.verify !== undefined) {
328
331
  const noteText = single ?? (Array.isArray(args.cards) && args.cards[0]?.text) ?? '';
329
332
  if (!String(noteText).trim()) return needInput('Nothing to capture — send text or a card to remember.');
330
333
  if (String(noteText).length > 20_000) return needInput('Captured text must be 20,000 characters or fewer.');
331
334
  const requested = args.canvas ?? 'brain';
332
335
  const target = confinedCanvas(requested);
333
336
  if (!target.ok) return refusedCanvas(requested);
334
- return await opBrainNote({ vault: VAULT, canvas: target.canvas, text: noteText, area: args.area, marker, closes: args.closes, via });
337
+ if (Array.isArray(args.cards) && args.cards.length !== 1) return needInput('Evidence capture requires exactly one card per request.');
338
+ return await opBrainNote({ vault: VAULT, canvas: target.canvas, text: noteText, area: args.area, marker, closes: args.closes, evidence: args.evidence, verify: args.verify, via });
335
339
  }
336
340
  // NL convenience: a bare "remember: X" becomes a single card on the brain.
337
341
  const cards = args.cards ?? (text.trim() ? [{ text: stripVerb(text) }] : null);
@@ -391,7 +391,7 @@ try {
391
391
  // canvas-view-app.html is the canvas_view MCP App UI — staged raw (an HTML
392
392
  // file must never get a JS-comment banner) beside the flat server, which
393
393
  // resolves it via its ./canvas-view-app.html candidate path.
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', 'editor-detect.mjs', 'agent-presence.mjs', 'mcp-presence.mjs', 'repo-state.mjs', 'result-reconcile.mjs', 'finding-routing.mjs', 'presence-relay.mjs', 'mcp-supervisor.mjs', 'mcp-auto-update.mjs', 'runtime-inspector.mjs', 'project-graph.mjs', 'bench.mjs', 'codex-brain-hook.mjs', 'codex-hooks.mjs', 'canvas-view-app.html']) {
394
+ for (const f of ['global-brain-hook.mjs', 'capture-gap.mjs', 'brain-semantic.mjs', 'semantic-memory.mjs', 'enrichment.mjs', 'brain-note.mjs', 'brain-evidence.mjs', 'brain-git-hook.mjs', 'git-capture-install.mjs', 'brain-history.mjs', 'brain-graveyard.mjs', 'klypix-format.mjs', 'klypix-core.mjs', 'brain-write-lock.mjs', 'agent-rules.mjs', 'brain-doctor.mjs', 'editor-detect.mjs', 'agent-presence.mjs', 'mcp-presence.mjs', 'repo-state.mjs', 'result-reconcile.mjs', 'finding-routing.mjs', 'presence-relay.mjs', 'mcp-supervisor.mjs', 'mcp-auto-update.mjs', 'runtime-inspector.mjs', 'project-graph.mjs', 'bench.mjs', 'codex-brain-hook.mjs', 'codex-hooks.mjs', 'canvas-view-app.html']) {
395
395
  const s = path.join(SRC, f); if (exists(s)) staged.push({ dst: f, content: fs.readFileSync(s, 'utf8') });
396
396
  }
397
397
  for (const [src, dst] of [
@@ -25,7 +25,7 @@ import { z } from 'zod';
25
25
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
26
26
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
27
27
  import {
28
- resolveVault, getEmbedder, shouldPrewarmSemantic, buildKlypixMap, cardSchema, connSchema,
28
+ resolveVault, getEmbedder, shouldPrewarmSemantic, buildKlypixMap, cardSchema, connSchema, groupSchema,
29
29
  opListCanvases, opReadCanvas, opSearchCanvases, opSearchAllBrains,
30
30
  opBrainInsights, opBrainConnect, opBrainReconcile, opBrainGarden, opCreateCanvas, opAddToCanvas, opBrainNote, opBrainMessage, opBrainAsk, opBrainChallenge, opCanvasView, opBrainLens,
31
31
  opBrainTaskContext,
@@ -652,14 +652,15 @@ server.registerTool('brain_garden', {
652
652
 
653
653
  server.registerTool('create_canvas', {
654
654
  title: 'Create a KLYPIX canvas',
655
- description: 'Create a new .klypix canvas from cards + connections and save it to the vault. The user opens it in the KLYPIX app (Canvas → Open). Prefer short, titled cards (one idea each) connected by meaningful arrows.',
655
+ description: 'Create a new .klypix canvas from cards + connections and save it to the vault. The user opens it in the KLYPIX app (Canvas → Open). Prefer short, titled cards (one idea each) connected by meaningful arrows. For anything a person reads IN ORDER — steps, phases, checklists, sections — put the cards in `groups`: each group becomes a titled box with its cards stacked in the order given, boxes left-to-right; the loose grid follows arrows, not reading order, and scatters a sequence.',
656
656
  inputSchema: {
657
657
  title: z.string().describe('Canvas title (also the filename).'),
658
- cards: z.array(cardSchema).min(1).describe('The cards. 5-12 atomic cards is ideal.'),
658
+ cards: z.array(cardSchema).min(1).describe('The cards. 5-12 atomic cards is ideal for a mind-map; a checklist can be longer when grouped.'),
659
659
  connections: z.array(connSchema).optional().describe('Arrows between cards.'),
660
+ groups: z.array(groupSchema).optional().describe('Titled boxes, each listing its member cards in reading order (index, title, or id). Ungrouped cards form a band above the boxes — good for the title card, a link, a legend.'),
660
661
  filename: z.string().optional().describe('Override the output filename (without extension).'),
661
662
  },
662
- }, async ({ title, cards, connections, filename }) => toContent(await opCreateCanvas({ vault: mcpPresence.vault, title, cards, connections, filename })));
663
+ }, async ({ title, cards, connections, groups, filename }) => toContent(await opCreateCanvas({ vault: mcpPresence.vault, title, cards, connections, groups, filename })));
663
664
 
664
665
  server.registerTool('add_to_canvas', {
665
666
  title: 'Add cards to an existing canvas',
@@ -683,6 +684,13 @@ server.registerTool('brain_note', {
683
684
  marker: z.enum(['', '?', '!', '+', '✓', '~']).optional().describe('(none)=decision · ?=open question · !=milestone · +=🛠️ skill (reusable how-to/gotcha; always resurfaces, never ages out) · ✓=resolve+archive the best-matching card · ~=update the matching card in place. Default: decision.'),
684
685
  area: z.string().optional().describe('Area/topic — routes the card into that titled container and becomes a #tag (e.g. "Auth", "Release").'),
685
686
  closes: z.string().optional().describe('Title or [[wikilink]] of a strategy/question card this note fulfils — resolves+archives it and draws a "closed by" arrow.'),
687
+ evidence: z.array(z.object({
688
+ kind: z.enum(['file', 'pr', 'url', 'commit', 'run']),
689
+ ref: z.string().min(1).max(1000).describe('A project-relative file path (optional :line or #Lline), or an external reference. External references are stored without fetching them.'),
690
+ oid: z.string().optional().describe('Optional full file blob hash from Git; a caller-supplied anchor, not proof of the claim.'),
691
+ verifiedAt: z.string().optional().describe('Optional caller-reported ISO verification date/time; not independently verified.'),
692
+ }).strict()).max(16).optional().describe('Supporting references. File bytes are fingerprinted as captured working-tree sources; hashes only detect source changes. On ~, [] clears evidence. Not accepted on resolve; use a milestone with closes to attach new evidence.'),
693
+ verify: z.string().max(2000).optional().describe('Verification instructions or command text to retain and display. Never executed by KLYPIX. On ~, an empty string clears it.'),
686
694
  guard: z.object({
687
695
  when: z.object({
688
696
  tool: z.string().max(200).optional().describe('Regex matched against the tool name (e.g. "Bash", "Edit|Write").'),
@@ -696,10 +704,10 @@ server.registerTool('brain_note', {
696
704
  }).optional().describe("GUARD CARDS: make this '+' skill fire BEFORE a matching tool call runs (Claude Code PreToolUse denies on severity block; other hosts warn), not just resurface in briefs. The card stays a normal 🛠️ rule — ✓-resolving it retires the guard, ~ with {remove:true} disarms it."),
697
705
  canvas: z.string().optional().describe('Brain canvas filename/path. Defaults to the project brain ("brain").'),
698
706
  },
699
- }, async ({ text, marker, area, closes, guard, canvas }, extra) => {
707
+ }, async ({ text, marker, area, closes, evidence, verify, guard, canvas }, extra) => {
700
708
  // Both 1.77 and 1.78 ride this call: the enrichment question (the asker's
701
709
  // vocabulary for retrieval) AND the per-session capture receipt below.
702
- const result = await opBrainNote({ vault: mcpPresence.vault, canvas: boundBrainCanvas(canvas), text, area, marker: marker || '', closes, guard, via: extra.klypixClientName, enrichmentQuestion: mcpPresence.declaredIntent });
710
+ const result = await opBrainNote({ vault: mcpPresence.vault, canvas: boundBrainCanvas(canvas), text, area, marker: marker || '', closes, evidence, verify, guard, via: extra.klypixClientName, enrichmentQuestion: mcpPresence.declaredIntent });
703
711
  // Per-session capture receipt — this is what stops the uncaptured-work nudge
704
712
  // from firing at a session that DID record its reasoning, just through MCP
705
713
  // rather than a 🧠 marker. The Stop hook and this server share one session-id
@@ -707,7 +715,7 @@ server.registerTool('brain_note', {
707
715
  // hook reads. Best-effort: a receipt failure must never fail the note.
708
716
  try {
709
717
  const { recordSessionCapture } = await import('../src/capture-gap.mjs');
710
- recordSessionCapture(extra?.klypixRequestIdentity?.sessionId || mcpPresence.id);
718
+ if (!result.isError) recordSessionCapture(extra?.klypixRequestIdentity?.sessionId || mcpPresence.id, undefined, Date.now(), { project: mcpPresence.vault });
711
719
  } catch { /* receipt is best-effort */ }
712
720
  return toContent(result);
713
721
  });
@@ -938,32 +946,34 @@ server.registerTool('brain_sync', {
938
946
  if (head) gap.recordTaskBaseline(sid, { head, project: projectDir });
939
947
  } else if (phase === 'complete' && projectDir && sid) {
940
948
  const baseline = gap.readTaskBaseline(sid);
941
- if (baseline?.head) {
942
- // An ancestry check first: a rebase/reset makes the range meaningless,
943
- // and reporting a rewritten history as "commits you didn't record" is
944
- // exactly the cry-wolf that gets a nudge switched off.
945
- const reachable = gitOk(['merge-base', '--is-ancestor', baseline.head, 'HEAD']);
946
- const count = reachable ? Number(gitOut(['rev-list', '--count', '--no-merges', `${baseline.head}..HEAD`]) || 0) : 0;
949
+ const sameProject = baseline?.project && path.resolve(baseline.project) === path.resolve(projectDir);
950
+ if (sameProject && baseline?.head) {
951
+ // A successful note checkpoints the observed HEAD only. Work committed
952
+ // after that note belongs to a new outcome even in the same task/session.
953
+ const receipt = gap.sessionCaptureReceipt(sid, projectDir);
954
+ let from = baseline.head;
955
+ if (receipt?.head && receipt.at >= baseline.at
956
+ && gitOk(['merge-base', '--is-ancestor', baseline.head, receipt.head])
957
+ && gitOk(['merge-base', '--is-ancestor', receipt.head, 'HEAD'])) from = receipt.head;
958
+ const reachable = gitOk(['merge-base', '--is-ancestor', from, 'HEAD']);
959
+ const range = from + '..HEAD';
960
+ const count = reachable ? Number(gitOut(['rev-list', '--count', '--no-merges', range]) || 0) : 0;
947
961
  if (count > 0) {
948
- const subjects = gitOut(['log', '--no-merges', '--format=%s', `${baseline.head}..HEAD`])
962
+ const subjects = gitOut(['log', '--no-merges', '--format=%s', range])
949
963
  .split('\n').map((s) => s.trim()).filter(Boolean).slice(0, 5);
950
- // Bodies decide whether ANY rationale was recorded — the same rule
951
- // the hook uses, so the two halves never disagree about one session.
952
- const withRationale = gitOut(['log', '--no-merges', '--format=%x1e%b', `${baseline.head}..HEAD`])
964
+ const withRationale = gitOut(['log', '--no-merges', '--format=%x1e%b', range])
953
965
  .split('\x1e').map((b) => b.replace(/\s+/g, ' ').trim()).filter((b) => b.length >= 12).length;
966
+ const outcome = projectDir + ':' + gitOut(['rev-parse', 'HEAD']);
954
967
  const decision = gap.captureGapDecision({
955
968
  commitTotal: count,
956
969
  commitCards: withRationale,
957
- sessionCaptured: gap.sessionHasCaptured(sid),
970
+ alreadyNudged: gap.outcomeWasNudged(sid, outcome),
958
971
  });
959
972
  if (decision) {
960
- const changed = gitOut(['diff', '--name-only', `${baseline.head}..HEAD`]).split('\n').filter(Boolean).slice(0, 20);
961
- const draft = gap.draftCaptureMarker({
962
- commits: subjects.map((subject) => ({ subject })),
963
- filesTouched: changed,
964
- });
973
+ const changed = gitOut(['diff', '--name-only', range]).split('\n').filter(Boolean).slice(0, 20);
974
+ const draft = gap.draftCaptureMarker({ commits: subjects.map((subject) => ({ subject })), filesTouched: changed });
965
975
  captureGapText = gap.captureGapReason({ ...decision, draft, mode: 'advise' });
966
- gap.recordCaptureGapNudge(sid);
976
+ gap.recordCaptureGapNudge(sid, undefined, outcome);
967
977
  }
968
978
  }
969
979
  }
@@ -9,11 +9,15 @@
9
9
  // cat spec.json | node scripts/write-klypix.mjs --out board.klypix
10
10
  //
11
11
  // Spec:
12
- // { "title": "...", "cards": [{ "text": "...", "heading"?, "color"? }],
13
- // "connections": [{ "from": 0, "to": 1, "relationship"? }] }
14
- // from/to reference a card by INDEX, generated id, or its title (first line).
15
- // relationship leads_to | depends_on | relates_to | conflicts_with |
16
- // supports | questions | costs | blocks.
12
+ // { "title": "...", "cards": [{ "text": "...", "heading"?, "color"?, "group"? }],
13
+ // "connections": [{ "from": 0, "to": 1, "relationship"? }],
14
+ // "groups": [{ "title": "Part 1", "cards": [0, 1, 2], "color"?, "columns"?, "width"? }] }
15
+ // from/to (and group members) reference a card by INDEX, id, or its title
16
+ // (first line). relationship ∈ leads_to | depends_on | relates_to |
17
+ // conflicts_with | supports | questions | costs | blocks.
18
+ // groups: anything read IN ORDER (steps, phases, sections) — each becomes a
19
+ // titled box with its cards stacked in the order listed, boxes left-to-right.
20
+ // Loose cards keep the connection-driven grid, as a band above the boxes.
17
21
 
18
22
  import fs from 'fs';
19
23
  import { buildKlypix, atomicWrite } from '../src/klypix-format.mjs';
@@ -41,5 +45,6 @@ const outPath = outArg || `${(spec.title || 'untitled').replace(/[^\w\- ]+/g, ''
41
45
  await atomicWrite(outPath, buf);
42
46
  const cardCount = spec.cards.length;
43
47
  const connCount = Array.isArray(spec.connections) ? spec.connections.length : 0;
44
- console.log(`Wrote ${outPath} ${cardCount} cards, ${connCount} connections.`);
48
+ const groupCount = Array.isArray(spec.groups) ? spec.groups.length : 0;
49
+ console.log(`Wrote ${outPath} — ${cardCount} cards, ${connCount} connections${groupCount ? `, ${groupCount} group box${groupCount === 1 ? '' : 'es'}` : ''}.`);
45
50
  console.log(`Open it in the KLYPIX app (Canvas → Open), or verify: node scripts/read-klypix.mjs "${outPath}"`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.82.2",
3
+ "version": "1.84.0",
4
4
  "mcpName": "io.github.dahshanlabs/klypix-mcp",
5
5
  "description": "Active state management for multi-agent coding: a shared, versioned project brain over MCP.",
6
6
  "type": "module",
@@ -84,10 +84,11 @@
84
84
  "bench": "node bin/klypix-mcp.mjs bench",
85
85
  "test:bench": "node test/bench.mjs",
86
86
  "pretest": "node test/publish-workflow.mjs",
87
- "test": "node test/publish-verdict.mjs && node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/install-version.mjs && node test/install-rename-backoff.mjs && node test/project-binding-rebind.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/capture-gap.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/orphan-gardener.mjs && node test/brief-and-recall.mjs && node test/guard-cards.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/retrieval-fusion.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/plan-fulfillment.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/enrichment.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/undeclared-active.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/release-lease.mjs && node test/release-ancestry.mjs && node test/release-claim-join.mjs && node test/release-claims.mjs && node test/release-handshake.mjs && node test/completion-guard.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/one-command-setup.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
87
+ "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/guard-cards.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/retrieval-fusion.mjs && node test/eval-retrieval.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/plan-fulfillment.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/enrichment.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/brain-evidence.mjs && node test/presence-visibility.mjs && node test/undeclared-active.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/release-lease.mjs && node test/release-ancestry.mjs && node test/release-claim-join.mjs && node test/release-claims.mjs && node test/release-handshake.mjs && node test/completion-guard.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/one-command-setup.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/canvas-groups.mjs && node test/git-tools.mjs && node test/uninstall.mjs && node test/current-guidance.mjs",
88
88
  "test:memory": "node test/memory-runtime.mjs",
89
89
  "test:memory:soak": "node --expose-gc test/memory-soak.mjs",
90
- "runtime": "node bin/klypix-runtime.mjs"
90
+ "runtime": "node bin/klypix-runtime.mjs",
91
+ "eval:retrieval": "node scripts/eval-retrieval.mjs"
91
92
  },
92
93
  "dependencies": {
93
94
  "@modelcontextprotocol/ext-apps": "^1.7.5",
@@ -0,0 +1,205 @@
1
+ // Evidence is provenance, not a truth verdict. Capture snapshots the WORKING
2
+ // file; HEAD identifies the repository revision but does not certify dirty bytes.
3
+ // Verification text is inert data. No caller-provided command is ever executed.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import crypto from 'node:crypto';
7
+ import { execFileSync } from 'node:child_process';
8
+
9
+ const MAX_FILE_BYTES = 2 * 1024 * 1024;
10
+ const KINDS = new Set(['file', 'pr', 'url', 'commit', 'run']);
11
+ const INPUT_FIELDS = new Set(['kind', 'ref', 'oid', 'verifiedAt']);
12
+ const SHA_RE = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i;
13
+ const READ_BUDGET = Symbol('brain-evidence-read-budget');
14
+ // Read-side metadata can come from imported JSON, not the capture schema.
15
+ // Never coerce objects (an own toString field can throw); bound string work
16
+ // before normalization so oversized metadata cannot monopolize a recall call.
17
+ const flat = (value, max = 1000) => typeof value === 'string'
18
+ ? value.slice(0, max).replace(/[\r\n\t]+/g, ' ').trim() : '';
19
+ const inside = (root, target) => {
20
+ const rel = path.relative(root, target);
21
+ return rel !== '' && rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
22
+ };
23
+
24
+ // Permit line anchors, but require portable project-relative file paths at the
25
+ // API boundary. Check the nearest existing ancestor to catch missing paths below
26
+ // a junction/symlink too. Read-side legacy invalid paths become unverified.
27
+ export function resolveEvidenceFile(projectRoot, ref) {
28
+ if (typeof ref !== 'string' || !ref.trim() || /[\x00-\x1f\x7f]/.test(ref)) return null;
29
+ const clean = ref.trim().replace(/#L\d+(?:-L?\d+)?$/i, '').replace(/:\d+(?::\d+)?$/, '').replace(/\\/g, '/');
30
+ if (!clean || clean.startsWith('/') || /^[a-z]:/i.test(clean) || clean.includes(':')
31
+ || clean.split('/').some(part => !part || part === '.' || part === '..') || /[*?\[\]]/.test(clean)) return null;
32
+ if (!projectRoot) return null;
33
+ const root = path.resolve(projectRoot), target = path.resolve(root, clean);
34
+ if (!inside(root, target)) return null;
35
+ try {
36
+ const realRoot = fs.realpathSync(root);
37
+ let ancestor = target;
38
+ while (!fs.existsSync(ancestor) && ancestor !== root) {
39
+ // existsSync follows symlinks; lstat catches a dangling link.
40
+ try { if (fs.lstatSync(ancestor).isSymbolicLink()) return null; } catch { /* missing */ }
41
+ ancestor = path.dirname(ancestor);
42
+ }
43
+ const real = fs.realpathSync(ancestor);
44
+ if (real !== realRoot && !inside(realRoot, real)) return null;
45
+ return { target, relative: clean };
46
+ } catch { return null; }
47
+ }
48
+
49
+ function readSnapshot(file) {
50
+ try {
51
+ const stat = fs.statSync(file);
52
+ if (!stat.isFile() || stat.size > MAX_FILE_BYTES) return { status: 'unverified' };
53
+ // Bound the read even if another process grows the file after stat.
54
+ const fd = fs.openSync(file, 'r');
55
+ try {
56
+ const buffer = Buffer.alloc(Math.min(MAX_FILE_BYTES + 1, stat.size + 1));
57
+ const count = fs.readSync(fd, buffer, 0, buffer.length, 0);
58
+ const after = fs.fstatSync(fd);
59
+ if (count !== stat.size || after.size !== stat.size || after.mtimeMs !== stat.mtimeMs) return { status: 'unverified' };
60
+ return { status: 'read', sha256: crypto.createHash('sha256').update(buffer.subarray(0, count)).digest('hex') };
61
+ } finally { fs.closeSync(fd); }
62
+ } catch (error) { return { status: error.code === 'ENOENT' ? 'missing' : 'unverified' }; }
63
+ }
64
+
65
+ function git(root, args, timeout = 500) {
66
+ if (timeout <= 0) return null;
67
+ try { return execFileSync('git', args, { cwd: root, encoding: 'utf8', timeout: Math.max(1, Math.min(500, timeout)), maxBuffer: 16_384, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }).trim(); }
68
+ catch { return null; }
69
+ }
70
+
71
+ function workingTreeStatus(root, relative, deadline) {
72
+ const remaining = deadline - Date.now();
73
+ if (remaining <= 0) return 'unverified';
74
+ try {
75
+ execFileSync('git', ['diff', '--no-ext-diff', '--no-textconv', '--quiet', 'HEAD', '--', relative], {
76
+ cwd: root, timeout: Math.max(1, Math.min(500, remaining)), stdio: 'ignore', windowsHide: true,
77
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
78
+ });
79
+ return 'unchanged';
80
+ } catch (error) { return error.status === 1 ? 'changed' : 'unverified'; }
81
+ }
82
+
83
+ export function prepareBrainEvidence({ projectRoot, evidence, verify, marker = '', text = '' } = {}) {
84
+ const bad = error => ({ ok: false, error });
85
+ if (marker === '✓' && (evidence !== undefined || verify !== undefined)) {
86
+ return bad('A resolve marker archives existing evidence. To record new evidence, write a milestone with closes, or amend the card with marker ~.');
87
+ }
88
+ if (verify !== undefined && (typeof verify !== 'string' || verify.length > 2000 || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(verify))) {
89
+ return bad('verify must be a string of at most 2000 characters; it is recorded, never executed.');
90
+ }
91
+ if (marker === '~' && typeof verify === 'string' && !verify.trim() && /(?:^|\s)verify:\s*[^\n\s]/i.test(String(text))) {
92
+ return bad('To clear verification, remove the inline verify: suffix from the amended text too.');
93
+ }
94
+ if (evidence !== undefined && (!Array.isArray(evidence) || evidence.length > 16)) return bad('evidence must be an array of at most 16 references.');
95
+ const normalized = [];
96
+ // Validate EVERY entry before any snapshot work (and, at the callers, before
97
+ // taking the write lock or draining pending capture queues).
98
+ for (const [index, item] of (evidence || []).entries()) {
99
+ const prefix = `evidence[${index}]`;
100
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return bad(`${prefix} must be an object.`);
101
+ if (Object.keys(item).some(key => !INPUT_FIELDS.has(key))) return bad(`${prefix} has an unknown field; use kind, ref, optional oid and verifiedAt.`);
102
+ if (!KINDS.has(item.kind)) return bad(`${prefix}.kind must be file, pr, url, commit, or run.`);
103
+ if (typeof item.ref !== 'string' || !item.ref.trim() || item.ref.length > 1000 || /[\x00-\x1f\x7f]/.test(item.ref)) return bad(`${prefix}.ref must be a non-empty single-line string of at most 1000 characters.`);
104
+ if (item.oid !== undefined && (item.kind !== 'file' || typeof item.oid !== 'string' || !SHA_RE.test(item.oid))) return bad(`${prefix}.oid must be a full 40- or 64-character file blob hash.`);
105
+ if (item.verifiedAt !== undefined && (typeof item.verifiedAt !== 'string' || !/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z)?$/.test(item.verifiedAt) || !Number.isFinite(Date.parse(item.verifiedAt)) || new Date(item.verifiedAt).toISOString().slice(0, 10) !== item.verifiedAt.slice(0, 10))) return bad(`${prefix}.verifiedAt must be an ISO date or UTC timestamp (caller-reported, not independently verified).`);
106
+ if (item.kind === 'file' && !resolveEvidenceFile(projectRoot, item.ref)) return bad(`${prefix}.ref must be a safe project-relative file path (no traversal, absolute paths, or links outside the project).`);
107
+ if (item.kind === 'url') {
108
+ try { const url = new URL(item.ref); if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) return bad(`${prefix}.ref must be an HTTP(S) URL without credentials.`); }
109
+ catch { return bad(`${prefix}.ref must be an HTTP(S) URL without credentials.`); }
110
+ }
111
+ normalized.push({ ...item, ref: item.ref.trim(), ...(item.oid ? { oid: item.oid.toLowerCase() } : {}) });
112
+ }
113
+ const now = new Date().toISOString();
114
+ const revision = normalized.some(item => item.kind === 'file') ? git(projectRoot, ['rev-parse', '--verify', 'HEAD']) : null;
115
+ for (const item of normalized) {
116
+ item.capturedAt = now;
117
+ if (item.kind !== 'file') continue;
118
+ const file = resolveEvidenceFile(projectRoot, item.ref);
119
+ const snapshot = file ? readSnapshot(file.target) : { status: 'unverified' };
120
+ if (snapshot.sha256) { item.sha256 = snapshot.sha256; item.sourceBasis = 'working-tree'; }
121
+ if (revision && SHA_RE.test(revision)) item.headRevision = revision;
122
+ }
123
+ return { ok: true, ...(evidence !== undefined ? { evidence: normalized } : {}), ...(verify !== undefined ? { verify: verify.trim() } : {}) };
124
+ }
125
+
126
+ export function inspectCardEvidence(card, { projectRoot, cache = new Map(), maxRefs = 4, budgetMs = 150 } = {}) {
127
+ // One shared cache is one response budget. Never pay refs * git-timeout on
128
+ // brain_sync's fast path. Local synchronous filesystem probes cannot be
129
+ // interrupted mid-call, but no additional probe starts after this deadline.
130
+ if (!cache.has(READ_BUDGET)) cache.set(READ_BUDGET, { deadline: Date.now() + Math.max(0, Math.min(1000, Number.isFinite(budgetMs) ? budgetMs : 150)), probes: 0 });
131
+ const budget = cache.get(READ_BUDGET);
132
+ const limit = Math.max(1, Math.min(16, Number(maxRefs) || 4));
133
+ const refs = Array.isArray(card?.evidence) ? card.evidence : [];
134
+ const sources = refs.slice(0, limit).map(item => {
135
+ const source = { kind: flat(item?.kind, 20), ref: flat(item?.ref, 1000), status: 'unverified' };
136
+ if (typeof item?.capturedAt === 'string') source.capturedAt = flat(item.capturedAt, 30);
137
+ if (typeof item?.verifiedAt === 'string') source.reportedVerifiedAt = flat(item.verifiedAt, 30);
138
+ if (typeof item?.headRevision === 'string' && SHA_RE.test(item.headRevision)) source.headRevision = item.headRevision;
139
+ // Never resolve a truncated reference as if it were the authored path.
140
+ if (item?.kind !== 'file' || typeof item?.ref !== 'string' || item.ref.length > 1000) return source;
141
+ const root = typeof projectRoot === 'string' && projectRoot ? path.resolve(projectRoot) : null;
142
+ if (!root) return source;
143
+ const exhausted = () => Date.now() >= budget.deadline || budget.probes >= 24;
144
+ // Remember containment-checked path resolutions separately from snapshots.
145
+ // A repeated reference can then reuse its observation after the deadline
146
+ // without even repeating filesystem-based path/symlink validation.
147
+ const pathKey = root + '\0path\0' + source.ref;
148
+ if (!cache.has(pathKey)) {
149
+ if (exhausted()) { source.reason = 'inspection budget exhausted'; return source; }
150
+ cache.set(pathKey, resolveEvidenceFile(root, source.ref));
151
+ }
152
+ const file = cache.get(pathKey);
153
+ if (!file) return source;
154
+ const key = root + '\0' + file.relative;
155
+ if (!cache.has(key)) {
156
+ if (exhausted()) { source.reason = 'inspection budget exhausted'; return source; }
157
+ budget.probes++;
158
+ cache.set(key, readSnapshot(file.target));
159
+ }
160
+ const current = cache.get(key);
161
+ if (current.status !== 'read') { source.status = current.status; return source; }
162
+ if (typeof item.sha256 === 'string' && /^[a-f0-9]{64}$/.test(item.sha256) && item.sourceBasis === 'working-tree') {
163
+ source.basis = 'captured working file';
164
+ source.status = current.sha256 === item.sha256 ? 'source-unchanged' : 'changed';
165
+ return source;
166
+ }
167
+ // Legacy hooks stamped HEAD blobs. Check both HEAD and local dirtiness;
168
+ // matching HEAD alone can conceal uncommitted changes. No repo/no git means
169
+ // unverified, never missing or unchanged by inference.
170
+ if (typeof item.oid === 'string' && SHA_RE.test(item.oid)) {
171
+ const gitKey = `${key}\0legacy`;
172
+ if (!cache.has(gitKey)) {
173
+ if (exhausted()) { source.reason = 'inspection budget exhausted'; return source; }
174
+ const oid = git(projectRoot, ['rev-parse', '--verify', `HEAD:${file.relative}`], budget.deadline - Date.now());
175
+ const working = oid ? workingTreeStatus(projectRoot, file.relative, budget.deadline) : 'unverified';
176
+ cache.set(gitKey, { oid, working });
177
+ }
178
+ const legacy = cache.get(gitKey);
179
+ source.basis = 'recorded HEAD blob and current working file';
180
+ source.status = !legacy.oid ? 'unverified'
181
+ : legacy.oid !== item.oid || legacy.working === 'changed' ? 'changed'
182
+ : legacy.working === 'unchanged' ? 'source-unchanged' : 'unverified';
183
+ }
184
+ return source;
185
+ });
186
+ const verification = typeof card?.verify === 'string' ? card.verify.slice(0, 2000).trim() : '';
187
+ return {
188
+ sources,
189
+ omitted: Math.max(0, refs.length - sources.length),
190
+ verify: verification ? { text: verification, status: 'not-executed' } : null,
191
+ recordedVia: flat(card?.createdVia, 80) || null,
192
+ recordedAt: Number.isFinite(card?.createdAt) && Math.abs(card.createdAt) <= 8.64e15 ? new Date(card.createdAt).toISOString() : null,
193
+ };
194
+ }
195
+
196
+ export function formatCardEvidence(summary, { maxChars = 700 } = {}) {
197
+ if (!summary?.sources?.length && !summary?.verify) return '';
198
+ const lines = ['Evidence (current source status is not claim verification):'];
199
+ if (summary.recordedVia) lines.push(`Recorded via ${flat(summary.recordedVia)}${summary.recordedAt ? ` at ${summary.recordedAt}` : ''}.`);
200
+ if (summary.verify) lines.push(`Recorded verification text (not executed):\n${summary.verify.text.replace(/\r\n?/g, '\n').split('\n').map(line => `> ${line}`).join('\n')}`);
201
+ for (const source of summary.sources) lines.push(`- ${source.status}: ${source.ref}${source.basis ? ` (${source.basis})` : ''}${source.reason ? ` (${source.reason})` : ''}${source.capturedAt ? `; captured ${source.capturedAt}` : ''}${source.headRevision ? `; HEAD ${source.headRevision.slice(0, 12)} (revision only)` : ''}${source.reportedVerifiedAt ? `; caller reported verification ${source.reportedVerifiedAt}` : ''}`);
202
+ if (summary.omitted) lines.push(`- ${summary.omitted} more reference(s) omitted.`);
203
+ const value = lines.join('\n'), limit = Math.max(160, Math.min(4000, Number(maxChars) || 700));
204
+ return value.length <= limit ? value : `${value.slice(0, limit - 1)}…`;
205
+ }
@@ -16,9 +16,12 @@
16
16
  //
17
17
  // --marker: (none)=decision · ?/question · !/milestone · ✓/resolve · ~/update
18
18
  // --area X route into the [Area] container (also a #tag) · --closes "<title>"
19
+ // --evidence '<JSON array>' · --verify '<inert verification text>' (also JSON stdin)
19
20
  // Optional trailing path picks a different .klypix (default ./brain.klypix).
20
21
  import fs from 'fs';
21
22
  import path from 'path';
23
+ import { prepareBrainEvidence } from './brain-evidence.mjs';
24
+ import { looksLikeUnfilledDraft } from './capture-gap.mjs';
22
25
  import { captureIntoBrain, tidyBrain, atomicWrite, noteToCaptureInput, formatCaptureReceipts } from './klypix-format.mjs';
23
26
  import { brainCaptureLockPath, withAdvisoryWriteLock } from './brain-write-lock.mjs';
24
27
 
@@ -33,6 +36,13 @@ function parseArgs(argv) {
33
36
  if (a === '--area') out.area = argv[++i] || '';
34
37
  else if (a === '--marker' || a === '-m') out.marker = normMarker(argv[++i]);
35
38
  else if (a === '--closes') out.closes = argv[++i] || '';
39
+ else if (a === '--verify') {
40
+ if (argv[i + 1] === undefined) throw new Error('--verify needs a text argument.');
41
+ out.verify = argv[++i];
42
+ }
43
+ else if (a === '--evidence') {
44
+ try { out.evidence = JSON.parse(argv[++i]); } catch { throw new Error('--evidence needs a JSON array of references.'); }
45
+ }
36
46
  else rest.push(a);
37
47
  }
38
48
  // A trailing .klypix/.any token is the target file; the rest is the note text.
@@ -45,19 +55,27 @@ function readStdin() {
45
55
  try { if (process.stdin.isTTY) return ''; return fs.readFileSync(0, 'utf8'); } catch { return ''; }
46
56
  }
47
57
 
48
- let opts = parseArgs(process.argv.slice(2));
58
+ let opts;
59
+ try { opts = parseArgs(process.argv.slice(2)); }
60
+ catch (e) { console.error(`brain-note: ${e.message}`); process.exit(1); }
49
61
  if (!opts.text) {
50
62
  const raw = readStdin().trim();
51
63
  if (raw) {
52
64
  try { const j = JSON.parse(raw); opts = { ...opts, ...j, marker: normMarker(j.marker ?? opts.marker) }; }
53
- catch { opts.text = raw; }
65
+ catch {
66
+ if (/^[\[{]/.test(raw)) { console.error('brain-note refused (brain unchanged): malformed JSON input.'); process.exit(1); }
67
+ opts.text = raw;
68
+ }
54
69
  }
55
70
  }
56
71
  const file = path.resolve(opts.file || 'brain.klypix');
57
72
  if (!opts.text) { console.error('brain-note: nothing to write — pass a note as an arg, or JSON/text on stdin.'); process.exit(1); }
58
73
  if (!fs.existsSync(file)) { console.error(`brain-note: no brain at ${file} (run from a project with ./brain.klypix, or pass a path).`); process.exit(1); }
59
74
 
60
- const input = noteToCaptureInput({ text: opts.text, area: opts.area, marker: opts.marker, closes: opts.closes, createdVia: 'cli' });
75
+ if (looksLikeUnfilledDraft(opts.text)) { console.error('brain-note refused (brain unchanged): complete or discard the draft rationale before capturing it.'); process.exit(1); }
76
+ const metadata = prepareBrainEvidence({ projectRoot: path.dirname(file), evidence: opts.evidence, verify: opts.verify, marker: opts.marker, text: opts.text });
77
+ if (!metadata.ok) { console.error(`brain-note refused (brain unchanged): ${metadata.error}`); process.exit(1); }
78
+ const input = noteToCaptureInput({ text: opts.text, area: opts.area, marker: opts.marker, closes: opts.closes, evidence: metadata.evidence, verify: metadata.verify, createdVia: 'cli' });
61
79
  try {
62
80
  // Same cross-process lock as the MCP engine, hooks, and desktop app: an
63
81
  // unlocked read-modify-write racing any of them is silent last-writer-wins