klypix-mcp 1.46.0 → 1.48.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.
@@ -160,6 +160,103 @@ try {
160
160
  a.client.callTool({ name: 'brain_sync', arguments: { phase: 'complete' } }),
161
161
  b.client.callTool({ name: 'brain_sync', arguments: { phase: 'complete' } }),
162
162
  ]);
163
+
164
+ // ── Cross-PC presence: simulated two-machine scenario ─────────────────────
165
+ // Two isolated registries (one per "machine") + a mock channel around the
166
+ // pure transport seam (src/presence-relay.mjs relayOutbound/relayInbound —
167
+ // the exact functions the desktop relay wraps its Realtime channel with).
168
+ // Proves: peer visibility across machines, overlap warning on the canonical
169
+ // file key, message delivered once under double-delivery, clean degradation
170
+ // with the channel dead, and the consent gate at the seam.
171
+ {
172
+ const [{ relayOutbound, relayInbound, PRESENCE_CONSENT_VERSION, PRESENCE_CONSENT_PURPOSE, PRESENCE_CONSENT_SCOPE },
173
+ { upsertSession, upsertRemoteSessions, listActiveSessions, postPresenceMessage, receiveMessages },
174
+ { findPresenceConflicts }] = await Promise.all([
175
+ import('../src/presence-relay.mjs'),
176
+ import('../src/agent-presence.mjs'),
177
+ import('../src/mcp-presence.mjs'),
178
+ ]);
179
+ const GRANT = {
180
+ version: PRESENCE_CONSENT_VERSION, decision: 'granted', decidedAt: new Date().toISOString(),
181
+ purpose: PRESENCE_CONSENT_PURPOSE, scope: PRESENCE_CONSENT_SCOPE,
182
+ };
183
+ const xpcRoot = path.join(tempRoot, 'xpc');
184
+ const homeA = path.join(xpcRoot, 'homeA');
185
+ const homeB = path.join(xpcRoot, 'homeB');
186
+ const repoA = path.join(xpcRoot, 'machineA', 'repo');
187
+ const repoB = path.join(xpcRoot, 'machineB', 'repo');
188
+ for (const dir of [homeA, homeB, repoA, repoB]) fs.mkdirSync(dir, { recursive: true });
189
+ const brainA = path.join(repoA, 'brain.klypix');
190
+ const brainB = path.join(repoB, 'brain.klypix');
191
+ fs.writeFileSync(brainA, 'xpc-fixture');
192
+ fs.writeFileSync(brainB, 'xpc-fixture');
193
+ const now = Date.now();
194
+
195
+ upsertSession({
196
+ brainPath: brainA, home: homeA, now, id: 'xpc-dev-a', client: 'claude-code', branch: 'main',
197
+ intent: 'edit the shared component', files: [path.join(repoA, 'src', 'Shared.tsx')],
198
+ });
199
+ upsertSession({
200
+ brainPath: brainB, home: homeB, now, id: 'xpc-dev-b', client: 'codex', branch: 'main',
201
+ intent: 'restyle the shared component', files: ['src/Shared.tsx'],
202
+ });
203
+
204
+ // Consent gate FIRST: with no record, the seam must emit nothing.
205
+ let framesSent = 0;
206
+ const gated = relayOutbound({
207
+ sessions: listActiveSessions({ brainPath: brainA, home: homeA, now }),
208
+ consent: null, machineId: 'xpc-mach-a', root: repoA, now, send: () => { framesSent++; },
209
+ });
210
+ checks.crossMachineConsentGate = framesSent === 0 && gated.reason === 'no-consent';
211
+
212
+ // Live channel: A's session and message reach B exactly once.
213
+ const wire = [];
214
+ relayOutbound({
215
+ sessions: listActiveSessions({ brainPath: brainA, home: homeA, now }),
216
+ messages: (() => {
217
+ postPresenceMessage({ brainPath: brainA, from: 'xpc-dev-a', text: 'starting on Shared.tsx now', home: homeA, now });
218
+ try { return JSON.parse(fs.readFileSync(path.join(homeA, '.claude', 'project-brain', 'sessions', fs.readdirSync(path.join(homeA, '.claude', 'project-brain', 'sessions')).find((f) => f.endsWith('.json'))), 'utf8')).messages || []; }
219
+ catch { return []; }
220
+ })(),
221
+ consent: GRANT, machineId: 'xpc-mach-a', hostLabel: 'MACHINE-A', root: repoA, now,
222
+ send: (frame) => wire.push(frame),
223
+ });
224
+ const deliverAll = (stampNow) => {
225
+ const rows = [];
226
+ for (const frame of wire) { // double-delivery: every frame arrives twice (at-least-once transport)
227
+ for (let i = 0; i < 2; i++) {
228
+ const inbound = relayInbound(frame, { consent: GRANT, machineId: 'xpc-mach-b', now: stampNow });
229
+ if (inbound?.type === 'presence') rows.push(inbound.row);
230
+ if (inbound?.type === 'message') {
231
+ postPresenceMessage({
232
+ brainPath: brainB, from: inbound.message.from, to: inbound.message.to,
233
+ text: inbound.message.text, dedupeKey: inbound.message.dedupeKey, home: homeB, now: stampNow,
234
+ });
235
+ }
236
+ }
237
+ }
238
+ if (rows.length) upsertRemoteSessions({ brainPath: brainB, rows, machineId: 'xpc-mach-b', home: homeB, now: stampNow });
239
+ };
240
+ deliverAll(now + 500);
241
+
242
+ const bSessions = listActiveSessions({ brainPath: brainB, home: homeB, now: now + 500 });
243
+ const remote = bSessions.find((session) => session.id === 'xpc-dev-a');
244
+ checks.crossMachinePeerVisibility = !!remote && remote.via === 'cloud' && remote.host === 'MACHINE-A';
245
+ const overlaps = findPresenceConflicts(bSessions, 'xpc-dev-b', { projectRoot: repoB });
246
+ checks.crossMachineOverlapWarning = overlaps.length === 1
247
+ && overlaps[0].id === 'xpc-dev-a'
248
+ && overlaps[0].files.some((file) => file.toLowerCase().includes('src/shared.tsx'));
249
+ const delivered = receiveMessages({ brainPath: brainB, sessionId: 'xpc-dev-b', home: homeB, now: now + 600 });
250
+ checks.crossMachineMessageOnce = delivered.filter((message) => message.text.includes('Shared.tsx')).length === 1;
251
+
252
+ // Dead channel: outbound reports without throwing; local presence intact.
253
+ const dead = relayOutbound({
254
+ sessions: listActiveSessions({ brainPath: brainA, home: homeA, now: now + 700 }),
255
+ consent: GRANT, machineId: 'xpc-mach-a', root: repoA, now: now + 700, send: undefined,
256
+ });
257
+ checks.crossMachineOfflineDegradation = dead.sent === 0 && dead.reason === 'no-channel'
258
+ && listActiveSessions({ brainPath: brainA, home: homeA, now: now + 700 }).some((session) => session.id === 'xpc-dev-a');
259
+ }
163
260
  } catch (error) {
164
261
  checks.runtime = false;
165
262
  checks.error = error?.message || String(error);
@@ -176,6 +273,11 @@ const required = [
176
273
  'alertQueued',
177
274
  'proactiveLogging',
178
275
  'guaranteedInBandDelivery',
276
+ 'crossMachineConsentGate',
277
+ 'crossMachinePeerVisibility',
278
+ 'crossMachineOverlapWarning',
279
+ 'crossMachineMessageOnce',
280
+ 'crossMachineOfflineDegradation',
179
281
  ];
180
282
  const ok = required.every((name) => checks[name] === true);
181
283
  const result = {
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ // Thin bin for `klypix-mcp diff` — the worker dispatcher splices the verb out
3
+ // of argv before importing, so this bin re-supplies it. Standalone use works
4
+ // identically: node bin/klypix-diff.mjs <args>
5
+ import { run } from './klypix-git-tools.mjs';
6
+ await run('diff', process.argv.slice(2));
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ // Thin bin for `klypix-mcp git-driver` — the worker dispatcher splices the verb out
3
+ // of argv before importing, so this bin re-supplies it. Standalone use works
4
+ // identically: node bin/klypix-git-driver.mjs <args>
5
+ import { run } from './klypix-git-tools.mjs';
6
+ await run('git-driver', process.argv.slice(2));
@@ -0,0 +1,377 @@
1
+ #!/usr/bin/env node
2
+ // klypix-git-tools — the GitHub lane: three verbs that put the brain where
3
+ // dev teams actually live (the repo and the PR page).
4
+ //
5
+ // git-driver [install|status] [repo] register the lossless .klypix merge
6
+ // driver for a repo, zero-command
7
+ // diff [ref] [--brain <path>] readable brain diff vs a git ref
8
+ // pr-brief [baseRef] [--brain <path>] brain cards touching the files
9
+ // changed since baseRef (PR comment)
10
+ //
11
+ // Design rules inherited from the engine:
12
+ // • ONE merge engine — the driver rides src/merge-brains.mjs verbatim.
13
+ // • The registered driver path is the INSTALLED runtime
14
+ // (~/.claude/project-brain) — stable across npx cache evictions; this
15
+ // module self-provisions the three engine files + their two deps there
16
+ // when missing, without running the full hook installer.
17
+ // • A truncated list must NEVER render as complete: every capped section
18
+ // emits its "…and N more" through an unguarded push.
19
+ // • Failure is a calm, specific message + non-zero exit — never a stack.
20
+
21
+ // SHAPE: this is a LIB — the worker dispatcher splices the verb out of argv
22
+ // before importing a verb bin (see runVerb), so each verb has a THIN bin
23
+ // (klypix-git-driver.mjs / klypix-diff.mjs / klypix-pr-brief.mjs) that calls
24
+ // run(<verb>, argv.slice(2)) here. Standalone `node <bin> …` works identically.
25
+
26
+ import fs from 'fs';
27
+ import os from 'os';
28
+ import path from 'path';
29
+ import { execFile } from 'child_process';
30
+ import { createRequire } from 'module';
31
+ import { fileURLToPath } from 'url';
32
+
33
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
34
+ const SRC = path.join(HERE, '..', 'src');
35
+ // Overridable for tests (temp dirs only — never point tests at the real one).
36
+ const BRAIN_DIR = process.env.KLYPIX_BRAIN_DIR || path.join(os.homedir(), '.claude', 'project-brain');
37
+
38
+ let args = [];
39
+ const flag = (name) => {
40
+ const i = args.indexOf(name);
41
+ return i >= 0 ? args[i + 1] : undefined;
42
+ };
43
+ let positional = [];
44
+
45
+ const git = (cwd, gitArgs, opts = {}) => new Promise((resolve, reject) => {
46
+ execFile('git', gitArgs, { cwd, timeout: 15000, windowsHide: true, maxBuffer: 128 * 1024 * 1024, ...opts },
47
+ (err, stdout) => err ? reject(err) : resolve(stdout));
48
+ });
49
+ const gitText = async (cwd, ...a) => String(await git(cwd, a)).trim();
50
+
51
+ async function repoToplevel(startDir) {
52
+ try { return await gitText(startDir, 'rev-parse', '--show-toplevel'); }
53
+ catch { return null; }
54
+ }
55
+
56
+ function findBrain(explicit) {
57
+ if (explicit) {
58
+ const p = path.resolve(explicit);
59
+ return fs.existsSync(p) ? p : null;
60
+ }
61
+ let dir = process.cwd();
62
+ for (let i = 0; i < 12; i++) {
63
+ const p = path.join(dir, 'brain.klypix');
64
+ if (fs.existsSync(p)) return p;
65
+ const up = path.dirname(dir);
66
+ if (up === dir) break;
67
+ dir = up;
68
+ }
69
+ return null;
70
+ }
71
+
72
+ // Card text is stored CANVAS-WRAPPED (single \n ≈ visual line breaks), so a
73
+ // naive first-line is ~35 chars of a sentence. Join the first paragraph back
74
+ // into prose and cap it.
75
+ const firstLine = (t) => {
76
+ const para = String(t || '').split(/\n\s*\n/)[0].replace(/\s*\n\s*/g, ' ').trim();
77
+ return para.length > 110 ? `${para.slice(0, 110)}…` : para;
78
+ };
79
+
80
+ async function loadEngine() {
81
+ const format = await import(new URL('../src/klypix-format.mjs', import.meta.url).href);
82
+ const merge = await import(new URL('../src/merge-brains.mjs', import.meta.url).href);
83
+ return { format, merge };
84
+ }
85
+
86
+ // ── git-driver ──────────────────────────────────────────────────────────────
87
+
88
+ const ENGINE_FILES = ['klypix-merge-driver.mjs', 'merge-brains.mjs', 'klypix-format.mjs'];
89
+ const ENGINE_DEPS = ['jszip', 'fractional-indexing'];
90
+
91
+ // Make sure the INSTALLED runtime can actually run the driver: the three
92
+ // engine files plus their two (dependency-free) deps. This is deliberately a
93
+ // light provision — it never touches hooks or servers; the full installer
94
+ // remains `npx klypix-mcp install`.
95
+ function ensureDriverRuntime() {
96
+ const provisioned = [];
97
+ fs.mkdirSync(BRAIN_DIR, { recursive: true });
98
+ for (const f of ENGINE_FILES) {
99
+ const dest = path.join(BRAIN_DIR, f);
100
+ const srcFile = path.join(SRC, f);
101
+ if (!fs.existsSync(dest) || fs.readFileSync(dest, 'utf8') !== fs.readFileSync(srcFile, 'utf8')) {
102
+ fs.copyFileSync(srcFile, dest);
103
+ provisioned.push(f);
104
+ }
105
+ }
106
+ const requireHere = createRequire(import.meta.url);
107
+ // Modern packages fence `exports`, so `<dep>/package.json` may not resolve —
108
+ // resolve the MAIN entry instead and walk up to the package root.
109
+ const depRootOf = (dep) => {
110
+ let p = path.dirname(requireHere.resolve(dep));
111
+ for (let i = 0; i < 6; i++) {
112
+ const pkg = path.join(p, 'package.json');
113
+ try {
114
+ if (fs.existsSync(pkg) && JSON.parse(fs.readFileSync(pkg, 'utf8')).name === dep) return p;
115
+ } catch { /* keep walking */ }
116
+ const up = path.dirname(p);
117
+ if (up === p) break;
118
+ p = up;
119
+ }
120
+ throw new Error(`cannot locate package root for dependency "${dep}"`);
121
+ };
122
+ const destMods = path.join(BRAIN_DIR, 'node_modules');
123
+ // Deps are provisioned as their RECURSIVE closure (jszip alone pulls pako,
124
+ // lie, readable-stream, …) — everything resolves from the local install, so
125
+ // this stays offline and deterministic. Nested (unhoisted) deps resolve via
126
+ // a require scoped to their parent package.
127
+ const provisionDep = (dep, fromDir, seen) => {
128
+ if (seen.has(dep)) return;
129
+ seen.add(dep);
130
+ let root;
131
+ try { root = depRootOf(dep); }
132
+ catch {
133
+ const scoped = createRequire(path.join(fromDir, 'package.json'));
134
+ let p = path.dirname(scoped.resolve(dep));
135
+ while (p !== path.dirname(p) && !fs.existsSync(path.join(p, 'package.json'))) p = path.dirname(p);
136
+ root = p;
137
+ }
138
+ const destDir = path.join(destMods, dep);
139
+ if (!fs.existsSync(destDir)) {
140
+ // Exclude only node_modules NESTED INSIDE the package (the closure walk
141
+ // provisions those flat) — judged relative to the package root, because
142
+ // the source root itself lives under a node_modules path.
143
+ fs.cpSync(root, destDir, {
144
+ recursive: true,
145
+ filter: (s) => !path.relative(root, s).split(path.sep).includes('node_modules'),
146
+ });
147
+ provisioned.push(`node_modules/${dep}`);
148
+ }
149
+ let pkg = {};
150
+ try { pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); } catch { /* leaf */ }
151
+ for (const child of Object.keys(pkg.dependencies || {})) provisionDep(child, root, seen);
152
+ };
153
+ const seen = new Set();
154
+ for (const dep of ENGINE_DEPS) provisionDep(dep, path.join(HERE, '..'), seen);
155
+ return provisioned;
156
+ }
157
+
158
+ const DRIVER_ATTR_RULE = '*.klypix merge=klypix -text';
159
+
160
+ async function gitDriver() {
161
+ const sub = positional[0] && !fs.existsSync(positional[0]) ? positional[0] : 'install';
162
+ const repoArg = positional.find(p => fs.existsSync(p)) || process.cwd();
163
+ const toplevel = await repoToplevel(repoArg);
164
+ if (!toplevel) { console.error(`Not a git repository: ${repoArg}`); process.exit(1); }
165
+
166
+ const driverPath = path.join(BRAIN_DIR, 'klypix-merge-driver.mjs');
167
+ const driverCmd = `node "${driverPath.replace(/\\/g, '/')}" %O %A %B %P`;
168
+ const gaPath = path.join(toplevel, '.gitattributes');
169
+ const gaText = fs.existsSync(gaPath) ? fs.readFileSync(gaPath, 'utf8') : '';
170
+ const gaHasRule = /merge=klypix/.test(gaText);
171
+
172
+ if (sub === 'status') {
173
+ let configured = '';
174
+ try { configured = await gitText(toplevel, 'config', '--get', 'merge.klypix.driver'); } catch { /* unset */ }
175
+ const runtimeOk = ENGINE_FILES.every(f => fs.existsSync(path.join(BRAIN_DIR, f)));
176
+ console.log(`repo: ${toplevel}`);
177
+ console.log(`driver config: ${configured || '(not registered)'}`);
178
+ console.log(`.gitattributes rule: ${gaHasRule ? 'present' : 'missing'}`);
179
+ console.log(`installed runtime: ${runtimeOk ? BRAIN_DIR : 'missing — run: npx klypix-mcp git-driver install'}`);
180
+ process.exit(configured && gaHasRule && runtimeOk ? 0 : 1);
181
+ }
182
+
183
+ const provisioned = ensureDriverRuntime();
184
+ let already = false;
185
+ try { already = (await gitText(toplevel, 'config', '--get', 'merge.klypix.driver')) === driverCmd; } catch { /* unset */ }
186
+ if (!already) {
187
+ await git(toplevel, ['config', 'merge.klypix.name', 'KLYPIX lossless brain merge (union by card id)']);
188
+ await git(toplevel, ['config', 'merge.klypix.driver', driverCmd]);
189
+ }
190
+ let gaState = 'present';
191
+ if (!gaHasRule) {
192
+ const rule = `${gaText && !gaText.endsWith('\n') ? '\n' : ''}# .klypix brains merge losslessly via the KLYPIX 3-way union driver\n# (per-machine registration: npx klypix-mcp git-driver install).\n${DRIVER_ATTR_RULE}\n`;
193
+ fs.appendFileSync(gaPath, rule);
194
+ gaState = 'added';
195
+ }
196
+ console.log(`✓ ${already ? 'Already registered' : 'Registered'} the .klypix merge driver for ${toplevel}`);
197
+ console.log(` driver: ${driverPath}${provisioned.length ? ` (provisioned: ${provisioned.join(', ')})` : ''}`);
198
+ console.log(` .gitattributes rule: ${gaState}${gaState === 'added' ? ' — commit it so every teammate\'s clone routes .klypix merges here' : ''}`);
199
+ console.log(' Teammates run the same command once per machine; unregistered machines fall back to a normal conflict.');
200
+ }
201
+
202
+ // ── diff ────────────────────────────────────────────────────────────────────
203
+
204
+ function renderCardList(title, entries, cap = 20) {
205
+ if (!entries.length) return [];
206
+ const lines = [`**${title} (${entries.length})**`];
207
+ for (const e of entries.slice(0, cap)) lines.push(`- ${e}`);
208
+ // Truncation notice is NEVER subject to the cap it reports.
209
+ if (entries.length > cap) lines.push(`- …and ${entries.length - cap} more`);
210
+ lines.push('');
211
+ return lines;
212
+ }
213
+
214
+ async function brainDiff() {
215
+ const ref = positional[0] || 'HEAD';
216
+ const brain = findBrain(flag('--brain'));
217
+ if (!brain) { console.error('No brain.klypix found (searched upward from cwd; use --brain <path>).'); process.exit(1); }
218
+ const toplevel = await repoToplevel(path.dirname(brain));
219
+ if (!toplevel) { console.error(`Brain is not inside a git repository: ${brain}`); process.exit(1); }
220
+ const rel = path.relative(toplevel, brain).replace(/\\/g, '/');
221
+
222
+ const { format, merge } = await loadEngine();
223
+ const current = fs.readFileSync(brain);
224
+
225
+ let baseBuf = null;
226
+ try { baseBuf = Buffer.from(await git(toplevel, ['show', `${ref}:${rel}`], { encoding: 'buffer' })); }
227
+ catch { baseBuf = null; }
228
+
229
+ const out = [`### 🧠 Brain diff — \`${rel}\` vs \`${ref}\``, ''];
230
+ if (!baseBuf || baseBuf.length === 0) {
231
+ const { struct } = await format.parseKlypix(current);
232
+ out.push(`The brain does not exist at \`${ref}\` — everything is new here (${struct.cards.length} cards).`);
233
+ console.log(out.join('\n'));
234
+ return;
235
+ }
236
+
237
+ // SEMANTIC diff, never byte diff: .klypix re-serialization is deliberately
238
+ // non-reproducible (zip metadata, zIndex renumbering), so comparing raw item
239
+ // bytes reports the whole brain as "updated" (live-reproduced: 1308 false
240
+ // updates over 8 commits). Parse both sides and compare key-sorted JSON with
241
+ // display-derived fields stripped — the same discipline as the sync core.
242
+ const stable = (v) => JSON.stringify(v, (_k, val) =>
243
+ (val && typeof val === 'object' && !Array.isArray(val))
244
+ ? Object.fromEntries(Object.keys(val).sort().map(x => [x, val[x]]))
245
+ : val);
246
+ const cardMap = async (buf) => {
247
+ const { zip, canvas } = await format.parseKlypix(buf);
248
+ const ids = [...new Set([...(Array.isArray(canvas.order) ? canvas.order : []), ...Object.keys(canvas.positions || {})])];
249
+ const m = new Map();
250
+ for (const id of ids) {
251
+ const f = zip.file(`items/${format.shard(id)}/${id}.json`);
252
+ if (!f) continue;
253
+ try {
254
+ const item = JSON.parse(await f.async('string'));
255
+ m.set(id, {
256
+ sig: stable({ ...item, zIndex: undefined }),
257
+ title: firstLine(item.content || item.title || '') || `\`${id}\``,
258
+ });
259
+ } catch { /* unreadable item — skip rather than mis-report */ }
260
+ }
261
+ return { map: m, connections: Array.isArray(canvas.connections) ? canvas.connections.length : 0 };
262
+ };
263
+ void merge; // brainDelta stays the live-apply engine; diff is semantic by design
264
+
265
+ const [baseSide, curSide] = await Promise.all([cardMap(baseBuf), cardMap(current)]);
266
+ const added = [], updated = [], removed = [];
267
+ for (const [id, cur] of curSide.map) {
268
+ const prev = baseSide.map.get(id);
269
+ if (!prev) added.push(cur.title);
270
+ else if (prev.sig !== cur.sig) updated.push(cur.title);
271
+ }
272
+ for (const [id, prev] of baseSide.map) {
273
+ if (!curSide.map.has(id)) removed.push(prev.title);
274
+ }
275
+ const connDelta = curSide.connections - baseSide.connections;
276
+
277
+ if (!added.length && !updated.length && !removed.length && !connDelta) {
278
+ out.push('No card-level changes.');
279
+ } else {
280
+ out.push(`**${added.length} added · ${updated.length} updated · ${removed.length} removed**`, '');
281
+ out.push(...renderCardList('Added', added));
282
+ out.push(...renderCardList('Updated', updated));
283
+ out.push(...renderCardList('Removed', removed));
284
+ if (connDelta) out.push(`_${connDelta > 0 ? '+' : ''}${connDelta} connection(s)._`);
285
+ }
286
+ console.log(out.join('\n'));
287
+ }
288
+
289
+ // ── pr-brief ────────────────────────────────────────────────────────────────
290
+
291
+ // The brain's own evidence-tag convention: #file-<slug> where slug is the
292
+ // basename minus its last extension, lowercased, non-alphanumerics folded to
293
+ // hyphens. Tag matches only (precision over recall — a PR comment that spams
294
+ // unrelated cards teaches people to ignore it).
295
+ function fileSlug(p) {
296
+ const base = path.basename(p).replace(/\.[^.]+$/, '');
297
+ return base.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
298
+ }
299
+
300
+ async function prBrief() {
301
+ const baseRef = positional[0] || 'HEAD~1';
302
+ const brain = findBrain(flag('--brain'));
303
+ if (!brain) { console.error('No brain.klypix found (searched upward from cwd; use --brain <path>).'); process.exit(1); }
304
+ const toplevel = await repoToplevel(path.dirname(brain));
305
+ if (!toplevel) { console.error(`Brain is not inside a git repository: ${brain}`); process.exit(1); }
306
+
307
+ let changed = [];
308
+ try {
309
+ changed = String(await git(toplevel, ['diff', '--name-only', `${baseRef}...HEAD`]))
310
+ .split('\n').map(s => s.trim()).filter(Boolean);
311
+ } catch (e) {
312
+ console.error(`git diff against "${baseRef}" failed: ${String(e.message || e).split('\n')[0]}`);
313
+ process.exit(1);
314
+ }
315
+ if (!changed.length) { console.log('_No changed files — no brain context to attach._'); return; }
316
+
317
+ const { format } = await loadEngine();
318
+ const { struct } = await format.parseKlypix(fs.readFileSync(brain));
319
+
320
+ const perFile = new Map(); // file -> [card first lines]
321
+ let total = 0;
322
+ for (const file of changed) {
323
+ const slug = fileSlug(file);
324
+ if (!slug) continue;
325
+ const tag = `#file-${slug}`;
326
+ const hits = [];
327
+ for (const card of struct.cards) {
328
+ const text = String(card.text || card.title || '');
329
+ const idx = text.indexOf(tag);
330
+ if (idx < 0) continue;
331
+ // Tag boundary: the next char must not extend the slug (avoids
332
+ // #file-use matching #file-usechat).
333
+ const after = text[idx + tag.length];
334
+ if (after && /[a-z0-9-]/.test(after)) continue;
335
+ hits.push(firstLine(text));
336
+ if (hits.length >= 3) break; // cap per file; total notice below
337
+ }
338
+ if (hits.length) { perFile.set(file, hits); total += hits.length; }
339
+ }
340
+
341
+ if (!perFile.size) {
342
+ console.log(`_No brain cards reference the ${changed.length} changed file(s)._`);
343
+ return;
344
+ }
345
+
346
+ const out = [`### 🧠 Brain context for this PR`, '',
347
+ `Decisions and findings already recorded about the files this PR touches (${perFile.size} of ${changed.length} changed files have brain context):`, ''];
348
+ let printed = 0;
349
+ const FILE_CAP = 12;
350
+ let fileIdx = 0;
351
+ for (const [file, hits] of perFile) {
352
+ if (fileIdx >= FILE_CAP) break;
353
+ fileIdx++;
354
+ out.push(`**\`${file}\`**`);
355
+ for (const h of hits) { out.push(`- ${h}`); printed++; }
356
+ out.push('');
357
+ }
358
+ if (perFile.size > FILE_CAP) out.push(`…and ${perFile.size - FILE_CAP} more file(s) with brain context.`);
359
+ out.push(`_From \`${path.relative(toplevel, brain).replace(/\\/g, '/')}\` — the project's shared brain. ${printed} card(s) shown._`);
360
+ console.log(out.join('\n'));
361
+ }
362
+
363
+ // ── entry ───────────────────────────────────────────────────────────────────
364
+
365
+ export async function run(verb, rawArgs) {
366
+ args = Array.isArray(rawArgs) ? rawArgs : [];
367
+ positional = args.filter((a, i) => !a.startsWith('--') && args[i - 1] !== '--brain');
368
+ try {
369
+ if (verb === 'git-driver') await gitDriver();
370
+ else if (verb === 'diff') await brainDiff();
371
+ else if (verb === 'pr-brief') await prBrief();
372
+ else { console.error(`klypix-git-tools: unknown verb "${verb}"`); process.exit(2); }
373
+ } catch (e) {
374
+ console.error(`${verb} failed: ${String(e?.message || e).split('\n')[0]}`);
375
+ process.exit(1);
376
+ }
377
+ }
@@ -19,7 +19,7 @@ const PKG_VERSION = (() => {
19
19
  }
20
20
  })();
