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
+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)),