auxilo-mcp 0.8.1 → 0.9.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/lib/installer.js CHANGED
@@ -46,17 +46,39 @@ const PACKAGE_ROOT = path.resolve(__dirname, '..');
46
46
  const RUNNER_STACK = Object.freeze([
47
47
  // [package-relative source, bin-relative dest, mode]
48
48
  ['scripts/runner.js', 'scripts/runner.js', 0o755],
49
+ ['scripts/capture-core.js', 'scripts/capture-core.js', 0o755], // UC-1 shim target
49
50
  ['scripts/sources/source.interface.js', 'scripts/sources/source.interface.js', 0o644],
50
51
  ['scripts/sources/claude-code.js', 'scripts/sources/claude-code.js', 0o644],
51
52
  ['scripts/sources/openclaw.js', 'scripts/sources/openclaw.js', 0o644],
53
+ ['scripts/sources/gemini-cli.js', 'scripts/sources/gemini-cli.js', 0o644],
54
+ ['scripts/sources/antigravity.js', 'scripts/sources/antigravity.js', 0o644],
55
+ ['scripts/sources/generic-jsonl.js', 'scripts/sources/generic-jsonl.js', 0o644],
52
56
  ['lib/sensitivity-filter.js', 'lib/sensitivity-filter.js', 0o644],
53
57
  ]);
54
58
 
55
- // ─── Client registry + detection (spec §LW-12 step 1) ───────────────────────
59
+ // ─── Client registry + detection (spec §LW-12 step 1; UC-0 expansion) ───────
56
60
 
