@cleocode/adapters 2026.10.3 → 2026.10.5-rc.ci.672

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.
Files changed (48) hide show
  1. package/dist/heavy-command-hook.d.ts +12 -0
  2. package/dist/heavy-command-hook.d.ts.map +1 -0
  3. package/dist/index.d.ts +2 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +2095 -1269
  6. package/dist/index.js.map +3 -3
  7. package/dist/providers/claude-code/adapter.d.ts.map +1 -1
  8. package/dist/providers/claude-code/hooks.d.ts +43 -17
  9. package/dist/providers/claude-code/hooks.d.ts.map +1 -1
  10. package/dist/providers/claude-code/install.d.ts +42 -14
  11. package/dist/providers/claude-code/install.d.ts.map +1 -1
  12. package/dist/providers/claude-code/paths.d.ts +77 -0
  13. package/dist/providers/claude-code/paths.d.ts.map +1 -1
  14. package/dist/providers/codex/install.d.ts.map +1 -1
  15. package/dist/providers/cursor/install.d.ts.map +1 -1
  16. package/dist/providers/gemini-cli/install.d.ts.map +1 -1
  17. package/dist/providers/kimi/install.d.ts.map +1 -1
  18. package/dist/providers/openai-sdk/install.d.ts.map +1 -1
  19. package/dist/providers/opencode/install.d.ts.map +1 -1
  20. package/dist/providers/pi/install.d.ts.map +1 -1
  21. package/dist/providers/shared/heavy-command-hook-delivery.d.ts +97 -0
  22. package/dist/providers/shared/heavy-command-hook-delivery.d.ts.map +1 -0
  23. package/dist/providers/shared/heavy-command-hook-install.d.ts +153 -7
  24. package/dist/providers/shared/heavy-command-hook-install.d.ts.map +1 -1
  25. package/dist/providers/shared/project-instruction-file.d.ts +30 -0
  26. package/dist/providers/shared/project-instruction-file.d.ts.map +1 -0
  27. package/package.json +9 -5
  28. package/src/__tests__/claude-code-adapter.test.ts +11 -6
  29. package/src/heavy-command-hook.ts +21 -0
  30. package/src/index.ts +12 -0
  31. package/src/providers/claude-code/__tests__/hooks-install.test.ts +20 -17
  32. package/src/providers/claude-code/__tests__/no-user-global-write.test.ts +293 -0
  33. package/src/providers/claude-code/adapter.ts +3 -1
  34. package/src/providers/claude-code/hooks.ts +138 -51
  35. package/src/providers/claude-code/install.ts +123 -52
  36. package/src/providers/claude-code/paths.ts +159 -1
  37. package/src/providers/codex/install.ts +7 -6
  38. package/src/providers/cursor/install.ts +26 -12
  39. package/src/providers/gemini-cli/install.ts +4 -2
  40. package/src/providers/kimi/install.ts +4 -2
  41. package/src/providers/openai-sdk/install.ts +5 -2
  42. package/src/providers/opencode/install.ts +4 -2
  43. package/src/providers/pi/install.ts +4 -2
  44. package/src/providers/shared/__tests__/heavy-command-hook-delivery.test.ts +580 -0
  45. package/src/providers/shared/__tests__/heavy-command-hook-install.test.ts +20 -0
  46. package/src/providers/shared/heavy-command-hook-delivery.ts +620 -0
  47. package/src/providers/shared/heavy-command-hook-install.ts +401 -32
  48. package/src/providers/shared/project-instruction-file.ts +47 -0
