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
//! Project-level settings hygiene: detect and remove tm hook contamination
//! from project `.claude/settings*.json` files (issue #2940).
//!
//! Why: `tm install` used to discover and mutate EVERY `.claude/settings.json`
//! / `settings.local.json` reachable under `$HOME`, wiring tm hooks into every
//! project it found — including projects the operator never launched via tm.
//! That was a one-way street (the project could no longer cleanly go back to
//! claude-mpm, since the tm entries now lived in a file the project owns) and
//! could conflict with a project's own pre-existing claude-mpm hooks. Hooks
//! now live solely in the tm-owned `CLAUDE_CONFIG_DIR` (see
//! [`super::ensure_managed_hooks`]); this module is the cleanup path for
//! settings files a PRE-FIX `tm install` already contaminated, exposed as
//! `tm hooks clean` and as the `tm doctor` hook-hygiene probe.
//! What: [`contains_tm_hooks`] / [`tm_hook_event_names`] detect tm-owned
//! entries; [`foreign_hook_event_names`] detects a DIFFERENT harness's hooks
//! (informational only — never removed); [`clean_settings_file`] performs the
//! actual scan-or-apply pass over one file, always backing up before writing.
//! [`build_tree_hook_commands`] / [`build_tree_statusline_command`] (#7262)
//! name the individual commands whose executable lives in a Cargo build tree,
//! so `tm doctor` can report WHICH command is broken rather than only how many
//! files are affected.
//!
//! Known limitations:
//! - **Mixed hook groups (issue #2948, RESOLVED).** [`event_names_matching`]
//! now classifies a group by `.any()` over its inner `hooks[*].command`
//! entries, and [`super::strip_hook_entries_matching_for_events`] filters at
//! PER-ENTRY granularity within a group rather than dropping/keeping the
//! whole group. A hand-edited group whose inner array mixes ONE tm command
//! with ONE foreign (e.g. claude-mpm) command is now flagged by both
//! [`tm_hook_event_names`] and [`foreign_hook_event_names`] (it genuinely
//! carries both kinds of entry), and `tm hooks clean --force` removes only
//! the tm-owned entry, leaving the foreign entry and the group itself intact.
//! Previously (issue #2940 review round 1) the `.all()` classification made
//! such a group invisible to both `tm doctor` and `tm hooks clean` even
//! though it carried a live tm entry.
//! - **The hash-suffixed binary matcher trusts any binary under a `deps/`
//! directory.** [`super::is_mpm_hook_command`]'s hash-suffix branch (via
//! [`super::is_mpm_hash_suffixed_artifact`]) requires the resolved path to
//! contain a `deps` path component — narrowing, but not eliminating, the
//! risk that a coincidentally-named foreign binary (`<stem>-<hexhash>` under
//! *some* `deps/` directory that isn't actually a tm Cargo build artifact)
//! is misclassified as tm-owned by `tm hooks clean --force`. The bare-name
//! branch (`tm`/`trusty-mpm`/`session_manager_mvp`, unscoped by path) carries
//! the same pre-existing residual risk. Dry-run mode always prints the
//! exact matched command strings before any deletion (see `commands::hooks::clean`)
//! so an operator can review before applying `--force`.
//! - **Both name branches miss a stem they do not ship (issue #7262, RESOLVED
//! for the build-tree case).** [`super::is_mpm_hook_command`] now consults
//! [`super::is_build_tree_hook_command`] first, which asks WHERE the
//! executable lives instead of what it is called, so a `cargo test` harness
//! wired in as a hook — the #7244 incident shape — is classified and removed.
//! A build-tree path alone is not enough: the argv must also be one tm
//! writes. A foreign binary named neither ours nor build-tree-hosted is still
//! invisible here, which is the correct answer for one this crate does not
//! own.
//!
//! Test: `cargo test -p trusty-mpm standalone::hooks::cleanup` — see
//! `cleanup_tests.rs`.
use ;
use Value;
use ;
/// Per-file outcome of a [`clean_settings_file`] pass.
///
/// Why: `tm hooks clean` needs to report exactly what it found (dry run) or
/// did (`--force`) per file, without re-deriving the event list from the
/// mutated value after the fact.
/// What: the scanned file's path, the `hooks` event keys that carried at
/// least one tm-owned group, whether one of the removed entries was the PM
/// enforcement guard, and — only when the caller passed `force: true` and a
/// write actually happened — the backup file's path.
/// Test: `clean_settings_file_dry_run_reports_without_writing`,
/// `clean_settings_file_force_writes_backup_and_strips`.
/// Does this settings value contain at least one tm-owned hook group?
///
/// Why: the fast, read-only predicate both `tm hooks clean`'s dry run and the
/// `tm doctor` contamination check need — scanning without ever risking a
/// write.
/// What: walks every array under the top-level `hooks` object and returns
/// `true` as soon as one group's `hooks[*].command` entries include AT LEAST
/// ONE recognised by [`is_mpm_hook_command`] (issue #2948: entry-level, not
/// whole-group, so a hand-mixed group carrying one tm command alongside a
/// foreign one is still detected — mirroring the matching semantics
/// [`strip_mpm_hook_entries`] uses to decide what to remove).
/// Test: `contains_tm_hooks_true_for_tm_entry`,
/// `contains_tm_hooks_false_for_foreign_or_absent`.
/// The `hooks` event keys that carry at least one tm-owned group.
///
/// Why: [`CleanOutcome::removed_events`] must be computed BEFORE
/// [`strip_mpm_hook_entries`] mutates the value away, and the doctor probe
/// wants the same list for its message.
/// What: returns every event key (e.g. `PreToolUse`, `Stop`) under `hooks`
/// where at least one group carries at least one tm-owned entry (issue #2948:
/// entry-level match, so a hand-mixed group counts). Order follows the JSON
/// object's iteration order; empty when `hooks` is absent or not an object.
/// Test: `tm_hook_event_names_lists_only_contaminated_events`,
/// `tm_hook_event_names_flags_mixed_group`.
/// The `hooks` event keys that carry at least one FOREIGN (claude-mpm) group.
///
/// Why: `tm doctor` must warn about foreign hooks that would fire inside a tm
/// session WITHOUT ever touching them — that call belongs to the operator.
/// What: returns every event key under `hooks` where at least one group
/// carries at least one entry recognised by [`is_claude_mpm_hook_command`]
/// (issue #2948: entry-level match). [`is_mpm_hook_command`] and
/// [`is_claude_mpm_hook_command`] are mutually exclusive PER COMMAND STRING —
/// a single command is never classified as both — but a single EVENT can now
/// appear in both [`tm_hook_event_names`] and this function's result when a
/// hand-mixed group (or two separate groups under the same event) carries one
/// entry of each kind; that is a real, simultaneous contamination-and-conflict
/// state, not a classification bug.
/// Test: `foreign_hook_event_names_lists_claude_mpm_entries`,
/// `foreign_hook_event_names_flags_mixed_group`.
/// Every hook command in `val` whose executable lives in a Cargo build tree
/// (issue #7262).
///
/// Why: `tm doctor` has to NAME the damage. `hooks_contamination` reports only
/// how many files carry tm entries, which is the right report for a pre-#2940
/// install writing a working `tm hook` where it did not belong — and useless
/// for #7244's corruption, where the entry is tm's own and the fault is the
/// path inside it. An operator needs to see the exact command before deciding
/// to let `--fix` remove it.
/// What: walks every `hooks.<event>[*].hooks[*].command` and collects the
/// distinct strings [`is_build_tree_hook_command`] claims, in first-seen order.
/// Empty when `hooks` is absent or not an object. These are the commands the
/// strip removes, since [`is_mpm_hook_command`] consults the same predicate.
/// Test: `build_tree_hook_commands_lists_the_incident_commands`,
/// `build_tree_hook_commands_is_empty_for_an_installed_binary`.
/// Every `hooks.<event>[*].hooks[*].command` string in `val`, in document
/// order.
///
/// Why (#7262): two readers here — [`build_tree_hook_commands`] and
/// [`strips_pm_guard_entry`] — ask different questions of the SAME traversal.
/// Walking the four nesting levels twice would let the two drift onto
/// different notions of where a command lives.
/// What: yields the `command` string of every entry, skipping any level whose
/// JSON shape is not the expected object/array. A missing or non-object
/// `hooks` key yields nothing.
/// Test: exercised through both callers'
/// tests (`build_tree_hook_commands_lists_the_incident_commands`,
/// `clean_settings_file_flags_a_removed_pm_guard_entry`).
/// Would a strip of `val` remove the PM enforcement guard's entry?
///
/// Why (#7262): `tm doctor --fix` prints one line per repair, and "remove tm
/// hook entries under `PreToolUse`" does not tell the operator that PM
/// enforcement just went offline. Nothing re-registers the guard until the
/// project's next managed `tm` launch, so the consequence outlives the repair
/// and has to be said out loud.
/// What: `true` when any hook command ends with [`PM_GUARD_SUFFIX`] AND
/// [`is_mpm_hook_command`] claims it — the same predicate
/// [`strip_mpm_hook_entries`] removes by, so this can never report a removal
/// that will not happen. In practice that pairing is only satisfiable by a
/// build-tree executable (the #7244 shape): an INSTALLED
/// `<abs>/tm hook --pm-guard` is left in place, and correctly reports `false`.
/// Test: `strips_pm_guard_entry_is_true_only_for_a_removable_guard`.
/// The `statusLine.command` when it points into a Cargo build tree (#7262).
///
/// Why: the same corruption reaches `statusLine.command` (#4492 is the earlier
/// instance), where it costs the operator their whole statusline with no error
/// printed anywhere. Detection must name it. Repair does not follow: the hooks
/// writer does not own this key, and stripping it would leave the file with no
/// statusline at all rather than a corrected one — `session_launch::settings`
/// upgrades it in place on the next managed launch, via its
/// `is_stale_statusline_command`, which already treats an ephemeral binary as
/// stale.
/// What: reads `statusLine.command` and returns it when
/// [`is_build_tree_statusline_command`] claims it; `None` for any other shape,
/// a missing key, or a non-string value.
/// Test: `build_tree_statusline_command_in_settings_is_reported`,
/// `build_tree_statusline_command_in_settings_ignores_an_installed_binary`.
/// Shared walker behind [`tm_hook_event_names`] / [`foreign_hook_event_names`].
///
/// Why (issue #2948): a hook GROUP is classified by whether ANY of its inner
/// `hooks[*].command` entries match — not ALL of them. The previous `.all()`
/// classification made a hand-mixed group (one tm command + one foreign
/// command in the same matcher group) invisible to both the tm-owned and
/// foreign-owned predicates, since neither ALL-tm nor ALL-foreign held. `.any()`
/// makes the walker symmetric with [`super::strip_hook_entries_matching_for_events`],
/// which also now operates at entry (not group) granularity.
///
/// `pub` since #7490: `session_launch::resume_hooks` asks the same question of
/// the same shape with a BROADER predicate (`is_project_managed_hook_command`,
/// which also claims the `trusty-memory` / PM-guard / divert commands the
/// project tier writes). A second walker there would be a second notion of
/// where a hook command lives.
/// Scan (and, when `force`, clean) one settings file for tm hook contamination.
///
/// Why: the single write path for `tm hooks clean` — every call backs up
/// before mutating so a bad scan can always be undone by hand, and a missing
/// or non-object file is treated as "nothing to clean" rather than an error
/// (a project with no settings file, or one that failed to parse, has no tm
/// contamination to report). The final write MUST be atomic (issue #2940
/// review round 1, HIGH): a plain truncate-then-write left `settings.json`
/// corrupt — unparseable by Claude Code until a manual `.bak` restore — if
/// the process died mid-write; [`trusty_common::claude_config::write_json_atomic`]
/// writes to a temp file and renames over `path`, matching the atomicity
/// [`super::remove_global_trusty_mpm_hooks`] (mod.rs:353) already uses for
/// the same class of mutation.
/// What: reads `path` (missing file → `Ok(None)`, malformed/non-object JSON →
/// `Ok(None)` — never touched), returns `Ok(None)` when
/// [`contains_tm_hooks`] is `false`. Otherwise computes the contaminated
/// event list and the [`CleanOutcome::removed_pm_guard`] flag (both BEFORE any
/// mutation), and when `force` is `true`: writes a byte-identical backup of
/// the pre-clean text to `<path>.bak-<unix-epoch-seconds>` (this crate's own
/// backup, reported via [`CleanOutcome::backup_path`]; `write_json_atomic`
/// additionally maintains its own internal `<path>.bak`, a harmless second
/// safety net), strips the tm-owned groups via [`strip_mpm_hook_entries`],
/// and atomically writes the result back (preserving every other key). In
/// dry-run mode (`force: false`) the file is never touched and `backup_path`
/// is `None`.
/// Test: `clean_settings_file_dry_run_reports_without_writing`,
/// `clean_settings_file_force_writes_backup_and_strips`,
/// `clean_settings_file_preserves_non_tm_keys`,
/// `clean_settings_file_missing_file_is_noop`,
/// `clean_settings_file_malformed_json_is_noop`,
/// `clean_settings_file_non_object_json_is_noop`.
/// The read / strip / backup / write body of [`clean_settings_file`].
///
/// Why (#7762): the apply arm runs this under the settings lock and the dry-run
/// arm runs it bare, so the body has to be callable both ways.
/// What: exactly what [`clean_settings_file`] documents.
/// Test: see [`clean_settings_file`].