57
61
  /**
58
62
  * Build the supported-client registry for a given home directory.
59
63
  *
64
+ * Each entry's `format` names the config dialect registerMcp writes
65
+ * (UC-0: "MCP registration writers for every Tier-1/2 client config format"):
66
+ * 'json-mcpServers' — Claude-Desktop-style `mcpServers` object (the default)
67
+ * 'toml-codex' — Codex CLI config.toml, append-only (no TOML parser)
68
+ * 'json-dropin' — standalone drop-in file we own entirely (Continue.dev)
69
+ * 'opencode' — opencode.json `mcp` key, `{type:'local',command:[...]}`
70
+ * 'amp' — settings.json flat `amp.mcpServers` key
71
+ * 'openhands-stdio' — mcp.json `stdio_servers` ARRAY keyed by entry name
72
+ *
73
+ * Optional fields: `detectDirs` (any-of detection, e.g. opencode's two roots),
74
+ * `rulesPath` (global rules file for the UC-0 agent-prompted-contribution
75
+ * snippet — see installRulesSnippet), and the UC-1 capture-hook trio:
76
+ * `captureHook` (client supports a session-end hook we can register),
77
+ * `captureEvent` (the client's event name), `captureConfigPath` (the hook
78
+ * config file registerCaptureHook patches), plus `sourceId` when the
79
+ * runner-side source id differs from the registry id (codex → codex-cli,
80
+ * copilot-cli → copilot; must match model_config.json source_allowlist).
81
+ *
60
82
  * @param {string} homeDir Explicit home directory (fixture dir in tests).
61
83
  * @param {object} [opts]
62
84
  * @param {string} [opts.platform=process.platform] 'darwin' | 'win32' | ...
@@ -78,6 +100,7 @@ function clientRegistry(homeDir, opts = {}) {
78
100
  name: 'Claude Code',
79
101
  detectDir: path.join(homeDir, '.claude'),
80
102
  configPath: path.join(homeDir, '.claude', 'settings.json'),
103
+ format: 'json-mcpServers',
81
104
  mcp: true,
82
105
  hooks: true, // SessionEnd hook (background extraction)
83
106
  },
@@ -86,6 +109,7 @@ function clientRegistry(homeDir, opts = {}) {
86
109
  name: 'Claude Desktop',
87
110
  detectDir: claudeDesktopDir,
88
111
  configPath: path.join(claudeDesktopDir, 'claude_desktop_config.json'),
112
+ format: 'json-mcpServers',
89
113
  mcp: true,
90
114
  hooks: false,
91
115
  },
@@ -94,8 +118,13 @@ function clientRegistry(homeDir, opts = {}) {
94
118
  name: 'Cursor',
95
119
  detectDir: path.join(homeDir, '.cursor'),
96
120
  configPath: path.join(homeDir, '.cursor', 'mcp.json'),
121
+ format: 'json-mcpServers',
97
122
  mcp: true,
98
123
  hooks: false,
124
+ // UC-1 capture hook (session-end → capture-core shim)
125
+ captureHook: true,
126
+ captureEvent: 'sessionEnd',
127
+ captureConfigPath: path.join(homeDir, '.cursor', 'hooks.json'),
99
128
  },
100
129
  {
101
130
  id: 'openclaw',
@@ -105,21 +134,177 @@ function clientRegistry(homeDir, opts = {}) {
105
134
  mcp: false,
106
135
  hooks: false,
107
136
  },
137
+ // ── UC-0 additions (config paths web-verified June 2026, BUILD-SPEC-UNIVERSAL-CLIENTS §5) ──
138
+ {
139
+ id: 'windsurf',
140
+ name: 'Windsurf',
141
+ detectDir: path.join(homeDir, '.codeium', 'windsurf'),
142
+ configPath: path.join(homeDir, '.codeium', 'windsurf', 'mcp_config.json'),
143
+ format: 'json-mcpServers',
144
+ mcp: true,
145
+ hooks: false,
146
+ captureHook: true,
147
+ captureEvent: 'post_cascade_response_with_transcript',
148
+ captureConfigPath: path.join(homeDir, '.codeium', 'windsurf', 'hooks.json'),
149
+ },
150
+ {
151
+ id: 'codex',
152
+ name: 'Codex CLI',
153
+ detectDir: path.join(homeDir, '.codex'),
154
+ configPath: path.join(homeDir, '.codex', 'config.toml'),
155
+ format: 'toml-codex',
156
+ mcp: true,
157
+ hooks: false,
158
+ captureHook: true,
159
+ captureEvent: 'Stop',
160
+ captureConfigPath: path.join(homeDir, '.codex', 'hooks.json'),
161
+ // Runner-side source id (model_config.json source_allowlist uses
162
+ // 'codex-cli'; the registry/client id stays 'codex').
163
+ sourceId: 'codex-cli',
164
+ },
165
+ {
166
+ id: 'gemini-cli',
167
+ name: 'Gemini CLI',
168
+ // Bare ~/.gemini is NOT sufficient: Antigravity also creates ~/.gemini
169
+ // (verified on a real machine with Antigravity but no Gemini CLI —
170
+ // phantom detection). Require CLI-specific artifacts: the session tmp
171
+ // dir, or an existing settings.json.
172
+ detectDirs: [path.join(homeDir, '.gemini', 'tmp')],
173
+ detectFiles: [path.join(homeDir, '.gemini', 'settings.json')],
174
+ configPath: path.join(homeDir, '.gemini', 'settings.json'),
175
+ format: 'json-mcpServers',
176
+ mcp: true,
177
+ hooks: false,
178
+ // Global context file — target for the UC-0 rules-snippet opt-in.
179
+ rulesPath: path.join(homeDir, '.gemini', 'GEMINI.md'),
180
+ captureHook: true,
181
+ captureEvent: 'SessionEnd',
182
+ captureConfigPath: path.join(homeDir, '.gemini', 'settings.json'),
183
+ },
184
+ {
185
+ // Separate client from Gemini CLI: detection requires the antigravity
186
+ // SUBDIR (a bare ~/.gemini means Gemini CLI only).
187
+ id: 'antigravity',
188
+ name: 'Antigravity',
189
+ detectDir: path.join(homeDir, '.gemini', 'antigravity'),
190
+ configPath: path.join(homeDir, '.gemini', 'config', 'mcp_config.json'),
191
+ format: 'json-mcpServers',
192
+ mcp: true,
193
+ hooks: false,
194
+ captureHook: true,
195
+ captureEvent: 'Stop',
196
+ captureConfigPath: path.join(homeDir, '.gemini', 'config', 'hooks.json'),
197
+ },
198
+ {
199
+ id: 'factory',
200
+ name: 'Factory droid',
201
+ detectDir: path.join(homeDir, '.factory'),
202
+ configPath: path.join(homeDir, '.factory', 'mcp.json'),
203
+ format: 'json-mcpServers',
204
+ mcp: true,
205
+ hooks: false,
206
+ captureHook: true,
207
+ captureEvent: 'SessionEnd',
208
+ // NOT settings.json — Factory reads hooks from ~/.factory/hooks.json.
209
+ captureConfigPath: path.join(homeDir, '.factory', 'hooks.json'),
210
+ },
211
+ {
212
+ id: 'copilot-cli',
213
+ name: 'GitHub Copilot CLI',
214
+ detectDir: path.join(homeDir, '.copilot'),
215
+ configPath: path.join(homeDir, '.copilot', 'mcp-config.json'),
216
+ format: 'json-mcpServers',
217
+ mcp: true,
218
+ hooks: false,
219
+ captureHook: true,
220
+ captureEvent: 'Stop',
221
+ // Drop-in file we own entirely; the ~/.copilot/hooks dir is shared with
222
+ // VS Code Copilot user-level hooks (same machine config root).
223
+ captureConfigPath: path.join(homeDir, '.copilot', 'hooks', 'auxilo.json'),
224
+ // Runner-side source id (allowlist uses 'copilot', not 'copilot-cli').
225
+ sourceId: 'copilot',
226
+ },
227
+ {
228
+ // Continue auto-loads Claude-style JSON drop-ins from ~/.continue/mcpServers/.
229
+ id: 'continue',
230
+ name: 'Continue.dev',
231
+ detectDir: path.join(homeDir, '.continue'),
232
+ configPath: path.join(homeDir, '.continue', 'mcpServers', 'auxilo.json'),
233
+ format: 'json-dropin',
234
+ mcp: true,
235
+ hooks: false,
236
+ },
237
+ {
238
+ id: 'opencode',
239
+ name: 'opencode',
240
+ detectDir: path.join(homeDir, '.config', 'opencode'),
241
+ detectDirs: [
242
+ path.join(homeDir, '.config', 'opencode'),
243
+ path.join(homeDir, '.local', 'share', 'opencode'),
244
+ ],
245
+ configPath: path.join(homeDir, '.config', 'opencode', 'opencode.json'),
246
+ format: 'opencode',
247
+ mcp: true,
248
+ hooks: false,
249
+ },
250
+ {
251
+ id: 'kiro',
252
+ name: 'Kiro',
253
+ detectDir: path.join(homeDir, '.kiro'),
254
+ configPath: path.join(homeDir, '.kiro', 'settings', 'mcp.json'),
255
+ format: 'json-mcpServers',
256
+ mcp: true,
257
+ hooks: false,
258
+ },
259
+ {
260
+ id: 'junie',
261
+ name: 'JetBrains Junie',
262
+ detectDir: path.join(homeDir, '.junie'),
263
+ configPath: path.join(homeDir, '.junie', 'mcp', 'mcp.json'),
264
+ format: 'json-mcpServers',
265
+ mcp: true,
266
+ hooks: false,
267
+ },
268
+ {
269
+ id: 'amp',
270
+ name: 'Amp',
271
+ detectDir: path.join(homeDir, '.config', 'amp'),
272
+ configPath: path.join(homeDir, '.config', 'amp', 'settings.json'),
273
+ format: 'amp',
274
+ mcp: true,
275
+ hooks: false,
276
+ },
277
+ {
278
+ id: 'openhands',
279
+ name: 'OpenHands',
280
+ detectDir: path.join(homeDir, '.openhands'),
281
+ configPath: path.join(homeDir, '.openhands', 'mcp.json'),
282
+ format: 'openhands-stdio',
283
+ mcp: true,
284
+ hooks: false,
285
+ },
108
286
  ];
109
287
  }
110
288
 
289
+ /** True when p exists and is a directory (detection probe). */
290
+ function dirExists(p) {
291
+ try {
292
+ return fs.statSync(p).isDirectory();
293
+ } catch {
294
+ return false;
295
+ }
296
+ }
297
+
111
298
  /**
112
299
  * Detect which supported clients are present under homeDir.
113
- * @returns {Array<object>} subset of clientRegistry whose detectDir exists
300
+ * @returns {Array<object>} subset of clientRegistry whose detectDir (or any
301
+ * of detectDirs, when a client has multiple roots) exists, or any of whose
302
+ * detectFiles exist (clients whose only reliable marker is a file)
114
303
  */
115
304
  function detectClients(homeDir, opts = {}) {
116
- return clientRegistry(homeDir, opts).filter((c) => {
117
- try {
118
- return fs.statSync(c.detectDir).isDirectory();
119
- } catch {
120
- return false;
121
- }
122
- });
305
+ return clientRegistry(homeDir, opts).filter((c) =>
306
+ (c.detectDirs || [c.detectDir]).some(dirExists) ||
307
+ (c.detectFiles || []).some((p) => fs.existsSync(p)));
123
308
  }
124
309
 
