Files
lanework/Kanban/Storage/AgentGuide.swift
T
rzen 274ccd9ff5 Realign code with the 2026-07-31 findings-resolution rulings
The full bullet list from Implementation card bf080d9a — both ruling
batches, including the three appended mid-session by 16ef377:

- Restore subjects compose the inverse, never nest: crossing "Undo: S"
  emits "Redo: S" and vice versa; parity, not stack depth, reads a
  legacy double prefix (GitHistoryProvider.restoreSubject).
- Git-operation failures join the one-shot failure banner tier:
  BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error
  tone at failure rank merged with write one-shots by recency; the
  postLoss compromise is retired at both AppModel wirings.
- order/schema optional below the board root: append-at-end reading
  (ordered siblings first, folder-name tie-break among the order-less),
  schema reads 1, both coerce-tier logged; the root keeps its
  requirements. Ranks.resolvedOrders materializes finite ranks so
  models and placement math stay untouched; first Writer rewrite
  stamps a real rank on touch, placement against an order-less sibling
  stamps that sibling inline in the same bracket. Agent guide v10
  teaches optional keys and zero-read filing. Hostile-YAML order
  shapes become coercion tests; Fixtures/Valid/optional-keys.kanban
  replaces the four retired Malformed boards.
- .gitignore is the relocation-heal noise gate: GitignoreRules pure
  matcher (standard semantics, board-root file only), loader consults
  it once per walk so matched loose files keep the stray posture;
  seeded (.DS_Store + .*.lanework-*) at board creation and template
  instantiation, healed in when missing at open — repo-nested
  included; empty file honored, existing files never edited; the
  committer's obedience via libgit2 status is pinned by test.
- Comments crash-residue sweep gates on step ownership: HistoryStep
  derives backing from its own undo expectations, backedContent unions
  both stacks, the sweep purges per-entry only what no live step owns.
- Skip-purge decoupled (16ef377): a stale-skipped coarse step strands
  whole in NativeHistoryProvider.strandedSteps — still backing, retired
  only at session end; clean exits purge as before.
- Coarse close step named "Changes to '<card>'"; the fine body-edit
  wording never leaks onto the board menu.
- Branch-switch settle clears every open card window's fine stack on
  Save All and Discard alike; the empty fold registers no coarse step.
- Close flush awaits its covering snapshot (quiesce + one generation
  bump, 1s bound), and an explicit flush now queues behind an
  in-flight one instead of skipping — the audit-caught interleaving
  could lose a close flush permanently when the debounce fired inside
  the close sequence; regression tests force both races.
- Commit comment bullets sort chronologically by created, not UUID.
- The production-unwired CardBodyEditSession.editSessionDidChange seam
  is deleted with its seam-only tests.
- Composition-root pins: beginSession composes the committer with the
  store's own EchoLedger and binds the announcer (the miswire class).
- Deliberate 06 conformance pass over every 2026-07-31-tagged
  sentence: fixed Change-custom-key subjects (the retired named
  generic was the only producer), the unbuilt Replace attachment
  vocabulary, heal commits now authored Lanework Integrity, the config
  reader scopes identity to plain [user] sections, add-git re-runs
  detection at create (a stale mode-none could initialize inside the
  user's repo), and add-git failures answer at the form or the banner.
  Structural residue filed on the Redesign board.

2554 tests / 439 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-01 07:43:45 -04:00

581 lines
32 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`).
///
/// **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()`.
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 <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.
static let version = 10
// 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 — **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
}
/// 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
/// 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 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.
///
/// **`lstat` semantics throughout, never `fileExists`** (`IntegrityRules.node(at:)`): a
/// **dangling** symlink is a node that is *there* — it holds the name, and it is displaced as a
/// link rather than followed — while `fileExists` follows the link, finds nothing, and would
/// call the name free.
static func inspect(atBoardRoot root: URL) -> State {
State(
existing: existingNode(at: root.appendingPathComponent(filename)),
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, 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 (under Pro) 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.
///
/// **It re-verifies against disk** (01-storage-format.md § Validation and healing: "every
/// scheduled heal re-verifies its defect against disk at write time and no-ops when it is
/// gone"): the board root is re-inspected here, and a guide that has become current since the
/// decision — an agent wrote it, another window healed it first — returns `nil` rather than
/// rewriting a file that no longer needs it. Losing the race to a foreign fix is success.
///
/// - Returns: what this call displaced, or `nil` when it wrote nothing at all.
@discardableResult
static func install(atBoardRoot root: URL) throws(BoardWriteError) -> Displacement? {
let guideURL = root.appendingPathComponent(filename)
let decision = decide(inspect(atBoardRoot: root))
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: a markerless `CLAUDE.md` is user *content*, and it has a
// designated destination (08-agent-integration.md ▸ Ownership).
do {
try FileManager.default.moveItem(at: guideURL, to: root.appendingPathComponent(userFilename))
} catch {
throw BoardWriteError(
operation: .agentGuide,
path: guideURL.path,
reason: .io(message: "could not move the existing \(filename) aside to \(userFilename): \(error.localizedDescription)")
)
}
EchoLedger.current?.recordMove(from: guideURL, to: root.appendingPathComponent(userFilename))
displaced = Displacement(name: filename, movedTo: userFilename, wasUserContent: true)
case .displaceSquatterThenWrite:
// The claimed-name displacement (ruled 2026-07-29): a folder or symlink on the app's own
// name, moved aside by the Finder ladder and never destroyed.
guard let freed = try BoardWriter.displaceClaimedName(
ClaimedNameSquatter(
name: filename,
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: filename, movedTo: freed, wasUserContent: false)
}
try BoardWriter.atomicReplace(text: content, at: guideURL, operation: .agentGuide)
// Heal-marked: the guide's refresh is app-initiated work, and its commit is its own
// ("Update agent guide (vN)" already commits alone — 06-history-undo.md ▸ Commit messages).
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).
struct Displacement: Sendable, Equatable {
/// The claimed name that was freed.
let name: String
/// The name the displaced node now has.
let movedTo: String
/// Whether this was the markerless-`CLAUDE.md` rescue (silent) rather than a squatter's
/// displacement (announced).
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. 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 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)
│ └── <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).
**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), `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.
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:
`![](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. 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
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).
"""
}