Skip to main content

amont_runtime/
agents_md.rs

1//! `amont agents-md` — a self-verifying pointer for coding agents.
2//!
3//! Deliberately NOT a prose contract and NOT a list of check names: both
4//! drift from what the registry actually enforces the moment a check is
5//! added, removed, or its severity changes — see `amont-fleet`'s
6//! `severities` module doc for the canonical shape of that failure. This
7//! generates a short block that instead tells an agent to ask
8//! `amont list --json`, which cannot go stale because it IS the registry.
9//!
10//! Never touches anything outside its own markers. An `AGENTS.md` a
11//! repository already wrote is the repository's, not this tool's, to
12//! rewrite — matching `amont.conf`'s "declared, never assumed" posture.
13
14use std::ops::Range;
15use std::path::Path;
16
17pub const START: &str = "<!-- amont:start -->";
18pub const END: &str = "<!-- amont:end -->";
19
20/// The block, START and END inclusive, always ending in exactly one newline.
21///
22/// The branch paragraph is rendered from `vocabulary::BRANCH_PREFIXES` — the
23/// table `pre-push-branch-pattern` enforces — precisely so this file cannot
24/// become a second, driftable copy of the rule. What the block says and what
25/// the hook rejects are the same constant.
26pub fn generate_block(settings: &crate::config::Settings) -> String {
27    let clocks = |secs: u64| match secs {
28        0 => "no limit".to_string(),
29        s => crate::hooks::common::human_secs(s),
30    };
31    let idle = clocks(crate::hooks::common::idle_timeout(settings));
32    let ceiling = clocks(crate::hooks::common::check_timeout(settings));
33    let prefixes = crate::vocabulary::BRANCH_PREFIXES
34        .iter()
35        .map(|p| p.name)
36        .collect::<Vec<_>>()
37        .join(", ");
38    format!(
39        "{START}\n\
40\n\
41## Git hooks (amont)\n\
42\n\
43This repository enforces pre-commit / pre-push checks. Ask the registry\n\
44rather than guessing before assuming a change is safe:\n\
45\n\
46```sh\n\
47amont list --json\n\
48amont list --json --stage pre-push --pushed  # exactly what pushing next gates\n\
49```\n\
50\n\
51Each check reports its _effective_ severity (`block`/`warn`, including any\n\
52`amont.severity.*` override) and whether it fires here. The same output\n\
53carries `commit_style`: the subject and description limits `commit-msg`\n\
54enforces, and where the type's gitmoji is placed. It also carries\n\
55`branch_style`: name a branch `<prefix>/<name>` BEFORE creating it —\n\
56prefixes are {prefixes} — because `pre-push` refuses a\n\
57new branch that breaks the pattern, at the end of the work instead of the\n\
58start.\n\
59\n\
60`git commit` and `git push` both run their checks first, and neither is\n\
61instant: pre-commit can invoke formatters, linters or clippy (a workspace\n\
62build), and pre-push can run the test suite. Run `amont rehearse --wait`\n\
63BEFORE `git push`: it runs the same push gate on a snapshot of HEAD with no\n\
64connection open and stamps the tree, so the push itself skips the suite and\n\
65holds the remote for seconds instead of minutes (git connects before\n\
66pre-push runs, and a remote may drop the idle session while a suite runs).\n\
67Where `amont.rehearseOnCommit` is on, every commit already starts that\n\
68rehearsal in the background and `--wait` only follows it. Give both commands the\n\
69longest timeout your tooling allows, never its default: here a check is\n\
70killed only after {idle} of silence or {ceiling} in total\n\
71(`amont.idleTimeout` / `amont.timeout`) — and on Linux and macOS a silent\n\
72check whose processes are busy on CPU is not \"silent\": it can run to the\n\
73{ceiling} ceiling. A test suite may legitimately run for most of that. If your tooling caps a foreground command below it,\n\
74run the command in the background and read its result when it exits —\n\
75while it runs, a line a minute on stderr says which check is alive and\n\
76when it last printed. A push killed mid-suite pushed nothing; a commit\n\
77killed mid-check committed nothing, and your unstaged work stays parked\n\
78until the next run says how to recover it. Neither is the checks failing —\n\
79it is the timeout. Run both bare and check the effect (`git log --oneline\n\
80-1`, `git ls-remote origin <branch>`): trimming their output with `| tail`\n\
81reports the pipe's exit status, so a killed or rejected run reads as\n\
82success.\n\
83\n\
84Never bypass with `--no-verify`. To change enforcement, downgrade it\n\
85intentionally instead:\n\
86\n\
87```sh\n\
88git config amont.severity.<check-id> warn\n\
89```\n\
90\n\
91`commit-msg` takes neither `hook.skip` nor a severity override. Write the\n\
92message it asks for, or change what it asks for — `amont setup`, or\n\
93`amont.commit.*` directly.\n\
94\n\
95{END}\n"
96    )
97}
98
99/// The `CLAUDE.md` pointer, START and END inclusive.
100///
101/// Claude Code loads `CLAUDE.md`; the tool-neutral convention is
102/// `AGENTS.md`. Writing the whole block into both would be two copies to
103/// drift apart, so the guidance keeps ONE home and this is a generated
104/// signpost to it — inside the same markers, so `--check` reports a stale
105/// pointer exactly as it reports a stale block. A hand-written "see
106/// AGENTS.md" line is the one part that could rot silently; this cannot.
107pub fn generate_pointer() -> String {
108    format!(
109        "{START}\n\
110\n\
111## Git hooks (amont)\n\
112\n\
113This repository enforces pre-commit / pre-push checks that can REJECT a\n\
114commit or a push. What runs, the branch-name rule, and why `git commit`\n\
115and `git push` both need a long timeout — or a background run — are in\n\
116[AGENTS.md](AGENTS.md) — read it before committing. Both files are\n\
117generated: run `amont agents-md` after changing either.\n\
118\n\
119{END}\n"
120    )
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum MarkerState {
125    /// Exactly one marker present, or `END` appears before `START` — a state
126    /// this tool refuses to guess its way out of.
127    Malformed,
128}
129
130/// The byte range to replace (markers inclusive, one trailing newline
131/// consumed), or `None` when neither marker is present at all.
132///
133/// `start`/`end` are searched INDEPENDENTLY — not `end` searched only within
134/// the text after a found `start` — so a file holding `END` with no `START`
135/// at all (searching from `start`'s position finds nothing, because there is
136/// no `start`) still surfaces as `(None, Some(_))` rather than silently
137/// reading as "no markers, nothing to guard against."
138fn marker_range(text: &str) -> Result<Option<Range<usize>>, MarkerState> {
139    let start = text.find(START);
140    let end = text.find(END);
141
142    match (start, end) {
143        (None, None) => Ok(None),
144        (Some(s), Some(e)) if e >= s + START.len() => {
145            let mut range_end = e + END.len();
146            if text[range_end..].starts_with('\n') {
147                range_end += 1; // swallow exactly one trailing newline
148            }
149            Ok(Some(s..range_end))
150        }
151        // Exactly one present, or END appears before START.
152        _ => Err(MarkerState::Malformed),
153    }
154}
155
156/// What writing would produce, given what is on disk today.
157///
158/// - no file / empty file → the block alone.
159/// - file, no markers → block appended, separated by exactly one blank line.
160/// - file, markers found → everything outside the markers is byte-for-byte
161///   untouched; the marked span is replaced.
162/// - markers malformed (exactly one present, or out of order) → refused.
163pub fn desired_file_content(
164    settings: &crate::config::Settings,
165    existing: &str,
166) -> Result<String, MarkerState> {
167    desired_with(existing, &generate_block(settings))
168}
169
170/// The same splice, for the pointer.
171pub fn desired_pointer_content(existing: &str) -> Result<String, MarkerState> {
172    desired_with(existing, &generate_pointer())
173}
174
175/// The splice itself, with the text to splice as a parameter — block and
176/// pointer differ only in what goes between the markers, and two copies of
177/// this logic would be two places for the "append vs replace" rules to
178/// disagree.
179fn desired_with(existing: &str, block: &str) -> Result<String, MarkerState> {
180    match marker_range(existing)? {
181        Some(range) => Ok(format!(
182            "{}{}{}",
183            &existing[..range.start],
184            block,
185            &existing[range.end..]
186        )),
187        None if existing.is_empty() => Ok(block.to_string()),
188        None => {
189            let sep = if existing.ends_with("\n\n") {
190                ""
191            } else if existing.ends_with('\n') {
192                "\n"
193            } else {
194                "\n\n"
195            };
196            Ok(format!("{existing}{sep}{block}"))
197        }
198    }
199}
200
201pub fn write(settings: &crate::config::Settings, path: &Path) -> Result<(), String> {
202    write_with(path, &generate_block(settings))
203}
204
205/// Write the CLAUDE.md signpost.
206pub fn write_pointer(path: &Path) -> Result<(), String> {
207    write_with(path, &generate_pointer())
208}
209
210fn write_with(path: &Path, block: &str) -> Result<(), String> {
211    let existing = std::fs::read_to_string(path).unwrap_or_default();
212    let content = desired_with(&existing, block).map_err(|_| {
213        format!(
214            "{}: has an unpaired amont marker — fix or remove it by hand, \
215             then re-run `amont agents-md`",
216            path.display()
217        )
218    })?;
219    if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
220        std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
221    }
222    std::fs::write(path, content).map_err(|e| format!("{}: {e}", path.display()))
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
226pub enum CheckResult {
227    /// No file, or a file with no markers at all — opt-in tooling, so this
228    /// is not drift.
229    NotPresent,
230    MatchesGenerated,
231    Drifted,
232}
233
234pub fn check(settings: &crate::config::Settings, path: &Path) -> Result<CheckResult, String> {
235    check_with(path, &generate_block(settings))
236}
237
238/// Is the CLAUDE.md signpost the one this binary generates?
239pub fn check_pointer(path: &Path) -> Result<CheckResult, String> {
240    check_with(path, &generate_pointer())
241}
242
243fn check_with(path: &Path, block: &str) -> Result<CheckResult, String> {
244    let existing = std::fs::read_to_string(path).unwrap_or_default();
245    match marker_range(&existing) {
246        Err(_) => Err(format!(
247            "{}: has an unpaired amont marker — fix or remove it by hand",
248            path.display()
249        )),
250        Ok(None) => Ok(CheckResult::NotPresent),
251        Ok(Some(range)) => {
252            if &existing[range] == block {
253                Ok(CheckResult::MatchesGenerated)
254            } else {
255                Ok(CheckResult::Drifted)
256            }
257        }
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    /// The block documents the CONFIGURED budgets; with no policy and no git
266    /// config in these tests that means the shipped defaults.
267    fn test_settings() -> crate::config::Settings {
268        crate::config::Settings::default()
269    }
270
271    #[test]
272    fn no_file_produces_just_the_block() {
273        assert_eq!(
274            desired_file_content(&test_settings(), "").unwrap(),
275            generate_block(&test_settings())
276        );
277    }
278
279    /// Both generated files must satisfy Prettier's markdown defaults,
280    /// because amont ALSO ships `pre-commit-prettier`: when the two
281    /// disagree, every JS repository is left choosing between two amont
282    /// checks, and `prettier --write` on a generated file is what
283    /// `agents-md --check` then reports as drift. Encoding the three rules
284    /// that bit us — blank line after an HTML comment, `_emphasis_` not
285    /// `*emphasis*`, blank line before a trailing HTML comment — keeps the
286    /// unit suite honest without making it shell out to node.
287    #[test]
288    fn the_generated_markdown_satisfies_prettier() {
289        for (what, text) in [
290            ("block", generate_block(&test_settings())),
291            ("pointer", generate_pointer()),
292        ] {
293            assert!(
294                text.starts_with(&format!("{START}\n\n")),
295                "{what}: prettier wants a blank line after the opening comment"
296            );
297            assert!(
298                text.ends_with(&format!("\n\n{END}\n")),
299                "{what}: prettier wants a blank line before the closing comment"
300            );
301            // A `*` OPENING emphasis is what prettier rewrites. A trailing
302            // one inside a code span (`amont.severity.*`) is untouched, so
303            // the rule is "no `*` immediately followed by a letter" rather
304            // than "no `*`".
305            let opener = text
306                .as_bytes()
307                .windows(2)
308                .any(|w| w[0] == b'*' && w[1].is_ascii_alphanumeric());
309            assert!(
310                !opener,
311                "{what}: prettier rewrites *emphasis* into _emphasis_, and a \
312                 formatted file then reads as drift — emit underscores"
313            );
314        }
315    }
316
317    /// The signpost is generated and self-checking — the whole reason it is
318    /// a marked block rather than a hand-written "see AGENTS.md" line, which
319    /// would be the one part of the pair able to rot in silence.
320    #[test]
321    fn the_pointer_is_generated_and_therefore_checkable() {
322        let p = generate_pointer();
323        assert!(p.starts_with(START) && p.ends_with(&format!("{END}\n")));
324        assert!(p.contains("AGENTS.md"), "it has to name where to look");
325        assert_ne!(
326            p,
327            generate_block(&test_settings()),
328            "a signpost, not a second copy"
329        );
330        assert!(
331            !p.contains("amont list --json"),
332            "the registry command lives in ONE place; duplicating it here is \
333             the drift this design avoids"
334        );
335        // Round-trips through the same splice as the block: appended to a
336        // repo's existing CLAUDE.md, then replaced in place next time.
337        let existing = "# Notes\n\nsomething the repo wrote\n";
338        let once = desired_pointer_content(existing).unwrap();
339        assert!(once.starts_with(existing), "never touches what was there");
340        assert!(once.ends_with(&p));
341        assert_eq!(
342            desired_pointer_content(&once).unwrap(),
343            once,
344            "re-running is a no-op, so `--check` can be trusted"
345        );
346    }
347
348    #[test]
349    fn no_markers_appends_with_one_blank_line() {
350        let got = desired_file_content(&test_settings(), "# My Project\n\nSome docs.\n").unwrap();
351        assert!(got.starts_with("# My Project\n\nSome docs.\n\n"));
352        assert!(got.ends_with(&generate_block(&test_settings())));
353    }
354
355    #[test]
356    fn no_markers_and_no_trailing_newline_still_gets_a_blank_line() {
357        let got = desired_file_content(&test_settings(), "# My Project").unwrap();
358        assert!(got.starts_with("# My Project\n\n"));
359    }
360
361    #[test]
362    fn existing_markers_are_replaced_and_everything_else_survives() {
363        let before = format!("before\n\n{START}\nstale\n{END}\n\nafter\n");
364        let got = desired_file_content(&test_settings(), &before).unwrap();
365        assert!(got.starts_with("before\n\n"));
366        assert!(got.ends_with("\n\nafter\n"));
367        assert!(got.contains(&generate_block(&test_settings())));
368        assert!(!got.contains("stale"));
369    }
370
371    #[test]
372    fn applying_twice_is_idempotent() {
373        let once = desired_file_content(&test_settings(), "preamble\n").unwrap();
374        let twice = desired_file_content(&test_settings(), &once).unwrap();
375        assert_eq!(once, twice);
376    }
377
378    #[test]
379    fn an_unpaired_marker_is_refused_not_guessed_at() {
380        assert_eq!(
381            desired_file_content(&test_settings(), &format!("{START}\nno end here\n")),
382            Err(MarkerState::Malformed)
383        );
384        assert_eq!(
385            desired_file_content(&test_settings(), &format!("no start\n{END}\n")),
386            Err(MarkerState::Malformed)
387        );
388    }
389
390    /// The bug the fix above was for: searching for `END` only within the
391    /// text AFTER a found `START` meant an `END`-with-no-`START` file
392    /// searched from a `start` that did not exist — finding nothing, and
393    /// reading as "no markers at all" rather than "unpaired."
394    #[test]
395    fn end_appearing_before_start_is_malformed_not_reordered() {
396        assert_eq!(
397            desired_file_content(&test_settings(), &format!("{END}\n...\n{START}\n...\n")),
398            Err(MarkerState::Malformed)
399        );
400    }
401
402    #[test]
403    fn check_reports_not_present_for_a_missing_file() {
404        let tmp = std::env::temp_dir().join("amont-agents-md-test-nonexistent-xyz");
405        let _ = std::fs::remove_file(&tmp);
406        assert_eq!(
407            check(&test_settings(), &tmp).unwrap(),
408            CheckResult::NotPresent
409        );
410    }
411
412    #[test]
413    fn check_reports_matches_generated_after_a_write() {
414        let tmp =
415            std::env::temp_dir().join(format!("amont-agents-md-test-{}-match", std::process::id()));
416        std::fs::write(&tmp, generate_block(&test_settings())).unwrap();
417        assert_eq!(
418            check(&test_settings(), &tmp).unwrap(),
419            CheckResult::MatchesGenerated
420        );
421        let _ = std::fs::remove_file(&tmp);
422    }
423
424    #[test]
425    fn check_reports_drifted_for_a_stale_block() {
426        let tmp =
427            std::env::temp_dir().join(format!("amont-agents-md-test-{}-drift", std::process::id()));
428        std::fs::write(&tmp, format!("{START}\nstale\n{END}\n")).unwrap();
429        assert_eq!(check(&test_settings(), &tmp).unwrap(), CheckResult::Drifted);
430        let _ = std::fs::remove_file(&tmp);
431    }
432}