Build AgentGuide — versioned CLAUDE.md maintenance
The app-owned agent guide at every board root (DESIGN/08 ▸ The agent guide): version-gated by a first-line marker (v5, superseding the pathfinder's v4 guides on real boards), rewritten when missing or older, byte-for-byte untouched when current or newer. A markerless CLAUDE.md is displaced to CLAUDE.user.md when that name is free — never clobbered — and the guide write is skipped with a log when it isn't. Symlinks, folders, and read-only volumes are skipped in silence; the write rides performWrite's bracket as an app-mediated Writer operation (new WriteOperation.agentGuide), so the echo lands appMediated and the Pro-era committer can attribute it honestly later. Hooked at store acquire (beside the loose-file relocation, after the watcher exists) and on every successful reload — the guide self-heals from foreign deletion or rollback, pre-wiring 06's acknowledged undo bounce. The refresh memo arms before each attempt and clears on a successful write, so a failing write can't hot-loop and a foreign deletion stays healable. First-line-only marker parsing (no Regex); guide content is one swappable literal, finalized under the next card. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -697,6 +697,13 @@ public final class BannerCenter {
|
||||
// failure the user did not provoke is exactly the one they have no other way to learn
|
||||
// about.
|
||||
"Couldn't move '\(filename)' into attachments"
|
||||
case .agentGuide:
|
||||
// **"the agent guide", not "CLAUDE.md"**: the file is the app's, written for agents, and
|
||||
// most users will never have opened it — a filename here would name something they have
|
||||
// no relationship with. It says nothing about the board's own files because none were
|
||||
// touched, and nothing is lost: the board works exactly as well without the guide, which
|
||||
// is why every *refusal* to write it is a log line and only a real I/O failure gets here.
|
||||
"Couldn't update the agent guide"
|
||||
case let .toggleTask(title):
|
||||
// The user's word for it, not the file's: they ticked a box. The card is named where
|
||||
// the read that preceded the flip learned its title, so a body write that refused says
|
||||
|
||||
@@ -442,6 +442,14 @@ public final class BoardStore {
|
||||
@ObservationIgnored
|
||||
private var attemptedTombstoneMigration: Set<String> = []
|
||||
|
||||
/// The board root's agent-guide picture the last refresh acted on — the third of the same loop
|
||||
/// guard, documented at `refreshAgentGuide()`. `nil` means "nothing has been acted on against
|
||||
/// the current picture", which is both the opening state and what a board whose guide is already
|
||||
/// current resets it to (so the state it holds is never a guide's own text for longer than the
|
||||
/// one reload that wrote it).
|
||||
@ObservationIgnored
|
||||
private var attemptedGuideRefresh: AgentGuide.State?
|
||||
|
||||
/// Awaited off the main actor **after** a tree walk finishes and **before** its result is
|
||||
/// applied — the one seam this type keeps, `nil` in production.
|
||||
///
|
||||
@@ -647,8 +655,15 @@ public final class BoardStore {
|
||||
// share one bracket-per-call posture, so neither can see the other's work half-done —
|
||||
// each opens its own bracket and each is re-armed by the reload the other's write
|
||||
// produces.
|
||||
//
|
||||
// The agent guide joins them last, and is the one of the three that runs on *every*
|
||||
// board rather than only on one an older version or an outside writer left work in: it
|
||||
// re-checks a single board-root file and writes only when the version marker says to
|
||||
// (08-agent-integration.md ▸ The agent guide). Running it here rather than at open alone
|
||||
// is what makes it self-healing — see `refreshAgentGuide()`.
|
||||
relocateLooseCardFiles()
|
||||
migrateLegacyTombstones()
|
||||
refreshAgentGuide()
|
||||
|
||||
case let .failure(error):
|
||||
// `snapshot`, `loadWarnings` and the transient state are untouched: a failed reload
|
||||
@@ -3305,6 +3320,101 @@ public final class BoardStore {
|
||||
.map(\.element)
|
||||
}
|
||||
|
||||
// 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.
|
||||
///
|
||||
/// **Run on every successful reload**, beside the relocation and the tombstone migration, and
|
||||
/// once more at open (`BoardStoreRegistry.acquire`, which fires it after the watcher and the
|
||||
/// brackets exist, for the reason stated there). 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.
|
||||
/// 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 read-only board is skipped, never banner-ed
|
||||
///
|
||||
/// Two gates, because two different things can be true. `performWrite` would refuse under the
|
||||
/// read-only lock on its own, but that refusal is a thrown error and this is not a gesture — so
|
||||
/// the lock is checked first, the relocation's own deferral idiom. The writability probe beside
|
||||
/// it covers the case the lock does not: 02-architecture.md's open-time unwritable-root lock is a
|
||||
/// separate card, and until it lands a board on a read-only volume would reach the Writer, fail,
|
||||
/// and post a banner about a file the user never asked for. 02 settles that exact case the other
|
||||
/// way — "the open-time agent-guide write is skipped-with-log, the `CLAUDE.user.md`-taken
|
||||
/// precedent" — so it is skipped with a log.
|
||||
///
|
||||
/// ### It cannot hot-loop
|
||||
///
|
||||
/// `performWrite`'s bracket schedules a reload whether or not the write succeeded, and this runs
|
||||
/// on every reload — so a *failing* guide write would retry forever at the speed of the debounce,
|
||||
/// posting a banner row each time. The guard is the relocation's exactly: an attempt is made only
|
||||
/// when the board root's picture **differs from the one last acted on**. One failure, one row,
|
||||
/// then silence until something on disk actually changes. The same memo is what keeps the two
|
||||
/// skip cases from repeating their log line on every reload of an unchanged board.
|
||||
public func refreshAgentGuide() {
|
||||
let state = AgentGuide.inspect(atBoardRoot: rootURL)
|
||||
let decision = AgentGuide.decide(state)
|
||||
guard decision != .leaveAlone else {
|
||||
// The resting state, and the memo's reset: a board whose guide is current has nothing to
|
||||
// remember having tried.
|
||||
attemptedGuideRefresh = nil
|
||||
return
|
||||
}
|
||||
// Deferred, not abandoned — and deliberately *before* the memo is written, so the refresh a
|
||||
// lock refused is not the one the guard below remembers.
|
||||
guard readOnlyLock == nil else {
|
||||
Self.logger.debug("agent-guide refresh deferred — the board is read-only")
|
||||
return
|
||||
}
|
||||
guard FileManager.default.isWritableFile(atPath: rootURL.path) else {
|
||||
Self.logger.debug("agent-guide refresh skipped — the board's location is not writable")
|
||||
return
|
||||
}
|
||||
guard state != attemptedGuideRefresh else { return }
|
||||
// Armed before anything is attempted, so a write that throws leaves it set — the memo's
|
||||
// whole job is to remember pictures this store has already failed or refused to act on.
|
||||
attemptedGuideRefresh = state
|
||||
|
||||
switch decision {
|
||||
case .leaveAlone:
|
||||
break // Ruled out above; the switch stays exhaustive so a new decision is a compile error.
|
||||
case .skipUntouchable:
|
||||
Self.logger.debug("agent-guide refresh skipped — CLAUDE.md is not an ordinary file")
|
||||
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")
|
||||
case .write, .displaceThenWrite:
|
||||
let root = rootURL
|
||||
let displace = decision == .displaceThenWrite
|
||||
do {
|
||||
// One bracket over the rescue move *and* the write: two files change, one
|
||||
// app-mediated reload lands, and (in the Pro edition) one honestly-attributed commit
|
||||
// records it.
|
||||
try performWrite { () throws(BoardWriteError) -> Void in
|
||||
try AgentGuide.install(atBoardRoot: root, displacingUserContent: displace)
|
||||
}
|
||||
// **Cleared on success, and this is load-bearing rather than tidy**: the memo keys on
|
||||
// the picture that provoked the write, and a foreign deletion restores that exact
|
||||
// picture ("missing"). A memo left standing would make the deletion the one thing the
|
||||
// self-heal could not heal.
|
||||
attemptedGuideRefresh = nil
|
||||
} catch {
|
||||
// Already the banner's — `performWrite` posts every `BoardWriteError` before it
|
||||
// rethrows — and there is nothing else a courtesy write can do about a failure. The
|
||||
// memo, left armed above, is what keeps it from being posted again every reload.
|
||||
Self.logger.error("agent-guide write failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selection (delegated)
|
||||
|
||||
// The thin pass-throughs to `transient`, and the only ones.
|
||||
|
||||
@@ -217,6 +217,12 @@ public final class BoardStoreRegistry {
|
||||
// thereafter re-fires it from `BoardStore.land`; this call is only the one the opening walk
|
||||
// would otherwise have no reload behind.
|
||||
store.relocateLooseCardFiles()
|
||||
// The agent guide's first firing (08-agent-integration.md ▸ The agent guide), here for the
|
||||
// same reason and with the same timing as the relocation above: a board opened with no
|
||||
// `CLAUDE.md`, or with one an older version wrote, gets the current guide now — as a
|
||||
// bracketed app write with a reload behind it, never as a write into a board nothing is
|
||||
// watching. Every reload thereafter re-checks it from `BoardStore.land`.
|
||||
store.refreshAgentGuide()
|
||||
return store
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
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`).
|
||||
///
|
||||
/// **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()`.
|
||||
enum AgentGuide {
|
||||
|
||||
// MARK: - The two 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 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.
|
||||
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.
|
||||
static let version = 5
|
||||
|
||||
// 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 `CLAUDE.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. **Symlinks are
|
||||
/// never followed or touched anywhere in this app** (01-storage-format.md § Fractal layout
|
||||
/// ▸ Rules), and a folder named `CLAUDE.md` is somebody's deliberate arrangement; neither
|
||||
/// is displaced or overwritten to make room for a courtesy file.
|
||||
case untouchable
|
||||
}
|
||||
|
||||
/// The board root's two claimed names, read once — the input to `decide(_:)`.
|
||||
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
|
||||
}
|
||||
|
||||
/// 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.
|
||||
case skipUserFilenameTaken
|
||||
|
||||
/// `CLAUDE.md` is a symlink, a folder, or some other non-file. Skipped with a log.
|
||||
case skipUntouchable
|
||||
}
|
||||
|
||||
/// 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 .untouchable:
|
||||
.skipUntouchable
|
||||
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 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.
|
||||
///
|
||||
/// **`attributesOfItem` throughout, never `fileExists`** — `lstat` semantics rather than `stat`:
|
||||
/// a **dangling** symlink is a node that is *there* (the rescue move would fail on it, and this
|
||||
/// app does not touch symlinks anyway), while `fileExists` follows the link, finds nothing, and
|
||||
/// would call the name free.
|
||||
static func inspect(atBoardRoot root: URL) -> State {
|
||||
State(
|
||||
existing: existingNode(at: root.appendingPathComponent(filename)),
|
||||
userFilenameIsFree: !nodeExists(at: root.appendingPathComponent(userFilename))
|
||||
)
|
||||
}
|
||||
|
||||
private static func nodeExists(at url: URL) -> Bool {
|
||||
(try? FileManager.default.attributesOfItem(atPath: url.path)) != nil
|
||||
}
|
||||
|
||||
private static func existingNode(at url: URL) -> Existing {
|
||||
guard let type = (try? FileManager.default.attributesOfItem(atPath: url.path))?[.type]
|
||||
as? FileAttributeType
|
||||
else {
|
||||
return .missing
|
||||
}
|
||||
guard type == .typeRegular else { return .untouchable }
|
||||
// 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, first moving a displaced `CLAUDE.md` out of the way
|
||||
/// when the decision called 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, and (in the Pro edition) as a
|
||||
/// single honestly-attributed commit rather than a foreign-looking rename followed by an
|
||||
/// app write (06-history-undo.md ▸ Commit messages, "Update agent guide (vN)").
|
||||
///
|
||||
/// 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.
|
||||
static func install(
|
||||
atBoardRoot root: URL,
|
||||
displacingUserContent displace: Bool
|
||||
) throws(BoardWriteError) {
|
||||
let guideURL = root.appendingPathComponent(filename)
|
||||
if displace {
|
||||
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)")
|
||||
)
|
||||
}
|
||||
}
|
||||
try BoardWriter.atomicReplace(text: content, at: guideURL, operation: .agentGuide)
|
||||
}
|
||||
|
||||
// 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. 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. -->
|
||||
|
||||
# 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)
|
||||
├── .trash/ deleted cards (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)
|
||||
│ └── <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.
|
||||
- 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; everything else at board root that isn't a
|
||||
UUID-named folder is not part of the board's content.
|
||||
|
||||
## Frontmatter
|
||||
|
||||
All levels: `schema` (required, always `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 additionally require `order` (a number; floats are
|
||||
fine). Lanes may set `width` (integer ≥ 1, multiplier of the standard lane
|
||||
width).
|
||||
|
||||
**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.
|
||||
|
||||
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), `remote-state`
|
||||
(lanes), and a card-level `comments/` folder.
|
||||
|
||||
## 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 and, on git boards, in the auto-commit. 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. When you need exact authorship, commit your
|
||||
changes yourself instead (see Git below).
|
||||
|
||||
## Creating a card
|
||||
|
||||
1. Pick the lane folder. Compute `order`: bottom of the lane = max existing
|
||||
card `order` + 1024; top = min − 1024; between two cards = their
|
||||
midpoint. (Empty lane: any number, conventionally 1024.)
|
||||
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
|
||||
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.
|
||||
```
|
||||
|
||||
Creating a lane is the same one level up (body optional; `order` ranks
|
||||
lanes left→right).
|
||||
|
||||
## Moving and reordering
|
||||
|
||||
- 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`.
|
||||
- Reorder within a lane: rewrite only that card's `order`.
|
||||
|
||||
## 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 = move it into `<board>/.trash/`**: `mv <lane>/<card-uuid>
|
||||
<board>/.trash/` (create `.trash/` if missing). Arrivals go on top: set
|
||||
the card's `order` below the smallest `order` already in `.trash/` (empty
|
||||
trash: any number), and update `modified`. Restore is the same move in
|
||||
reverse — into a lane, with a fresh `order`.
|
||||
- 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. Lanes have no trash: deleting a lane folder is
|
||||
permanent, so be sure.
|
||||
|
||||
## 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.
|
||||
- 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:
|
||||
``.
|
||||
- Subfolders under `attachments/` are tolerated but the app never creates
|
||||
or lists them — keep attachments top-level.
|
||||
|
||||
## Hard rules (the app fails loudly on violations)
|
||||
|
||||
- Frontmatter must parse as YAML; `schema` (plus `order` on lanes and
|
||||
cards) is required. Keep `schema: 1`. The classic violation is an
|
||||
unquoted colon in a title (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
|
||||
|
||||
Some boards are git repositories — because the board lives inside a repo of
|
||||
yours, or because Lanework Pro manages its history. Two rules when one is:
|
||||
|
||||
- **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.
|
||||
- Committing your changes yourself is fine and gives you exact authorship;
|
||||
the app follows along. If you don't commit, Lanework Pro auto-commits
|
||||
your changes as external edits (attributed via `modified-by` when you
|
||||
stamped it).
|
||||
"""
|
||||
}
|
||||
@@ -2362,6 +2362,21 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
/// never the Finder-renamed one it would have landed under.
|
||||
case relocateLooseFile(filename: String)
|
||||
|
||||
/// The board-root `CLAUDE.md` being written or upgraded — and the rescue move that precedes it
|
||||
/// when a markerless one has to be displaced to `CLAUDE.user.md` (08-agent-integration.md ▸ The
|
||||
/// agent guide; `AgentGuide`).
|
||||
///
|
||||
/// **No payload**, unlike every other case that names something: there is exactly one guide per
|
||||
/// board, its filename is fixed, and it is not an item with a title. Its own case on
|
||||
/// `.relocateLooseFile`'s reasoning, doubled — this is work the *app* started on its own, for a
|
||||
/// file the user did not create, does not own and may not know exists; folding it into any
|
||||
/// gesture's phrasing would name an act that never happened.
|
||||
///
|
||||
/// One case rather than two (the move and the write) deliberately: they ride one bracket and
|
||||
/// have one outcome the user could care about — the board's guide is not up to date — and a
|
||||
/// second phrasing for "couldn't move a file you have never seen" would explain nothing.
|
||||
case agentGuide
|
||||
|
||||
/// A Preview task-list checkbox being ticked or unticked (05-card-window.md ▸ Preview).
|
||||
///
|
||||
/// Its own case rather than a fold into `.rename`'s or `.style`'s neighbourhood, on the
|
||||
@@ -2399,7 +2414,8 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
/// on — identity for the cases with no title slot at all: `createBoard`/`createLane`/
|
||||
/// `createCard` are minting a file, not reading one; `importAttachment`, `removeAttachment` and
|
||||
/// `relocateLooseFile` carry a filename, which is the name the user is looking at and the only
|
||||
/// one their banner should say; `listAttachments` and `renumberChildren` name no single item.
|
||||
/// one their banner should say; `listAttachments`, `renumberChildren` and `agentGuide` name no
|
||||
/// single item.
|
||||
/// Called once, right where the operation's `readDocument` succeeds — `updateIndex` itself
|
||||
/// (which covers every case that funnels through it: renumber, delete, restore, style, and
|
||||
/// the tail end of move/copy) and the move/copy pre-flight, before the folder travels or the
|
||||
@@ -2408,7 +2424,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
public func withTitle(_ title: String?) -> WriteOperation {
|
||||
switch self {
|
||||
case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments,
|
||||
.removeAttachment, .renumberChildren, .relocateLooseFile:
|
||||
.removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide:
|
||||
self
|
||||
case .move: .move(title: title)
|
||||
case .reorder: .reorder(title: title)
|
||||
@@ -2454,6 +2470,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case let .removeAttachment(filename): "move attachment '\(filename)' to the Trash"
|
||||
case .renumberChildren: "renumber children"
|
||||
case let .relocateLooseFile(filename): "relocate loose file '\(filename)'"
|
||||
case .agentGuide: "update the agent guide"
|
||||
case let .toggleTask(title): Self.phrase("toggle a checkbox in", title)
|
||||
case let .editBody(title): Self.phrase("save the body of", title)
|
||||
case let .rawSource(title): Self.phrase("apply source changes to", title)
|
||||
|
||||
Reference in New Issue
Block a user