klypix-mcp 1.70.0 → 1.72.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 CHANGED
@@ -85,28 +85,55 @@ It verifies 15 required coordination behaviours — not the 26 tools, and not th
85
85
 
86
86
  ## Quick start
87
87
 
88
- **Claude Code + Codex:**
88
+ Run this **inside your project**:
89
89
 
90
90
  ```bash
91
91
  npx klypix-mcp install
92
92
  ```
93
93
 
94
- This copies the engine and a local MCP runtime into `~/.claude/project-brain`, wires Claude Code's
95
- four lifecycle hooks, writes Codex's global `~/.codex/AGENTS.md` guidance block, and wires Codex's
96
- MCP connection **for the project you run it in**.
94
+ One command, every editor. It finds the project root (walking up, so running it from `src/` is
95
+ fine), gives the project a brain if it doesn't have one, wires the agent tools you actually have
96
+ installed, registers the lossless `.klypix` merge driver if it's a git repo, and then **proves the
97
+ result** before it exits:
98
+
99
+ ```text
100
+ project E:\work\api (git repository root)
101
+ brain created brain.klypix — a starter brain, ready for its first decision
102
+ editors Claude Code · Cursor · Codex · Gemini CLI · Antigravity · VS Code
103
+ wired 9 file(s) · 9 updated (skipped 5 for tools you don't have)
104
+ git lossless .klypix merge driver registered
105
+ verified ✓ 26 tools reachable via .mcp.json (892ms)
106
+ ```
107
+
108
+ That last line is the point. MCP config fails **silently** — a wrong entry means the server never
109
+ starts, the agent quietly loses every brain verb, and nothing reports an error. So `install` opens
110
+ a real stdio handshake against the config it just wrote and counts the tools that answered. A
111
+ broken entry dies in ~100ms with `Connection closed` and is reported, not shipped.
112
+
113
+ What goes where:
114
+
115
+ - **Machine-global, once** — the engine + runtime in `~/.claude/project-brain`, Claude Code's four
116
+ lifecycle hooks in `~/.claude/settings.json`, and the `~/.codex/AGENTS.md` guidance block. Claude
117
+ Code is therefore covered in every project on that machine that has a `./brain.klypix`.
118
+ - **Per project** — MCP config and rules for Cursor, Codex, Cline, Windsurf, Copilot, Gemini CLI /
119
+ Antigravity and Aider. Run `install` once inside each project.
97
120
 
98
- Be precise about what "machine-global" covers:
121
+ Three things it deliberately will **not** do:
99
122
 
100
- - **Machine-global** — the engine + runtime in `~/.claude/project-brain`, the four Claude Code
101
- hooks in `~/.claude/settings.json`, and the `~/.codex/AGENTS.md` guidance. Claude Code is
102
- therefore covered in every project on that machine that has a `./brain.klypix`.
103
- - **Per project** — Codex's MCP connection. `install` writes it into `<cwd>/.codex/config.toml`,
104
- only when that directory has a `brain.klypix`, and it deliberately **removes** any *global*
105
- `~/.codex/config.toml` KLYPIX entry (a global entry resolves its `--vault` from the wrong
106
- directory and binds the wrong brain). Run `install` — or `link` — once inside each brain project
107
- you want Codex wired to.
123
+ - **Write for editors you don't have.** Config is projected only for hosts detected on this
124
+ machine — a two-person team using one editor no longer commits rules for six they never opened.
125
+ A file your project *already* carries stays maintained regardless, so you can't silently stop
126
+ updating your team's committed configs.
127
+ - **Wire a directory that isn't a project.** It refuses your home folder, a drive root, and
128
+ anything with no brain, no git repo and no project manifest. A mistyped command can't seed a
129
+ brain into `C:\Users\you`.
130
+ - **Replace a project-owned server.** A repo-relative launch like
131
+ `node scripts/klypix-mcp-server.mjs` is deliberate — it resolves offline and rides a bundle the
132
+ repo version-gates — so it's left byte-identical and reported. An explicit `link` still rewrites
133
+ everything: an action you didn't ask for stays more conservative than one you did.
108
134
 
109
- It does **not** set up Cursor, Cline, Windsurf, Copilot, Gemini CLI or Aider — those need `link`.
135
+ Opt out with `--no-project` (CI images, scripted provisioning). `--json` emits the report as
136
+ structured data; `--verify-all` handshakes every written config instead of one.
110
137
 
111
138
  Optional, opt-in, and approved inside Codex itself:
112
139
 
@@ -119,15 +146,16 @@ file-overlap warning. Codex owns the trust decision and will ask you to review t
119
146
  `brain_doctor` reports this layer separately as off, execution-unverified, or active. Even with it
