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
//! Re-merge the project-tier tm hook groups into an EXISTING
//! `.claude/settings.json` on every resume and relaunch (issue #7490).
//!
//! Why: [`super::prepare_session`] is the ONLY caller of
//! [`super::settings::write_project_hooks`], and no resume path reaches it —
//! `runtime::claude_code::spawn_resume` and the bare-`tm` in-place relaunch
//! both compose a session through `prepare_managed_config`, which provisions
//! the tm-owned `CLAUDE_CONFIG_DIR` and regenerates
//! `INSTRUCTIONS-COMPILED.md` but never touches the project's own
//! `.claude/settings.json`. A project provisioned before an event group
//! existed therefore never gained it: on session `trusty-tools-c4` the file's
//! `SessionStart` array carried only `trusty-memory inbox-check`, so
//! `tm hook`'s `SessionStart` arm never fired, no `savings.jsonl` row was
//! written under the live Claude session id, and the 💸 statusline segment
//! stayed hidden behind `SavingsTotal::is_zero`.
//! What: [`ensure_project_hooks`] re-derives the same manifest/config layers
//! `prepare_session_inner` resolves and calls the SAME
//! [`super::settings::write_project_hooks_with`] merge — no second merge
//! implementation, so the #7244 no-op exit (a file that already carries every
//! group is left byte-identical) and the snapshot-before-write rule come
//! along unchanged. [`missing_lifecycle_hook_events`] is the read-only
//! predicate `tm doctor`'s `hooks_missing_tm_group` check and its `--fix`
//! repair share.
//! Test: `resume_hooks_tests.rs`.
use Path;
use Value;
use PrepError;
use is_project_managed_hook_command;
use write_project_hooks_with;
use crateMPM_LIFECYCLE_HOOK_EVENTS;
use crate;
/// Merge the project-tier tm hook groups into `<project_dir>/.claude/settings.json`.
///
/// Why (#7490): see the module doc — this is the call every resume and
/// relaunch path was missing. It exists as its own entry point rather than as
/// a second `write_project_hooks` call site so the manifest/config
/// re-derivation those paths need (they hold no `HarnessPlan`, exactly like
/// `core::mcp_session_env`) lives in one place.
/// What: resolves [`crate::core::paths::FrameworkPaths::for_managed_workspace`],
/// resolves every toggle through
/// [`super::hook_group_diff::project_hook_additions_for`] (#7849), then
/// delegates to
/// [`write_project_hooks_with`] — which strips tm's own prior entries, deep-merges
/// the additions, returns without writing when the result equals the file as
/// read, and otherwise snapshots before the atomic write. Foreign entries
/// (the `trusty-memory inbox-check` under `SessionStart`) survive: the strip
/// is by tm-owned identity, never by event key.
/// `exe_override` pins the hook binary; production passes `None` and lets
/// `resolve_stable_hook_exe` find the installed one, which refuses a build
/// artifact and so writes nothing (#7244).
/// Test: `resume_merge_adds_the_sessionstart_group_to_an_existing_file`,
/// `resume_merge_preserves_the_inbox_check_entry`,
/// `resume_merge_leaves_a_complete_file_byte_identical`.
/// [`ensure_project_hooks`] with the framework layout supplied explicitly.
///
/// Why (#5040's seam, reused): `FrameworkPaths::for_managed_workspace` reads
/// `$HOME`, and the two layers resolved from it — `[hooks] prompt_context` and
/// `[divert] enabled` — decide which event keys get written. A test asserting
/// byte-identity across two calls would therefore read the operator's real
/// config, and would see the two answers DIFFER if any concurrently-running
/// test redirected the process-global `$HOME` between them.
/// What: the body of [`ensure_project_hooks`], taking the layout rather than
/// deriving it. Returns without writing when
/// [`settings_is_writable_object`] refuses the existing file.
/// Test: `resume_merge_leaves_a_complete_file_byte_identical`,
/// `resume_merge_leaves_an_unparseable_file_untouched`.
pub
/// May the hooks writer rewrite `<project_dir>/.claude/settings.json`?
///
/// Why (#7490): [`write_project_hooks_with`]'s read step coerces an unparseable or
/// non-object file into an EMPTY object and keeps going. Its no-op exit then
/// compares the merge against that fallback rather than against the file, so
/// the comparison always differs and the file is snapshotted and replaced with
/// tm's hook additions alone — `permissions`, `statusLine`, `env` and
/// `outputStyle` gone. That writer ran once per managed launch and now runs on
/// every spawn, resume and in-place relaunch, so a settings file with one
/// trailing comma or one `//` comment would be destroyed at the next session.
/// Refusing here rather than in the writer keeps the #7244 fail-closed rule in
/// one direction — this call site adds a precondition, it does not change what
/// `prepare_session` does.
/// What: `true` when the file is absent (the writer creates it) or parses to a
/// JSON object; `false` — with a `tracing::warn!` naming the path — when it
/// exists and does not, mirroring `daemon::doctor_hooks_hygiene::read_settings`'s
/// tolerate-and-skip reading of the same file. An unreadable file is refused
/// for the same reason: its contents cannot be preserved by a merge that never
/// saw them.
/// Test: `resume_merge_leaves_an_unparseable_file_untouched`,
/// `resume_merge_still_creates_a_missing_file`.
/// The lifecycle hook events a tm-provisioned settings file is missing.
///
/// Why (#7490): `tm doctor` has to say WHICH event is unwired — the incident
/// was one missing `SessionStart` group in a file that otherwise looked
/// complete, and a file-counting report cannot express that. Read-only, so
/// the diagnostic stays a diagnostic; the repair calls
/// [`ensure_project_hooks`].
/// What: empty for a settings value tm has never provisioned (no
/// `trusty-memory` / PM-guard / lifecycle / divert command anywhere in it) —
/// a project that does not run tm sessions owes no hook group, and adding one
/// would undo what `hooks_contamination`'s repair just removed. Otherwise
/// every [`MPM_LIFECYCLE_HOOK_EVENTS`] entry whose array carries no tm-owned
/// command, in the canonical six-event order.
/// Test: `missing_events_names_a_sessionstart_gap`,
/// `missing_events_is_empty_for_a_complete_file`,
/// `missing_events_is_empty_for_a_file_tm_never_provisioned`.