feat(agents): write AGENTS.md from a tracked AGENTS.template.md

The dependency notes were kept in a section of the AGENTS.md beside
project.cpp, the file a project commits, so a build could change a
tracked file at any time: a dependency editing its AGENTS.consumer.md,
an unpinned one moving on, a flag pulling a dependency in or out, each
showed up in the consumer's diff as a change nobody made. Users
reported it as the feature clobbering their diffs.

A project now commits its instructions as AGENTS.template.md, and a
build and a test run write AGENTS.md from it: a first line saying the
file is generated and where to edit, the template's text as written,
then the notes section, after it or where the template's marker lines
put it. AGENTS.md is build output and goes into .gitignore. It is
created when missing, since a fresh clone has none, and otherwise
written as before: only when its text changes, in place and under a
lock, so links, ownership and a read-only file are respected.

The first line is also how a build tells its own AGENTS.md from one
someone wrote. Beside a template the second is easily there, as a
committed AGENTS.md nobody has removed yet; it is never replaced, and
each build warns until it is deleted. An empty file counts as a
build's, since that is what a write cut short leaves of a new one. An
AGENTS.md linked to the template is refused: writing it would write
the committed file again. A template the notes cannot be placed in
leaves no empty AGENTS.md behind.

The in-place mode is removed rather than kept as the fallback for a
project without a template. It is the default that put generated text
into tracked files unasked, the moment any dependency started shipping
notes. Without a template a build no longer touches AGENTS.md. One
that still holds a section from 3.0.0 would go stale silently, under a
preamble promising that every build rewrites it, so it gets a warning
naming the move instead.

MergeDependencyNotes keeps its rules and now runs on the template. The
test changes in this commit rather than its own, because it pinned the
in-place behaviour this removes: it now covers the generated file's
shape, edits and markers in the template, the ownership check, a CRLF
template, a link to the template and the cases without one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-10-31 16:53:42 +01:00
commit 74e8370b2b
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
8 changed files with 259 additions and 121 deletions

View file

