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 +6 -0
- package/FORMAT.md +12 -0
- package/README.md +24 -0
- package/bin/klypix-a2a.mjs +3 -2
- package/bin/klypix-install.mjs +1 -1
- package/bin/klypix-worker.mjs +29 -20
- 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 +53 -19
- package/src/klypix-format.mjs +110 -51
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
|
@@ -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
|
-
|
|
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);
|
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
|
@@ -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
|
-
|
|
943
|
-
|
|
944
|
-
//
|
|
945
|
-
//
|
|
946
|
-
const
|
|
947
|
-
|
|
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',
|
|
962
|
+
const subjects = gitOut(['log', '--no-merges', '--format=%s', range])
|
|
950
963
|
.split('\n').map((s) => s.trim()).filter(Boolean).slice(0, 5);
|
|
951
|
-
|
|
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
|
-
|
|
970
|
+
alreadyNudged: gap.outcomeWasNudged(sid, outcome),
|
|
959
971
|
});
|
|
960
972
|
if (decision) {
|
|
961
|
-
const changed = gitOut(['diff', '--name-only',
|
|
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.
|
|
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
|
+
}
|
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
|