The guide learns the thread it was silent on, and grows a vendor-neutral twin

CLAUDE.md bumps to v14: a new Comments section teaches the comments/ mechanics
(chronology-as-ordering, author vs modified-by, .draft/.trash as the app's,
retract-by-follow-up), and a new "Use the thread: journal your work" section
teaches conduct the guide never had — body is the spec, thread is the journal,
post a plan cold-reader-ready on start, decisions as they're made, questions
as comments, re-read before resuming, close with verification. comments/
leaves the reserved-tracker-keys list; the Layout diagram gains comments/ and
AGENTS.md.

The app now writes a byte-identical twin at AGENTS.md, the vendor-neutral name
most non-Claude tools read — same lifecycle as CLAUDE.md, decided and healed
independently per claimed name, both funneling a markerless foreign file to
the single shared CLAUDE.user.md rescue. AgentGuide.install now returns
[Displacement]; IntegrityRules.claimedRootNames and BoardStore.refreshAgentGuide
cover both names; ChangeNarrator's guide-commit path check widens to both.

DESIGN/08-agent-integration.md amends: the agent-guide section gains the
Comments and Use-the-thread bullets and an AGENTS.md twin paragraph.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-09 08:38:07 -04:00
parent 0fe92bdf38
commit 0c4aad01a0
9 changed files with 488 additions and 101 deletions
+202 -3
View File
@@ -58,6 +58,11 @@ private func guideURL(in fixture: WriterFixture) -> URL {
fixture.root.appendingPathComponent(AgentGuide.filename)
}
/// The `AGENTS.md` twin's URL `guideURL`'s counterpart, for the tests that pin the twin promise.
private func agentsURL(in fixture: WriterFixture) -> URL {
fixture.root.appendingPathComponent(AgentGuide.agentsFilename)
}
private func userFileURL(in fixture: WriterFixture) -> URL {
fixture.root.appendingPathComponent(AgentGuide.userFilename)
}
@@ -252,9 +257,12 @@ struct AgentGuideStoreTests {
defer { fixture.tearDown() }
// Deliberately *not* the app's own text: a current marker is the whole gate, and a file
// carrying one must survive verbatim even when its body differs from what this version
// would write.
// would write. Both claimed names, so the twin doesn't itself count as work needing a
// bracket this test is about the current-guide gate, not the twin write.
let url = try writeRoot(AgentGuide.filename, Data(guideText(version: AgentGuide.version).utf8), in: fixture)
let agentsURLValue = try writeRoot(AgentGuide.agentsFilename, Data(guideText(version: AgentGuide.version).utf8), in: fixture)
let before = try stat(url)
let agentsBefore = try stat(agentsURLValue)
let store = try BoardStore(rootURL: fixture.root)
let brackets = GuideBracketLog()
brackets.attach(to: store)
@@ -262,8 +270,11 @@ struct AgentGuideStoreTests {
store.refreshAgentGuide()
let after = try stat(url)
let agentsAfter = try stat(agentsURLValue)
#expect(after.bytes == before.bytes)
#expect(after.modified == before.modified)
#expect(agentsAfter.bytes == agentsBefore.bytes)
#expect(agentsAfter.modified == agentsBefore.modified)
#expect(brackets.begins == 0, "no write means no bracket and no commit")
}
@@ -330,17 +341,21 @@ struct AgentGuideStoreTests {
#expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
}
/// "Otherwise the guide write is skipped with a log user content is never destroyed." Two
/// files the user owns, both still theirs afterwards, and no guide on this board at all.
/// "Otherwise the guide write is skipped with a log user content is never destroyed." Three
/// files the user owns (both claimed guide names, foreign, plus the rescue destination already
/// taken), all still theirs afterwards, and no guide on this board at all.
@Test("A taken CLAUDE.user.md leaves both files alone and writes no guide")
func takenUserFilenameSkipsTheWrite() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let mine = "# Board rules\n"
let mineToo = "# Board rules, the AGENTS.md copy\n"
let theirs = "# Older board rules\n"
let guide = try writeRoot(AgentGuide.filename, Data(mine.utf8), in: fixture)
let agentsGuide = try writeRoot(AgentGuide.agentsFilename, Data(mineToo.utf8), in: fixture)
let user = try writeRoot(AgentGuide.userFilename, Data(theirs.utf8), in: fixture)
let guideBefore = try stat(guide)
let agentsGuideBefore = try stat(agentsGuide)
let userBefore = try stat(user)
let store = try BoardStore(rootURL: fixture.root)
let brackets = GuideBracketLog()
@@ -350,6 +365,8 @@ struct AgentGuideStoreTests {
#expect(try stat(guide).bytes == guideBefore.bytes)
#expect(try stat(guide).modified == guideBefore.modified)
#expect(try stat(agentsGuide).bytes == agentsGuideBefore.bytes)
#expect(try stat(agentsGuide).modified == agentsGuideBefore.modified)
#expect(try stat(user).bytes == userBefore.bytes)
#expect(try stat(user).modified == userBefore.modified)
#expect(brackets.begins == 0)
@@ -539,6 +556,10 @@ struct AgentGuideStoreTests {
// one, renames around it, or overwrites it.
try writeRoot(AgentGuide.filename, Data("# mine\n".utf8), in: fixture)
try fixture.file("\(AgentGuide.userFilename)/inside.txt", Data("inside".utf8))
// The twin is already current throughout: this test's whole point is the memo behavior on
// `CLAUDE.md`'s own skip/rescue arc, so `AGENTS.md` sits out every reload as a quiet
// `.leaveAlone` never contributing to the signature, never itself opening a bracket.
try writeRoot(AgentGuide.agentsFilename, Data(AgentGuide.content.utf8), in: fixture)
let store = try BoardStore(rootURL: fixture.root)
let brackets = GuideBracketLog()
brackets.attach(to: store)
@@ -552,6 +573,7 @@ struct AgentGuideStoreTests {
#expect(brackets.begins == 0, "no write was ever attempted")
#expect(store.banners.oneShots.isEmpty, "and no row was posted, once or four times")
#expect(try fixture.data(AgentGuide.filename) == Data("# mine\n".utf8))
#expect(try fixture.data(AgentGuide.agentsFilename) == Data(AgentGuide.content.utf8), "the already-current twin stayed untouched too")
#expect(try fixture.data("\(AgentGuide.userFilename)/inside.txt") == Data("inside".utf8))
// The name frees up: a different picture, so a fresh attempt and the rescue finally runs.
@@ -561,6 +583,7 @@ struct AgentGuideStoreTests {
#expect(try fixture.data(AgentGuide.userFilename) == Data("# mine\n".utf8))
#expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
#expect(try fixture.data(AgentGuide.agentsFilename) == Data(AgentGuide.content.utf8), "still exactly current — this bracket was entirely CLAUDE.md's")
#expect(brackets.begins == 1)
}
@@ -578,6 +601,127 @@ struct AgentGuideStoreTests {
}
}
// MARK: - 3b. The AGENTS.md twin (ruled 2026-08-09, card dc1314bb)
/// The twin promise, exercised where it costs something: both claimed names get the identical guide,
/// upgrade independently, and heal independently the "byte-identical, no drift" claim proved on
/// disk rather than merely read off the source.
@MainActor
@Suite("Agent guide ▸ the twin")
struct AgentGuideTwinTests {
@Test("A board with neither claimed name gets both, byte-identical, in one bracket")
func freshBoardGetsBothFilesByteIdentical() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let brackets = GuideBracketLog()
brackets.attach(to: store)
store.refreshAgentGuide()
let claude = try fixture.data(AgentGuide.filename)
let agents = try fixture.data(AgentGuide.agentsFilename)
#expect(claude == Data(AgentGuide.content.utf8))
#expect(agents == claude, "byte-identical, not merely equal-content")
#expect(brackets.begins == 1, "one bracket for both files, one app-mediated reload")
}
@Test("Both claimed names at an older version are upgraded to the current one, identically")
func bothOlderFilesAreUpgradedIdentically() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try writeRoot(AgentGuide.filename, Data(guideText(version: 4).utf8), in: fixture)
try writeRoot(AgentGuide.agentsFilename, Data(guideText(version: 4).utf8), in: fixture)
let store = try BoardStore(rootURL: fixture.root)
store.refreshAgentGuide()
let claude = try fixture.data(AgentGuide.filename)
let agents = try fixture.data(AgentGuide.agentsFilename)
#expect(claude == Data(AgentGuide.content.utf8))
#expect(agents == claude)
}
/// The per-file independence the design leans on: one claimed name being current must never gate
/// the other's write, and vice versa.
@Test("A stale AGENTS.md is upgraded even while CLAUDE.md is already current")
func staleAgentsFileUpgradesAloneWhenClaudeIsCurrent() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let claudeURL = try writeRoot(AgentGuide.filename, Data(AgentGuide.content.utf8), in: fixture)
try writeRoot(AgentGuide.agentsFilename, Data(guideText(version: 4).utf8), in: fixture)
let claudeBefore = try stat(claudeURL)
let store = try BoardStore(rootURL: fixture.root)
store.refreshAgentGuide()
let claudeAfter = try stat(claudeURL)
#expect(claudeAfter.bytes == claudeBefore.bytes)
#expect(claudeAfter.modified == claudeBefore.modified, "current guide untouched, even while its twin is rewritten")
#expect(try fixture.data(AgentGuide.agentsFilename) == Data(AgentGuide.content.utf8))
}
@Test("A stale CLAUDE.md is upgraded even while AGENTS.md is already current")
func staleClaudeFileUpgradesAloneWhenAgentsIsCurrent() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try writeRoot(AgentGuide.filename, Data(guideText(version: 4).utf8), in: fixture)
let agentsURLBefore = try writeRoot(AgentGuide.agentsFilename, Data(AgentGuide.content.utf8), in: fixture)
let agentsBefore = try stat(agentsURLBefore)
let store = try BoardStore(rootURL: fixture.root)
store.refreshAgentGuide()
let agentsAfter = try stat(agentsURLBefore)
#expect(agentsAfter.bytes == agentsBefore.bytes)
#expect(agentsAfter.modified == agentsBefore.modified)
#expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
}
/// A folder or symlink squatting `AGENTS.md` is displaced exactly as one squatting `CLAUDE.md`
/// is the level-uniform claimed-name rule extended to the twin.
@Test("A folder squatting AGENTS.md is displaced whole, contents intact, and the guide takes its place")
func squattedAgentsFileIsDisplaced() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file("\(AgentGuide.agentsFilename)/inside.txt", Data("inside".utf8))
let store = try BoardStore(rootURL: fixture.root)
store.refreshAgentGuide()
#expect(try fixture.data("AGENTS 2.md/inside.txt") == Data("inside".utf8))
#expect(try fixture.data(AgentGuide.agentsFilename) == Data(AgentGuide.content.utf8))
#expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
}
/// **The design decision on the shared rescue name**: both claimed names funnel a markerless
/// foreign file to the same single `CLAUDE.user.md` there is no second `AGENTS.user.md`. When
/// both are foreign at once, `filename` (processed first) wins the rescue and `agentsFilename`
/// finds the name taken and skips the standing "two files the user owns, both left alone"
/// contract, just reachable from either starting name.
@Test("Markerless content on both claimed names at once: the first rescues, the second skips")
func simultaneousMarkerlessContentOnBothNamesRescuesOnlyTheFirst() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let claudeMine = "# My CLAUDE.md notes\n"
let agentsMine = "# My AGENTS.md notes\n"
try writeRoot(AgentGuide.filename, Data(claudeMine.utf8), in: fixture)
try writeRoot(AgentGuide.agentsFilename, Data(agentsMine.utf8), in: fixture)
let store = try BoardStore(rootURL: fixture.root)
store.refreshAgentGuide()
// `filename` is processed first: its content is rescued to the shared destination, and the
// guide takes its place.
#expect(try fixture.data(AgentGuide.userFilename) == Data(claudeMine.utf8))
#expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
// `agentsFilename` finds `CLAUDE.user.md` taken by the time it's inspected: its own content
// is left exactly where it was, untouched never destroyed, never silently adopted either.
#expect(try fixture.data(AgentGuide.agentsFilename) == Data(agentsMine.utf8))
}
}
// MARK: - 4. Through the registry, with a real watcher
@MainActor
@@ -769,4 +913,59 @@ struct AgentGuideContentTests {
#expect(!content.contains("moves don't stamp"))
#expect(!content.contains("moves-don't-stamp"))
}
/// **v14: the Comments chapter** mechanics only (01-storage-format.md § Enhanced schema is the
/// authority; conduct is a separate section, tested below). Each pin is a phrase an agent reading
/// the guide would actually see.
@Test("v14 teaches the comment thread's mechanics")
func v14CommentsVocabularyIsPresent() {
let content = AgentGuide.content
#expect(content.contains("## Comments"))
#expect(content.contains("comments/<lowercase-uuid>/index.md"))
#expect(content.contains("**No\n `title`, no `order`.**"))
#expect(content.contains("**Chronology is the ordering**"))
#expect(content.contains("**`author` is self-reported and survives app writes**"))
#expect(content.contains("**`comments/.draft` and `comments/.trash` are the app's**"))
#expect(content.contains("post a follow-up saying so — never\n rewrite or remove an existing one"))
#expect(content.contains("counts as **edited** when `modified` differs from"))
}
/// **v14: the conduct chapter** the guide's first word on *when* to post, not just how
/// (08-agent-integration.md Use the thread). Card body vs. thread, START-with-a-plan, questions
/// as comments, re-read before resuming, verification on close, and the no-stamp guarantee.
@Test("v14 teaches thread conduct: journal your work")
func v14ConductVocabularyIsPresent() {
let content = AgentGuide.content
#expect(content.contains("## Use the thread: journal your work"))
#expect(content.contains("The card's body is the spec; its comment thread"))
#expect(content.contains("**Starting work on a card**"))
#expect(content.contains("written for a\n reader with none of your context"))
#expect(content.contains("**Questions**: post them as comments."))
#expect(content.contains("**re-read the whole thread before resuming any card**"))
#expect(content.contains("**Finishing**: close with verification evidence"))
#expect(content.contains("**Comments never stamp the card**"))
#expect(content.contains("belongs in `CLAUDE.user.md`, not here."))
}
/// **v14: `comments/` leaves the reserved-keys list** it shipped, so it is no longer a name
/// agents are told to avoid; the tracker-integration keys are what remains reserved.
@Test("v14 corrects the reserved-keys list: comments/ is gone, the tracker keys remain")
func v14ReservedKeysCorrectionIsPresent() {
let content = AgentGuide.content
#expect(content.contains("`comments/` is **not** on this list"))
#expect(content.contains("`labels`,\n`assignees`, `due`, the `remote` key (cards and board), and `remote-state`"))
#expect(!content.contains("and a card-level `comments/` folder"))
}
/// **v14: the `AGENTS.md` twin** named in the marker line, the Layout diagram, and the Git
/// section's "leave the app-maintained files alone" bullet, so an agent reading either copy of
/// the guide learns the other exists.
@Test("v14 names the AGENTS.md twin in the marker, the layout, and Git")
func v14TwinVocabularyIsPresent() {
let content = AgentGuide.content
#expect(content.contains("This guide is written at two names, CLAUDE.md and AGENTS.md, kept byte-identical."))
#expect(content.contains("AGENTS.md byte-identical twin of CLAUDE.md (app-maintained)"))
#expect(content.contains("comments/ the card's comment thread (see Comments)"))
#expect(content.contains("this guide (both\n `CLAUDE.md` and its `AGENTS.md` twin) and the seeded `.gitignore`"))
}
}
+17
View File
@@ -842,6 +842,23 @@ struct BoardWriterCreateBoardTests {
#expect(guideText == AgentGuide.content, "the exact shipped guide, not a hand-rolled write")
#expect(!fixture.exists("MyBoard.kanban/\(AgentGuide.userFilename)"), "nothing existed to rescue at a brand-new root")
}
/// **The twin** (ruled 2026-08-09, card dc1314bb): `createBoard` writes `AGENTS.md` alongside
/// `CLAUDE.md`, byte-identical a fresh board never lives even a moment with only one of the two
/// claimed guide names.
@Test("createBoard installs the AGENTS.md twin too, byte-identical to CLAUDE.md")
func createBoardInstallsTheAgentsMdTwin() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let root = fixture.url("MyBoard.kanban")
try BoardWriter.createBoard(at: root, title: "My Board")
let claudeText = try fixture.data("MyBoard.kanban/\(AgentGuide.filename)")
let agentsText = try fixture.data("MyBoard.kanban/\(AgentGuide.agentsFilename)")
#expect(agentsText == claudeText, "byte-identical, not merely equal-content")
#expect(agentsText == Data(AgentGuide.content.utf8))
}
}
// MARK: - Create lane / card
+5
View File
@@ -89,6 +89,11 @@ struct IntegrityReservedNameTests {
#expect(guide.expected == .file)
#expect(guide.displacesSquatters)
// The AGENTS.md twin (ruled 2026-08-09): claimed on the identical terms as CLAUDE.md.
let agentsTwin = try #require(IntegrityRules.claimedRootNames.first { $0.name == "AGENTS.md" })
#expect(agentsTwin.expected == .file)
#expect(agentsTwin.displacesSquatters)
// The standing exception: a rescue *destination* is never itself freed by a second
// displacement, which would cascade renames (08-agent-integration.md Ownership).
let userFile = try #require(IntegrityRules.claimedRootNames.first { $0.name == "CLAUDE.user.md" })
+8 -6
View File
@@ -30,16 +30,18 @@ import Testing
/// A one-lane, one-card board, ready for whatever the test wants to leave beside `index.md`.
///
/// It carries a **current agent guide** and the **seeded `.gitignore`**, which is what any board the
/// app has opened once looks like (08-agent-integration.md The agent guide; 06-history-undo.md
/// Repository hygiene). Without them the store's own scheduled heals the guide refresh and the
/// seed, which run on every successful reload beside this file's relocation would write those two
/// files on the first reload and open brackets of their own, and the bracket counts below would stop
/// being claims about the relocation.
/// It carries a **current agent guide, at both claimed names**, and the **seeded `.gitignore`**,
/// which is what any board the app has opened once looks like (08-agent-integration.md The agent
/// guide; 06-history-undo.md Repository hygiene). Without them the store's own scheduled heals
/// the guide refresh (both `CLAUDE.md` and its `AGENTS.md` twin) and the seed, which run on every
/// successful reload beside this file's relocation would write those files on the first reload and
/// open brackets of their own, and the bracket counts below would stop being claims about the
/// relocation.
private func makeCardBoard(gitignore: String = BoardWriter.gitignoreSeed) throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8))
try fixture.file(AgentGuide.agentsFilename, Data(AgentGuide.content.utf8))
try fixture.file(IntegrityRules.gitignoreFileName, Data(gitignore.utf8))
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login"))