Skip to main content

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}