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. v6 adds the one-folder-at-a-time move warning: /// a real agent incident (2026-07-29) showed `mv /*` 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. static let version = 6 // 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 = """ # 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 ``` / 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) ├── / a LANE │ ├── index.md lane title + order; body = lane notes/policy │ ├── / a CARD │ │ ├── index.md card title + order; body = the card's content │ │ └── attachments/ the card's files (flat, top-level) │ └── / another card └── / 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: ` (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 `/$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 / /` — 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 /* /`. 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`. ## 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 `/.trash/`**: `mv / /.trash/` (create `.trash/` if missing). Arrivals go on top: set the card's `order` to the smallest `order` already in `.trash/` minus 1024 (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: `![](attachments/sketch.png)`. - 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). """ }