klypix-mcp 1.83.0 → 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
 
@@ -327,14 +327,15 @@ async function runSkill(skill, args, text, via) {
327
327
  if (skill === 'learn_skill') marker = '+';
328
328
  const single = (!args.cards && text.trim()) ? stripVerb(text) : null;
329
329
  if (!marker && single && looksLikeSkill(single)) marker = '+'; // NL "remember this gotcha: always…" → skill
330
- if (marker) {
330
+ if (marker || args.evidence !== undefined || args.verify !== undefined) {
331
331
  const noteText = single ?? (Array.isArray(args.cards) && args.cards[0]?.text) ?? '';
332
332
  if (!String(noteText).trim()) return needInput('Nothing to capture — send text or a card to remember.');
333
333
  if (String(noteText).length > 20_000) return needInput('Captured text must be 20,000 characters or fewer.');
334
334
  const requested = args.canvas ?? 'brain';
335
335
  const target = confinedCanvas(requested);
336
336
  if (!target.ok) return refusedCanvas(requested);
337
- 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 });
338
339
  }
339
340
  // NL convenience: a bare "remember: X" becomes a single card on the brain.
340
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 [
@@ -684,6 +684,13 @@ server.registerTool('brain_note', {
684
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.'),
685
685
  area: z.string().optional().describe('Area/topic — routes the card into that titled container and becomes a #tag (e.g. "Auth", "Release").'),
686
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.'),
687
694
  guard: z.object({
688
695
  when: z.object({
689
696
  tool: z.string().max(200).optional().describe('Regex matched against the tool name (e.g. "Bash", "Edit|Write").'),
@@ -697,10 +704,10 @@ server.registerTool('brain_note', {
697
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."),
698
705
  canvas: z.string().optional().describe('Brain canvas filename/path. Defaults to the project brain ("brain").'),
699
706
  },
700
- }, async ({ text, marker, area, closes, guard, canvas }, extra) => {
707
+ }, async ({ text, marker, area, closes, evidence, verify, guard, canvas }, extra) => {
701
708
  // Both 1.77 and 1.78 ride this call: the enrichment question (the asker's
702
709
  // vocabulary for retrieval) AND the per-session capture receipt below.
703
- 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 });
704
711
  // Per-session capture receipt — this is what stops the uncaptured-work nudge
705
712
  // from firing at a session that DID record its reasoning, just through MCP
706
713
  // rather than a 🧠 marker. The Stop hook and this server share one session-id
@@ -708,7 +715,7 @@ server.registerTool('brain_note', {
708
715
  // hook reads. Best-effort: a receipt failure must never fail the note.
709
716
  try {
710
717
  const { recordSessionCapture } = await import('../src/capture-gap.mjs');
711
- recordSessionCapture(extra?.klypixRequestIdentity?.sessionId || mcpPresence.id);
718
+ if (!result.isError) recordSessionCapture(extra?.klypixRequestIdentity?.sessionId || mcpPresence.id, undefined, Date.now(), { project: mcpPresence.vault });
712
719
  } catch { /* receipt is best-effort */ }
713
720
  return toContent(result);
714
721
  });
@@ -939,32 +946,34 @@ server.registerTool('brain_sync', {
939
946
  if (head) gap.recordTaskBaseline(sid, { head, project: projectDir });
940
947
  } else if (phase === 'complete' && projectDir && sid) {
941
948
  const baseline = gap.readTaskBaseline(sid);
942
- if (baseline?.head) {
943
- // An ancestry check first: a rebase/reset makes the range meaningless,
944
- // and reporting a rewritten history as "commits you didn't record" is
945
- // exactly the cry-wolf that gets a nudge switched off.
946
- const reachable = gitOk(['merge-base', '--is-ancestor', baseline.head, 'HEAD']);
947
- 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;
948
961
  if (count > 0) {
949
- const subjects = gitOut(['log', '--no-merges', '--format=%s', `${baseline.head}..HEAD`])
962
+ const subjects = gitOut(['log', '--no-merges', '--format=%s', range])
950
963
  .split('\n').map((s) => s.trim()).filter(Boolean).slice(0, 5);
951
- // Bodies decide whether ANY rationale was recorded — the same rule
952
- // the hook uses, so the two halves never disagree about one session.
953
- const withRationale = gitOut(['log', '--no-merges', '--format=%x1e%b', `${baseline.head}..HEAD`])
964
+ const withRationale = gitOut(['log', '--no-merges', '--format=%x1e%b', range])
954
965
  .split('\x1e').map((b) => b.replace(/\s+/g, ' ').trim()).filter((b) => b.length >= 12).length;
966
+ const outcome = projectDir + ':' + gitOut(['rev-parse', 'HEAD']);
955
967
  const decision = gap.captureGapDecision({
956
968
  commitTotal: count,
957
969
  commitCards: withRationale,
958
- sessionCaptured: gap.sessionHasCaptured(sid),
970
+ alreadyNudged: gap.outcomeWasNudged(sid, outcome),
959
971
  });
960
972
  if (decision) {
961
- const changed = gitOut(['diff', '--name-only', `${baseline.head}..HEAD`]).split('\n').filter(Boolean).slice(0, 20);
962
- const draft = gap.draftCaptureMarker({
963
- commits: subjects.map((subject) => ({ subject })),
964
- filesTouched: changed,
965
- });
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 });
966
975
  captureGapText = gap.captureGapReason({ ...decision, draft, mode: 'advise' });
967
- gap.recordCaptureGapNudge(sid);
976
+ gap.recordCaptureGapNudge(sid, undefined, outcome);
968
977
  }
969
978
  }
970
979
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.83.0",
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/canvas-groups.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