@event4u/agent-config 8.2.0 → 8.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.claude-plugin/marketplace.json +4 -426
  2. package/.claude-plugin/skills/install-agent-config/SKILL.md +32 -0
  3. package/CHANGELOG.md +57 -0
  4. package/MIGRATION.md +18 -0
  5. package/README.md +86 -3
  6. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  7. package/dist/cli/registry.js +1 -0
  8. package/dist/cli/registry.js.map +1 -1
  9. package/dist/discovery/deprecation-report.md +1 -1
  10. package/dist/discovery/discovery-manifest.json +1 -1
  11. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  12. package/dist/discovery/discovery-manifest.summary.md +1 -1
  13. package/dist/discovery/orphan-report.md +1 -1
  14. package/dist/discovery/packs.json +1 -1
  15. package/dist/discovery/trust-report.md +1 -1
  16. package/dist/discovery/workspaces.json +1 -1
  17. package/dist/install/install.mjs +586 -386
  18. package/dist/mcp/registry-manifest.json +2 -2
  19. package/docs/contracts/CHANGELOG-conventions.md +8 -5
  20. package/docs/contracts/branch-protection-policy.md +14 -5
  21. package/docs/contracts/mcp-phase-1-scope.md +75 -0
  22. package/docs/contracts/mcp-tool-inventory.md +22 -15
  23. package/docs/contracts/release-pr-gating.md +22 -11
  24. package/docs/contracts/surface-matrix.md +103 -0
  25. package/docs/decisions/ADR-111-canonical-glama-listing.md +97 -0
  26. package/docs/decisions/ADR-112-stdio-lite-stays-read-only.md +92 -0
  27. package/docs/decisions/ADR-113-ci-native-release-label-trigger.md +201 -0
  28. package/docs/decisions/INDEX.md +3 -0
  29. package/docs/distribution/registry-submissions.md +1 -1
  30. package/docs/getting-started.md +8 -6
  31. package/docs/installation.md +13 -14
  32. package/docs/mcp-server.md +32 -20
  33. package/docs/setup/mcp-cloud-endpoints.md +9 -2
  34. package/package.json +2 -2
  35. package/src/config/surface-matrix.yml +213 -0
  36. package/src/scripts/_cli/cmd_converge.ts +399 -0
  37. package/src/scripts/_cli/cmd_doctor.ts +203 -9
  38. package/src/scripts/_cli/cmd_uninstall.ts +22 -0
  39. package/src/scripts/_cli/cmd_upgrade.ts +101 -16
  40. package/src/scripts/_dispatch.bash +13 -0
  41. package/src/scripts/_lib/claude_settings_hooks.ts +251 -0
  42. package/src/scripts/condense.ts +74 -65
  43. package/src/scripts/hook_manifest.yaml +16 -7
  44. package/src/scripts/install +23 -4
  45. package/src/scripts/install.ts +30 -0
  46. package/src/scripts/lint_glama_drift.ts +75 -0
  47. package/src/scripts/lint_marketplace.ts +54 -56
  48. package/src/scripts/lint_surface_matrix.ts +200 -0
  49. package/src/scripts/mcp_exec/safety_envelope.ts +105 -0
  50. package/src/scripts/mcp_registry_submit.sh +14 -3
  51. package/src/scripts/mcp_server/__main__.ts +6 -8
  52. package/src/scripts/mcp_server/consumer_tool_catalog.json +78 -3
  53. package/src/scripts/mcp_server/tools.ts +731 -0
  54. package/src/scripts/release.ts +203 -39
  55. package/src/scripts/surface_probe_hook.ts +231 -0
@@ -55,6 +55,7 @@
55
55
  */
56
56
 
57
57
  import * as fs from 'node:fs';
58
+ import * as os from 'node:os';
58
59
  import * as path from 'node:path';
59
60
  import process from 'node:process';
60
61
  import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -62,6 +63,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
62
63
  import * as fs_atomic from '../_lib/fs_atomic.js';
63
64
  import * as installed_lock from '../_lib/installed_lock.js';
64
65
  import * as installed_tools from '../_lib/installed_tools.js';