120
147
  on, **Codex never captures decisions automatically** — the Codex hook never writes the brain.
121
148
 
122
- **Every other agent tool — one command per project:**
149
+ **Re-project everything explicitly:**
123
150
 
124
151
  ```bash
125
152
  npx klypix-mcp link
126
153
  ```
127
154
 
128
- Writes 14 managed, hash-stamped files: MCP server config for six hosts, plus rules files for
129
- eight. Managed blocks are merged into your existing instruction files and never clobber your
130
- content.
155
+ `install` already does this for the editors you have. Reach for `link` when you want all 14
156
+ managed, hash-stamped files regardless of what's installed — MCP server config for six hosts plus
157
+ rules files for eight — or to repair drift. Managed blocks are merged into your existing
158
+ instruction files and never clobber your content.
131
159
 
132
160
  ```bash
133
161
  npx klypix-mcp link --check # audits without writing; exits non-zero on drift
@@ -481,8 +509,8 @@ The MCP verbs below are what agents call. These are what **you** call:
481
509
  | Command | What it does |
482
510
  |---|---|
483
511
  | `npx klypix-mcp init` | Seed a starter `brain.klypix` here and print an MCP config |
484
- | `npx klypix-mcp install` | Install the engine + Claude Code hooks on this machine (see Quick start) |
485
- | `npx klypix-mcp link` | Wire this project for Cursor, Cline, Windsurf, Copilot, Gemini CLI, Aider (`--check` audits) |
512
+ | `npx klypix-mcp install` | Set up everything: machine engine + hooks, then this project — brain, config for the editors you have, merge driver, verified (see Quick start) |
513
+ | `npx klypix-mcp link` | Re-project all 14 managed files regardless of what is installed (`--check` audits) |
486
514
  | `npx klypix-mcp doctor` | One verdict: version, hosts, live sessions, tool count, drift. Exits non-zero — usable as a CI gate |
487
515
  | `npx klypix-mcp runtime` | Passive per-connection process/RAM attribution (`--json`, optional `--watch seconds`); never kills or deduplicates |
488
516
  | `npx klypix-mcp conformance` | Launch two real MCP clients against this build and verify coordination behaviour |
@@ -34,6 +34,7 @@ import {
34
34
  import { brainInstallDecision, deploySourceDecision } from '../src/install-version.mjs';
35
35
  import { acquireInstallLockSync, releaseInstallLockSync } from '../src/install-lock.mjs';
36
36
  import { collectRepoState } from '../src/repo-state.mjs';
37
+ import { runSetup, renderBrief } from '../src/setup.mjs';
37
38
 
38
39
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
39
40
  const PKG_ROOT = path.resolve(__dirname, '..');
@@ -48,6 +49,12 @@ const VERSION = (() => { try { return JSON.parse(fs.readFileSync(path.join(PKG_R
48
49
  const FORCE = process.argv.includes('--force');
49
50
  const CODEX_HOOKS = process.argv.includes('--codex-hooks');
50
51
  const RUNTIME_ONLY = process.argv.includes('--runtime-only');
52
+ // Project wiring is the default because it is the step users did not know
53
+ // existed. These opt OUT for the cases that genuinely want machine-only:
54
+ // CI images, scripted provisioning, and anyone wiring the project by hand.
55
+ const NO_PROJECT = process.argv.includes('--no-project');
56
+ const VERIFY_ALL = process.argv.includes('--verify-all');
57
+ const SETUP_JSON = process.argv.includes('--json');
51
58
  // Released-tag deploy-guard acknowledgement. Deliberately a SEPARATE axis from
52
59
  // --force: --force is destination authority (overwrite what is installed),
53
60
  // this is source authority (knowingly deploy an untagged working tree).
@@ -64,7 +71,7 @@ const CODEX_CONFIG = path.join(HOME, '.codex', 'config.toml');
64
71
  const HOOK_MARK = 'global-brain-hook';
65
72
  const exists = (p) => { try { fs.statSync(p); return true; } catch { return false; } };
66
73
  const fwd = (p) => p.replace(/\\/g, '/');
67
- const copyRetrySleepSync = (ms) => {
74
+ const retrySleepSync = (ms) => {
68
75
  try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
69
76
  catch { /* best effort */ }
70
77
  };
@@ -74,7 +81,7 @@ function copyFileRobust(src, dest, tries = 8) {
74
81
  catch (error) {
75
82
  const retryable = ['EBUSY', 'EPERM', 'EACCES'].includes(error?.code);
76
83
  if (!retryable || attempt === tries) throw error;
77
- copyRetrySleepSync(20 * attempt);
84
+ retrySleepSync(20 * attempt);
78
85
  }
79
86
  }
80
87
  }
@@ -167,6 +174,44 @@ function reportCodex(result) {
167
174
  // update hook runs install per project, so every project heals once). Only the
168
175
  // known npx/node launch is migrated; a hand-customized command / invalid JSON is
169
176
  // left untouched, and the original is backed up. Best-effort, never throws.
177
+ // ── Windows rename hardening (1.71.1) ───────────────────────────────────────
178
+ // Every atomic commit in this installer is a rename-over-destination, and on
179
+ // Windows that throws EPERM/EBUSY/EACCES while ANY process briefly holds the
180
+ // target — an AV scan, an indexer, or, routinely on a developer machine, the
181
+ // live MCP servers reading the very bundle we are replacing. Measured 2026-08-15:
182
+ // three failures in one session with 7-14 servers live, on .mcp-runtime.json and
183
+ // brain-history.mjs; each retry succeeded immediately.
184
+ //
185
+ // The failure is loud rather than silent, and a manual re-run fixes it — but
186
+ // this is the FIRST command a new user types, and a raw EPERM stack at that
187
+ // moment is a bad first contact. So the installer now outlasts a transient
188
+ // holder using the SAME bounded backoff the brain write funnel has used since
189
+ // 1.68.0 (klypix-format.mjs atomicWrite): ~2.7s total, then rethrow. A
190
+ // persistent holder still fails loudly — delayed is acceptable, silently wrong
191
+ // is not, and pretending we wrote a file we did not would be far worse.
192
+ const RENAME_RETRYABLE_CODES = new Set(['EPERM', 'EBUSY', 'EACCES']);
193
+ const RENAME_BACKOFF_MS = [40, 120, 300, 700, 1500];
194
+ /** fs.renameSync that outlasts a transient Windows lock on the destination. */
195
+ function renameSyncWithBackoff(from, to) {
196
+ for (let attempt = 0; ; attempt++) {
197
+ try { return fs.renameSync(from, to); }
198
+ catch (e) {
199
+ if (attempt >= RENAME_BACKOFF_MS.length || !RENAME_RETRYABLE_CODES.has(e?.code)) {
200
+ // Name the real cause: "EPERM: operation not permitted" tells a
201
+ // user nothing actionable, and the fix is usually to close the
202
+ // editors whose servers hold the bundle.
203
+ if (RENAME_RETRYABLE_CODES.has(e?.code)) {
204
+ e.message = `${e.message}\n ↳ ${path.basename(to)} is held by another process after ~2.7s of retries.`
205
+ + '\n On Windows this is normally a running MCP server, an antivirus scan, or an indexer.'
206
+ + '\n Close your editors (or quit KLYPIX) and run the command again — nothing was left half-written.';
207
+ }
208
+ throw e;
209
+ }
210
+ retrySleepSync(RENAME_BACKOFF_MS[attempt]);
211
+ }
212
+ }
213
+ }
214
+
170
215
  function migrateProjectMcpConfig() {
171
216
  try {
172
217
  const file = path.join(process.cwd(), '.mcp.json');
@@ -355,7 +400,7 @@ try {
355
400
  } catch { /* .prev rollback snapshot is best-effort */ }
356
401
  const renameOrder = staged.slice().sort((a, b) => (a.dst === 'global-brain-hook.mjs' ? 1 : 0) - (b.dst === 'global-brain-hook.mjs' ? 1 : 0));
357
402
  let n = 0;
358
- for (const st of renameOrder) { fs.renameSync(path.join(BRAIN_DIR, st.dst + '.klypix-new'), path.join(BRAIN_DIR, st.dst)); n++; }
403
+ for (const st of renameOrder) { renameSyncWithBackoff(path.join(BRAIN_DIR, st.dst + '.klypix-new'), path.join(BRAIN_DIR, st.dst)); n++; }
359
404
 
360
405
  // 3) mark the dir an ESM package
361
406
  fs.writeFileSync(path.join(BRAIN_DIR, 'package.json'), JSON.stringify({ name: 'klypix-project-brain', private: true, type: 'module' }, null, 2));
@@ -390,7 +435,7 @@ try {
390
435
  const tmp = SETTINGS + '.klypix-tmp';
391
436
  fs.writeFileSync(tmp, JSON.stringify(settings, null, 2), 'utf8');
392
437
  JSON.parse(fs.readFileSync(tmp, 'utf8')); // verify before swap
393
- fs.renameSync(tmp, SETTINGS);
438
+ renameSyncWithBackoff(tmp, SETTINGS);
394
439
  }
395
440
 
396
441
  // 6) Commit the runtime pointer and version receipt atomically while the
@@ -417,7 +462,7 @@ try {
417
462
  };
418
463
  const runtimePath = path.join(BRAIN_DIR, '.mcp-runtime.json');
419
464
  fs.writeFileSync(runtimePath + '.klypix-new', JSON.stringify(runtime, null, 2) + '\n', 'utf8');
420
- fs.renameSync(runtimePath + '.klypix-new', runtimePath);
465
+ renameSyncWithBackoff(runtimePath + '.klypix-new', runtimePath);
421
466
  // A tagged-but-DIRTY checkout keeps via:'npm' (the tag still names the
422
467
  // payload identity, and dev:true would stop auto-update from healing the
423
468
  // machine back to clean released bytes) but stamps dirty:true + the audit
@@ -430,7 +475,7 @@ try {
430
475
  : { brainVersion: VERSION, via: 'npm', dirty: false, installedAt };
431
476
  const versionPath = path.join(BRAIN_DIR, '.brain-version.json');
432
477
  fs.writeFileSync(versionPath + '.klypix-new', JSON.stringify(versionStamp, null, 2), 'utf8');
433
- fs.renameSync(versionPath + '.klypix-new', versionPath);
478
+ renameSyncWithBackoff(versionPath + '.klypix-new', versionPath);
434
479
 
435
480
  // 7) migrate THIS project's .mcp.json off npx onto the now-installed local bundle
436
481
  // (heals an existing stale config so the next MCP server spawn runs current).
@@ -480,6 +525,24 @@ try {
480
525
  console.log(' Enhanced Codex auto-context + pre-edit overlap guard: re-run with `--codex-hooks`, then approve/review KLYPIX once in a Codex surface that supports hook trust.');
481
526
  }
482
527
  console.log(' Compatible brain-core updates hot-swap behind the same MCP connection. Only the one-time legacy→supervisor migration, a supervisor change, or an intentionally breaking tool/protocol change needs reconnect.');
528
+
529
+ // 9) PROJECT setup (1.71.0) — the step users never knew they had to take.
530
+ // `install` wired the machine; without this it wired nothing a Cursor,
531
+ // Antigravity, Codex, Cline or Copilot user could see, and the failure
532
+ // was silent. A runtime-only refresh deliberately skips it: that path
533
+ // exists to preserve every host/project config byte.
534
+ if (!RUNTIME_ONLY && !NO_PROJECT) {
535
+ try {
536
+ const report = await runSetup({ verifyAll: VERIFY_ALL });
537
+ if (SETUP_JSON) console.log(JSON.stringify(report, null, 2));
538
+ else console.log(renderBrief(report));
539
+ } catch (e) {
540
+ // The machine install already succeeded and is independently
541
+ // useful; a project-wiring failure must report itself, not undo it.
542
+ console.error(`⚠ project setup could not finish: ${e?.message || e}`);
543
+ console.error(' The machine install is intact — re-run inside your project, or use `npx klypix-mcp link`.');
544
+ }
545
+ }
483
546
  console.log(' Verify anytime: `npx klypix-mcp doctor`; prove two-client behavior with `npx klypix-mcp conformance`.');
484
547
  } catch (e) {
485
548
  releaseInstallLockSync(installLock);
@@ -25,7 +25,8 @@ const USAGE = [
25
25
  `klypix-mcp ${PKG_VERSION} — shared project brain + MCP coordination server.`,
26
26
  '',
27
27
  'Verbs:',
28
- ' install [--force] [--codex-hooks] install/update this machine\'s brain engine + Claude Code hooks',
28
+ ' install [--force] [--codex-hooks] set up everything: machine engine + Claude hooks, then THIS project —',
29
+ ' [--no-project] [--json] brain, config for the editors you actually have, merge driver, verified',
29
30
  ' link [dir] [--check] project this project\'s 14 managed agent config files (--check audits, writes nothing, exits 1 on drift)',
30
31
  ' doctor [--npm] [--all] [--json] read-only self-check; exits 1 on drift',
31
32
  ' runtime [--json] [--watch seconds] passive MCP process/RAM attribution; never terminates a process',
@@ -876,7 +876,8 @@ server.registerTool('brain_sync', {
876
876
  releaseIntent: z.object({
877
877
  version: z.string().max(64).describe('The version this session intends to release (e.g. "1.70.0").'),
878
878
  ref: z.string().max(200).describe('The git ref (branch or tag) the release will be cut from.'),
879
- }).optional().describe('Declare EXCLUSIVE intent to prepare a release of this project. The first declarer takes a ~2h lease (refreshed by checkpoints, freed by phase "complete", by expiry, or when the holder session ends); a second declarer gets a structured hard conflict naming the holder, version, and ref. While any lease is active every peer\'s sync gains a "release in preparation" footer line.'),
879
+ acknowledge: z.array(z.string().max(40)).max(64).optional().describe('Commit shas this release DELIBERATELY leaves behind. Only needed after a refusal: if the ref would drop finished work, the lease is refused and the response names every sha. Re-declare with those shas here to proceed — and tell the user what they are first.'),
880
+ }).optional().describe('Declare EXCLUSIVE intent to prepare a release of this project. The first declarer takes a ~2h lease (refreshed by checkpoints, freed by phase "complete", by expiry, or when the holder session ends); a second declarer gets a structured hard conflict naming the holder, version, and ref. While any lease is active every peer\'s sync gains a "release in preparation" footer line. A NEW declaration is also checked against what the release would LEAVE BEHIND: if the ref is missing commits that are on trunk or on a branch a live peer session is working on, the lease is REFUSED (nothing is changed) and the response lists them — report those commits to the user, then re-declare with acknowledge:[...] naming each sha if the release should go ahead without them.'),
880
881
  },
881
882
  }, async ({ project, intent, files, phase, include_context, results, releaseIntent }, extra) => {
882
883
  const totalStartedAt = Date.now();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.70.0",
3
+ "version": "1.72.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",
@@ -84,7 +84,7 @@
84
84
  "bench": "node bin/klypix-mcp.mjs bench",
85
85
  "test:bench": "node test/bench.mjs",
86
86
  "pretest": "node test/publish-workflow.mjs",
87
- "test": "node test/publish-verdict.mjs && node test/remote-client.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/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/presence-liveness.mjs && node test/observed-scope.mjs && node test/release-lease.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/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
87
+ "test": "node test/publish-verdict.mjs && node test/remote-client.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/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-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",
88
88
  "test:memory": "node test/memory-runtime.mjs",
89
89
  "test:memory:soak": "node --expose-gc test/memory-soak.mjs",
90
90
  "runtime": "node bin/klypix-runtime.mjs"
@@ -660,46 +660,73 @@ function classifyMcp(file, wrapKey, projectDir) {
660
660
 
661
661
  // The projection map — the single source of truth shared by WRITE (linkProject) and
662
662
  // CHECK (auditProject), so the two can never disagree about what's projected where.
663
+ //
664
+ // `editors` (1.71.0) names which detected hosts justify writing each file, so a
665
+ // project stops collecting managed config for tools nobody on the machine has.
666
+ // `standard: true` marks the cross-tool AGENTS.md convention — justified by ANY
667
+ // agent host rather than one specific id. Filtering only happens when a caller
668
+ // passes an explicit editor set; the default projects everything, exactly as
669
+ // before, so every existing caller is unaffected.
663
670
  function targets(projectDir) {
664
671
  const j = (...p) => path.join(projectDir, ...p);
665
672
  return {
666
673
  rules: [
667
- { tool: 'Codex / AGENTS.md standard', file: j('AGENTS.md'), kind: 'merge' },
668
- { tool: 'Cursor', file: j('.cursor', 'rules', 'klypix-brain.mdc'), kind: 'dedicated', frontmatter: '---\ndescription: KLYPIX project brain — read at task start, capture decisions\nalwaysApply: true\n---' },
669
- { tool: 'Windsurf', file: j('.windsurf', 'rules', 'klypix-brain.md'), kind: 'dedicated', frontmatter: '---\ntrigger: always_on\n---' },
670
- { tool: 'Cline', file: j('.clinerules', 'klypix-brain.md'), kind: 'dedicated', frontmatter: '' },
671
- { tool: 'GitHub Copilot', file: j('.github', 'copilot-instructions.md'), kind: 'merge' },
674
+ { tool: 'Codex / AGENTS.md standard', file: j('AGENTS.md'), kind: 'merge', editors: ['codex'], standard: true },
675
+ { tool: 'Cursor', file: j('.cursor', 'rules', 'klypix-brain.mdc'), kind: 'dedicated', editors: ['cursor'], frontmatter: '---\ndescription: KLYPIX project brain — read at task start, capture decisions\nalwaysApply: true\n---' },
676
+ { tool: 'Windsurf', file: j('.windsurf', 'rules', 'klypix-brain.md'), kind: 'dedicated', editors: ['windsurf'], frontmatter: '---\ntrigger: always_on\n---' },
677
+ { tool: 'Cline', file: j('.clinerules', 'klypix-brain.md'), kind: 'dedicated', editors: ['cline'], frontmatter: '' },
678
+ { tool: 'GitHub Copilot', file: j('.github', 'copilot-instructions.md'), kind: 'merge', editors: ['copilot'] },
672
679
  // Added 1.13.0 — close the "not generated at all" coverage gap the audit flagged.
673
- { tool: 'Gemini CLI', file: j('GEMINI.md'), kind: 'merge' },
674
- { tool: 'Aider', file: j('CONVENTIONS.md'), kind: 'dedicated', frontmatter: '' },
680
+ { tool: 'Gemini CLI', file: j('GEMINI.md'), kind: 'merge', editors: ['gemini-cli', 'antigravity'] },
681
+ { tool: 'Aider', file: j('CONVENTIONS.md'), kind: 'dedicated', editors: ['aider'], frontmatter: '' },
675
682
  // Added 1.29.1 — Antigravity (Gemini IDE) reads .agents/AGENTS.md; the desktop
676
683
  // app already writes it (projectBrainConnect.ts), this adds CLI audit coverage.
677
684
  // kind:'merge' so the fenced block coexists with any existing content.
678
- { tool: 'Antigravity', file: j('.agents', 'AGENTS.md'), kind: 'merge' },
685
+ { tool: 'Antigravity', file: j('.agents', 'AGENTS.md'), kind: 'merge', editors: ['antigravity'] },
679
686
  // Intentionally NO project CLAUDE.md target. Claude receives the same task
680
687
  // contract mechanically through lifecycle hooks + MCP tool instructions;
681
688
  // the desktop legacy connector still owns CLAUDE.md, so a second Core
682
689
  // writer here would create duelling managed blocks and update churn.
683
690
  ],
684
691
  mcp: [
685
- { tool: 'Codex', file: j('.codex', 'config.toml'), format: 'toml' },
686
- { tool: 'Claude Code', file: j('.mcp.json'), wrapKey: 'mcpServers', withType: false },
687
- { tool: 'Cursor', file: j('.cursor', 'mcp.json'), wrapKey: 'mcpServers', withType: false },
688
- { tool: 'Cline', file: j('.cline', 'mcp.json'), wrapKey: 'mcpServers', withType: false },
689
- { tool: 'Gemini CLI / Antigravity', file: j('.gemini', 'settings.json'), wrapKey: 'mcpServers', withType: false },
690
- { tool: 'VS Code (Copilot/Continue)', file: j('.vscode', 'mcp.json'), wrapKey: 'servers', withType: true },
692
+ { tool: 'Codex', file: j('.codex', 'config.toml'), format: 'toml', editors: ['codex'] },
693
+ { tool: 'Claude Code', file: j('.mcp.json'), wrapKey: 'mcpServers', withType: false, editors: ['claude-code'] },
694
+ { tool: 'Cursor', file: j('.cursor', 'mcp.json'), wrapKey: 'mcpServers', withType: false, editors: ['cursor'] },
695
+ { tool: 'Cline', file: j('.cline', 'mcp.json'), wrapKey: 'mcpServers', withType: false, editors: ['cline'] },
696
+ { tool: 'Gemini CLI / Antigravity', file: j('.gemini', 'settings.json'), wrapKey: 'mcpServers', withType: false, editors: ['gemini-cli', 'antigravity'] },
697
+ { tool: 'VS Code (Copilot/Continue)', file: j('.vscode', 'mcp.json'), wrapKey: 'servers', withType: true, editors: ['vscode', 'copilot'] },
691
698
  ],
692
699
  };
693
700
  }
694
701
 
702
+ /**
703
+ * Should this target be projected, given the hosts detected on the machine?
704
+ *
705
+ * Three independent yeses, in order of strength:
706
+ * 1. a detected host reads it;
707
+ * 2. it is the cross-tool AGENTS.md standard and SOME agent host is present;
708
+ * 3. the file already exists in the project — a teammate committed it, so it
709
+ * stays current whether or not THIS machine has that editor. Without this
710
+ * rule a one-editor developer would silently stop maintaining the configs
711
+ * the rest of their team depends on.
712
+ */
713
+ function targetJustified(target, editors, anyEditor) {
714
+ if (!editors) return true; // unfiltered: project everything
715
+ if ((target.editors || []).some((id) => editors.has(id))) return true;
716
+ if (target.standard && anyEditor) return true;
717
+ return exists(target.file);
718
+ }
719
+
695
720
  const relFile = (projectDir, abs) => path.relative(projectDir, abs).replace(/\\/g, '/');
696
721
 
697
722
  /**
698
723
  * Wire a project so EVERY agent tool reads + captures its brain automatically — or,
699
724
  * with { check:true }, AUDIT the projection without touching disk.
700
725
  * @param {string} projectDir absolute project root (holds ./brain.klypix)
701
- * @param {{ version?: string, check?: boolean }} [opts]
702
- * @returns {{ rules: Array, mcp: Array, hasBrain: boolean, version: string, check: boolean }}
726
+ * @param {{ version?: string, check?: boolean, editors?: Set<string>|string[] }} [opts]
727
+ * editors — when supplied, project only what a detected host actually reads
728
+ * (plus files the project already carries). Omit for the legacy write-all.
729
+ * @returns {{ rules, mcp, hasBrain, version, check, skipped: Array }}
703
730
  */
704
731
  export function linkProject(projectDir, opts = {}) {
705
732
  const version = opts.version || resolveVersion();
@@ -707,6 +734,29 @@ export function linkProject(projectDir, opts = {}) {
707
734
  const t = targets(projectDir);
708
735
  const hasBrain = exists(path.join(projectDir, 'brain.klypix')) || exists(path.join(projectDir, 'brain.any'));
709
736
 
737
+ const editors = opts.editors == null ? null
738
+ : (opts.editors instanceof Set ? opts.editors : new Set(opts.editors));
739
+ const anyEditor = !!editors && editors.size > 0;
740
+ // Files the caller has decided not to touch at all — used by the automatic
741
+ // setup path to leave a project-owned (vendored, repo-relative) server alone.
742
+ // An explicitly typed `link` still rewrites everything; an action the user
743
+ // did not ask for should be more conservative than one they did.
744
+ const exclude = opts.exclude instanceof Set ? opts.exclude
745
+ : new Set(Array.isArray(opts.exclude) ? opts.exclude : []);
746
+ const skipped = [];
747
+ const keep = (target) => {
748
+ const file = relFile(projectDir, target.file);
749
+ if (exclude.has(file)) {
750
+ skipped.push({ tool: target.tool, file, why: 'project-owned server — left untouched' });
751
+ return false;
752
+ }
753
+ if (targetJustified(target, editors, anyEditor)) return true;
754
+ skipped.push({ tool: target.tool, file, why: 'not installed on this machine' });
755
+ return false;
756
+ };
757
+ t.rules = t.rules.filter(keep);
758
+ t.mcp = t.mcp.filter(keep);
759
+
710
760
  const rules = t.rules.map((r) => {
711
761
  const file = relFile(projectDir, r.file);
712
762
  try {
@@ -761,7 +811,7 @@ export function linkProject(projectDir, opts = {}) {
761
811
  }
762
812
  });
763
813
 
764
- return { rules, mcp, hasBrain, version, check };
814
+ return { rules, mcp, hasBrain, version, check, skipped };
765
815
  }
766
816
 
767
817
  /**
@@ -0,0 +1,117 @@
1
+ // Which agent tools does this machine ACTUALLY have?
2
+ //
3
+ // Before 1.71 `link` projected all 14 managed files into every project, for
4
+ // every supported host, whether or not the user had ever installed it. Those
5
+ // files get COMMITTED, so a two-person team using one editor shipped rules and
6
+ // MCP config for six they had never opened — repo clutter that reads as noise
7
+ // in review and makes the tool look presumptuous on first contact.
8
+ //
9
+ // This module answers the narrower question the projection should have been
10
+ // asking: is this host present on this machine? Detection is deliberately
11
+ // EVIDENCE-BASED and conservative — every positive names the path or variable
12
+ // that produced it (`why`), so a wrong answer is auditable rather than
13
+ // mysterious. Absence is never proof; the caller pairs this with the
14
+ // project-level signal (a target file already committed) and keeps projecting
15
+ // anything a teammate already relies on.
16
+ //
17
+ // Pure except for fs existence checks, and every environment input is
18
+ // injectable so the whole matrix is testable without touching a real home dir.
19
+ import fs from 'fs';
20
+ import path from 'path';
21
+ import os from 'os';
22
+
23
+ /** Roaming-config root per platform — where Electron editors keep their profile. */
24
+ export function appDataDir({ platform = process.platform, home = os.homedir(), env = process.env } = {}) {
25
+ if (platform === 'win32') return env.APPDATA || path.join(home, 'AppData', 'Roaming');
26
+ if (platform === 'darwin') return path.join(home, 'Library', 'Application Support');
27
+ return env.XDG_CONFIG_HOME || path.join(home, '.config');
28
+ }
29
+
30
+ // Extension hosts whose extension folders we scan for editor PLUGINS (Cline,
31
+ // Copilot). VS Code forks keep the same `<publisher>.<name>-<version>` layout.
32
+ const EXTENSION_ROOTS = ['.vscode', '.vscode-insiders', '.cursor', '.windsurf', '.vscode-oss'];
33
+
34
+ /**
35
+ * The detection matrix. Each entry lists independent signals; ANY hit is a
36
+ * positive, because a user may have a CLI without the GUI or vice versa.
37
+ * - home: dot-dirs/files directly under the home directory
38
+ * - app: profile directory names under the platform's roaming-config root
39
+ * - ext: installed-extension id patterns (for plugin-shaped hosts)
40
+ * - env: environment variables an editor exports into its own terminal —
41
+ * the strongest signal available, since it means we are running
42
+ * INSIDE that editor right now
43
+ */
44
+ const EDITORS = [
45
+ { id: 'claude-code', name: 'Claude Code', home: ['.claude'], env: ['CLAUDE_CODE_SESSION_ID', 'CLAUDE_PID'] },
46
+ { id: 'cursor', name: 'Cursor', home: ['.cursor'], app: ['Cursor'], env: ['CURSOR_SESSION_ID', 'CURSOR_TRACE_ID'] },
47
+ { id: 'codex', name: 'Codex', home: ['.codex'], env: ['CODEX_THREAD_ID'] },
48
+ { id: 'gemini-cli', name: 'Gemini CLI', home: ['.gemini'] },
49
+ { id: 'antigravity', name: 'Antigravity', home: ['.antigravity'], app: ['Antigravity'] },
50
+ { id: 'vscode', name: 'VS Code', home: ['.vscode', '.vscode-insiders'], app: ['Code', 'Code - Insiders', 'VSCodium'] },
51
+ { id: 'windsurf', name: 'Windsurf', home: ['.windsurf'], app: ['Windsurf'], env: ['WINDSURF_SESSION_ID'] },
52
+ { id: 'cline', name: 'Cline', ext: [/^saoudrizwan\.claude-dev/i], env: ['CLINE_SESSION_ID'] },
53
+ { id: 'copilot', name: 'GitHub Copilot', ext: [/^github\.copilot/i] },
54
+ { id: 'aider', name: 'Aider', home: ['.aider', '.aider.conf.yml', '.aider.model.settings.yml'] },
55
+ ];
56
+
57
+ const exists = (p) => { try { return fs.existsSync(p); } catch { return false; } };
58
+
59
+ /** Installed extension ids across every VS Code-shaped host on this machine. */
60
+ function installedExtensions(home) {
61
+ const ids = [];
62
+ for (const root of EXTENSION_ROOTS) {
63
+ const dir = path.join(home, root, 'extensions');
64
+ let entries;
65
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
66
+ for (const e of entries) {
67
+ if (!e.isDirectory()) continue;
68
+ ids.push({ id: e.name, from: `${root}/extensions/${e.name}` });
69
+ }
70
+ }
71
+ return ids;
72
+ }
73
+
74
+ /**
75
+ * Detect the agent tools present on this machine.
76
+ * @param {{ home?: string, platform?: string, env?: object }} [opts]
77
+ * @returns {{ present: Map<string,{id,name,why}>, absent: Array<{id,name}>, all: Array }}
78
+ */
79
+ export function detectEditors(opts = {}) {
80
+ const home = opts.home || os.homedir();
81
+ const platform = opts.platform || process.platform;
82
+ const env = opts.env || process.env;
83
+ const appRoot = appDataDir({ platform, home, env });
84
+
85
+ // Scanned once — readdir on up to five extension roots is the only
86
+ // non-trivial cost in this module, and most machines have one or two.
87
+ let extensions = null;
88
+ const getExtensions = () => (extensions ??= installedExtensions(home));
89
+
90
+ const present = new Map();
91
+ const absent = [];
92
+
93
+ for (const ed of EDITORS) {
94
+ let why = null;
95
+
96
+ for (const key of ed.env || []) {
97
+ if (env[key]) { why = `running inside it (${key})`; break; }
98
+ }
99
+ if (!why) for (const dir of ed.home || []) {
100
+ if (exists(path.join(home, dir))) { why = `~/${dir}`; break; }
101
+ }
102
+ if (!why) for (const dir of ed.app || []) {
103
+ if (exists(path.join(appRoot, dir))) { why = `${path.basename(appRoot)}/${dir}`; break; }
104
+ }
105
+ if (!why && ed.ext) {
106
+ const hit = getExtensions().find((x) => ed.ext.some((re) => re.test(x.id)));
107
+ if (hit) why = hit.from;
108
+ }
109
+
110
+ if (why) present.set(ed.id, { id: ed.id, name: ed.name, why });
111
+ else absent.push({ id: ed.id, name: ed.name });
112
+ }
113
+
114
+ return { present, absent, all: EDITORS.map((e) => ({ id: e.id, name: e.name })) };
115
+ }
116
+
117
+ export { EDITORS };