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 check waiting on a cargo or uv lock answers to\n\
74`amont.lockWait` instead, the silence budget stretches with the machine's\n\
75load (`amont.idleLoadScale`), and a heavy check (clippy, a test suite) may\n\
76first queue for a host slot (`amont.hostSlots`) while other worktrees run\n\
77theirs. A test suite may legitimately run for most of that. If your tooling caps a foreground command below it,\n\
78run the command in the background and read its result when it exits —\n\
79while it runs, a line a minute on stderr says which check is alive and\n\
80when it last printed. A push killed mid-suite pushed nothing; a commit\n\
81killed mid-check committed nothing, and your unstaged work stays parked\n\
82until the next run says how to recover it. Neither is the checks failing —\n\
83it is the timeout. Run both bare and check the effect (`git log --oneline\n\
84-1`, `git ls-remote origin <branch>`): trimming their output with `| tail`\n\
85reports the pipe's exit status, so a killed or rejected run reads as\n\
86success.\n\
87\n\
88Never bypass with `--no-verify`. To change enforcement, downgrade it\n\
89intentionally instead:\n\
90\n\
91```sh\n\
92git config amont.severity.<check-id> warn\n\
93```\n\
94\n\
95`commit-msg` takes neither `hook.skip` nor a severity override. Write the\n\
96message it asks for, or change what it asks for — `amont setup`, or\n\
97`amont.commit.*` directly.\n\
98\n\
99{END}\n"
100    )
101}
102
103/// The `CLAUDE.md` pointer, START and END inclusive.
104///
105/// Claude Code loads `CLAUDE.md`; the tool-neutral convention is
106/// `AGENTS.md`. Writing the whole block into both would be two copies to
107/// drift apart, so the guidance keeps ONE home and this is a generated
108/// signpost to it — inside the same markers, so `--check` reports a stale
109/// pointer exactly as it reports a stale block. A hand-written "see
110/// AGENTS.md" line is the one part that could rot silently; this cannot.
111pub fn generate_pointer() -> String {
112    format!(
113        "{START}\n\
114\n\
115## Git hooks (amont)\n\
116\n\
117This repository enforces pre-commit / pre-push checks that can REJECT a\n\
118commit or a push. What runs, the branch-name rule, and why `git commit`\n\
119and `git push` both need a long timeout — or a background run — are in\n\
120[AGENTS.md](AGENTS.md) — read it before committing. Both files are\n\
121generated: run `amont agents-md` after changing either.\n\
122\n\
123{END}\n"
124    )
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum MarkerState {
129    /// Exactly one marker present, or `END` appears before `START` — a state
130    /// this tool refuses to guess its way out of.
131    Malformed,
132}
133
134/// The byte range to replace (markers inclusive, one trailing newline
135/// consumed), or `None` when neither marker is present at all.
136///
137/// `start`/`end` are searched INDEPENDENTLY — not `end` searched only within
138/// the text after a found `start` — so a file holding `END` with no `START`
139/// at all (searching from `start`'s position finds nothing, because there is
140/// no `start`) still surfaces as `(None, Some(_))` rather than silently
141/// reading as "no markers, nothing to guard against."
142fn marker_range(text: &str) -> Result<Option<Range<usize>>, MarkerState> {
143    let start = text.find(START);
144    let end = text.find(END);
145
146    match (start, end) {
147        (None, None) => Ok(None),
148        (Some(s), Some(e)) if e >= s + START.len() => {
149            let mut range_end = e + END.len();
150            if text[range_end..].starts_with('\n') {
151                range_end += 1; // swallow exactly one trailing newline
152            }
153            Ok(Some(s..range_end))
154        }
155        // Exactly one present, or END appears before START.
156        _ => Err(MarkerState::Malformed),
157    }
158}
159
160/// What writing would produce, given what is on disk today.
161///
162/// - no file / empty file → the block alone.
163/// - file, no markers → block appended, separated by exactly one blank line.
164/// - file, markers found → everything outside the markers is byte-for-byte
165///   untouched; the marked span is replaced.
166/// - markers malformed (exactly one present, or out of order) → refused.
167pub fn desired_file_content(
168    settings: &crate::config::Settings,
169    existing: &str,
170) -> Result<String, MarkerState> {
171    desired_with(existing, &generate_block(settings))
172}
173
174/// The same splice, for the pointer.
175pub fn desired_pointer_content(existing: &str) -> Result<String, MarkerState> {
176    desired_with(existing, &generate_pointer())
177}
178
179/// The splice itself, with the text to splice as a parameter — block and
180/// pointer differ only in what goes between the markers, and two copies of
181/// this logic would be two places for the "append vs replace" rules to
182/// disagree.
183fn desired_with(existing: &str, block: &str) -> Result<String, MarkerState> {
184    match marker_range(existing)? {
185        Some(range) => Ok(format!(
186            "{}{}{}",
187            &existing[..range.start],
188            block,
189            &existing[range.end..]
190        )),
191        None if existing.is_empty() => Ok(block.to_string()),
192        None => {
193            let sep = if existing.ends_with("\n\n") {
194                ""
195            } else if existing.ends_with('\n') {
196                "\n"
197            } else {
198                "\n\n"
199            };
200            Ok(format!("{existing}{sep}{block}"))
201        }
202    }
203}
204
205pub fn write(settings: &crate::config::Settings, path: &Path) -> Result<(), String> {
206    write_with(path, &generate_block(settings))
207}
208
209/// Write the CLAUDE.md signpost.
210pub fn write_pointer(path: &Path) -> Result<(), String> {
211    write_with(path, &generate_pointer())
212}
213
214fn write_with(path: &Path, block: &str) -> Result<(), String> {
215    let existing = std::fs::read_to_string(path).unwrap_or_default();
216    let content = desired_with(&existing, block).map_err(|_| {
217        format!(
218            "{}: has an unpaired amont marker — fix or remove it by hand, \
219             then re-run `amont agents-md`",
220            path.display()
221        )
222    })?;
223    if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
224        std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
225    }
226    std::fs::write(path, content).map_err(|e| format!("{}: {e}", path.display()))
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
230pub enum CheckResult {
231    /// No file, or a file with no markers at all — opt-in tooling, so this
232    /// is not drift.
233    NotPresent,
234    MatchesGenerated,
235    Drifted,
236}
237
238pub fn check(settings: &crate::config::Settings, path: &Path) -> Result<CheckResult, String> {
239    check_with(path, &generate_block(settings))
240}
241
242/// Is the CLAUDE.md signpost the one this binary generates?
243pub fn check_pointer(path: &Path) -> Result<CheckResult, String> {
244    check_with(path, &generate_pointer())
245}
246
247fn check_with(path: &Path, block: &str) -> Result<CheckResult, String> {
248    let existing = std::fs::read_to_string(path).unwrap_or_default();
249    match marker_range(&existing) {
250        Err(_) => Err(format!(
251            "{}: has an unpaired amont marker — fix or remove it by hand",
252            path.display()
253        )),
254        Ok(None) => Ok(CheckResult::NotPresent),
255        Ok(Some(range)) => {
256            if &existing[range] == block {
257                Ok(CheckResult::MatchesGenerated)
258            } else {
259                Ok(CheckResult::Drifted)
260            }
261        }
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    /// The block documents the CONFIGURED budgets; with no policy and no git
270    /// config in these tests that means the shipped defaults.
271    fn test_settings() -> crate::config::Settings {
272        crate::config::Settings::default()
273    }
274
275    #[test]
276    fn no_file_produces_just_the_block() {
277        assert_eq!(
278            desired_file_content(&test_settings(), "").unwrap(),
279            generate_block(&test_settings())
280        );
281    }
282
283    /// Both generated files must satisfy Prettier's markdown defaults,
284    /// because amont ALSO ships `pre-commit-prettier`: when the two
285    /// disagree, every JS repository is left choosing between two amont
286    /// checks, and `prettier --write` on a generated file is what
287    /// `agents-md --check` then reports as drift. Encoding the three rules
288    /// that bit us — blank line after an HTML comment, `_emphasis_` not
289    /// `*emphasis*`, blank line before a trailing HTML comment — keeps the
290    /// unit suite honest without making it shell out to node.
291    #[test]
292    fn the_generated_markdown_satisfies_prettier() {
293        for (what, text) in [
294            ("block", generate_block(&test_settings())),
295            ("pointer", generate_pointer()),
296        ] {
297            assert!(
298                text.starts_with(&format!("{START}\n\n")),
299                "{what}: prettier wants a blank line after the opening comment"
300            );
301            assert!(
302                text.ends_with(&format!("\n\n{END}\n")),
303                "{what}: prettier wants a blank line before the closing comment"
304            );
305            // A `*` OPENING emphasis is what prettier rewrites. A trailing
306            // one inside a code span (`amont.severity.*`) is untouched, so
307            // the rule is "no `*` immediately followed by a letter" rather
308            // than "no `*`".
309            let opener = text
310                .as_bytes()
311                .windows(2)
312                .any(|w| w[0] == b'*' && w[1].is_ascii_alphanumeric());
313            assert!(
314                !opener,
315                "{what}: prettier rewrites *emphasis* into _emphasis_, and a \
316                 formatted file then reads as drift — emit underscores"
317            );
318        }
319    }
320
321    /// The signpost is generated and self-checking — the whole reason it is
322    /// a marked block rather than a hand-written "see AGENTS.md" line, which
323    /// would be the one part of the pair able to rot in silence.
324    #[test]
325    fn the_pointer_is_generated_and_therefore_checkable() {
326        let p = generate_pointer();
327        assert!(p.starts_with(START) && p.ends_with(&format!("{END}\n")));
328        assert!(p.contains("AGENTS.md"), "it has to name where to look");
329        assert_ne!(
330            p,
331            generate_block(&test_settings()),
332            "a signpost, not a second copy"
333        );
334        assert!(
335            !p.contains("amont list --json"),
336            "the registry command lives in ONE place; duplicating it here is \
337             the drift this design avoids"
338        );
339        // Round-trips through the same splice as the block: appended to a
340        // repo's existing CLAUDE.md, then replaced in place next time.
341        let existing = "# Notes\n\nsomething the repo wrote\n";
342        let once = desired_pointer_content(existing).unwrap();
343        assert!(once.starts_with(existing), "never touches what was there");
344        assert!(once.ends_with(&p));
345        assert_eq!(
346            desired_pointer_content(&once).unwrap(),
347            once,
348            "re-running is a no-op, so `--check` can be trusted"
349        );
350    }
351
352    #[test]
353    fn no_markers_appends_with_one_blank_line() {
354        let got = desired_file_content(&test_settings(), "# My Project\n\nSome docs.\n").unwrap();
355        assert!(got.starts_with("# My Project\n\nSome docs.\n\n"));
356        assert!(got.ends_with(&generate_block(&test_settings())));
357    }
358
359    #[test]
360    fn no_markers_and_no_trailing_newline_still_gets_a_blank_line() {
361        let got = desired_file_content(&test_settings(), "# My Project").unwrap();
362        assert!(got.starts_with("# My Project\n\n"));
363    }
364
365    #[test]
366    fn existing_markers_are_replaced_and_everything_else_survives() {
367        let before = format!("before\n\n{START}\nstale\n{END}\n\nafter\n");
368        let got = desired_file_content(&test_settings(), &before).unwrap();
369        assert!(got.starts_with("before\n\n"));
370        assert!(got.ends_with("\n\nafter\n"));
371        assert!(got.contains(&generate_block(&test_settings())));
372        assert!(!got.contains("stale"));
373    }
374
375    #[test]
376    fn applying_twice_is_idempotent() {
377        let once = desired_file_content(&test_settings(), "preamble\n").unwrap();
378        let twice = desired_file_content(&test_settings(), &once).unwrap();
379        assert_eq!(once, twice);
380    }
381
382    #[test]
383    fn an_unpaired_marker_is_refused_not_guessed_at() {
384        assert_eq!(
385            desired_file_content(&test_settings(), &format!("{START}\nno end here\n")),
386            Err(MarkerState::Malformed)
387        );
388        assert_eq!(
389            desired_file_content(&test_settings(), &format!("no start\n{END}\n")),
390            Err(MarkerState::Malformed)
391        );
392    }
393
394    /// The bug the fix above was for: searching for `END` only within the
395    /// text AFTER a found `START` meant an `END`-with-no-`START` file
396    /// searched from a `start` that did not exist — finding nothing, and
397    /// reading as "no markers at all" rather than "unpaired."
398    #[test]
399    fn end_appearing_before_start_is_malformed_not_reordered() {
400        assert_eq!(
401            desired_file_content(&test_settings(), &format!("{END}\n...\n{START}\n...\n")),
402            Err(MarkerState::Malformed)
403        );
404    }
405
406    #[test]
407    fn check_reports_not_present_for_a_missing_file() {
408        let tmp = std::env::temp_dir().join("amont-agents-md-test-nonexistent-xyz");
409        let _ = std::fs::remove_file(&tmp);
410        assert_eq!(
411            check(&test_settings(), &tmp).unwrap(),
412            CheckResult::NotPresent
413        );
414    }
415
416    #[test]
417    fn check_reports_matches_generated_after_a_write() {
418        let tmp =
419            std::env::temp_dir().join(format!("amont-agents-md-test-{}-match", std::process::id()));
420        std::fs::write(&tmp, generate_block(&test_settings())).unwrap();
421        assert_eq!(
422            check(&test_settings(), &tmp).unwrap(),
423            CheckResult::MatchesGenerated
424        );
425        let _ = std::fs::remove_file(&tmp);
426    }
427
428    #[test]
429    fn check_reports_drifted_for_a_stale_block() {
430        let tmp =
431            std::env::temp_dir().join(format!("amont-agents-md-test-{}-drift", std::process::id()));
432        std::fs::write(&tmp, format!("{START}\nstale\n{END}\n")).unwrap();
433        assert_eq!(check(&test_settings(), &tmp).unwrap(), CheckResult::Drifted);
434        let _ = std::fs::remove_file(&tmp);
435    }
436}