probe_hook/lib.rs
1//! probe-hook — the edit-time ENVELOPE as a SHIPPED binary (the eleventh ask).
2//!
3//! The whole edit-time sense organ, every voice priced in silence — this hook is FOR the
4//! agent, so it surfaces everything a path-and-text edit can derive:
5//!
6//! * the GUARD (`Agenda::edit_guard`) — refusals that already exist downstream, pre-fired;
7//! * the SHAPE TICKER (`discover::watch::Ticker`) — a coupling move on ANY Rust edit: a theory
8//! nets on its sorts, a plain module on its own declared types, and the ticker speaks only
9//! when the edit bridges two clusters or opens a new one — a sixth sense, not a firehose;
10//! * the QUALIFY voice (`spec/qualify.spec`) — the edit-time LOCK DELTA as a DRIFT LEDGER: the whole
11//! current drift of the surface census, re-derived from the tree via
12//! `boundary_enforce::qualify_census_lines` and diffed against the committed lock. Surfaced on ANY
13//! edit (un-scoped) but DEDUPED against the last-shown ledger, so it speaks only when the drift
14//! moves — the behavioural mirror at the edit, for the one lock a text edit can re-derive (no
15//! how-to-bless recipe: the named lock's own header carries it);
16//! * the TYPE-LIBRARY voice (`library_voice`) — the anti-duplication sense (rung 0 of the
17//! bundle candidate): when the edited file touches sorts that already carry operators in
18//! OTHER files' census lines, the existing families are named — per-file, deduped, the
19//! census intersection doubling as the noise filter;
20//! * the TIER voice (`spec/tiers.spec`) — on first edit of a file, its derived tier and the
21//! rules it carries (the reader-service the deleted `//! Tier:` markers gave);
22//! * the FREEZE-DELTA courier (`freeze_delta_voice`) — the recommendation movement the last
23//! build derived via `spec_lock::Lock::delta` (a placement re-settling, a seam candidate
24//! appearing), inserted once into the window and then cleared.
25//!
26//! The first five voices the hook DERIVES from a single text edit. The sixth it does not
27//! compute at all: distance, cohesion, and placement need the compiled theory (running `eval`),
28//! which a text edit cannot afford — so the movement of those recommendations is derived where
29//! it is cheap, at the build that emits the locks (`spec_lock::Lock::delta` holds both sides at
30//! that instant), and the hook only COURIERS the result into the next context window. The qualify
31//! voice is the boundary case that DOES fit a text edit: qualification is a structural property
32//! (operator-shaped functions), so its lock delta is computed live here, while the behavioural
33//! locks stay couriered. Nothing is watched or reconstructed: the emitter narrates its own delta,
34//! the hook carries it (or, for qualify, derives it). (The
35//! freedom/survivor census stays at session start, read from the committed mutation locks.) All
36//! the voices are mutation-tested and register-driven, and before this crate every
37//! consumer wrapped them in the same four pieces of unjudged glue: a bash wrapper, inline
38//! JSON-parsing Python, a build-on-demand fallback that could run a stale binary, and
39//! hand-authored `settings.json` plumbing. This crate is that envelope, inside the boundary —
40//! and this repo now dogfoods it in its own `.claude/settings.json`, the retired
41//! `shape-watch.sh` being exactly the glue described:
42//!
43//! * **speaks the Claude Code hook protocol natively** — reads the PostToolUse JSON
44//! from stdin, extracts `tool_input.file_path`, honours `CLAUDE_PROJECT_DIR`;
45//! * **discovers the repo's own declarations** — voices derived from the tree
46//! (`GuardVoices::for_edit`), classes taught from `spec/agenda.register`; the
47//! consumer writes zero code;
48//! * **carries the fail-open contract inside the boundary** — every internal failure
49//! (malformed JSON, missing path, unreadable file, refused register) is silence,
50//! as a drilled property of [`respond`], not a `|| exit 0` convention;
51//! * **installs its own wiring** — [`install`] writes or idempotently merges the
52//! `settings.json` entry, so the plumbing is derived output, never hand-authored.
53//!
54//! Honest frame — version skew: a globally installed `probe-hook` can be newer or
55//! older than the probe-algebra a repo pins, and the two can disagree about register
56//! grammar or voice derivation. The floor shipped here: every non-silent voice block
57//! carries the binary's version on its last line, and the guard is advisory and
58//! fail-open, so skew degrades to weaker advice, never a false refusal. (Re-execing a
59//! repo-local build is the known nicer form; deliberately not built until skew is
60//! observed hurting.)
61
62use std::path::Path;
63
64use boundary_spec::discover::agenda::{Agenda, GuardVoices};
65use boundary_spec::discover::watch::Ticker;
66
67/// The version tag every non-silent voice block carries — the skew floor.
68const VERSION: &str = env!("CARGO_PKG_VERSION");
69
70/// The edited file's path, out of the hook protocol's stdin JSON
71/// (`tool_input.file_path`). `None` for anything else — malformed JSON, a tool event
72/// without a path — because the hook's silence must be total on input it does not
73/// understand.
74pub fn extract_path(hook_json: &str) -> Option<String> {
75 let value: serde_json::Value = serde_json::from_str(hook_json).ok()?;
76 value
77 .get("tool_input")?
78 .get("file_path")?
79 .as_str()
80 .map(str::to_string)
81}
82
83/// The whole envelope, total: hook JSON in, whichever of the guard / shape-ticker / tier
84/// voices fire out (joined, one version footer), or `None`. Every failure path is `None` — the fail-open
85/// contract as a return type. The voices are derived from `project_dir` (the register, the
86/// shim evidence), the classes come from `spec/agenda.register`, and the edited path is
87/// normalized repo-relative so messages match what the repo's own gates would say.
88///
89/// Capability: Effectful — reads the edited file, the registers, and `build.rs`
90/// under `project_dir`.
91pub fn respond(hook_json: &str, project_dir: &Path) -> Option<String> {
92 let path = extract_path(hook_json)?;
93 // one file, one name: the Edit tool hands absolute paths — normalized here so
94 // guard messages and class matching see the repo-relative form.
95 let rel = project_dir
96 .to_str()
97 .and_then(|root| path.strip_prefix(&format!("{root}/")))
98 .unwrap_or(path.as_str())
99 .to_string();
100 let source = std::fs::read_to_string(&path).unwrap_or_default();
101
102 // SIX voices, each priced in silence — the whole edit-time envelope, so the shipped
103 // binary fully replaces the bash wrapper it descends from (the last, the freeze-delta
104 // courier, the hook carries rather than computes):
105 let mut blocks: Vec<String> = Vec::new();
106
107 // the GUARD — refusals that already exist downstream (a hand-edited generated lock, a
108 // loose `pub fn` the shim refuses), voices derived from the tree, classes taught from
109 // spec/agenda.register (a refused register fails open to "no taught classes").
110 let voices = GuardVoices::for_edit(project_dir, &rel);
111 let classes = spec_lock::Register {
112 name: "agenda".to_string(),
113 path: project_dir.join("spec/agenda.register"),
114 }
115 .entries()
116 .unwrap_or_default();
117 if let Some(guard) = Agenda::edit_guard(&rel, &source, &voices, &classes) {
118 blocks.push(guard);
119 }
120
121 // the SHAPE TICKER — speaks only when an edit moves the coupling (a bridge, a new
122 // net-disjoint component) on ANY Rust file, stateful across invocations (see [`shape_voice`]).
123 if let Some(shape) = shape_voice(project_dir, &rel, &source) {
124 blocks.push(shape);
125 }
126
127 // the TYPE-LIBRARY voice — the anti-duplication sense: when the edited file touches
128 // sorts that already carry operators ELSEWHERE in the committed qualify census, name
129 // those files and their operator families before a twin gets written (see
130 // [`library_voice`]).
131 if let Some(library) = library_voice(project_dir, &rel, &source) {
132 blocks.push(library);
133 }
134
135 // the QUALIFY voice — the edit-time lock delta as a DRIFT LEDGER: the whole current drift of
136 // `spec/qualify.spec`, re-derived from the tree on disk, surfaced on ANY edit (not scoped to
137 // certain files) but DEDUPED so it speaks only when the drift actually moves — see
138 // [`qualify_voice`]. Narrated BEFORE the build that would otherwise be first to notice.
139 if let Some(qualify) = qualify_voice(project_dir) {
140 blocks.push(qualify);
141 }
142
143 // the TIER voice — on FIRST edit of a file, its derived tier and the rules that tier
144 // carries (the reader-service the deleted `//! Tier:` markers gave, moved to the hook).
145 if let Some(tier) = tier_voice(project_dir, &rel) {
146 blocks.push(tier);
147 }
148
149 // the FREEZE-DELTA courier — the recommendation movement the last build derived (via
150 // `spec_lock::Lock::delta`) and left at `target/probe-hook/freeze-delta`. The hook does
151 // not COMPUTE it — it inserts it once into this context window and clears the courier.
152 if let Some(delta) = freeze_delta_voice(project_dir) {
153 blocks.push(delta);
154 }
155
156 if blocks.is_empty() {
157 return None;
158 }
159 Some(format!(
160 "{}\n(probe-hook {VERSION} — advisory, fail-open)",
161 blocks.join("\n")
162 ))
163}
164
165/// The shape ticker's voice for one edit — the coupling sense, folded in from the retired
166/// `place_watch --event` wrapper and now widened to ANY Rust file, not just theories. Two
167/// fronts onto one placer core: a `.rs` carrying `ops {` stanzas nets on its theory sorts
168/// (`parse_ops`); any other `.rs` nets on its OWN declared types (`parse_rust_sigs` — the
169/// module's structs/enums are its sorts, ubiquitous types never couple). Either way the
170/// placement is re-derived from TEXT (no compilation), diffed against the previous placement
171/// kept in a per-file state slug under `<project>/target/probe-hook`, and a move is narrated in
172/// the monotone vocabulary (a second net-disjoint component forming, or a BRIDGE coupling two).
173/// The noise policy lives in the ticker: an edit within one cluster is silence, so this stays a
174/// sixth sense, not a firehose. Fail-open throughout: an unparseable (half-written) file or any
175/// unreadable/unwritable state is silence (`.ok()?`), never a broken edit loop.
176///
177/// Capability: Effectful — reads and writes the ticker state under `project_dir/target`.
178fn shape_voice(project_dir: &Path, rel: &str, source: &str) -> Option<String> {
179 if !rel.ends_with(".rs") {
180 return None;
181 }
182 // pick the front by content: theory sorts if there is an `ops { }` stanza, else the
183 // module's own types. A half-written file that neither parser accepts falls silent.
184 let sigs = if source.contains("ops {") {
185 Ticker::parse_ops(source)
186 } else {
187 Ticker::parse_rust_sigs(source)
188 }
189 .ok()?;
190
191 let state_dir = project_dir.join("target/probe-hook");
192 let slug: String = rel
193 .chars()
194 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
195 .collect();
196 let state = state_dir.join(format!("{slug}.sigs"));
197 // the ticker keys on a `&'static` name; the process is short-lived, so a leak per hook
198 // invocation is the price of the borrow (the same trade the example made).
199 let name: &'static str = Box::leak(rel.to_string().into_boxed_str());
200 // the stored form is signature-level and parser-agnostic, so resume works for either front.
201 let mut ticker = match std::fs::read_to_string(&state) {
202 Ok(stored) => Ticker::resume(name, &stored),
203 // first sight: capture the baseline; a multi-component file announces itself once.
204 Err(_) => Ticker::new(),
205 };
206 let line = ticker.hook_line_signatures(name, sigs.clone());
207 std::fs::create_dir_all(&state_dir).ok()?;
208 std::fs::write(&state, Ticker::store_signatures(&sigs)).ok()?;
209 line
210}
211
212/// The QUALIFY voice — the edit-time LOCK DELTA as a DRIFT LEDGER, un-scoped and deduped. The
213/// qualify census (`spec/qualify.spec`) is text-derivable — a module qualifies by the SHAPE of its
214/// functions (operator-shaped, no I/O), no `eval` — so the whole live census is re-derived from the
215/// tree on disk and diffed against the committed lock, and the delta IS the drift the next
216/// `BLESS_QUALIFY=1` build would ratify. It shows the FULL current drift, every stale line together,
217/// accumulating as files move and empty the moment a re-bless reconciles the tree. The behavioural
218/// half of the mirror — distance, discovered laws — still needs the compiled theory and stays at the
219/// build (the freeze-delta courier carries it); qualification is the one lock a text edit re-derives.
220///
221/// Two design choices, both deliberate:
222/// * UN-SCOPED — the ledger is a property of the whole tree, surfaced on ANY edit, not narrowed to
223/// `src/*.rs` or any file class. Qualify drift is a repo fact; which file you happened to touch
224/// should not gate whether you see it.
225/// * DEDUPED, not naggy — because the standing drift renders identically on every edit until it
226/// moves, the voice persists the last-shown ledger under `target/probe-hook` and speaks ONLY when
227/// the render differs: an accumulated line appears once, on whatever edit first surfaces it, then
228/// stays quiet until the drift moves again; a re-bless empties it and the next real drift
229/// re-announces. So breadth of triggering does not become breadth of repetition.
230///
231/// A file that does not parse contributes nothing to the live census (`qualify_census_lines` skips
232/// it), so a half-written save never invents a movement. NO recipe: the delta names
233/// `spec/qualify.spec`, whose own header carries the regenerate command
234/// (`# … Regenerate with \`BLESS_QUALIFY=1 cargo build\``) — how-to-bless is self-documenting at the
235/// named lock and stable orientation (also CLAUDE.md's one rule), not news to reprint each firing.
236/// The movement renders through `spec_lock::LockDelta`, the renderer the freeze-delta courier uses.
237///
238/// A repo with no census pays nothing: the missing lock returns before any tree scan.
239///
240/// Capability: Effectful — reads `spec/qualify.spec`, rescans the `src/` tree, and reads/writes the
241/// dedup state under `project_dir`.
242/// The TYPE-LIBRARY voice — the anti-duplication sense (rung 0 of the bundle candidate,
243/// docs/roadmap.md): when the edited file touches SORTS that already carry operators in
244/// OTHER files, whisper which files and which operator families, so the existing vocabulary
245/// is in the window before a twin gets written. The library is the committed
246/// `spec/qualify.spec` — derived, ratified, cheap to read (the tier voice's move) — and the
247/// edited file's side is `Ticker::type_vocabulary` (every type ident its signatures
248/// mention, plus its own declared types). The census intersection IS the noise filter:
249/// ubiquitous types (`String`, `Vec`, `Result`) never appear as census sorts, so nothing
250/// wires to everything.
251///
252/// Priced in silence, the standing rules: only Rust edits; the edited file's own census
253/// line never speaks (its operators are not news to itself); no intersection is silence;
254/// and the render is DEDUPED per file (`target/probe-hook/<slug>.library` holds the last
255/// shown text), so the library speaks on first contact and again only when the overlap
256/// CHANGES — a grown family, a new sharing file, a dropped sort.
257///
258/// Capability: Effectful — reads `spec/qualify.spec` and the per-file dedup state.
259fn library_voice(project_dir: &Path, rel: &str, source: &str) -> Option<String> {
260 if !rel.ends_with(".rs") {
261 return None;
262 }
263 let census = std::fs::read_to_string(project_dir.join("spec/qualify.spec")).ok()?;
264 let vocabulary = Ticker::type_vocabulary(source).ok()?;
265
266 let mut lines: Vec<String> = Vec::new();
267 for line in census.lines() {
268 // the committed format: `<path>: QUALIFIES — operators [..] over sorts {..}`;
269 // header comments and blank lines simply do not match.
270 let Some((path, rest)) = line.split_once(": QUALIFIES — operators [") else {
271 continue;
272 };
273 let Some((operators, sorts)) = rest.split_once("] over sorts {") else {
274 continue;
275 };
276 let Some(sorts) = sorts.strip_suffix('}') else {
277 continue;
278 };
279 if path == rel {
280 continue;
281 }
282 let shared: Vec<&str> = sorts
283 .split(", ")
284 .filter(|s| vocabulary.contains(*s))
285 .collect();
286 if shared.is_empty() {
287 continue;
288 }
289 lines.push(format!(
290 " {path}: shares {{{}}} — operators [{operators}]",
291 shared.join(", ")
292 ));
293 }
294 if lines.is_empty() {
295 return None;
296 }
297 let rendered = format!(
298 "type library — sorts this file touches already carry operators elsewhere:\n{}",
299 lines.join("\n")
300 );
301
302 let slug: String = rel
303 .chars()
304 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
305 .collect();
306 let state = project_dir
307 .join("target/probe-hook")
308 .join(format!("{slug}.library"));
309 if std::fs::read_to_string(&state).is_ok_and(|s| s == rendered) {
310 return None;
311 }
312 std::fs::create_dir_all(project_dir.join("target/probe-hook")).ok()?;
313 std::fs::write(&state, &rendered).ok()?;
314 Some(rendered)
315}
316
317fn qualify_voice(project_dir: &Path) -> Option<String> {
318 // a repo without the census returns here, before any scan — the feature exists only where the
319 // lock does, which is not the same as scoping which EDITS may surface it.
320 let committed_text = std::fs::read_to_string(project_dir.join("spec/qualify.spec")).ok()?;
321 let committed: String = committed_text
322 .lines()
323 .filter(|l| !l.trim_start().starts_with('#'))
324 .collect::<Vec<_>>()
325 .join("\n");
326 // the LIVE census body, rescanned from the tree on disk (the edit is already written) — the
327 // WHOLE current drift, so every stale line sits together.
328 let live =
329 boundary_enforce::qualify_census_lines(&project_dir.join("src"), project_dir).join("\n");
330
331 let state = project_dir.join("target/probe-hook/qualify-ledger");
332 let delta = spec_lock::LockDelta::between(&committed, &live);
333 if delta.is_empty() {
334 // clean (nothing stale, or a re-bless just reconciled it): forget any shown ledger so the
335 // NEXT real drift re-announces, and say nothing — an empty ledger is not worth a block.
336 if std::fs::read_to_string(&state).is_ok_and(|s| !s.is_empty()) {
337 let _ = std::fs::write(&state, "");
338 }
339 return None;
340 }
341 // DEDUP: the standing drift renders the same until it moves, so speak only when it differs from
342 // what was last shown — breadth of triggering must not become breadth of repetition.
343 let rendered = delta.render("qualify census (spec/qualify.spec)");
344 if std::fs::read_to_string(&state).is_ok_and(|s| s == rendered) {
345 return None;
346 }
347 std::fs::create_dir_all(project_dir.join("target/probe-hook")).ok()?;
348 std::fs::write(&state, &rendered).ok()?;
349 Some(rendered)
350}
351
352/// The TIER voice — one line on the FIRST edit of a file: its DERIVED tier and what that
353/// tier's membership means, BOTH read from the committed `spec/tiers.spec` (the single source
354/// the build's rule dispatch also consumes). The tier comes from the file's `- <path>: <TIER>`
355/// row; the meaning comes from the lock's `# rule <TIER>:` legend, which `boundary-enforce`
356/// renders — so the hook recites what is ENFORCED, from disk, never a compiled-in copy that
357/// could drift from a newer enforcer. (An older lock without the legend still names the tier
358/// and points at regeneration; it never guesses the rules.) This is the reader-service the
359/// deleted `//! Tier:` markers gave, moved to the edit hook. Fires once per file (a persisted
360/// marker under `<project>/target/probe-hook`), so the orientation is paid once, not on every
361/// save. Silent for a file the partition does not name, and fail-open on any read/write failure.
362///
363/// Capability: Effectful — reads `spec/tiers.spec`, reads and writes the seen-marker.
364fn tier_voice(project_dir: &Path, rel: &str) -> Option<String> {
365 let tiers = std::fs::read_to_string(project_dir.join("spec/tiers.spec")).ok()?;
366 // the committed format is `- <path>: <TIER> (<reason>)`, one line per file.
367 let prefix = format!("- {rel}: ");
368 let rest = tiers
369 .lines()
370 .find_map(|line| line.trim_start().strip_prefix(&prefix))?;
371 let tier = rest.split_whitespace().next()?;
372
373 let slug: String = rel
374 .chars()
375 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
376 .collect();
377 let seen = project_dir
378 .join("target/probe-hook")
379 .join(format!("{slug}.tier"));
380 if seen.exists() {
381 return None;
382 }
383
384 // the rule prose is READ from the lock's legend, never held here — so what the hook recites
385 // is exactly what the enforcer that wrote the lock forbids. A lock without the legend (an
386 // older enforcer, a consumer yet to regenerate) gets the tier and a pointer, not a guess.
387 let legend = format!("# rule {tier}: ");
388 let line = match tiers
389 .lines()
390 .find_map(|l| l.trim_start().strip_prefix(&legend))
391 {
392 Some(rules) => format!("tier: {rel} is {tier} — {}", rules.trim()),
393 None => format!("tier: {rel} is {tier} — regenerate spec/tiers.spec for this tier's rules"),
394 };
395 std::fs::create_dir_all(seen.parent()?).ok()?;
396 std::fs::write(&seen, "").ok()?;
397 Some(line)
398}
399
400/// The FREEZE-DELTA courier — the fourth voice, and the only one the hook does NOT compute.
401/// It carries a recommendation movement (`interpreter arithmetic` re-placed as two, a seam
402/// candidate on `Int` appeared) that `spec_lock::Lock::delta` already derived at the build
403/// which produced it: `examples/freeze_spec` holds each lock's committed text against the live
404/// text it just derived — the diff the drift gate collapses to a bool — and writes the rendered
405/// movement to `target/probe-hook/freeze-delta`. The hook reads that courier, injects it ONCE,
406/// and clears it, so the movement reaches the next context window after the build that caused it
407/// and never lingers. Nothing here re-derives or watches: the mechanism is native to `delta()`,
408/// run at freeze time; this is only the wire into the window.
409///
410/// Fail-open: a missing/empty/unreadable courier is silence, and if the clear-on-consume write
411/// fails the voice stays silent rather than risk repeating the same movement every edit.
412///
413/// Capability: Effectful — reads and truncates the courier under `project_dir/target`.
414fn freeze_delta_voice(project_dir: &Path) -> Option<String> {
415 let courier = project_dir.join("target/probe-hook/freeze-delta");
416 let narration = std::fs::read_to_string(&courier).ok()?;
417 let narration = narration.trim();
418 if narration.is_empty() {
419 return None;
420 }
421 // consume before speaking: if the courier cannot be cleared, stay silent — better an
422 // unseen movement than the same one re-injected on every subsequent edit.
423 std::fs::write(&courier, "").ok()?;
424 Some(format!(
425 "your last freeze moved these recommendations (from spec_lock::Lock::delta):\n{narration}"
426 ))
427}
428
429/// The settings entry this crate wires for itself.
430const MATCHER: &str = "Edit|Write";
431
432/// Write or merge the `.claude/settings.json` hook entry — the plumbing as DERIVED
433/// output. Idempotent: an entry whose command already invokes `probe-hook` is left
434/// alone; everything else in the file is preserved untouched. Returns a line saying
435/// what happened; errs only on a `settings.json` that exists but does not parse
436/// (never overwrite what cannot be read — that file is not ours).
437///
438/// Capability: Effectful — reads and writes `.claude/settings.json` under
439/// `project_dir`.
440pub fn install(project_dir: &Path) -> Result<String, String> {
441 let path = project_dir.join(".claude/settings.json");
442 let mut root: serde_json::Value = match std::fs::read_to_string(&path) {
443 Ok(text) => serde_json::from_str(&text).map_err(|e| {
444 format!(
445 "{} exists but does not parse ({e}) — refusing to touch a settings \
446 file that cannot be read back",
447 path.display()
448 )
449 })?,
450 Err(_) => serde_json::json!({}),
451 };
452
453 let post = root
454 .as_object_mut()
455 .ok_or("settings.json is not a JSON object")?
456 .entry("hooks")
457 .or_insert_with(|| serde_json::json!({}))
458 .as_object_mut()
459 .ok_or("settings.json `hooks` is not an object")?
460 .entry("PostToolUse")
461 .or_insert_with(|| serde_json::json!([]));
462 let entries = post
463 .as_array_mut()
464 .ok_or("settings.json `hooks.PostToolUse` is not an array")?;
465
466 let already = entries.iter().any(|entry| {
467 entry["hooks"]
468 .as_array()
469 .is_some_and(|hooks| hooks.iter().any(|h| h["command"] == "probe-hook"))
470 });
471 if already {
472 return Ok(format!(
473 "probe-hook {VERSION}: already installed — no change"
474 ));
475 }
476
477 entries.push(serde_json::json!({
478 "matcher": MATCHER,
479 "hooks": [{ "type": "command", "command": "probe-hook", "timeout": 10 }]
480 }));
481 if let Some(parent) = path.parent() {
482 std::fs::create_dir_all(parent)
483 .map_err(|e| format!("create {} ({e})", parent.display()))?;
484 }
485 let rendered = serde_json::to_string_pretty(&root).map_err(|e| e.to_string())?;
486 std::fs::write(&path, rendered + "\n")
487 .map_err(|e| format!("write {} ({e})", path.display()))?;
488 Ok(format!(
489 "probe-hook {VERSION}: installed PostToolUse({MATCHER}) into {}",
490 path.display()
491 ))
492}
493
494#[cfg(test)]
495mod drills {
496 use super::*;
497
498 fn tree(name: &str, files: &[(&str, &str)]) -> std::path::PathBuf {
499 let root = std::env::temp_dir().join(format!("probe-hook-{}-{name}", std::process::id()));
500 let _ = std::fs::remove_dir_all(&root);
501 std::fs::create_dir_all(&root).unwrap();
502 for (rel, contents) in files {
503 let path = root.join(rel);
504 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
505 std::fs::write(path, contents).unwrap();
506 }
507 root
508 }
509
510 fn event(path: &std::path::Path) -> String {
511 serde_json::json!({ "tool_input": { "file_path": path.to_str().unwrap() } }).to_string()
512 }
513
514 /// THE FAIL-OPEN CONTRACT, drilled — the property the per-consumer `|| exit 0`
515 /// conventions never tested: malformed JSON, an event without a path, a path that
516 /// does not exist, and a refused class register are all SILENCE, not errors.
517 #[test]
518 fn every_failure_path_is_silence() {
519 let root = tree(
520 "fail-open",
521 &[("spec/agenda.register", "bare key no colon\n")],
522 );
523 assert_eq!(respond("not json at all {", &root), None);
524 assert_eq!(respond("{\"tool_input\":{}}", &root), None);
525 assert_eq!(respond("{\"tool_input\":{\"file_path\":123}}", &root), None);
526 // the register above REFUSES to parse — the guard still answers (fail-open to
527 // no taught classes), and an unremarkable file is silence:
528 let doc = root.join("docs/note.md");
529 std::fs::create_dir_all(doc.parent().unwrap()).unwrap();
530 std::fs::write(&doc, "prose\n").unwrap();
531 assert_eq!(respond(&event(&doc), &root), None);
532 }
533
534 /// The envelope end to end: a generated-lock edit warns (with the version-tagged
535 /// skew floor), a taught consumer class warns as a lock instead of refusing as
536 /// unknown, and the structural voice speaks only where the shim's refusal exists.
537 #[test]
538 fn the_envelope_speaks_with_derived_voices_and_taught_classes() {
539 let root = tree(
540 "voices",
541 &[
542 ("spec/router.spec", "# a lock\n"),
543 (
544 "spec/agenda.register",
545 "surface.lock: the surface census moved — admit the new commands.\n",
546 ),
547 ("spec/custom.surface.lock", "# consumer lock\n"),
548 (
549 "src/gates.rs",
550 "pub fn pipeline() -> Pipeline { todo!() }\n",
551 ),
552 ],
553 );
554 // a generated lock warns, and the voice block carries the version (skew floor):
555 let voice = respond(&event(&root.join("spec/router.spec")), &root).expect("a lock warns");
556 assert!(voice.contains("never hand-edit"), "{voice}");
557 assert!(voice.contains(&format!("probe-hook {VERSION}")), "{voice}");
558 // a TAUGHT class is a known lock (never "teach the router"):
559 let voice =
560 respond(&event(&root.join("spec/custom.surface.lock")), &root).expect("taught warns");
561 assert!(voice.contains("never hand-edit"), "{voice}");
562 // no shim in this tree: the loose pub fn is NOT a refusal here — silence.
563 assert_eq!(respond(&event(&root.join("src/gates.rs")), &root), None);
564 // the same tree WITH shim evidence: the structural voice exists and speaks.
565 std::fs::write(
566 root.join("build.rs"),
567 "use boundary_enforce::Enforcement;\nfn main() {}\n",
568 )
569 .unwrap();
570 let voice = respond(&event(&root.join("src/gates.rs")), &root).expect("loose fn warns");
571 assert!(
572 voice.contains("`pub fn pipeline` is a loose public function"),
573 "{voice}"
574 );
575 }
576
577 /// THE SECOND VOICE, drilled — a theory edit that splits into net-disjoint features
578 /// makes the shape ticker speak (the half folded in from the retired `place_watch`
579 /// wrapper), while an ordinary `.rs` with a single type cluster stays silent. The version
580 /// footer rides both voices.
581 #[test]
582 fn a_theory_edit_speaks_the_shape_voice() {
583 let root = tree("shape", &[]);
584 let thy = root.join("src/workbench.rs");
585 std::fs::create_dir_all(thy.parent().unwrap()).unwrap();
586 // two net-disjoint features in one bundle: the ticker announces the split on sight.
587 let ops = " ops {\n Nullary \"zero\" \"zero\" () -> S::A = zero;\n \
588 Nullary \"off\" \"off\" () -> S::B = off;\n }\n";
589 std::fs::write(&thy, ops).unwrap();
590 let voice =
591 respond(&event(&thy), &root).expect("a multi-component theory announces its shape");
592 assert!(voice.contains("net-disjoint"), "{voice}");
593 assert!(voice.contains(&format!("probe-hook {VERSION}")), "{voice}");
594 // a plain .rs with one type and no coupling structure is silence.
595 let plain = root.join("src/plain.rs");
596 std::fs::write(&plain, "pub struct X;\n").unwrap();
597 assert_eq!(respond(&event(&plain), &root), None);
598 }
599
600 /// THE SIXTH SENSE, drilled — the coupling voice now fires on ANY Rust file, netting on the
601 /// module's OWN types. A two-cluster module announces itself once; then the function that
602 /// first spans both clusters BRIDGES them, as it is saved. The whole point of the widening:
603 /// no `ops { }`, no theory, just plain Rust getting the live coupling sense.
604 #[test]
605 fn a_plain_rust_edit_speaks_the_coupling_voice() {
606 let root = tree("coupling", &[]);
607 let m = root.join("src/billing.rs");
608 std::fs::create_dir_all(m.parent().unwrap()).unwrap();
609 // Order|Invoice one cluster, Ledger another — two net-disjoint components on first sight.
610 let two = "struct Order; struct Invoice; struct Ledger;\n\
611 fn bill(o: Order) -> Invoice { todo!() }\n\
612 fn post(l: Ledger) -> Ledger { todo!() }\n";
613 std::fs::write(&m, two).unwrap();
614 let voice = respond(&event(&m), &root).expect("a two-cluster module announces itself");
615 assert!(voice.contains("net-disjoint components"), "{voice}");
616 // an edit within one cluster: silence (the sixth sense, not a firehose).
617 let local = format!("{two}fn refund(o: Order) -> Order {{ todo!() }}\n");
618 std::fs::write(&m, &local).unwrap();
619 assert_eq!(respond(&event(&m), &root), None);
620 // the function that spans both clusters bridges them, at the moment it is written.
621 let bridge = format!("{local}fn reconcile(i: Invoice) -> Ledger {{ todo!() }}\n");
622 std::fs::write(&m, &bridge).unwrap();
623 let voice = respond(&event(&m), &root).expect("the spanning fn bridges");
624 assert!(
625 voice.contains("BRIDGED") && voice.contains("intended?"),
626 "{voice}"
627 );
628 }
629
630 /// THE THIRD VOICE, drilled — a file's derived tier and rules on FIRST edit, paid once
631 /// (a second edit is silent), and silent for a file the partition does not name. The rule
632 /// prose is READ from the lock's `# rule <TIER>:` legend, never compiled in — so a lock
633 /// WITHOUT the legend names the tier and points at regeneration rather than guessing.
634 #[test]
635 fn the_tier_voice_reads_its_rules_from_the_lock_legend() {
636 let root = tree(
637 "tier",
638 &[
639 (
640 "spec/tiers.spec",
641 "# the partition\n# rule BOUNDARY: tier 1 — a domain's surface; no loose `pub fn`\n\
642 - src/engine.rs: BOUNDARY (a door)\n",
643 ),
644 ("src/engine.rs", "pub struct X;\n"),
645 ],
646 );
647 let ev = event(&root.join("src/engine.rs"));
648 let first = respond(&ev, &root).expect("the first edit orients");
649 assert!(first.contains("tier: src/engine.rs is BOUNDARY"), "{first}");
650 // the rule text came from the lock's legend, not a copy in the binary:
651 assert!(first.contains("no loose `pub fn`"), "{first}");
652 // the orientation was paid — a second edit of the same file is silence.
653 assert_eq!(respond(&ev, &root), None);
654 // a file the partition does not name: silence.
655 let other = root.join("src/unlisted.rs");
656 std::fs::write(&other, "pub struct Y;\n").unwrap();
657 assert_eq!(respond(&event(&other), &root), None);
658
659 // a lock that names the tier but carries NO legend line: the tier still surfaces, and
660 // the hook points at regeneration instead of reciting a rule it no longer holds.
661 let bare = tree(
662 "tier-no-legend",
663 &[
664 (
665 "spec/tiers.spec",
666 "# the partition\n- src/x.rs: ALGEBRA (remainder)\n",
667 ),
668 ("src/x.rs", "pub struct Z;\n"),
669 ],
670 );
671 let voice =
672 respond(&event(&bare.join("src/x.rs")), &bare).expect("tier still names itself");
673 assert!(voice.contains("tier: src/x.rs is ALGEBRA"), "{voice}");
674 assert!(voice.contains("regenerate spec/tiers.spec"), "{voice}");
675 }
676
677 /// THE TYPE-LIBRARY VOICE, drilled — the anti-duplication sense. An edit touching a sort
678 /// that already carries operators ELSEWHERE names the file and its family, once; the same
679 /// overlap again is silence (deduped per file); a GROWN overlap re-announces; a file whose
680 /// types intersect nothing is silence; and the edited file's own census line never speaks.
681 #[test]
682 fn the_library_voice_names_existing_operator_families_once() {
683 // the tree is CONSISTENT with its committed census (the qualify ledger stays
684 // silent), so every block below is the library voice's alone. The edited file
685 // does not qualify (borrowed arg) — but its signature MENTIONS Credits, an
686 // imported domain type: exactly the twin-about-to-be-written moment.
687 let meter = "pub struct Credits;\npub struct Order;\n\
688 impl Credits {\n\
689 pub fn grant(self, o: Order) -> Credits { let _ = o; self }\n\
690 pub fn spend(self, c: Credits) -> Credits { c }\n\
691 }\n";
692 let gauge =
693 "pub struct Level;\nimpl Level { pub fn fuse(self, l: Level) -> Level { l } }\n";
694 let root = tree(
695 "library",
696 &[
697 (
698 "spec/qualify.spec",
699 "# census\n\
700 src/gauge.rs: QUALIFIES — operators [Level::fuse] over sorts {Level}\n\
701 src/meter.rs: QUALIFIES — operators [Credits::grant, Credits::spend] over sorts {Credits, Order}\n",
702 ),
703 ("src/meter.rs", meter),
704 ("src/gauge.rs", gauge),
705 (
706 "src/new_work.rs",
707 "use crate::meter::Credits;\npub fn top_up(c: &Credits) -> Credits { c.spend(Credits) }\n",
708 ),
709 ("src/stranger.rs", "pub struct Unrelated;\n"),
710 ],
711 );
712 let ev = event(&root.join("src/new_work.rs"));
713 let voice = respond(&ev, &root).expect("the overlap speaks");
714 assert!(
715 voice.contains("type library — sorts this file touches already carry operators"),
716 "{voice}"
717 );
718 assert!(
719 voice.contains(
720 "src/meter.rs: shares {Credits} — operators [Credits::grant, Credits::spend]"
721 ),
722 "{voice}"
723 );
724 assert!(!voice.contains("gauge"), "no Level overlap: {voice}");
725 // deduped: the same overlap on the next edit is silence.
726 assert_eq!(respond(&ev, &root), None);
727 // a GROWN overlap re-announces: the edit now touches Order too.
728 std::fs::write(
729 root.join("src/new_work.rs"),
730 "use crate::meter::{Credits, Order};\n\
731 pub fn top_up(c: &Credits) -> Credits { c.spend(Credits) }\n\
732 pub fn settle(o: &Order) -> Order { let _ = o; Order }\n",
733 )
734 .unwrap();
735 let voice = respond(&ev, &root).expect("the grown overlap re-announces");
736 assert!(voice.contains("shares {Credits, Order}"), "{voice}");
737 // a file intersecting nothing is silence; the census file's OWN line never
738 // speaks (its operators are not news to itself).
739 assert_eq!(respond(&event(&root.join("src/stranger.rs")), &root), None);
740 assert_eq!(
741 respond(&event(&root.join("src/meter.rs")), &root),
742 None,
743 "a file's own operators are not news to itself"
744 );
745 }
746
747 /// THE QUALIFY VOICE, drilled — the edit-time LOCK DELTA as an UN-SCOPED, DEDUPED drift ledger.
748 /// A fresh tree is silence; a reshaping edit shows the census movement once with NO how-to-bless
749 /// recipe; the SAME drift on a later edit is silence (deduped, not naggy); a grown ledger
750 /// surfaces on ANY edit — including a non-`.rs` file — because it is un-scoped; and re-blessing
751 /// empties it, so the next real drift re-announces.
752 #[test]
753 fn the_qualify_voice_is_an_unscoped_deduped_drift_ledger() {
754 let root = tree(
755 "qualify",
756 &[
757 (
758 "spec/qualify.spec",
759 "# census\nsrc/a.rs: QUALIFIES — operators [f] over sorts {A}\n",
760 ),
761 (
762 "src/a.rs",
763 "pub struct A;\npub fn f(x: A) -> A { todo!() }\n",
764 ),
765 ],
766 );
767 let eva = event(&root.join("src/a.rs"));
768 // fresh: the live census reproduces the committed lock — silence.
769 assert_eq!(respond(&eva, &root), None);
770
771 // reshape a.rs (a second operator): the ledger moved (empty -> one line) → it speaks once.
772 std::fs::write(
773 root.join("src/a.rs"),
774 "pub struct A;\npub fn f(x: A) -> A { todo!() }\npub fn g(x: A) -> A { todo!() }\n",
775 )
776 .unwrap();
777 let v = respond(&eva, &root).expect("the census drift shows on change");
778 assert!(v.contains("qualify census (spec/qualify.spec)"), "{v}");
779 assert!(
780 v.contains("+ src/a.rs: QUALIFIES — operators [f, g] over sorts {A}"),
781 "{v}"
782 );
783 assert!(
784 !v.contains("BLESS_QUALIFY") && !v.to_lowercase().contains("re-bless"),
785 "the how-to-bless recipe is gone: {v}"
786 );
787 // DEDUP: the same drift on the next edit is silence — breadth of triggering is not breadth
788 // of repetition.
789 assert_eq!(respond(&eva, &root), None);
790
791 // a SECOND file starts qualifying: the GROWN ledger surfaces on a NON-`.rs` edit — proof it
792 // is un-scoped (which file you touch does not gate seeing the drift), and both lines sit.
793 std::fs::write(
794 root.join("src/b.rs"),
795 "pub struct B;\npub fn h(x: B) -> B { todo!() }\n",
796 )
797 .unwrap();
798 let doc = root.join("README.md");
799 std::fs::write(&doc, "notes\n").unwrap();
800 let v = respond(&event(&doc), &root).expect("a non-.rs edit surfaces the CHANGED ledger");
801 assert!(
802 v.contains("+ src/a.rs: QUALIFIES — operators [f, g] over sorts {A}"),
803 "{v}"
804 );
805 assert!(
806 v.contains("+ src/b.rs: QUALIFIES — operators [h] over sorts {B}"),
807 "{v}"
808 );
809 // and the same grown drift on the next edit is silence again (deduped).
810 assert_eq!(respond(&event(&doc), &root), None);
811
812 // re-bless (the committed lock catches up to the tree): the ledger empties — silence.
813 std::fs::write(
814 root.join("spec/qualify.spec"),
815 "# census\nsrc/a.rs: QUALIFIES — operators [f, g] over sorts {A}\n\
816 src/b.rs: QUALIFIES — operators [h] over sorts {B}\n",
817 )
818 .unwrap();
819 assert_eq!(respond(&eva, &root), None);
820 }
821
822 /// THE FOURTH VOICE, drilled — the courier carries a movement `delta()` derived at freeze
823 /// time into the window ONCE, then clears itself, and an empty/absent courier is silence.
824 /// The hook computes nothing here; it only wires the emitter's narration into context.
825 #[test]
826 fn the_freeze_delta_courier_injects_once_then_clears() {
827 let root = tree("courier", &[("src/plain.rs", "pub struct X;\n")]);
828 // an empty courier (a build with no movement) is silence.
829 let courier = root.join("target/probe-hook/freeze-delta");
830 std::fs::create_dir_all(courier.parent().unwrap()).unwrap();
831 std::fs::write(&courier, "").unwrap();
832 assert_eq!(respond(&event(&root.join("src/plain.rs")), &root), None);
833 // a real movement (as `spec_lock::LockDelta::render` would write it) is carried once.
834 std::fs::write(
835 &courier,
836 "lock `boundary-spec` moved:\n - verdict: 7 of 7 settled\n + verdict: 6 of 7 settled\n",
837 )
838 .unwrap();
839 let voice = respond(&event(&root.join("src/plain.rs")), &root).expect("the courier speaks");
840 assert!(
841 voice.contains("your last freeze moved these recommendations"),
842 "{voice}"
843 );
844 assert!(voice.contains("6 of 7 settled"), "{voice}");
845 // consumed: the very next edit is silent — the movement is injected once, not per save.
846 assert_eq!(respond(&event(&root.join("src/plain.rs")), &root), None);
847 assert_eq!(
848 std::fs::read_to_string(&courier).unwrap(),
849 "",
850 "the courier is cleared on consume"
851 );
852 }
853
854 /// `install` is derived plumbing: creates the file from nothing, is idempotent,
855 /// and preserves everything it did not write — including an existing unrelated
856 /// hook in the same PostToolUse list. A settings file that does not parse is
857 /// REFUSED, never overwritten.
858 #[test]
859 fn install_wires_itself_and_touches_nothing_else() {
860 let root = tree("install", &[]);
861 let done = install(&root).expect("installs from nothing");
862 assert!(done.contains("installed PostToolUse(Edit|Write)"), "{done}");
863 let again = install(&root).expect("idempotent");
864 assert!(again.contains("already installed"), "{again}");
865
866 // an existing settings file with an unrelated hook survives the merge intact:
867 let root = tree(
868 "install-merge",
869 &[(
870 ".claude/settings.json",
871 r#"{"model":"opus","hooks":{"PostToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"other-tool"}]}]}}"#,
872 )],
873 );
874 install(&root).expect("merges");
875 let text = std::fs::read_to_string(root.join(".claude/settings.json")).unwrap();
876 let value: serde_json::Value = serde_json::from_str(&text).unwrap();
877 assert_eq!(value["model"], "opus", "unrelated settings preserved");
878 let post = value["hooks"]["PostToolUse"].as_array().unwrap();
879 assert_eq!(post.len(), 2, "the unrelated hook survives alongside ours");
880 assert_eq!(post[0]["hooks"][0]["command"], "other-tool");
881 assert_eq!(post[1]["hooks"][0]["command"], "probe-hook");
882
883 // a corrupt settings file is refused by name, never clobbered:
884 let root = tree(
885 "install-corrupt",
886 &[(".claude/settings.json", "{ not json")],
887 );
888 let err = install(&root).unwrap_err();
889 assert!(err.contains("does not parse"), "{err}");
890 assert_eq!(
891 std::fs::read_to_string(root.join(".claude/settings.json")).unwrap(),
892 "{ not json",
893 "the unreadable file is untouched"
894 );
895 }
896}