klypix-mcp 1.75.0 → 1.77.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/bin/klypix-install.mjs +29 -7
- package/bin/klypix-worker.mjs +86 -2
- package/package.json +3 -3
- package/src/capture-gap.mjs +258 -0
- package/src/enrichment.mjs +167 -0
- package/src/global-brain-hook.mjs +216 -22
- package/src/install-version.mjs +15 -2
- package/src/klypix-core.mjs +11 -1
- package/src/mcp-presence.mjs +82 -7
- package/src/repo-state.mjs +113 -0
- package/src/semantic-memory.mjs +18 -2
package/README.md
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# Every project gets a brain.
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**Active state management for multi-agent coding — a local-first active context engine with a shared brain.**
|
|
4
4
|
*One project. One shared understanding.*
|
|
5
5
|
|
|
6
|
-
**One
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
**One actively managed project brain for multi-agent coding.** `klypix-mcp` keeps one versioned
|
|
7
|
+
`brain.klypix` in your repo: the project's active state — current decisions, corrections, evidence
|
|
8
|
+
anchors, open questions, active work, and handoffs. Corrections supersede stale decisions,
|
|
9
|
+
`brain_challenge` tests proposed decisions against standing rules and reversed approaches, and
|
|
10
|
+
sessions declare their scope and get warned about same-machine file overlap. Agents read it and
|
|
11
|
+
write to it over MCP. You read it and correct it in the [KLYPIX app](https://klypix.com).
|
|
10
12
|
|
|
11
13
|
> **One project. Many agents. One current understanding.**
|
|
12
14
|
|
package/bin/klypix-install.mjs
CHANGED
|
@@ -246,7 +246,15 @@ const flatten = (code) => code
|
|
|
246
246
|
.replace(/\.\.\/src\/klypix-(core|format)\.mjs/g, './klypix-$1.mjs')
|
|
247
247
|
// brain-doctor + agent-rules (the server's lazy `import('../src/brain-doctor.mjs')`
|
|
248
248
|
// for the brain_doctor tool) → flat sibling refs in the runtime layout.
|
|
249
|
-
|
|
249
|
+
// capture-gap: the worker imports it from BOTH brain_note (the per-session
|
|
250
|
+
// capture receipt) and brain_sync (the uncaptured-work check). Both are
|
|
251
|
+
// wrapped in try/catch by design, so an unflattened path here would not
|
|
252
|
+
// crash — it would make the agent-neutral half silently never fire in the
|
|
253
|
+
// deployed runtime. test/cli-args.mjs "G: no unresolved ../src/*.mjs" is the
|
|
254
|
+
// only thing standing between that and the field; it caught exactly this.
|
|
255
|
+
// (remote-client deliberately absent: the Remote feature was removed in
|
|
256
|
+
// 1.73.x, and this cherry-pick must not resurrect it — recorded rule.)
|
|
257
|
+
.replace(/\.\.\/src\/(bench|brain-doctor|agent-presence|agent-rules|capture-gap|enrichment|finding-routing|mcp-presence|mcp-supervisor|mcp-auto-update|presence-relay|semantic-memory|runtime-inspector|project-graph|git-capture-install)\.mjs/g, './$1.mjs')
|
|
250
258
|
.replace(/klypix-worker\.mjs/g, 'klypix-mcp-worker.mjs')
|
|
251
259
|
.replace(/const PKG_VERSION = \(\(\) => \{[\s\S]*?\}\)\(\);/, `const PKG_VERSION = '${VERSION}'; // baked at install (flat layout has no package.json)`);
|
|
252
260
|
|
|
@@ -291,12 +299,15 @@ try {
|
|
|
291
299
|
// (`git tag --points-at HEAD` semantics inside collectRepoState): the
|
|
292
300
|
// release tag names the exact evidence commit, so any non-HEAD comparison
|
|
293
301
|
// would certify code the tag never covered.
|
|
294
|
-
const
|
|
295
|
-
const
|
|
302
|
+
const gitPresent = exists(path.join(PKG_ROOT, '.git'));
|
|
303
|
+
const checkout = gitPresent ? collectRepoState(PKG_ROOT) : null;
|
|
304
|
+
const sourceDecision = deploySourceDecision({ checkout, allowUntagged: ALLOW_UNTAGGED, gitPresent });
|
|
296
305
|
const checkoutLabel = `v${VERSION}, branch ${checkout?.branch || '(detached)'}, head ${checkout?.headShort || '?'}`;
|
|
297
306
|
if (sourceDecision.action === 'refuse') {
|
|
298
307
|
releaseInstallLockSync(installLock);
|
|
299
|
-
console.error(
|
|
308
|
+
console.error(sourceDecision.source === 'unverifiable-git-state'
|
|
309
|
+
? `✗ refusing to deploy from a git checkout whose state could NOT be verified: ${checkoutLabel} — the git probes failed (busy machine?); no files were changed. Retry, or acknowledge a dev deploy explicitly.`
|
|
310
|
+
: `✗ refusing to deploy an UNRELEASED source checkout machine-globally: ${checkoutLabel} — no release tag v${VERSION} at HEAD; no files were changed.`);
|
|
300
311
|
console.error(' Released installs come from the registry: npx -y klypix-mcp@latest install');
|
|
301
312
|
console.error(' To deliberately deploy this working tree (a dev deploy), acknowledge it: re-run with --allow-untagged or KLYPIX_MCP_ALLOW_UNTAGGED=1.');
|
|
302
313
|
console.error(' An acknowledged dev deploy is stamped dev-owned, so brain_doctor shows it and auto-update will not silently replace it.');
|
|
@@ -380,7 +391,7 @@ try {
|
|
|
380
391
|
// canvas-view-app.html is the canvas_view MCP App UI — staged raw (an HTML
|
|
381
392
|
// file must never get a JS-comment banner) beside the flat server, which
|
|
382
393
|
// resolves it via its ./canvas-view-app.html candidate path.
|
|
383
|
-
for (const f of ['global-brain-hook.mjs', 'brain-semantic.mjs', 'semantic-memory.mjs', 'brain-note.mjs', 'brain-git-hook.mjs', 'git-capture-install.mjs', 'brain-history.mjs', 'brain-graveyard.mjs', 'klypix-format.mjs', 'klypix-core.mjs', 'brain-write-lock.mjs', 'agent-rules.mjs', 'brain-doctor.mjs', 'agent-presence.mjs', 'mcp-presence.mjs', 'repo-state.mjs', 'result-reconcile.mjs', 'finding-routing.mjs', 'presence-relay.mjs', 'mcp-supervisor.mjs', 'mcp-auto-update.mjs', 'runtime-inspector.mjs', 'project-graph.mjs', 'bench.mjs', 'codex-brain-hook.mjs', 'codex-hooks.mjs', 'canvas-view-app.html']) {
|
|
394
|
+
for (const f of ['global-brain-hook.mjs', 'capture-gap.mjs', 'brain-semantic.mjs', 'semantic-memory.mjs', 'enrichment.mjs', 'brain-note.mjs', 'brain-git-hook.mjs', 'git-capture-install.mjs', 'brain-history.mjs', 'brain-graveyard.mjs', 'klypix-format.mjs', 'klypix-core.mjs', 'brain-write-lock.mjs', 'agent-rules.mjs', 'brain-doctor.mjs', 'agent-presence.mjs', 'mcp-presence.mjs', 'repo-state.mjs', 'result-reconcile.mjs', 'finding-routing.mjs', 'presence-relay.mjs', 'mcp-supervisor.mjs', 'mcp-auto-update.mjs', 'runtime-inspector.mjs', 'project-graph.mjs', 'bench.mjs', 'codex-brain-hook.mjs', 'codex-hooks.mjs', 'canvas-view-app.html']) {
|
|
384
395
|
const s = path.join(SRC, f); if (exists(s)) staged.push({ dst: f, content: fs.readFileSync(s, 'utf8') });
|
|
385
396
|
}
|
|
386
397
|
for (const [src, dst] of [
|
|
@@ -402,8 +413,19 @@ try {
|
|
|
402
413
|
let n = 0;
|
|
403
414
|
for (const st of renameOrder) { renameSyncWithBackoff(path.join(BRAIN_DIR, st.dst + '.klypix-new'), path.join(BRAIN_DIR, st.dst)); n++; }
|
|
404
415
|
|
|
405
|
-
// 3) mark the dir an ESM package
|
|
406
|
-
|
|
416
|
+
// 3) mark the dir an ESM package — WITH the brain-core version in it. The
|
|
417
|
+
// version was only ever discoverable from .brain-version.json or by regexing
|
|
418
|
+
// the baked PKG_VERSION out of klypix-mcp-server.mjs; an agent asked "what
|
|
419
|
+
// version is this brain?", read package.json (the one place everybody looks),
|
|
420
|
+
// got `version: undefined`, and reported the install unidentifiable
|
|
421
|
+
// (2026-08-16 field report). This is provenance, never the gate: the
|
|
422
|
+
// never-downgrade decision still reads .brain-version.json / .mcp-runtime.json.
|
|
423
|
+
fs.writeFileSync(path.join(BRAIN_DIR, 'package.json'), JSON.stringify({
|
|
424
|
+
name: 'klypix-project-brain',
|
|
425
|
+
...(VERSION ? { version: VERSION } : {}),
|
|
426
|
+
private: true,
|
|
427
|
+
type: 'module',
|
|
428
|
+
}, null, 2));
|
|
407
429
|
|
|
408
430
|
// 5) wire the 4 hooks into settings.json (refuse on invalid JSON; back up;
|
|
409
431
|
// atomic). A background runtime-only update refreshes the scripts while
|
package/bin/klypix-worker.mjs
CHANGED
|
@@ -686,7 +686,19 @@ server.registerTool('brain_note', {
|
|
|
686
686
|
canvas: z.string().optional().describe('Brain canvas filename/path. Defaults to the project brain ("brain").'),
|
|
687
687
|
},
|
|
688
688
|
}, async ({ text, marker, area, closes, canvas }, extra) => {
|
|
689
|
-
|
|
689
|
+
// Both 1.77 and 1.78 ride this call: the enrichment question (the asker's
|
|
690
|
+
// vocabulary for retrieval) AND the per-session capture receipt below.
|
|
691
|
+
const result = await opBrainNote({ vault: mcpPresence.vault, canvas: boundBrainCanvas(canvas), text, area, marker: marker || '', closes, via: extra.klypixClientName, enrichmentQuestion: mcpPresence.declaredIntent });
|
|
692
|
+
// Per-session capture receipt — this is what stops the uncaptured-work nudge
|
|
693
|
+
// from firing at a session that DID record its reasoning, just through MCP
|
|
694
|
+
// rather than a 🧠 marker. The Stop hook and this server share one session-id
|
|
695
|
+
// space (the same presence lane), so the receipt written here is the one the
|
|
696
|
+
// hook reads. Best-effort: a receipt failure must never fail the note.
|
|
697
|
+
try {
|
|
698
|
+
const { recordSessionCapture } = await import('../src/capture-gap.mjs');
|
|
699
|
+
recordSessionCapture(extra?.klypixRequestIdentity?.sessionId || mcpPresence.id);
|
|
700
|
+
} catch { /* receipt is best-effort */ }
|
|
701
|
+
return toContent(result);
|
|
690
702
|
});
|
|
691
703
|
|
|
692
704
|
server.registerTool('brain_message', {
|
|
@@ -796,6 +808,7 @@ server.registerTool('brain_sync', {
|
|
|
796
808
|
shas: z.array(z.string().max(40)).max(20).optional().describe('Commit shas that MUST ride the next release. Stake after committing work a user was promised — the claim OUTLIVES this session (14d), and every future releaseIntent must contain these commits or acknowledge them by name.'),
|
|
797
809
|
note: z.string().max(160).optional().describe('One line of why — shown verbatim in any refusal that names this claim ("founder was told the Arrow tool ships in the next build").'),
|
|
798
810
|
withdraw: z.union([z.array(z.string().max(40)).max(20), z.boolean()]).optional().describe('Shas to withdraw from this session\'s claim; [] or true withdraws the whole claim. Only the staking session (or its logical continuation) can withdraw.'),
|
|
811
|
+
publish: z.boolean().optional().describe('Also write the claim as .klypix/claims/<owner>.json in the project (or delete that file when withdrawing). Commit it and the promise TRAVELS WITH THE REPO: every clone\'s release gate reads it, it is reviewable in PRs, and its history is auditable — team-wide claims over plain git, zero infrastructure.'),
|
|
799
812
|
}).optional().describe('Stake a durable claim that specific commits ride the NEXT release — the promise "you\'ll see it in the next build" made machine-readable. Unlike presence rows (which age out ~10min after a session ends), a claim persists until fulfilled (the release ref contains the shas — auto-retired with a courtesy note), withdrawn, or expired (14d). A release that would drop claimed shas is REFUSED until they are acknowledged BY NAME, and acknowledging them away notifies the owner. Use exactly one of shas (stake/extend) or withdraw.'),
|
|
800
813
|
},
|
|
801
814
|
}, async ({ project, intent, files, phase, include_context, results, releaseIntent, releaseClaim }, extra) => {
|
|
@@ -876,6 +889,77 @@ server.registerTool('brain_sync', {
|
|
|
876
889
|
}
|
|
877
890
|
} catch { /* observation is best-effort — never fail a sync */ }
|
|
878
891
|
}
|
|
892
|
+
// ── Uncaptured-work check, host-neutral half ────────────────────────────────
|
|
893
|
+
// The Stop hook can REFUSE a stop; every other host has no lifecycle hook at
|
|
894
|
+
// all, so brain_sync is the only place the same question can be asked. Stamp
|
|
895
|
+
// the git HEAD at "start", and at "complete" compare it against HEAD: commits
|
|
896
|
+
// that landed during this task with nothing recorded about WHY is the same gap
|
|
897
|
+
// the hook catches, and it produces the same drafted card. Advisory only — it
|
|
898
|
+
// never changes status/mutation, so completion semantics are untouched.
|
|
899
|
+
let captureGapText = '';
|
|
900
|
+
{
|
|
901
|
+
const projectDir = report.structured?.project || mcpPresence.vault;
|
|
902
|
+
const sid = String(extra?.klypixRequestIdentity?.sessionId || mcpPresence.id || '');
|
|
903
|
+
// `require` does not exist in ESM — the child_process binding has to be
|
|
904
|
+
// imported, and gitOut must stay synchronous for the ancestry/count chain.
|
|
905
|
+
let execFileSync = null;
|
|
906
|
+
try { ({ execFileSync } = await import('child_process')); } catch { /* no child_process → no check */ }
|
|
907
|
+
const gitOut = (args) => {
|
|
908
|
+
if (!execFileSync) return '';
|
|
909
|
+
try {
|
|
910
|
+
return String(execFileSync('git', args, { cwd: projectDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000 })).trim();
|
|
911
|
+
} catch { return ''; }
|
|
912
|
+
};
|
|
913
|
+
// `git merge-base --is-ancestor` answers with its EXIT CODE and prints
|
|
914
|
+
// nothing, so it must be read as ok/not-ok — an output check would treat
|
|
915
|
+
// "not an ancestor" and "success" as the same empty string.
|
|
916
|
+
const gitOk = (args) => {
|
|
917
|
+
if (!execFileSync) return false;
|
|
918
|
+
try {
|
|
919
|
+
execFileSync('git', args, { cwd: projectDir, stdio: 'ignore', timeout: 4000 });
|
|
920
|
+
return true;
|
|
921
|
+
} catch { return false; }
|
|
922
|
+
};
|
|
923
|
+
try {
|
|
924
|
+
const gap = await import('../src/capture-gap.mjs');
|
|
925
|
+
if (phase === 'start' && projectDir && sid) {
|
|
926
|
+
const head = gitOut(['rev-parse', 'HEAD']);
|
|
927
|
+
if (head) gap.recordTaskBaseline(sid, { head, project: projectDir });
|
|
928
|
+
} else if (phase === 'complete' && projectDir && sid) {
|
|
929
|
+
const baseline = gap.readTaskBaseline(sid);
|
|
930
|
+
if (baseline?.head) {
|
|
931
|
+
// An ancestry check first: a rebase/reset makes the range meaningless,
|
|
932
|
+
// and reporting a rewritten history as "commits you didn't record" is
|
|
933
|
+
// exactly the cry-wolf that gets a nudge switched off.
|
|
934
|
+
const reachable = gitOk(['merge-base', '--is-ancestor', baseline.head, 'HEAD']);
|
|
935
|
+
const count = reachable ? Number(gitOut(['rev-list', '--count', '--no-merges', `${baseline.head}..HEAD`]) || 0) : 0;
|
|
936
|
+
if (count > 0) {
|
|
937
|
+
const subjects = gitOut(['log', '--no-merges', '--format=%s', `${baseline.head}..HEAD`])
|
|
938
|
+
.split('\n').map((s) => s.trim()).filter(Boolean).slice(0, 5);
|
|
939
|
+
// Bodies decide whether ANY rationale was recorded — the same rule
|
|
940
|
+
// the hook uses, so the two halves never disagree about one session.
|
|
941
|
+
const withRationale = gitOut(['log', '--no-merges', '--format=%x1e%b', `${baseline.head}..HEAD`])
|
|
942
|
+
.split('\x1e').map((b) => b.replace(/\s+/g, ' ').trim()).filter((b) => b.length >= 12).length;
|
|
943
|
+
const decision = gap.captureGapDecision({
|
|
944
|
+
commitTotal: count,
|
|
945
|
+
commitCards: withRationale,
|
|
946
|
+
sessionCaptured: gap.sessionHasCaptured(sid),
|
|
947
|
+
});
|
|
948
|
+
if (decision) {
|
|
949
|
+
const changed = gitOut(['diff', '--name-only', `${baseline.head}..HEAD`]).split('\n').filter(Boolean).slice(0, 20);
|
|
950
|
+
const draft = gap.draftCaptureMarker({
|
|
951
|
+
commits: subjects.map((subject) => ({ subject })),
|
|
952
|
+
filesTouched: changed,
|
|
953
|
+
});
|
|
954
|
+
captureGapText = gap.captureGapReason({ ...decision, draft, mode: 'advise' });
|
|
955
|
+
gap.recordCaptureGapNudge(sid);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
gap.clearTaskBaseline(sid);
|
|
960
|
+
}
|
|
961
|
+
} catch { /* the check never fails a sync */ }
|
|
962
|
+
}
|
|
879
963
|
let taskContext = null;
|
|
880
964
|
if (phase !== 'complete' && include_context !== false && (intent || files?.length)) {
|
|
881
965
|
taskContext = await opBrainTaskContext({
|
|
@@ -926,7 +1010,7 @@ server.registerTool('brain_sync', {
|
|
|
926
1010
|
return {
|
|
927
1011
|
content: [{
|
|
928
1012
|
type: 'text',
|
|
929
|
-
text: [report.text, harnessText, shipNotice, contextText, timingText].filter(Boolean).join('\n\n'),
|
|
1013
|
+
text: [report.text, harnessText, shipNotice, captureGapText, contextText, timingText].filter(Boolean).join('\n\n'),
|
|
930
1014
|
}],
|
|
931
1015
|
structuredContent,
|
|
932
1016
|
...(report.isError ? { isError: true } : {}),
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "klypix-mcp",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.77.0",
|
|
4
|
+
"description": "Active state management for multi-agent coding: a shared, versioned project brain over MCP.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"keywords": [
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
"bench": "node bin/klypix-mcp.mjs bench",
|
|
84
84
|
"test:bench": "node test/bench.mjs",
|
|
85
85
|
"pretest": "node test/publish-workflow.mjs",
|
|
86
|
-
"test": "node test/publish-verdict.mjs && node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/install-version.mjs && node test/install-rename-backoff.mjs && node test/project-binding-rebind.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/orphan-gardener.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/retrieval-fusion.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/undeclared-active.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/release-lease.mjs && node test/release-ancestry.mjs && node test/release-claim-join.mjs && node test/release-claims.mjs && node test/release-handshake.mjs && node test/completion-guard.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/one-command-setup.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
|
|
86
|
+
"test": "node test/publish-verdict.mjs && node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/install-version.mjs && node test/install-rename-backoff.mjs && node test/project-binding-rebind.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/capture-gap.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/orphan-gardener.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/retrieval-fusion.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/enrichment.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/undeclared-active.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/release-lease.mjs && node test/release-ancestry.mjs && node test/release-claim-join.mjs && node test/release-claims.mjs && node test/release-handshake.mjs && node test/completion-guard.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/one-command-setup.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
|
|
87
87
|
"test:memory": "node test/memory-runtime.mjs",
|
|
88
88
|
"test:memory:soak": "node --expose-gc test/memory-soak.mjs",
|
|
89
89
|
"runtime": "node bin/klypix-runtime.mjs"
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// Uncaptured-work detection — ONE implementation, every host.
|
|
2
|
+
//
|
|
3
|
+
// Capture is opt-in: something has to CHOOSE to emit a 🧠 marker or call
|
|
4
|
+
// brain_note. That fails hardest exactly where it matters most — a long, loaded
|
|
5
|
+
// session is both the least likely to remember and the most likely to have
|
|
6
|
+
// produced something worth keeping. The 2026-08-16 field report is the canonical
|
|
7
|
+
// instance: a multi-session workstream took a file format to a REGISTERED IANA
|
|
8
|
+
// media type (spec written, 8 schema-drift bugs fixed, PR merged, registration
|
|
9
|
+
// landed) and the brain recorded NONE of it — the agent had been writing to its
|
|
10
|
+
// own private per-project memory dir the whole time, which no peer can read.
|
|
11
|
+
//
|
|
12
|
+
// This module holds the parts that must behave identically whether the caller is
|
|
13
|
+
// the Claude Code Stop hook (which can REFUSE the stop) or an MCP host calling
|
|
14
|
+
// brain_sync(phase:"complete") (which can only advise). Both ask the same
|
|
15
|
+
// question of the same facts and hand back the same drafted card.
|
|
16
|
+
//
|
|
17
|
+
// Three deliberate properties, each learned the hard way:
|
|
18
|
+
//
|
|
19
|
+
// • A DRAFT, NOT AN ASSIGNMENT. Asking a drained session to "record the
|
|
20
|
+
// decision" buys a one-line receipt ("merged PR #406") — the event, never
|
|
21
|
+
// the reasoning, which is the half that was actually lost. So the draft
|
|
22
|
+
// pre-fills every mechanical fact from the artifacts themselves (ship, commit
|
|
23
|
+
// subjects, files, area) and leaves exactly ONE slot: why it matters. The
|
|
24
|
+
// agent supplies the only sentence it alone knows, or discards the card.
|
|
25
|
+
//
|
|
26
|
+
// • PER-SESSION PROVENANCE, NOT FILE MTIME. The first cut stayed silent when
|
|
27
|
+
// brain.klypix changed during the session — which, on a shared brain with
|
|
28
|
+
// ten live sessions, means a PEER's card silences the session that shipped
|
|
29
|
+
// and recorded nothing. That is backwards: it goes quiet exactly in the
|
|
30
|
+
// multi-agent case. Silence now requires THIS session id to have captured.
|
|
31
|
+
//
|
|
32
|
+
// • NARROW ON PURPOSE. A nudge that cries wolf gets ignored, then disabled.
|
|
33
|
+
// It needs a real artifact and a total absence of recorded rationale — a
|
|
34
|
+
// good commit body IS capture and buys silence.
|
|
35
|
+
|
|
36
|
+
import fs from 'fs';
|
|
37
|
+
import os from 'os';
|
|
38
|
+
import path from 'path';
|
|
39
|
+
|
|
40
|
+
// Shared sidecar: which sessions have been nudged (once each) and which have
|
|
41
|
+
// captured something (so a later turn in the same session stays quiet). Lives
|
|
42
|
+
// beside the other brain-home state; the Claude Code session id and the MCP
|
|
43
|
+
// presence session id are the SAME id space (the hook and the server share one
|
|
44
|
+
// lane), so a brain_note through MCP correctly silences the Stop hook.
|
|
45
|
+
export function captureGapStatePath(home = os.homedir()) {
|
|
46
|
+
return path.join(home, '.claude', 'project-brain', '.capture-gap.json');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function readCaptureGapState(file = captureGapStatePath()) {
|
|
50
|
+
try {
|
|
51
|
+
const j = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
52
|
+
return {
|
|
53
|
+
nudged: Array.isArray(j?.nudged) ? j.nudged.map(String) : [],
|
|
54
|
+
captured: (j && typeof j.captured === 'object' && !Array.isArray(j.captured)) ? j.captured : {},
|
|
55
|
+
baselines: (j && typeof j.baselines === 'object' && !Array.isArray(j.baselines)) ? j.baselines : {},
|
|
56
|
+
};
|
|
57
|
+
} catch { return { nudged: [], captured: {}, baselines: {} }; }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Best-effort, never throws. A torn write only loses the memo — `stop_hook_active`
|
|
61
|
+
// remains the primary loop guard and a re-nudge is bounded to one extra turn.
|
|
62
|
+
function writeCaptureGapState(state, file) {
|
|
63
|
+
try {
|
|
64
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
65
|
+
fs.writeFileSync(file, JSON.stringify(state));
|
|
66
|
+
} catch { /* best-effort */ }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function recordCaptureGapNudge(sessionId, file = captureGapStatePath()) {
|
|
70
|
+
const sid = String(sessionId || ''); if (!sid) return;
|
|
71
|
+
const state = readCaptureGapState(file);
|
|
72
|
+
state.nudged = [...state.nudged.filter(s => s !== sid), sid].slice(-300);
|
|
73
|
+
writeCaptureGapState(state, file);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Called by EVERY capture path that knows its session: the Stop hook after an
|
|
77
|
+
// authored capture, and the brain_note MCP verb. This is what makes the silence
|
|
78
|
+
// rule per-session instead of per-file.
|
|
79
|
+
export function recordSessionCapture(sessionId, file = captureGapStatePath(), now = Date.now()) {
|
|
80
|
+
const sid = String(sessionId || ''); if (!sid) return;
|
|
81
|
+
const state = readCaptureGapState(file);
|
|
82
|
+
state.captured[sid] = now;
|
|
83
|
+
// Bound the map: keep the 300 most recent sessions.
|
|
84
|
+
const entries = Object.entries(state.captured).sort((a, b) => Number(b[1]) - Number(a[1])).slice(0, 300);
|
|
85
|
+
state.captured = Object.fromEntries(entries);
|
|
86
|
+
writeCaptureGapState(state, file);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function sessionHasCaptured(sessionId, file = captureGapStatePath()) {
|
|
90
|
+
const sid = String(sessionId || ''); if (!sid) return false;
|
|
91
|
+
return Boolean(readCaptureGapState(file).captured[sid]);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Task baseline, for hosts with no lifecycle hook ──────────────────────────
|
|
95
|
+
// The Stop hook gets its commit window from the hook's own per-project baseline.
|
|
96
|
+
// An MCP host has no such thing, so brain_sync stamps the git HEAD at phase
|
|
97
|
+
// "start" and reads it back at phase "complete" — the same "what landed during
|
|
98
|
+
// this task" question, answered from the only two calls every host makes. Kept
|
|
99
|
+
// in the same sidecar so there is one file to reason about.
|
|
100
|
+
export function recordTaskBaseline(sessionId, { head = '', project = '' } = {}, file = captureGapStatePath(), now = Date.now()) {
|
|
101
|
+
const sid = String(sessionId || ''); if (!sid || !head) return;
|
|
102
|
+
const state = readCaptureGapState(file);
|
|
103
|
+
state.baselines = (state.baselines && typeof state.baselines === 'object' && !Array.isArray(state.baselines)) ? state.baselines : {};
|
|
104
|
+
state.baselines[sid] = { head: String(head), project: String(project || ''), at: now };
|
|
105
|
+
const entries = Object.entries(state.baselines).sort((a, b) => Number(b[1]?.at || 0) - Number(a[1]?.at || 0)).slice(0, 300);
|
|
106
|
+
state.baselines = Object.fromEntries(entries);
|
|
107
|
+
writeCaptureGapState(state, file);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function readTaskBaseline(sessionId, file = captureGapStatePath()) {
|
|
111
|
+
const sid = String(sessionId || ''); if (!sid) return null;
|
|
112
|
+
const b = readCaptureGapState(file).baselines?.[sid];
|
|
113
|
+
return (b && b.head) ? b : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function clearTaskBaseline(sessionId, file = captureGapStatePath()) {
|
|
117
|
+
const sid = String(sessionId || ''); if (!sid) return;
|
|
118
|
+
const state = readCaptureGapState(file);
|
|
119
|
+
if (state.baselines?.[sid]) { delete state.baselines[sid]; writeCaptureGapState(state, file); }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── The decision ─────────────────────────────────────────────────────────────
|
|
123
|
+
// Returns null to stay silent, or the observed evidence. Pure: every input is
|
|
124
|
+
// supplied by the caller, so the Stop hook (transcript + git) and brain_sync
|
|
125
|
+
// (git + presence) can answer the same question from what each can see.
|
|
126
|
+
export function captureGapDecision({
|
|
127
|
+
authored = 0, commitTotal = 0, commitCards = 0, shipped = [], pushed = false,
|
|
128
|
+
filesTouched = 0, sessionCaptured = false, stopHookActive = false,
|
|
129
|
+
alreadyNudged = false, env = process.env,
|
|
130
|
+
} = {}) {
|
|
131
|
+
if (String(env.KLYPIX_BRAIN_NUDGE || '').toLowerCase() === 'off') return null;
|
|
132
|
+
if (stopHookActive || alreadyNudged) return null;
|
|
133
|
+
if (authored > 0 || commitCards > 0) return null; // rationale exists somewhere
|
|
134
|
+
if (sessionCaptured) return null; // THIS session already fed the brain
|
|
135
|
+
const significant = shipped.length > 0 || commitTotal >= 2
|
|
136
|
+
|| (pushed && commitTotal >= 1) || filesTouched >= 6;
|
|
137
|
+
if (!significant) return null;
|
|
138
|
+
const undocumented = Math.max(0, commitTotal - commitCards);
|
|
139
|
+
const evidence = [];
|
|
140
|
+
if (shipped.length) evidence.push(shipped.slice(0, 3).join(' · '));
|
|
141
|
+
if (commitTotal) evidence.push(`${commitTotal} new commit${commitTotal === 1 ? '' : 's'}${undocumented === commitTotal ? ' (no rationale body on any of them)' : ''}`);
|
|
142
|
+
else if (pushed) evidence.push('pushed to a remote');
|
|
143
|
+
if (filesTouched) evidence.push(`${filesTouched} file${filesTouched === 1 ? '' : 's'} edited`);
|
|
144
|
+
return evidence.length ? { evidence } : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── The draft ────────────────────────────────────────────────────────────────
|
|
148
|
+
const slug = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
149
|
+
const CC_SCOPE = /^(?:feat|fix|perf|refactor|chore|docs|test|build|ci)(?:\(([^)]+)\))?!?:\s*(.+)$/i;
|
|
150
|
+
|
|
151
|
+
// The area a drafted card should land in, inferred from what the session
|
|
152
|
+
// actually touched: a conventional-commit scope first (the author's own label),
|
|
153
|
+
// then the dominant source directory, then a neutral fallback. Never invents a
|
|
154
|
+
// topic — an area guessed from prose is worse than "Ship".
|
|
155
|
+
export function inferArea({ commits = [], filesTouched = [] } = {}) {
|
|
156
|
+
for (const c of commits) {
|
|
157
|
+
const m = CC_SCOPE.exec(String(c?.subject || ''));
|
|
158
|
+
if (m && m[1] && m[1].trim()) return m[1].trim();
|
|
159
|
+
}
|
|
160
|
+
const counts = new Map();
|
|
161
|
+
for (const p of filesTouched) {
|
|
162
|
+
const parts = String(p).replace(/\\/g, '/').split('/').filter(Boolean);
|
|
163
|
+
// Skip a leading src/lib/app wrapper — "src" is never a useful area.
|
|
164
|
+
const seg = parts.length >= 2
|
|
165
|
+
? (/^(src|lib|app|packages|apps)$/i.test(parts[0]) ? parts[1] : parts[0])
|
|
166
|
+
: '';
|
|
167
|
+
if (seg && !/\./.test(seg)) counts.set(seg, (counts.get(seg) || 0) + 1);
|
|
168
|
+
}
|
|
169
|
+
let best = '', n = 0;
|
|
170
|
+
for (const [k, v] of counts) if (v > n) { best = k; n = v; }
|
|
171
|
+
return best || 'Ship';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const shortFileList = (files, max = 3) => {
|
|
175
|
+
const uniq = [...new Set(files.map(f => String(f).replace(/\\/g, '/').split('/').pop()).filter(Boolean))];
|
|
176
|
+
if (!uniq.length) return '';
|
|
177
|
+
const head = uniq.slice(0, max).join(', ');
|
|
178
|
+
return uniq.length > max ? `${head} (+${uniq.length - max} more)` : head;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// Build the ready-to-emit marker. Everything mechanical is filled in from the
|
|
182
|
+
// artifacts; the ONE thing left blank is the sentence only the session can
|
|
183
|
+
// supply. That asymmetry is the whole design — a fully pre-written card would
|
|
184
|
+
// just be approved, and we would be back to capturing the event without the why.
|
|
185
|
+
export const WHY_SLOT = '<one sentence: the decision, obligation, or discovery a future session needs — if there is none, say so and discard this>';
|
|
186
|
+
// A draft pasted back with the slot STILL IN IT is a placeholder wearing a
|
|
187
|
+
// card's clothes: it would sit in the brain reading, to every future session, as
|
|
188
|
+
// though the reasoning had been recorded. That is strictly worse than no card,
|
|
189
|
+
// so the marker guard refuses it and says why. The sentinel deliberately matches
|
|
190
|
+
// the head of WHY_SLOT above — test/capture-gap.mjs asserts the two agree, so
|
|
191
|
+
// they cannot drift apart in silence.
|
|
192
|
+
export const UNFILLED_DRAFT_RE = /WHY THIS MATTERS:\s*<\s*one sentence/i;
|
|
193
|
+
export const looksLikeUnfilledDraft = (text) => UNFILLED_DRAFT_RE.test(String(text || ''));
|
|
194
|
+
export function draftCaptureMarker({ shipped = [], commits = [], filesTouched = [], area = '' } = {}) {
|
|
195
|
+
const resolvedArea = area || inferArea({ commits, filesTouched });
|
|
196
|
+
const facts = [];
|
|
197
|
+
if (shipped.length) facts.push(shipped.slice(0, 2).join(' · '));
|
|
198
|
+
const subjects = commits
|
|
199
|
+
.map(c => { const m = CC_SCOPE.exec(String(c?.subject || '')); return (m ? m[2] : String(c?.subject || '')).trim(); })
|
|
200
|
+
.filter(Boolean).slice(0, 3);
|
|
201
|
+
if (subjects.length) facts.push(subjects.map(s => `"${s}"`).join('; '));
|
|
202
|
+
const files = shortFileList(filesTouched);
|
|
203
|
+
if (files) facts.push(files);
|
|
204
|
+
// A ship with no subjects and no files still deserves a draft — the marker
|
|
205
|
+
// shape matters more than the richness of the pre-fill.
|
|
206
|
+
const head = facts.length ? facts.join(' — ') : 'work completed this session';
|
|
207
|
+
const marker = shipped.length ? '!' : ''; // a ship is a milestone; everything else a decision
|
|
208
|
+
return {
|
|
209
|
+
area: resolvedArea,
|
|
210
|
+
marker,
|
|
211
|
+
text: `${head}. WHY THIS MATTERS: ${WHY_SLOT}`,
|
|
212
|
+
line: `🧠 BRAIN [${resolvedArea}]${marker ? ' ' + marker : ''}: ${head}. WHY THIS MATTERS: ${WHY_SLOT}`,
|
|
213
|
+
tags: [`#${slug(resolvedArea)}`].filter(Boolean),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── The message ──────────────────────────────────────────────────────────────
|
|
218
|
+
// `mode` picks the register: 'refuse' is the Stop hook, which has just declined
|
|
219
|
+
// to end the turn; 'advise' is brain_sync(complete) on a host with no lifecycle
|
|
220
|
+
// hook, where this is the only channel available and nothing is being blocked.
|
|
221
|
+
export function captureGapReason({ evidence = [], draft = null, mode = 'refuse' } = {}) {
|
|
222
|
+
const lead = mode === 'refuse'
|
|
223
|
+
? '[brain] 🧠 UNCAPTURED WORK — this session produced durable artifacts and recorded NOTHING in the project brain.'
|
|
224
|
+
: '🧠 UNCAPTURED WORK — this task produced durable artifacts and recorded nothing in the project brain.';
|
|
225
|
+
const out = [
|
|
226
|
+
lead,
|
|
227
|
+
` Observed: ${evidence.join(' · ')}.`,
|
|
228
|
+
' Nothing recorded the reasoning: no 🧠 marker, no ✓/~, no rationale-bearing commit body, and this',
|
|
229
|
+
' session has not written the brain through any channel.',
|
|
230
|
+
];
|
|
231
|
+
if (draft) {
|
|
232
|
+
out.push(
|
|
233
|
+
'',
|
|
234
|
+
' A card is DRAFTED from what this session actually did. The facts are filled in; supply the one',
|
|
235
|
+
' sentence only you know, then emit it verbatim (or correct it first):',
|
|
236
|
+
'',
|
|
237
|
+
` ${draft.line}`,
|
|
238
|
+
'',
|
|
239
|
+
mode === 'refuse'
|
|
240
|
+
? ' Emitting that line in your reply is all it takes — the Stop hook harvests it. Or call brain_note.'
|
|
241
|
+
: ' Pass it to brain_note (text + area), or emit it as a marker if your host harvests them.',
|
|
242
|
+
);
|
|
243
|
+
} else {
|
|
244
|
+
out.push(
|
|
245
|
+
' Record it now — one line in your reply:',
|
|
246
|
+
' 🧠 BRAIN [Area]: <the decision and why> (`!` milestone · `?` open question · `+` reusable rule)',
|
|
247
|
+
' — or call the brain_note MCP verb.',
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
out.push(
|
|
251
|
+
mode === 'refuse'
|
|
252
|
+
? ' If there is genuinely nothing durable to keep, say so in one sentence and stop; this will not ask again this session.'
|
|
253
|
+
: ' If there is genuinely nothing durable to keep, ignore this; it will not be raised again for this session.',
|
|
254
|
+
' NOTE: your own memory directory (~/.claude/projects/<project>/memory/) is NOT the project brain. Writing',
|
|
255
|
+
' there is private to this host — no other session, agent, or human can read it. Only brain.klypix is shared.',
|
|
256
|
+
);
|
|
257
|
+
return out.join('\n') + '\n';
|
|
258
|
+
}
|