@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
@@ -37,6 +37,7 @@ import {
37
37
  readFileSync,
38
38
  realpathSync,
39
39
  rmSync,
40
+ statSync,
40
41
  writeFileSync,
41
42
  } from 'node:fs';
42
43
  import { homedir, tmpdir } from 'node:os';
@@ -100,14 +101,70 @@ export const HEAVY_TOOL_WORDS: readonly string[] = Object.freeze([
100
101
  */
101
102
  export type HeavyHookSyncResult = 'installed' | 'updated' | 'removed' | 'unchanged' | 'skipped';
102
103
 
104
+ /**
105
+ * `path` through the native realpath, which returns the case stored on disk,
106
+ * so a differently cased spelling of the home directory on a case-insensitive
107
+ * volume still matches it (T13227 review).
108
+ */
103
109
  function canonical(path: string): string {
104
110
  try {
105
- return realpathSync(path);
111
+ return realpathSync.native(path);
106
112
  } catch {
107
113
  return resolve(path);
108
114
  }
109
115
  }
110
116
 
117
+ /**
118
+ * A config path cannot be created because something on the way to it exists
119
+ * but is not a directory (T13124: a stray empty `<project>/.codex` file made
120
+ * `mkdirSync` throw `EEXIST` and aborted the Codex install). Nothing is
121
+ * written; the delivery reports the provider as `blocked`.
122
+ */
123
+ export class HeavyHookPathBlockedError extends Error {
124
+ /** The existing path that is not a directory. */
125
+ readonly path: string;
126
+
127
+ /**
128
+ * @param path - the existing non-directory path.
129
+ * @param target - the config file that would have lived under it.
130
+ */
131
+ constructor(path: string, target: string) {
132
+ super(`${path} exists but is not a directory, so ${target} cannot be created under it`);
133
+ this.name = 'HeavyHookPathBlockedError';
134
+ this.path = path;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * The nearest existing path at or above `dir` when it is NOT a directory, or
140
+ * `null` when the nearest existing one is a directory (so `mkdir -p dir`
141
+ * can succeed). Errors other than "does not exist" return `null` and are left
142
+ * to the write that follows.
143
+ *
144
+ * @param dir - the directory a config file needs.
145
+ */
146
+ export function nonDirectoryAncestor(dir: string): string | null {
147
+ let cur = resolve(dir);
148
+ for (;;) {
149
+ try {
150
+ return statSync(cur).isDirectory() ? null : cur;
151
+ } catch (err) {
152
+ const code = (err as NodeJS.ErrnoException).code;
153
+ if (code !== 'ENOENT' && code !== 'ENOTDIR') return null;
154
+ }
155
+ const parent = dirname(cur);
156
+ if (parent === cur) return null;
157
+ cur = parent;
158
+ }
159
+ }
160
+
161
+ /** Create `dirname(target)`, or throw {@link HeavyHookPathBlockedError} when a file is in the way. */
162
+ function ensureParentDir(target: string): void {
163
+ const blocked = nonDirectoryAncestor(dirname(target));
164
+ if (blocked !== null) throw new HeavyHookPathBlockedError(blocked, target);
165
+ mkdirSync(dirname(target), { recursive: true });
166
+ }
167
+
111
168
  /**
112
169
  * Whether `projectDir` is the user's home directory. There,
113
170
  * `<home>/.claude/settings.local.json`, `<home>/.codex/hooks.json` and
@@ -268,7 +325,7 @@ export function heavyCommandHookEntry(provider: 'claude-code' | 'codex'): Record
268
325
  }
269
326
 
270
327
  /** Whether one hook object is CLEO's heavy-command hook. */
271
- function isHeavyHookObject(hook: unknown): boolean {
328
+ export function isHeavyHookObject(hook: unknown): boolean {
272
329
  return (
273
330
  isPlainObject(hook) &&
274
331
  typeof hook.command === 'string' &&
@@ -348,7 +405,7 @@ export async function syncJsonHeavyCommandHook(
348
405
  });
349
406
  return removed ? 'removed' : 'unchanged';
350
407
  }
351
- mkdirSync(dirname(configPath), { recursive: true });
408
+ ensureParentDir(configPath);
352
409
  clearOlderCleoMarkers();
353
410
  let result: HeavyHookSyncResult = 'unchanged';
354
411
  await updateJsonConfigFile(configPath, (config) => {
@@ -370,12 +427,26 @@ export async function syncJsonHeavyCommandHook(
370
427
  }
371
428
 
372
429
  /** Claude Code's per-machine settings file, relative to the project. */
373
- const CLAUDE_LOCAL_SETTINGS = '.claude/settings.local.json';
430
+ export const CLAUDE_LOCAL_SETTINGS = '.claude/settings.local.json';
374
431
 
375
- /** First line of the `info/exclude` block CLEO adds (and alone may remove). */
432
+ /** First line of the `info/exclude` block CLEO adds for `settings.local.json` (and alone may remove). */
376
433
  export const LOCAL_SETTINGS_EXCLUDE_MARKER =
377
434
  '# cleo-hook: keep the per-machine heavy-command hook settings out of git (T12983)';
378
435
 
436
+ /**
437
+ * First line of the `info/exclude` block CLEO adds for any other hook file it
438
+ * writes: Codex's `.codex/hooks.json` and the opencode plugin (T13124, gh#1805:
439
+ * an untracked plugin file widened `cleo verify`'s evidence scope).
440
+ */
441
+ export const HOOK_FILE_EXCLUDE_MARKER =
442
+ '# cleo-hook: keep a per-machine heavy-command hook file out of git (T13124)';
443
+
444
+ /** Codex's project hook config, relative to the project. */
445
+ export const CODEX_HOOKS_FILE = '.codex/hooks.json';
446
+
447
+ /** The opencode plugin, relative to the project. */
448
+ export const OPENCODE_PLUGIN_FILE = `.opencode/plugins/${OPENCODE_HEAVY_COMMAND_PLUGIN}`;
449
+
379
450
  /** Run git in `projectDir`; its trimmed stdout, or `null` on any failure. */
380
451
  function gitOutput(projectDir: string, args: readonly string[]): string | null {
381
452
  try {
@@ -397,54 +468,340 @@ function excludeFile(projectDir: string): string | null {
397
468
  return isAbsolute(path) ? path : join(projectDir, path);
398
469
  }
399
470
 
400
- /** The `info/exclude` line for this project's settings file (relative to the repo root). */
401
- function excludeLine(projectDir: string): string {
471
+ /** The `info/exclude` line for a project file (relative to the repo root). */
472
+ function excludeLine(projectDir: string, relPath: string): string {
402
473
  const prefix = gitOutput(projectDir, ['rev-parse', '--show-prefix']) ?? '';
403
- return `/${prefix}${CLAUDE_LOCAL_SETTINGS}`;
474
+ return `/${prefix}${relPath}`;
404
475
  }
405
476
 
406
477
  /**
407
- * Keep `.claude/settings.local.json` out of git when nothing ignores it yet
408
- * (Claude Code ignores the file only when it creates it itself). Adds a marked
409
- * block naming this project's file to the repository's `info/exclude`, never
410
- * to a tracked `.gitignore`. Keyed on the exact path line, so a second CLEO
478
+ * Keep a hook file CLEO wrote out of git when nothing ignores it yet: add a
479
+ * marked block naming this project's file to the repository's `info/exclude`,
480
+ * never to a tracked `.gitignore`. A file git already tracks is left alone (an
481
+ * exclude cannot untrack it). Keyed on the exact path line, so a second CLEO
411
482
  * project in a subdirectory of the same repository gets its own line.
412
483
  * Idempotent; does nothing outside a git work tree.
413
484
  *
414
485
  * @param projectDir - the project root.
486
+ * @param relPath - the file, relative to the project (forward slashes).
487
+ * @param marker - the block's first line.
415
488
  * @returns whether a block was added.
416
489
  */
417
- export function excludeLocalSettingsFromGit(projectDir: string): boolean {
490
+ export function excludeHookFileFromGit(
491
+ projectDir: string,
492
+ relPath: string,
493
+ marker: string = HOOK_FILE_EXCLUDE_MARKER,
494
+ ): boolean {
418
495
  const exclude = excludeFile(projectDir);
419
496
  if (exclude === null) return false;
420
- if (gitOutput(projectDir, ['check-ignore', CLAUDE_LOCAL_SETTINGS]) !== null) return false;
421
- const line = excludeLine(projectDir);
497
+ if (gitOutput(projectDir, ['check-ignore', relPath]) !== null) return false;
498
+ if (gitOutput(projectDir, ['ls-files', '--error-unmatch', relPath]) !== null) return false;
499
+ const line = excludeLine(projectDir, relPath);
422
500
  const text = existsSync(exclude) ? readFileSync(exclude, 'utf-8') : '';
423
501
  if (text.split('\n').includes(line)) return false;
424
- const block = `${LOCAL_SETTINGS_EXCLUDE_MARKER}\n${line}\n`;
502
+ const block = `${marker}\n${line}\n`;
425
503
  mkdirSync(dirname(exclude), { recursive: true });
426
504
  writeFileSync(exclude, text === '' || text.endsWith('\n') ? text + block : `${text}\n${block}`);
427
505
  return true;
428
506
  }
429
507
 
430
508
  /**
431
- * Remove the block {@link excludeLocalSettingsFromGit} added for this
432
- * project, and only that: the exact path line and the marker right above it.
509
+ * Remove the block {@link excludeHookFileFromGit} added for this project's
510
+ * file, and only that: the exact path line and the CLEO marker right above it.
433
511
  *
434
512
  * @param projectDir - the project root.
513
+ * @param relPath - the file, relative to the project (forward slashes).
435
514
  * @returns whether a block was removed.
436
515
  */
437
- export function unexcludeLocalSettingsFromGit(projectDir: string): boolean {
516
+ export function unexcludeHookFileFromGit(projectDir: string, relPath: string): boolean {
438
517
  const exclude = excludeFile(projectDir);
439
518
  if (exclude === null || !existsSync(exclude)) return false;
440
519
  const lines = readFileSync(exclude, 'utf-8').split('\n');
441
- const at = lines.indexOf(excludeLine(projectDir));
442
- if (at < 1 || lines[at - 1] !== LOCAL_SETTINGS_EXCLUDE_MARKER) return false;
520
+ const at = lines.indexOf(excludeLine(projectDir, relPath));
521
+ const above = lines[at - 1];
522
+ if (at < 1 || (above !== LOCAL_SETTINGS_EXCLUDE_MARKER && above !== HOOK_FILE_EXCLUDE_MARKER)) {
523
+ return false;
524
+ }
443
525
  lines.splice(at - 1, 2);
444
526
  writeFileSync(exclude, lines.join('\n'));
445
527
  return true;
446
528
  }
447
529
 
530
+ /**
531
+ * Whether git sees a hook file as an untracked change: inside a work tree,
532
+ * not ignored and not tracked. Such a file shows up in `git status` and
533
+ * widens `cleo verify`'s evidence scope (gh#1805).
534
+ *
535
+ * @param projectDir - the project root.
536
+ * @param relPath - the file, relative to the project (forward slashes).
537
+ */
538
+ export function hookFileVisibleToGit(projectDir: string, relPath: string): boolean {
539
+ if (gitOutput(projectDir, ['rev-parse', '--is-inside-work-tree']) !== 'true') return false;
540
+ if (gitOutput(projectDir, ['check-ignore', relPath]) !== null) return false;
541
+ return gitOutput(projectDir, ['ls-files', '--error-unmatch', relPath]) === null;
542
+ }
543
+
544
+ /**
545
+ * Keep `.claude/settings.local.json` out of git when nothing ignores it yet
546
+ * (Claude Code ignores the file only when it creates it itself). See
547
+ * {@link excludeHookFileFromGit}.
548
+ *
549
+ * @param projectDir - the project root.
550
+ * @returns whether a block was added.
551
+ */
552
+ export function excludeLocalSettingsFromGit(projectDir: string): boolean {
553
+ return excludeHookFileFromGit(projectDir, CLAUDE_LOCAL_SETTINGS, LOCAL_SETTINGS_EXCLUDE_MARKER);
554
+ }
555
+
556
+ /**
557
+ * Remove the block {@link excludeLocalSettingsFromGit} added for this
558
+ * project, and only that.
559
+ *
560
+ * @param projectDir - the project root.
561
+ * @returns whether a block was removed.
562
+ */
563
+ export function unexcludeLocalSettingsFromGit(projectDir: string): boolean {
564
+ return unexcludeHookFileFromGit(projectDir, CLAUDE_LOCAL_SETTINGS);
565
+ }
566
+
567
+ /**
568
+ * A hook config CLEO will not add its per-machine hook to, because it is the
569
+ * project's own: Codex's `.codex/hooks.json` is committable, unlike Claude
570
+ * Code's `settings.local.json`. Writing there would put a per-machine hook
571
+ * into a team file, and excluding it would hide the team's file from git.
572
+ * Nothing is written; the delivery reports `needs-consent` with the entry to
573
+ * add by hand.
574
+ */
575
+ export class HeavyHookSharedConfigError extends Error {
576
+ /** The project's own config file. */
577
+ readonly path: string;
578
+
579
+ /**
580
+ * @param path - the config file.
581
+ * @param why - what makes it the project's own.
582
+ */
583
+ constructor(path: string, why: string) {
584
+ super(`${path} ${why}, so CLEO does not write or hide it`);
585
+ this.name = 'HeavyHookSharedConfigError';
586
+ this.path = path;
587
+ }
588
+ }
589
+
590
+ /**
591
+ * Whether a parsed hooks config is one CLEO created: nothing but a `hooks`
592
+ * map whose every hook object is CLEO's own (`# cleo-hook`), with at least
593
+ * one. An empty `{}` is the user's, not CLEO's.
594
+ */
595
+ function cleoCreatedConfig(config: unknown): boolean {
596
+ if (!isPlainObject(config) || Object.keys(config).some((k) => k !== 'hooks')) return false;
597
+ if (!isPlainObject(config.hooks)) return false;
598
+ let mine = 0;
599
+ for (const groups of Object.values(config.hooks)) {
600
+ if (!Array.isArray(groups)) return false;
601
+ for (const group of groups) {
602
+ if (!isPlainObject(group) || !Array.isArray(group.hooks)) return false;
603
+ for (const hook of group.hooks) {
604
+ if (
605
+ !isPlainObject(hook) ||
606
+ typeof hook.command !== 'string' ||
607
+ !hook.command.includes(CLEO_HOOK_MARKER)
608
+ ) {
609
+ return false;
610
+ }
611
+ mine++;
612
+ }
613
+ }
614
+ }
615
+ return mine > 0;
616
+ }
617
+
618
+ /**
619
+ * Why Codex's `.codex/hooks.json` in `projectDir` is the project's own config
620
+ * that CLEO must not write or hide, or `null` when CLEO may: the file is
621
+ * absent, or is one CLEO created and git does not track (review MED-3). An
622
+ * unreadable file is left to the sync, which refuses it.
623
+ *
624
+ * @param projectDir - the project root.
625
+ * @param checkGit - also ask git whether the file is tracked (one spawn);
626
+ * `false` reads the file only (the session briefing).
627
+ */
628
+ export function codexHooksSharedReason(projectDir: string, checkGit = true): string | null {
629
+ const file = join(projectDir, CODEX_HOOKS_FILE);
630
+ // Tracking first: a committed file is the team's whatever it holds, even
631
+ // empty (review MED-3a).
632
+ if (
633
+ checkGit &&
634
+ gitOutput(projectDir, ['ls-files', '--error-unmatch', CODEX_HOOKS_FILE]) !== null
635
+ ) {
636
+ return 'is tracked by git (a shared team config)';
637
+ }
638
+ if (!existsSync(file)) return null;
639
+ let config: unknown;
640
+ try {
641
+ config = JSON.parse(readFileSync(file, 'utf-8'));
642
+ } catch {
643
+ // Empty or not valid JSON: CLEO never writes such a file, so it is the user's.
644
+ return "is the project's own Codex hook config (empty or not valid JSON)";
645
+ }
646
+ return cleoCreatedConfig(config) ? null : "is the project's own Codex hook config";
647
+ }
648
+
649
+ /**
650
+ * Whether the committed (`HEAD`) version of Codex's `.codex/hooks.json` holds
651
+ * CLEO's hook: the team adopted it, so mode `off` on one machine must not
652
+ * remove it from the tracked file (review MED-3b).
653
+ *
654
+ * @param projectDir - the project root.
655
+ */
656
+ export function committedCodexHookHoldsCleo(projectDir: string): boolean {
657
+ const committed = gitOutput(projectDir, ['show', `HEAD:./${CODEX_HOOKS_FILE}`]);
658
+ if (committed === null || committed === '') return false;
659
+ try {
660
+ const config: unknown = JSON.parse(committed);
661
+ if (!isPlainObject(config)) return false;
662
+ const groups = isPlainObject(config.hooks) ? config.hooks.PreToolUse : undefined;
663
+ return (
664
+ Array.isArray(groups) &&
665
+ groups.some(
666
+ (g) => isPlainObject(g) && Array.isArray(g.hooks) && g.hooks.some(isHeavyHookObject),
667
+ )
668
+ );
669
+ } catch {
670
+ return false;
671
+ }
672
+ }
673
+
674
+ /** Structural equality of two parsed JSON values (key order ignored). */
675
+ function jsonEqual(a: unknown, b: unknown): boolean {
676
+ if (Array.isArray(a) || Array.isArray(b)) {
677
+ return (
678
+ Array.isArray(a) &&
679
+ Array.isArray(b) &&
680
+ a.length === b.length &&
681
+ a.every((v, i) => jsonEqual(v, b[i]))
682
+ );
683
+ }
684
+ if (isPlainObject(a) && isPlainObject(b)) {
685
+ const keys = Object.keys(a);
686
+ return (
687
+ keys.length === Object.keys(b).length &&
688
+ keys.every((k) => Object.hasOwn(b, k) && jsonEqual(a[k], b[k]))
689
+ );
690
+ }
691
+ return a === b;
692
+ }
693
+
694
+ /**
695
+ * Mode `off` on a TRACKED `hooks.json` whose only change from `HEAD` is
696
+ * CLEO's hook: write `HEAD`'s exact bytes back, so the file returns to its
697
+ * committed state instead of being re-serialised with CLEO's formatting
698
+ * (review LOW-3). Returns whether it did.
699
+ *
700
+ * @param projectDir - the project root.
701
+ */
702
+ export function restoreCommittedCodexHooks(projectDir: string): boolean {
703
+ if (gitOutput(projectDir, ['ls-files', '--error-unmatch', CODEX_HOOKS_FILE]) === null)
704
+ return false;
705
+ const file = join(projectDir, CODEX_HOOKS_FILE);
706
+ let committed: string;
707
+ let working: unknown;
708
+ try {
709
+ committed = execFileSync('git', ['-C', projectDir, 'show', `HEAD:./${CODEX_HOOKS_FILE}`], {
710
+ encoding: 'utf-8',
711
+ stdio: ['ignore', 'pipe', 'ignore'],
712
+ timeout: 5000,
713
+ });
714
+ working = JSON.parse(readFileSync(file, 'utf-8'));
715
+ } catch {
716
+ return false;
717
+ }
718
+ if (!isPlainObject(working)) return false;
719
+ const hooks = hookMap(working);
720
+ if (!placeHeavyHook(hooks, null).changed) return false;
721
+ try {
722
+ if (!jsonEqual(working, JSON.parse(committed))) return false;
723
+ } catch {
724
+ return false;
725
+ }
726
+ writeFileSync(file, committed);
727
+ return true;
728
+ }
729
+
730
+ /**
731
+ * Whether CLEO's own marked `info/exclude` block names this project file
732
+ * (it hides the file from git).
733
+ *
734
+ * @param projectDir - the project root.
735
+ * @param relPath - the file, relative to the project (forward slashes).
736
+ */
737
+ export function excludedByCleo(projectDir: string, relPath: string): boolean {
738
+ const exclude = excludeFile(projectDir);
739
+ if (exclude === null || !existsSync(exclude)) return false;
740
+ const lines = readFileSync(exclude, 'utf-8').split('\n');
741
+ const at = lines.indexOf(excludeLine(projectDir, relPath));
742
+ const above = lines[at - 1];
743
+ return at >= 1 && (above === LOCAL_SETTINGS_EXCLUDE_MARKER || above === HOOK_FILE_EXCLUDE_MARKER);
744
+ }
745
+
746
+ /**
747
+ * The exact `PreToolUse` entry to add to Codex's `.codex/hooks.json` by hand,
748
+ * for a project whose own config CLEO does not write (`needs-consent`).
749
+ */
750
+ export function codexHookSnippet(): string {
751
+ return JSON.stringify(heavyCommandHookEntry('codex'));
752
+ }
753
+
754
+ /**
755
+ * Whether git tracks a project file and it has uncommitted changes.
756
+ *
757
+ * @param projectDir - the project root.
758
+ * @param relPath - the file, relative to the project (forward slashes).
759
+ */
760
+ export function trackedFileModified(projectDir: string, relPath: string): boolean {
761
+ if (gitOutput(projectDir, ['ls-files', '--error-unmatch', relPath]) === null) return false;
762
+ return (gitOutput(projectDir, ['diff', '--name-only', 'HEAD', '--', relPath]) ?? '') !== '';
763
+ }
764
+
765
+ /**
766
+ * Codex: sync the hook in `<project>/.codex/hooks.json` and keep that file out
767
+ * of git (T13124), as {@link syncClaudeCodeHeavyCommandHook} does for Claude
768
+ * Code's settings. A shared `hooks.json` (tracked, or holding hooks that are
769
+ * not CLEO's) is never written: {@link HeavyHookSharedConfigError}. Mode `off`
770
+ * still removes CLEO's own hook object from it.
771
+ *
772
+ * @param projectDir - the project root.
773
+ * @param mode - the resolved hook mode.
774
+ * @returns what changed in `hooks.json`.
775
+ * @throws {HeavyHookSharedConfigError} when `hooks.json` is shared.
776
+ */
777
+ export async function syncCodexHeavyCommandHook(
778
+ projectDir: string,
779
+ mode: HeavyCommandHookMode,
780
+ ): Promise<HeavyHookSyncResult> {
781
+ const file = join(projectDir, CODEX_HOOKS_FILE);
782
+ if (mode !== 'off') {
783
+ const shared = codexHooksSharedReason(projectDir);
784
+ if (shared !== null) {
785
+ // A file CLEO once created and excluded is now the user's: stop hiding
786
+ // it from git (review MED-3c). Only CLEO's own marked block goes.
787
+ unexcludeHookFileFromGit(projectDir, CODEX_HOOKS_FILE);
788
+ throw new HeavyHookSharedConfigError(file, shared);
789
+ }
790
+ } else if (committedCodexHookHoldsCleo(projectDir)) {
791
+ throw new HeavyHookSharedConfigError(
792
+ file,
793
+ "holds CLEO's hook as the team committed it (removing it is a team change)",
794
+ );
795
+ } else if (restoreCommittedCodexHooks(projectDir)) {
796
+ unexcludeHookFileFromGit(projectDir, CODEX_HOOKS_FILE);
797
+ return 'removed';
798
+ }
799
+ const result = await syncJsonHeavyCommandHook(file, 'codex', mode);
800
+ if (mode === 'off') unexcludeHookFileFromGit(projectDir, CODEX_HOOKS_FILE);
801
+ else excludeHookFileFromGit(projectDir, CODEX_HOOKS_FILE);
802
+ return result;
803
+ }
804
+
448
805
  /**
449
806
  * Claude Code: sync the hook in `<project>/.claude/settings.local.json`, keep
450
807
  * that file out of git, and remove a hook an earlier build put in the shared
@@ -465,11 +822,17 @@ export async function syncClaudeCodeHeavyCommandHook(
465
822
  );
466
823
  if (mode === 'off') unexcludeLocalSettingsFromGit(projectDir);
467
824
  else excludeLocalSettingsFromGit(projectDir);
468
- await syncJsonHeavyCommandHook(
469
- join(projectDir, '.claude', 'settings.json'),
470
- 'claude-code',
471
- 'off',
472
- );
825
+ try {
826
+ await syncJsonHeavyCommandHook(
827
+ join(projectDir, '.claude', 'settings.json'),
828
+ 'claude-code',
829
+ 'off',
830
+ );
831
+ } catch {
832
+ // Legacy cleanup only (T13124): a shared settings.json that is not valid
833
+ // JSON is the user's to fix, and Claude Code cannot load a hook from it
834
+ // anyway. The local install above already succeeded.
835
+ }
473
836
  return result;
474
837
  }
475
838
 
@@ -537,7 +900,8 @@ export function opencodeHeavyCommandPluginSource(): string {
537
900
 
538
901
  /**
539
902
  * Write (or, mode `off`, delete) the opencode plugin in
540
- * `<projectDir>/.opencode/plugins/`. Rewritten only when its content changed.
903
+ * `<projectDir>/.opencode/plugins/`, and keep it out of git (T13124).
904
+ * Rewritten only when its content changed.
541
905
  *
542
906
  * @param projectDir - the project root.
543
907
  * @param mode - the resolved hook mode.
@@ -547,16 +911,21 @@ export function syncOpencodeHeavyCommandPlugin(
547
911
  projectDir: string,
548
912
  mode: HeavyCommandHookMode,
549
913
  ): HeavyHookSyncResult {
550
- const pluginPath = join(projectDir, '.opencode', 'plugins', OPENCODE_HEAVY_COMMAND_PLUGIN);
914
+ const pluginPath = join(projectDir, OPENCODE_PLUGIN_FILE);
551
915
  const exists = existsSync(pluginPath);
552
916
  if (mode === 'off') {
917
+ unexcludeHookFileFromGit(projectDir, OPENCODE_PLUGIN_FILE);
553
918
  if (!exists) return 'unchanged';
554
919
  rmSync(pluginPath, { force: true });
555
920
  return 'removed';
556
921
  }
557
922
  const source = opencodeHeavyCommandPluginSource();
558
- if (exists && readFileSync(pluginPath, 'utf-8') === source) return 'unchanged';
559
- mkdirSync(dirname(pluginPath), { recursive: true });
560
- writeFileSync(pluginPath, source, 'utf-8');
561
- return exists ? 'updated' : 'installed';
923
+ const current = exists && readFileSync(pluginPath, 'utf-8') === source;
924
+ if (!current) {
925
+ ensureParentDir(pluginPath);
926
+ writeFileSync(pluginPath, source, 'utf-8');
927
+ }
928
+ // An untracked plugin file widened `cleo verify`'s evidence scope (gh#1805).
929
+ excludeHookFileFromGit(projectDir, OPENCODE_PLUGIN_FILE);
930
+ return current ? 'unchanged' : exists ? 'updated' : 'installed';
562
931
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * A provider's PROJECT instruction file, skipped (not failed) at a project
3
+ * rooted at the home directory (T13227).
4
+ *
5
+ * Providers load CLAUDE.md / AGENTS.md / GEMINI.md from the working directory
6
+ * up through every ancestor, so a file at `$HOME` reaches every session under
7
+ * it: CAAMP refuses it with `HomeInstructionFileError`. An adapter's install
8
+ * reports that step as `skipped` and carries on with the rest, the way the
9
+ * heavy-command hook already skips the home directory (T12983).
10
+ *
11
+ * @task T13227
12
+ */
13
+
14
+ import {
15
+ type EnsureProviderInstructionFileOptions,
16
+ type EnsureProviderInstructionFileResult,
17
+ ensureProviderInstructionFile,
18
+ HomeInstructionFileError,
19
+ } from '@cleocode/caamp';
20
+
21
+ /**
22
+ * Ensure a provider's project instruction file, or `null` when the project is
23
+ * the home directory and the file was refused (nothing written).
24
+ *
25
+ * @param providerId - CAAMP provider id.
26
+ * @param projectDir - the project root.
27
+ * @param options - references, content, scope.
28
+ * @returns the CAAMP result, or `null` when skipped at `$HOME`.
29
+ *
30
+ * @example
31
+ * ```typescript
32
+ * const r = await ensureProjectInstructionFile('codex', projectDir, { scope: 'project' });
33
+ * if (r === null) details.instructionFile = 'skipped';
34
+ * ```
35
+ */
36
+ export async function ensureProjectInstructionFile(
37
+ providerId: string,
38
+ projectDir: string,
39
+ options: EnsureProviderInstructionFileOptions,
40
+ ): Promise<EnsureProviderInstructionFileResult | null> {
41
+ try {
42
+ return await ensureProviderInstructionFile(providerId, projectDir, options);
43
+ } catch (err) {
44
+ if (err instanceof HomeInstructionFileError) return null;
45
+ throw err;
46
+ }
47
+ }