@cleocode/adapters 2026.10.3 → 2026.10.4

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