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