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
//! Diff a project's `.claude/settings.json` against the hook groups this
//! project's CURRENT config asks for (#7849).
//!
//! Why: every toggle-driven hook group — `[hooks] prompt_context` (#5034),
//! `[divert] enabled` (#6887), `prompt_self_improvement` (#7688) — was written
//! by [`super::settings::write_project_hooks_with`] and by nothing that ever
//! re-read it. `tm validate --repair` asked only whether the `hooks` key was
//! present and non-empty, so a project whose flag flipped on AFTER its settings
//! file was written reported "no gaps found" and the file was left alone: the
//! capture registered at the next fresh session and not before. This module is
//! the missing comparison — it recomputes what the writer WOULD write and names
//! every group the file does not carry, plus every tm-owned group it carries
//! that the config no longer asks for.
//!
//! What: [`project_hook_additions_for`] resolves every toggle (#9018 adds
//! `[pm_guard] enabled`) and builds
//! the additions block (the ONE builder the launch path and the resume merge
//! also use, so the comparison can never expect a shape no writer produces);
//! [`project_hook_group_gaps`] diffs that block against a settings value, and
//! reports an unresolvable hook binary as an `Err` rather than as an empty diff
//! — an UNKNOWN answer must never read as a complete one.
//! Read-only — the repair is a call to
//! [`super::resume_hooks::ensure_project_hooks_with`], which merges through the
//! same writer.
//! Test: `hook_group_diff_tests.rs`.
use BTreeSet;
use Path;
use Value;
use ;
use crateFrameworkPaths;
use crateStableHookExeError;
use crateevent_names_matching;
/// Build the project-tier hook additions this project's config asks for.
///
/// Why (#7849): the validator and the resume merge each needed the same three
/// answers — `[hooks] prompt_context`, `[divert] enabled`, and
/// `prompt_self_improvement` — and a second resolution of any one of them would
/// let the diff expect a group no writer writes (or miss one every writer
/// does). One function resolves all three, and both callers go through it.
/// What: `[hooks] prompt_context` from [`crate::core::config::MpmConfig`] at
/// `fw.root`, `[divert] enabled` from the re-resolved plan, the #7688 flag
/// from [`crate::core::prompt_self_improvement::enabled_for_with_host`] with
/// the host layer taken from that same `fw.root` config, and (#9018)
/// `[pm_guard] enabled` from that config too, fed to
/// [`super::project_hooks::project_managed_hook_additions_with_pm_guard`].
/// Nothing here reads `$HOME`; `fw` is the only host input.
/// `exe_override` pins the hook binary; production passes `None` and lets
/// [`crate::core::standalone::hooks::resolve_stable_hook_exe`] (#7670) find the
/// installed one, which refuses a build-tree artifact (#7244).
/// Test: `expected_additions_carry_the_capture_when_the_flag_is_on`,
/// `expected_additions_omit_the_capture_when_the_flag_is_off`,
/// `expected_additions_read_the_host_flag_from_the_framework_root`,
/// `a_disabled_guard_is_left_out_of_the_additions`,
/// `resume_with_the_guard_disabled_removes_an_existing_entry`.
pub
/// The two ways a settings file can disagree with the expected hook set.
///
/// Why: the operator has to see WHICH group and on which event — a
/// file-counting verdict cannot express "the `Stop` capture is unwired", which
/// is the whole #7849 incident. Both directions are reported because both are
/// real: a group the config asks for and the file lacks, and a group the file
/// carries that the config turned off.
/// What: `(event, command)` pairs, deduplicated and in a stable order.
/// Test: `a_flipped_on_flag_is_a_missing_group`,
/// `a_flipped_off_flag_is_a_stale_group`.
pub
/// Diff `settings` against the hook groups `project_dir`'s config asks for.
///
/// Why: see the module doc. This is the read-only predicate
/// `core::deploy_validate` and `tm doctor`'s `hooks_missing_tm_group` share, so
/// the two surfaces can never disagree about what a complete hook set is.
/// What: `Ok` with no gaps for a file tm's PROJECT tier never provisioned (the
/// #7490 rule, restated: a foreign project owes tm no hook group, and the user
/// tier owes it no project-tier one — see [`is_project_tier_marker_command`]).
/// `Err` when no stable binary resolves, because the expected COMMANDS cannot
/// then be known and the answer is UNKNOWN rather than "complete" — swallowing
/// that into an empty diff reproduced this very issue one probe along, gated on
/// exe resolution instead of on the toggle. Otherwise `Ok` with every expected
/// group the file does not carry, and every tm-owned command on an owned event
/// that no expected group names.
/// Test: `a_flipped_on_flag_is_a_missing_group`,
/// `a_flipped_off_flag_is_a_stale_group`,
/// `a_file_tm_never_provisioned_has_no_gaps`,
/// `a_user_tier_file_has_no_project_tier_gaps`,
/// `an_unresolved_hook_binary_is_an_error_never_an_empty_diff`.
pub
/// [`project_hook_group_gaps`] with the expected additions supplied by the
/// caller.
///
/// Why (#7849): `resolve_stable_hook_exe` falls back to any `tm` on `$PATH` or
/// in the well-known daemon directories, so a refused `exe_override` is rescued
/// on every host that has the binary installed — which is every developer
/// machine. The refusal arm therefore cannot be reached from outside, exactly
/// as [`super::settings::write_project_hooks_with`] found for the writer.
/// Taking the already-computed `Result` lets a test hand the refusal in
/// directly, on any host.
/// What: the tier gate, then `expected()?`, then the comparison. `expected` is
/// not called at all for a file tm's project tier never provisioned, so a
/// foreign project never pays for a resolution it does not need.
/// Test: `an_unresolved_hook_binary_is_an_error_never_an_empty_diff`,
/// `a_file_tm_never_provisioned_has_no_gaps`.
pub
/// Compare an expected `{"hooks": {...}}` block against a settings value.
///
/// Why: split from [`project_hook_group_gaps`] so the comparison — the part
/// with a real contract — is testable without resolving a config, a manifest or
/// a binary.
/// What: compares (event, [`argv_tail`]) pairs in both directions — `missing`
/// is every expected pair the file carries no tm-owned command for, `stale`
/// every tm-owned pair on an owned event the expected set does not name.
/// Command identity rather than group deep-equality, because the command is
/// what the strip in [`super::settings::write_project_hooks_with`] matches on.
///
/// The ARGV TAIL, never the whole command: the command embeds an absolute
/// binary path, and this crate installs two binaries (`tm` and `trusty-mpm`)
/// that resolve to different paths for the same hook. Comparing the whole
/// string would report every group of a `tm`-written file stale to the daemon
/// running as `trusty-mpm`, and each would rewrite what the other wrote — a
/// repair ping-pong, on every launch. Repointing a command at the installed
/// binary is `hooks_build_tree_binary`'s job (#7262), not this probe's.
/// Test: `diff_reports_nothing_for_an_exact_match`,
/// `diff_reports_a_group_the_file_lacks`,
/// `diff_reports_a_tm_owned_command_the_config_no_longer_asks_for`,
/// `diff_ignores_a_different_binary_path_for_the_same_hook`.
/// A hook command with its leading executable path removed.
///
/// Why (#7849): see [`diff_hook_groups`] — the identity of a hook is its
/// arguments, not the absolute path of whichever of this crate's two binaries
/// happened to write it.
/// What: everything from the first space onward (the space included), or the
/// whole string when there is no argument at all.
/// Test: `diff_ignores_a_different_binary_path_for_the_same_hook`.
/// Every `hooks.<event>[*].hooks[*].command` string in a settings-shaped value.