import Foundation import Testing @testable import Kanban /// The agent guide, end to end (08-agent-integration.md ▸ The agent guide). /// /// The rule is three promises, and this file is organized as them: /// /// 1. **The marker decides, and only the first line carries it** — the version gate, parsed as a /// pure function of text. /// 2. **The decision is pure** — write, leave alone, displace, or skip, from what the two claimed /// board-root names look like on disk and nothing else. /// 3. **Nothing the user owns is ever destroyed** — a markerless `CLAUDE.md` is rescued, a taken /// `CLAUDE.user.md` cancels the write outright, a symlink or a folder wearing the name is /// *displaced* rather than clobbered (ruled 2026-07-29 — the claimed-name rule; it replaced an /// untouchable-skip, and displacement-never-destruction is what survives), and a current guide is /// not even opened for writing. /// /// Every on-disk claim is read back as **raw bytes**, never through a snapshot: the promises are /// about the files. `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`. // MARK: - Shared fixtures /// A one-lane board — enough tree that a reload has something to walk. @MainActor private func makeBoard() throws -> WriterFixture { let fixture = try WriterFixture() try fixture.item("", Item.board) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) return fixture } /// A guide-shaped file at some version — the shape an older app version left behind, or one a newer /// version will. private func guideText(version: Int) -> String { "\n\n# This folder is a Lanework kanban board\n" } /// A file's bytes and mtime — "this file was not rewritten", stated the way `LooseFileRelocationTests` /// states it. private func stat(_ url: URL) throws -> (bytes: Data, modified: Date) { let attributes = try FileManager.default.attributesOfItem(atPath: url.path) guard let modified = attributes[.modificationDate] as? Date else { throw NSError(domain: "AgentGuideTests", code: 1) } return (try Data(contentsOf: url), modified) } private func guideURL(in fixture: WriterFixture) -> URL { fixture.root.appendingPathComponent(AgentGuide.filename) } private func userFileURL(in fixture: WriterFixture) -> URL { fixture.root.appendingPathComponent(AgentGuide.userFilename) } /// Writes raw bytes to one of the two claimed board-root names, without going near the app. @discardableResult private func writeRoot(_ name: String, _ bytes: Data, in fixture: WriterFixture) throws -> URL { let url = fixture.root.appendingPathComponent(name) try bytes.write(to: url) return url } /// Counts the bracket calls a store makes, standing in for the watcher the registry wires up. @MainActor private final class GuideBracketLog { private(set) var begins = 0 func attach(to store: BoardStore) { store.watcherBrackets = (begin: { self.begins += 1 }, end: {}) } } /// Polls until `condition` holds or the deadline passes — generous, because FSEvents delivery is not /// a bounded-latency promise (`BoardStoreRegistryTests`' idiom). @MainActor private func waitUntil(_ deadline: Duration = .seconds(15), _ condition: () -> Bool) async { let start = ContinuousClock.now while ContinuousClock.now - start < deadline { if condition() { return } try? await Task.sleep(for: .milliseconds(25)) } } /// Gives a freshly started stream a beat to register with `fseventsd` — see `FolderWatcherTests`. @MainActor private func settle() async { try? await Task.sleep(for: .milliseconds(300)) } // MARK: - 1. The version marker @Suite("Agent guide ▸ the marker") struct AgentGuideMarkerTests { @Test("The guide the app ships carries the current marker in its first line") func shippedGuideCarriesTheMarker() { let firstLine = String(AgentGuide.content.prefix { !$0.isNewline }) #expect(firstLine.contains("lanework-agent-guide v\(AgentGuide.version)")) #expect(AgentGuide.installedVersion(of: AgentGuide.content) == AgentGuide.version) // Files the app creates end with LF (01-storage-format.md § Encoding and line endings). #expect(AgentGuide.content.hasSuffix("\n")) #expect(!AgentGuide.content.hasSuffix("\n\n")) } @Test("Any version in the first line parses") func versionsParse() { #expect(AgentGuide.installedVersion(of: guideText(version: 1)) == 1) #expect(AgentGuide.installedVersion(of: guideText(version: 4)) == 4) #expect(AgentGuide.installedVersion(of: guideText(version: 12)) == 12) } @Test("A file with no marker has no version") func markerlessHasNoVersion() { #expect(AgentGuide.installedVersion(of: "# My project\n\nNotes for agents.\n") == nil) #expect(AgentGuide.installedVersion(of: "") == nil) // The prefix without a number is not a marker either. #expect(AgentGuide.installedVersion(of: "lanework-agent-guide vNext\n") == nil) } /// **The deliberate divergence from the pathfinder** (08: "version marker in the first line"): /// a user's own file that merely *quotes* the marker further down is not an app-owned guide, and /// reading it as one would overwrite it. @Test("A marker below the first line does not count") func markerBelowTheFirstLineIsNotAMarker() { #expect(AgentGuide.installedVersion(of: "# Notes\nlanework-agent-guide v5\n") == nil) #expect(AgentGuide.installedVersion(of: "\nlanework-agent-guide v9\n") == nil) } @Test("CRLF and a missing final newline both parse") func lineEndingsDoNotMatter() { #expect(AgentGuide.installedVersion(of: "\r\nbody\r\n") == 3) #expect(AgentGuide.installedVersion(of: "") == 3) } } // MARK: - 2. The decision @Suite("Agent guide ▸ the decision") struct AgentGuideDecisionTests { private func state(_ existing: AgentGuide.Existing, userFileFree: Bool = true) -> AgentGuide.State { AgentGuide.State(existing: existing, userFilenameIsFree: userFileFree) } @Test("Missing writes; older rewrites") func missingAndOlderWrite() { #expect(AgentGuide.decide(state(.missing)) == .write) #expect(AgentGuide.decide(state(.file(text: guideText(version: 1)))) == .write) #expect(AgentGuide.decide(state(.file(text: guideText(version: 4)))) == .write) } /// "Never downgraded" — a newer app version may have written it. @Test("Current and newer are left alone") func currentAndNewerAreLeftAlone() { #expect(AgentGuide.decide(state(.file(text: guideText(version: AgentGuide.version)))) == .leaveAlone) #expect(AgentGuide.decide(state(.file(text: guideText(version: AgentGuide.version + 1)))) == .leaveAlone) #expect(AgentGuide.decide(state(.file(text: AgentGuide.content))) == .leaveAlone) } @Test("A markerless file is displaced when the user name is free") func markerlessIsDisplaced() { #expect(AgentGuide.decide(state(.file(text: "# My own notes\n"))) == .displaceThenWrite) // Undecodable bytes are markerless too — the move preserves them either way. #expect(AgentGuide.decide(state(.file(text: nil))) == .displaceThenWrite) // A marker that is not on the first line is a user file, not an old guide. #expect(AgentGuide.decide(state(.file(text: "# Notes\nlanework-agent-guide v4\n"))) == .displaceThenWrite) } /// "Otherwise the guide write is skipped with a log — user content is never destroyed." @Test("A taken CLAUDE.user.md cancels the write outright") func takenUserFilenameSkips() { #expect(AgentGuide.decide(state(.file(text: "# My own notes\n"), userFileFree: false)) == .skipUserFilenameTaken) #expect(AgentGuide.decide(state(.file(text: nil), userFileFree: false)) == .skipUserFilenameTaken) // It has no bearing on the version gate: an app-owned guide is still upgraded in place. #expect(AgentGuide.decide(state(.file(text: guideText(version: 4)), userFileFree: false)) == .write) #expect(AgentGuide.decide(state(.missing, userFileFree: false)) == .write) } /// **Updated 2026-07-29** — the claimed-name squatter ruling (01-storage-format.md § Fractal /// layout ▸ Rules) upgraded this case from a skip to a displacement: Lanework owns the board, so /// a folder or symlink on a name the app claims is an invalid artifact, not a resident. It is /// moved aside by the Finder ladder and never destroyed. /// /// **Free name or not is still irrelevant here**, but for a new reason: `CLAUDE.user.md` is the /// *rescue* destination for user content, and a squatter is not rescued to it — it goes to /// `CLAUDE.md 2`, so the other name's state has no bearing on the decision. @Test("A symlink or a folder is displaced, free name or not") func squatterIsDisplaced() { #expect(AgentGuide.decide(state(.squatted)) == .displaceSquatterThenWrite) #expect(AgentGuide.decide(state(.squatted, userFileFree: false)) == .displaceSquatterThenWrite) } } // MARK: - 3. On disk, through the store @MainActor @Suite("Agent guide ▸ on disk") struct AgentGuideStoreTests { @Test("A board with no CLAUDE.md gets the current guide") func missingGuideIsWritten() 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 written = try String(decoding: fixture.data(AgentGuide.filename), as: UTF8.self) #expect(written.prefix { !$0.isNewline }.contains("lanework-agent-guide v\(AgentGuide.version)")) #expect(written == AgentGuide.content) #expect(!fixture.exists(AgentGuide.userFilename)) #expect(brackets.begins == 1, "one bracket — one app-mediated reload, one commit") // A courtesy file the user never asked for has nothing to say to them. #expect(store.banners.oneShots.isEmpty) #expect(store.banners.losses.isEmpty) // No temp-file residue from the atomic replace. #expect(try fixture.entryNames("").contains(AgentGuide.filename)) #expect(try !fixture.entryNames("").contains { $0.hasPrefix(".\(AgentGuide.filename)") }) } @Test("An older guide is rewritten to the current one") func olderGuideIsUpgraded() throws { let fixture = try makeBoard() defer { fixture.tearDown() } try writeRoot(AgentGuide.filename, Data(guideText(version: 4).utf8), in: fixture) let store = try BoardStore(rootURL: fixture.root) store.refreshAgentGuide() #expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8)) // An upgrade is not a displacement: nothing was rescued, because nothing was the user's. #expect(!fixture.exists(AgentGuide.userFilename)) } /// "Left untouched when current or newer" — and untouched means the file is not even opened for /// writing, so its mtime (and, on git boards, the tree) is undisturbed. @Test("A current guide is left byte-for-byte alone, mtime included") func currentGuideIsNotRewritten() throws { let fixture = try makeBoard() 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. let url = try writeRoot(AgentGuide.filename, Data(guideText(version: AgentGuide.version).utf8), in: fixture) let before = try stat(url) let store = try BoardStore(rootURL: fixture.root) let brackets = GuideBracketLog() brackets.attach(to: store) store.refreshAgentGuide() let after = try stat(url) #expect(after.bytes == before.bytes) #expect(after.modified == before.modified) #expect(brackets.begins == 0, "no write means no bracket and no commit") } @Test("A newer guide is never downgraded") func newerGuideIsNotDowngraded() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let url = try writeRoot(AgentGuide.filename, Data(guideText(version: 99).utf8), in: fixture) let before = try stat(url) let store = try BoardStore(rootURL: fixture.root) store.refreshAgentGuide() let after = try stat(url) #expect(after.bytes == before.bytes) #expect(after.modified == before.modified) } @Test("A markerless CLAUDE.md moves to CLAUDE.user.md, byte for byte, and the guide takes its place") func markerlessIsDisplaced() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let mine = "# Board rules\n\nAlways file bugs in the Triage lane.\n" try writeRoot(AgentGuide.filename, Data(mine.utf8), in: fixture) let store = try BoardStore(rootURL: fixture.root) let brackets = GuideBracketLog() brackets.attach(to: store) store.refreshAgentGuide() #expect(try fixture.data(AgentGuide.userFilename) == Data(mine.utf8)) #expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8)) #expect(brackets.begins == 1, "the rescue and the write ride one bracket") #expect(store.banners.oneShots.isEmpty) } /// The first-line rule, proved where it costs something: a user file quoting the marker is /// rescued, not overwritten. @Test("A marker below the first line is user content and is displaced") func markerBelowTheFirstLineIsDisplaced() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let mine = "# Notes about Lanework\n\nThe app stamps `lanework-agent-guide v5` into CLAUDE.md.\n" try writeRoot(AgentGuide.filename, Data(mine.utf8), in: fixture) let store = try BoardStore(rootURL: fixture.root) store.refreshAgentGuide() #expect(try fixture.data(AgentGuide.userFilename) == Data(mine.utf8)) #expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8)) } @Test("A CLAUDE.md that is not UTF-8 is displaced with its bytes intact") func undecodableIsDisplacedByteForByte() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let bytes = Data([0xFF, 0xFE, 0x00, 0x41, 0x80, 0x0A]) try writeRoot(AgentGuide.filename, bytes, in: fixture) let store = try BoardStore(rootURL: fixture.root) store.refreshAgentGuide() #expect(try fixture.data(AgentGuide.userFilename) == bytes) #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. @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 theirs = "# Older board rules\n" let guide = try writeRoot(AgentGuide.filename, Data(mine.utf8), in: fixture) let user = try writeRoot(AgentGuide.userFilename, Data(theirs.utf8), in: fixture) let guideBefore = try stat(guide) let userBefore = try stat(user) let store = try BoardStore(rootURL: fixture.root) let brackets = GuideBracketLog() brackets.attach(to: store) store.refreshAgentGuide() #expect(try stat(guide).bytes == guideBefore.bytes) #expect(try stat(guide).modified == guideBefore.modified) #expect(try stat(user).bytes == userBefore.bytes) #expect(try stat(user).modified == userBefore.modified) #expect(brackets.begins == 0) #expect(store.banners.oneShots.isEmpty, "a skip is a log line, not a banner") } /// **Updated 2026-07-29** — the claimed-name squatter ruling. A symlink wearing the guide's name /// is still never *followed*: it is moved aside **as a link** (`lstat` semantics all the way /// down), its target is never opened, and the guide is written on the freed name. @Test("A symlinked CLAUDE.md is displaced as a link, and its target is untouched") func symlinkedGuideIsDisplaced() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let target = try fixture.file("elsewhere.md", Data("# somewhere else\n".utf8)) try FileManager.default.createSymbolicLink(atPath: guideURL(in: fixture).path, withDestinationPath: "elsewhere.md") let store = try BoardStore(rootURL: fixture.root) store.refreshAgentGuide() // The link moved, still a link, still pointing where it pointed — never resolved, never // written through. // Finder's own splitting: `CLAUDE.md` → `CLAUDE 2.md` (the ladder splits after the last // dot), exactly as `.trash` → `.trash 2` for an extension-less name. let moved = fixture.root.appendingPathComponent("CLAUDE 2.md") #expect(try FileManager.default.destinationOfSymbolicLink(atPath: moved.path) == "elsewhere.md") #expect(try Data(contentsOf: target) == Data("# somewhere else\n".utf8)) // And the freed name now carries the guide. #expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8)) // A squatter's displacement is announced — the relocation-style warning-tone notice, naming // old and new. The rescue to CLAUDE.user.md is silent; this is not that. #expect(!fixture.exists(AgentGuide.userFilename)) #expect(store.banners.losses.count == 1) #expect(store.banners.losses.first?.message == "Renamed 'CLAUDE.md' to 'CLAUDE 2.md' — Lanework needs that name") } /// The rescue name is checked with `lstat` semantics, so a **broken** symlink counts as taken: /// `fileExists` would follow it, find nothing, call the name free — and the move would then fail /// into a banner about a file the user never asked for. @Test("A dangling symlink on CLAUDE.user.md counts as taken") func danglingRescueNameIsTaken() throws { let fixture = try makeBoard() defer { fixture.tearDown() } try writeRoot(AgentGuide.filename, Data("# mine\n".utf8), in: fixture) try FileManager.default.createSymbolicLink(atPath: userFileURL(in: fixture).path, withDestinationPath: "nowhere.md") let store = try BoardStore(rootURL: fixture.root) store.refreshAgentGuide() #expect(try fixture.data(AgentGuide.filename) == Data("# mine\n".utf8)) #expect(try FileManager.default.destinationOfSymbolicLink(atPath: userFileURL(in: fixture).path) == "nowhere.md") #expect(store.banners.oneShots.isEmpty) } /// A regular file the app cannot *read* is markerless, not missing — so it is displaced, never /// overwritten. The move preserves it without ever needing to read it. @Test("An unreadable CLAUDE.md is displaced, not clobbered") func unreadableGuideIsDisplaced() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let url = try writeRoot(AgentGuide.filename, Data("# secret\n".utf8), in: fixture) try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: url.path) let store = try BoardStore(rootURL: fixture.root) store.refreshAgentGuide() #expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8)) try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: userFileURL(in: fixture).path) #expect(try fixture.data(AgentGuide.userFilename) == Data("# secret\n".utf8)) } /// **Updated 2026-07-29** — the claimed-name squatter ruling: a folder on the guide's name is an /// invalid artifact, not a resident. It moves aside whole, **contents preserved verbatim**, and /// the guide takes the freed name. @Test("A folder named CLAUDE.md is displaced whole, contents intact") func directoryGuideIsDisplaced() throws { let fixture = try makeBoard() defer { fixture.tearDown() } try fixture.file("\(AgentGuide.filename)/inside.txt", Data("inside".utf8)) let store = try BoardStore(rootURL: fixture.root) store.refreshAgentGuide() #expect(try fixture.data("CLAUDE 2.md/inside.txt") == Data("inside".utf8)) #expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8)) // Displacement, never a rescue: `CLAUDE.user.md` is where *user content* goes, and a folder // on a file's name is not that. #expect(!fixture.exists(AgentGuide.userFilename)) } /// The ladder climbs rather than overwriting: a board that already has a `CLAUDE.md 2` gets a /// `CLAUDE.md 3`, Finder-style, one collision at a time. @Test("The displacement climbs the Finder ladder past a taken name") func displacementClimbsTheLadder() throws { let fixture = try makeBoard() defer { fixture.tearDown() } try fixture.file("\(AgentGuide.filename)/inside.txt", Data("inside".utf8)) try writeRoot("CLAUDE 2.md", Data("someone else's\n".utf8), in: fixture) let store = try BoardStore(rootURL: fixture.root) store.refreshAgentGuide() #expect(try fixture.data("CLAUDE 2.md") == Data("someone else's\n".utf8), "untouched") #expect(try fixture.data("CLAUDE 3.md/inside.txt") == Data("inside".utf8)) #expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8)) } /// 02-architecture.md § Write-failure surfacing: "The open-time agent-guide write … is /// skipped-with-log, the `CLAUDE.user.md`-taken precedent." A board on a read-only volume must /// not spend a banner on a courtesy file. @Test("An unwritable board root is skipped silently") func unwritableRootIsSkipped() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let brackets = GuideBracketLog() brackets.attach(to: store) // Readable (so the board still loads and renders) but not writable. try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: fixture.root.path) store.refreshAgentGuide() #expect(!fixture.exists(AgentGuide.filename)) #expect(brackets.begins == 0) #expect(store.banners.oneShots.isEmpty) } /// The read-only *lock* defers it as well — the relocation's posture, and 02's. @Test("A read-only-locked board defers, and writes the guide when the lock clears") func lockedBoardDefers() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.enterUnwritableLock(.permissionDenied) store.refreshAgentGuide() #expect(!fixture.exists(AgentGuide.filename)) // A reconciling reload re-probes writability, the lock clears — and the same reload writes // the guide it had been holding back. store.handleWatcherEvent(.treeChanged(.reconciling)) await store.awaitQuiescence() #expect(!store.isReadOnly) #expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8)) } @Test("Refreshing twice writes once") func secondRefreshIsANoOp() 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 after = try stat(guideURL(in: fixture)) store.refreshAgentGuide() #expect(try stat(guideURL(in: fixture)).modified == after.modified) #expect(brackets.begins == 1) } /// The self-heal, stated where it is deterministic: every successful reload re-checks the guide, /// so a foreign deletion or downgrade is repaired by the reload that noticed it. @Test("A reload re-checks the guide — a deleted one comes back") func aReloadHealsIt() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.refreshAgentGuide() try FileManager.default.removeItem(at: guideURL(in: fixture)) store.handleWatcherEvent(.treeChanged(.foreign)) await store.awaitQuiescence() #expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8)) } /// The memo `refreshAgentGuide()` documents, from both sides: a picture this store has already /// refused to act on is not acted on again by every reload that re-reads it, and a picture that /// genuinely *changed* is a fresh attempt. @Test("A skipped board stays skipped across reloads, and heals when the name frees up") func aSkipIsNotRetriedUntilThePictureChanges() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } // A *folder* on the rescue name is exactly as taken as a file — the app never descends into // 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)) let store = try BoardStore(rootURL: fixture.root) let brackets = GuideBracketLog() brackets.attach(to: store) store.refreshAgentGuide() for _ in 0 ..< 3 { store.handleWatcherEvent(.treeChanged(.foreign)) await store.awaitQuiescence() } #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.userFilename)/inside.txt") == Data("inside".utf8)) // The name frees up: a different picture, so a fresh attempt — and the rescue finally runs. try FileManager.default.removeItem(at: userFileURL(in: fixture)) store.handleWatcherEvent(.treeChanged(.foreign)) await store.awaitQuiescence() #expect(try fixture.data(AgentGuide.userFilename) == Data("# mine\n".utf8)) #expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8)) #expect(brackets.begins == 1) } @Test("The guide is not a stray, and does not disturb the load") func guideIsNotAStray() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.refreshAgentGuide() try writeRoot(AgentGuide.userFilename, Data("# mine\n".utf8), in: fixture) let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.warnings.isEmpty, "both names are app-claimed, never stray-warned") #expect(result.model.lanes.count == 1) } } // MARK: - 4. Through the registry, with a real watcher @MainActor @Suite("Agent guide ▸ the registry") struct AgentGuideRegistryTests { /// The open-time firing, wired the way the app wires it — after the watcher and the brackets /// exist, so the write has a reload behind it. @Test("Acquiring a board writes the guide") func acquireWritesTheGuide() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let registry = BoardStoreRegistry() let store = try registry.acquire(fixture.root) defer { registry.release(store) } await waitUntil { fixture.exists(AgentGuide.filename) } #expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8)) #expect(store.reloadFailure == nil) } /// Self-healing, over the real FSEvents path: nothing in this test hands the store an event. @Test("A guide deleted under a live board comes back") func foreignDeletionHeals() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let registry = BoardStoreRegistry() let store = try registry.acquire(fixture.root) defer { registry.release(store) } await waitUntil { fixture.exists(AgentGuide.filename) } await settle() try FileManager.default.removeItem(at: guideURL(in: fixture)) #expect(!fixture.exists(AgentGuide.filename)) await waitUntil { fixture.exists(AgentGuide.filename) } #expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8)) #expect(store.reloadFailure == nil) } } // MARK: - 5. The content /// What the guide *says*, pinned where saying it wrong would mislead every agent that reads it — /// the authoring card's contract (08-agent-integration.md ▸ The agent guide's list): the schema it /// condenses is 01-storage-format.md's, post-pivot, and the vocabulary it teaches must be the /// app's own. @Suite("Agent guide ▸ the content") struct AgentGuideContentTests { /// The palette tables are transcribed prose, and transcriptions drift. Tying every name to /// `Palette`'s own tables makes a palette rename a failing guide test rather than a silently /// wrong document teaching agents colors the app no longer resolves. @Test("Every palette name the app resolves appears in the guide") func paletteNamesMatchTheSource() { for color in Palette.foregrounds + Palette.backgrounds { #expect(AgentGuide.content.contains("`\(color.name)`"), "missing palette name: \(color.name)") } } @Test("The guide teaches the current conventions by name") func currentVocabularyIsPresent() { let content = AgentGuide.content // The rewrite's list, 08 ▸ The agent guide: attachments, the trash, self-stamping, the // user extension point, staging etiquette — each findable by the string an agent would // grep for. #expect(content.contains(".trash/")) #expect(content.contains("attachments/")) #expect(content.contains("modified-by")) #expect(content.contains("CLAUDE.user.md")) #expect(content.contains("git add -A")) #expect(content.contains("schema: 1")) } /// v7's list, 08 ▸ The agent guide's "v7 additionally teaches" bullet: the refined /// stamp-discipline predicate (01-storage-format.md ▸ `modified`'s scope, ruled 2026-07-29, /// refined 2026-07-30) and the card-level `attachments` claimed name (01-storage-format.md /// § Fractal layout ▸ Rules, "level-uniform"). Each pin is a phrase an agent reading the guide /// would actually see, not a paraphrase — so a wording rewrite that silently drops the rule /// fails here instead of shipping quietly. @Test("v7 teaches the stamp-discipline predicate and the attachments claimed name") func v7VocabularyIsPresent() { let content = AgentGuide.content // The predicate itself: one rule, not a trash special case. #expect(content.contains("a move that changes an item's container")) #expect(content.contains("rewrites only `order`")) #expect(content.contains("into/out of `.trash/`")) #expect(content.contains("The trash move isn't an exception")) // The attachments claimed name: don't squat the folder's name with a file. #expect(content.contains("The name `attachments` itself belongs to that folder")) #expect(content.contains("*file* called `attachments` in a card")) } /// **Lanes delete into the trash too** (08 ▸ The agent guide's present-tense list; 03-board-ui.md /// § Trash, re-ruled 2026-07-29): the guide has to teach the *move*, the `kind` value that tells a /// trashed lane from a card in the flat container, and the stamp on the way in. The shipped v7 /// literal already says all three — this is the guard that keeps a wording pass from dropping one /// silently, which is the failure class the per-version changelog bullets were retired over. @Test("The guide teaches lanes-in-trash: the move, the kind value, and the stamp") func lanesInTrashVocabularyIsPresent() { let content = AgentGuide.content // The delete itself is one sentence covering both levels. #expect(content.contains("Delete a card or a lane = move its folder into `/.trash/`")) #expect(content.contains("for a whole lane (create `.trash/` if missing)")) #expect(content.contains("travels with its cards inside it")) // `kind` at creation, always — depth says what an item is on the board, but `.trash/` is flat. #expect(content.contains("**Always write `kind`** at creation")) #expect(content.contains("tells a trashed lane from a card")) // And stamped on the way in when it is missing. #expect(content.contains("**Stamp `kind: lane` when you trash a lane that lacks it.**")) // The one place permanence is named, and it names both kinds. #expect(content.contains("the trash is the recoverable path for both")) } /// The pathfinder's guide taught `media/` and tombstone deletes; both are retired /// (01-storage-format.md ▸ Changes from the pathfinder schema; ▸ Deletion). The one legitimate /// mention of `deleted:` is the warning never to write it. @Test("Retired vocabulary does not resurface") func retiredVocabularyIsAbsent() { let content = AgentGuide.content #expect(!content.contains("media/")) #expect(!content.contains("tombstone")) #expect(content.contains("Never write a `deleted:` key")) // The 2026-07-29 rule was first named "moves-don't-stamp"; the 2026-07-30 refinement // retired that framing (container changes stamp, the trash move included) — the guide // must never teach the superseded shape of the rule. #expect(!content.contains("moves don't stamp")) #expect(!content.contains("moves-don't-stamp")) } }