|
|
|
@@ -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`"))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|