@cleocode/adapters 2026.10.17 → 2026.10.18

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.
@@ -2,7 +2,7 @@
2
2
  import { execFileSync, spawnSync } from 'node:child_process';
3
3
  import { createHash } from 'node:crypto';
4
4
  import { existsSync } from 'node:fs';
5
- import { lstat, realpath, rmdir, unlink } from 'node:fs/promises';
5
+ import { lstat, mkdir, realpath, rmdir, unlink } from 'node:fs/promises';
6
6
  import { homedir } from 'node:os';
7
7
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
8
8
  import {
@@ -580,6 +580,128 @@ async function validateStateDirectory(options: ProjectHookDeliveryOptions): Prom
580
580
  throw new Error('HOOK_STATE_DIRECTORY_INVALID');
581
581
  }
582
582
 
583
+ /**
584
+ * Providers that read a linked worktree's project hooks from the root checkout. Codex replaces a
585
+ * worktree layer's hooks with the matching root-checkout `.codex` folder, and discovers that layer
586
+ * only when the worktree has its own `.codex` directory (codex-rs `merge_root_checkout_project_hooks`).
587
+ */
588
+ const ROOT_CHECKOUT_HOOK_PROVIDERS = new Set(['codex']);
589
+
590
+ /** Root-checkout delivery options when `projectRoot` is a linked worktree, mirroring Codex's root check. */
591
+ async function rootCheckoutOptions(
592
+ options: ProjectHookDeliveryOptions,
593
+ ): Promise<ProjectHookDeliveryOptions | undefined> {
594
+ const git = (cwd: string, ...args: string[]): string | undefined => {
595
+ try {
596
+ return execFileSync('git', ['-C', cwd, 'rev-parse', ...args], {
597
+ encoding: 'utf8',
598
+ timeout: 2000,
599
+ maxBuffer: 16384,
600
+ stdio: ['ignore', 'pipe', 'ignore'],
601
+ env: options.services.gitEnvironment,
602
+ }).trim();
603
+ } catch {
604
+ return undefined;
605
+ }
606
+ };
607
+ const common = git(options.projectRoot, '--path-format=absolute', '--git-common-dir');
608
+ const gitDir = git(options.projectRoot, '--path-format=absolute', '--git-dir');
609
+ if (!common || !gitDir || (await realpath(common)) === (await realpath(gitDir))) return undefined;
610
+ // Like Codex, only a root whose own .git resolves to the shared common directory counts.
611
+ const rootGitDir = git(dirname(common), '--path-format=absolute', '--git-dir');
612
+ const root = git(dirname(common), '--show-toplevel');
613
+ const stateDir = git(
614
+ dirname(common),
615
+ '--path-format=absolute',
616
+ '--git-path',
617
+ 'cleo-project-hooks',
618
+ );
619
+ if (
620
+ !rootGitDir ||
621
+ !root ||
622
+ !stateDir ||
623
+ (await realpath(rootGitDir)) !== (await realpath(common))
624
+ )
625
+ return undefined;
626
+ return { ...options, projectRoot: root, stateDir: resolve(root, stateDir) };
627
+ }
628
+
629
+ /** Run one provider delivery under its receipt lock (dry runs write no lock file). */
630
+ function deliverLocked(
631
+ options: ProjectHookDeliveryOptions,
632
+ provider: string,
633
+ ): Promise<ProjectHookDeliveryResult> {
634
+ return options.dryRun
635
+ ? deliverProvider(options, provider)
636
+ : withFileLock(join(options.stateDir, 'provider-' + provider + '.json'), () =>
637
+ deliverProvider(options, provider),
638
+ );
639
+ }
640
+
641
+ /** Deliver into the root checkout the provider reads, keeping the worktree discoverable (T13772). */
642
+ async function deliverFromLinkedWorktree(
643
+ worktree: ProjectHookDeliveryOptions,
644
+ root: ProjectHookDeliveryOptions,
645
+ provider: string,
646
+ ): Promise<ProjectHookDeliveryResult> {
647
+ const configPath = GENERATED_PROVIDER_HOOK_PROFILES[provider]?.projectDelivery?.configPath;
648
+ if (!configPath) return deliverLocked(worktree, provider);
649
+ // Earlier releases wrote worktree-local entries the provider never reads; undo them by receipt.
650
+ if (!worktree.dryRun && existsSync(join(worktree.stateDir, 'provider-' + provider + '.json')))
651
+ await deliverLocked({ ...worktree, rollback: true }, provider);
652
+ const folder = await localTarget(worktree.projectRoot, dirname(configPath));
653
+ if (worktree.rollback) {
654
+ // The root entry also serves the root checkout and sibling worktrees, so it stays; this
655
+ // worktree's disabled activation already makes it a no-op here. rmdir keeps foreign files.
656
+ if (!worktree.dryRun) await rmdir(folder).catch(() => undefined);
657
+ return {
658
+ provider,
659
+ state: 'disabled',
660
+ capability: 'unverified',
661
+ nativeTrust: 'unverified',
662
+ provenance: 'unknown',
663
+ diagnostics: ['HOOK_ROOT_CHECKOUT_ENTRY_RETAINED'],
664
+ };
665
+ }
666
+ // The root entry serves the root checkout and every sibling, so a worktree only grows it;
667
+ // shrinking stays with a root sync or root disable. Extra events are inert in a checkout
668
+ // whose own activation does not bind them.
669
+ if (worktree.events.length === 0)
670
+ return {
671
+ provider,
672
+ state: 'unsupported',
673
+ capability: 'unverified',
674
+ nativeTrust: 'unverified',
675
+ provenance: 'unknown',
676
+ diagnostics: ['HOOK_NO_AGENT_BINDINGS', 'HOOK_ROOT_CHECKOUT_ENTRY_RETAINED'],
677
+ };
678
+ const receiptPath = join(root.stateDir, 'provider-' + provider + '.json');
679
+ const deliver = async (): Promise<ProjectHookDeliveryResult> => {
680
+ const prior = await receiptAt(receiptPath, root.services);
681
+ const delivered = (prior?.entries ?? []).flatMap(
682
+ (item) => /--event ([A-Za-z][A-Za-z0-9]*)/.exec(item.command)?.[1] ?? [],
683
+ );
684
+ return deliverProvider(
685
+ {
686
+ ...root,
687
+ events: [...new Set([...delivered, ...worktree.events])],
688
+ enabled: worktree.enabled,
689
+ dryRun: worktree.dryRun,
690
+ },
691
+ provider,
692
+ );
693
+ };
694
+ const result = worktree.dryRun ? await deliver() : await withFileLock(receiptPath, deliver);
695
+ result.diagnostics.push('HOOK_ROOT_CHECKOUT_DELIVERY');
696
+ if (
697
+ !worktree.dryRun &&
698
+ (result.state === 'installed' || result.state === 'current') &&
699
+ !existsSync(folder)
700
+ )
701
+ await mkdir(folder);
702
+ return result;
703
+ }
704
+
583
705
  /** Inspect or sync only project-local entries; no provider trust settings are written. */
