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.
- package/bin/klypix-conformance.mjs +102 -0
- package/bin/klypix-diff.mjs +6 -0
- package/bin/klypix-git-driver.mjs +6 -0
- package/bin/klypix-git-tools.mjs +377 -0
- package/bin/klypix-mcp.mjs +4 -1
- package/bin/klypix-pr-brief.mjs +6 -0
- package/bin/klypix-worker.mjs +8 -0
- package/examples/github/brain-pr.yml +68 -0
- package/package.json +3 -2
- package/src/agent-presence.mjs +81 -0
- package/src/klypix-merge-driver.mjs +89 -0
- package/src/merge-brains.mjs +339 -0
- package/src/presence-relay.mjs +250 -0
|
@@ -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
|
+
}
|
package/bin/klypix-mcp.mjs
CHANGED
|
@@ -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));
|
package/bin/klypix-worker.mjs
CHANGED
|
@@ -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.
|
|
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",
|