The 2026-07-29 integrity design pass, consolidated (DESIGN/01 -
Validation and healing; DESIGN/02 - Components): IntegrityRules
(Storage, pure) is the one home for the identity predicate and
canonical form (BoardWriter.canonicalIdentity deleted, ItemID and the
loader forward to it), the per-field rulebook, uneditable shapes,
per-kind index validation, the reserved-name tables, and the trash
kind discriminator (values trusted - kind: lane/card explicit,
unrecognized falls to shape). LoadResult's ad-hoc channels fold into
one typed Defect stream (looseCardFiles / legacyTombstone /
claimedNameSquatted, per-defect heal signatures); the old accessors
survive as computed views.
HealScheduler (LiveStore) states the six-step heal pattern once -
resting-clear, lock gate, isWritableFile gate (now covering all four
heals), signature memo armed-before-attempt with explicit
clear-on-success, disk re-verify in each write half, one banner-posture
table (BannerCenter keeps all phrasing). The three hand-rolled healers
run on it with behavior preserved - including the
relocation-notice-despite-partial-failure quirk, deliberately. Heals
run at the reload tail AND at registry acquire, closing the
migration-never-fires-at-open asymmetry. Displacement runs first: a
squatted .trash would otherwise fail the migration and arm its memo
against an unchanged picture.
Claimed-name squatters (ruled today, 62c47a2) displace by the shared
Finder-style rename ladder - preserved verbatim, symlinks moved as
links, nothing stamped; AgentGuide's untouchable-skip upgrades to
displace-then-write, the CLAUDE.user.md-taken skip stands. kind stamps
on every create and backfills on any index rewrite via the on-touch
seam (placement resolver stamps nothing when the parent is unknown -
a guessed kind is worse than an absent one; board-root writers declare
theirs). Heal writes mark their EchoLedger receipts (inert in base;
pro-m1's committer will split them into their own commits). The
renumber ask-renumber-ask-again two-step is one shared helper, adopted
at all nine call sites.
69 tests added. 1738 green on both schemes.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
661 lines
31 KiB
Swift
661 lines
31 KiB
Swift
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 {
|
|
"<!-- lanework-agent-guide v\(version) — created and kept up to date by the Lanework app. -->\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: "<!-- lanework-agent-guide v3 -->\r\nbody\r\n") == 3)
|
|
#expect(AgentGuide.installedVersion(of: "<!-- lanework-agent-guide v3 -->") == 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"))
|
|
}
|
|
|
|
/// 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"))
|
|
}
|
|
}
|