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
761 lines
45 KiB
Swift
761 lines
45 KiB
Swift
import Foundation
|
||
|
||
/// The board-embedded agent guide: a `CLAUDE.md` the app silently maintains at every board root,
|
||
/// teaching any file-capable agent how to read and write the board directly (08-agent-integration.md
|
||
/// ▸ The agent guide). The folder is the API, and this is the file an agent working inside the
|
||
/// folder will actually find.
|
||
///
|
||
/// **App-owned and version-gated.** The first line carries `lanework-agent-guide vN`. The guide is
|
||
/// rewritten when it is **missing or older**, and left **byte-for-byte untouched** when it is
|
||
/// current or newer — "never downgraded" (08): a newer app version may have written it, and an
|
||
/// older copy of the app opening the board must not walk it back. Untouched means untouched: a
|
||
/// current guide is never reopened for writing, so its mtime — and, on git boards, the tree — is
|
||
/// undisturbed by an open.
|
||
///
|
||
/// **The marker is read from the first line and nowhere else**, which is 08's own wording and a
|
||
/// deliberate divergence from the pathfinder's anywhere-in-the-file match: a user's own `CLAUDE.md`
|
||
/// that merely *quotes* the marker (a note about this feature, a pasted excerpt) would otherwise
|
||
/// read as an app-owned guide and be silently overwritten.
|
||
///
|
||
/// **A markerless `CLAUDE.md` is displaced, never clobbered** (08 ▸ Ownership). It is user content
|
||
/// sitting on a name the app claims, so it moves — byte-preserving, `FileManager.moveItem` — to
|
||
/// `CLAUDE.user.md` when that name is free, and the guide is written in its place. When the name is
|
||
/// taken the guide write is **skipped entirely**: user content is never destroyed, and a board
|
||
/// without a guide is a board that merely lacks a courtesy. `CLAUDE.user.md` is otherwise never
|
||
/// written, read, upgraded or validated by this app — that one rescue is its only creation
|
||
/// (08 ▸ `CLAUDE.user.md`).
|
||
///
|
||
/// **A folder or symlink squatting `CLAUDE.md` is displaced too** (ruled 2026-07-29 — the
|
||
/// claimed-names rule, 01-storage-format.md § Fractal layout ▸ Rules): Lanework owns the board, so
|
||
/// an invalid artifact on a name the app claims is a defect rather than a resident. It moves aside
|
||
/// by the Finder-style rename ladder (`CLAUDE.md` → `CLAUDE.md 2`) — preserved verbatim, a symlink
|
||
/// moved as a link and never followed — and the guide is written on the freed name, with a
|
||
/// warning-tone notice naming old and new. This replaced an untouchable-skip; what survives from it
|
||
/// is displacement-never-destruction.
|
||
///
|
||
/// **Nothing here is a user-facing event.** Every refusal below is a log line and nothing more; the
|
||
/// only thing that can reach a banner is a genuine I/O failure of the write itself, because
|
||
/// `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 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` **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
|
||
/// rewrite's guide (lanes, `.trash/`, `attachments/`, `modified-by`, the `CLAUDE.user.md`
|
||
/// pointer) and supersedes it on the next open. v6 adds the one-folder-at-a-time move warning:
|
||
/// a real agent incident (2026-07-29) showed `mv <lane>/*` sweeping the lane's own `index.md`
|
||
/// along with the cards and destroying the destination lane's identity — the guide now says
|
||
/// *why* the named-folder form is load-bearing, not just what to type. v7 teaches two more
|
||
/// things settled after v6 shipped: the refined stamp-discipline predicate
|
||
/// (01-storage-format.md ▸ `modified`'s scope, ruled 2026-07-29, refined 2026-07-30) — a
|
||
/// within-container reorder rewrites only `order`, while a move that changes an item's
|
||
/// container (another lane, another board, into or out of `.trash/`) stamps `modified` and
|
||
/// `modified-by` like any content edit, the trash move included, since it's the same rule and
|
||
/// not a special case — and the card-level `attachments` claimed name
|
||
/// (01-storage-format.md § Fractal layout ▸ Rules, "level-uniform"): that name belongs to the
|
||
/// app's own folder, so a *file* by that name is a defect the app displaces on sight. **v8
|
||
/// retires the trash's arrival rank** (01-storage-format.md § Deletion and 03-board-ui.md ▸
|
||
/// Trash, re-ruled 2026-07-31; 08-agent-integration.md's own line): the trash sorts by `modified`
|
||
/// descending, so there is no rank to mint on the way in — the guide's smallest-`order`-minus-1024
|
||
/// formula is replaced by "restamp `modified`, leave `order` alone", which is the same stamp
|
||
/// discipline v7 already taught, now doing the ordering as well. **v9 teaches the one-line
|
||
/// value**, from a real agent incident (2026-07-31): a card's `title` was a double-quoted
|
||
/// scalar wrapped across two lines, and a hand copy of that card onto another board took the
|
||
/// first line without its continuation. An unclosed quote does not stop at the key it began
|
||
/// on — it runs to the end of the block — so a board failed to load over a file whose only
|
||
/// defect was a missing second line, and the parser's complaint pointed at the *last* key it
|
||
/// swallowed rather than the title. The guide now teaches long values as one long line, says
|
||
/// why the wrapped shape is the dangerous one to copy, and names the unterminated quote beside
|
||
/// the unquoted colon in Hard rules. **v10 makes `order` and `schema` optional below the board
|
||
/// root** (01-storage-format.md § Frontmatter and § Ordering, re-ruled 2026-07-31): the guide's
|
||
/// whole point is that filing a card must need nothing but the schema
|
||
/// (08-agent-integration.md's masterplan requirement), and until now that was untrue — a card
|
||
/// needed a rank, and a rank needed a scan of every sibling in the lane. The guide now teaches
|
||
/// the zero-read minimum (`mkdir` plus one `index.md`, no `order`, no `schema`; it lands at the
|
||
/// lane's bottom and the app stamps a real rank on its first touch) while still teaching
|
||
/// *writing* `order` as the way to control position, which is the only way to control it.
|
||
/// **v11 rewrites Git for the excision** (strategy/01-git-excision.md, ruled 2026-08-08):
|
||
/// app-managed git is gone — the app never runs git, and the auto-commit the old section
|
||
/// promised no longer exists, so shipping v10's text would document machinery to agents that
|
||
/// isn't there. The section now teaches repo-resident etiquette alone — the format stays
|
||
/// deliberately git-friendly, a board may live in a repository of the user's own, and there an
|
||
/// agent stages only its own paths, commits its own changes with clear messages, and leaves the
|
||
/// app-maintained files to the app. The stamping section drops its auto-commit clause the same
|
||
/// way. **v12 names the lane's `collapsed` key** (03-board-ui.md § Lane ▸ Collapsed lanes): a lane
|
||
/// folded to a slim strip is document state exactly like `width`, so an agent can fold and unfold
|
||
/// lanes by editing frontmatter — and, more to the point, has to know that `collapsed: true` is why
|
||
/// a lane it wrote a card into is not showing it. One clause beside `width` in Frontmatter, with the
|
||
/// remove-to-expand rule stated because writing `false` is the mistake the key invites.
|
||
/// **v13 names the card's `hero` key** (03-board-ui.md § Card face ▸ Hero image): a card face draws
|
||
/// one of the card's own attachments as a banner, and since there is no in-app setter this version,
|
||
/// 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 `` into a
|
||
/// 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
|
||
|
||
private static let markerPrefix = "lanework-agent-guide v"
|
||
|
||
/// The version stamped into `text`'s **first line**, or `nil` when that line carries no marker —
|
||
/// which is how a foreign, user-authored `CLAUDE.md` is recognized (there is no "version 0": a
|
||
/// markerless file is not an old guide, it is somebody else's file).
|
||
///
|
||
/// Parsed rather than matched with a `Regex`: `Regex` is not `Sendable`, so a stored pattern
|
||
/// would have to be rebuilt on every call (the pathfinder's trick), and the grammar here — a
|
||
/// literal prefix and the ASCII digits after it — is smaller than the machinery to match it.
|
||
/// Both line endings work by construction, since the first line ends at the first newline
|
||
/// scalar of either kind.
|
||
static func installedVersion(of text: String) -> Int? {
|
||
let firstLine = text.prefix { !$0.isNewline }
|
||
guard let marker = firstLine.range(of: markerPrefix) else { return nil }
|
||
return Int(firstLine[marker.upperBound...].prefix { $0.isASCII && $0.isNumber })
|
||
}
|
||
|
||
// MARK: - The decision
|
||
|
||
/// 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
|
||
|
||
/// A regular file. `text` is its strict UTF-8 decoding, `nil` when it does not decode:
|
||
/// reads are strict everywhere in this app (01-storage-format.md § Encoding), and a file
|
||
/// the app cannot read is a file whose marker it cannot honestly claim to have checked.
|
||
case file(text: String?)
|
||
|
||
/// A symlink, a directory, or any other node that is not a regular file — **a squatter on a
|
||
/// claimed name**, and since 2026-07-29 not a resident (01-storage-format.md § Fractal
|
||
/// layout ▸ Rules: "Lanework owns the board, so an invalid artifact on a claimed name is a
|
||
/// defect, not a resident").
|
||
///
|
||
/// It is moved aside by the Finder-style rename ladder (`CLAUDE.md` → `CLAUDE.md 2`),
|
||
/// **preserved verbatim, never destroyed** — a symlink moved as a link, never followed —
|
||
/// and the guide is then written on the freed name. This case used to mean "skipped"; the
|
||
/// ruling upgraded the skip to a displacement, and the invariant that survives is
|
||
/// displacement-never-destruction.
|
||
case squatted
|
||
}
|
||
|
||
/// 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
|
||
|
||
/// Whether `CLAUDE.user.md` is free — **no file, no folder, no symlink** of that name. The
|
||
/// rescue move re-checks this atomically anyway (`FileManager.moveItem` fails rather than
|
||
/// overwrite), so this is the decision's input, not its safety.
|
||
var userFilenameIsFree: Bool
|
||
|
||
/// This picture as the heal engine's comparable value (`HealScheduler`'s memo unit).
|
||
///
|
||
/// The file's *text* is hashed rather than carried: two states are the same picture exactly
|
||
/// when the same bytes are on the same name, and a memo holding a whole guide's prose for the
|
||
/// life of a session would be the one place in this store that grows with a file's size.
|
||
var signature: String {
|
||
let existing = switch existing {
|
||
case .missing: "missing"
|
||
case .squatted: "squatted"
|
||
case let .file(text): "file:\(text.map(EchoLedger.hash(of:)) ?? "undecodable")"
|
||
}
|
||
return "guide:\(existing):\(userFilenameIsFree)"
|
||
}
|
||
}
|
||
|
||
/// The four outcomes, and the only four.
|
||
enum Decision: Equatable {
|
||
/// The guide on disk is current or newer. Nothing is opened for writing.
|
||
case leaveAlone
|
||
|
||
/// Missing, or an older marker: write the guide.
|
||
case write
|
||
|
||
/// A markerless `CLAUDE.md`: rescue it to `CLAUDE.user.md`, then write the guide.
|
||
case displaceThenWrite
|
||
|
||
/// A markerless `CLAUDE.md` with `CLAUDE.user.md` already taken — the ruling's
|
||
/// skipped-with-a-log case. Two files the user owns, both left alone.
|
||
///
|
||
/// **The one standing exception to the squatter displacement, and it stands** (ruled
|
||
/// 2026-07-29): this displacement has a designated *destination*, and freeing a destination
|
||
/// by a second displacement would cascade renames.
|
||
case skipUserFilenameTaken
|
||
|
||
/// `CLAUDE.md` is a symlink, a folder, or some other non-file: move it aside by the
|
||
/// Finder-style rename ladder, then write the guide on the freed name (ruled 2026-07-29 —
|
||
/// the claimed-name squatter rule; it replaced a skip).
|
||
case displaceSquatterThenWrite
|
||
}
|
||
|
||
/// The whole rule, as a pure function of `state` — so "never downgrade", "never clobber" and
|
||
/// "never touch a symlink" are pinned by the suite without a filesystem in the way.
|
||
///
|
||
/// One edge worth naming rather than special-casing: a **zero-byte** `CLAUDE.md` is markerless,
|
||
/// so it takes the displacement path like any other foreign file. Uniformity is the point —
|
||
/// every rule that decides whether to destroy something answers "no" the same way.
|
||
static func decide(_ state: State) -> Decision {
|
||
switch state.existing {
|
||
case .missing:
|
||
.write
|
||
case .squatted:
|
||
.displaceSquatterThenWrite
|
||
case let .file(text):
|
||
if let text, let installed = installedVersion(of: text) {
|
||
installed >= version ? .leaveAlone : .write
|
||
} else {
|
||
state.userFilenameIsFree ? .displaceThenWrite : .skipUserFilenameTaken
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Reading the board root
|
||
|
||
/// 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, guideFilename: String) -> State {
|
||
State(
|
||
existing: existingNode(at: root.appendingPathComponent(guideFilename)),
|
||
userFilenameIsFree: IntegrityRules.node(at: root.appendingPathComponent(userFilename)) == nil
|
||
)
|
||
}
|
||
|
||
private static func existingNode(at url: URL) -> Existing {
|
||
guard let node = IntegrityRules.node(at: url) else { return .missing }
|
||
guard node == .file else { return .squatted }
|
||
// A regular file whose *contents* cannot be read reads as undecodable rather than as
|
||
// missing, and the difference is the whole promise: `.missing` would overwrite it, while
|
||
// undecodable displaces it — and the rescue move needs no read permission on the file to
|
||
// preserve it byte for byte.
|
||
guard let data = try? Data(contentsOf: url) else { return .file(text: nil) }
|
||
return .file(text: String(data: data, encoding: .utf8))
|
||
}
|
||
|
||
// MARK: - Writing 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.
|
||
///
|
||
/// **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.
|
||
///
|
||
/// **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.
|
||
///
|
||
/// 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.
|
||
///
|
||
/// **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] {
|
||
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 {
|
||
case .leaveAlone, .skipUserFilenameTaken:
|
||
// Nothing to write: either the defect healed itself under us, or the standing exception
|
||
// applies and both of the user's files stay exactly where they are.
|
||
return nil
|
||
case .write:
|
||
break
|
||
case .displaceThenWrite:
|
||
// 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 \(guideFilename) aside to \(userFilename): \(error.localizedDescription)")
|
||
)
|
||
}
|
||
EchoLedger.current?.recordMove(from: guideURL, to: root.appendingPathComponent(userFilename))
|
||
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: guideFilename,
|
||
found: IntegrityRules.node(at: guideURL) ?? .directory,
|
||
expected: .file
|
||
),
|
||
atBoardRoot: root
|
||
) else {
|
||
// Gone under us — re-decide rather than write blind, which the next reload does
|
||
// anyway. Nothing displaced, nothing written.
|
||
return nil
|
||
}
|
||
displaced = Displacement(name: guideFilename, movedTo: freed, wasUserContent: false)
|
||
}
|
||
|
||
try BoardWriter.atomicReplace(text: content, at: guideURL, operation: .agentGuide)
|
||
// Heal-marked: the guide's refresh is app-initiated work, and the ledger records it as the
|
||
// heal it is rather than as anyone's edit.
|
||
EchoLedger.current?.markHeal(at: guideURL)
|
||
return displaced
|
||
}
|
||
|
||
/// What an install moved out of the way, for the notice that names old and new.
|
||
///
|
||
/// Two shapes ride one type because the *user-facing* fact is the same in both — a file the user
|
||
/// 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). `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-guide rescue (silent) rather than a squatter's displacement
|
||
/// (announced).
|
||
let wasUserContent: Bool
|
||
}
|
||
|
||
// MARK: - The guide itself
|
||
|
||
/// The bytes written to `CLAUDE.md`: the prose below plus the closing newline a multi-line
|
||
/// literal does not carry. Files the app creates end with LF (01-storage-format.md § Encoding
|
||
/// and line endings), and this one is no exception for being prose.
|
||
static let content = guideBody + "\n"
|
||
|
||
/// **The one swappable string.** Its wording is a separate concern from this file's mechanism —
|
||
/// what the guide must teach is 08-agent-integration.md ▸ The agent guide's list, and revising
|
||
/// it is a `version` bump plus a new literal here, with nothing else to change.
|
||
///
|
||
/// 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. 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
|
||
|
||
Plain folders and Markdown, rendered live by the Lanework app. You can (and
|
||
should) manipulate the board by editing files directly — while the board is
|
||
open, the app picks up every filesystem change automatically. There is
|
||
nothing to sync and no API to call: the files are the board.
|
||
|
||
**If a `CLAUDE.user.md` exists next to this file, read it too** — it carries
|
||
board-specific instructions from the board's owner.
|
||
|
||
## Layout
|
||
|
||
```
|
||
<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)
|
||
│ │ └── comments/ the card's comment thread (see Comments)
|
||
│ └── <uuid>/ another card
|
||
└── <uuid>/ another lane
|
||
```
|
||
|
||
- Depth alone defines meaning: depth 1 = lane, depth 2 = card. There is no
|
||
type field.
|
||
- Folder names are lowercase UUIDs and are the item's permanent identity.
|
||
**Never rename a folder.** Titles live in frontmatter only.
|
||
- Every `index.md` is YAML frontmatter between `---` lines, then a Markdown
|
||
body. Files are plain UTF-8, **no BOM**; keep each file's existing line
|
||
endings, and end new files with LF.
|
||
|
||
## Reading the board
|
||
|
||
- Lanes run left→right by ascending `order`; cards top→bottom by ascending
|
||
`order` within their lane. Ties break by folder name. An item with no
|
||
`order` sorts after every item that has one — see Creating a card.
|
||
- Lane titles carry the workflow semantics (e.g. To Do → In Progress →
|
||
Done). Read the board's and lanes' index.md bodies for descriptions and
|
||
per-lane policy before deciding where a card belongs.
|
||
- `.trash/` holds deleted cards and lanes; everything else at board root that isn't a
|
||
UUID-named folder is not part of the board's content.
|
||
|
||
## Frontmatter
|
||
|
||
All levels: `schema` (always `1`; **required at the board's own `index.md`**,
|
||
optional below it — a lane or card without one is read as schema 1), `title`
|
||
(optional — an item without one renders as untitled, so give cards real
|
||
titles), `created` and `modified` (ISO-8601 with timezone, e.g.
|
||
`2026-07-24T18:00:00Z`), `background` (color), `icon` (SF Symbol name),
|
||
`iconColor` (color, tints `icon`). Lanes and cards may set `order` (a number;
|
||
floats are fine) — **optional, and the way to control position**: an item
|
||
without one goes last. Lanes may set `width` (integer ≥ 1, multiplier of the
|
||
standard lane width) and `collapsed` (`true` folds the lane to a slim strip
|
||
in the app; its cards are still there, just not drawn). **To expand a lane,
|
||
remove the `collapsed` key** rather than writing `collapsed: false` — an
|
||
absent key is the default, and the app removes it too. A lane's `width`
|
||
rides along untouched while it is folded.
|
||
|
||
Cards may set `hero` — **the bare filename of one of that card's own
|
||
attachments** (`hero: sketch.png`), which the app draws as a banner across
|
||
the top of the card's face. **A filename, never a path**: `attachments/` is
|
||
implied, so `hero: attachments/sketch.png` and any other value containing a
|
||
`/` name nothing and draw nothing. The file has to sit directly in that
|
||
card's `attachments/` folder (Attachments below); a name that is missing,
|
||
unreadable or not an image draws no banner and is otherwise harmless, so a
|
||
hero set before the file arrives simply starts working when it does. There
|
||
is no control for this in the app — writing the key is how a hero gets set.
|
||
|
||
**Quote any `title` containing a colon** — `title: Fix: the thing` is
|
||
invalid YAML; write `title: "Fix: the thing"`. The same goes for any value
|
||
containing `: ` or starting with `#`, `[`, `{`, or a quote — when in doubt,
|
||
double-quote.
|
||
|
||
**Keep every value on one line.** A double-quoted scalar may legally
|
||
continue on the following line (`title: "Long title` then ` the rest"`),
|
||
and some tools emit that shape for long titles — but it is the most common
|
||
way frontmatter gets broken by hand: the continuation reads as a line of
|
||
its own, so an edit or a copy that takes only the first one leaves the
|
||
quote unclosed, and an unclosed quote runs on to swallow every key below
|
||
it. The whole file then fails to parse, not just the title. Titles have no
|
||
length limit — write a long one as one long line, and when you copy a card
|
||
between boards, copy its frontmatter block whole.
|
||
|
||
Unknown keys are preserved verbatim by the app and invisible in its UI —
|
||
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), 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`
|
||
|
||
Add `modified-by: <your-name>` (e.g. `modified-by: claude`) to the
|
||
frontmatter of every `index.md` you write — it attributes the change in the
|
||
app. The app clears the stamp on its own writes, so **re-stamp on every
|
||
write, and after every move**: a bare folder move rewrites no file, so the
|
||
moved card arrives unstamped unless you touch its `index.md` again. On a
|
||
board that lives in a git repository of the user's own, committing your
|
||
changes yourself (see Git below) records exact authorship as well.
|
||
|
||
## Creating a card
|
||
|
||
1. Pick the lane folder.
|
||
2. Create a folder named a fresh lowercase UUID:
|
||
`id=$(uuidgen | tr 'A-Z' 'a-z')`.
|
||
3. Write `<lane>/$id/index.md` (timestamp: `date -u +%FT%TZ`):
|
||
|
||
```markdown
|
||
---
|
||
schema: 1
|
||
kind: card
|
||
title: Short imperative card title
|
||
order: 3072
|
||
created: 2026-07-24T18:00:00Z
|
||
modified: 2026-07-24T18:00:00Z
|
||
modified-by: claude
|
||
---
|
||
The card's content — any Markdown.
|
||
```
|
||
|
||
**`order` is what places the card, and computing it means reading the
|
||
lane**: bottom of the lane = max existing card `order` + 1024; top =
|
||
min − 1024; between two cards = their midpoint. (Empty lane: any number,
|
||
conventionally 1024.) Write it whenever the position matters.
|
||
|
||
**You can also file a card without reading the lane at all.** The minimum
|
||
legal card is a `mkdir` and one `index.md` containing nothing but a title —
|
||
no `order`, no `schema`:
|
||
|
||
```markdown
|
||
---
|
||
title: Short imperative card title
|
||
---
|
||
The card's content.
|
||
```
|
||
|
||
It lands at the bottom of the lane (an item with no `order` sorts after
|
||
every item that has one; two such items sort by folder name), and the app
|
||
writes a real `order` into it the next time it rewrites that file. Prefer
|
||
the full frontmatter above — `kind`, the timestamps and `modified-by` are
|
||
all worth having — but when you are filing into a 200-card lane and the
|
||
position doesn't matter, the short form costs one write and no reads.
|
||
|
||
Creating a lane is the same one level up (body optional; `kind: lane`;
|
||
`order` ranks lanes left→right, and is optional in the same way).
|
||
|
||
**Always write `kind`** at creation — `kind: card`, `kind: lane`,
|
||
`kind: board` at board root. Depth already says what an item is on the
|
||
board, but `.trash/` is flat, and there the value is the only thing that
|
||
tells a trashed lane from a card. Omitting it is healable, never fatal: the
|
||
app fills a missing `kind` in the next time it rewrites that file.
|
||
|
||
## Moving and reordering
|
||
|
||
- **The stamp rule**: a move that changes an item's container — another
|
||
lane, another board, or into/out of `.trash/` — stamps `modified` and
|
||
re-stamps `modified-by`, the same as any content edit. A reorder that
|
||
keeps an item in the same container (a card among its lane's cards, a
|
||
lane among the board's) rewrites only `order`; leave `modified` and
|
||
`modified-by` alone. The trash move isn't an exception to this — it
|
||
stamps because every container change stamps.
|
||
- Move to another lane: `mv <laneA>/<card-uuid> <laneB>/` — the folder move
|
||
IS the move. Then set the card's `order` to place it among the
|
||
destination's cards, update `modified`, and re-stamp `modified-by`.
|
||
- Move folders **one at a time, by name** — never `mv <laneA>/* <laneB>/`.
|
||
A lane folder holds its own `index.md` beside its cards, so a glob
|
||
sweeps the lane's identity file along with them and overwrites the
|
||
destination lane's.
|
||
- Reorder within a lane: rewrite only that card's `order` — don't touch
|
||
`modified` or `modified-by`.
|
||
|
||
## Editing and deleting
|
||
|
||
- Edit bodies freely; update `modified` on every write. Preserve frontmatter
|
||
keys you don't recognize and don't reformat content you didn't change.
|
||
- **Delete a card or a lane = move its folder into `<board>/.trash/`**:
|
||
`mv <lane>/<card-uuid> <board>/.trash/`, or `mv <lane-uuid>
|
||
<board>/.trash/` for a whole lane (create `.trash/` if missing). A lane
|
||
travels with its cards inside it. It's a container change like any other
|
||
move (Moving and reordering above): stamp `modified` and re-stamp
|
||
`modified-by` — and here the stamp is also the position. **The trash
|
||
sorts by `modified`, newest first**, so a restamped arrival lands on top;
|
||
there is no rank to mint, and you should leave `order` exactly as it is —
|
||
it rides along for the restore. Restore is the same move in reverse — a
|
||
card into a lane, a lane back to board root, with a fresh `order`,
|
||
stamped the same way.
|
||
- **Stamp `kind: lane` when you trash a lane that lacks it.** `.trash/` is
|
||
flat, so an empty lane folder looks exactly like a card folder; the `kind`
|
||
value is what tells them apart in there.
|
||
- Never write a `deleted:` key — that convention is retired; the app
|
||
migrates any it finds.
|
||
- Remove a folder outright (`rm -r`) only when you mean permanent,
|
||
unrecoverable deletion — the trash is the recoverable path for both
|
||
cards and lanes.
|
||
|
||
## Attachments
|
||
|
||
- A card's files live in `attachments/` inside the card folder, flat at its
|
||
top level. **Put files there, never beside `index.md`** — the app
|
||
relocates loose files into `attachments/` and tells the user it did.
|
||
The name `attachments` itself belongs to that folder — never create a
|
||
*file* called `attachments` in a card; the app treats one as a defect
|
||
and displaces it on sight.
|
||
- To attach a file: create `attachments/` if missing and copy the file in.
|
||
If the name is taken, pick a free one Finder-style (`shot.png` →
|
||
`shot 2.png`) — never overwrite.
|
||
- Reference attachments from the card body by relative path:
|
||
``. To put that same picture on the card's
|
||
face, add `hero: sketch.png` to the frontmatter — the bare name, without
|
||
the folder (Frontmatter above).
|
||
- 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
|
||
`schema: 1`; everywhere else `schema` and `order` are optional and a
|
||
missing one is read, never refused. Never write a `schema` other than
|
||
`1`. The classic violations are an unquoted colon in a title and a
|
||
quoted value left unclosed across a line break (see Frontmatter above).
|
||
- Files must be UTF-8 without BOM.
|
||
- Never create a card folder without an `index.md`.
|
||
- Never rename UUID folders.
|
||
- Prefer atomic writes (write a temp file, then rename over the target) —
|
||
the app reloads on every filesystem event and can catch half-written
|
||
files.
|
||
|
||
## Colors and icons
|
||
|
||
`background` takes a palette name (preferred) or `#RRGGBB[AA]` hex. `icon`
|
||
is any SF Symbol name; `iconColor` takes a palette name (preferred) or hex
|
||
to tint it.
|
||
|
||
- Icon tint palette: `obsidian`, `aluminum`, `soapstone`, `chalk`,
|
||
`carnation`, `rich-grapefruit`, `smokey-tangerine`, `fern`,
|
||
`light-teal`, `deep-sky-blue`, `pale-violet`, `deep-cool-granite`.
|
||
- Background palette: `obsidian`, `shale`, `aluminum`, `chalk`,
|
||
`light-cayenne`, `light-mocha`, `smokey-mocha`, `smokey-fern`,
|
||
`dark-teal`, `smokey-ocean`, `smokey-rich-eggplant`,
|
||
`intense-cool-shale`.
|
||
|
||
## Git
|
||
|
||
Lanework itself never runs git: the app manages no repository, makes no
|
||
commits, and never reads `.git`. But the format is deliberately
|
||
git-friendly — one file per card, stable UUID folder names, byte-faithful
|
||
rewrites — and a board may live inside a repository of the user's own.
|
||
When it does:
|
||
|
||
- **Stage only your own paths** — never `git add -A` or `git add .`: a
|
||
sweep would commit the user's not-yet-committed changes under your name.
|
||
- **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 (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.
|
||
"""
|
||
}
|