@ -22,12 +22,26 @@ namespace {
// Right under the begin marker, for whoever opens the file: an edit made
// inside the section is gone after the next build.
constexpr std::string_view Preamble = "<!-- Written by crafter-build from the AGENTS.consumer.md each dependency ships, and rewritten on every build. Edit those files, not this section; cfg.dependencyNotes in project.cpp picks which dependencies are included. -->";
// The first line of every AGENTS.md a build writes. It tells whoever
// opens the file, an agent asked to change it included, where an edit
// has to be made to outlive the next build, and it is how a build tells
// its own AGENTS.md from one someone wrote, which it never replaces. A
// changed word would make every AGENTS.md written before look like
// someone's own.
constexpr std::string_view GeneratedLine = "<!-- Written by crafter-build from AGENTS.template.md, and rewritten on every build. Edit that file, not this one. -->";
constexpr std::string_view NotesFileName = "AGENTS.consumer.md";
constexpr std::string_view TemplateFileName = "AGENTS.template.md";
constexpr std::string_view AgentsFileName = "AGENTS.md";
void Warn(std::string_view message) {
Progress::Diagnostic(std::format("warning: {}", message));
}
// The line ending `text` uses, so a CRLF file does not end up mixed.
std::string_view LineEnd(std::string_view text) {
return text.contains("\r\n") ? "\r\n" : "\n";
}
std::string ErrnoMessage() {
return std::error_code(errno, std::generic_category()).message();
}
@ -249,9 +263,9 @@ namespace {
}
// Why `text` cannot go into the section as it is; empty when it can. A
// marker line of its own would end or restart the section once the file
// is read back, and a code block left open would hide the end marker:
// either way the next build could no longer find what this one wrote.
// marker line of its own would end or restart the section for whoever
// reads it, a template copied from the result included, and a code block
// left open would run on over the notes after it and the end marker.
// Lines are numbered from `firstLine`.
std::string NoteProblem(std::string_view text, std::size_t firstLine) {
const MarkerScan scan = ScanMarkers(text);
@ -397,6 +411,33 @@ namespace {
return notes;
}
// Whether a build may replace `agentsMd`, the AGENTS.md beside a
// template: one a build wrote, or one with no text at all, which is what
// a write cut short leaves of a new file. Anything else someone wrote,
// and beside a template that is easily the case: an AGENTS.md committed
// before the template was, or one an agent kept its own notes in.
bool Replaceable(std::string_view agentsMd) {
if (agentsMd.find_first_not_of(" \t\r\n") == std::string_view::npos) return true;
std::string_view first = agentsMd.substr(0, agentsMd.find('\n'));
if (first.ends_with('\r')) first.remove_suffix(1);
return first == GeneratedLine;
}
// An AGENTS.md with no template beside it is someone's own, and no build
// writes it. One that still holds a section from the builds that kept the
// notes in a committed AGENTS.md would go stale without a word, under a
// preamble saying every build rewrites it, so each build says so until
// the file has moved to a template or the section is gone.
void WarnStaleSection(const fs::path& agents) {
std::error_code ec;
if (!fs::is_regular_file(agents, ec)) return;
std::string text;
if (!ReadFile(agents, text).empty()) return;
const MarkerScan scan = ScanMarkers(text);
if (scan.begins.empty() && scan.ends.empty()) return;
Warn(std::format("{} has a dependency notes section, which builds no longer update; rename the file to {} and keep {} out of version control to have every build write it with current notes, or delete the section", agents.string(), TemplateFileName, AgentsFileName));
}
// Bring `fd`, which holds `current`, to `wanted`: from the first byte the
// two differ at, so the text in front of the section is never rewritten,
// then cut to length and synced. Empty on success, otherwise the error.
@ -405,10 +446,9 @@ namespace {
// rename swaps in another file, which drops whatever belongs to the old
// one (a hard link such as a CLAUDE.md beside it, ACLs, ownership, being
// read-only) and needs fsyncs to be crash safe at all. The price is that
// a reader can catch the section half written, and that a write failing
// partway, on a full disk say, leaves it so. With the section last,
// where it is appended, only the section can be damaged, and the next
// build reports its markers instead of guessing.
// a reader can catch the file half written, and that a write failing
// partway, on a full disk say, leaves it so until the next build writes
// it again.
std::string WriteChanged(std::int32_t fd, std::string_view current, std::string_view wanted) {
const auto diverge = std::ranges::mismatch(current, wanted);
for (auto done = static_cast<std::size_t>(diverge.in2 - wanted.begin()); done < wanted.size();) {
@ -427,24 +467,24 @@ namespace {
}
}
std::string Crafter::MergeDependencyNotes(std::string_view agentsMd, std::span<const DependencyNote> notes) {
std::string Crafter::MergeDependencyNotes(std::string_view templateMd, std::span<const DependencyNote> notes) {
for (const DependencyNote& note : notes) {
if (const std::string problem = NoteProblem(note.text, 1); !problem.empty()) throw std::runtime_error(std::format("the notes from {}: {}", note.name, problem));
}
const MarkerScan scan = ScanMarkers(agentsMd);
const MarkerScan scan = ScanMarkers(templateMd);
const bool section = scan.begins.size() == 1 && scan.ends.size() == 1 && scan.begins[0] < scan.ends[0];
// The file's own line ending, so a CRLF file does not end up mixed.
const std::string_view eol = agentsMd.contains("\r\n") ? "\r\n" : "\n";
const std::string_view eol = LineEnd(templateMd);
if (notes.empty()) {
// With nothing to write, leaving the file alone loses nothing, so
// anything short of a well-formed section is no concern of this one.
if (!section) return std::string(agentsMd);
std::string_view before = agentsMd.substr(0, scan.lines[scan.begins[0]].begin);
const std::string_view after = agentsMd.substr(scan.lines[scan.ends[0]].next);
// The blank line a section at the end was appended after goes with
// it. One the user moved keeps the text around it apart.
// With nothing to place, nothing can land in the wrong place, so
// anything short of a well-formed section is left as it was written.
if (!section) return std::string(templateMd);
std::string_view before = templateMd.substr(0, scan.lines[scan.begins[0]].begin);
const std::string_view after = templateMd.substr(scan.lines[scan.ends[0]].next);
// A section that ends the template takes the blank line in front of
// it, which an appended one gets. One placed elsewhere keeps the text
// around it apart.
if (after.empty() && before.ends_with(std::format("{0}{0}", eol))) before.remove_suffix(eol.size());
return std::format("{}{}", before, after);
}
@ -455,13 +495,12 @@ std::string Crafter::MergeDependencyNotes(std::string_view agentsMd, std::span<c
if (!scan.begins.empty() && scan.ends.empty()) throw std::runtime_error(std::format("line {}: a dependency notes begin marker with no end marker after it", scan.begins[0] + 1));
const std::string rendered = RenderSection(notes, eol);
if (section) return std::format("{}{}{}", agentsMd.substr(0, scan.lines[scan.begins[0]].begin), rendered, agentsMd.substr(scan.lines[scan.ends[0]].next));
// Appended, the section would sit inside that block and never be found
// again, so every build would add another.
if (section) return std::format("{}{}{}", templateMd.substr(0, scan.lines[scan.begins[0]].begin), rendered, templateMd.substr(scan.lines[scan.ends[0]].next));
// Appended, the section would sit inside that block and read as code.
if (scan.unclosedBlock) throw std::runtime_error(std::format("line {}: a code block opens here and never closes", *scan.unclosedBlock + 1));
if (agentsMd.empty()) return rendered;
if (templateMd.empty()) return rendered;
// On a line of its own, after a blank one.
return std::format("{}{}{}{}", agentsMd, agentsMd.ends_with('\n') ? std::string_view() : eol, eol, rendered);
return std::format("{}{}{}{}", templateMd, templateMd.ends_with('\n') ? std::string_view() : eol, eol, rendered);
}
bool Crafter::UpdateAgentsMd(const Configuration& project, const fs::path& projectFile) {
@ -470,60 +509,82 @@ bool Crafter::UpdateAgentsMd(const Configuration& project, const fs::path& proje
// keeps the AGENTS.md beside the link.
const fs::path projectDir = fs::absolute(projectFile, ec).lexically_normal().parent_path();
if (ec) return false;
const fs::path agents = projectDir / "AGENTS.md";
if (!fs::is_regular_file(agents, ec)) return false;
const fs::path agents = projectDir / AgentsFileName;
const fs::path templateFile = projectDir / TemplateFileName;
if (!fs::is_regular_file(templateFile, ec)) {
WarnStaleSection(agents);
return false;
}
// Linked to the template, AGENTS.md would have every build write the
// file that is committed, which is what the template is there to stop.
if (fs::equivalent(agents, templateFile, ec)) {
Warn(std::format("{} and {} are the same file, so {} was not written", agents.string(), templateFile.string(), AgentsFileName));
return false;
}
std::string templateMd;
if (const std::string error = ReadFile(templateFile, templateMd); !error.empty()) {
Warn(std::format("{} could not be read ({}), so {} was not written", templateFile.string(), error, agents.string()));
return false;
}
const std::vector<DependencyNote> notes = project.dependencyNotes == DependencyNotes::None ? std::vector<DependencyNote>() : CollectNotes(project, projectFile, projectDir);
std::string wanted;
try {
wanted = std::format("{}{}{}", GeneratedLine, LineEnd(templateMd), MergeDependencyNotes(templateMd, notes));
} catch (const std::exception& e) {
Warn(std::format("{}: {}, so {} was not written", templateFile.string(), e.what(), agents.string()));
return false;
}
// Locked from the read through the write, so two builds never interleave
// their writes, and on the file itself, which also covers two projects
// that link one AGENTS.md. A read-only descriptor takes the lock as
// well, so a read-only AGENTS.md is still read without complaint.
const Descriptor reading(::open(agents.c_str(), O_RDONLY | O_CLOEXEC));
// Created when missing, since a fresh clone has no build output, and only
// now, with the text in hand, so a template the notes cannot be placed
// in leaves no empty AGENTS.md behind. Locked from the read through the
// write, so two builds never interleave their writes, and on the file
// itself, which also covers two projects that link one AGENTS.md. A
// read-only descriptor takes the lock as well, so a read-only AGENTS.md
// is still read without complaint.
const Descriptor reading(::open(agents.c_str(), O_RDONLY | O_CREAT | O_CLOEXEC, 0666));
if (!reading.Valid()) {
Warn(std::format("{} could not be read ({}), so its dependency notes were not updated", agents.string(), ErrnoMessage()));
Warn(std::format("{} could not be opened ({}), so it was not written", agents.string(), ErrnoMessage()));
return false;
}
while (::flock(reading.Get(), LOCK_EX) != 0 && errno == EINTR) {}
std::string current;
if (const std::string error = ReadAll(reading.Get(), current); !error.empty()) {
Warn(std::format("{} could not be read ({}), so its dependency notes were not updated", agents.string(), error));
Warn(std::format("{} could not be read ({}), so it was not written", agents.string(), error));
return false;
}
std::string merged;
try {
merged = MergeDependencyNotes(current, notes);
} catch (const std::exception& e) {
Warn(std::format("{}: {}, so its dependency notes were not updated", agents.string(), e.what()));
if (current == wanted) return false;
if (!Replaceable(current)) {
Warn(std::format("{} was not written by a build, so it was not replaced by one written from {}; once the template holds what it says, delete it", agents.string(), TemplateFileName));
return false;
}
if (merged == current) return false;
// Only now, with something to write: a read-only AGENTS.md is refused
// here, and not on every build that leaves it as it is.
const Descriptor writing(::open(agents.c_str(), O_WRONLY | O_CLOEXEC));
if (!writing.Valid()) {
Warn(std::format("{} could not be opened for writing ({}), so its dependency notes were not updated", agents.string(), ErrnoMessage()));
Warn(std::format("{} could not be opened for writing ({}), so it was not written", agents.string(), ErrnoMessage()));
return false;
}
if (const std::string error = WriteChanged(writing.Get(), current, merged); !error.empty()) {
if (const std::string error = WriteChanged(writing.Get(), current, wanted); !error.empty()) {
// Put back what was there. A full disk that refused the new bytes
// usually still takes the old ones, whose space was just freed.
if (WriteChanged(writing.Get(), merged, current).empty()) {
Warn(std::format("{} could not be written ({}), so its dependency notes were not updated", agents.string(), error));
if (WriteChanged(writing.Get(), wanted, current).empty()) {
Warn(std::format("{} could not be written ({}), so it was left as it was", agents.string(), error));
} else {
Warn(std::format("{} could not be written ({}) and may now end in part of the section; the next build reports it if so", agents.string(), error));
Warn(std::format("{} could not be written ({}) and may now be half written; the next build writes it again", agents.string(), error));
}
return false;
}
// A build that edits a file the user commits says so.
// A file that appears beside project.cpp, or changes there, unasked,
// says where it came from.
if (Progress::GetVerbosity() != Progress::Verbosity::Quiet) {
Progress::Clear();
if (notes.empty()) {
std::println("Removed the dependency notes from {}", agents.string());
std::println("Wrote {} from {}", agents.string(), TemplateFileName);
} else {
std::println("Updated the dependency notes in {} ({} {})", agents.string(), notes.size(), notes.size() == 1 ? "dependency" : "dependencies");
std::println("Wrote {} from {} and the notes of {} {}", agents.string(), TemplateFileName, notes.size(), notes.size() == 1 ? "dependency" : "dependencies");
}
}
return true;

View file

@ -1840,11 +1840,13 @@ Clean (after the `clean` subcommand):
and dependency artifact under those trees goes with it.
AGENTS.md:
When an AGENTS.md sits beside the project file, a build and a test run keep a
section in it holding the AGENTS.consumer.md each dependency ships beside its
project.cpp, and write the file only when that section changes. An AGENTS.md
is never created. cfg.dependencyNotes picks the dependencies: Transitive (the
default), Direct, or None, which removes the section.
When an AGENTS.template.md sits beside the project file, a build and a test
run write AGENTS.md from it, with a section holding the AGENTS.consumer.md
each dependency ships beside its project.cpp, and only when the text changes.
Commit the template and keep AGENTS.md out of version control. An AGENTS.md
no build wrote is never replaced, and without a template AGENTS.md is left
alone. cfg.dependencyNotes picks the dependencies: Transitive (the default),
Direct, or None.
Project args:
Any flag not consumed above is forwarded verbatim to CrafterBuildProject as