Files
lanework/Kanban/Storage/AgentGuide.swift
T
rzen 7ba90a8cc9 Author the agent guide content
The v5 guide prose, verified two ways. A scripted walkthrough gave a
fresh agent nothing but the guide and a demo board: it created a card
(fresh lowercase UUID, correct bottom rank), moved one to a lane top
with modified and modified-by re-stamped, deleted one into .trash/,
attached a file into attachments/, picked `fern` off the palette table,
and quoted a colon title — and the resulting board loads through
BoardLoader with zero warnings. The walkthrough's one finding is fixed:
the trash-arrival rule now reads formulaically ("smallest order minus
1024") instead of the spatially ambiguous "below the smallest order".

Content drift-guards join the suite: every palette name the app resolves
must appear in the guide (a Palette rename now fails a test instead of
teaching agents dead colors), the rewrite's conventions are present by
name (.trash/, attachments/, modified-by, CLAUDE.user.md, the
stage-only-your-own-paths rule), and the pathfinder's retired vocabulary
(media/, tombstones) cannot resurface — the only deleted: mention is the
warning never to write it.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 22:03:38 -04:00

399 lines
20 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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` 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).
"""
}