125
310
  // ─── MCP registration (spec §LW-12 step 1) ──────────────────────────────────
@@ -149,29 +334,157 @@ function readClientConfig(configPath) {
149
334
  }
150
335
  }
151
336
 
337
+ /** Fresh copy of the standard MCP server entry (never share the frozen one). */
338
+ function mcpEntry() {
339
+ return { command: MCP_ENTRY.command, args: [...MCP_ENTRY.args] };
340
+ }
341
+
342
+ /** Result helpers shared by every registerMcp format writer. */
343
+ function unchangedResult(configPath) {
344
+ return { changed: false, status: 'already-registered', configPath };
345
+ }
346
+ function registeredResult(configPath) {
347
+ return { changed: true, status: 'registered', configPath };
348
+ }
349
+
350
+ /** Default writer: Claude-Desktop-style `mcpServers` object (most clients). */
351
+ function registerJsonMcpServers(client) {
352
+ const config = readClientConfig(client.configPath);
353
+ if (config.mcpServers && config.mcpServers.auxilo) return unchangedResult(client.configPath);
354
+ if (!config.mcpServers) config.mcpServers = {};
355
+ config.mcpServers.auxilo = mcpEntry();
356
+ writeJsonAtomic(client.configPath, config);
357
+ return registeredResult(client.configPath);
358
+ }
359
+
360
+ /** TOML section header used for Codex idempotency (substring probe — no TOML dep). */
361
+ const CODEX_SECTION_HEADER = '[mcp_servers.auxilo]';
362
+
363
+ /**
364
+ * Codex CLI writer: APPEND-ONLY into ~/.codex/config.toml. Existing TOML is
365
+ * never parsed, rewritten, or reformatted — we only ever append our own
366
+ * section, and only when the `[mcp_servers.auxilo]` header is absent.
367
+ */
368
+ function registerCodexToml(client) {
369
+ const raw = fs.existsSync(client.configPath)
370
+ ? fs.readFileSync(client.configPath, 'utf-8')
371
+ : '';
372
+ if (raw.includes(CODEX_SECTION_HEADER)) return unchangedResult(client.configPath);
373
+
374
+ const section = `\n${CODEX_SECTION_HEADER}\n` +
375
+ `command = ${JSON.stringify(MCP_ENTRY.command)}\n` +
376
+ `args = ${JSON.stringify(MCP_ENTRY.args)}\n`;
377
+
378
+ const tmp = `${client.configPath}.tmp`;
379
+ fs.mkdirSync(path.dirname(client.configPath), { recursive: true });
380
+ fs.writeFileSync(tmp, raw + section);
381
+ fs.renameSync(tmp, client.configPath);
382
+ return registeredResult(client.configPath);
383
+ }
384
+
385
+ /** Continue.dev writer: standalone drop-in file under ~/.continue/mcpServers/. */
386
+ function registerJsonDropin(client) {
387
+ const config = readClientConfig(client.configPath);
388
+ if (config.mcpServers && config.mcpServers.auxilo) return unchangedResult(client.configPath);
389
+ if (!config.mcpServers) config.mcpServers = {};
390
+ config.mcpServers.auxilo = mcpEntry();
391
+ writeJsonAtomic(client.configPath, config);
392
+ return registeredResult(client.configPath);
393
+ }
394
+
395
+ /** opencode writer: `mcp` key, entry shape {type:'local', command:[bin,...args]}. */
396
+ function registerOpencode(client) {
397
+ const config = readClientConfig(client.configPath);
398
+ if (config.mcp && config.mcp.auxilo) return unchangedResult(client.configPath);
399
+ if (!config.mcp) config.mcp = {};
400
+ config.mcp.auxilo = { type: 'local', command: [MCP_ENTRY.command, ...MCP_ENTRY.args] };
401
+ writeJsonAtomic(client.configPath, config);
402
+ return registeredResult(client.configPath);
403
+ }
404
+
405
+ /** Amp writer: settings.json flat `amp.mcpServers` key (an object of servers). */
406
+ function registerAmp(client) {
407
+ const config = readClientConfig(client.configPath);
408
+ const servers = config['amp.mcpServers'];
409
+ if (servers && servers.auxilo) return unchangedResult(client.configPath);
410
+ config['amp.mcpServers'] = { ...(servers || {}), auxilo: mcpEntry() };
411
+ writeJsonAtomic(client.configPath, config);
412
+ return registeredResult(client.configPath);
413
+ }
414
+
415
+ /** OpenHands writer: `stdio_servers` ARRAY of {name, command, args}, keyed by name. */
416
+ function registerOpenHands(client) {
417
+ const config = readClientConfig(client.configPath);
418
+ // GOV-3 M1: a non-array stdio_servers means the schema isn't what we expect
419
+ // (drift, or an object map) — skip loudly rather than clobbering the user's
420
+ // existing registrations with a coerced array (B15 discipline).
421
+ if (config.stdio_servers !== undefined && !Array.isArray(config.stdio_servers)) {
422
+ throw new Error(`Unexpected stdio_servers shape in ${client.configPath} (not an array) — add the Auxilo entry manually`);
423
+ }
424
+ const servers = config.stdio_servers || [];
425
+ if (servers.some((s) => s && s.name === 'auxilo')) return unchangedResult(client.configPath);
426
+ config.stdio_servers = [...servers, { name: 'auxilo', ...mcpEntry() }];
427
+ writeJsonAtomic(client.configPath, config);
428
+ return registeredResult(client.configPath);
429
+ }
430
+
431
+ /** Format → writer dispatch table (extend here when a new dialect appears). */
432
+ const MCP_WRITERS = Object.freeze({
433
+ 'json-mcpServers': registerJsonMcpServers,
434
+ 'toml-codex': registerCodexToml,
435
+ 'json-dropin': registerJsonDropin,
436
+ 'opencode': registerOpencode,
437
+ 'amp': registerAmp,
438
+ 'openhands-stdio': registerOpenHands,
439
+ });
440
+
152
441
  /**
153
442
  * Register the Auxilo MCP server in one client's config file.
154
- * Read-modify-write with tmp+rename; existing keys preserved; no-op (file
155
- * untouched) when `mcpServers.auxilo` is already present.
443
+ * Dispatches on the client's `format` (default 'json-mcpServers'). All JSON
444
+ * writers are read-modify-write with tmp+rename; existing keys preserved;
445
+ * no-op (file untouched) when the auxilo entry is already present. The Codex
446
+ * TOML writer is append-only and never touches existing content.
156
447
  *
157
448
  * @param {object} client Entry from clientRegistry (must have configPath).
158
449
  * @returns {{ changed: boolean, status: 'registered'|'already-registered', configPath: string }}
159
- * @throws on malformed existing JSON (caller skips that client loudly)
450
+ * @throws on malformed existing JSON (B15 — caller skips that client loudly)
160
451
  */
