1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
//! Managed-session hook definitions and idempotent merge into settings.json.
//!
//! Why: WI-3 requires the managed CLAUDE_CONFIG_DIR (`~/.trusty-mpm/claude-config/`)
//! to ship the full trusty-mpm hook triad (PreToolUse / PostToolUse / Stop) so
//! the daemon receives Claude Code lifecycle events from managed sessions. Without
//! hooks the circuit breaker, audit log, and dashboard are blind to managed sessions.
//! Centralising the definition here (rather than duplicating it in the `tm install`
//! binary) lets `ensure_global_config_dir` call it from the library crate, and
//! lets the binary re-use the same literal for `tm install`.
//! What: [`mpm_hook_command`] resolves the absolute binary path (falling back to
//! the bare name when resolution fails); [`mpm_hook_additions`] returns the hook
//! triad JSON block; [`ensure_managed_hooks`] reads `<claude_config_dir>/settings.json`,
//! deep-merges the triad idempotently, and writes back; [`remove_global_trusty_mpm_hooks`]
//! strips MPM hook entries from all global settings files; [`write_project_hooks`]
//! writes project-scoped hooks into a given settings file.
//! Test: `test_ensure_managed_hooks_writes_triad`,
//! `test_ensure_managed_hooks_is_idempotent`,
//! `test_hook_command_uses_absolute_path`,
//! `test_remove_global_trusty_mpm_hooks_removes_only_mpm_entries`,
//! `test_write_project_hooks_targets_project_dir`,
//! `test_is_mpm_hook_command_recognises_tm_bin_name`,
//! `test_write_project_hooks_replaces_stale_exe_path_group`,
//! `test_strip_mpm_hook_entries_removes_only_tm_entry_from_mixed_group` in `tests`.
//!
//! Issue #2940: [`cleanup`] (sibling module) builds `tm hooks clean` and the
//! `tm doctor` hook-hygiene probe on top of [`is_mpm_hook_command`] and
//! [`strip_mpm_hook_entries`] — see that module's doc for the contamination
//! background.
//!
//! Issue #2948: [`strip_hook_entries_matching_for_events`] generalises the
//! removal logic to per-entry (not per-group) granularity so a hand-mixed
//! group is handled correctly by both this module and [`cleanup`]; issue
//! #2003 reuses the same primitive from `session_launch::settings` for the
//! project-tier writer's broader trusty-owned predicate.
use ;
/// The bare fallback command name when the binary path cannot be resolved.
///
/// Why: if `current_exe()` fails (e.g. under `cargo test` without a real
/// install), we must still produce a working hook definition rather than
/// panicking. The bare name is what Claude Code would previously use
/// (resolved via PATH), so it degrades gracefully.
/// What: `"trusty-mpm hook"`.
const BARE_HOOK_COMMAND: &str = "trusty-mpm hook";
/// Resolve the absolute path for the `trusty-mpm hook` command.
///
/// Why: using a bare binary name means Claude Code resolves the hook via
/// `$PATH` at fire-time. In build environments or fresh shells where
/// `~/.cargo/bin` is absent from `PATH`, this silently fails to launch the
/// process — the hook executor throws a not-found error and the event is
/// lost. Using the absolute path of the running binary eliminates the PATH
/// dependency entirely and is correct by construction: the binary being
/// installed is the same binary running `tm install`.
/// What: delegates to [`resolve_stable_hook_exe`] and renders `"<abs-path>
/// hook"`; falls back to `BARE_HOOK_COMMAND` when no stable binary resolves so
/// the install never panics. If a caller already knows the exe path (e.g. from
/// `current_exe()` cached at startup), they can pass it via `exe_override` to
/// skip the syscall.
/// Test: `test_hook_command_uses_absolute_path`.
/// Resolve a STABLE, installed absolute binary path for baking into managed
/// hook commands — never an ephemeral build/worktree path.
///
/// Why (#2229): `std::env::current_exe()` returns a
/// `target/debug/deps/trusty_mpm-<hash>` (or worktree) path when `tm` runs from
/// a debug build. Persisting that path into the SHARED global `settings.json`
/// breaks every managed session's hooks once the artifact is rebuilt away. The
/// hook command must instead point at a stable installed binary (`~/.cargo/bin/tm`)
/// or degrade to the PATH-resolved bare name — anything but the transient path.
/// What: prefers `exe_override` (canonicalized), then `current_exe()`
/// (canonicalized), but only when the result is absolute AND not an ephemeral
/// build path per [`trusty_common::bin_resolve::is_ephemeral_build_path`]. When
/// the running binary is ephemeral or unresolved, PATH-resolves the installed
/// `tm`/`trusty-mpm` binary via [`trusty_common::bin_resolve::resolve_binary`].
/// Returns `None` only when no stable absolute path can be found (caller then
/// uses the bare command name).
/// Test: covered by `test_hook_command_uses_absolute_path`,
/// `test_hook_command_rejects_ephemeral_exe_override`,
/// `test_hook_command_rejects_system_temp_exe_override`.
/// Build the MPM lifecycle hook additions JSON block (six events).
///
/// Why: every call site — the managed global config writer AND `tm install` — must
/// use the exact same shape so [`trusty_common::claude_config::merge_hook_entries`]
/// can dedup by deep equality without producing duplicates. Centralising the literal
/// here means the managed path and the `install` path can never silently diverge.
/// `SessionStart` and `SessionEnd` (#1744) are required so the daemon can capture
/// the Claude Code internal session UUID (for `--resume`) and immediately mark a
/// session Stopped when Claude Code exits, even on ungraceful exits. Without these
/// two events wired, `correlate_session_start` and `handle_session_end` in
/// `daemon/api.rs` receive no traffic and `claude_session_id` stays `None` forever.
/// The `merge_hook_entries` dedup logic preserves any existing `SessionStart` entry
/// (e.g. `trusty-memory inbox-check` from the project-level config) — adding the
/// `trusty-mpm hook` entry alongside it does NOT clobber the memory hook.
/// What: returns a JSON object with six `hooks` arrays:
/// `PreToolUse`, `PostToolUse` (async), `Stop`, `SubagentStop`, `SessionStart`,
/// `SessionEnd`. `SubagentStop` (#2610) fires when a delegated Task-tool
/// subagent ends its turn, letting the hook handler flag an idle-parking final
/// message (see `commands::misc::hook` / `core::idle_parking`).
/// `PostToolUse` is marked `async: true` so Claude Code does not block waiting for
/// the daemon to ingest tool results; the other five use short synchronous timeouts.
/// `exe_override` pins the binary path for the hook command; pass `None` to resolve
/// via `mpm_hook_command(None)`.
/// Test: `test_mpm_hook_additions_has_six_events`,
/// covered by `test_ensure_managed_hooks_writes_triad`.
/// Build the MPM lifecycle hook additions JSON block with the default exe resolution.
///
/// Why: convenience wrapper that calls `mpm_hook_additions_with_exe(None)` so
/// existing call sites that do not need to pin the exe path stay concise.
/// What: delegates to [`mpm_hook_additions_with_exe`] with `None`.
/// Test: covered by `test_mpm_hook_additions_has_six_events`.
/// Canonical binary names this crate ships as `[[bin]]` targets (`Cargo.toml`):
/// `"trusty-mpm"` (the full name) then `"tm"` (the short everyday alias used
/// by `tm run`/`tm load`/`tm login`).
///
/// #4058 review round 1 MEDIUM finding 2: this is the SAME two-name SET as
/// [`crate::core::own_binary_names::OWN_BINARY_NAMES`], but kept as its own
/// array rather than an alias of it, because the ORDER here is load-bearing
/// and differs from that constant's order. [`resolve_stable_hook_exe`]
/// consumes this list via `.find_map(resolve_binary)` — first PATH hit
/// wins — so `"trusty-mpm"` must stay first to keep preferring the full name
/// over the `tm` alias when both happen to be on `PATH`, exactly as it did
/// before the #4058 consolidation. `OWN_BINARY_NAMES` is ordered `tm` first
/// instead, because ITS order-sensitive consumer
/// (`session_launch::settings::STATUSLINE_BIN_NAMES`) needs the opposite
/// preference. `"trusty-mpm"` is first on purpose: it is the unambiguous full
/// binary name, while `"tm"` is a short alias a user may have shadowed on
/// `PATH`, and the resolved exe is persisted into `settings.json` where a
/// wrong resolution survives across sessions.
///
/// Test: `test_mpm_bin_names_prefers_full_name_over_short_alias` pins this
/// array's exact ORDER (the only mechanical guard — `resolve_stable_hook_exe`
/// calls the real `resolve_binary` and is not injectable, so no behavioural
/// test can observe the preference), and
/// `test_mpm_bin_names_matches_own_binary_names_set` pins the two arrays to
/// the same SET so a future third `[[bin]]` target can't drift between them
/// unnoticed.
const MPM_BIN_NAMES: & = &;
/// File-name STEMS that identify an mpm-owned binary once any Cargo
/// build-artifact `-<hash>` suffix is stripped.
///
/// Why (#2235): managed hooks resolved from a debug/worktree build embed a
/// `target/debug/deps/<stem>-<hash>` path whose file name is NOT one of
/// [`MPM_BIN_NAMES`] — the Cargo dep artifact uses the underscore crate name
/// (`trusty_mpm`) or the `[[bin]]` alias (`tm`) with a trailing content hash,
/// and the long-defunct pre-rename binary was `session_manager_mvp`. All of
/// these must be recognised as the SAME hook owner so stale entries collapse.
/// What: the canonical stems (dash + underscore spellings) plus the retired MVP
/// name. Matched by [`is_mpm_binary_filename`] against the hash-stripped stem.
const MPM_BIN_STEMS: & = &;
/// Recognise an mpm-owned binary by its EXACT file-name component: a
/// canonical [`MPM_BIN_NAMES`] entry or the defunct `session_manager_mvp` name.
///
/// Why: this is the low-risk branch of [`is_mpm_hook_command`] — an exact
/// bare-name or absolute-path match to a name tm itself ships. It does NOT
/// cover Cargo build-artifact hash suffixes; see [`is_mpm_hash_suffixed_artifact`]
/// for that (path-scoped, per #2940 review round 1 MEDIUM) branch.
/// What: returns `true` when `name` is a canonical [`MPM_BIN_NAMES`] entry or
/// the bare defunct `session_manager_mvp` name.
/// Test: `test_is_mpm_hook_command_recognises_tm_bin_name`.
/// Recognise a Cargo build-artifact hash-suffixed mpm binary, SCOPED to a
/// path that actually looks like a `deps/` build-artifact directory.
///
/// Why (#2235, tightened #2940 review round 1 MEDIUM): dedup that keyed
/// identity on the exact file name ∈ {`trusty-mpm`,`tm`} could never strip a
/// stale entry whose command carried a build-artifact path
/// (`.../deps/trusty_mpm-<hash> hook`, `.../tm-<hash> hook`) — those file
/// names are not in [`MPM_BIN_NAMES`], so every managed launch appended a
/// fresh entry beside the un-strippable stale ones and `settings.json` grew
/// without bound (#2235's unbounded-growth bug). Recognising the hash-suffixed
/// shape fixed that — but issue #2940 wired this SAME predicate into
/// `tm hooks clean`'s `--force` DESTRUCTIVE deletion path, where a coincidental
/// foreign binary named e.g. `tm-a1b2c3d4` (an unrelated tool that happens to
/// share the `<stem>-<hexhash>` shape) would previously have been silently
/// deleted from a project's settings. Requiring a `deps` path component scopes
/// the match to the one shape `resolve_stable_hook_exe`/`current_exe()` can
/// ever actually produce for a hash-suffixed binary (Cargo always places build
/// artifacts under `target/<profile>/deps/`), closing that false-positive
/// window without touching the exact-name branch above (which carries the
/// same coincidental-collision risk pre-existing #2940 and is out of this
/// PR's scope — see `cleanup.rs`'s module doc for the residual risk note).
/// What: returns `true` when `path`'s file name is `<stem>-<hexhash>` with
/// `stem ∈ MPM_BIN_STEMS` and an all-hex-digit `<hexhash>` of length ≥ 8, AND
/// `path` has a `deps` component anywhere in it.
/// Test: `test_is_mpm_hook_command_recognises_stale_hash_and_mvp_variants`,
/// `test_is_mpm_hook_command_rejects_hash_suffixed_binary_outside_deps_dir`.
/// Check if a command string is one of the trusty-mpm hook command variants.
///
/// Why (#2015, #2235): the crate ships TWO `[[bin]]` targets that share one
/// binary — `trusty-mpm` and the short alias `tm` (the everyday entry point for
/// `tm run`/`tm load`/`tm login`). `mpm_hook_command` resolves whichever binary
/// is currently running via `current_exe()`, so a hook group's command can be
/// `.../tm hook` on one write and `.../trusty-mpm hook` on the next (or vice
/// versa) purely from which entry point launched the process. Worse, a
/// debug/worktree build resolves to a hash-suffixed artifact
/// (`.../deps/trusty_mpm-<hash> hook`) and legacy configs still carry the
/// retired `session_manager_mvp-<hash>` name — none of which the original
/// file-name-exact predicate recognised, so those stale groups were invisible
/// to the replace-by-identity strip in [`write_project_hooks`] and survived
/// every merge: exactly the unbounded-growth bug #2235 reports. The whole
/// binary family must be recognised as the SAME hook owner.
/// What: returns `true` when `cmd` ends with ` hook` (scoping the match to an
/// actual MPM hook invocation, not just any binary that happens to share a
/// name) AND EITHER the remaining prefix's file-name component is recognised
/// by [`is_mpm_binary_filename`] (bare names — `"tm hook"`; absolute paths —
/// `"/opt/bin/trusty-mpm hook"`) OR the full prefix is recognised by
/// [`is_mpm_hash_suffixed_artifact`] (hash-suffixed build artifacts under a
/// `deps/` directory — `".../deps/trusty_mpm-<hash> hook"`).
/// Test: `test_remove_global_trusty_mpm_hooks_removes_only_mpm_entries`,
/// `test_is_mpm_hook_command_recognises_tm_bin_name`,
/// `test_is_mpm_hook_command_recognises_stale_hash_and_mvp_variants`,
/// `test_is_mpm_hook_command_rejects_hash_suffixed_binary_outside_deps_dir`,
/// `test_write_project_hooks_replaces_stale_exe_path_group`,
/// `test_write_project_hooks_collapses_stale_hash_and_mvp_entries`.
///
/// `pub` (rather than `pub(crate)`) since issue #2940: [`cleanup`] and the
/// `tm doctor` hook-hygiene probe both need this exact predicate so the
/// contamination scan and the removal logic can never classify a command
/// differently.
/// Recognise a foreign claude-mpm-owned hook command signature (issue #2940).
///
/// Why: a project that still carries claude-mpm's own hook wiring alongside
/// (or instead of) tm's would fire BOTH harnesses' hooks in the same tm
/// session, producing conflicting/undefined behaviour — `tm doctor` needs to
/// warn about this without ever touching the foreign entry (that call is the
/// operator's, not tm's). claude-mpm invokes its hooks via its own
/// `claude-mpm`/`claude_mpm` binary or a script under a `.claude-mpm/`
/// directory; neither ever produces a command [`is_mpm_hook_command`]
/// recognises, so the two predicates are mutually exclusive by construction
/// — checked here defensively so a command is NEVER double-classified.
/// What: returns `true` when `cmd` contains the substring `claude-mpm` or
/// `claude_mpm` (case-insensitive, covering both the installed CLI and a
/// `.claude-mpm/`-rooted script path) AND [`is_mpm_hook_command`] does not
/// already claim it.
/// Test: `test_is_claude_mpm_hook_command_recognises_foreign_signatures`,
/// `test_is_claude_mpm_hook_command_never_overlaps_tm`.
/// Strip trusty-mpm hook entries from every global Claude settings file.
///
/// Why: after switching from global hooks to project-scoped hooks, any
/// previously installed global hook triad must be cleaned up so it does not
/// fire in unrelated projects. This mirrors the `remove_global_trusty_memory_hooks`
/// pattern from trusty-memory — strip first, then write project-scoped hooks.
/// What: discovers all `.claude/settings*.json` files under `$HOME` via
/// [`trusty_common::claude_config::discover_claude_settings`], and for each file
/// removes any hook group whose `command` field matches a trusty-mpm hook pattern
/// (see [`is_mpm_hook_command`]). Writes back atomically only when something
/// actually changed. Returns the count of files modified.
/// Test: `test_remove_global_trusty_mpm_hooks_removes_only_mpm_entries`.
/// Remove every trusty-mpm hook entry from a settings JSON value in-place.
///
/// Why: shared logic between `remove_global_trusty_mpm_hooks`, [`cleanup`]
/// (issue #2940's `tm hooks clean`), and tests.
/// What: delegates to [`strip_mpm_hook_entries_for_events`] with `events =
/// None`, which strips MPM-owned groups from every event key present.
/// Test: `test_remove_global_trusty_mpm_hooks_removes_only_mpm_entries`.
/// Remove MPM-owned hook groups for the given events (or all events) in-place.
///
/// Why (#2015): [`trusty_common::claude_config::merge_hook_entries`] dedups a
/// hook group only by byte-for-byte JSON equality. When the resolved absolute
/// exe path changes (bin name `tm` vs `trusty-mpm`, worktree rebuilds,
/// reinstalls) the `command` string differs, so a NEW MPM hook group is
/// appended beside the stale one on every merge — MPM groups accumulate and
/// each fires on every lifecycle event. Replacing the existing MPM-owned
/// group for an event *before* merging enforces replace-by-identity (event
/// name + "is this an MPM hook"), not full-value equality, so exactly one
/// MPM group per event survives regardless of exe-path churn.
/// What: delegates to [`strip_hook_entries_matching_for_events`] with
/// [`is_mpm_hook_command`] as the predicate.
/// Test: `test_remove_global_trusty_mpm_hooks_removes_only_mpm_entries`,
/// `test_write_project_hooks_replaces_stale_exe_path_group`,
/// `test_strip_mpm_hook_entries_removes_only_tm_entry_from_mixed_group`.
/// Remove hook group ENTRIES matching `matches_cmd` for the given events (or
/// all events) in-place, at PER-ENTRY (not per-group) granularity.
///
/// Why (issue #2948): the original group-level filter (`inner_hooks.iter().all(...)`
/// then drop-or-keep the WHOLE group) left a hand-mixed group — one entry this
/// predicate owns alongside one genuinely foreign entry in the SAME matcher
/// group — completely untouched, since not every entry matched. That silently
/// failed to strip the owned entry AND (via [`cleanup::event_names_matching`]'s
/// matching `.all()`) made the contamination invisible to `tm doctor` too.
/// Filtering each group's `hooks[*]` array individually strips exactly the
/// matched entries and leaves any foreign entry — and the group itself — in
/// place; only a group whose `hooks` array becomes empty (every entry matched,
/// or it started empty) is dropped. Generalised over an arbitrary predicate
/// (rather than hard-coding [`is_mpm_hook_command`]) so
/// `session_launch::settings::write_project_hooks` (issue #2003) can reuse the
/// exact same entry-level replace-by-identity logic for its broader
/// trusty-owned predicate (lifecycle triad + `trusty-memory` + PM-guard),
/// keeping the two writers' contamination-safety guarantees identical.
/// What: when `events` is `Some(list)`, only those event keys are inspected;
/// when `None`, every event key under `hooks` is inspected. Within scope, each
/// group's `hooks[*]` array is filtered to drop entries whose `command` matches
/// `matches_cmd`; a group whose array is left empty is dropped entirely, and an
/// event key emptied of all groups is removed. Groups with a non-array/absent
/// `hooks` field (unrecognised shape) are always retained untouched. Returns
/// `true` if anything was removed.
/// Test: `test_remove_global_trusty_mpm_hooks_removes_only_mpm_entries`,
/// `test_write_project_hooks_replaces_stale_exe_path_group`,
/// `test_strip_mpm_hook_entries_removes_only_tm_entry_from_mixed_group`.
pub
/// Write project-scoped MPM hooks into a single Claude settings file.
///
/// Why: global hooks fire in every project and can break unrelated build
/// environments that have a stripped PATH. Project-scoped hooks only fire
/// inside the specific project directory, matching trusty-memory's approach.
/// Before merging, any EXISTING MPM-owned hook group for each event about to
/// be (re-)added is stripped first (#2015): `merge_hook_entries` dedups only
/// by byte-for-byte JSON equality, so when the resolved exe path differs from
/// a previous write (bin name `tm` vs `trusty-mpm`, worktree rebuild,
/// reinstall) the stale group would otherwise survive alongside the fresh
/// one, and MPM hook groups accumulate — each one firing on every lifecycle
/// event. Stripping first enforces replace-by-identity (event name + "is this
/// an MPM hook") rather than full-value equality.
/// What: reads `settings_path` (tolerates missing/empty — starts from `{}`),
/// strips any MPM-owned hook group for the events present in the fresh
/// additions via [`strip_mpm_hook_entries_for_events`], deep-merges the MPM
/// hook additions, and writes back atomically only when something actually
/// changed. Returns `true` when the file was updated. `exe_override` is
/// forwarded to [`mpm_hook_additions_with_exe`] so the caller can pin the
/// binary path at install time.
/// Test: `test_write_project_hooks_targets_project_dir`,
/// `test_write_project_hooks_replaces_stale_exe_path_group`.
/// Idempotently merge the MPM hook triad into `<claude_config_dir>/settings.json`.
///
/// Why: the managed CLAUDE_CONFIG_DIR starts with an empty `settings.json` (`{}`),
/// so without this call managed sessions have NO hooks and the daemon is blind to
/// their lifecycle events. Called from [`super::global_config::ensure_global_config_dir`]
/// after the initial settings.json seed so the file is always wired on every
/// managed launch.
/// What: reads `<claude_config_dir>/settings.json` (tolerates missing / empty /
/// malformed by starting from `{}`), deep-merges [`mpm_hook_additions`] using
/// [`trusty_common::claude_config::merge_hook_entries`], and writes back only when
/// the merged value differs — so calling this twice produces identical files
/// (idempotency requirement). Uses [`mpm_hook_additions_with_exe`] to embed the
/// absolute binary path rather than a bare name.
/// Test: `test_ensure_managed_hooks_writes_triad`, `test_ensure_managed_hooks_is_idempotent`.
/// Resolve the resolved-exe path from `current_exe()` for use at install time.
///
/// Why: callers that run `tm install` should pin the absolute path once
/// (at install entry, before any `cd`) and pass it through. This helper
/// centralises resolution and, crucially, refuses to pin an ephemeral
/// build/worktree path (#2229) that would 404 after a rebuild — falling back to
/// the PATH-resolved installed binary instead.
/// What: delegates to [`resolve_stable_hook_exe`] with no override, returning a
/// stable installed absolute path, or `None` when none can be found.
/// Test: covered indirectly by `test_hook_command_uses_absolute_path`,
/// `test_hook_command_rejects_ephemeral_exe_override`.