584
706
  export async function syncProjectHookProviders(
585
707
  options: ProjectHookDeliveryOptions,
@@ -606,12 +728,14 @@ export async function syncProjectHookProviders(
606
728
  )
607
729
  throw new Error('HOOK_PROVIDER_INVALID');
608
730
  await validateStateDirectory(options);
731
+ const root = ROOT_CHECKOUT_HOOK_PROVIDERS.has(provider)
732
+ ? await rootCheckoutOptions(options)
733
+ : undefined;
734
+ if (root) await validateStateDirectory(root);
609
735
  outcomes.push(
610
- options.dryRun
611
- ? await deliverProvider(options, provider)
612
- : await withFileLock(join(options.stateDir, 'provider-' + provider + '.json'), () =>
613
- deliverProvider(options, provider),
614
- ),
736
+ root
737
+ ? await deliverFromLinkedWorktree(options, root, provider)
738
+ : await deliverLocked(options, provider),
615
739
  );
616
740
  } catch (error) {
617
741
  outcomes.push({
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Claude Code's native Stop hook is the one Stop dispatcher (T13796): a fresh
3
+ * project gets `cleo hook stop`, and a project carrying the pre-dispatcher
4
+ * `cleo session end --quiet` Stop entry has it replaced in place, the user's
5
+ * own hooks untouched.
6
+ *
7
+ * @task T13796
8
+ */
9
+
10
+ import { execFileSync } from 'node:child_process';
11
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
15
+ import { askEnforceHookObject } from '../../shared/heavy-command-hook-install.js';
16
+ import {
17
+ ClaudeCodeHookProvider,
18
+ LEGACY_NATIVE_STOP_HOOK_COMMAND,
19
+ NATIVE_NEXUS_HOOK_COMMAND,
20
+ NATIVE_OBSERVE_HOOK_COMMAND,
21
+ NATIVE_STOP_HOOK_COMMAND,
22
+ } from '../hooks.js';
23
+
24
+ let root: string;
25
+ let projectDir: string;
26
+ let savedHome: string | undefined;
27
+
28
+ beforeEach(() => {
29
+ savedHome = process.env.HOME;
30
+ root = mkdtempSync(join(tmpdir(), 'cleo-stop-dispatch-'));
31
+ projectDir = join(root, 'project');
32
+ mkdirSync(join(projectDir, '.claude'), { recursive: true });
33
+ execFileSync('git', ['init', '-q', projectDir]);
34
+ process.env.HOME = join(root, 'home');
35
+ });
36
+
37
+ afterEach(() => {
38
+ if (savedHome === undefined) delete process.env.HOME;
39
+ else process.env.HOME = savedHome;
40
+ rmSync(root, { recursive: true, force: true });
41
+ });
42
+
43
+ type Settings = { hooks: Record<string, Array<{ hooks: Array<{ command: string }> }>> };
44
+ const read = (): Settings =>
45
+ JSON.parse(readFileSync(join(projectDir, '.claude', 'settings.local.json'), 'utf-8'));
46
+ const stopCommands = (s: Settings) =>
47
+ (s.hooks.Stop ?? []).flatMap((e) => e.hooks.map((h) => h.command));
48
+
49
+ describe('Claude Code Stop dispatcher hook (T13796)', () => {
50
+ it('registers cleo hook stop as the Stop command', async () => {
51
+ expect(NATIVE_STOP_HOOK_COMMAND).toContain('cleo hook stop --provider claude-code 2>/dev/null');
52
+ expect(NATIVE_STOP_HOOK_COMMAND.endsWith('exit 0 # cleo-hook')).toBe(true);
53
+ await new ClaudeCodeHookProvider().registerNativeHooks(projectDir);
54
+ expect(stopCommands(read())).toEqual([NATIVE_STOP_HOOK_COMMAND]);
55
+ });
56
+
57
+ it('folds the old session-end and ask-enforce Stop entries into one dispatcher, keeping user hooks', async () => {
58
+ writeFileSync(
59
+ join(projectDir, '.claude', 'settings.local.json'),
60
+ JSON.stringify({
61
+ hooks: {
62
+ Stop: [
63
+ { matcher: '', hooks: [{ type: 'command', command: LEGACY_NATIVE_STOP_HOOK_COMMAND }] },
64
+ { matcher: '', hooks: [{ type: 'command', command: 'my-stop.sh' }] },
65
+ { hooks: [askEnforceHookObject('claude-code')] },
66
+ ],
67
+ PostToolUse: [
68
+ {
69
+ matcher: 'Write|Edit',
70
+ hooks: [
71
+ { type: 'command', command: NATIVE_OBSERVE_HOOK_COMMAND },
72
+ { type: 'command', command: NATIVE_NEXUS_HOOK_COMMAND },
73
+ ],
74
+ },
75
+ ],
76
+ },
77
+ }),
78
+ );
79
+ const provider = new ClaudeCodeHookProvider();
80
+ await provider.registerNativeHooks(projectDir);
81
+ const after = read();
82
+ expect(stopCommands(after).sort()).toEqual([NATIVE_STOP_HOOK_COMMAND, 'my-stop.sh'].sort());
83
+ expect(JSON.stringify(after)).not.toContain(LEGACY_NATIVE_STOP_HOOK_COMMAND);
84
+ expect(JSON.stringify(after)).not.toContain('cleo hook ask-enforce');
85
+ expect(after.hooks.PostToolUse?.flatMap((e) => e.hooks.map((h) => h.command))).toEqual([
86
+ NATIVE_OBSERVE_HOOK_COMMAND,
87
+ NATIVE_NEXUS_HOOK_COMMAND,
88
+ ]);
89
+ // Idempotent once migrated.
90
+ const bytes = readFileSync(join(projectDir, '.claude', 'settings.local.json'), 'utf-8');
91
+ await provider.registerNativeHooks(projectDir);
92
+ expect(readFileSync(join(projectDir, '.claude', 'settings.local.json'), 'utf-8')).toBe(bytes);
93
+ });
94
+ });
@@ -22,11 +22,25 @@ import { join } from 'node:path';
22
22
  import { updateJsonConfigFile } from '@cleocode/caamp';
23
23
  import { PROVIDER_NATIVE_EVENT_MAPS } from '@cleocode/caamp/hooks';
24
24
  import type { AdapterHookProvider } from '@cleocode/contracts';
25
- import { excludeLocalSettingsFromGit } from '../shared/heavy-command-hook-install.js';
25
+ import {
26
+ applyStopDispatch,
27
+ excludeLocalSettingsFromGit,
28
+ isStopDispatchHookObject,
29
+ LEGACY_SESSION_END_STOP_COMMAND,
30
+ stopDispatchHookObject,
31
+ } from '../shared/heavy-command-hook-install.js';
26
32
  import { appendHookEntry, hookMap, isPlainObject, projectClaudeSettingsPath } from './paths.js';
27
33
 
28
- /** The `Stop` hook command: end the CLEO session when Claude Code stops. */
29
- export const NATIVE_STOP_HOOK_COMMAND = 'cleo session end --quiet # cleo-hook';
34
+ /**
35
+ * The `Stop` hook command: the one Stop dispatcher (T13796). It asks a live
36
+ * goal runtime first and ends the CLEO session only when the goal says the
37
+ * turn is final or no goal is live, so a goal hook and the session end never
38
+ * fight over the event.
39
+ */
40
+ export const NATIVE_STOP_HOOK_COMMAND = String(stopDispatchHookObject('claude-code').command);
41
+
42
+ /** The `Stop` command before T13796; recognised so registration replaces it. */
43
+ export const LEGACY_NATIVE_STOP_HOOK_COMMAND = LEGACY_SESSION_END_STOP_COMMAND;
30
44
 
31
45
  /** The `PostToolUse` (`Write|Edit`) hook command: a brain observation per file write. */
32
46
  export const NATIVE_OBSERVE_HOOK_COMMAND =
@@ -43,6 +57,7 @@ export const NATIVE_NEXUS_HOOK_COMMAND =
43
57
  /** Every command {@link ClaudeCodeHookProvider.registerNativeHooks} writes, and alone may remove. */
44
58
  const NATIVE_HOOK_COMMANDS: ReadonlySet<string> = new Set([
45
59
  NATIVE_STOP_HOOK_COMMAND,
60
+ LEGACY_NATIVE_STOP_HOOK_COMMAND,
46
61
  NATIVE_OBSERVE_HOOK_COMMAND,
47
62
  NATIVE_NEXUS_HOOK_COMMAND,
48
63
  ]);
@@ -56,20 +71,22 @@ function isNativeHookObject(hook: unknown): boolean {
56
71
  );
57
72
  }
58
73
 
59
- /** Whether any event of a `hooks` map already holds a native hook. */
60
- function hasNativeHooks(hooks: Record<string, unknown>): boolean {
74
+ /** Whether any event of a `hooks` map holds a hook object `match` accepts. */
75
+ function holdsHook(hooks: Record<string, unknown>, match: (hook: unknown) => boolean): boolean {
61
76
  return Object.values(hooks).some(
62
77
  (entries) =>
63
78
  Array.isArray(entries) &&
64
79
  entries.some(
65
- (entry) =>
66
- isPlainObject(entry) &&
67
- Array.isArray(entry.hooks) &&
68
- entry.hooks.some(isNativeHookObject),
80
+ (entry) => isPlainObject(entry) && Array.isArray(entry.hooks) && entry.hooks.some(match),
69
81
  ),
70
82
  );
71
83
  }
72
84
 
85
+ /** Whether any event of a `hooks` map already holds a native hook. */
86
+ function hasNativeHooks(hooks: Record<string, unknown>): boolean {
87
+ return holdsHook(hooks, isNativeHookObject);
88
+ }
89
+
73
90
  /**
74
91
  * Remove the native hook objects from every event, then any entry left with
75
92
  * no hooks and any event left with no entries. Other hooks sharing an entry
@@ -181,7 +198,8 @@ export class ClaudeCodeHookProvider implements AdapterHookProvider {
181
198
  * Idempotent: skips writing if the native hooks are already present.
182
199
  *
183
200
  * Hook entries registered:
184
- * - `Stop` → `cleo session end --quiet` (triggers LLM extraction, reflector, consolidation)
201
+ * - `Stop` → `cleo hook stop` (the goal runtime first, then `cleo session end`,
202
+ * which triggers LLM extraction, reflector, consolidation; T13796)
185
203
  * - `PostToolUse` (Write|Edit) → brain observation for file modifications
186
204
  * and the NEXUS post-check
187
205
  *
@@ -196,12 +214,11 @@ export class ClaudeCodeHookProvider implements AdapterHookProvider {
196
214
  // untouched — never replaced with an object holding only CLEO's entries.
197
215
  const wrote = await this.updateSettings(projectDir, (settings) => {
198
216
  const hooks = hookMap(settings);
199
- if (hasNativeHooks(hooks)) return false; // Already wired — idempotent
200
-
201
- appendHookEntry(hooks, 'Stop', {
202
- matcher: '',
203
- hooks: [{ type: 'command', command: NATIVE_STOP_HOOK_COMMAND }],
204
- });
217
+ // A fresh project gets every native hook; a wired one keeps its PostToolUse as the user left it.
218
+ const fresh = !hasNativeHooks(hooks) && !holdsHook(hooks, isStopDispatchHookObject);
219
+ // T13796: one Stop hook, folding in the separate ask-enforce and old session-end hooks.
220
+ const stopChanged = applyStopDispatch(hooks, 'claude-code', true);
221
+ if (!fresh) return stopChanged;
205
222
  appendHookEntry(hooks, 'PostToolUse', {
206
223
  matcher: 'Write|Edit',
207
224
  hooks: [
@@ -96,20 +96,23 @@ describe('syncProjectHeavyCommandHooks', () => {
96
96
  });
97
97
  });
98
98
 
99
- it('installs the ask-enforce Stop hook beside it for Claude Code and Codex (T13420)', async () => {
99
+ it('installs ONE Stop dispatcher (goal → ask-enforce → session end) for Claude Code and Codex (T13796)', async () => {
100
100
  useAll();
101
101
  await syncProjectHeavyCommandHooks(project, 'rewrite', { env });
102
102
  const stop = (file: string) =>
103
103
  JSON.stringify(JSON.parse(readFileSync(file, 'utf-8')).hooks.Stop ?? []);
104
104
  const claude = join(project, '.claude', 'settings.local.json');
105
105
  const codex = join(project, '.codex', 'hooks.json');
106
- expect(stop(claude)).toContain('cleo hook ask-enforce --provider claude-code');
107
- expect(stop(codex)).toContain('cleo hook ask-enforce --provider codex');
108
- await syncProjectHeavyCommandHooks(project, 'rewrite', {
109
- env: { ...env, CLEO_ASK_ENFORCE: 'off' },
110
- });
106
+ expect(stop(claude)).toContain('cleo hook stop --provider claude-code');
107
+ expect(stop(codex)).toContain('cleo hook stop --provider codex');
108
+ // Ask-enforce runs inside the dispatcher; there is no separate Stop entry for it.
111
109
  expect(stop(claude)).not.toContain('ask-enforce');
112
110
  expect(stop(codex)).not.toContain('ask-enforce');
111
+ expect(stop(claude).match(/cleo hook stop/g)).toHaveLength(1);
112
+ // Mode off removes Codex's dispatcher; Claude Code's keeps its session end.
113
+ await syncProjectHeavyCommandHooks(project, 'off', { env });
114
+ expect(stop(codex)).not.toContain('cleo hook stop');
115
+ expect(stop(claude)).toContain('cleo hook stop --provider claude-code');
113
116
  });
114
117
 
115
118
  it('a .codex FILE blocks only Codex, with the reason and remedy; the others install', async () => {
@@ -55,12 +55,15 @@ import {
55
55
  heavyCommandHookEntry,
56
56
  heavyCommandHookObject,
57
57
  isUserHomeDir,
58
+ LEGACY_SESSION_END_STOP_COMMAND,
58
59
  LOCAL_SETTINGS_EXCLUDE_MARKER,
59
60
  OLDER_CLEO_HOOK_ANSWER,
60
61
  OLDER_CLEO_MARKER_PREFIX,
61
62
  OPENCODE_HEAVY_COMMAND_PLUGIN,
62
- syncJsonAskEnforceHook,
63
+ STOP_DISPATCH_HOOK_ID,
64
+ stopDispatchHookObject,
63
65
  syncJsonHeavyCommandHook,
66
+ syncJsonStopDispatchHook,
64
67
  syncOpencodeHeavyCommandPlugin,
65
68
  } from '../heavy-command-hook-install.js';
66
69
 
@@ -330,40 +333,44 @@ describe.each(SHELLS)('heavyCommandHookCommand under %s', (shell) => {
330
333
  });
331
334
  });
332
335
 
333
- describe('syncJsonAskEnforceHook (T13420)', () => {
334
- const read = (p: string) => JSON.parse(readFileSync(p, 'utf-8'));
335
-
336
- it('adds one Stop entry beside the heavy-command hook, then is idempotent', async () => {
337
- const path = join(dir, 'project', '.claude', 'settings.local.json');
338
- await syncJsonHeavyCommandHook(path, 'claude-code', 'rewrite');
339
- expect(await syncJsonAskEnforceHook(path, 'claude-code', true)).toBe('installed');
340
- expect(read(path).hooks).toEqual({
341
- PreToolUse: [heavyCommandHookEntry('claude-code')],
342
- Stop: [{ hooks: [askEnforceHookObject('claude-code')] }],
343
- });
344
- expect(await syncJsonAskEnforceHook(path, 'claude-code', true)).toBe('unchanged');
336
+ describe('askEnforceHookObject (T13420)', () => {
337
+ it('runs a fail-open script: exits 0 and prints only a JSON answer', () => {
338
+ const claude = String(askEnforceHookObject('claude-code').command);
339
+ expect(claude).toContain('command -v cleo >/dev/null 2>&1 || exit 0');
340
+ expect(claude).toContain(`${ASK_ENFORCE_HOOK_ID} --provider claude-code 2>/dev/null`);
341
+ expect(claude.endsWith('exit 0 # cleo-hook')).toBe(true);
342
+ expect(String(askEnforceHookObject('codex').command)).toMatch(/^\/bin\/sh -c '/);
345
343
  });
344
+ });
346
345
 
347
- it('keeps user Stop hooks, refreshes a stale CLEO one, and removes only its own', async () => {
348
- const path = join(dir, 'hooks.json');
346
+ describe('syncJsonStopDispatchHook (T13796)', () => {
347
+ const read = (p: string) => JSON.parse(readFileSync(p, 'utf-8'));
348
+
349
+ it('folds ask-enforce and the old session end into one Stop dispatcher, keeps user hooks, removes only its own', async () => {
350
+ const path = join(dir, 'codex-hooks.json');
349
351
  const user = { hooks: [{ type: 'command', command: './notify.sh' }] };
350
- const stale = { hooks: [{ type: 'command', command: `${ASK_ENFORCE_HOOK_ID} # cleo-hook` }] };
351
- writeFileSync(path, JSON.stringify({ hooks: { Stop: [user, stale] } }));
352
- expect(await syncJsonAskEnforceHook(path, 'codex', true)).toBe('updated');
353
- expect(read(path).hooks.Stop).toEqual([user, { hooks: [askEnforceHookObject('codex')] }]);
354
- expect(await syncJsonAskEnforceHook(path, 'codex', false)).toBe('removed');
352
+ const legacy = {
353
+ matcher: '',
354
+ hooks: [{ type: 'command', command: LEGACY_SESSION_END_STOP_COMMAND }],
355
+ };
356
+ const ask = { hooks: [askEnforceHookObject('codex')] };
357
+ writeFileSync(path, JSON.stringify({ hooks: { Stop: [user, legacy, ask] } }));
358
+ expect(await syncJsonStopDispatchHook(path, 'codex', true)).toBe('installed');
359
+ expect(read(path).hooks.Stop).toEqual([user, { hooks: [stopDispatchHookObject('codex')] }]);
360
+ expect(await syncJsonStopDispatchHook(path, 'codex', true)).toBe('unchanged');
361
+ expect(await syncJsonStopDispatchHook(path, 'codex', false)).toBe('removed');
355
362
  expect(read(path).hooks.Stop).toEqual([user]);
356
- expect(await syncJsonAskEnforceHook(join(dir, 'missing.json'), 'codex', false)).toBe(
363
+ expect(await syncJsonStopDispatchHook(join(dir, 'missing.json'), 'codex', false)).toBe(
357
364
  'unchanged',
358
365
  );
359
366
  });
360
367
 
361
- it('runs a fail-open script: exits 0 and prints only a JSON answer', () => {
362
- const claude = String(askEnforceHookObject('claude-code').command);
368
+ it('runs a fail-open script that forwards only a JSON answer', () => {
369
+ const claude = String(stopDispatchHookObject('claude-code').command);
363
370
  expect(claude).toContain('command -v cleo >/dev/null 2>&1 || exit 0');
364
- expect(claude).toContain(`${ASK_ENFORCE_HOOK_ID} --provider claude-code 2>/dev/null`);
371
+ expect(claude).toContain(`${STOP_DISPATCH_HOOK_ID} --provider claude-code 2>/dev/null`);
365
372
  expect(claude.endsWith('exit 0 # cleo-hook')).toBe(true);
366
- expect(String(askEnforceHookObject('codex').command)).toMatch(/^\/bin\/sh -c '/);
373
+ expect(String(stopDispatchHookObject('codex').command)).toMatch(/^\/bin\/sh -c '/);
367
374
  });
368
375
  });
369
376
 
@@ -56,7 +56,7 @@ import {
56
56
  opencodeHeavyCommandPluginSource,
57
57
  syncClaudeCodeHeavyCommandHook,
58
58
  syncCodexHeavyCommandHook,
59
- syncJsonAskEnforceHook,
59
+ syncJsonStopDispatchHook,
60
60
  syncOpencodeHeavyCommandPlugin,
61
61
  trackedFileModified,
62
62
  } from './heavy-command-hook-install.js';
@@ -193,21 +193,20 @@ function errorText(err: unknown): string {
193
193
 
194
194
  /** Sync one provider; never throws. */
195
195
  /**
196
- * Install (or remove) CLEO's ask-enforce Stop hook (T13420) in the same JSON
197
- * config as the heavy-command hook, after that hook synced. It rides the
198
- * heavy-command delivery: removed when that hook's mode is `off` or when
199
- * `CLEO_ASK_ENFORCE=off`. Never fails the heavy-command outcome.
200
- * shortcut: no separate outcome or doctor row yet; add one when a config key replaces the env switch.
196
+ * Install CLEO's one Stop hook (T13796): goal tick → ask-enforce (T13420) →
197
+ * session end, folding the separate ask-enforce and session-end Stop hooks in
198
+ * place. Claude Code always keeps it (its Stop hook ended the session before
199
+ * the dispatcher); Codex's rides the heavy-command delivery and is removed
200
+ * when that mode is `off`. Ask-enforce inside it honours `CLEO_ASK_ENFORCE` at
201
+ * run time. Never fails the heavy-command outcome.
201
202
  */
202
- async function syncAskEnforceHook(
203
+ async function syncStopDispatch(
203
204
  provider: 'claude-code' | 'codex',
204
205
  target: string,
205
206
  mode: HeavyCommandHookMode,
206
- env: Env,
207
207
  ): Promise<void> {
208
- const install = mode !== 'off' && env.CLEO_ASK_ENFORCE?.trim().toLowerCase() !== 'off';
209
208
  try {
210
- await syncJsonAskEnforceHook(target, provider, install);
209
+ await syncJsonStopDispatchHook(target, provider, provider === 'claude-code' || mode !== 'off');
211
210
  } catch {
212
211
  // Fail open: a config the Stop hook cannot be written to keeps the heavy-command result.
213
212
  }
@@ -232,7 +231,7 @@ async function syncProvider(
232
231
  : provider === 'codex'
233
232
  ? await syncCodexHeavyCommandHook(projectDir, mode)
234
233
  : syncOpencodeHeavyCommandPlugin(projectDir, mode);
235
- if (provider !== 'opencode') await syncAskEnforceHook(provider, target, mode, env);
234
+ if (provider !== 'opencode') await syncStopDispatch(provider, target, mode);
236
235
  return { provider, status: result, target };
237
236
  } catch (err) {
238
237
  if (err instanceof HeavyHookSharedConfigError) {
@@ -44,7 +44,7 @@ import { homedir, tmpdir } from 'node:os';
44
44
  import { dirname, isAbsolute, join, resolve } from 'node:path';
45
45
  import { updateJsonConfigFile } from '@cleocode/caamp';
46
46
  import type { HeavyCommandHookMode } from '@cleocode/contracts';
47
- import { CLEO_HOOK_MARKER, hookMap, isPlainObject } from './hook-config.js';
47
+ import { appendHookEntry, CLEO_HOOK_MARKER, hookMap, isPlainObject } from './hook-config.js';
48
48
 
49
49
  /** Identifies the heavy-command hook among CLEO's hook commands. */
50
50
  export const HEAVY_COMMAND_HOOK_ID = 'cleo hook heavy-command';
@@ -460,45 +460,102 @@ export function isAskEnforceHookObject(hook: unknown): boolean {
460
460
  );
461
461
  }
462
462
 
463
+ /** The command CLEO's Stop dispatcher runs (T13796); also its marker. */
464
+ export const STOP_DISPATCH_HOOK_ID = 'cleo hook stop';
465
+
466
+ /** Seconds a Stop dispatch may take: the goal tick (30 s), ask-enforce and `cleo session end`. */
467
+ export const STOP_DISPATCH_HOOK_TIMEOUT_SEC = 120;
468
+
469
+ /** Claude Code's Stop command before T13796, which ended the session on its own. */
470
+ export const LEGACY_SESSION_END_STOP_COMMAND = 'cleo session end --quiet # cleo-hook';
471
+
472
+ /**
473
+ * CLEO's one Stop hook object (T13796): goal tick → ask-enforce → session end.
474
+ * Like the ask-enforce script, it forwards only a JSON answer and always exits
475
+ * 0, so a machine without `cleo` (or with an older one) is never blocked.
476
+ *
477
+ * @param provider - `claude-code` or `codex` (Codex runs hooks through the login shell).
478
+ */
479
+ export function stopDispatchHookObject(provider: 'claude-code' | 'codex'): Record<string, unknown> {
480
+ const script = `command -v cleo >/dev/null 2>&1 || exit 0; out="$(${STOP_DISPATCH_HOOK_ID} --provider ${provider} 2>/dev/null)"; case "$out" in "{"*) printf '%s\\n' "$out";; esac; exit 0`;
481
+ const line = provider === 'codex' ? `/bin/sh -c ${shellWord(script)}` : script;
482
+ return {
483
+ type: 'command',
484
+ command: `${line} ${CLEO_HOOK_MARKER}`,
485
+ timeout: STOP_DISPATCH_HOOK_TIMEOUT_SEC,
486
+ };
487
+ }
488
+
489
+ /** Whether one hook object is CLEO's Stop dispatcher. */
490
+ export function isStopDispatchHookObject(hook: unknown): boolean {
491
+ return (
492
+ isPlainObject(hook) &&
493
+ typeof hook.command === 'string' &&
494
+ hook.command.includes(CLEO_HOOK_MARKER) &&
495
+ hook.command.includes(`${STOP_DISPATCH_HOOK_ID} `)
496
+ );
497
+ }
498
+
499
+ /** The separate Stop hooks the dispatcher folds in: ask-enforce and the old session end. */
500
+ function isFoldedStopHook(hook: unknown): boolean {
501
+ return (
502
+ isAskEnforceHookObject(hook) ||
503
+ (isPlainObject(hook) && hook.command === LEGACY_SESSION_END_STOP_COMMAND)
504
+ );
505
+ }
506
+
463
507
  /**
464
- * Add, refresh or (`install: false`) remove CLEO's ask-enforce Stop hook in a
465
- * JSON hook config (`hooks.Stop`), through CAAMP's locked atomic writer. Only
466
- * CLEO's own hook object is touched; a malformed file throws untouched.
508
+ * Leave exactly one CLEO Stop hook, the dispatcher (or, `install: false`,
509
+ * none), in a `hooks` map: the separate ask-enforce and legacy session-end
510
+ * Stop hooks are removed in place, user hooks stay.
511
+ *
512
+ * @param hooks - The config's `hooks` map (edited in place).
513
+ * @param provider - `claude-code` or `codex`.
514
+ * @param install - `false` removes the dispatcher too.
515
+ * @returns whether the map changed.
516
+ */
517
+ export function applyStopDispatch(
518
+ hooks: Record<string, unknown>,
519
+ provider: 'claude-code' | 'codex',
520
+ install: boolean,
521
+ ): boolean {
522
+ const folded = placeHeavyHook(hooks, null, 'Stop', isFoldedStopHook).changed;
523
+ const desired = install ? stopDispatchHookObject(provider) : null;
524
+ const placed = placeHeavyHook(hooks, desired, 'Stop', isStopDispatchHookObject);
525
+ if (desired === null || placed.found) return folded || placed.changed;
526
+ appendHookEntry(hooks, 'Stop', { hooks: [desired] });
527
+ return true;
528
+ }
529
+
530
+ /**
531
+ * Sync CLEO's Stop dispatcher into a JSON hook config through CAAMP's locked
532
+ * atomic writer, folding the separate ask-enforce and session-end Stop hooks
533
+ * into it (T13796). A malformed file throws untouched.
467
534
  *
468
535
  * @param configPath - the config file (created when missing, unless removing).
469
536
  * @param provider - `claude-code` or `codex`.
470
- * @param install - `false` removes the hook.
537
+ * @param install - `false` removes the dispatcher.
471
538
  * @returns what changed.
472
539
  */
473
- export async function syncJsonAskEnforceHook(
540
+ export async function syncJsonStopDispatchHook(
474
541
  configPath: string,
475
542
  provider: 'claude-code' | 'codex',
476
543
  install: boolean,
477
544
  ): Promise<HeavyHookSyncResult> {
478
- const mine = (hook: unknown) => isAskEnforceHookObject(hook);
479
- if (!install) {
480
- if (!existsSync(configPath)) return 'unchanged';
481
- const removed = await updateJsonConfigFile(configPath, (config) => {
482
- if (config.hooks === undefined) return false;
483
- return placeHeavyHook(hookMap(config), null, 'Stop', mine).changed;
484
- });
485
- return removed ? 'removed' : 'unchanged';
486
- }
487
- ensureParentDir(configPath);
545
+ if (!install && !existsSync(configPath)) return 'unchanged';
546
+ if (install) ensureParentDir(configPath);
488
547
  let result: HeavyHookSyncResult = 'unchanged';
489
548
  await updateJsonConfigFile(configPath, (config) => {
549
+ if (!install && config.hooks === undefined) return false;
490
550
  const hooks = hookMap(config);
491
- const placed = placeHeavyHook(hooks, askEnforceHookObject(provider), 'Stop', mine);
492
- if (placed.found) {
493
- if (placed.changed) result = 'updated';
494
- return placed.changed;
495
- }
496
- const existing = hooks.Stop;
497
- hooks.Stop = [
498
- ...(Array.isArray(existing) ? existing : []),
499
- { hooks: [askEnforceHookObject(provider)] },
500
- ];
501
- result = 'installed';
551
+ const stop = hooks.Stop;
552
+ const had =
553
+ Array.isArray(stop) &&
554
+ stop.some(
555
+ (g) => isPlainObject(g) && Array.isArray(g.hooks) && g.hooks.some(isStopDispatchHookObject),
556
+ );
557
+ if (!applyStopDispatch(hooks, provider, install)) return false;
558
+ result = !install ? 'removed' : had ? 'updated' : 'installed';
502
559
  return true;
503
560
  });
504
561
  return result;