161
452
  function registerMcp(client) {
162
453
  if (!client || !client.configPath) {
163
454
  throw new Error(`registerMcp: client ${client && client.id} has no MCP config path`);
164
455
  }
165
- const config = readClientConfig(client.configPath);
166
-
167
- if (config.mcpServers && config.mcpServers.auxilo) {
168
- return { changed: false, status: 'already-registered', configPath: client.configPath };
456
+ const writer = MCP_WRITERS[client.format || 'json-mcpServers'];
457
+ if (!writer) {
458
+ throw new Error(`registerMcp: client ${client.id} has unknown config format "${client.format}"`);
169
459
  }
460
+ return writer(client);
461
+ }
170
462
 
171
- if (!config.mcpServers) config.mcpServers = {};
172
- config.mcpServers.auxilo = { command: MCP_ENTRY.command, args: [...MCP_ENTRY.args] };
173
- writeJsonAtomic(client.configPath, config);
174
- return { changed: true, status: 'registered', configPath: client.configPath };
463
+ /**
464
+ * True when the auxilo MCP entry is present in a client's config, per the
465
+ * client's format. Read-only probe for `auxilo status` — missing or malformed
466
+ * config reads as "not registered" (status must never throw on local probes).
467
+ */
468
+ function mcpRegistrationPresent(client) {
469
+ try {
470
+ if (client.format === 'toml-codex') {
471
+ return fs.readFileSync(client.configPath, 'utf-8').includes(CODEX_SECTION_HEADER);
472
+ }
473
+ const config = JSON.parse(fs.readFileSync(client.configPath, 'utf-8'));
474
+ switch (client.format || 'json-mcpServers') {
475
+ case 'opencode':
476
+ return Boolean(config.mcp && config.mcp.auxilo);
477
+ case 'amp':
478
+ return Boolean(config['amp.mcpServers'] && config['amp.mcpServers'].auxilo);
479
+ case 'openhands-stdio':
480
+ return Array.isArray(config.stdio_servers) &&
481
+ config.stdio_servers.some((s) => s && s.name === 'auxilo');
482
+ default: // json-mcpServers + json-dropin share the mcpServers shape
483
+ return Boolean(config.mcpServers && config.mcpServers.auxilo);
484
+ }
485
+ } catch {
486
+ return false; // missing or malformed config → not registered
487
+ }
175
488
  }
176
489
 
177
490
  // ─── Credentials (spec §LW-12 step 2) ───────────────────────────────────────
@@ -246,8 +559,11 @@ async function deviceLogin(opts = {}) {
246
559
  });
247
560
  if (!res.ok) throw new Error(`Device code request failed (HTTP ${res.status})`);
248
561
  const device = await res.json();
249
- const { user_code: userCode, verification_url: verificationUrl } = device;
562
+ // A-1: device_code is the secret polling credential; user_code is the human
563
+ // code shown on the verification page only.
564
+ const { user_code: userCode, device_code: deviceCode, verification_url: verificationUrl } = device;
250
565
  if (!userCode) throw new Error('Device code request failed: no user_code in response');
566
+ if (!deviceCode) throw new Error('Device code request failed: no device_code in response');
251
567
 
252
568
  // Server returns interval in seconds (default 5 per server.js /auth/device).
253
569
  const intervalMs = (device.interval || 5) * 1000;