66
+ import { remove_managed_hooks } from '../_lib/claude_settings_hooks.js';
65
67
  import { resolve_project_root } from '../_lib/agent_settings.js';
66
68
  import { subtract_pointers } from '../_lib/json_pointers.js';
67
69
  import { PROJECT_BRIDGE_MARKERS, USER_SCOPE_PATHS } from '../install.js';
@@ -771,6 +773,26 @@ function _uninstall_global(opts: Options): number {
771
773
  removed_names.push(tool);
772
774
  }
773
775
  }
776
+ // Managed Claude hooks live in ~/.claude/settings.json (single-surface
777
+ // model) — remove exactly our entries, never user-owned ones.
778
+ if (tools.includes('claude-code')) {
779
+ const settings_path = path.join(os.homedir(), '.claude', 'settings.json');
780
+ if (opts.dry_run) {
781
+ _print(_stdout, ` · claude-code: would remove managed hooks from ${settings_path}`);
782
+ } else {
783
+ try {
784
+ const res = remove_managed_hooks(settings_path);
785
+ if (res.changed) {
786
+ _print(
787
+ _stdout,
788
+ ` · claude-code: removed managed hooks (${res.events.join(', ')})`,
789
+ );
790
+ }
791
+ } catch (e) {
792
+ _print(_stderr, `⚠️ claude-code: managed-hook removal skipped — ${String(e)}`);
793
+ }
794
+ }
795
+ }
774
796
  if (lock !== null && !opts.dry_run) {
775
797
  const remaining = (lock.tools ?? []).filter((t) => !tools.includes(t));
776
798
  const version = String((lock as unknown as AnyDict)['agent_config_version'] ?? '');
@@ -90,7 +90,7 @@ import * as installed_lock from '../_lib/installed_lock.js';
90
90
  import * as update_check from '../_lib/update_check.js';
91
91
  import * as cli_wrapper from '../_lib/cli_wrapper.js';
92
92
  import * as claude_plugin from '../_lib/claude_plugin.js';
93
- import { event4u_root } from '../_lib/user_global_paths.js';
93
+ import { event4u_root, write_target } from '../_lib/user_global_paths.js';
94
94
  import { project_settings_path } from '../_lib/agent_settings.js';
95
95
  import { _is_source_repo } from './cmd_refresh.js';
96
96
 
@@ -407,10 +407,11 @@ function _refresh_claude_plugin(
407
407
  interface Args {
408
408
  check: boolean;
409
409
  dry_run: boolean;
410
+ converge: boolean;
410
411
  }
411
412
 
412
413
  const PROG = 'agent-config upgrade';
413
- const USAGE = `usage: ${PROG} [-h] [--check] [--dry-run]\n`;
414
+ const USAGE = `usage: ${PROG} [-h] [--check] [--dry-run] [--converge]\n`;
414
415
 
415
416
  function _argError(msg: string): never {
416
417
  _stderr.write(USAGE);
@@ -419,7 +420,7 @@ function _argError(msg: string): never {
419
420
  }
420
421
 
421
422
  function _parse(argv: string[]): Args {
422
- const args: Args = { check: false, dry_run: false };
423
+ const args: Args = { check: false, dry_run: false, converge: false };
423
424
  const positionals: string[] = [];
424
425
  let i = 0;
425
426
  while (i < argv.length) {
@@ -447,6 +448,14 @@ function _parse(argv: string[]): Args {
447
448
  i += 1;
448
449
  continue;
449
450
  }
451
+ if (flag === '--converge') {
452
+ if (inlineVal !== null) {
453
+ _argError(`argument --converge: ignored explicit argument '${inlineVal}'`);
454
+ }
455
+ args.converge = true;
456
+ i += 1;
457
+ continue;
458
+ }
450
459
  if (a.startsWith('-') && a !== '-') {
451
460
  _argError(`unrecognized arguments: ${a}`);
452
461
  }
@@ -504,12 +513,11 @@ export async function main(argv: string[] | null = null, options: MainOptions =
504
513
  }
505
514
 
506
515
  const target = `${PACKAGE_NAME}@latest`;
507
- const steps: string[][] = [
508
- ['npm', 'install', '-g', target],
509
- // --no-ui: the setup wizard is an onboarding surface, not an upgrade
510
- // step — its foreground server used to block the upgrade until Ctrl-C.
511
- [_agent_config_bin(), 'global', '--no-ui'],
512
- ];
516
+ const npm_cmd: string[] = ['npm', 'install', '-g', target];
517
+ // --no-ui: the setup wizard is an onboarding surface, not an upgrade
518
+ // step — its foreground server used to block the upgrade until Ctrl-C.
519
+ const global_cmd: string[] = [_agent_config_bin(), 'global', '--no-ui'];
520
+ const steps: string[][] = [npm_cmd, global_cmd];
513
521
 
514
522
  const project_root = options.project_root ?? process.cwd();
515
523
  const sync_targets = _settings_sync_targets(project_root);
@@ -529,23 +537,73 @@ export async function main(argv: string[] | null = null, options: MainOptions =
529
537
  return 0;
530
538
  }
531
539
 
532
- for (const cmd of steps) {
540
+ // Step 1 — the new package version. Nothing after makes sense without
541
+ // it, so this is the only hard-abort step.
542
+ _print(out, '→ ' + npm_cmd.join(' '));
543
+ const npm_rc = runner(npm_cmd);
544
+ if (npm_rc !== 0) {
545
+ _print(
546
+ err,
547
+ `❌ agent-config upgrade: step failed (exit ${npm_rc}): ` + `${npm_cmd.join(' ')}`,
548
+ );
549
+ return 1;
550
+ }
551
+
552
+ // Steps 2..N — independent. A failing step no longer skips the rest:
553
+ // the 8.2.0 failure class (aborted `global` step silently skipping the
554
+ // plugin refresh + settings sync) is closed by running every remaining
555
+ // step and reporting per-step outcomes in the summary. A failed
556
+ // essential step (global deploy) still exits 1 — after the others ran.
557
+ const failed_essential: string[] = [];
558
+ for (const cmd of steps.slice(1)) {
533
559
  _print(out, '→ ' + cmd.join(' '));
534
560
  const rc = runner(cmd);
535
561
  if (rc !== 0) {
562
+ failed_essential.push(cmd.join(' '));
536
563
  _print(
537
564
  err,
538
- `❌ agent-config upgrade: step failed (exit ${rc}): ` + `${cmd.join(' ')}`,
565
+ `❌ agent-config upgrade: step failed (exit ${rc}): ${cmd.join(' ')} — ` +
566
+ 'continuing with the remaining steps.',
539
567
  );
540
- return 1;
541
568
  }
542
569
  }
543
570
 
544
571
  // Refresh the OPTIONAL Claude Code plugin so its command surface tracks
545
- // the upgrade (no-op when the plugin is not installed — the file
546
- // projection is a full install on its own).
572
+ // the upgrade — gated on the plugin actually being installed (empty step
573
+ // list otherwise). Deprecation window of the single-surface model: once
574
+ // the plugin is retired this block disappears with it.
547
575
  _refresh_claude_plugin(plugin_steps, runner, out, err);
548
576
 
577
+ // Duplicate-surface convergence (road-to-install-path-convergence
578
+ // Phase 3). Consent model (council Q2, 2026-07-07):
579
+ // --converge → explicit consent for this run; persists
580
+ // install.auto_converge: true as standing consent.
581
+ // standing key true → auto-converge (the user opted in earlier).
582
+ // no key + TTY → converge asks ONE y/N when duplicates exist.
583
+ // no key + non-TTY → today's print-only prompt; NEVER a silent
584
+ // mutation of a user-owned surface.
585
+ {
586
+ const converge_mod = await import('./cmd_converge.js');
587
+ const consent = converge_mod.read_consent(write_target('agent-settings.yml'));
588
+ const tty = Boolean(process.stdin.isTTY && process.stdout.isTTY);
589
+ if (args.converge || consent || tty) {
590
+ const rc = converge_mod.main(args.converge ? ['--yes'] : [], { out, err });
591
+ if (rc !== 0 && (args.converge || consent)) {
592
+ failed_essential.push('agent-config converge');
593
+ }
594
+ } else if (plugin_steps.length > 0) {
595
+ _print(
596
+ out,
597
+ 'ℹ️ Claude Code marketplace plugin detected — deprecated surface. Hooks now ' +
598
+ 'live in ~/.claude/settings.json (wired by the global step above), so the ' +
599
+ 'plugin only duplicates skills/commands. Remove it with:\n' +
600
+ ' claude plugin uninstall ' +
601
+ `${claude_plugin.CLAUDE_PLUGIN_ID}@${claude_plugin.CLAUDE_MARKETPLACE_NAME}\n` +
602
+ ' (or: agent-config converge)',
603
+ );
604
+ }
605
+ }
606
+
549
607
  // Bring existing settings files up to the NEW template (additive; the
550
608
  // subprocess resolves the freshly installed binary + template).
551
609
  _sync_settings_files(sync_targets, runner, out, err);
@@ -556,10 +614,37 @@ export async function main(argv: string[] | null = null, options: MainOptions =
556
614
  // (marker-guarded; foreign hooks are never touched).
557
615
  _maybe_refresh_git_hook(project_root, runner, out, err);
558
616
 
617
+ // Doctor runs LAST, every time, non-fatal: its duplicate-surface and
618
+ // hook-wiring findings ARE the upgrade summary's health section — a
619
+ // mixed state (stale plugin, missing hooks) is named in the same run
620
+ // instead of waiting for the user to think of running doctor.
621
+ const doctor_cmd = [_agent_config_bin(), 'doctor'];
622
+ _print(out, '→ ' + doctor_cmd.join(' '));
623
+ const doctor_rc = runner(doctor_cmd);
624
+ if (doctor_rc !== 0) {
625
+ _print(
626
+ err,
627
+ `⚠️ agent-config doctor reported findings (exit ${doctor_rc}) — ` +
628
+ 'see the check output above for per-item fixes.',
629
+ );
630
+ }
631
+
632
+ if (failed_essential.length > 0) {
633
+ _print(
634
+ err,
635
+ `❌ agent-config upgrade finished with ${failed_essential.length} failed ` +
636
+ `step(s):\n` +
637
+ failed_essential.map((c) => ` · ${c}`).join('\n') +
638
+ '\n Re-run `agent-config upgrade`, or run the failed step directly. ' +
639
+ '`agent-config doctor` names anything left in a mixed state.',
640
+ );
641
+ return 1;
642
+ }
643
+
559
644
  _print(
560
645
  out,
561
- '✅ agent-config upgraded. Run `agent-config doctor` to verify ' +
562
- 'PATH + plugin parity.',
646
+ '✅ agent-config upgraded' +
647
+ (doctor_rc === 0 ? ' — doctor green.' : ' — review the doctor findings above.'),
563
648
  );
564
649
  return 0;
565
650
  }
@@ -105,6 +105,11 @@ Tier 1 — power-user (release shape, audit, migration):
105
105
  Lists missing, modified, and foreign files.
106
106
  Exits 1 on drift, 2 on missing lockfile.
107
107
  Flags: --json | --project=<path>
108
+ converge Consented cleanup of duplicate install surfaces
109
+ (surface-matrix driven). Never touches
110
+ user-authored files; persists standing consent
111
+ (install.auto_converge) on first use.
112
+ Flags: --dry-run | --yes
108
113
  explain Read-only decision-chain trace.
109
114
  Usage: explain config | explain rule <name>
110
115
  | explain route "<text>"
@@ -959,6 +964,13 @@ cmd_doctor() {
959
964
  exec_ts "$PACKAGE_ROOT/src/scripts/_cli/cmd_doctor.ts" "$@"
960
965
  }
961
966
 
967
+ # `agent-config converge` — consented duplicate-surface cleanup.
968
+ # Exit codes: 0 converged/nothing-to-do/dry-run · 1 refused/failed · 2 usage.
969
+ # See scripts/_cli/cmd_converge.ts.
970
+ cmd_converge() {
971
+ exec_ts "$PACKAGE_ROOT/src/scripts/_cli/cmd_converge.ts" "$@"
972
+ }
973
+
962
974
  # `agent-config conformance` — consumer conformance contract
963
975
  # (road-to-flow-learnings Phase 0). Doctor --ci semantics plus the five
964
976
  # consumer checks (txlog tail, router pointers, dispatcher smoke,
@@ -1048,6 +1060,7 @@ main() {
1048
1060
  uninstall) cmd_uninstall "$@" ;;
1049
1061
  prune) cmd_prune "$@" ;;
1050
1062
  doctor) cmd_doctor "$@" ;;
1063
+ converge) cmd_converge "$@" ;;
1051
1064
  conformance) cmd_conformance "$@" ;;
1052
1065
  versions) cmd_versions "$@" ;;
1053
1066
  explain) cmd_explain "$@" ;;
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Managed Claude Code hook registration in settings.json.
3
+ *
4
+ * road-to-claude-code-single-surface Phase 1: the marketplace plugin used to
5
+ * be the only carrier of the deterministic hook matrix (hooks/hooks.json).
6
+ * This module registers the same matrix directly in a Claude Code settings
7
+ * file (`~/.claude/settings.json` for the global install) so the plugin is
8
+ * no longer required for hooks.
9
+ *
10
+ * Contract (parity findings: agents/settings/contexts/claude-code-hook-parity.md):
11
+ *
12
+ * - The hook schema is identical between plugin hooks.json and settings.json;
13
+ * Claude Code fires all sources in parallel and dedupes identical command
14
+ * strings — so a still-installed plugin never double-fires during the
15
+ * migration window.
16
+ * - Managed entries are identified by the dispatch signature inside the
17
+ * command string (MANAGED_SIGNATURE). No extra marker field is written —
18
+ * content-derived identity keeps the entry byte-identical to the plugin's,
19
+ * which is what makes the dedup guarantee hold.
20
+ * - User-owned entries (anything without the signature) are never touched,
21
+ * reordered, or removed. Managed entries are replaced in place per event.
22
+ * - Writes are atomic (temp file + rename via atomicWriteFile) and guarded
23
+ * by a sidecar lockfile so two concurrent installers cannot tear the file.
24
+ * - A settings file that exists but does not parse as JSON is NEVER
25
+ * overwritten — the operation throws CorruptSettingsError and leaves the
26
+ * file byte-identical (the user's file outranks our convenience).
27
+ *
28
+ * The hook matrix itself derives from src/scripts/hook_manifest.yaml via
29
+ * build_claude_hook_matrix() — the same single source generate_plugin_hooks()
30
+ * (condense.ts) uses for the plugin's hooks/hooks.json.
31
+ */
32
+
33
+ import * as fs from 'node:fs';
34
+ import * as path from 'node:path';
35
+
36
+ import * as YAML from 'yaml';
37
+
38
+ import { atomicWriteFile } from '../../install/atomic.js';
39
+
40
+ // Bundle note: a static import of the CJS `yaml` package requires the
41
+ // createRequire banner in `build:install-bundle` (package.json) — without it
42
+ // the esbuild ESM bundle throws `Dynamic require of "process" is not
43
+ // supported` at module init. A bare lazy `require('yaml')` is NOT an
44
+ // alternative: it is a ReferenceError under plain tsx/node ESM (CI).
45
+ function _yaml_parse(text: string): unknown {
46
+ return YAML.parse(text);
47
+ }
48
+
49
+ /** Substring that identifies an entry as agent-config-managed. */
50
+ export const MANAGED_SIGNATURE = 'dispatch:hook --platform claude';
51
+
52
+ export class CorruptSettingsError extends Error {
53
+ constructor(file: string, cause: string) {
54
+ super(
55
+ `${file} exists but is not valid JSON (${cause}). ` +
56
+ 'Refusing to overwrite a user-owned settings file — fix or remove it, then re-run.',
57
+ );
58
+ this.name = 'CorruptSettingsError';
59
+ }
60
+ }
61
+
62
+ export class SettingsLockError extends Error {
63
+ constructor(lockPath: string) {
64
+ super(
65
+ `settings lock ${lockPath} is held by another process. ` +
66
+ 'Re-run when the concurrent install finishes (stale lock: delete the file).',
67
+ );
68
+ this.name = 'SettingsLockError';
69
+ }
70
+ }
71
+
72
+ interface HookCommand {
73
+ type: string;
74
+ command: string;
75
+ [k: string]: unknown;
76
+ }
77
+
78
+ interface HookGroup {
79
+ matcher?: string;
80
+ hooks: HookCommand[];
81
+ [k: string]: unknown;
82
+ }
83
+
84
+ /** native event name → full shell command, e.g. { SessionStart: "BIN=..." }. */
85
+ export type ClaudeHookMatrix = Record<string, string>;
86
+
87
+ /**
88
+ * Derive the Claude hook matrix from hook_manifest.yaml — the SAME source
89
+ * generate_plugin_hooks() uses, so settings-registered hooks and the plugin's
90
+ * hooks.json can never drift from each other.
91
+ */
92
+ export function build_claude_hook_matrix(manifest_path: string): ClaudeHookMatrix {
93
+ const raw = fs.readFileSync(manifest_path, 'utf8');
94
+ const manifest = (_yaml_parse(raw) ?? {}) as Record<string, unknown>;
95
+ const hook_spec = (manifest['schema_version'] as unknown) ?? 1;
96
+ const platforms = (manifest['platforms'] ?? {}) as Record<string, unknown>;
97
+ const claude_events = ((platforms['claude'] ?? {}) as Record<string, unknown>) || {};
98
+ const aliasesAll = (manifest['native_event_aliases'] ?? {}) as Record<string, unknown>;
99
+ const aliases = ((aliasesAll['claude'] ?? {}) as Record<string, unknown>) || {};
100
+
101
+ const ac_to_native: Record<string, string> = {};
102
+ for (const [native, ac] of Object.entries(aliases)) {
103
+ ac_to_native[String(ac)] = native;
104
+ }
105
+
106
+ const matrix: ClaudeHookMatrix = {};
107
+ for (const [ac_event, concerns] of Object.entries(claude_events)) {
108
+ if (!concerns || (Array.isArray(concerns) && concerns.length === 0)) continue;
109
+ const native = ac_to_native[ac_event];
110
+ if (native === undefined) continue;
111
+ matrix[native] =
112
+ 'BIN="$CLAUDE_PROJECT_DIR/agent-config"; [ -x "$BIN" ] || BIN=agent-config; ' +
113
+ `"$BIN" dispatch:hook --platform claude --event ${ac_event} ` +
114
+ `--native-event ${native} --project-dir "$CLAUDE_PROJECT_DIR" ` +
115
+ `--min-version ${String(hook_spec)}`;
116
+ }
117
+ return matrix;
118
+ }
119
+
120
+ function _is_managed_group(group: unknown): boolean {
121
+ if (typeof group !== 'object' || group === null || Array.isArray(group)) return false;
122
+ const hooks = (group as HookGroup).hooks;
123
+ if (!Array.isArray(hooks)) return false;
124
+ return hooks.some(
125
+ (h) =>
126
+ typeof h === 'object' &&
127
+ h !== null &&
128
+ typeof (h as HookCommand).command === 'string' &&
129
+ (h as HookCommand).command.includes(MANAGED_SIGNATURE),
130
+ );
131
+ }
132
+
133
+ function _read_settings(settings_path: string): Record<string, unknown> {
134
+ if (!fs.existsSync(settings_path)) return {};
135
+ const raw = fs.readFileSync(settings_path, 'utf8');
136
+ if (raw.trim() === '') return {};
137
+ let parsed: unknown;
138
+ try {
139
+ parsed = JSON.parse(raw);
140
+ } catch (e) {
141
+ throw new CorruptSettingsError(settings_path, (e as Error).message);
142
+ }
143
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
144
+ throw new CorruptSettingsError(settings_path, 'top level is not an object');
145
+ }
146
+ return parsed as Record<string, unknown>;
147
+ }
148
+
149
+ function _with_lock<T>(settings_path: string, fn: () => T): T {
150
+ const lockPath = settings_path + '.agent-config.lock';
151
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
152
+ let fd: number;
153
+ try {
154
+ fd = fs.openSync(lockPath, 'wx');
155
+ } catch {
156
+ throw new SettingsLockError(lockPath);
157
+ }
158
+ try {
159
+ return fn();
160
+ } finally {
161
+ fs.closeSync(fd);
162
+ fs.rmSync(lockPath, { force: true });
163
+ }
164
+ }
165
+
166
+ export interface EnsureResult {
167
+ /** true when the file content changed on disk. */
168
+ changed: boolean;
169
+ /** native events whose managed entry was added or replaced. */
170
+ events: string[];
171
+ }
172
+
173
+ /**
174
+ * Idempotently register the managed hook matrix in a Claude settings file.
175
+ * User entries are preserved untouched; managed entries are replaced in
176
+ * place; a second run with the same matrix produces zero diff.
177
+ */
178
+ export function ensure_managed_hooks(
179
+ settings_path: string,
180
+ matrix: ClaudeHookMatrix,
181
+ ): EnsureResult {
182
+ return _with_lock(settings_path, () => {
183
+ const settings = _read_settings(settings_path);
184
+ const before = JSON.stringify(settings);
185
+
186
+ const hooks = ((settings['hooks'] ?? {}) as Record<string, unknown>) || {};
187
+ const touched: string[] = [];
188
+
189
+ for (const [native, command] of Object.entries(matrix)) {
190
+ const existing = Array.isArray(hooks[native]) ? (hooks[native] as unknown[]) : [];
191
+ const user_groups = existing.filter((g) => !_is_managed_group(g));
192
+ const managed_group: HookGroup = {
193
+ hooks: [{ type: 'command', command }],
194
+ };
195
+ const next = [...user_groups, managed_group];
196
+ if (JSON.stringify(next) !== JSON.stringify(existing)) touched.push(native);
197
+ hooks[native] = next;
198
+ }
199
+
200
+ settings['hooks'] = hooks;
201
+ const after = JSON.stringify(settings);
202
+ if (after === before) {
203
+ return { changed: false, events: [] };
204
+ }
205
+ atomicWriteFile(settings_path, JSON.stringify(settings, null, 2) + '\n');
206
+ return { changed: true, events: touched };
207
+ });
208
+ }
209
+
210
+ export interface RemoveResult {
211
+ changed: boolean;
212
+ /** native events a managed entry was removed from. */
213
+ events: string[];
214
+ }
215
+
216
+ /**
217
+ * Remove exactly the managed entries (dispatch-signature match) from a
218
+ * Claude settings file. User entries, other settings keys, and files
219
+ * without managed entries are left byte-identical. Missing file → no-op.
220
+ */
221
+ export function remove_managed_hooks(settings_path: string): RemoveResult {
222
+ if (!fs.existsSync(settings_path)) return { changed: false, events: [] };
223
+ return _with_lock(settings_path, () => {
224
+ const settings = _read_settings(settings_path);
225
+ const hooks = settings['hooks'];
226
+ if (typeof hooks !== 'object' || hooks === null || Array.isArray(hooks)) {
227
+ return { changed: false, events: [] };
228
+ }
229
+
230
+ const hooksRec = hooks as Record<string, unknown>;
231
+ const touched: string[] = [];
232
+ for (const [native, groups] of Object.entries(hooksRec)) {
233
+ if (!Array.isArray(groups)) continue;
234
+ const kept = groups.filter((g) => !_is_managed_group(g));
235
+ if (kept.length !== groups.length) {
236
+ touched.push(native);
237
+ if (kept.length === 0) {
238
+ delete hooksRec[native];
239
+ } else {
240
+ hooksRec[native] = kept;
241
+ }
242
+ }
243
+ }
244
+ if (touched.length === 0) return { changed: false, events: [] };
245
+ if (Object.keys(hooksRec).length === 0) {
246
+ delete settings['hooks'];
247
+ }
248
+ atomicWriteFile(settings_path, JSON.stringify(settings, null, 2) + '\n');
249
+ return { changed: true, events: touched };
250
+ });
251
+ }