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
+6 -2
View File
@@ -11,7 +11,7 @@ AI agents are first-class users of Lanework boards — not through an API, but t
5. **Git trail** (06-history-undo.md) — on git-enabled boards, agent changes are auto-committed, attributed, and undoable like any others. Foreign changes are committed under a synthetic external author, structurally distinct from the user's own commits; an agent can claim its work lightly by stamping `modified-by` (guarantee 6 below), or precisely by committing its changes itself — the app follows along: a held `index.lock` just makes the auto-committer retry/re-debounce, and a tree the agent already committed is a silent no-op that preserves the agent's authorship (06-history-undo.md). The guide says so, and also tells agents to **stage only their own paths** (no `git add -A`) — a sweep would commit the user's not-yet-auto-committed changes under the agent's name.
6. **`modified-by` self-stamping** (01-storage-format.md) — the lightweight attribution path: an agent stamps files it writes (`modified-by: <your-name>`), and the stamp renders on the card window's modified line and authors the agent's foreign commits (06-history-undo.md) — no git ceremony, and it works on no-git boards, where it is the *only* attribution there is. The app clears the stamp on its own writes, so agents should re-stamp on every write, not once — and on every *move*: a bare folder move rewrites nothing, so the moved card arrives unstamped and the commit falls back to the generic external author (06's fallback demotes on unstamped changed files and true deletions; a move is neither stamped nor a deletion until the agent re-stamps it).
## The agent guide (`CLAUDE.md` at board root)
## The agent guide (`CLAUDE.md` at board root, twinned at `AGENTS.md`)
The app silently maintains a `CLAUDE.md` in every board — a condensed, agent-facing rendition of the schema teaching any agent how to operate on the board directly:
@@ -22,6 +22,8 @@ The app silently maintains a `CLAUDE.md` in every board — a condensed, agent-f
- **Attachments** (the `attachments/` convention, importing files) — including the rule that **card files belong in `attachments/`**: a loose file written beside `index.md` will be relocated there by the app with a notice (01-storage-format.md's loose-file carve-out), so agents should put it there in the first place — and the card-level **`attachments` claimed name**: `attachments` inside a card folder is the app's (the card's file folder); never create a *file* by that name.
- **`modified-by` self-stamping** — stamp files you write; re-stamp every write *and every move* (the app clears it on its own writes; a bare folder move leaves the card unstamped); self-commit instead when you need exact authorship.
- **The stamp discipline** of 01 ▸ `modified`'s scope: **reordering within a lane rewrites only `order`** — leave `modified` and `modified-by` alone — while **a move between lanes, between boards, or into/out of `.trash/` updates both** (stamp `modified`, re-stamp `modified-by`).
- **Comments** (`comments/` — 01-storage-format.md § Enhanced schema, shipped 2026-07-29): the folder shape (`comments/<uuid>/index.md`, no `title`/`order`), chronology as the ordering (sorts by `created` ascending — write real current UTC, distinct seconds for a burst), `author` as self-reported content that survives app writes (the deliberate contrast with `modified-by`, which the app clears), `comments/.draft` and `comments/.trash` as reserved names one level down the agent never writes into, and retraction by a follow-up comment rather than a rewrite. `comments/` **no longer appears on the reserved-tracker-keys list** below — it shipped, and the list now names only the fields tracker sync still reserves (`labels`, `assignees`, `due`, `remote`, `remote-state`).
- **Use the thread: journal your work** (added with the comments chapter, ruled 2026-08-09, card dc1314bb): the card's body is the spec, its comment thread is the journal. Starting a card gets a comment with the plan and every decision already made, written for a reader with none of the session's context; decisions get recorded as they're made, not reconstructed at the end; questions post as comments, since the app narrates arrivals live and a thread has to be re-read in full before a card is resumed; finishing gets a comment with verification evidence. Posting a comment never stamps the card's own `modified`/`modified-by`. Per-board process — lane cadence, commit conventions — stays `CLAUDE.user.md`'s, not the guide's.
- A pointer to the optional **`CLAUDE.user.md`** (see below), instructing agents to read it when present.
This list is **present-tense and normative for the current guide** (re-ruled 2026-07-30): it states what the shipped guide teaches *now*, and editing it means editing the guide literal in the same change. Per-version changelog bullets are deliberately gone — a hand-maintained version history beside the literal drifted within days of v6 (claiming teachings the shipped body lacked), the same failure class as README's hand-enumerated deferred list; version history lives in git.
@@ -30,7 +32,9 @@ Mechanics: version marker in the first line (`lanework-agent-guide vN`); rewritt
**Ownership**: the board-root `CLAUDE.md` is **app-owned and not available for user editing** — its header says so, and user edits do not survive upgrades. This holds on every board, including repo-nested ones: the guide is auto-written there too (the untracked file in the user's repository is accepted — a board is agent-facing wherever it lives). A board-root `CLAUDE.md` found *without* the marker is displaced user content, not clobbered: its content is moved to `CLAUDE.user.md` if that name is free (otherwise the guide write is skipped with a log — user content is never destroyed), and the guide is then written.
**`CLAUDE.user.md`** is the user's extension point: an optional, user-authored file at board root carrying board-specific agent instructions. The generated guide tells agents to read it when present, so it lands in agent context without the app ever touching it — the app never writes, upgrades, or validates an *existing* `CLAUDE.user.md`; the one exception is its creation, once, to rescue displaced content (the markerless-`CLAUDE.md` relocation above, which only runs when the name is free).
**The `AGENTS.md` twin** (ruled 2026-08-09, card dc1314bb): the app writes the identical guide a second time, at `AGENTS.md` — the vendor-neutral filename most non-Claude coding agents read, alongside `CLAUDE.md` for Claude's own pickup. Same lifecycle as `CLAUDE.md` in every particular — seeded at board creation, rewritten on a version bump, left untouched when current, a markerless file on the name rescued and a squatter displaced exactly as `CLAUDE.md`'s own are — because each name gets the identical decision run against it independently, not a copy of the other's outcome. **A duplicate, deliberately not a symlink**: iCloud Drive's symlink handling is unreliable, and the two files are written from the one generator on every install, which makes drift structurally impossible rather than a discipline someone has to maintain. Both names join the claimed board-root list the Git section already tells agents to leave to the app.
**`CLAUDE.user.md`** is the user's extension point: an optional, user-authored file at board root carrying board-specific agent instructions. The generated guide tells agents to read it when present, so it lands in agent context without the app ever touching it — the app never writes, upgrades, or validates an *existing* `CLAUDE.user.md`; the one exception is its creation, once, to rescue displaced content (the markerless-`CLAUDE.md` relocation above, which only runs when the name is free — and which `AGENTS.md` shares: a markerless file on *either* claimed name rescues to this same single destination, never a second `AGENTS.user.md`).
On git boards, a guide write rides the normal watcher → auto-commit path with an honest message ("Update agent guide (v3)"), not "External edit" (06-history-undo.md ▸ Commit messages — the non-snapshot-files rule: the committer stages the whole root, and the composer reads the subject's version from the guide's marker line, a pure function of file content).
+6 -4
View File
@@ -847,10 +847,12 @@ enum ChangeNarrator {
// **N is a pure function of the file's content** (06; the m10 agent-guide card's deferred
// bullet): the version marker on the guide's first line, read here rather than tagged at
// the write site "the no-interception rule stands". A `CLAUDE.md` with no marker is not
// an old guide, it is somebody else's file (`AgentGuide.installedVersion`), and it
// composes as the ordinary path it is.
if path == AgentGuide.filename,
// the write site "the no-interception rule stands". A guide filename with no marker is
// not an old guide, it is somebody else's file (`AgentGuide.installedVersion`), and it
// composes as the ordinary path it is. **Both claimed names** (`CLAUDE.md` and its
// `AGENTS.md` twin, ruled 2026-08-09) share this shape the twin carries the identical
// marker line by construction, so the same version parse answers for either path.
if AgentGuide.targetFilenames.contains(path),
let version = request.agentGuideText.flatMap(AgentGuide.installedVersion(of:)) {
events.append(Event(kind: .agentGuide, subject: "Update agent guide (v\(version))", paths: [path]))
continue
+40 -29
View File
@@ -4793,54 +4793,63 @@ public final class BoardStore: HealHost {
// MARK: - The agent guide
/// Brings the board root's `CLAUDE.md` up to the current guide version, or leaves it exactly as
/// it is the whole of 08-agent-integration.md The agent guide's scheduling. The rule itself
/// is `AgentGuide.decide(_:)`, a pure function; this method is the I/O and the policy around it.
/// Brings the board root's `CLAUDE.md` **and its `AGENTS.md` twin** up to the current guide
/// version, or leaves either exactly as it is the whole of 08-agent-integration.md The agent
/// guide's scheduling. The rule itself is `AgentGuide.decide(_:)`, a pure function applied
/// independently to each of `AgentGuide.targetFilenames`; this method is the I/O and the policy
/// around both.
///
/// **Run on every successful reload**, beside the other scheduled heals, and once more at open
/// (`BoardStoreRegistry.acquire`). That makes the guide *self-healing* rather than merely
/// written-once: a foreign deletion, a downgrade to an older guide, a board restored from a
/// template carrying a stale one each heals on the next reload, without a single new signal.
/// template carrying a stale one each heals on the next reload, without a single new signal,
/// and the two claimed names heal independently of each other (one can be current while the other
/// is stale, deleted, or squatted).
/// It also pre-wires the Pro-era bounce 06-history-undo.md acknowledges by name, where undoing
/// an "Update agent guide (vN)" commit restores an older guide that the app immediately
/// re-upgrades.
///
/// **The steady state is a read and a comparison** one `lstat`, one small file read, one
/// first-line parse and no write at all. Nothing here touches the snapshot: the bytes land, the
/// watcher notices, the reload applies, exactly like every other app write.
/// **The steady state is a read and a comparison, twice over** one `lstat`, one small file read,
/// one first-line parse, per claimed name and no write at all. Nothing here touches the
/// snapshot: the bytes land, the watcher notices, the reload applies, exactly like every other
/// app write.
///
/// ### The two skips, and the one displacement
/// ### The two skips, and the one displacement per file
///
/// `.skipUserFilenameTaken` is the standing exception (a rescue destination is not itself freed
/// by a second displacement); `.displaceSquatterThenWrite` is the 2026-07-29 upgrade of the old
/// untouchable-skip, and it *does* post a node of the user's moved aside owes the same
/// warning-tone notice `.trash`'s displacement does. Both decisions are re-made inside the write
/// half, which is this heal's disk re-verify.
/// half, which is this heal's disk re-verify independently for `CLAUDE.md` and `AGENTS.md`, so
/// one file's skip never silences the other's write.
///
/// ### The signature is the board root's picture
/// ### The signature is the board root's picture, both names folded in
///
/// One failure, one row, then silence until something on disk actually changes and the memo
/// clears on success, which is what lets a foreign deletion of the guide be healed again
/// clears on success, which is what lets a foreign deletion of either guide file be healed again
/// immediately (the picture "missing" is restored, and a standing memo would make that deletion
/// the one thing this could not heal).
/// the one thing this could not heal). Each entry is filename-qualified, so a change confined to
/// one claimed name the other untouched still changes the combined signature.
public func refreshAgentGuide() {
let root = rootURL
let state = AgentGuide.inspect(atBoardRoot: root)
let decision = AgentGuide.decide(state)
// **A decision that writes nothing is no work at all**, and says so with an empty signature:
// the engine's resting-clear then costs no bracket, which matters because a bracket schedules
// a reload whether or not anything was written a skip that opened one would tick forever.
let signature: Set<String>
switch decision {
// **A decision that writes nothing is no work at all**, and says so by contributing nothing to
// the signature: the engine's resting-clear then costs no bracket, which matters because a
// bracket schedules a reload whether or not anything was written a skip that opened one
// would tick forever.
var signature: Set<String> = []
for guideFilename in AgentGuide.targetFilenames {
let state = AgentGuide.inspect(atBoardRoot: root, guideFilename: guideFilename)
switch AgentGuide.decide(state) {
case .leaveAlone:
signature = []
break
case .skipUserFilenameTaken:
// The ruling's own outcome (08 Ownership): a user-authored CLAUDE.md that cannot be
// rescued keeps its name, and the guide simply does not exist on this board.
Self.logger.debug("agent-guide refresh skipped — CLAUDE.md is not the app's and CLAUDE.user.md is taken")
signature = []
// The ruling's own outcome (08 Ownership): user-authored content on a claimed guide
// name that cannot be rescued keeps its name, and that guide simply does not exist on
// this board.
Self.logger.debug("agent-guide refresh skipped — \(guideFilename, privacy: .public) is not the app's and \(AgentGuide.userFilename, privacy: .public) is taken")
case .write, .displaceThenWrite, .displaceSquatterThenWrite:
signature = [state.signature]
signature.insert("\(guideFilename):\(state.signature)")
}
}
var displaced: [BannerCenter.Displacement] = []
@@ -4849,13 +4858,15 @@ public final class BoardStore: HealHost {
signature: signature,
on: self
) { () throws(BoardWriteError) -> Void in
// One bracket over the displacement *and* the write: two files change, one app-mediated
// reload lands, and (under Pro) one honestly-attributed commit records it.
guard let moved = try AgentGuide.install(atBoardRoot: root) else { return }
// One bracket over every displacement *and* write across both claimed names: whatever
// changes, one app-mediated reload lands, and (under Pro) one honestly-attributed commit
// records it.
for moved in try AgentGuide.install(atBoardRoot: root) {
// The `CLAUDE.user.md` rescue is the settled, silent ownership rule; a squatter's
// displacement is announced.
guard !moved.wasUserContent else { return }
guard !moved.wasUserContent else { continue }
displaced.append(BannerCenter.Displacement(name: moved.name, movedTo: moved.movedTo))
}
} posting: {
.displacedClaimedNames(displaced)
}
+189 -48
View File
@@ -38,16 +38,39 @@ import Foundation
/// `BoardStore.performWrite` posts every `BoardWriteError` it sees. The scheduling when this is
/// consulted, and why it is safe to consult on every reload lives at
/// `BoardStore.refreshAgentGuide()`.
///
/// **`CLAUDE.md` has a twin: `AGENTS.md`** (ruled 2026-08-09, card dc1314bb) byte-for-byte the same
/// guide, at a second board-root name, written and upgraded in lockstep. Every promise above
/// never-downgrade, the markerless-content rescue, squatter displacement applies to each name
/// independently; `install(atBoardRoot:)` is simply called once per name. See `agentsFilename` and
/// `targetFilenames` below for why a duplicate file rather than a symlink, and `install`'s doc for how
/// one call keeps both in lockstep without two bodies of prose to let drift.
enum AgentGuide {
// MARK: - The two claimed names
// MARK: - The claimed names
/// The app-owned guide, and one of the board-root names the loader already claims
/// (`BoardLoader.reservedRootNames`) so that neither file is ever read as a stray.
static let filename = "CLAUDE.md"
/// **The `AGENTS.md` twin** (08-agent-integration.md The agent guide; ruled 2026-08-09, card
/// dc1314bb) byte-for-byte the same content as `filename`, written and upgraded in lockstep by
/// the same `install(atBoardRoot:)`. The vendor-neutral name most non-Claude tools read; `CLAUDE.md`
/// stays for Claude's own pickup. **Deliberately a duplicate, never a symlink**: iCloud Drive's
/// symlink handling is unreliable, and rewriting both from the one literal on every install makes
/// drift impossible by construction there is no second body of prose to let drift.
static let agentsFilename = "AGENTS.md"
/// The two guide filenames `install(atBoardRoot:)` maintains, in the order it processes them
/// `filename` first, so that a simultaneous foreign claim on both funnels its one rescue through
/// `filename`'s decision before `agentsFilename`'s is even inspected (see `install`'s doc).
static let targetFilenames: [String] = [filename, agentsFilename]
/// The user's extension point (08 `CLAUDE.user.md`) and the rescue destination for a
/// markerless `CLAUDE.md`. The app writes this name exactly once per board, if ever.
/// markerless `CLAUDE.md` **or** a markerless `AGENTS.md`. The app writes this name at most once
/// per board: a single shared destination for "content that was somebody's before either claimed
/// name was the app's," never a second `AGENTS.user.md` the rescue is conceptually the user's
/// guide content, singular, regardless of which claimed name it was sitting on.
static let userFilename = "CLAUDE.user.md"
/// The guide the app ships. **v4 was the pathfinder's**, and real boards carry it; v5 is the
@@ -101,8 +124,27 @@ enum AgentGuide {
/// an agent writing the key *is* how a hero image gets set. One clause beside the other card keys
/// in Frontmatter, spelling the grammar the reading enforces a bare filename, never a path
/// because a path is exactly what an agent that has just written `![](attachments/x.png)` into a
/// body will reach for.
static let version = 13
/// body will reach for. **v14 teaches the comment thread mechanics and conduct and ships the
/// `AGENTS.md` twin** (08-agent-integration.md The agent guide, Use the thread; 01-storage-
/// format.md § Enhanced schema; filed 2026-08-08 off a live session that journaled an
/// implementation card by deriving every comments rule from 01 by hand, because the guide never
/// taught them). Comments shipped after v11 rewrote Git for the excision and left `comments/` on
/// the reserved-keys list telling agents not to use a surface the app had since built: the
/// thread column, `.draft`, and `comments/.trash/` are all live. Two new sections close the gap.
/// **Comments** teaches the mechanics the folder shape, chronology as the ordering, `author`
/// surviving app writes where `modified-by` doesn't, `.draft` and `.trash` as the app's own,
/// retraction by follow-up rather than rewrite, `modified` differing from `created` as the whole
/// of "edited." **Use the thread: journal your work** teaches the conduct the mechanics section
/// never could: the card body is the spec, the thread is the journal, and a cold reader the
/// user checking in later, another session picking the card back up should be able to
/// reconstruct the plan and every decision from the thread alone; per-board process stays
/// `CLAUDE.user.md`'s. `comments/` itself leaves the reserved-keys line, which now names only the
/// tracker-integration keys still unclaimed. Separately, the app now writes a second guide file,
/// `AGENTS.md`, byte-for-byte identical to this one and carrying the same marker the
/// vendor-neutral filename most non-Claude tools read, kept a deliberate duplicate rather than a
/// symlink (iCloud Drive's unreliable symlink handling), rewritten in lockstep by the same
/// `install(atBoardRoot:)` so drift is impossible by construction rather than by discipline.
static let version = 14
// MARK: - The version marker
@@ -125,8 +167,8 @@ enum AgentGuide {
// MARK: - The decision
/// What is sitting on `CLAUDE.md`, as the filesystem answers it no policy, so the rule below
/// can be a pure function of it.
/// What is sitting on one claimed guide name (`CLAUDE.md` or `AGENTS.md`), as the filesystem
/// answers it no policy, so the rule below can be a pure function of it.
enum Existing: Equatable {
/// Nothing at that path (or nothing this process can read there the same non-event).
case missing
@@ -149,7 +191,8 @@ enum AgentGuide {
case squatted
}
/// The board root's two claimed names, read once the input to `decide(_:)`.
/// One claimed guide name's picture, plus the shared rescue name's freedom the input to
/// `decide(_:)`. `inspect(atBoardRoot:guideFilename:)` builds one of these per guide filename.
struct State: Equatable {
var existing: Existing
@@ -221,17 +264,22 @@ enum AgentGuide {
// MARK: - Reading the board root
/// Reads the state of the two claimed names. Purely a read it creates nothing, and it is
/// cheap enough (one `lstat`, plus a small file read only when there is a file to read) to run
/// on every reload.
/// Reads the state of one guide filename (`filename` or `agentsFilename`) plus the shared rescue
/// name's freedom. Purely a read it creates nothing, and it is cheap enough (one `lstat`, plus a
/// small file read only when there is a file to read) to run on every reload, for both names.
///
/// **A live disk read, not a memoized one** called once per guide filename, sequentially, inside
/// `install`, so a rescue the first call just performed (`filename` moved to `userFilename`) is
/// reflected in `userFilenameIsFree` by the time the second call inspects `agentsFilename`, with no
/// stale picture to reconcile.
///
/// **`lstat` semantics throughout, never `fileExists`** (`IntegrityRules.node(at:)`): a
/// **dangling** symlink is a node that is *there* it holds the name, and it is displaced as a
/// link rather than followed while `fileExists` follows the link, finds nothing, and would
/// call the name free.
static func inspect(atBoardRoot root: URL) -> State {
static func inspect(atBoardRoot root: URL, guideFilename: String) -> State {
State(
existing: existingNode(at: root.appendingPathComponent(filename)),
existing: existingNode(at: root.appendingPathComponent(guideFilename)),
userFilenameIsFree: IntegrityRules.node(at: root.appendingPathComponent(userFilename)) == nil
)
}
@@ -249,30 +297,56 @@ enum AgentGuide {
// MARK: - Writing it
/// Puts the current guide at the board root, first moving a displaced `CLAUDE.md` out of the way
/// when the decision called for it.
/// Puts the current guide at the board root **at both claimed names** (ruled 2026-08-09,
/// card dc1314bb) first moving a displaced file out of the way wherever a decision calls for it.
///
/// **Called inside `BoardStore.performWrite`**, so both halves ride one watcher bracket: the
/// rescue and the guide land as a single app-mediated reload rather than as two foreign-looking
/// events.
/// **One call, up to two independent decisions, one identical body.** `targetFilenames` is
/// processed in order, `filename` then `agentsFilename`: each gets its own `inspect` (a fresh disk
/// read) and its own `decide`, exactly as if the other name did not exist, and each that needs a
/// write gets the same `content` the twin promise is upheld by writing one literal to two paths,
/// never by copying one file onto the other. **The sequencing is what makes the shared rescue name
/// safe**: if both `CLAUDE.md` and `AGENTS.md` are markerless simultaneously, `filename`'s rescue
/// to `userFilename` runs first and actually lands on disk before `agentsFilename` is even
/// inspected, so its `decide` sees `userFilename` correctly as taken and falls to
/// `.skipUserFilenameTaken` never a lost race, because there is no concurrency here to race.
///
/// The move is `FileManager.moveItem` and nothing else: it preserves the bytes exactly the
/// displaced file may not even be UTF-8 and it **fails rather than overwrite** if
/// `CLAUDE.user.md` appeared between the decision and this call, which is what makes the
/// "user content is never destroyed" promise hold against a race rather than merely against a
/// stale read.
/// **Called inside `BoardStore.performWrite`**, so every half rides one watcher bracket: up to two
/// rescues and up to two guide writes land as a single app-mediated reload rather than as four
/// foreign-looking events.
///
/// **It re-verifies against disk** (01-storage-format.md § Validation and healing: "every
/// scheduled heal re-verifies its defect against disk at write time and no-ops when it is
/// gone"): the board root is re-inspected here, and a guide that has become current since the
/// decision an agent wrote it, another window healed it first returns `nil` rather than
/// rewriting a file that no longer needs it. Losing the race to a foreign fix is success.
/// Each move is `FileManager.moveItem` and nothing else: it preserves the bytes exactly the
/// displaced file may not even be UTF-8 and it **fails rather than overwrite** if `userFilename`
/// appeared between that file's decision and this call, which is what makes the "user content is
/// never destroyed" promise hold against a race rather than merely against a stale read.
///
/// - Returns: what this call displaced, or `nil` when it wrote nothing at all.
/// **Each write re-verifies against disk** (01-storage-format.md § Validation and healing: "every
/// scheduled heal re-verifies its defect against disk at write time and no-ops when it is gone"):
/// the board root is re-inspected per file, and a guide that has become current since its decision
/// an agent wrote it, another window healed it first contributes nothing to the result rather
/// than rewriting a file that no longer needs it. Losing the race to a foreign fix is success, and
/// the two files' races are independent: one can win while the other loses.
///
/// - Returns: what this call displaced, one entry per file that needed one empty when it wrote
/// nothing at all.
@discardableResult
static func install(atBoardRoot root: URL) throws(BoardWriteError) -> Displacement? {
let guideURL = root.appendingPathComponent(filename)
let decision = decide(inspect(atBoardRoot: root))
static func install(atBoardRoot root: URL) throws(BoardWriteError) -> [Displacement] {
var displacements: [Displacement] = []
for guideFilename in targetFilenames {
if let displaced = try installOne(guideFilename: guideFilename, atBoardRoot: root) {
displacements.append(displaced)
}
}
return displacements
}
/// One claimed name's whole install `install(atBoardRoot:)`'s per-file body, factored out so the
/// loop above is the only place that knows there are two.
private static func installOne(
guideFilename: String,
atBoardRoot root: URL
) throws(BoardWriteError) -> Displacement? {
let guideURL = root.appendingPathComponent(guideFilename)
let decision = decide(inspect(atBoardRoot: root, guideFilename: guideFilename))
var displaced: Displacement?
switch decision {
@@ -283,25 +357,25 @@ enum AgentGuide {
case .write:
break
case .displaceThenWrite:
// The rescue, not a squatter: a markerless `CLAUDE.md` is user *content*, and it has a
// designated destination (08-agent-integration.md Ownership).
// The rescue, not a squatter: markerless content on a claimed name is user *content*, and
// it has a designated destination (08-agent-integration.md Ownership).
do {
try FileManager.default.moveItem(at: guideURL, to: root.appendingPathComponent(userFilename))
} catch {
throw BoardWriteError(
operation: .agentGuide,
path: guideURL.path,
reason: .io(message: "could not move the existing \(filename) aside to \(userFilename): \(error.localizedDescription)")
reason: .io(message: "could not move the existing \(guideFilename) aside to \(userFilename): \(error.localizedDescription)")
)
}
EchoLedger.current?.recordMove(from: guideURL, to: root.appendingPathComponent(userFilename))
displaced = Displacement(name: filename, movedTo: userFilename, wasUserContent: true)
displaced = Displacement(name: guideFilename, movedTo: userFilename, wasUserContent: true)
case .displaceSquatterThenWrite:
// The claimed-name displacement (ruled 2026-07-29): a folder or symlink on the app's own
// name, moved aside by the Finder ladder and never destroyed.
guard let freed = try BoardWriter.displaceClaimedName(
ClaimedNameSquatter(
name: filename,
name: guideFilename,
found: IntegrityRules.node(at: guideURL) ?? .directory,
expected: .file
),
@@ -311,7 +385,7 @@ enum AgentGuide {
// anyway. Nothing displaced, nothing written.
return nil
}
displaced = Displacement(name: filename, movedTo: freed, wasUserContent: false)
displaced = Displacement(name: guideFilename, movedTo: freed, wasUserContent: false)
}
try BoardWriter.atomicReplace(text: content, at: guideURL, operation: .agentGuide)
@@ -327,14 +401,16 @@ enum AgentGuide {
/// owns is now under a different name and only the tone differs: the `CLAUDE.user.md` rescue
/// is the settled, silent ownership rule (08-agent-integration.md), while a squatter's
/// displacement gets the relocation-style warning-tone notice (01-storage-format.md § Fractal
/// layout Rules, ruled 2026-07-29).
/// layout Rules, ruled 2026-07-29). `name` names whichever claimed name `CLAUDE.md` or
/// `AGENTS.md` this particular displacement was about; a call to `install` that touches both can
/// produce two of these, one per name.
struct Displacement: Sendable, Equatable {
/// The claimed name that was freed.
let name: String
/// The name the displaced node now has.
let movedTo: String
/// Whether this was the markerless-`CLAUDE.md` rescue (silent) rather than a squatter's
/// displacement (announced).
/// Whether this was the markerless-guide rescue (silent) rather than a squatter's displacement
/// (announced).
let wasUserContent: Bool
}
@@ -352,7 +428,7 @@ enum AgentGuide {
/// The marker interpolates `version` rather than spelling the number twice: the constant and the
/// first line cannot drift apart, and a bump is one edit.
private static let guideBody = """
<!-- lanework-agent-guide v\(version) — created and kept up to date by the Lanework app. Don't edit this file: it is overwritten on upgrades. Board-specific instructions live in CLAUDE.user.md (see below), which the app never touches. -->
<!-- lanework-agent-guide v\(version) — created and kept up to date by the Lanework app. This guide is written at two names, CLAUDE.md and AGENTS.md, kept byte-identical. Don't edit either file: both are overwritten on upgrades. Board-specific instructions live in CLAUDE.user.md (see below), which the app never touches. -->
# This folder is a Lanework kanban board
@@ -370,12 +446,14 @@ enum AgentGuide {
<board>/ this folder (the board)
├── index.md board title + settings; body = board description
├── CLAUDE.md this guide (app-maintained)
├── AGENTS.md byte-identical twin of CLAUDE.md (app-maintained)
├── .trash/ deleted cards and lanes (app-managed — see Deleting)
├── <uuid>/ a LANE
│ ├── index.md lane title + order; body = lane notes/policy
│ ├── <uuid>/ a CARD
│ │ ├── index.md card title + order; body = the card's content
│ │ ── attachments/ the card's files (flat, top-level)
│ │ ── attachments/ the card's files (flat, top-level)
│ │ └── comments/ the card's comment thread (see Comments)
│ └── <uuid>/ another card
└── <uuid>/ another lane
```
@@ -444,8 +522,9 @@ enum AgentGuide {
custom metadata (`project:`, `tags:`, `claimed-by:` …) is safe to add and
survives every app rewrite. Reserved for Lanework's upcoming tracker sync —
preserved but not rendered, don't repurpose them: the card keys `labels`,
`assignees`, `due`, the `remote` key (cards and board), `remote-state`
(lanes), and a card-level `comments/` folder.
`assignees`, `due`, the `remote` key (cards and board), and `remote-state`
(lanes). `comments/` is **not** on this list — it's a shipped feature with
its own section below, not a reserved name.
## Stamping your work: `modified-by`
@@ -570,6 +649,37 @@ enum AgentGuide {
- Subfolders under `attachments/` are tolerated but the app never creates
or lists them — keep attachments top-level.
## Comments
Every card has a comment thread at `comments/` inside the card folder —
a lightweight, chronological log beside the card itself. The card body
is the spec; the thread is where work on it gets journaled (see Use the
thread below).
- A comment is `comments/<lowercase-uuid>/index.md`: frontmatter
`schema: 1`, `kind: comment`, `author`, `created`, `modified`. **No
`title`, no `order`.** A comment may carry its own `attachments/`,
one level down from the card's — the same rules as Attachments above
apply there too.
- **Chronology is the ordering**: the thread sorts by `created`
ascending, so the stamp *is* the position. Write real current UTC
(`date -u +%FT%TZ`); give a burst of several comments distinct
seconds — ties fall back to folder-name order.
- **`author` is self-reported and survives app writes** — the
deliberate contrast with `modified-by` above, which the app clears on
every write of its own. Write your name into `author` once; it
sticks through every later app-mediated rewrite of the card or board.
- **`comments/.draft` and `comments/.trash` are the app's** — the
unposted composer draft and the undo backing store. Never write into
either, and never "delete" a comment by moving it there yourself:
content no live undo step owns is swept as residue at the next
window open. To retract a comment, post a follow-up saying so — never
rewrite or remove an existing one.
- A comment counts as **edited** when `modified` differs from
`created` — that's the whole rule, no separate flag. Fixing a typo
is fine; once the conversation has moved past something you wrote,
post a follow-up instead of rewriting it.
## Hard rules (the app fails loudly on violations)
- Frontmatter must parse as YAML. The board's own `index.md` must carry
@@ -611,9 +721,40 @@ enum AgentGuide {
- **Commit your own changes, with clear messages** — nothing else will
commit them for you, and a semantic message ("Move card 'Fix login' to
Doing") is the history the user will actually read.
- **Leave the app-maintained files to the app** — this guide and the
seeded `.gitignore` are rewritten by Lanework when they need to be;
don't edit or delete them, and don't commit changes to the user's other
files that you didn't make.
- **Leave the app-maintained files to the app** — this guide (both
`CLAUDE.md` and its `AGENTS.md` twin) and the seeded `.gitignore` are
rewritten by Lanework when they need to be; don't edit or delete
them, and don't commit changes to the user's other files that you
didn't make.
## Use the thread: journal your work
The card's body is the spec; its comment thread (see Comments above)
is the journal. Edit the body when scope, constraints, or done-when
change. Everything else — progress, in-the-moment thinking, questions —
belongs in the thread.
- **Starting work on a card**: post a comment with your plan and every
decision already made, rejected alternatives included, written for a
reader with none of your context — the user checking in later,
another agent, or your own future session picking the card back up
cold.
- **While working**: record decisions as you make them, not
reconstructed afterward. Outcomes live elsewhere — commits, diffs —
so the thread's job is the *why*, the routes you didn't take, and
any limitation you knowingly accepted.
- **Questions**: post them as comments. The app narrates arrivals by
path shape ("Comment on 'card'"), so a posted question genuinely
reaches the user live. Answers come back as later comments —
**re-read the whole thread before resuming any card**, not just the
last entry.
- **Finishing**: close with verification evidence — what you ran, what
it showed — and name anything you did differently from what the
card asked.
- **Comments never stamp the card**: posting one leaves the card's own
`modified` and `modified-by` alone, the same as the app's own posts.
Per-board process — lane cadence, commit conventions, anything specific
to this board — belongs in `CLAUDE.user.md`, not here.
"""
}
+9 -3
View File
@@ -215,9 +215,15 @@ public enum IntegrityRules: Sendable {
/// `CLAUDE.md`'s squatter is displaced by the **agent guide's** own heal, which already owns
/// that file's whole decision (`AgentGuide.Decision.displaceSquatterThenWrite`); `.trash`'s is
/// its own scheduled heal, because nothing else ever writes that name.
///
/// **`AGENTS.md` joined 2026-08-09** (card dc1314bb, 08-agent-integration.md The agent guide):
/// the byte-identical twin of `CLAUDE.md`, claimed and displaced on exactly the same terms its
/// squatter is the agent guide's own heal too (`AgentGuide.install` runs the same decision against
/// both names), so it is not answered by `squattedClaimedName(atBoardRoot:)` below either.
public static let claimedRootNames: [ClaimedName] = [
ClaimedName(name: trashFolderName, expected: .directory, displacesSquatters: true),
ClaimedName(name: "CLAUDE.md", expected: .file, displacesSquatters: true),
ClaimedName(name: "AGENTS.md", expected: .file, displacesSquatters: true),
ClaimedName(name: "CLAUDE.user.md", expected: .file, displacesSquatters: false),
ClaimedName(name: gitignoreFileName, expected: .file, displacesSquatters: false),
]
@@ -291,9 +297,9 @@ public enum IntegrityRules: Sendable {
/// (01-storage-format.md § Fractal layout Rules, ruled 2026-07-29).
///
/// Only names whose `displacesSquatters` is `true` can produce one, and only `.trash` is
/// answered here: `CLAUDE.md`'s squatter is the agent guide's, detected by `AgentGuide.inspect`
/// in the same read that decides everything else about that file, so answering it twice would be
/// two mechanisms racing to displace one node.
/// answered here: `CLAUDE.md` and its `AGENTS.md` twin's squatters are the agent guide's,
/// detected by `AgentGuide.inspect` in the same read that decides everything else about each
/// file, so answering either here too would be two mechanisms racing to displace one node.
public static func squattedClaimedName(atBoardRoot root: URL) -> ClaimedNameSquatter? {
guard let claimed = claimedRootNames.first(where: { $0.name == trashFolderName }),
let found = node(at: root.appendingPathComponent(claimed.name)),
+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"))