@@ -0,0 +1,293 @@
1
+ /**
2
+ * The Claude Code adapter never writes the user-global Claude config (T13227).
3
+ *
4
+ * `ClaudeCodeAdapter.initialize` (native hooks), the install provider (plugin
5
+ * + PreCompact hook templates) and `dispose` (hook removal) all write the
6
+ * PROJECT's `.claude/` only. Every case runs in a sandbox: `HOME`,
7
+ * `CLAUDE_HOME` and `CLAUDE_SETTINGS` point into a throwaway dir, and the
8
+ * user-global dir there is snapshotted (bytes + mtime of every file) before
9
+ * and compared after. The real `~/.claude` is never read.
10
+ *
11
+ * @task T13227
12
+ */
13
+
14
+ import { execFileSync } from 'node:child_process';
15
+ import {
16
+ existsSync,
17
+ mkdirSync,
18
+ mkdtempSync,
19
+ readdirSync,
20
+ readFileSync,
21
+ rmSync,
22
+ statSync,
23
+ writeFileSync,
24
+ } from 'node:fs';
25
+ import { tmpdir } from 'node:os';
26
+ import { join } from 'node:path';
27
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
28
+
29
+ // initialize() asks the `cleo` binary for the spawn mode; never run a real one.
30
+ vi.mock('node:child_process', async (importOriginal) => {
31
+ const actual = await importOriginal<typeof import('node:child_process')>();
32
+ return {
33
+ ...actual,
34
+ execFile: (...args: unknown[]) => {
35
+ const callback = args[args.length - 1];
36
+ if (typeof callback === 'function') callback(new Error('cleo not available in test'));
37
+ },
38
+ };
39
+ });
40
+
41
+ import { ClaudeCodeAdapter } from '../adapter.js';
42
+ import { ClaudeCodeHookProvider, NATIVE_STOP_HOOK_COMMAND } from '../hooks.js';
43
+ import { ClaudeCodeInstallProvider } from '../install.js';
44
+
45
+ /** Every file under `dir`, as path → bytes + mtime. */
46
+ function snapshot(dir: string): Record<string, string> {
47
+ const out: Record<string, string> = {};
48
+ if (!existsSync(dir)) return out;
49
+ for (const name of readdirSync(dir, { recursive: true, encoding: 'utf-8' })) {
50
+ const path = join(dir, name);
51
+ const st = statSync(path);
52
+ out[name] = st.isDirectory()
53
+ ? `dir@${st.mtimeMs}`
54
+ : `${readFileSync(path, 'base64')}@${st.mtimeMs}`;
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /** What a user's own global Claude config holds. */
60
+ const USER_SETTINGS = JSON.stringify(
61
+ {
62
+ model: 'opus',
63
+ enabledPlugins: { 'other@market': true },
64
+ hooks: { Stop: [{ matcher: '', hooks: [{ type: 'command', command: 'my-stop.sh' }] }] },
65
+ },
66
+ null,
67
+ 2,
68
+ );
69
+
70
+ const SAVED = ['HOME', 'USERPROFILE', 'CLAUDE_HOME', 'CLAUDE_SETTINGS'] as const;
71
+ let saved: Record<string, string | undefined>;
72
+ let root: string;
73
+ let home: string;
74
+ let claudeHome: string;
75
+ let projectDir: string;
76
+
77
+ beforeEach(() => {
78
+ saved = Object.fromEntries(SAVED.map((k) => [k, process.env[k]]));
79
+ root = mkdtempSync(join(tmpdir(), 'cleo-claude-global-'));
80
+ home = join(root, 'home');
81
+ claudeHome = join(home, '.claude');
82
+ projectDir = join(root, 'project');
83
+ mkdirSync(join(claudeHome, 'hooks'), { recursive: true });
84
+ mkdirSync(projectDir, { recursive: true });
85
+ writeFileSync(join(claudeHome, 'settings.json'), USER_SETTINGS);
86
+ writeFileSync(join(claudeHome, 'hooks', 'mine.sh'), '#!/bin/sh\n');
87
+ execFileSync('git', ['init', '-q', projectDir]);
88
+ process.env.HOME = home;
89
+ process.env.USERPROFILE = home;
90
+ process.env.CLAUDE_HOME = claudeHome;
91
+ delete process.env.CLAUDE_SETTINGS;
92
+ mkdirSync(join(home, '.cleo', 'templates'), { recursive: true });
93
+ writeFileSync(join(home, '.cleo', 'templates', 'CLEO-INJECTION.md'), 'Fixture protocol.');
94
+ });
95
+
96
+ afterEach(() => {
97
+ for (const k of SAVED) {
98
+ const v = saved[k];
99
+ if (v === undefined) delete process.env[k];
100
+ else process.env[k] = v;
101
+ }
102
+ rmSync(root, { recursive: true, force: true });
103
+ vi.restoreAllMocks();
104
+ });
105
+
106
+ /** Run every adapter writer for `dir`: initialize, install, dispose. */
107
+ async function runAllWriters(dir: string): Promise<{
108
+ adapter: ClaudeCodeAdapter;
109
+ install: Awaited<ReturnType<ClaudeCodeInstallProvider['install']>>;
110
+ }> {
111
+ const adapter = new ClaudeCodeAdapter();
112
+ await adapter.initialize(dir);
113
+ const install = await new ClaudeCodeInstallProvider().install({ projectDir: dir });
114
+ await adapter.dispose();
115
+ return { adapter, install };
116
+ }
117
+
118
+ describe('Claude Code adapter never writes the user-global Claude config (T13227)', () => {
119
+ it('initialize + install + dispose leave CLAUDE_HOME byte-identical and write the project', async () => {
120
+ const before = snapshot(claudeHome);
121
+ const adapter = new ClaudeCodeAdapter();
122
+
123
+ await adapter.initialize(projectDir);
124
+ const local = join(projectDir, '.claude', 'settings.local.json');
125
+ const afterInit = JSON.parse(readFileSync(local, 'utf-8')) as {
126
+ hooks: Record<string, Array<{ hooks: Array<{ command: string }> }>>;
127
+ };
128
+ expect(afterInit.hooks.Stop?.[0]?.hooks[0]?.command).toBe(NATIVE_STOP_HOOK_COMMAND);
129
+ expect(afterInit.hooks.PostToolUse?.[0]?.hooks).toHaveLength(2);
130
+
131
+ const result = await new ClaudeCodeInstallProvider().install({ projectDir });
132
+ expect(result.success).toBe(true);
133
+ const afterInstall = JSON.parse(readFileSync(local, 'utf-8')) as {
134
+ enabledPlugins: Record<string, boolean>;
135
+ hooks: Record<string, unknown[]>;
136
+ };
137
+ expect(afterInstall.enabledPlugins['cleo@cleocode']).toBe(true);
138
+ expect(afterInstall.hooks.PreCompact).toHaveLength(1);
139
+ expect(existsSync(join(projectDir, '.claude', 'hooks', 'precompact-safestop.sh'))).toBe(true);
140
+
141
+ await adapter.dispose();
142
+
143
+ expect(snapshot(claudeHome)).toEqual(before);
144
+ expect(readFileSync(join(claudeHome, 'settings.json'), 'utf-8')).toBe(USER_SETTINGS);
145
+ // The per-machine files are kept out of git.
146
+ const exclude = readFileSync(join(projectDir, '.git', 'info', 'exclude'), 'utf-8');
147
+ expect(exclude).toContain('/.claude/settings.local.json');
148
+ expect(exclude).toContain('/.claude/hooks/precompact-safestop.sh');
149
+ });
150
+
151
+ it('a project that is the home directory is refused: nothing written, steps skipped', async () => {
152
+ const before = snapshot(claudeHome);
153
+ const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
154
+
155
+ const hooks = new ClaudeCodeHookProvider();
156
+ await hooks.registerNativeHooks(home);
157
+ const { install } = await runAllWriters(home);
158
+
159
+ expect(snapshot(claudeHome)).toEqual(before);
160
+ expect(hooks.getSettingsError()).toMatch(
161
+ /refusing to write .*the project is the home directory/,
162
+ );
163
+ expect(install.details?.plugin).toBe('skipped');
164
+ expect(install.details?.hookTemplates).toBe('skipped');
165
+ // ~/CLAUDE.md is loaded into every session under $HOME (#1898 review MED).
166
+ expect(install.details?.instructionFile).toBe('skipped');
167
+ expect(existsSync(join(home, 'CLAUDE.md'))).toBe(false);
168
+ await expect(new ClaudeCodeInstallProvider().ensureInstructionReferences(home)).rejects.toThrow(
169
+ /the project is the home directory/,
170
+ );
171
+ expect(existsSync(join(home, 'CLAUDE.md'))).toBe(false);
172
+ expect(stderr).toHaveBeenCalled();
173
+ });
174
+
175
+ it('the home directory is refused even when CLAUDE_HOME points elsewhere', async () => {
176
+ process.env.CLAUDE_HOME = join(root, 'elsewhere', '.claude');
177
+ vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
178
+ const before = snapshot(join(home, '.claude'));
179
+
180
+ const { install } = await runAllWriters(home);
181
+
182
+ expect(snapshot(join(home, '.claude'))).toEqual(before);
183
+ expect(install.details?.instructionFile).toBe('skipped');
184
+ expect(existsSync(join(home, 'CLAUDE.md'))).toBe(false);
185
+ expect(install.details?.plugin).toBe('skipped');
186
+ expect(install.details?.commands).toBe('skipped');
187
+ });
188
+
189
+ it('CLAUDE_HOME pointing at the project .claude is refused: nothing written', async () => {
190
+ const projectClaude = join(projectDir, '.claude');
191
+ mkdirSync(projectClaude, { recursive: true });
192
+ writeFileSync(join(projectClaude, 'settings.json'), USER_SETTINGS);
193
+ process.env.CLAUDE_HOME = projectClaude;
194
+ vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
195
+ const before = snapshot(projectClaude);
196
+
197
+ const { install } = await runAllWriters(projectDir);
198
+
199
+ expect(snapshot(projectClaude)).toEqual(before);
200
+ expect(install.details?.plugin).toBe('skipped');
201
+ expect(install.details?.hookTemplates).toBe('skipped');
202
+ });
203
+
204
+ it('the Claude dir itself as the project is refused, CLAUDE.md included', async () => {
205
+ vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
206
+ const before = snapshot(claudeHome);
207
+
208
+ const { install } = await runAllWriters(claudeHome);
209
+
210
+ expect(snapshot(claudeHome)).toEqual(before);
211
+ expect(install.details?.instructionFile).toBe('skipped');
212
+ expect(install.details?.commands).toBe('skipped');
213
+ await expect(
214
+ new ClaudeCodeInstallProvider().ensureInstructionReferences(claudeHome),
215
+ ).rejects.toThrow(/inside the user-global Claude config dir/);
216
+ expect(existsSync(join(claudeHome, 'CLAUDE.md'))).toBe(false);
217
+ });
218
+
219
+ it('a differently cased spelling of the Claude dir is refused on a case-insensitive volume', async (ctx) => {
220
+ const variant = join(home, '.CLAUDE');
221
+ // Only meaningful where the volume folds case (default APFS / NTFS).
222
+ if (!existsSync(variant)) ctx.skip();
223
+ vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
224
+ const before = snapshot(claudeHome);
225
+
226
+ const hooks = new ClaudeCodeHookProvider();
227
+ await hooks.registerNativeHooks(variant);
228
+ const { install } = await runAllWriters(variant);
229
+
230
+ expect(snapshot(claudeHome)).toEqual(before);
231
+ expect(hooks.getSettingsError()).toMatch(/inside the user-global Claude config dir/);
232
+ expect(install.details?.instructionFile).toBe('skipped');
233
+ expect(install.details?.plugin).toBe('skipped');
234
+ expect(install.details?.hookTemplates).toBe('skipped');
235
+ expect(install.details?.commands).toBe('skipped');
236
+ });
237
+
238
+ it('a differently cased spelling of the home directory is refused on a case-insensitive volume', async (ctx) => {
239
+ const variant = home.replace(/home$/, 'HOME');
240
+ if (!existsSync(variant)) ctx.skip();
241
+ process.env.CLAUDE_HOME = join(root, 'elsewhere', '.claude');
242
+ vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
243
+ const before = snapshot(claudeHome);
244
+
245
+ const { install } = await runAllWriters(variant);
246
+
247
+ expect(snapshot(claudeHome)).toEqual(before);
248
+ expect(install.details?.plugin).toBe('skipped');
249
+ });
250
+
251
+ it('CLAUDE_SETTINGS naming the project settings.local.json is refused: nothing written', async () => {
252
+ const local = join(projectDir, '.claude', 'settings.local.json');
253
+ process.env.CLAUDE_SETTINGS = local;
254
+ vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
255
+ const before = snapshot(claudeHome);
256
+
257
+ const { install } = await runAllWriters(projectDir);
258
+
259
+ expect(existsSync(local)).toBe(false);
260
+ expect(snapshot(claudeHome)).toEqual(before);
261
+ expect(install.details?.plugin).toBe('skipped');
262
+ });
263
+
264
+ it('dispose removes only the native hook objects; the heavy-command and user hooks stay', async () => {
265
+ const local = join(projectDir, '.claude', 'settings.local.json');
266
+ mkdirSync(join(projectDir, '.claude'), { recursive: true });
267
+ const heavy = {
268
+ matcher: 'Bash',
269
+ hooks: [{ type: 'command', command: 'cleo hook heavy-command # cleo-hook', timeout: 20 }],
270
+ };
271
+ writeFileSync(
272
+ local,
273
+ JSON.stringify({
274
+ hooks: {
275
+ PreToolUse: [heavy],
276
+ Stop: [{ matcher: '', hooks: [{ type: 'command', command: 'my-stop.sh' }] }],
277
+ },
278
+ }),
279
+ );
280
+ const adapter = new ClaudeCodeAdapter();
281
+
282
+ await adapter.initialize(projectDir);
283
+ // Wired beside the heavy-command hook (which is not a native hook).
284
+ expect(readFileSync(local, 'utf-8')).toContain(JSON.stringify(NATIVE_STOP_HOOK_COMMAND));
285
+ await adapter.dispose();
286
+
287
+ const after = JSON.parse(readFileSync(local, 'utf-8')) as { hooks: Record<string, unknown> };
288
+ expect(after.hooks).toEqual({
289
+ PreToolUse: [heavy],
290
+ Stop: [{ matcher: '', hooks: [{ type: 'command', command: 'my-stop.sh' }] }],
291
+ });
292
+ });
293
+ });
@@ -159,7 +159,9 @@ export class ClaudeCodeAdapter implements CLEOProviderAdapter {
159
159
  }
160
160
 
161
161
  // Activate CLEO hook bridge for this project — connects Claude Code
162
- // native events to CLEO's internal hook dispatch (T555).
162
+ // native events to CLEO's internal hook dispatch (T555). Written to the
163
+ // project's .claude/settings.local.json only, never the user-global
164
+ // ~/.claude/settings.json (T13227).
163
165
  await this.hooks.registerNativeHooks(projectDir);
164
166
  }
165
167
 
@@ -21,13 +21,85 @@ import { readdir, readFile } from 'node:fs/promises';
21
21
  import { join } from 'node:path';
22
22
  import { updateJsonConfigFile } from '@cleocode/caamp';
23
23
  import type { AdapterHookProvider } from '@cleocode/contracts';
24
- import {
25
- appendHookEntry,
26
- claudeSettingsPath,
27
- hasCleoHook,
28
- hookMap,
29
- removeCleoHookEntries,
30
- } from './paths.js';
24
+ import { excludeLocalSettingsFromGit } from '../shared/heavy-command-hook-install.js';
25
+ import { appendHookEntry, hookMap, isPlainObject, projectClaudeSettingsPath } from './paths.js';
26
+
27
+ /** The `Stop` hook command: end the CLEO session when Claude Code stops. */
28
+ export const NATIVE_STOP_HOOK_COMMAND = 'cleo session end --quiet # cleo-hook';
29
+
30
+ /** The `PostToolUse` (`Write|Edit`) hook command: a brain observation per file write. */
31
+ export const NATIVE_OBSERVE_HOOK_COMMAND =
32
+ 'cleo observe "File modified via $TOOL_NAME" --title "tool-use" --quiet # cleo-hook';
33
+
34
+ /**
35
+ * The `PostToolUse` (`Write|Edit`) NEXUS post-check: re-index changed files and
36
+ * flag regressions. `$TOOL_INPUT_file_path` is populated by Claude Code for
37
+ * Write/Edit events.
38
+ */
39
+ export const NATIVE_NEXUS_HOOK_COMMAND =
40
+ 'cleo nexus analyze --incremental --json > /dev/null 2>&1 && cleo observe "NEXUS re-indexed after $TOOL_NAME on $TOOL_INPUT_file_path" --title "nexus-post-check" --quiet # cleo-hook';
41
+
42
+ /** Every command {@link ClaudeCodeHookProvider.registerNativeHooks} writes, and alone may remove. */
43
+ const NATIVE_HOOK_COMMANDS: ReadonlySet<string> = new Set([
44
+ NATIVE_STOP_HOOK_COMMAND,
45
+ NATIVE_OBSERVE_HOOK_COMMAND,
46
+ NATIVE_NEXUS_HOOK_COMMAND,
47
+ ]);
48
+
49
+ /** Whether a hook object is one of the adapter's native hooks (never the heavy-command hook or a user's). */
50
+ function isNativeHookObject(hook: unknown): boolean {
51
+ return (
52
+ isPlainObject(hook) &&
53
+ typeof hook.command === 'string' &&
54
+ NATIVE_HOOK_COMMANDS.has(hook.command)
55
+ );
56
+ }
57
+
58
+ /** Whether any event of a `hooks` map already holds a native hook. */
59
+ function hasNativeHooks(hooks: Record<string, unknown>): boolean {
60
+ return Object.values(hooks).some(
61
+ (entries) =>
62
+ Array.isArray(entries) &&
63
+ entries.some(
64
+ (entry) =>
65
+ isPlainObject(entry) &&
66
+ Array.isArray(entry.hooks) &&
67
+ entry.hooks.some(isNativeHookObject),
68
+ ),
69
+ );
70
+ }
71
+
72
+ /**
73
+ * Remove the native hook objects from every event, then any entry left with
74
+ * no hooks and any event left with no entries. Other hooks sharing an entry
75
+ * (the heavy-command hook, the user's own) stay.
76
+ */
77
+ function removeNativeHooks(hooks: Record<string, unknown>): boolean {
78
+ let changed = false;
79
+ for (const [event, entries] of Object.entries(hooks)) {
80
+ if (!Array.isArray(entries)) continue;
81
+ const kept: unknown[] = [];
82
+ let eventChanged = false;
83
+ for (const entry of entries) {
84
+ if (
85
+ !isPlainObject(entry) ||
86
+ !Array.isArray(entry.hooks) ||
87
+ !entry.hooks.some(isNativeHookObject)
88
+ ) {
89
+ kept.push(entry);
90
+ continue;
91
+ }
92
+ eventChanged = true;
93
+ const rest = entry.hooks.filter((hook) => !isNativeHookObject(hook));
94
+ if (rest.length > 0) kept.push({ ...entry, hooks: rest });
95
+ }
96
+ if (!eventChanged) continue;
97
+ changed = true;
98
+ if (kept.length === 0) delete hooks[event];
99
+ else hooks[event] = kept;
100
+ }
101
+ return changed;
102
+ }
31
103
 
32
104
  /** CAAMP provider identifier for Claude Code. */
33
105
  const PROVIDER_ID = 'claude-code' as const;
@@ -76,8 +148,10 @@ const CLAUDE_CODE_EVENT_MAP: Record<string, string> = {
76
148
  /**
77
149
  * Hook provider for Claude Code.
78
150
  *
79
- * Claude Code registers hooks via its global config at `~/.claude/settings.json`.
80
- * Supported handler types: command, http, prompt, agent.
151
+ * CLEO registers its Claude Code hooks in the project's per-machine
152
+ * `<project>/.claude/settings.local.json`, never in the user-global
153
+ * `~/.claude/settings.json` (T13227). Supported handler types: command, http,
154
+ * prompt, agent.
81
155
  *
82
156
  * Event mapping is based on `getProviderHookProfile('claude-code')` from
83
157
  * CAAMP 1.9.1. Async accessors (`getSupportedCanonicalEvents`,
@@ -122,76 +196,79 @@ export class ClaudeCodeHookProvider implements AdapterHookProvider {
122
196
  /**
123
197
  * Register native hooks for a project.
124
198
  *
125
- * Writes CLEO hook entries to `~/.claude/settings.json` so that Claude Code's
126
- * native event system calls cleo CLI commands when events fire. This bridges
127
- * Claude Code's event loop to CLEO's internal hook dispatch.
199
+ * Writes CLEO hook entries to the project's per-machine
200
+ * `<project>/.claude/settings.local.json` so that Claude Code's native event
201
+ * system calls cleo CLI commands when events fire in this project. This
202
+ * bridges Claude Code's event loop to CLEO's internal hook dispatch.
203
+ * CLEO never writes the user-global `~/.claude/settings.json` (T13227): a
204
+ * project that is the home directory, or whose settings file resolves into
205
+ * the user-global Claude config, is refused and nothing is written. When
206
+ * git does not ignore `settings.local.json` yet, a marked `info/exclude`
207
+ * block keeps it out of git (the T12983 heavy-command rule).
128
208
  *
129
- * Idempotent: skips writing if CLEO hooks already exist in settings.json.
209
+ * Idempotent: skips writing if the native hooks are already present.
130
210
  *
131
211
  * Hook entries registered:
132
212
  * - `Stop` → `cleo session end --quiet` (triggers LLM extraction, reflector, consolidation)
133
213
  * - `PostToolUse` (Write|Edit) → brain observation for file modifications
134
- * - `SubagentStop` → brain observation for agent completion
214
+ * and the NEXUS post-check
135
215
  *
136
- * @param projectDir - Project directory for context-scoped hook commands
137
- * @task T164 @task T555
216
+ * @param projectDir - Project whose Claude Code settings receive the hooks
217
+ * @task T164 @task T555 @task T13227
138
218
  */
139
219
  async registerNativeHooks(projectDir: string): Promise<void> {
140
220
  this.projectDir = projectDir;
141
221
  this.registered = true;
142
222
 
143
- // Write CLEO hook entries to settings.json (idempotent). T12385: locked,
144
- // atomic, and a malformed file is reported and left untouched — never
145
- // replaced with an object holding only CLEO's entries.
146
- await this.updateSettings((settings) => {
223
+ // T12385: locked, atomic, and a malformed file is reported and left
224
+ // untouched — never replaced with an object holding only CLEO's entries.
225
+ const wrote = await this.updateSettings(projectDir, (settings) => {
147
226
  const hooks = hookMap(settings);
148
- if (Object.values(hooks).some((entries) => hasCleoHook(entries))) {
149
- return false; // Already wired — idempotent
150
- }
227
+ if (hasNativeHooks(hooks)) return false; // Already wired — idempotent
151
228
 
152
- // Register Stop hook → triggers cleo session end (LLM extraction, reflector, consolidation)
153
229
  appendHookEntry(hooks, 'Stop', {
154
230
  matcher: '',
155
- hooks: [{ type: 'command', command: `cleo session end --quiet # cleo-hook` }],
231
+ hooks: [{ type: 'command', command: NATIVE_STOP_HOOK_COMMAND }],
156
232
  });
157
-
158
- // Register PostToolUse hook → brain observation for file writes + NEXUS post-check (T625)
159
233
  appendHookEntry(hooks, 'PostToolUse', {
160
234
  matcher: 'Write|Edit',
161
235
  hooks: [
162
- {
163
- type: 'command',
164
- command: `cleo observe "File modified via $TOOL_NAME" --title "tool-use" --quiet # cleo-hook`,
165
- },
166
- {
167
- // NEXUS post-modification check: re-index changed files and flag regressions.
168
- // $TOOL_INPUT_file_path is populated by Claude Code for Write/Edit events.
169
- type: 'command',
170
- command: `cleo nexus analyze --incremental --json > /dev/null 2>&1 && cleo observe "NEXUS re-indexed after $TOOL_NAME on $TOOL_INPUT_file_path" --title "nexus-post-check" --quiet # cleo-hook`,
171
- },
236
+ { type: 'command', command: NATIVE_OBSERVE_HOOK_COMMAND },
237
+ { type: 'command', command: NATIVE_NEXUS_HOOK_COMMAND },
172
238
  ],
173
239
  });
174
240
  return true;
175
241
  });
242
+ if (wrote) excludeLocalSettingsFromGit(projectDir);
176
243
  }
177
244
 
178
245
  /**
179
246
  * Unregister native hooks.
180
247
  *
181
- * Removes CLEO hook entries from Claude Code's `settings.json` by filtering
182
- * out entries containing the `# cleo-hook` marker. Same locking, atomicity
183
- * and parse-error rules as {@link registerNativeHooks}.
248
+ * Removes the hook objects {@link registerNativeHooks} wrote from the
249
+ * project's `settings.local.json`, and nothing else: the heavy-command hook
250
+ * and the user's own hooks stay, and an entry or event is dropped only when
251
+ * left empty. Same locking, atomicity, parse-error and project-scope rules
252
+ * as {@link registerNativeHooks}.
184
253
  *
185
- * @task T164 @task T555
254
+ * @task T164 @task T555 @task T13227
186
255
  */
187
256
  async unregisterNativeHooks(): Promise<void> {
257
+ const projectDir = this.projectDir;
188
258
  this.registered = false;
189
259
  this.projectDir = null;
260
+ if (projectDir === null) return;
190
261
 
191
- if (!existsSync(claudeSettingsPath())) return;
192
- await this.updateSettings((settings) => {
262
+ let settingsPath: string;
263
+ try {
264
+ settingsPath = projectClaudeSettingsPath(projectDir);
265
+ } catch {
266
+ return; // Never written there, so nothing to remove.
267
+ }
268
+ if (!existsSync(settingsPath)) return;
269
+ await this.updateSettings(projectDir, (settings) => {
193
270
  if (settings.hooks === undefined) return false;
194
- return removeCleoHookEntries(hookMap(settings));
271
+ return removeNativeHooks(hookMap(settings));
195
272
  });
196
273
  }
197
274
 
@@ -199,12 +276,13 @@ export class ClaudeCodeHookProvider implements AdapterHookProvider {
199
276
  private settingsError: string | null = null;
200
277
 
201
278
  /**
202
- * Why the last settings.json update did not happen, or `null`.
279
+ * Why the last settings update did not happen, or `null`.
203
280
  *
204
281
  * @remarks
205
282
  * Hook registration stays non-fatal for adapter initialisation, so a
206
- * refused write (malformed settings.json, lock timeout) is surfaced here and
207
- * on stderr instead of being swallowed (T12385).
283
+ * refused write (malformed settings file, lock timeout, a user-global
284
+ * target) is surfaced here and on stderr instead of being swallowed
285
+ * (T12385, T13227).
208
286
  *
209
287
  * @returns The failure message, or `null`
210
288
  */
@@ -212,19 +290,28 @@ export class ClaudeCodeHookProvider implements AdapterHookProvider {
212
290
  return this.settingsError;
213
291
  }
214
292
 
215
- /** Apply `mutate` to settings.json via CAAMP's locked, atomic JSON writer. */
293
+ /**
294
+ * Apply `mutate` to the project's `settings.local.json` via CAAMP's locked,
295
+ * atomic JSON writer.
296
+ *
297
+ * @returns whether the file was written.
298
+ */
216
299
  private async updateSettings(
300
+ projectDir: string,
217
301
  mutate: (settings: Record<string, unknown>) => boolean,
218
- ): Promise<void> {
219
- const settingsPath = claudeSettingsPath();
302
+ ): Promise<boolean> {
303
+ let settingsPath = `${projectDir}/.claude/settings.local.json`;
220
304
  try {
221
- await updateJsonConfigFile(settingsPath, mutate);
305
+ settingsPath = projectClaudeSettingsPath(projectDir);
306
+ const wrote = await updateJsonConfigFile(settingsPath, mutate);
222
307
  this.settingsError = null;
308
+ return wrote;
223
309
  } catch (err) {
224
310
  this.settingsError = err instanceof Error ? err.message : String(err);
225
311
  process.stderr.write(
226
312
  `[cleo:claude-code] hooks not written to ${settingsPath}: ${this.settingsError}\n`,
227
313
  );
314
+ return false;
228
315
  }
229
316
  }
230
317