Files
lanework/Kanban/Storage/AgentGuide.swift
T
rzen 092300c7d2 Collapse the edition split — one target, twins merged, verify-editions retired
Phase 1 of the 2026-07-30 one-app pivot (DESIGN 0bec9a6, card c3a3ddd5):
the KanbanPro target, LaneworkPro scheme, KanbanProTests module-alias
bundle, KanbanPro/ source root and scripts/verify-editions.sh retire
wholesale. project.yml reads as a single-target file again (anchors
inlined, header rewritten in tier vocabulary).

The edition twins merge: EditionTypes -> PasteboardTypes (one
UTType(exportedAs:) home — the one app owns the family types),
EditionAbout -> AboutBox (the quiet Pro signpost survives as the About
box's one line; "…in Settings" deferred until the StoreKit phase gives
it somewhere to point). InertGitTests drops its Base prefix — the
inert-.git posture is unconditional app behavior, unsubscribed and
lapsed being one state.

Entitlements gain com.apple.security.network.client, declared now and
dormant until Pro's remotes use it; no keychain access group. The App
Group key deliberately stays — it goes with AppGroup.swift in phase 2,
since pulling it first would silently drop the app into the fallback
container. README/RELEASE.md build-and-pipeline prose updated to the
one-record world; the subscription story lands with phase 3.

1893 tests in 322 suites green.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-30 17:25:50 -04:00

523 lines
28 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.
static let version = 7
// 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.
- 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` (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
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.
```
Creating a lane is the same one level up (body optional; `kind: lane`;
`order` ranks lanes left→right).
**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. Arrivals go on top: set the moved
item's `order` to the smallest `order` already in `.trash/` minus 1024
(empty trash: any number). It's a container change like any other move
(Moving and reordering above): stamp `modified` and re-stamp
`modified-by`. 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; `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).
"""
}