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 +6 -0
- package/FORMAT.md +12 -0
- package/README.md +24 -0
- package/bin/klypix-a2a.mjs +8 -4
- package/bin/klypix-install.mjs +1 -1
- package/bin/klypix-worker.mjs +34 -24
- package/bin/klypix-write.mjs +11 -6
- package/package.json +4 -3
- package/src/brain-evidence.mjs +205 -0
- package/src/brain-note.mjs +21 -3
- package/src/capture-gap.mjs +78 -26
- package/src/global-brain-hook.mjs +85 -54
- package/src/klypix-core.mjs +69 -22
- package/src/klypix-format.mjs +221 -64
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
|
|
package/bin/klypix-a2a.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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);
|
package/bin/klypix-install.mjs
CHANGED
|
@@ -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 [
|
package/bin/klypix-worker.mjs
CHANGED
|
@@ -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
|
-
|
|
942
|
-
|
|
943
|
-
//
|
|
944
|
-
//
|
|
945
|
-
const
|
|
946
|
-
|
|
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',
|
|
962
|
+
const subjects = gitOut(['log', '--no-merges', '--format=%s', range])
|
|
949
963
|
.split('\n').map((s) => s.trim()).filter(Boolean).slice(0, 5);
|
|
950
|
-
|
|
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
|
-
|
|
970
|
+
alreadyNudged: gap.outcomeWasNudged(sid, outcome),
|
|
958
971
|
});
|
|
959
972
|
if (decision) {
|
|
960
|
-
const changed = gitOut(['diff', '--name-only',
|
|
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
|
}
|
package/bin/klypix-write.mjs
CHANGED
|
@@ -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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|
package/src/brain-note.mjs
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
-
|
|
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
|