@@ -259,7 +575,7 @@ async function deviceLogin(opts = {}) {
259
575
  await sleep(intervalMs);
260
576
  let status;
261
577
  try {
262
- const poll = await fetchImpl(`${baseUrl}/auth/device/status?code=${encodeURIComponent(userCode)}`);
578
+ const poll = await fetchImpl(`${baseUrl}/auth/device/status?device_code=${encodeURIComponent(deviceCode)}`);
263
579
  status = await poll.json();
264
580
  } catch {
265
581
  continue; // transient network error during poll — keep polling
@@ -338,7 +654,10 @@ LOG="${auxiloDir}/extract.log"
338
654
  # P1-13 lesson: do NOT set AUXILO_EXTRACTING here — runner.js's own recursion
339
655
  # guard would trip and silently no-op every run. The runner sets it itself
340
656
  # for child processes.
341
- nohup /usr/bin/env node "$RUNNER" --transcript "$transcript_path" >> "$LOG" 2>&1 &
657
+ # LW-17: stdout goes to /dev/null runner.js log() already appends every
658
+ # line to extract.log itself; redirecting stdout there too double-wrote each
659
+ # line and the daily digest double-counted. stderr still captured for crashes.
660
+ nohup /usr/bin/env node "$RUNNER" --transcript "$transcript_path" > /dev/null 2>> "$LOG" &
342
661
 
343
662
  exit 0
344
663
  `;
@@ -385,14 +704,27 @@ function installRunner(homeDir, opts = {}) {
385
704
 
386
705
  // ─── Claude Code hook registration (spec §LW-12 step 4) ─────────────────────
387
706
 
707
+ /** True when an inner hook object is an Auxilo extraction command. */
708
+ function isAuxiloCommandHook(h) {
709
+ return h && typeof h === 'object' && typeof h.command === 'string' &&
710
+ h.command.includes('auxilo-extract');
711
+ }
712
+
388
713
  /**
389
714
  * Patch <home>/.claude/settings.json hooks.SessionEnd[] to contain exactly one
390
- * Auxilo entry: <home>/.auxilo/bin/auxilo-extract.sh.
715
+ * Auxilo entry: <home>/.auxilo/bin/auxilo-extract.sh, in Claude Code's
716
+ * STRUCTURED form ({ hooks: [{ type: 'command', command }] }).
717
+ *
718
+ * LW-17: 0.8.1 appended the command as a bare string — Claude Code silently
719
+ * ignores string entries, so the hook NEVER fired. It also only removed
720
+ * legacy entries that were strings, missing the pre-LW-12 structured entry
721
+ * (~/.claude/hooks/auxilo-extract.sh nested inside a matcher group).
391
722
  *
392
- * Idempotent. Replaces any legacy string entry referencing auxilo-extract.sh
393
- * (e.g. the pre-LW-12 ~/.claude/hooks/auxilo-extract.sh location). Non-Auxilo
394
- * entries including non-string structured entries are preserved untouched.
395
- * Malformed settings.json throws (B15) — never overwritten.
723
+ * Idempotent. Removes every Auxilo reference (bare strings, and command
724
+ * objects inside matcher groups preserving any non-Auxilo commands sharing
725
+ * the group), then appends one canonical structured entry. Non-Auxilo entries
726
+ * are preserved untouched. Malformed settings.json throws (B15) — never
727
+ * overwritten.
396
728
  *
397
729
  * @returns {{ changed: boolean, hookCmd: string, removedLegacy: string[] }}
398
730
  */
@@ -406,21 +738,335 @@ function registerClaudeCodeHook(homeDir) {
406
738
  if (!Array.isArray(settings.hooks.SessionEnd)) settings.hooks.SessionEnd = [];
407
739
 
408
740
  const before = settings.hooks.SessionEnd;
409
- const removedLegacy = before.filter(
410
- (h) => typeof h === 'string' && h.includes('auxilo-extract') && h !== hookCmd
411
- );
412
- const kept = before.filter(
413
- (h) => !(typeof h === 'string' && h.includes('auxilo-extract'))
414
- );
415
-
416
- const alreadyExact = before.includes(hookCmd) && removedLegacy.length === 0;
741
+ const removedLegacy = [];
742
+ const kept = [];
743
+
744
+ for (const entry of before) {
745
+ if (typeof entry === 'string' && entry.includes('auxilo-extract')) {
746
+ removedLegacy.push(entry); // includes 0.8.1's dead bare-string entry
747
+ continue;
748
+ }
749
+ if (entry && typeof entry === 'object' && Array.isArray(entry.hooks)) {
750
+ const auxilo = entry.hooks.filter(isAuxiloCommandHook);
751
+ if (auxilo.length > 0) {
752
+ removedLegacy.push(...auxilo.map((h) => h.command));
753
+ const rest = entry.hooks.filter((h) => !isAuxiloCommandHook(h));
754
+ if (rest.length > 0) kept.push({ ...entry, hooks: rest });
755
+ continue;
756
+ }
757
+ }
758
+ kept.push(entry);
759
+ }
760
+
761
+ const canonical = { hooks: [{ type: 'command', command: hookCmd }] };
762
+
763
+ // Idempotency: unchanged iff the only Auxilo reference found was already
764
+ // the canonical command in a structured single-command entry.
765
+ const alreadyExact =
766
+ removedLegacy.length === 1 && removedLegacy[0] === hookCmd &&
767
+ before.some((e) =>
768
+ e && typeof e === 'object' && Array.isArray(e.hooks) &&
769
+ e.hooks.length === 1 && isAuxiloCommandHook(e.hooks[0]) &&
770
+ e.hooks[0].command === hookCmd && e.hooks[0].type === 'command'
771
+ );
417
772
  if (alreadyExact) {
418
773
  return { changed: false, hookCmd, removedLegacy: [] };
419
774
  }
420
775
 
421
- settings.hooks.SessionEnd = [...kept, hookCmd];
776
+ settings.hooks.SessionEnd = [...kept, canonical];
422
777
  writeJsonAtomic(settingsPath, settings);
423
- return { changed: true, hookCmd, removedLegacy };
778
+ return { changed: true, hookCmd, removedLegacy: removedLegacy.filter((c) => c !== hookCmd) };
779
+ }
780
+
781
+ // ─── UC-1 capture-hook registration ─────────────────────────────────────────
782
+ //
783
+ // Per-client session-end hooks that pipe the client's hook JSON into the
784
+ // shared capture core (<home>/.auxilo/bin/scripts/capture-core.js), which owns
785
+ // ALL guard logic (consent sentinel, AUXILO_EXTRACTING recursion guard,
786
+ // fail-silent). Each client gets its own tiny bash shim so the hook config
787
+ // carries a stable absolute path and the right --source id.
788
+ //
789
+ // Hook config schemas web-verified June 2026 from official docs
790
+ // (BUILD-SPEC-UNIVERSAL-CLIENTS §UC-1). All JSON writers: merge-preserve
791
+ // unrelated content, idempotent, stale 'auxilo-capture' entries replaced not
792
+ // duplicated, malformed JSON throws (B15 — never overwritten).
793
+
794
+ /** Absolute path of the per-source capture shim. */
795
+ function captureShimPath(homeDir, sourceId) {
796
+ return path.join(binRootFor(homeDir), `auxilo-capture-${sourceId}.sh`);
797
+ }
798
+
799
+ /**
800
+ * Generate the capture shim body. stdin (the client's hook JSON) passes
801
+ * straight through `exec` to capture-core.js; the PATH export covers GUI-
802
+ * launched clients whose hooks run without a login shell (no node on PATH).
803
+ */
804
+ function renderCaptureShim(homeDir, sourceId) {
805
+ const corePath = path.join(binRootFor(homeDir), 'scripts', 'capture-core.js');
806
+ return `#!/bin/bash
807
+ # Auxilo capture shim (${sourceId}) — generated by \`auxilo setup\` (UC-1).
808
+ export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
809
+ exec /usr/bin/env node "${corePath}" --source ${sourceId}
810
+ `;
811
+ }
812
+
813
+ /** Write the shim (0755) if missing or stale. Returns true when (re)written. */
814
+ function writeCaptureShim(homeDir, sourceId) {
815
+ const shimPath = captureShimPath(homeDir, sourceId);
816
+ const body = renderCaptureShim(homeDir, sourceId);
817
+ if (fs.existsSync(shimPath) && fs.readFileSync(shimPath, 'utf-8') === body) {
818
+ return false;
819
+ }
820
+ fs.mkdirSync(path.dirname(shimPath), { recursive: true });
821
+ fs.writeFileSync(shimPath, body);
822
+ fs.chmodSync(shimPath, 0o755);
823
+ return true;
824
+ }
825
+
826
+ /** True when an entry's command string references an Auxilo capture shim. */
827
+ function isAuxiloCaptureCommand(cmd) {
828
+ return typeof cmd === 'string' && cmd.includes('auxilo-capture');
829
+ }
830
+
831
+ /**
832
+ * Flat hook arrays ({command} entries — Cursor, Windsurf): strip stale
833
+ * auxilo-capture entries, append the canonical one.
834
+ */
835
+ function patchFlatHookArray(arr, shimPath) {
836
+ const kept = (Array.isArray(arr) ? arr : [])
837
+ .filter((e) => !(e && typeof e === 'object' && isAuxiloCaptureCommand(e.command)));
838
+ return [...kept, { command: shimPath }];
839
+ }
840
+
841
+ /**
842
+ * Matcher-group hook arrays ([{hooks:[{type,command,...}]}] — Gemini CLI,
843
+ * Factory, Codex): strip stale auxilo-capture inner hooks (preserving any
844
+ * non-Auxilo commands sharing a group, dropping groups left empty), then
845
+ * append one canonical group containing `entry`.
846
+ */
847
+ function patchGroupHookArray(arr, entry) {
848
+ const kept = [];
849
+ for (const group of Array.isArray(arr) ? arr : []) {
850
+ if (group && typeof group === 'object' && Array.isArray(group.hooks)) {
851
+ const rest = group.hooks.filter((h) => !(h && isAuxiloCaptureCommand(h.command)));
852
+ if (rest.length === 0 && rest.length !== group.hooks.length) continue; // group was all ours
853
+ kept.push(rest.length === group.hooks.length ? group : { ...group, hooks: rest });
854
+ continue;
855
+ }
856
+ kept.push(group);
857
+ }
858
+ return [...kept, { hooks: [entry] }];
859
+ }
860
+
861
+ /**
862
+ * Patch a JSON hook config in place: read (B15: malformed throws), mutate via
863
+ * `mutate(config)`, write atomically ONLY when the result differs (idempotent
864
+ * re-runs leave the file byte-identical).
865
+ * @returns {boolean} changed
866
+ */
867
+ function patchJsonHookConfig(configPath, mutate) {
868
+ const config = readClientConfig(configPath);
869
+ const before = JSON.stringify(config);
870
+ mutate(config);
871
+ if (JSON.stringify(config) === before && fs.existsSync(configPath)) return false;
872
+ writeJsonAtomic(configPath, config);
873
+ return true;
874
+ }
875
+
876
+ /**
877
+ * Per-client capture-hook config writers. Each receives (client, homeDir,
878
+ * shimPath) and returns { changed, notes? } for the hook CONFIG only (the
879
+ * shim is handled by registerCaptureHook). Keyed by registry id.
880
+ */
881
+ const CAPTURE_WRITERS = Object.freeze({
882
+ // ~/.cursor/hooks.json — {"version":1,"hooks":{"sessionEnd":[{"command":...}]}}
883
+ // version:1 is REQUIRED by Cursor; other events/entries preserved.
884
+ 'cursor': (client, homeDir, shimPath) => {
885
+ const changed = patchJsonHookConfig(client.captureConfigPath, (config) => {
886
+ if (config.version === undefined) config.version = 1;
887
+ if (!config.hooks || typeof config.hooks !== 'object') config.hooks = {};
888
+ config.hooks.sessionEnd = patchFlatHookArray(config.hooks.sessionEnd, shimPath);
889
+ });
890
+ return { changed };
891
+ },
892
+
893
+ // ~/.gemini/settings.json — hooks.SessionEnd: array of matcher groups, each
894
+ // {hooks:[{name,type,command,timeout}]}. Other settings keys (mcpServers,
895
+ // theme, ...) and other hook events preserved.
896
+ 'gemini-cli': (client, homeDir, shimPath) => {
897
+ const changed = patchJsonHookConfig(client.captureConfigPath, (config) => {
898
+ if (!config.hooks || typeof config.hooks !== 'object') config.hooks = {};
899
+ config.hooks.SessionEnd = patchGroupHookArray(config.hooks.SessionEnd, {
900
+ name: 'auxilo-capture',
901
+ type: 'command',
902
+ command: shimPath,
903
+ timeout: 5000,
904
+ });
905
+ });
906
+ return { changed };
907
+ },
908
+
909
+ // ~/.gemini/config/hooks.json — top level keyed by hook-GROUP name (NOT a
910
+ // "hooks" key). We own ONLY the "auxilo-capture" group key; everything else
911
+ // is user content and is never touched.
912
+ 'antigravity': (client, homeDir, shimPath) => {
913
+ const changed = patchJsonHookConfig(client.captureConfigPath, (config) => {
914
+ config['auxilo-capture'] = {
915
+ Stop: [{ hooks: [{ type: 'command', command: shimPath, timeout: 15 }] }],
916
+ };
917
+ });
918
+ return { changed, notes: 'schema low-confidence — verify live' };
919
+ },
920
+
921
+ // ~/.codeium/windsurf/hooks.json — flat {command} entries, no version field.
922
+ 'windsurf': (client, homeDir, shimPath) => {
923
+ const changed = patchJsonHookConfig(client.captureConfigPath, (config) => {
924
+ if (!config.hooks || typeof config.hooks !== 'object') config.hooks = {};
925
+ config.hooks.post_cascade_response_with_transcript =
926
+ patchFlatHookArray(config.hooks.post_cascade_response_with_transcript, shimPath);
927
+ });
928
+ return { changed, notes: 'fires per-response; dedupe relies on runner ledger + server idempotency' };
929
+ },
930
+
931
+ // ~/.copilot/hooks/auxilo.json — drop-in file we own entirely (the hooks
932
+ // dir is shared with VS Code Copilot user-level hooks).
933
+ 'copilot-cli': (client, homeDir, shimPath) => {
934
+ const changed = patchJsonHookConfig(client.captureConfigPath, (config) => {
935
+ for (const key of Object.keys(config)) delete config[key]; // we own the whole file
936
+ config.hooks = { Stop: [{ type: 'command', command: shimPath }] };
937
+ });
938
+ return { changed };
939
+ },
940
+
941
+ // ~/.factory/hooks.json (NOT settings.json) — matcher-group SessionEnd.
942
+ 'factory': (client, homeDir, shimPath) => {
943
+ const changed = patchJsonHookConfig(client.captureConfigPath, (config) => {
944
+ if (!config.hooks || typeof config.hooks !== 'object') config.hooks = {};
945
+ config.hooks.SessionEnd = patchGroupHookArray(config.hooks.SessionEnd,
946
+ { type: 'command', command: shimPath });
947
+ });
948
+ return { changed };
949
+ },
950
+
951
+ // ~/.codex/hooks.json — matcher-group Stop with timeout. GOTCHA: when
952
+ // ~/.codex/config.toml already defines a [hooks...] table, writing
953
+ // hooks.json too would double-layer (Codex warns) — skip with a note.
954
+ 'codex': (client, homeDir, shimPath) => {
955
+ try {
956
+ const toml = fs.readFileSync(client.configPath, 'utf-8');
957
+ if (toml.includes('[hooks')) {
958
+ return {
959
+ changed: false,
960
+ notes: 'config.toml already defines hooks — add manually to avoid double-layer warning',
961
+ };
962
+ }
963
+ } catch { /* no config.toml — hooks.json is safe */ }
964
+ const changed = patchJsonHookConfig(client.captureConfigPath, (config) => {
965
+ if (!config.hooks || typeof config.hooks !== 'object') config.hooks = {};
966
+ config.hooks.Stop = patchGroupHookArray(config.hooks.Stop,
967
+ { type: 'command', command: shimPath, timeout: 30 });
968
+ });
969
+ return { changed, notes: 'requires one-time interactive /hooks trust approval in Codex' };
970
+ },
971
+ });
972
+
973
+ /**
974
+ * Register the UC-1 capture hook for one client: write the per-source shim
975
+ * (0755) and patch the client's hook config. Idempotent — identical re-runs
976
+ * report changed:false and leave both files byte-identical.
977
+ *
978
+ * @param {object} client Registry entry with captureHook:true.
979
+ * @param {string} homeDir
980
+ * @returns {{ changed: boolean, hookPath: string, configPath: string, notes?: string }}
981
+ * @throws on malformed existing hook config JSON (B15 — caller skips loudly)
982
+ */
983
+ function registerCaptureHook(client, homeDir) {
984
+ if (!homeDir) throw new Error('registerCaptureHook: homeDir is required');
985
+ if (!client || !client.captureHook || !client.captureConfigPath) {
986
+ throw new Error(`registerCaptureHook: client ${client && client.id} has no capture-hook support`);
987
+ }
988
+ const writer = CAPTURE_WRITERS[client.id];
989
+ if (!writer) {
990
+ throw new Error(`registerCaptureHook: no capture writer for client "${client.id}"`);
991
+ }
992
+ const sourceId = client.sourceId || client.id;
993
+ const shimPath = captureShimPath(homeDir, sourceId);
994
+
995
+ // Codex skip-gotcha must not leave a dangling shim behind — let the writer
996
+ // decide first, then write the shim only when the hook is actually wired.
997
+ const result = writer(client, homeDir, shimPath);
998
+ let shimChanged = false;
999
+ if (!(client.id === 'codex' && result.changed === false &&
1000
+ result.notes && result.notes.includes('config.toml already defines hooks'))) {
1001
+ shimChanged = writeCaptureShim(homeDir, sourceId);
1002
+ }
1003
+
1004
+ return {
1005
+ changed: Boolean(result.changed || shimChanged),
1006
+ hookPath: shimPath,
1007
+ configPath: client.captureConfigPath,
1008
+ ...(result.notes ? { notes: result.notes } : {}),
1009
+ };
1010
+ }
1011
+
1012
+ /**
1013
+ * Read-only probe for `auxilo status`: a client's capture hook counts as
1014
+ * registered when the shim file exists AND the hook config references it.
1015
+ * Never throws (missing/malformed config reads as "not registered").
1016
+ */
1017
+ function captureHookRegistered(client, homeDir) {
1018
+ if (!client || !client.captureHook || !client.captureConfigPath) return false;
1019
+ const shimPath = captureShimPath(homeDir, client.sourceId || client.id);
1020
+ try {
1021
+ return fs.existsSync(shimPath) &&
1022
+ fs.readFileSync(client.captureConfigPath, 'utf-8').includes(shimPath);
1023
+ } catch {
1024
+ return false;
1025
+ }
1026
+ }
1027
+
1028
+ /**
1029
+ * Register capture hooks for every captureHook-capable client in `clients`.
1030
+ * Ensures the capture core is installed under <home>/.auxilo/bin first (it
1031
+ * ships via RUNNER_STACK; installRunner normally ran already — this is the
1032
+ * belt-and-braces copy for partial installs). Per-client try/catch: one
1033
+ * malformed config skips loudly (B15) without stopping the loop.
1034
+ *
1035
+ * @param {string} homeDir
1036
+ * @param {Array<object>} clients Registry entries (e.g. the user's chosen set).
1037
+ * @param {object} [opts] { packageRoot }
1038
+ * @returns {Array<{ id, name, event, changed?, hookPath?, configPath?, notes?, error? }>}
1039
+ */
1040
+ function installCaptureHooks(homeDir, clients, opts = {}) {
1041
+ if (!homeDir) throw new Error('installCaptureHooks: homeDir is required');
1042
+ const packageRoot = opts.packageRoot || PACKAGE_ROOT;
1043
+ const results = [];
1044
+
1045
+ const targets = (clients || []).filter((c) => c && c.captureHook);
1046
+ if (targets.length === 0) return results;
1047
+
1048
+ // Ensure the shared capture core exists where the shims point.
1049
+ const corePath = path.join(binRootFor(homeDir), 'scripts', 'capture-core.js');
1050
+ if (!fs.existsSync(corePath)) {
1051
+ const src = path.join(packageRoot, 'scripts', 'capture-core.js');
1052
+ if (!fs.existsSync(src)) {
1053
+ throw new Error(`installCaptureHooks: missing package file ${src} — reinstall auxilo-mcp`);
1054
+ }
1055
+ fs.mkdirSync(path.dirname(corePath), { recursive: true });
1056
+ fs.copyFileSync(src, corePath);
1057
+ fs.chmodSync(corePath, 0o755);
1058
+ }
1059
+
1060
+ for (const client of targets) {
1061
+ try {
1062
+ const r = registerCaptureHook(client, homeDir);
1063
+ results.push({ id: client.id, name: client.name, event: client.captureEvent, ...r });
1064
+ } catch (err) {
1065
+ // B15 skip-loudly: collect the error, keep going for the other clients.
1066
+ results.push({ id: client.id, name: client.name, event: client.captureEvent, error: err.message });
1067
+ }
1068
+ }
1069
+ return results;
424
1070
  }
425
1071
 
426
1072
  // ─── Consent (spec §LW-12 step 5; server.js POST /extract/consent) ──────────
@@ -499,15 +1145,16 @@ async function getStatus(homeDir, opts = {}) {
499
1145
 
500
1146
  // 1. Clients: detected + whether MCP registration is present
501
1147
  const clients = detectClients(homeDir, opts).map((c) => {
502
- let registered = null; // null = not applicable (no MCP config)
503
- if (c.mcp) {
504
- registered = false;
505
- try {
506
- const config = JSON.parse(fs.readFileSync(c.configPath, 'utf-8'));
507
- registered = Boolean(config.mcpServers && config.mcpServers.auxilo);
508
- } catch { /* missing or malformed config → not registered */ }
1148
+ // null = not applicable (no MCP config); per-format probe otherwise (UC-0).
1149
+ const registered = c.mcp ? mcpRegistrationPresent(c) : null;
1150
+ const base = { id: c.id, name: c.name, mcp: c.mcp, registered };
1151
+ // UC-1: capture-hook probe (shim exists AND hook config references it).
1152
+ if (c.captureHook) {
1153
+ base.captureHook = true;
1154
+ base.captureEvent = c.captureEvent;
1155
+ base.captureRegistered = captureHookRegistered(c, homeDir);
509
1156
  }
510
- return { id: c.id, name: c.name, mcp: c.mcp, registered };
1157
+ return base;
511
1158
  });
512
1159
 
513
1160
  // 2. Auth state
@@ -541,8 +1188,12 @@ async function getStatus(homeDir, opts = {}) {
541
1188
  const settings = JSON.parse(
542
1189
  fs.readFileSync(path.join(homeDir, '.claude', 'settings.json'), 'utf-8')
543
1190
  );
1191
+ // LW-17: only STRUCTURED entries count — Claude Code silently ignores
1192
+ // bare-string entries (the 0.8.1 dead-hook bug), so reporting one as
1193
+ // "registered" would mask exactly the failure this status exists to catch.
544
1194
  hookRegistered = Array.isArray(settings.hooks && settings.hooks.SessionEnd) &&
545
- settings.hooks.SessionEnd.some((h) => typeof h === 'string' && h.includes('auxilo-extract'));
1195
+ settings.hooks.SessionEnd.some((h) =>
1196
+ h && typeof h === 'object' && Array.isArray(h.hooks) && h.hooks.some(isAuxiloCommandHook));
546
1197
  } catch { /* no settings */ }
547
1198
 
548
1199
  // 5. Last extraction + pending queue (ledger conventions from scripts/runner.js)
@@ -572,6 +1223,75 @@ async function getStatus(homeDir, opts = {}) {
572
1223
  };
573
1224
  }
574
1225
 
1226
+ // ─── Rules-file snippet (UC-0 "agent-prompted contribution", R-class) ────────
1227
+
1228
+ /** Fenced-block markers: everything between them is ours to replace. */
1229
+ const RULES_MARKER_BEGIN = '<!-- auxilo:begin -->';
1230
+ const RULES_MARKER_END = '<!-- auxilo:end -->';
1231
+
1232
+ /**
1233
+ * The instruction written into agents' GLOBAL rules files (R-class fallback,
1234
+ * BUILD-SPEC-UNIVERSAL-CLIENTS §3). Mirrors the auxilo_contribute contract:
1235
+ * quality gate >= 14/20, no credentials/PII, and the R-class recursion guard
1236
+ * (never contribute from an Auxilo-extraction session — §6).
1237
+ */
1238
+ const RULES_SNIPPET = `## Auxilo: contribute what you learn
1239
+
1240
+ At the end of a completed task, if you solved a non-trivial problem — one that
1241
+ required debugging, multiple attempts, a workaround, or undocumented behavior —
1242
+ submit the learning to the Auxilo marketplace by calling the
1243
+ \`auxilo_contribute\` MCP tool. Only submit when the learning self-assesses at
1244
+ 14/20 or higher across specificity, actionability, novelty, and completeness.
1245
+ Never include credentials, API keys, or PII in a submission, and never submit
1246
+ from an Auxilo-extraction session.`;
1247
+
1248
+ /**
1249
+ * Write the Auxilo contribution snippet into one or more GLOBAL rules files
1250
+ * (UC-0 scope: client-global files only — e.g. ~/.gemini/GEMINI.md — never
1251
+ * project-level AGENTS.md). Idempotent replace-between-markers: an existing
1252
+ * fenced block is replaced in place; otherwise the block is appended,
1253
+ * preserving all surrounding content. Writes are tmp + rename.
1254
+ *
1255
+ * @param {string} homeDir Explicit home (consistency guard — paths must live
1256
+ * under it; tests run on fixture homes only).
1257
+ * @param {object} opts
1258
+ * @param {string[]} opts.targets Absolute rules-file paths to write.
1259
+ * @returns {Array<{ path: string, changed: boolean }>} one result per target
1260
+ */
1261
+ function installRulesSnippet(homeDir, opts = {}) {
1262
+ if (!homeDir) throw new Error('installRulesSnippet: homeDir is required');
1263
+ const targets = opts.targets || [];
1264
+ const block = `${RULES_MARKER_BEGIN}\n${RULES_SNIPPET}\n${RULES_MARKER_END}`;
1265
+ const results = [];
1266
+
1267
+ for (const target of targets) {
1268
+ const raw = fs.existsSync(target) ? fs.readFileSync(target, 'utf-8') : '';
1269
+ const begin = raw.indexOf(RULES_MARKER_BEGIN);
1270
+ const end = raw.indexOf(RULES_MARKER_END);
1271
+
1272
+ let next;
1273
+ if (begin !== -1 && end !== -1 && end > begin) {
1274
+ // Replace ONLY between (and including) the markers; surrounding content untouched.
1275
+ next = raw.slice(0, begin) + block + raw.slice(end + RULES_MARKER_END.length);
1276
+ } else if (raw === '') {
1277
+ next = `${block}\n`;
1278
+ } else {
1279
+ next = raw + (raw.endsWith('\n') ? '' : '\n') + `\n${block}\n`;
1280
+ }
1281
+
1282
+ if (next === raw) {
1283
+ results.push({ path: target, changed: false });
1284
+ continue;
1285
+ }
1286
+ const tmp = `${target}.tmp`;
1287
+ fs.mkdirSync(path.dirname(target), { recursive: true });
1288
+ fs.writeFileSync(tmp, next);
1289
+ fs.renameSync(tmp, target);
1290
+ results.push({ path: target, changed: true });
1291
+ }
1292
+ return results;
1293
+ }
1294
+
575
1295
  // ─── Exports ────────────────────────────────────────────────────────────────
576
1296
 
577
1297
  module.exports = {
@@ -582,7 +1302,12 @@ module.exports = {
582
1302
  clientRegistry,
583
1303
  detectClients,
584
1304
  registerMcp,
1305
+ mcpRegistrationPresent,
585
1306
  readClientConfig,
1307
+ RULES_MARKER_BEGIN,
1308
+ RULES_MARKER_END,
1309
+ RULES_SNIPPET,
1310
+ installRulesSnippet,
586
1311
  credentialsPath,
587
1312
  readCredentials,
588
1313
  writeCredentials,
@@ -592,6 +1317,11 @@ module.exports = {
592
1317
  renderHookScript,
593
1318
  installRunner,
594
1319
  registerClaudeCodeHook,
1320
+ captureShimPath,
1321
+ renderCaptureShim,
1322
+ registerCaptureHook,
1323
+ captureHookRegistered,
1324
+ installCaptureHooks,
595
1325
  recordConsent,
596
1326
  sentinelPath,
597
1327
  sentinelPresent,