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