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

@ -7,10 +7,17 @@ namespace fs = std::filesystem;
// Dependency notes. A crafter-build project ships an AGENTS.consumer.md beside
// its project.cpp, written for the agents of the projects that depend on it,
// and crafter-build keeps those files in each consumer's AGENTS.md. Without
// and crafter-build merges those files into each consumer's AGENTS.md. Without
// that, an agent working on a consumer knows nothing about the libraries it
// builds against unless someone copied the knowledge in by hand, and a copy
// goes stale the moment the library changes.
//
// The consumer writes its own instructions in an AGENTS.template.md beside its
// project.cpp, and every build writes AGENTS.md from that, so AGENTS.md is
// build output and stays out of version control. Kept in a committed
// AGENTS.md instead, as they once were, the notes changed that file whenever
// a dependency changed its own, on builds that changed nothing else, and
// every diff and rebase carried changes nobody had made.
export namespace Crafter {
struct Configuration;
@ -23,7 +30,7 @@ export namespace Crafter {
// Only the projects this one's own configurations and
// `#pragma crafter dependency` lines name.
Direct,
// None. A section an earlier build wrote is removed.
// None: AGENTS.md holds the template's text alone.
None,
};
@ -34,38 +41,43 @@ export namespace Crafter {
std::string name;
// The file's content, without leading blank lines or trailing
// whitespace. Lines end in LF; the merge writes the line ending the
// document uses.
// template uses.
std::string text;
};
// `agentsMd` with its dependency notes section holding `notes`, in order.
// A document without a section gets one appended after a blank line; one
// with a section has it rewritten in place, wherever it was moved to; and
// empty `notes` remove it. Removing a section that ends the document takes
// the blank line in front of it too, so a document that ended in a newline
// before it gained the section gets its own bytes back. The section uses
// the document's line ending, so a CRLF file stays CRLF.
// `templateMd` with its dependency notes section holding `notes`, in
// order: AGENTS.md as it reads below the line that says a build wrote it.
// A template without a section gets one appended after a blank line; one
// with a section, which is how a template places it, has it rewritten
// where it stands, whatever it held; and empty `notes` remove it.
// Removing a section that ends the template takes the blank line in front
// of it too, so the result ends as the text before it did. The section
// uses the template's line ending, so a CRLF file stays CRLF.
//
// The two marker lines count only as whole lines outside fenced code
// blocks, which is what lets a document quote them. Throws, naming the
// line, when there are notes to write and the markers are unbalanced, or
// when the section would have to be appended to a document that ends
// inside a code block: guessing which part of someone's file is generated
// would overwrite their text, and missing the section would add another on
// every build. With no notes it never throws, since leaving the document
// alone loses nothing. Also throws for a note that would break the section
// when it is read back: one with a marker line of its own, or a code block
// it never closes.
std::string MergeDependencyNotes(std::string_view agentsMd, std::span<const DependencyNote> notes);
// when the section would have to be appended to a template that ends
// inside a code block: a lone or doubled marker is a slip in the
// template, and any guess at what it meant would put the notes, or cut
// the text, where its author did not, while notes appended inside a code
// block would read as code. With no notes it never throws, since nothing
// has to be placed. Also throws for a note that would break the section
// for whoever reads it: one with a marker line of its own, or a code
// block it never closes.
std::string MergeDependencyNotes(std::string_view templateMd, std::span<const DependencyNote> notes);
// Refresh the dependency notes section of the AGENTS.md beside
// `projectFile` from the AGENTS.consumer.md of every dependency
// project.dependencyNotes selects. `project` is the Configuration that
// file returned, after LoadProject: loading is what found the
// dependencies. Does nothing when there is no AGENTS.md, which is never
// created. Writes only when the text changes, in place, so hard links,
// ownership and a read-only AGENTS.md are respected; returns whether it
// wrote. A read or write that fails, a section that cannot be placed and
// Write the AGENTS.md beside `projectFile` from the AGENTS.template.md
// beside it, with the AGENTS.consumer.md of every dependency
// project.dependencyNotes selects merged in. `project` is the
// Configuration that file returned, after LoadProject: loading is what
// found the dependencies. Creates AGENTS.md when it is missing, and
// replaces only one a build wrote: an AGENTS.md someone wrote is left
// alone, with a warning. Without a template it writes nothing, and only
// warns about a section an earlier crafter-build left in AGENTS.md.
// Writes only when the text changes, in place, so hard links, ownership
// and a read-only AGENTS.md are respected; returns whether it wrote. A
// read or write that fails, a template the notes cannot be placed in and
// a dependency's unusable notes are warnings: the notes never fail a
// build, and nothing is reported when nothing would have been written.
bool UpdateAgentsMd(const Configuration& project, const fs::path& projectFile);

View file

@ -529,8 +529,9 @@ export namespace Crafter {
// Not part of VariantId: a script changes nothing that is compiled.
std::vector<Script> scripts;
// Which dependencies' AGENTS.consumer.md `crafter-build` and
// `crafter-build test` keep in the AGENTS.md beside project.cpp, when
// there is one (see Crafter.Build:Agents):
// `crafter-build test` merge into the AGENTS.md they write from the
// AGENTS.template.md beside project.cpp, when there is one (see
// Crafter.Build:Agents):
// cfg.dependencyNotes = DependencyNotes::Direct;
// Only read on the Configuration project.cpp returns; a dependency's
// setting decides nothing for its consumers. Not part of VariantId: