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