21
21
 
22
- const DIRECT = new Set(['install', 'link', 'doctor', 'conformance', 'garden-code', 'init']);
22
+ const DIRECT = new Set(['install', 'link', 'doctor', 'conformance', 'garden-code', 'init', 'git-driver', 'diff', 'pr-brief']);
23
23
 
24
24
  const USAGE = [
25
25
  `klypix-mcp ${PKG_VERSION} — shared project brain + MCP coordination server.`,
@@ -31,6 +31,9 @@ const USAGE = [
31
31
  ' conformance [--json] launch two real MCP clients against this build',
32
32
  ' init seed a starter ./brain.klypix + print an MCP config',
33
33
  ' garden-code [brain] print the human approval code for brain_garden',
34
+ ' git-driver [install|status] [repo] register the lossless .klypix merge driver for a repo (zero-command teams)',
35
+ ' diff [ref] [--brain <path>] readable brain diff vs a git ref (default HEAD) — markdown to stdout',
36
+ ' pr-brief [baseRef] [--brain <path>] brain decisions touching the files changed since baseRef — PR-comment markdown',
34
37
  '',
35
38
  'With no verb (or any --flag, e.g. --vault <dir>) it runs as an MCP stdio server.',
36
39
  'There is no uninstall command — removal is manual (see README).',
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ // Thin bin for `klypix-mcp pr-brief` — the worker dispatcher splices the verb out
3
+ // of argv before importing, so this bin re-supplies it. Standalone use works
4
+ // identically: node bin/klypix-pr-brief.mjs <args>
5
+ import { run } from './klypix-git-tools.mjs';
6
+ await run('pr-brief', process.argv.slice(2));
@@ -102,6 +102,14 @@ await runVerb('doctor', './klypix-doctor.mjs');
102
102
  // overlap detection, proactive logging, and guaranteed next-action delivery.
103
103
  await runVerb('conformance', './klypix-conformance.mjs');
104
104
 
105
+ // `npx klypix-mcp git-driver | diff | pr-brief` — the GitHub lane: register the
106
+ // lossless .klypix merge driver for any repo, render a readable brain diff vs a
107
+ // git ref, and print the brain cards touching a PR's changed files. One module,
108
+ // three verbs (it reads argv[2] itself).
109
+ await runVerb('git-driver', './klypix-git-driver.mjs');
110
+ await runVerb('diff', './klypix-diff.mjs');
111
+ await runVerb('pr-brief', './klypix-pr-brief.mjs');
112
+
105
113
  // `npx klypix-mcp garden-code` — the HUMAN half of the garden approval gate.
106
114
  // brain_garden's apply requires an 8-char code derived from the exact dormant
107
115
  // candidate set + day; the agent is deliberately never shown it. The human runs
@@ -0,0 +1,68 @@
1
+ # Brain-aware pull requests — copy this file to .github/workflows/brain-pr.yml
2
+ # in any repo whose brain.klypix is committed.
3
+ #
4
+ # On every PR it posts (and keeps updated) ONE sticky comment with:
5
+ # 1. the brain decisions/corrections that reference the files the PR touches
6
+ # (evidence tags: #file-<name> inside cards), and
7
+ # 2. a readable card-level diff of the brain itself, when the PR changes it.
8
+ #
9
+ # Nothing here talks to any KLYPIX service — the brain is read from the
10
+ # checkout, exactly as your agents read it. Requires only the default
11
+ # GITHUB_TOKEN with pull-requests: write.
12
+
13
+ name: brain-pr
14
+ on:
15
+ pull_request:
16
+ types: [opened, synchronize, reopened]
17
+
18
+ permissions:
19
+ contents: read
20
+ pull-requests: write
21
+
22
+ jobs:
23
+ brain-context:
24
+ runs-on: ubuntu-latest
25
+ steps:
26
+ - uses: actions/checkout@v4
27
+ with:
28
+ fetch-depth: 0 # pr-brief and diff need the base ref
29
+
30
+ - uses: actions/setup-node@v4
31
+ with:
32
+ node-version: 20
33
+
34
+ - name: Build the comment
35
+ id: brain
36
+ env:
37
+ BASE: ${{ github.event.pull_request.base.sha }}
38
+ run: |
39
+ {
40
+ npx --yes klypix-mcp pr-brief "$BASE" || true
41
+ echo ""
42
+ # Only show the brain diff when the PR actually changes the brain.
43
+ if git diff --name-only "$BASE"...HEAD | grep -q '\.klypix$'; then
44
+ npx --yes klypix-mcp diff "$BASE" || true
45
+ fi
46
+ } > brain-comment.md
47
+ # Skip the comment entirely when there is nothing to say.
48
+ if ! grep -qE '🧠' brain-comment.md; then
49
+ echo "post=false" >> "$GITHUB_OUTPUT"
50
+ else
51
+ echo "post=true" >> "$GITHUB_OUTPUT"
52
+ fi
53
+
54
+ - name: Post / update the sticky comment
55
+ if: steps.brain.outputs.post == 'true'
56
+ env:
57
+ GH_TOKEN: ${{ github.token }}
58
+ PR: ${{ github.event.pull_request.number }}
59
+ run: |
60
+ MARKER="<!-- klypix-brain-pr -->"
61
+ printf '%s\n\n' "$MARKER" | cat - brain-comment.md > body.md
62
+ EXISTING=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" \
63
+ --jq ".[] | select(.body | startswith(\"$MARKER\")) | .id" | head -1)
64
+ if [ -n "$EXISTING" ]; then
65
+ gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${EXISTING}" -F body=@body.md > /dev/null
66
+ else
67
+ gh pr comment "$PR" --body-file body.md > /dev/null
68
+ fi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.46.0",
3
+ "version": "1.48.0",
4
4
  "description": "Shared project brain and MCP coordination server for multi-agent coding.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -47,6 +47,7 @@
47
47
  "./core": "./src/klypix-core.mjs",
48
48
  "./presence": "./src/agent-presence.mjs",
49
49
  "./mcp-presence": "./src/mcp-presence.mjs",
50
+ "./presence-relay": "./src/presence-relay.mjs",
50
51
  "./supervisor": "./src/mcp-supervisor.mjs",
51
52
  "./auto-update": "./src/mcp-auto-update.mjs"
52
53
  },
@@ -65,7 +66,7 @@
65
66
  "node": ">=18"
66
67
  },
67
68
  "scripts": {
68
- "test": "node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/codex-hooks.mjs && node test/agent-presence.mjs && node test/context-gateway.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/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.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-gate.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/presence-visibility.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/uninstall.mjs"
69
+ "test": "node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/codex-hooks.mjs && node test/agent-presence.mjs && node test/presence-relay.mjs && node test/context-gateway.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/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.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-gate.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/presence-visibility.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs"
69
70
  },
70
71
  "dependencies": {
71
72
  "@modelcontextprotocol/ext-apps": "^1.7.4",