Build AgentGuide — versioned CLAUDE.md maintenance

The app-owned agent guide at every board root (DESIGN/08 ▸ The agent
guide): version-gated by a first-line marker (v5, superseding the
pathfinder's v4 guides on real boards), rewritten when missing or older,
byte-for-byte untouched when current or newer. A markerless CLAUDE.md is
displaced to CLAUDE.user.md when that name is free — never clobbered —
and the guide write is skipped with a log when it isn't. Symlinks,
folders, and read-only volumes are skipped in silence; the write rides
performWrite's bracket as an app-mediated Writer operation (new
WriteOperation.agentGuide), so the echo lands appMediated and the
Pro-era committer can attribute it honestly later.

Hooked at store acquire (beside the loose-file relocation, after the
watcher exists) and on every successful reload — the guide self-heals
from foreign deletion or rollback, pre-wiring 06's acknowledged undo
bounce. The refresh memo arms before each attempt and clears on a
successful write, so a failing write can't hot-loop and a foreign
deletion stays healable. First-line-only marker parsing (no Regex);
guide content is one swappable literal, finalized under the next card.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 21:56:10 -04:00
parent 3aa80db2a4
commit b3812ed928
8 changed files with 1119 additions and 2 deletions
+7
View File
@@ -697,6 +697,13 @@ public final class BannerCenter {
// failure the user did not provoke is exactly the one they have no other way to learn // failure the user did not provoke is exactly the one they have no other way to learn
// about. // about.
"Couldn't move '\(filename)' into attachments" "Couldn't move '\(filename)' into attachments"
case .agentGuide:
// **"the agent guide", not "CLAUDE.md"**: the file is the app's, written for agents, and
// most users will never have opened it a filename here would name something they have
// no relationship with. It says nothing about the board's own files because none were
// touched, and nothing is lost: the board works exactly as well without the guide, which
// is why every *refusal* to write it is a log line and only a real I/O failure gets here.
"Couldn't update the agent guide"
case let .toggleTask(title): case let .toggleTask(title):
// The user's word for it, not the file's: they ticked a box. The card is named where // The user's word for it, not the file's: they ticked a box. The card is named where
// the read that preceded the flip learned its title, so a body write that refused says // the read that preceded the flip learned its title, so a body write that refused says
+110
View File
@@ -442,6 +442,14 @@ public final class BoardStore {
@ObservationIgnored @ObservationIgnored
private var attemptedTombstoneMigration: Set<String> = [] private var attemptedTombstoneMigration: Set<String> = []
/// The board root's agent-guide picture the last refresh acted on the third of the same loop
/// guard, documented at `refreshAgentGuide()`. `nil` means "nothing has been acted on against
/// the current picture", which is both the opening state and what a board whose guide is already
/// current resets it to (so the state it holds is never a guide's own text for longer than the
/// one reload that wrote it).
@ObservationIgnored
private var attemptedGuideRefresh: AgentGuide.State?
/// Awaited off the main actor **after** a tree walk finishes and **before** its result is /// Awaited off the main actor **after** a tree walk finishes and **before** its result is
/// applied the one seam this type keeps, `nil` in production. /// applied the one seam this type keeps, `nil` in production.
/// ///
@@ -647,8 +655,15 @@ public final class BoardStore {
// share one bracket-per-call posture, so neither can see the other's work half-done // share one bracket-per-call posture, so neither can see the other's work half-done
// each opens its own bracket and each is re-armed by the reload the other's write // each opens its own bracket and each is re-armed by the reload the other's write
// produces. // produces.
//
// The agent guide joins them last, and is the one of the three that runs on *every*
// board rather than only on one an older version or an outside writer left work in: it
// re-checks a single board-root file and writes only when the version marker says to
// (08-agent-integration.md The agent guide). Running it here rather than at open alone
// is what makes it self-healing see `refreshAgentGuide()`.
relocateLooseCardFiles() relocateLooseCardFiles()
migrateLegacyTombstones() migrateLegacyTombstones()
refreshAgentGuide()
case let .failure(error): case let .failure(error):
// `snapshot`, `loadWarnings` and the transient state are untouched: a failed reload // `snapshot`, `loadWarnings` and the transient state are untouched: a failed reload
@@ -3305,6 +3320,101 @@ public final class BoardStore {
.map(\.element) .map(\.element)
} }
// MARK: - The agent guide
/// Brings the board root's `CLAUDE.md` up to the current guide version, or leaves it exactly as
/// it is the whole of 08-agent-integration.md The agent guide's scheduling. The rule itself
/// is `AgentGuide.decide(_:)`, a pure function; this method is the I/O and the policy around it.
///
/// **Run on every successful reload**, beside the relocation and the tombstone migration, and
/// once more at open (`BoardStoreRegistry.acquire`, which fires it after the watcher and the
/// brackets exist, for the reason stated there). That makes the guide *self-healing* rather than
/// merely written-once: a foreign deletion, a downgrade to an older guide, a board restored from
/// a template carrying a stale one each heals on the next reload, without a single new signal.
/// It also pre-wires the Pro-era bounce 06-history-undo.md acknowledges by name, where undoing
/// an "Update agent guide (vN)" commit restores an older guide that the app immediately
/// re-upgrades.
///
/// **The steady state is a read and a comparison** one `lstat`, one small file read, one
/// first-line parse and no write at all. Nothing here touches the snapshot: the bytes land, the
/// watcher notices, the reload applies, exactly like every other app write.
///
/// ### The read-only board is skipped, never banner-ed
///
/// Two gates, because two different things can be true. `performWrite` would refuse under the
/// read-only lock on its own, but that refusal is a thrown error and this is not a gesture so
/// the lock is checked first, the relocation's own deferral idiom. The writability probe beside
/// it covers the case the lock does not: 02-architecture.md's open-time unwritable-root lock is a
/// separate card, and until it lands a board on a read-only volume would reach the Writer, fail,
/// and post a banner about a file the user never asked for. 02 settles that exact case the other
/// way "the open-time agent-guide write is skipped-with-log, the `CLAUDE.user.md`-taken
/// precedent" so it is skipped with a log.
///
/// ### It cannot hot-loop
///
/// `performWrite`'s bracket schedules a reload whether or not the write succeeded, and this runs
/// on every reload so a *failing* guide write would retry forever at the speed of the debounce,
/// posting a banner row each time. The guard is the relocation's exactly: an attempt is made only
/// when the board root's picture **differs from the one last acted on**. One failure, one row,
/// then silence until something on disk actually changes. The same memo is what keeps the two
/// skip cases from repeating their log line on every reload of an unchanged board.
public func refreshAgentGuide() {
let state = AgentGuide.inspect(atBoardRoot: rootURL)
let decision = AgentGuide.decide(state)
guard decision != .leaveAlone else {
// The resting state, and the memo's reset: a board whose guide is current has nothing to
// remember having tried.
attemptedGuideRefresh = nil
return
}
// Deferred, not abandoned and deliberately *before* the memo is written, so the refresh a
// lock refused is not the one the guard below remembers.
guard readOnlyLock == nil else {
Self.logger.debug("agent-guide refresh deferred — the board is read-only")
return
}
guard FileManager.default.isWritableFile(atPath: rootURL.path) else {
Self.logger.debug("agent-guide refresh skipped — the board's location is not writable")
return
}
guard state != attemptedGuideRefresh else { return }
// Armed before anything is attempted, so a write that throws leaves it set the memo's
// whole job is to remember pictures this store has already failed or refused to act on.
attemptedGuideRefresh = state
switch decision {
case .leaveAlone:
break // Ruled out above; the switch stays exhaustive so a new decision is a compile error.
case .skipUntouchable:
Self.logger.debug("agent-guide refresh skipped — CLAUDE.md is not an ordinary file")
case .skipUserFilenameTaken:
// The ruling's own outcome (08 Ownership): a user-authored CLAUDE.md that cannot be
// rescued keeps its name, and the guide simply does not exist on this board.
Self.logger.debug("agent-guide refresh skipped — CLAUDE.md is not the app's and CLAUDE.user.md is taken")
case .write, .displaceThenWrite:
let root = rootURL
let displace = decision == .displaceThenWrite
do {
// One bracket over the rescue move *and* the write: two files change, one
// app-mediated reload lands, and (in the Pro edition) one honestly-attributed commit
// records it.
try performWrite { () throws(BoardWriteError) -> Void in
try AgentGuide.install(atBoardRoot: root, displacingUserContent: displace)
}
// **Cleared on success, and this is load-bearing rather than tidy**: the memo keys on
// the picture that provoked the write, and a foreign deletion restores that exact
// picture ("missing"). A memo left standing would make the deletion the one thing the
// self-heal could not heal.
attemptedGuideRefresh = nil
} catch {
// Already the banner's `performWrite` posts every `BoardWriteError` before it
// rethrows and there is nothing else a courtesy write can do about a failure. The
// memo, left armed above, is what keeps it from being posted again every reload.
Self.logger.error("agent-guide write failed: \(error.localizedDescription, privacy: .public)")
}
}
}
// MARK: - Selection (delegated) // MARK: - Selection (delegated)
// The thin pass-throughs to `transient`, and the only ones. // The thin pass-throughs to `transient`, and the only ones.
@@ -217,6 +217,12 @@ public final class BoardStoreRegistry {
// thereafter re-fires it from `BoardStore.land`; this call is only the one the opening walk // thereafter re-fires it from `BoardStore.land`; this call is only the one the opening walk
// would otherwise have no reload behind. // would otherwise have no reload behind.
store.relocateLooseCardFiles() store.relocateLooseCardFiles()
// The agent guide's first firing (08-agent-integration.md The agent guide), here for the
// same reason and with the same timing as the relocation above: a board opened with no
// `CLAUDE.md`, or with one an older version wrote, gets the current guide now as a
// bracketed app write with a reload behind it, never as a write into a board nothing is
// watching. Every reload thereafter re-checks it from `BoardStore.land`.
store.refreshAgentGuide()
return store return store
} }
+398
View File
@@ -0,0 +1,398 @@
import Foundation
/// The board-embedded agent guide: a `CLAUDE.md` the app silently maintains at every board root,
/// teaching any file-capable agent how to read and write the board directly (08-agent-integration.md
/// The agent guide). The folder is the API, and this is the file an agent working inside the
/// folder will actually find.
///
/// **App-owned and version-gated.** The first line carries `lanework-agent-guide vN`. The guide is
/// rewritten when it is **missing or older**, and left **byte-for-byte untouched** when it is
/// current or newer "never downgraded" (08): a newer app version may have written it, and an
/// older copy of the app opening the board must not walk it back. Untouched means untouched: a
/// current guide is never reopened for writing, so its mtime and, on git boards, the tree is
/// undisturbed by an open.
///
/// **The marker is read from the first line and nowhere else**, which is 08's own wording and a
/// deliberate divergence from the pathfinder's anywhere-in-the-file match: a user's own `CLAUDE.md`
/// that merely *quotes* the marker (a note about this feature, a pasted excerpt) would otherwise
/// read as an app-owned guide and be silently overwritten.
///
/// **A markerless `CLAUDE.md` is displaced, never clobbered** (08 Ownership). It is user content
/// sitting on a name the app claims, so it moves byte-preserving, `FileManager.moveItem` to
/// `CLAUDE.user.md` when that name is free, and the guide is written in its place. When the name is
/// taken the guide write is **skipped entirely**: user content is never destroyed, and a board
/// without a guide is a board that merely lacks a courtesy. `CLAUDE.user.md` is otherwise never
/// written, read, upgraded or validated by this app that one rescue is its only creation
/// (08 `CLAUDE.user.md`).
///
/// **Nothing here is a user-facing event.** Every refusal below is a log line and nothing more; the
/// only thing that can reach a banner is a genuine I/O failure of the write itself, because
/// `BoardStore.performWrite` posts every `BoardWriteError` it sees. The scheduling when this is
/// consulted, and why it is safe to consult on every reload lives at
/// `BoardStore.refreshAgentGuide()`.
enum AgentGuide {
// MARK: - The two claimed names
/// The app-owned guide, and one of the board-root names the loader already claims
/// (`BoardLoader.reservedRootNames`) so that neither file is ever read as a stray.
static let filename = "CLAUDE.md"
/// The user's extension point (08 `CLAUDE.user.md`) and the rescue destination for a
/// markerless `CLAUDE.md`. The app writes this name exactly once per board, if ever.
static let userFilename = "CLAUDE.user.md"
/// The guide the app ships. **v4 was the pathfinder's**, and real boards carry it; v5 is the
/// rewrite's guide (lanes, `.trash/`, `attachments/`, `modified-by`, the `CLAUDE.user.md`
/// pointer) and supersedes it on the next open.
static let version = 5
// MARK: - The version marker
private static let markerPrefix = "lanework-agent-guide v"
/// The version stamped into `text`'s **first line**, or `nil` when that line carries no marker
/// which is how a foreign, user-authored `CLAUDE.md` is recognized (there is no "version 0": a
/// markerless file is not an old guide, it is somebody else's file).
///
/// Parsed rather than matched with a `Regex`: `Regex` is not `Sendable`, so a stored pattern
/// would have to be rebuilt on every call (the pathfinder's trick), and the grammar here a
/// literal prefix and the ASCII digits after it is smaller than the machinery to match it.
/// Both line endings work by construction, since the first line ends at the first newline
/// scalar of either kind.
static func installedVersion(of text: String) -> Int? {
let firstLine = text.prefix { !$0.isNewline }
guard let marker = firstLine.range(of: markerPrefix) else { return nil }
return Int(firstLine[marker.upperBound...].prefix { $0.isASCII && $0.isNumber })
}
// MARK: - The decision
/// What is sitting on `CLAUDE.md`, as the filesystem answers it no policy, so the rule below
/// can be a pure function of it.
enum Existing: Equatable {
/// Nothing at that path (or nothing this process can read there the same non-event).
case missing
/// A regular file. `text` is its strict UTF-8 decoding, `nil` when it does not decode:
/// reads are strict everywhere in this app (01-storage-format.md § Encoding), and a file
/// the app cannot read is a file whose marker it cannot honestly claim to have checked.
case file(text: String?)
/// A symlink, a directory, or any other node that is not a regular file. **Symlinks are
/// never followed or touched anywhere in this app** (01-storage-format.md § Fractal layout
/// Rules), and a folder named `CLAUDE.md` is somebody's deliberate arrangement; neither
/// is displaced or overwritten to make room for a courtesy file.
case untouchable
}
/// The board root's two claimed names, read once the input to `decide(_:)`.
struct State: Equatable {
var existing: Existing
/// Whether `CLAUDE.user.md` is free **no file, no folder, no symlink** of that name. The
/// rescue move re-checks this atomically anyway (`FileManager.moveItem` fails rather than
/// overwrite), so this is the decision's input, not its safety.
var userFilenameIsFree: Bool
}
/// The four outcomes, and the only four.
enum Decision: Equatable {
/// The guide on disk is current or newer. Nothing is opened for writing.
case leaveAlone
/// Missing, or an older marker: write the guide.
case write
/// A markerless `CLAUDE.md`: rescue it to `CLAUDE.user.md`, then write the guide.
case displaceThenWrite
/// A markerless `CLAUDE.md` with `CLAUDE.user.md` already taken the ruling's
/// skipped-with-a-log case. Two files the user owns, both left alone.
case skipUserFilenameTaken
/// `CLAUDE.md` is a symlink, a folder, or some other non-file. Skipped with a log.
case skipUntouchable
}
/// The whole rule, as a pure function of `state` so "never downgrade", "never clobber" and
/// "never touch a symlink" are pinned by the suite without a filesystem in the way.
///
/// One edge worth naming rather than special-casing: a **zero-byte** `CLAUDE.md` is markerless,
/// so it takes the displacement path like any other foreign file. Uniformity is the point
/// every rule that decides whether to destroy something answers "no" the same way.
static func decide(_ state: State) -> Decision {
switch state.existing {
case .missing:
.write
case .untouchable:
.skipUntouchable
case let .file(text):
if let text, let installed = installedVersion(of: text) {
installed >= version ? .leaveAlone : .write
} else {
state.userFilenameIsFree ? .displaceThenWrite : .skipUserFilenameTaken
}
}
}
// MARK: - Reading the board root
/// Reads the state of the two claimed names. Purely a read it creates nothing, and it is
/// cheap enough (one `lstat`, plus a small file read only when there is a file to read) to run
/// on every reload.
///
/// **`attributesOfItem` throughout, never `fileExists`** `lstat` semantics rather than `stat`:
/// a **dangling** symlink is a node that is *there* (the rescue move would fail on it, and this
/// app does not touch symlinks anyway), while `fileExists` follows the link, finds nothing, and
/// would call the name free.
static func inspect(atBoardRoot root: URL) -> State {
State(
existing: existingNode(at: root.appendingPathComponent(filename)),
userFilenameIsFree: !nodeExists(at: root.appendingPathComponent(userFilename))
)
}
private static func nodeExists(at url: URL) -> Bool {
(try? FileManager.default.attributesOfItem(atPath: url.path)) != nil
}
private static func existingNode(at url: URL) -> Existing {
guard let type = (try? FileManager.default.attributesOfItem(atPath: url.path))?[.type]
as? FileAttributeType
else {
return .missing
}
guard type == .typeRegular else { return .untouchable }
// A regular file whose *contents* cannot be read reads as undecodable rather than as
// missing, and the difference is the whole promise: `.missing` would overwrite it, while
// undecodable displaces it and the rescue move needs no read permission on the file to
// preserve it byte for byte.
guard let data = try? Data(contentsOf: url) else { return .file(text: nil) }
return .file(text: String(data: data, encoding: .utf8))
}
// MARK: - Writing it
/// Puts the current guide at the board root, first moving a displaced `CLAUDE.md` out of the way
/// when the decision called for it.
///
/// **Called inside `BoardStore.performWrite`**, so both halves ride one watcher bracket: the
/// rescue and the guide land as a single app-mediated reload, and (in the Pro edition) as a
/// single honestly-attributed commit rather than a foreign-looking rename followed by an
/// app write (06-history-undo.md Commit messages, "Update agent guide (vN)").
///
/// The move is `FileManager.moveItem` and nothing else: it preserves the bytes exactly the
/// displaced file may not even be UTF-8 and it **fails rather than overwrite** if
/// `CLAUDE.user.md` appeared between the decision and this call, which is what makes the
/// "user content is never destroyed" promise hold against a race rather than merely against a
/// stale read.
static func install(
atBoardRoot root: URL,
displacingUserContent displace: Bool
) throws(BoardWriteError) {
let guideURL = root.appendingPathComponent(filename)
if displace {
do {
try FileManager.default.moveItem(at: guideURL, to: root.appendingPathComponent(userFilename))
} catch {
throw BoardWriteError(
operation: .agentGuide,
path: guideURL.path,
reason: .io(message: "could not move the existing \(filename) aside to \(userFilename): \(error.localizedDescription)")
)
}
}
try BoardWriter.atomicReplace(text: content, at: guideURL, operation: .agentGuide)
}
// MARK: - The guide itself
/// The bytes written to `CLAUDE.md`: the prose below plus the closing newline a multi-line
/// literal does not carry. Files the app creates end with LF (01-storage-format.md § Encoding
/// and line endings), and this one is no exception for being prose.
static let content = guideBody + "\n"
/// **The one swappable string.** Its wording is a separate concern from this file's mechanism
/// what the guide must teach is 08-agent-integration.md The agent guide's list, and revising
/// it is a `version` bump plus a new literal here, with nothing else to change.
///
/// The marker interpolates `version` rather than spelling the number twice: the constant and the
/// first line cannot drift apart, and a bump is one edit.
private static let guideBody = """
<!-- lanework-agent-guide v\(version) created and kept up to date by the Lanework app. Don't edit this file: it is overwritten on upgrades. Board-specific instructions live in CLAUDE.user.md (see below), which the app never touches. -->
# This folder is a Lanework kanban board
Plain folders and Markdown, rendered live by the Lanework app. You can (and
should) manipulate the board by editing files directly while the board is
open, the app picks up every filesystem change automatically. There is
nothing to sync and no API to call: the files are the board.
**If a `CLAUDE.user.md` exists next to this file, read it too** it carries
board-specific instructions from the board's owner.
## Layout
```
<board>/ this folder (the board)
index.md board title + settings; body = board description
CLAUDE.md this guide (app-maintained)
.trash/ deleted cards (app-managed see Deleting)
<uuid>/ a LANE
index.md lane title + order; body = lane notes/policy
<uuid>/ a CARD
index.md card title + order; body = the card's content
attachments/ the card's files (flat, top-level)
<uuid>/ another card
<uuid>/ another lane
```
- Depth alone defines meaning: depth 1 = lane, depth 2 = card. There is no
type field.
- Folder names are lowercase UUIDs and are the item's permanent identity.
**Never rename a folder.** Titles live in frontmatter only.
- Every `index.md` is YAML frontmatter between `---` lines, then a Markdown
body. Files are plain UTF-8, **no BOM**; keep each file's existing line
endings, and end new files with LF.
## Reading the board
- Lanes run leftright by ascending `order`; cards topbottom 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 leftright).
## Moving and reordering
- Move to another lane: `mv <laneA>/<card-uuid> <laneB>/` the folder move
IS the move. Then set the card's `order` to place it among the
destination's cards, update `modified`, and re-stamp `modified-by`.
- Reorder within a lane: rewrite only that card's `order`.
## Editing and deleting
- Edit bodies freely; update `modified` on every write. Preserve frontmatter
keys you don't recognize and don't reformat content you didn't change.
- **Delete a card = move it into `<board>/.trash/`**: `mv <lane>/<card-uuid>
<board>/.trash/` (create `.trash/` if missing). Arrivals go on top: set
the card's `order` below the smallest `order` already in `.trash/` (empty
trash: any number), and update `modified`. Restore is the same move in
reverse into a lane, with a fresh `order`.
- Never write a `deleted:` key that convention is retired; the app
migrates any it finds.
- Remove a folder outright (`rm -r`) only when you mean permanent,
unrecoverable deletion. Lanes have no trash: deleting a lane folder is
permanent, so be sure.
## Attachments
- A card's files live in `attachments/` inside the card folder, flat at its
top level. **Put files there, never beside `index.md`** the app
relocates loose files into `attachments/` and tells the user it did.
- To attach a file: create `attachments/` if missing and copy the file in.
If the name is taken, pick a free one Finder-style (`shot.png`
`shot 2.png`) never overwrite.
- Reference attachments from the card body by relative path:
`![](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).
"""
}
+19 -2
View File
@@ -2362,6 +2362,21 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
/// never the Finder-renamed one it would have landed under. /// never the Finder-renamed one it would have landed under.
case relocateLooseFile(filename: String) case relocateLooseFile(filename: String)
/// The board-root `CLAUDE.md` being written or upgraded and the rescue move that precedes it
/// when a markerless one has to be displaced to `CLAUDE.user.md` (08-agent-integration.md The
/// agent guide; `AgentGuide`).
///
/// **No payload**, unlike every other case that names something: there is exactly one guide per
/// board, its filename is fixed, and it is not an item with a title. Its own case on
/// `.relocateLooseFile`'s reasoning, doubled this is work the *app* started on its own, for a
/// file the user did not create, does not own and may not know exists; folding it into any
/// gesture's phrasing would name an act that never happened.
///
/// One case rather than two (the move and the write) deliberately: they ride one bracket and
/// have one outcome the user could care about the board's guide is not up to date and a
/// second phrasing for "couldn't move a file you have never seen" would explain nothing.
case agentGuide
/// A Preview task-list checkbox being ticked or unticked (05-card-window.md Preview). /// A Preview task-list checkbox being ticked or unticked (05-card-window.md Preview).
/// ///
/// Its own case rather than a fold into `.rename`'s or `.style`'s neighbourhood, on the /// Its own case rather than a fold into `.rename`'s or `.style`'s neighbourhood, on the
@@ -2399,7 +2414,8 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
/// on identity for the cases with no title slot at all: `createBoard`/`createLane`/ /// on identity for the cases with no title slot at all: `createBoard`/`createLane`/
/// `createCard` are minting a file, not reading one; `importAttachment`, `removeAttachment` and /// `createCard` are minting a file, not reading one; `importAttachment`, `removeAttachment` and
/// `relocateLooseFile` carry a filename, which is the name the user is looking at and the only /// `relocateLooseFile` carry a filename, which is the name the user is looking at and the only
/// one their banner should say; `listAttachments` and `renumberChildren` name no single item. /// one their banner should say; `listAttachments`, `renumberChildren` and `agentGuide` name no
/// single item.
/// Called once, right where the operation's `readDocument` succeeds `updateIndex` itself /// Called once, right where the operation's `readDocument` succeeds `updateIndex` itself
/// (which covers every case that funnels through it: renumber, delete, restore, style, and /// (which covers every case that funnels through it: renumber, delete, restore, style, and
/// the tail end of move/copy) and the move/copy pre-flight, before the folder travels or the /// the tail end of move/copy) and the move/copy pre-flight, before the folder travels or the
@@ -2408,7 +2424,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
public func withTitle(_ title: String?) -> WriteOperation { public func withTitle(_ title: String?) -> WriteOperation {
switch self { switch self {
case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments, case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments,
.removeAttachment, .renumberChildren, .relocateLooseFile: .removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide:
self self
case .move: .move(title: title) case .move: .move(title: title)
case .reorder: .reorder(title: title) case .reorder: .reorder(title: title)
@@ -2454,6 +2470,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case let .removeAttachment(filename): "move attachment '\(filename)' to the Trash" case let .removeAttachment(filename): "move attachment '\(filename)' to the Trash"
case .renumberChildren: "renumber children" case .renumberChildren: "renumber children"
case let .relocateLooseFile(filename): "relocate loose file '\(filename)'" case let .relocateLooseFile(filename): "relocate loose file '\(filename)'"
case .agentGuide: "update the agent guide"
case let .toggleTask(title): Self.phrase("toggle a checkbox in", title) case let .toggleTask(title): Self.phrase("toggle a checkbox in", title)
case let .editBody(title): Self.phrase("save the body of", title) case let .editBody(title): Self.phrase("save the body of", title)
case let .rawSource(title): Self.phrase("apply source changes to", title) case let .rawSource(title): Self.phrase("apply source changes to", title)
+570
View File
@@ -0,0 +1,570 @@
import Foundation
import Testing
@testable import Kanban
/// The agent guide, end to end (08-agent-integration.md The agent guide).
///
/// The rule is three promises, and this file is organized as them:
///
/// 1. **The marker decides, and only the first line carries it** the version gate, parsed as a
/// pure function of text.
/// 2. **The decision is pure** write, leave alone, displace, or skip, from what the two claimed
/// board-root names look like on disk and nothing else.
/// 3. **Nothing the user owns is ever destroyed** a markerless `CLAUDE.md` is rescued, a taken
/// `CLAUDE.user.md` cancels the write outright, a symlink or a folder is not touched at all, and
/// a current guide is not even opened for writing.
///
/// Every on-disk claim is read back as **raw bytes**, never through a snapshot: the promises are
/// about the files. `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.
// MARK: - Shared fixtures
/// A one-lane board enough tree that a reload has something to walk.
@MainActor
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
return fixture
}
/// A guide-shaped file at some version the shape an older app version left behind, or one a newer
/// version will.
private func guideText(version: Int) -> String {
"<!-- lanework-agent-guide v\(version) — created and kept up to date by the Lanework app. -->\n\n# This folder is a Lanework kanban board\n"
}
/// A file's bytes and mtime "this file was not rewritten", stated the way `LooseFileRelocationTests`
/// states it.
private func stat(_ url: URL) throws -> (bytes: Data, modified: Date) {
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
guard let modified = attributes[.modificationDate] as? Date else {
throw NSError(domain: "AgentGuideTests", code: 1)
}
return (try Data(contentsOf: url), modified)
}
private func guideURL(in fixture: WriterFixture) -> URL {
fixture.root.appendingPathComponent(AgentGuide.filename)
}
private func userFileURL(in fixture: WriterFixture) -> URL {
fixture.root.appendingPathComponent(AgentGuide.userFilename)
}
/// Writes raw bytes to one of the two claimed board-root names, without going near the app.
@discardableResult
private func writeRoot(_ name: String, _ bytes: Data, in fixture: WriterFixture) throws -> URL {
let url = fixture.root.appendingPathComponent(name)
try bytes.write(to: url)
return url
}
/// Counts the bracket calls a store makes, standing in for the watcher the registry wires up.
@MainActor
private final class GuideBracketLog {
private(set) var begins = 0
func attach(to store: BoardStore) {
store.watcherBrackets = (begin: { self.begins += 1 }, end: {})
}
}
/// Polls until `condition` holds or the deadline passes generous, because FSEvents delivery is not
/// a bounded-latency promise (`BoardStoreRegistryTests`' idiom).
@MainActor
private func waitUntil(_ deadline: Duration = .seconds(15), _ condition: () -> Bool) async {
let start = ContinuousClock.now
while ContinuousClock.now - start < deadline {
if condition() { return }
try? await Task.sleep(for: .milliseconds(25))
}
}
/// Gives a freshly started stream a beat to register with `fseventsd` see `FolderWatcherTests`.
@MainActor
private func settle() async {
try? await Task.sleep(for: .milliseconds(300))
}
// MARK: - 1. The version marker
@Suite("Agent guide ▸ the marker")
struct AgentGuideMarkerTests {
@Test("The guide the app ships carries the current marker in its first line")
func shippedGuideCarriesTheMarker() {
let firstLine = String(AgentGuide.content.prefix { !$0.isNewline })
#expect(firstLine.contains("lanework-agent-guide v5"))
#expect(AgentGuide.installedVersion(of: AgentGuide.content) == AgentGuide.version)
// Files the app creates end with LF (01-storage-format.md § Encoding and line endings).
#expect(AgentGuide.content.hasSuffix("\n"))
#expect(!AgentGuide.content.hasSuffix("\n\n"))
}
@Test("Any version in the first line parses")
func versionsParse() {
#expect(AgentGuide.installedVersion(of: guideText(version: 1)) == 1)
#expect(AgentGuide.installedVersion(of: guideText(version: 4)) == 4)
#expect(AgentGuide.installedVersion(of: guideText(version: 12)) == 12)
}
@Test("A file with no marker has no version")
func markerlessHasNoVersion() {
#expect(AgentGuide.installedVersion(of: "# My project\n\nNotes for agents.\n") == nil)
#expect(AgentGuide.installedVersion(of: "") == nil)
// The prefix without a number is not a marker either.
#expect(AgentGuide.installedVersion(of: "lanework-agent-guide vNext\n") == nil)
}
/// **The deliberate divergence from the pathfinder** (08: "version marker in the first line"):
/// a user's own file that merely *quotes* the marker further down is not an app-owned guide, and
/// reading it as one would overwrite it.
@Test("A marker below the first line does not count")
func markerBelowTheFirstLineIsNotAMarker() {
#expect(AgentGuide.installedVersion(of: "# Notes\nlanework-agent-guide v5\n") == nil)
#expect(AgentGuide.installedVersion(of: "\nlanework-agent-guide v9\n") == nil)
}
@Test("CRLF and a missing final newline both parse")
func lineEndingsDoNotMatter() {
#expect(AgentGuide.installedVersion(of: "<!-- lanework-agent-guide v3 -->\r\nbody\r\n") == 3)
#expect(AgentGuide.installedVersion(of: "<!-- lanework-agent-guide v3 -->") == 3)
}
}
// MARK: - 2. The decision
@Suite("Agent guide ▸ the decision")
struct AgentGuideDecisionTests {
private func state(_ existing: AgentGuide.Existing, userFileFree: Bool = true) -> AgentGuide.State {
AgentGuide.State(existing: existing, userFilenameIsFree: userFileFree)
}
@Test("Missing writes; older rewrites")
func missingAndOlderWrite() {
#expect(AgentGuide.decide(state(.missing)) == .write)
#expect(AgentGuide.decide(state(.file(text: guideText(version: 1)))) == .write)
#expect(AgentGuide.decide(state(.file(text: guideText(version: 4)))) == .write)
}
/// "Never downgraded" a newer app version may have written it.
@Test("Current and newer are left alone")
func currentAndNewerAreLeftAlone() {
#expect(AgentGuide.decide(state(.file(text: guideText(version: 5)))) == .leaveAlone)
#expect(AgentGuide.decide(state(.file(text: guideText(version: 6)))) == .leaveAlone)
#expect(AgentGuide.decide(state(.file(text: AgentGuide.content))) == .leaveAlone)
}
@Test("A markerless file is displaced when the user name is free")
func markerlessIsDisplaced() {
#expect(AgentGuide.decide(state(.file(text: "# My own notes\n"))) == .displaceThenWrite)
// Undecodable bytes are markerless too the move preserves them either way.
#expect(AgentGuide.decide(state(.file(text: nil))) == .displaceThenWrite)
// A marker that is not on the first line is a user file, not an old guide.
#expect(AgentGuide.decide(state(.file(text: "# Notes\nlanework-agent-guide v4\n"))) == .displaceThenWrite)
}
/// "Otherwise the guide write is skipped with a log user content is never destroyed."
@Test("A taken CLAUDE.user.md cancels the write outright")
func takenUserFilenameSkips() {
#expect(AgentGuide.decide(state(.file(text: "# My own notes\n"), userFileFree: false)) == .skipUserFilenameTaken)
#expect(AgentGuide.decide(state(.file(text: nil), userFileFree: false)) == .skipUserFilenameTaken)
// It has no bearing on the version gate: an app-owned guide is still upgraded in place.
#expect(AgentGuide.decide(state(.file(text: guideText(version: 4)), userFileFree: false)) == .write)
#expect(AgentGuide.decide(state(.missing, userFileFree: false)) == .write)
}
@Test("A symlink or a folder is never touched, free name or not")
func untouchableIsSkipped() {
#expect(AgentGuide.decide(state(.untouchable)) == .skipUntouchable)
#expect(AgentGuide.decide(state(.untouchable, userFileFree: false)) == .skipUntouchable)
}
}
// MARK: - 3. On disk, through the store
@MainActor
@Suite("Agent guide ▸ on disk")
struct AgentGuideStoreTests {
@Test("A board with no CLAUDE.md gets the current guide")
func missingGuideIsWritten() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let brackets = GuideBracketLog()
brackets.attach(to: store)
store.refreshAgentGuide()
let written = try String(decoding: fixture.data(AgentGuide.filename), as: UTF8.self)
#expect(written.prefix { !$0.isNewline }.contains("lanework-agent-guide v5"))
#expect(written == AgentGuide.content)
#expect(!fixture.exists(AgentGuide.userFilename))
#expect(brackets.begins == 1, "one bracket — one app-mediated reload, one commit")
// A courtesy file the user never asked for has nothing to say to them.
#expect(store.banners.oneShots.isEmpty)
#expect(store.banners.losses.isEmpty)
// No temp-file residue from the atomic replace.
#expect(try fixture.entryNames("").contains(AgentGuide.filename))
#expect(try !fixture.entryNames("").contains { $0.hasPrefix(".\(AgentGuide.filename)") })
}
@Test("An older guide is rewritten to the current one")
func olderGuideIsUpgraded() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try writeRoot(AgentGuide.filename, Data(guideText(version: 4).utf8), in: fixture)
let store = try BoardStore(rootURL: fixture.root)
store.refreshAgentGuide()
#expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
// An upgrade is not a displacement: nothing was rescued, because nothing was the user's.
#expect(!fixture.exists(AgentGuide.userFilename))
}
/// "Left untouched when current or newer" and untouched means the file is not even opened for
/// writing, so its mtime (and, on git boards, the tree) is undisturbed.
@Test("A current guide is left byte-for-byte alone, mtime included")
func currentGuideIsNotRewritten() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
// Deliberately *not* the app's own text: a v5 marker is the whole gate, and a file carrying
// one must survive verbatim even when its body differs from what this version would write.
let url = try writeRoot(AgentGuide.filename, Data(guideText(version: 5).utf8), in: fixture)
let before = try stat(url)
let store = try BoardStore(rootURL: fixture.root)
let brackets = GuideBracketLog()
brackets.attach(to: store)
store.refreshAgentGuide()
let after = try stat(url)
#expect(after.bytes == before.bytes)
#expect(after.modified == before.modified)
#expect(brackets.begins == 0, "no write means no bracket and no commit")
}
@Test("A newer guide is never downgraded")
func newerGuideIsNotDowngraded() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let url = try writeRoot(AgentGuide.filename, Data(guideText(version: 99).utf8), in: fixture)
let before = try stat(url)
let store = try BoardStore(rootURL: fixture.root)
store.refreshAgentGuide()
let after = try stat(url)
#expect(after.bytes == before.bytes)
#expect(after.modified == before.modified)
}
@Test("A markerless CLAUDE.md moves to CLAUDE.user.md, byte for byte, and the guide takes its place")
func markerlessIsDisplaced() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let mine = "# Board rules\n\nAlways file bugs in the Triage lane.\n"
try writeRoot(AgentGuide.filename, Data(mine.utf8), in: fixture)
let store = try BoardStore(rootURL: fixture.root)
let brackets = GuideBracketLog()
brackets.attach(to: store)
store.refreshAgentGuide()
#expect(try fixture.data(AgentGuide.userFilename) == Data(mine.utf8))
#expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
#expect(brackets.begins == 1, "the rescue and the write ride one bracket")
#expect(store.banners.oneShots.isEmpty)
}
/// The first-line rule, proved where it costs something: a user file quoting the marker is
/// rescued, not overwritten.
@Test("A marker below the first line is user content and is displaced")
func markerBelowTheFirstLineIsDisplaced() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let mine = "# Notes about Lanework\n\nThe app stamps `lanework-agent-guide v5` into CLAUDE.md.\n"
try writeRoot(AgentGuide.filename, Data(mine.utf8), in: fixture)
let store = try BoardStore(rootURL: fixture.root)
store.refreshAgentGuide()
#expect(try fixture.data(AgentGuide.userFilename) == Data(mine.utf8))
#expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
}
@Test("A CLAUDE.md that is not UTF-8 is displaced with its bytes intact")
func undecodableIsDisplacedByteForByte() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let bytes = Data([0xFF, 0xFE, 0x00, 0x41, 0x80, 0x0A])
try writeRoot(AgentGuide.filename, bytes, in: fixture)
let store = try BoardStore(rootURL: fixture.root)
store.refreshAgentGuide()
#expect(try fixture.data(AgentGuide.userFilename) == bytes)
#expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
}
/// "Otherwise the guide write is skipped with a log user content is never destroyed." Two
/// files the user owns, both still theirs afterwards, and no guide on this board at all.
@Test("A taken CLAUDE.user.md leaves both files alone and writes no guide")
func takenUserFilenameSkipsTheWrite() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let mine = "# Board rules\n"
let theirs = "# Older board rules\n"
let guide = try writeRoot(AgentGuide.filename, Data(mine.utf8), in: fixture)
let user = try writeRoot(AgentGuide.userFilename, Data(theirs.utf8), in: fixture)
let guideBefore = try stat(guide)
let userBefore = try stat(user)
let store = try BoardStore(rootURL: fixture.root)
let brackets = GuideBracketLog()
brackets.attach(to: store)
store.refreshAgentGuide()
#expect(try stat(guide).bytes == guideBefore.bytes)
#expect(try stat(guide).modified == guideBefore.modified)
#expect(try stat(user).bytes == userBefore.bytes)
#expect(try stat(user).modified == userBefore.modified)
#expect(brackets.begins == 0)
#expect(store.banners.oneShots.isEmpty, "a skip is a log line, not a banner")
}
/// Symlinks are never followed or touched anywhere in this app (01-storage-format.md § Fractal
/// layout Rules) including one wearing the guide's name.
@Test("A symlinked CLAUDE.md is left as a symlink, and its target is untouched")
func symlinkedGuideIsSkipped() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let target = try fixture.file("elsewhere.md", Data("# somewhere else\n".utf8))
try FileManager.default.createSymbolicLink(atPath: guideURL(in: fixture).path, withDestinationPath: "elsewhere.md")
let store = try BoardStore(rootURL: fixture.root)
store.refreshAgentGuide()
let destination = try FileManager.default.destinationOfSymbolicLink(atPath: guideURL(in: fixture).path)
#expect(destination == "elsewhere.md", "the link is still a link")
#expect(try Data(contentsOf: target) == Data("# somewhere else\n".utf8), "and it was not written through")
#expect(!fixture.exists(AgentGuide.userFilename))
}
/// The rescue name is checked with `lstat` semantics, so a **broken** symlink counts as taken:
/// `fileExists` would follow it, find nothing, call the name free and the move would then fail
/// into a banner about a file the user never asked for.
@Test("A dangling symlink on CLAUDE.user.md counts as taken")
func danglingRescueNameIsTaken() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try writeRoot(AgentGuide.filename, Data("# mine\n".utf8), in: fixture)
try FileManager.default.createSymbolicLink(atPath: userFileURL(in: fixture).path, withDestinationPath: "nowhere.md")
let store = try BoardStore(rootURL: fixture.root)
store.refreshAgentGuide()
#expect(try fixture.data(AgentGuide.filename) == Data("# mine\n".utf8))
#expect(try FileManager.default.destinationOfSymbolicLink(atPath: userFileURL(in: fixture).path) == "nowhere.md")
#expect(store.banners.oneShots.isEmpty)
}
/// A regular file the app cannot *read* is markerless, not missing so it is displaced, never
/// overwritten. The move preserves it without ever needing to read it.
@Test("An unreadable CLAUDE.md is displaced, not clobbered")
func unreadableGuideIsDisplaced() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let url = try writeRoot(AgentGuide.filename, Data("# secret\n".utf8), in: fixture)
try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: url.path)
let store = try BoardStore(rootURL: fixture.root)
store.refreshAgentGuide()
#expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: userFileURL(in: fixture).path)
#expect(try fixture.data(AgentGuide.userFilename) == Data("# secret\n".utf8))
}
@Test("A folder named CLAUDE.md is left alone")
func directoryGuideIsSkipped() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file("\(AgentGuide.filename)/inside.txt", Data("inside".utf8))
let store = try BoardStore(rootURL: fixture.root)
store.refreshAgentGuide()
#expect(try fixture.data("\(AgentGuide.filename)/inside.txt") == Data("inside".utf8))
#expect(!fixture.exists(AgentGuide.userFilename))
}
/// 02-architecture.md § Write-failure surfacing: "The open-time agent-guide write is
/// skipped-with-log, the `CLAUDE.user.md`-taken precedent." A board on a read-only volume must
/// not spend a banner on a courtesy file.
@Test("An unwritable board root is skipped silently")
func unwritableRootIsSkipped() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let brackets = GuideBracketLog()
brackets.attach(to: store)
// Readable (so the board still loads and renders) but not writable.
try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: fixture.root.path)
store.refreshAgentGuide()
#expect(!fixture.exists(AgentGuide.filename))
#expect(brackets.begins == 0)
#expect(store.banners.oneShots.isEmpty)
}
/// The read-only *lock* defers it as well the relocation's posture, and 02's.
@Test("A read-only-locked board defers, and writes the guide when the lock clears")
func lockedBoardDefers() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.enterUnwritableLock()
store.refreshAgentGuide()
#expect(!fixture.exists(AgentGuide.filename))
// A reconciling reload re-probes writability, the lock clears and the same reload writes
// the guide it had been holding back.
store.handleWatcherEvent(.treeChanged(.reconciling))
await store.awaitQuiescence()
#expect(!store.isReadOnly)
#expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
}
@Test("Refreshing twice writes once")
func secondRefreshIsANoOp() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let brackets = GuideBracketLog()
brackets.attach(to: store)
store.refreshAgentGuide()
let after = try stat(guideURL(in: fixture))
store.refreshAgentGuide()
#expect(try stat(guideURL(in: fixture)).modified == after.modified)
#expect(brackets.begins == 1)
}
/// The self-heal, stated where it is deterministic: every successful reload re-checks the guide,
/// so a foreign deletion or downgrade is repaired by the reload that noticed it.
@Test("A reload re-checks the guide — a deleted one comes back")
func aReloadHealsIt() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.refreshAgentGuide()
try FileManager.default.removeItem(at: guideURL(in: fixture))
store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence()
#expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
}
/// The memo `refreshAgentGuide()` documents, from both sides: a picture this store has already
/// refused to act on is not acted on again by every reload that re-reads it, and a picture that
/// genuinely *changed* is a fresh attempt.
@Test("A skipped board stays skipped across reloads, and heals when the name frees up")
func aSkipIsNotRetriedUntilThePictureChanges() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
// A *folder* on the rescue name is exactly as taken as a file the app never descends into
// one, renames around it, or overwrites it.
try writeRoot(AgentGuide.filename, Data("# mine\n".utf8), in: fixture)
try fixture.file("\(AgentGuide.userFilename)/inside.txt", Data("inside".utf8))
let store = try BoardStore(rootURL: fixture.root)
let brackets = GuideBracketLog()
brackets.attach(to: store)
store.refreshAgentGuide()
for _ in 0 ..< 3 {
store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence()
}
#expect(brackets.begins == 0, "no write was ever attempted")
#expect(store.banners.oneShots.isEmpty, "and no row was posted, once or four times")
#expect(try fixture.data(AgentGuide.filename) == Data("# mine\n".utf8))
#expect(try fixture.data("\(AgentGuide.userFilename)/inside.txt") == Data("inside".utf8))
// The name frees up: a different picture, so a fresh attempt and the rescue finally runs.
try FileManager.default.removeItem(at: userFileURL(in: fixture))
store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence()
#expect(try fixture.data(AgentGuide.userFilename) == Data("# mine\n".utf8))
#expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
#expect(brackets.begins == 1)
}
@Test("The guide is not a stray, and does not disturb the load")
func guideIsNotAStray() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.refreshAgentGuide()
try writeRoot(AgentGuide.userFilename, Data("# mine\n".utf8), in: fixture)
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.warnings.isEmpty, "both names are app-claimed, never stray-warned")
#expect(result.model.lanes.count == 1)
}
}
// MARK: - 4. Through the registry, with a real watcher
@MainActor
@Suite("Agent guide ▸ the registry")
struct AgentGuideRegistryTests {
/// The open-time firing, wired the way the app wires it after the watcher and the brackets
/// exist, so the write has a reload behind it.
@Test("Acquiring a board writes the guide")
func acquireWritesTheGuide() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let registry = BoardStoreRegistry()
let store = try registry.acquire(fixture.root)
defer { registry.release(store) }
await waitUntil { fixture.exists(AgentGuide.filename) }
#expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
#expect(store.reloadFailure == nil)
}
/// Self-healing, over the real FSEvents path: nothing in this test hands the store an event.
@Test("A guide deleted under a live board comes back")
func foreignDeletionHeals() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let registry = BoardStoreRegistry()
let store = try registry.acquire(fixture.root)
defer { registry.release(store) }
await waitUntil { fixture.exists(AgentGuide.filename) }
await settle()
try FileManager.default.removeItem(at: guideURL(in: fixture))
#expect(!fixture.exists(AgentGuide.filename))
await waitUntil { fixture.exists(AgentGuide.filename) }
#expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
#expect(store.reloadFailure == nil)
}
}
@@ -23,9 +23,16 @@ import Testing
// MARK: - Shared fixtures // MARK: - Shared fixtures
/// A one-lane, one-card board, ready for whatever the test wants to leave beside `index.md`. /// A one-lane, one-card board, ready for whatever the test wants to leave beside `index.md`.
///
/// It carries a **current agent guide**, which is what any board the app has opened once looks like
/// (08-agent-integration.md The agent guide). Without it the store's own guide refresh which
/// runs on every successful reload, beside this file's relocation would write a `CLAUDE.md` on
/// the first reload and open a bracket of its own, and the bracket counts below would stop being
/// claims about the relocation.
private func makeCardBoard() throws -> WriterFixture { private func makeCardBoard() throws -> WriterFixture {
let fixture = try WriterFixture() let fixture = try WriterFixture()
try fixture.item("", Item.board) try fixture.item("", Item.board)
try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8))
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login")) try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login"))
return fixture return fixture
+2
View File
@@ -46,6 +46,8 @@ Lanework is in early development. This list tracks what has actually shipped and
- **Undo** — ⌘Z and ⇧⌘Z are native macOS undo, per board: one stack owned by the board's session and shared by every window over it, so a card window's ⌘Z crosses the same step the board window's does, and another board's never does. Every app-mediated mutation registers an inverse at the write boundary — create, move, reorder, rename, restyle, resize, delete, and an Edit session's whole run of saves — with a restore registering as the ordinary move it is — one gesture to one step, named in the app's own vocabulary so the Edit menu reads "Undo Move 3 Cards" and the toolbar's twins light up and dim with it. Undoing is a real write, never an in-memory revert: it goes through the same atomic writer, echoes back through the watcher, and refreshes every window. Because the app is not the only writer, each step re-checks its target the moment you press ⌘Z — field by field, against what its own write left — and a step the disk has moved past is **skipped rather than applied**, with a quiet row saying which item changed outside Lanework, while ⌘Z falls through to the next step; a step that merely failed to write (a full disk, an unplugged volume) stays put to be retried. Permanent deletion and the duplicate-id repair are deliberately outside it — the confirmation is the safety — attachment add and remove register nothing in v1, and foreign edits never join the stack. The read-only lock disables Undo and Redo with every other mutating command and gives them back, stack intact, when it clears. The stack lives with the session and dies at close, standard macOS behaviour. Both editions ship it: base runs the native stack, and Lanework Pro binds git behind the same seam without changing a keystroke. - **Undo** — ⌘Z and ⇧⌘Z are native macOS undo, per board: one stack owned by the board's session and shared by every window over it, so a card window's ⌘Z crosses the same step the board window's does, and another board's never does. Every app-mediated mutation registers an inverse at the write boundary — create, move, reorder, rename, restyle, resize, delete, and an Edit session's whole run of saves — with a restore registering as the ordinary move it is — one gesture to one step, named in the app's own vocabulary so the Edit menu reads "Undo Move 3 Cards" and the toolbar's twins light up and dim with it. Undoing is a real write, never an in-memory revert: it goes through the same atomic writer, echoes back through the watcher, and refreshes every window. Because the app is not the only writer, each step re-checks its target the moment you press ⌘Z — field by field, against what its own write left — and a step the disk has moved past is **skipped rather than applied**, with a quiet row saying which item changed outside Lanework, while ⌘Z falls through to the next step; a step that merely failed to write (a full disk, an unplugged volume) stays put to be retried. Permanent deletion and the duplicate-id repair are deliberately outside it — the confirmation is the safety — attachment add and remove register nothing in v1, and foreign edits never join the stack. The read-only lock disables Undo and Redo with every other mutating command and gives them back, stack intact, when it clears. The stack lives with the session and dies at close, standard macOS behaviour. Both editions ship it: base runs the native stack, and Lanework Pro binds git behind the same seam without changing a keystroke.
- **The agent guide** — every board root carries a `CLAUDE.md` the app writes and keeps current: a condensed, agent-facing rendition of the schema — the folder layout, ordering arithmetic, creating and moving cards, the `.trash/` convention, `attachments/`, `modified-by` self-stamping, the colour and icon palettes, and the git etiquette — so any file-capable agent dropped into the folder already knows how to work the board. It is app-owned and version-gated by a marker in its first line: rewritten when missing or older, left byte-for-byte alone when current or newer, and re-checked on every reload, so a guide deleted or rolled back from outside heals by itself. A `CLAUDE.md` that isn't the app's is never clobbered — it moves to `CLAUDE.user.md` (the user's own extension point, which the app otherwise never touches), and if that name is taken the app simply doesn't write a guide. A symlink or folder wearing the name is left alone, and a board on a read-only volume is skipped in silence: the guide is a courtesy and never an interruption.
## Development ## Development
The Xcode project is generated — `project.yml` is the source of truth, not the `.xcodeproj`: The Xcode project is generated — `project.yml` is the source of truth, not the `.xcodeproj`: