The guide learns the thread it was silent on, and grows a vendor-neutral twin
CLAUDE.md bumps to v14: a new Comments section teaches the comments/ mechanics (chronology-as-ordering, author vs modified-by, .draft/.trash as the app's, retract-by-follow-up), and a new "Use the thread: journal your work" section teaches conduct the guide never had — body is the spec, thread is the journal, post a plan cold-reader-ready on start, decisions as they're made, questions as comments, re-read before resuming, close with verification. comments/ leaves the reserved-tracker-keys list; the Layout diagram gains comments/ and AGENTS.md. The app now writes a byte-identical twin at AGENTS.md, the vendor-neutral name most non-Claude tools read — same lifecycle as CLAUDE.md, decided and healed independently per claimed name, both funneling a markerless foreign file to the single shared CLAUDE.user.md rescue. AgentGuide.install now returns [Displacement]; IntegrityRules.claimedRootNames and BoardStore.refreshAgentGuide cover both names; ChangeNarrator's guide-commit path check widens to both. DESIGN/08-agent-integration.md amends: the agent-guide section gains the Comments and Use-the-thread bullets and an AGENTS.md twin paragraph. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -11,7 +11,7 @@ AI agents are first-class users of Lanework boards — not through an API, but t
|
|||||||
5. **Git trail** (06-history-undo.md) — on git-enabled boards, agent changes are auto-committed, attributed, and undoable like any others. Foreign changes are committed under a synthetic external author, structurally distinct from the user's own commits; an agent can claim its work lightly by stamping `modified-by` (guarantee 6 below), or precisely by committing its changes itself — the app follows along: a held `index.lock` just makes the auto-committer retry/re-debounce, and a tree the agent already committed is a silent no-op that preserves the agent's authorship (06-history-undo.md). The guide says so, and also tells agents to **stage only their own paths** (no `git add -A`) — a sweep would commit the user's not-yet-auto-committed changes under the agent's name.
|
5. **Git trail** (06-history-undo.md) — on git-enabled boards, agent changes are auto-committed, attributed, and undoable like any others. Foreign changes are committed under a synthetic external author, structurally distinct from the user's own commits; an agent can claim its work lightly by stamping `modified-by` (guarantee 6 below), or precisely by committing its changes itself — the app follows along: a held `index.lock` just makes the auto-committer retry/re-debounce, and a tree the agent already committed is a silent no-op that preserves the agent's authorship (06-history-undo.md). The guide says so, and also tells agents to **stage only their own paths** (no `git add -A`) — a sweep would commit the user's not-yet-auto-committed changes under the agent's name.
|
||||||
6. **`modified-by` self-stamping** (01-storage-format.md) — the lightweight attribution path: an agent stamps files it writes (`modified-by: <your-name>`), and the stamp renders on the card window's modified line and authors the agent's foreign commits (06-history-undo.md) — no git ceremony, and it works on no-git boards, where it is the *only* attribution there is. The app clears the stamp on its own writes, so agents should re-stamp on every write, not once — and on every *move*: a bare folder move rewrites nothing, so the moved card arrives unstamped and the commit falls back to the generic external author (06's fallback demotes on unstamped changed files and true deletions; a move is neither stamped nor a deletion until the agent re-stamps it).
|
6. **`modified-by` self-stamping** (01-storage-format.md) — the lightweight attribution path: an agent stamps files it writes (`modified-by: <your-name>`), and the stamp renders on the card window's modified line and authors the agent's foreign commits (06-history-undo.md) — no git ceremony, and it works on no-git boards, where it is the *only* attribution there is. The app clears the stamp on its own writes, so agents should re-stamp on every write, not once — and on every *move*: a bare folder move rewrites nothing, so the moved card arrives unstamped and the commit falls back to the generic external author (06's fallback demotes on unstamped changed files and true deletions; a move is neither stamped nor a deletion until the agent re-stamps it).
|
||||||
|
|
||||||
## The agent guide (`CLAUDE.md` at board root)
|
## The agent guide (`CLAUDE.md` at board root, twinned at `AGENTS.md`)
|
||||||
|
|
||||||
The app silently maintains a `CLAUDE.md` in every board — a condensed, agent-facing rendition of the schema teaching any agent how to operate on the board directly:
|
The app silently maintains a `CLAUDE.md` in every board — a condensed, agent-facing rendition of the schema teaching any agent how to operate on the board directly:
|
||||||
|
|
||||||
@@ -22,6 +22,8 @@ The app silently maintains a `CLAUDE.md` in every board — a condensed, agent-f
|
|||||||
- **Attachments** (the `attachments/` convention, importing files) — including the rule that **card files belong in `attachments/`**: a loose file written beside `index.md` will be relocated there by the app with a notice (01-storage-format.md's loose-file carve-out), so agents should put it there in the first place — and the card-level **`attachments` claimed name**: `attachments` inside a card folder is the app's (the card's file folder); never create a *file* by that name.
|
- **Attachments** (the `attachments/` convention, importing files) — including the rule that **card files belong in `attachments/`**: a loose file written beside `index.md` will be relocated there by the app with a notice (01-storage-format.md's loose-file carve-out), so agents should put it there in the first place — and the card-level **`attachments` claimed name**: `attachments` inside a card folder is the app's (the card's file folder); never create a *file* by that name.
|
||||||
- **`modified-by` self-stamping** — stamp files you write; re-stamp every write *and every move* (the app clears it on its own writes; a bare folder move leaves the card unstamped); self-commit instead when you need exact authorship.
|
- **`modified-by` self-stamping** — stamp files you write; re-stamp every write *and every move* (the app clears it on its own writes; a bare folder move leaves the card unstamped); self-commit instead when you need exact authorship.
|
||||||
- **The stamp discipline** of 01 ▸ `modified`'s scope: **reordering within a lane rewrites only `order`** — leave `modified` and `modified-by` alone — while **a move between lanes, between boards, or into/out of `.trash/` updates both** (stamp `modified`, re-stamp `modified-by`).
|
- **The stamp discipline** of 01 ▸ `modified`'s scope: **reordering within a lane rewrites only `order`** — leave `modified` and `modified-by` alone — while **a move between lanes, between boards, or into/out of `.trash/` updates both** (stamp `modified`, re-stamp `modified-by`).
|
||||||
|
- **Comments** (`comments/` — 01-storage-format.md § Enhanced schema, shipped 2026-07-29): the folder shape (`comments/<uuid>/index.md`, no `title`/`order`), chronology as the ordering (sorts by `created` ascending — write real current UTC, distinct seconds for a burst), `author` as self-reported content that survives app writes (the deliberate contrast with `modified-by`, which the app clears), `comments/.draft` and `comments/.trash` as reserved names one level down the agent never writes into, and retraction by a follow-up comment rather than a rewrite. `comments/` **no longer appears on the reserved-tracker-keys list** below — it shipped, and the list now names only the fields tracker sync still reserves (`labels`, `assignees`, `due`, `remote`, `remote-state`).
|
||||||
|
- **Use the thread: journal your work** (added with the comments chapter, ruled 2026-08-09, card dc1314bb): the card's body is the spec, its comment thread is the journal. Starting a card gets a comment with the plan and every decision already made, written for a reader with none of the session's context; decisions get recorded as they're made, not reconstructed at the end; questions post as comments, since the app narrates arrivals live and a thread has to be re-read in full before a card is resumed; finishing gets a comment with verification evidence. Posting a comment never stamps the card's own `modified`/`modified-by`. Per-board process — lane cadence, commit conventions — stays `CLAUDE.user.md`'s, not the guide's.
|
||||||
- A pointer to the optional **`CLAUDE.user.md`** (see below), instructing agents to read it when present.
|
- A pointer to the optional **`CLAUDE.user.md`** (see below), instructing agents to read it when present.
|
||||||
|
|
||||||
This list is **present-tense and normative for the current guide** (re-ruled 2026-07-30): it states what the shipped guide teaches *now*, and editing it means editing the guide literal in the same change. Per-version changelog bullets are deliberately gone — a hand-maintained version history beside the literal drifted within days of v6 (claiming teachings the shipped body lacked), the same failure class as README's hand-enumerated deferred list; version history lives in git.
|
This list is **present-tense and normative for the current guide** (re-ruled 2026-07-30): it states what the shipped guide teaches *now*, and editing it means editing the guide literal in the same change. Per-version changelog bullets are deliberately gone — a hand-maintained version history beside the literal drifted within days of v6 (claiming teachings the shipped body lacked), the same failure class as README's hand-enumerated deferred list; version history lives in git.
|
||||||
@@ -30,7 +32,9 @@ Mechanics: version marker in the first line (`lanework-agent-guide vN`); rewritt
|
|||||||
|
|
||||||
**Ownership**: the board-root `CLAUDE.md` is **app-owned and not available for user editing** — its header says so, and user edits do not survive upgrades. This holds on every board, including repo-nested ones: the guide is auto-written there too (the untracked file in the user's repository is accepted — a board is agent-facing wherever it lives). A board-root `CLAUDE.md` found *without* the marker is displaced user content, not clobbered: its content is moved to `CLAUDE.user.md` if that name is free (otherwise the guide write is skipped with a log — user content is never destroyed), and the guide is then written.
|
**Ownership**: the board-root `CLAUDE.md` is **app-owned and not available for user editing** — its header says so, and user edits do not survive upgrades. This holds on every board, including repo-nested ones: the guide is auto-written there too (the untracked file in the user's repository is accepted — a board is agent-facing wherever it lives). A board-root `CLAUDE.md` found *without* the marker is displaced user content, not clobbered: its content is moved to `CLAUDE.user.md` if that name is free (otherwise the guide write is skipped with a log — user content is never destroyed), and the guide is then written.
|
||||||
|
|
||||||
**`CLAUDE.user.md`** is the user's extension point: an optional, user-authored file at board root carrying board-specific agent instructions. The generated guide tells agents to read it when present, so it lands in agent context without the app ever touching it — the app never writes, upgrades, or validates an *existing* `CLAUDE.user.md`; the one exception is its creation, once, to rescue displaced content (the markerless-`CLAUDE.md` relocation above, which only runs when the name is free).
|
**The `AGENTS.md` twin** (ruled 2026-08-09, card dc1314bb): the app writes the identical guide a second time, at `AGENTS.md` — the vendor-neutral filename most non-Claude coding agents read, alongside `CLAUDE.md` for Claude's own pickup. Same lifecycle as `CLAUDE.md` in every particular — seeded at board creation, rewritten on a version bump, left untouched when current, a markerless file on the name rescued and a squatter displaced exactly as `CLAUDE.md`'s own are — because each name gets the identical decision run against it independently, not a copy of the other's outcome. **A duplicate, deliberately not a symlink**: iCloud Drive's symlink handling is unreliable, and the two files are written from the one generator on every install, which makes drift structurally impossible rather than a discipline someone has to maintain. Both names join the claimed board-root list the Git section already tells agents to leave to the app.
|
||||||
|
|
||||||
|
**`CLAUDE.user.md`** is the user's extension point: an optional, user-authored file at board root carrying board-specific agent instructions. The generated guide tells agents to read it when present, so it lands in agent context without the app ever touching it — the app never writes, upgrades, or validates an *existing* `CLAUDE.user.md`; the one exception is its creation, once, to rescue displaced content (the markerless-`CLAUDE.md` relocation above, which only runs when the name is free — and which `AGENTS.md` shares: a markerless file on *either* claimed name rescues to this same single destination, never a second `AGENTS.user.md`).
|
||||||
|
|
||||||
On git boards, a guide write rides the normal watcher → auto-commit path with an honest message ("Update agent guide (v3)"), not "External edit" (06-history-undo.md ▸ Commit messages — the non-snapshot-files rule: the committer stages the whole root, and the composer reads the subject's version from the guide's marker line, a pure function of file content).
|
On git boards, a guide write rides the normal watcher → auto-commit path with an honest message ("Update agent guide (v3)"), not "External edit" (06-history-undo.md ▸ Commit messages — the non-snapshot-files rule: the committer stages the whole root, and the composer reads the subject's version from the guide's marker line, a pure function of file content).
|
||||||
|
|
||||||
|
|||||||
@@ -847,10 +847,12 @@ enum ChangeNarrator {
|
|||||||
|
|
||||||
// **N is a pure function of the file's content** (06; the m10 agent-guide card's deferred
|
// **N is a pure function of the file's content** (06; the m10 agent-guide card's deferred
|
||||||
// bullet): the version marker on the guide's first line, read here rather than tagged at
|
// bullet): the version marker on the guide's first line, read here rather than tagged at
|
||||||
// the write site — "the no-interception rule stands". A `CLAUDE.md` with no marker is not
|
// the write site — "the no-interception rule stands". A guide filename with no marker is
|
||||||
// an old guide, it is somebody else's file (`AgentGuide.installedVersion`), and it
|
// not an old guide, it is somebody else's file (`AgentGuide.installedVersion`), and it
|
||||||
// composes as the ordinary path it is.
|
// composes as the ordinary path it is. **Both claimed names** (`CLAUDE.md` and its
|
||||||
if path == AgentGuide.filename,
|
// `AGENTS.md` twin, ruled 2026-08-09) share this shape — the twin carries the identical
|
||||||
|
// marker line by construction, so the same version parse answers for either path.
|
||||||
|
if AgentGuide.targetFilenames.contains(path),
|
||||||
let version = request.agentGuideText.flatMap(AgentGuide.installedVersion(of:)) {
|
let version = request.agentGuideText.flatMap(AgentGuide.installedVersion(of:)) {
|
||||||
events.append(Event(kind: .agentGuide, subject: "Update agent guide (v\(version))", paths: [path]))
|
events.append(Event(kind: .agentGuide, subject: "Update agent guide (v\(version))", paths: [path]))
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -4793,54 +4793,63 @@ public final class BoardStore: HealHost {
|
|||||||
|
|
||||||
// MARK: - The agent guide
|
// MARK: - The agent guide
|
||||||
|
|
||||||
/// Brings the board root's `CLAUDE.md` up to the current guide version, or leaves it exactly as
|
/// Brings the board root's `CLAUDE.md` **and its `AGENTS.md` twin** up to the current guide
|
||||||
/// it is — the whole of 08-agent-integration.md ▸ The agent guide's scheduling. The rule itself
|
/// version, or leaves either exactly as it is — the whole of 08-agent-integration.md ▸ The agent
|
||||||
/// is `AgentGuide.decide(_:)`, a pure function; this method is the I/O and the policy around it.
|
/// guide's scheduling. The rule itself is `AgentGuide.decide(_:)`, a pure function applied
|
||||||
|
/// independently to each of `AgentGuide.targetFilenames`; this method is the I/O and the policy
|
||||||
|
/// around both.
|
||||||
///
|
///
|
||||||
/// **Run on every successful reload**, beside the other scheduled heals, and once more at open
|
/// **Run on every successful reload**, beside the other scheduled heals, and once more at open
|
||||||
/// (`BoardStoreRegistry.acquire`). That makes the guide *self-healing* rather than merely
|
/// (`BoardStoreRegistry.acquire`). 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
|
/// 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.
|
/// template carrying a stale one — each heals on the next reload, without a single new signal,
|
||||||
|
/// and the two claimed names heal independently of each other (one can be current while the other
|
||||||
|
/// is stale, deleted, or squatted).
|
||||||
/// It also pre-wires the Pro-era bounce 06-history-undo.md acknowledges by name, where undoing
|
/// 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
|
/// an "Update agent guide (vN)" commit restores an older guide that the app immediately
|
||||||
/// re-upgrades.
|
/// re-upgrades.
|
||||||
///
|
///
|
||||||
/// **The steady state is a read and a comparison** — one `lstat`, one small file read, one
|
/// **The steady state is a read and a comparison, twice over** — one `lstat`, one small file read,
|
||||||
/// first-line parse — and no write at all. Nothing here touches the snapshot: the bytes land, the
|
/// one first-line parse, per claimed name — and no write at all. Nothing here touches the
|
||||||
/// watcher notices, the reload applies, exactly like every other app write.
|
/// snapshot: the bytes land, the watcher notices, the reload applies, exactly like every other
|
||||||
|
/// app write.
|
||||||
///
|
///
|
||||||
/// ### The two skips, and the one displacement
|
/// ### The two skips, and the one displacement — per file
|
||||||
///
|
///
|
||||||
/// `.skipUserFilenameTaken` is the standing exception (a rescue destination is not itself freed
|
/// `.skipUserFilenameTaken` is the standing exception (a rescue destination is not itself freed
|
||||||
/// by a second displacement); `.displaceSquatterThenWrite` is the 2026-07-29 upgrade of the old
|
/// by a second displacement); `.displaceSquatterThenWrite` is the 2026-07-29 upgrade of the old
|
||||||
/// untouchable-skip, and it *does* post — a node of the user's moved aside owes the same
|
/// untouchable-skip, and it *does* post — a node of the user's moved aside owes the same
|
||||||
/// warning-tone notice `.trash`'s displacement does. Both decisions are re-made inside the write
|
/// warning-tone notice `.trash`'s displacement does. Both decisions are re-made inside the write
|
||||||
/// half, which is this heal's disk re-verify.
|
/// half, which is this heal's disk re-verify — independently for `CLAUDE.md` and `AGENTS.md`, so
|
||||||
|
/// one file's skip never silences the other's write.
|
||||||
///
|
///
|
||||||
/// ### The signature is the board root's picture
|
/// ### The signature is the board root's picture, both names folded in
|
||||||
///
|
///
|
||||||
/// One failure, one row, then silence until something on disk actually changes — and the memo
|
/// One failure, one row, then silence until something on disk actually changes — and the memo
|
||||||
/// clears on success, which is what lets a foreign deletion of the guide be healed again
|
/// clears on success, which is what lets a foreign deletion of either guide file be healed again
|
||||||
/// immediately (the picture "missing" is restored, and a standing memo would make that deletion
|
/// immediately (the picture "missing" is restored, and a standing memo would make that deletion
|
||||||
/// the one thing this could not heal).
|
/// the one thing this could not heal). Each entry is filename-qualified, so a change confined to
|
||||||
|
/// one claimed name — the other untouched — still changes the combined signature.
|
||||||
public func refreshAgentGuide() {
|
public func refreshAgentGuide() {
|
||||||
let root = rootURL
|
let root = rootURL
|
||||||
let state = AgentGuide.inspect(atBoardRoot: root)
|
// **A decision that writes nothing is no work at all**, and says so by contributing nothing to
|
||||||
let decision = AgentGuide.decide(state)
|
// the signature: the engine's resting-clear then costs no bracket, which matters because a
|
||||||
// **A decision that writes nothing is no work at all**, and says so with an empty signature:
|
// bracket schedules a reload whether or not anything was written — a skip that opened one
|
||||||
// the engine's resting-clear then costs no bracket, which matters because a bracket schedules
|
// would tick forever.
|
||||||
// a reload whether or not anything was written — a skip that opened one would tick forever.
|
var signature: Set<String> = []
|
||||||
let signature: Set<String>
|
for guideFilename in AgentGuide.targetFilenames {
|
||||||
switch decision {
|
let state = AgentGuide.inspect(atBoardRoot: root, guideFilename: guideFilename)
|
||||||
|
switch AgentGuide.decide(state) {
|
||||||
case .leaveAlone:
|
case .leaveAlone:
|
||||||
signature = []
|
break
|
||||||
case .skipUserFilenameTaken:
|
case .skipUserFilenameTaken:
|
||||||
// The ruling's own outcome (08 ▸ Ownership): a user-authored CLAUDE.md that cannot be
|
// The ruling's own outcome (08 ▸ Ownership): user-authored content on a claimed guide
|
||||||
// rescued keeps its name, and the guide simply does not exist on this board.
|
// name that cannot be rescued keeps its name, and that guide simply does not exist on
|
||||||
Self.logger.debug("agent-guide refresh skipped — CLAUDE.md is not the app's and CLAUDE.user.md is taken")
|
// this board.
|
||||||
signature = []
|
Self.logger.debug("agent-guide refresh skipped — \(guideFilename, privacy: .public) is not the app's and \(AgentGuide.userFilename, privacy: .public) is taken")
|
||||||
case .write, .displaceThenWrite, .displaceSquatterThenWrite:
|
case .write, .displaceThenWrite, .displaceSquatterThenWrite:
|
||||||
signature = [state.signature]
|
signature.insert("\(guideFilename):\(state.signature)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var displaced: [BannerCenter.Displacement] = []
|
var displaced: [BannerCenter.Displacement] = []
|
||||||
@@ -4849,13 +4858,15 @@ public final class BoardStore: HealHost {
|
|||||||
signature: signature,
|
signature: signature,
|
||||||
on: self
|
on: self
|
||||||
) { () throws(BoardWriteError) -> Void in
|
) { () throws(BoardWriteError) -> Void in
|
||||||
// One bracket over the displacement *and* the write: two files change, one app-mediated
|
// One bracket over every displacement *and* write across both claimed names: whatever
|
||||||
// reload lands, and (under Pro) one honestly-attributed commit records it.
|
// changes, one app-mediated reload lands, and (under Pro) one honestly-attributed commit
|
||||||
guard let moved = try AgentGuide.install(atBoardRoot: root) else { return }
|
// records it.
|
||||||
|
for moved in try AgentGuide.install(atBoardRoot: root) {
|
||||||
// The `CLAUDE.user.md` rescue is the settled, silent ownership rule; a squatter's
|
// The `CLAUDE.user.md` rescue is the settled, silent ownership rule; a squatter's
|
||||||
// displacement is announced.
|
// displacement is announced.
|
||||||
guard !moved.wasUserContent else { return }
|
guard !moved.wasUserContent else { continue }
|
||||||
displaced.append(BannerCenter.Displacement(name: moved.name, movedTo: moved.movedTo))
|
displaced.append(BannerCenter.Displacement(name: moved.name, movedTo: moved.movedTo))
|
||||||
|
}
|
||||||
} posting: {
|
} posting: {
|
||||||
.displacedClaimedNames(displaced)
|
.displacedClaimedNames(displaced)
|
||||||
}
|
}
|
||||||
|
|||||||
+189
-48
@@ -38,16 +38,39 @@ import Foundation
|
|||||||
/// `BoardStore.performWrite` posts every `BoardWriteError` it sees. The scheduling — when this is
|
/// `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
|
/// consulted, and why it is safe to consult on every reload — lives at
|
||||||
/// `BoardStore.refreshAgentGuide()`.
|
/// `BoardStore.refreshAgentGuide()`.
|
||||||
|
///
|
||||||
|
/// **`CLAUDE.md` has a twin: `AGENTS.md`** (ruled 2026-08-09, card dc1314bb) — byte-for-byte the same
|
||||||
|
/// guide, at a second board-root name, written and upgraded in lockstep. Every promise above —
|
||||||
|
/// never-downgrade, the markerless-content rescue, squatter displacement — applies to each name
|
||||||
|
/// independently; `install(atBoardRoot:)` is simply called once per name. See `agentsFilename` and
|
||||||
|
/// `targetFilenames` below for why a duplicate file rather than a symlink, and `install`'s doc for how
|
||||||
|
/// one call keeps both in lockstep without two bodies of prose to let drift.
|
||||||
enum AgentGuide {
|
enum AgentGuide {
|
||||||
|
|
||||||
// MARK: - The two claimed names
|
// MARK: - The claimed names
|
||||||
|
|
||||||
/// The app-owned guide, and one of the board-root names the loader already claims
|
/// 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.
|
/// (`BoardLoader.reservedRootNames`) so that neither file is ever read as a stray.
|
||||||
static let filename = "CLAUDE.md"
|
static let filename = "CLAUDE.md"
|
||||||
|
|
||||||
|
/// **The `AGENTS.md` twin** (08-agent-integration.md ▸ The agent guide; ruled 2026-08-09, card
|
||||||
|
/// dc1314bb) — byte-for-byte the same content as `filename`, written and upgraded in lockstep by
|
||||||
|
/// the same `install(atBoardRoot:)`. The vendor-neutral name most non-Claude tools read; `CLAUDE.md`
|
||||||
|
/// stays for Claude's own pickup. **Deliberately a duplicate, never a symlink**: iCloud Drive's
|
||||||
|
/// symlink handling is unreliable, and rewriting both from the one literal on every install makes
|
||||||
|
/// drift impossible by construction — there is no second body of prose to let drift.
|
||||||
|
static let agentsFilename = "AGENTS.md"
|
||||||
|
|
||||||
|
/// The two guide filenames `install(atBoardRoot:)` maintains, in the order it processes them —
|
||||||
|
/// `filename` first, so that a simultaneous foreign claim on both funnels its one rescue through
|
||||||
|
/// `filename`'s decision before `agentsFilename`'s is even inspected (see `install`'s doc).
|
||||||
|
static let targetFilenames: [String] = [filename, agentsFilename]
|
||||||
|
|
||||||
/// The user's extension point (08 ▸ `CLAUDE.user.md`) — and the rescue destination for a
|
/// 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.
|
/// markerless `CLAUDE.md` **or** a markerless `AGENTS.md`. The app writes this name at most once
|
||||||
|
/// per board: a single shared destination for "content that was somebody's before either claimed
|
||||||
|
/// name was the app's," never a second `AGENTS.user.md` — the rescue is conceptually the user's
|
||||||
|
/// guide content, singular, regardless of which claimed name it was sitting on.
|
||||||
static let userFilename = "CLAUDE.user.md"
|
static let userFilename = "CLAUDE.user.md"
|
||||||
|
|
||||||
/// The guide the app ships. **v4 was the pathfinder's**, and real boards carry it; v5 is the
|
/// The guide the app ships. **v4 was the pathfinder's**, and real boards carry it; v5 is the
|
||||||
@@ -101,8 +124,27 @@ enum AgentGuide {
|
|||||||
/// an agent writing the key *is* how a hero image gets set. One clause beside the other card keys
|
/// an agent writing the key *is* how a hero image gets set. One clause beside the other card keys
|
||||||
/// in Frontmatter, spelling the grammar the reading enforces — a bare filename, never a path —
|
/// in Frontmatter, spelling the grammar the reading enforces — a bare filename, never a path —
|
||||||
/// because a path is exactly what an agent that has just written `` into a
|
/// because a path is exactly what an agent that has just written `` into a
|
||||||
/// body will reach for.
|
/// body will reach for. **v14 teaches the comment thread — mechanics and conduct — and ships the
|
||||||
static let version = 13
|
/// `AGENTS.md` twin** (08-agent-integration.md ▸ The agent guide, ▸ Use the thread; 01-storage-
|
||||||
|
/// format.md § Enhanced schema; filed 2026-08-08 off a live session that journaled an
|
||||||
|
/// implementation card by deriving every comments rule from 01 by hand, because the guide never
|
||||||
|
/// taught them). Comments shipped after v11 rewrote Git for the excision and left `comments/` on
|
||||||
|
/// the reserved-keys list — telling agents not to use a surface the app had since built: the
|
||||||
|
/// thread column, `.draft`, and `comments/.trash/` are all live. Two new sections close the gap.
|
||||||
|
/// **Comments** teaches the mechanics — the folder shape, chronology as the ordering, `author`
|
||||||
|
/// surviving app writes where `modified-by` doesn't, `.draft` and `.trash` as the app's own,
|
||||||
|
/// retraction by follow-up rather than rewrite, `modified` differing from `created` as the whole
|
||||||
|
/// of "edited." **Use the thread: journal your work** teaches the conduct the mechanics section
|
||||||
|
/// never could: the card body is the spec, the thread is the journal, and a cold reader — the
|
||||||
|
/// user checking in later, another session picking the card back up — should be able to
|
||||||
|
/// reconstruct the plan and every decision from the thread alone; per-board process stays
|
||||||
|
/// `CLAUDE.user.md`'s. `comments/` itself leaves the reserved-keys line, which now names only the
|
||||||
|
/// tracker-integration keys still unclaimed. Separately, the app now writes a second guide file,
|
||||||
|
/// `AGENTS.md`, byte-for-byte identical to this one and carrying the same marker — the
|
||||||
|
/// vendor-neutral filename most non-Claude tools read, kept a deliberate duplicate rather than a
|
||||||
|
/// symlink (iCloud Drive's unreliable symlink handling), rewritten in lockstep by the same
|
||||||
|
/// `install(atBoardRoot:)` so drift is impossible by construction rather than by discipline.
|
||||||
|
static let version = 14
|
||||||
|
|
||||||
// MARK: - The version marker
|
// MARK: - The version marker
|
||||||
|
|
||||||
@@ -125,8 +167,8 @@ enum AgentGuide {
|
|||||||
|
|
||||||
// MARK: - The decision
|
// MARK: - The decision
|
||||||
|
|
||||||
/// What is sitting on `CLAUDE.md`, as the filesystem answers it — no policy, so the rule below
|
/// What is sitting on one claimed guide name (`CLAUDE.md` or `AGENTS.md`), as the filesystem
|
||||||
/// can be a pure function of it.
|
/// answers it — no policy, so the rule below can be a pure function of it.
|
||||||
enum Existing: Equatable {
|
enum Existing: Equatable {
|
||||||
/// Nothing at that path (or nothing this process can read there — the same non-event).
|
/// Nothing at that path (or nothing this process can read there — the same non-event).
|
||||||
case missing
|
case missing
|
||||||
@@ -149,7 +191,8 @@ enum AgentGuide {
|
|||||||
case squatted
|
case squatted
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The board root's two claimed names, read once — the input to `decide(_:)`.
|
/// One claimed guide name's picture, plus the shared rescue name's freedom — the input to
|
||||||
|
/// `decide(_:)`. `inspect(atBoardRoot:guideFilename:)` builds one of these per guide filename.
|
||||||
struct State: Equatable {
|
struct State: Equatable {
|
||||||
var existing: Existing
|
var existing: Existing
|
||||||
|
|
||||||
@@ -221,17 +264,22 @@ enum AgentGuide {
|
|||||||
|
|
||||||
// MARK: - Reading the board root
|
// MARK: - Reading the board root
|
||||||
|
|
||||||
/// Reads the state of the two claimed names. Purely a read — it creates nothing, and it is
|
/// Reads the state of one guide filename (`filename` or `agentsFilename`) plus the shared rescue
|
||||||
/// cheap enough (one `lstat`, plus a small file read only when there is a file to read) to run
|
/// name's freedom. Purely a read — it creates nothing, and it is cheap enough (one `lstat`, plus a
|
||||||
/// on every reload.
|
/// small file read only when there is a file to read) to run on every reload, for both names.
|
||||||
|
///
|
||||||
|
/// **A live disk read, not a memoized one** — called once per guide filename, sequentially, inside
|
||||||
|
/// `install`, so a rescue the first call just performed (`filename` moved to `userFilename`) is
|
||||||
|
/// reflected in `userFilenameIsFree` by the time the second call inspects `agentsFilename`, with no
|
||||||
|
/// stale picture to reconcile.
|
||||||
///
|
///
|
||||||
/// **`lstat` semantics throughout, never `fileExists`** (`IntegrityRules.node(at:)`): a
|
/// **`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
|
/// **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
|
/// link rather than followed — while `fileExists` follows the link, finds nothing, and would
|
||||||
/// call the name free.
|
/// call the name free.
|
||||||
static func inspect(atBoardRoot root: URL) -> State {
|
static func inspect(atBoardRoot root: URL, guideFilename: String) -> State {
|
||||||
State(
|
State(
|
||||||
existing: existingNode(at: root.appendingPathComponent(filename)),
|
existing: existingNode(at: root.appendingPathComponent(guideFilename)),
|
||||||
userFilenameIsFree: IntegrityRules.node(at: root.appendingPathComponent(userFilename)) == nil
|
userFilenameIsFree: IntegrityRules.node(at: root.appendingPathComponent(userFilename)) == nil
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -249,30 +297,56 @@ enum AgentGuide {
|
|||||||
|
|
||||||
// MARK: - Writing it
|
// MARK: - Writing it
|
||||||
|
|
||||||
/// Puts the current guide at the board root, first moving a displaced `CLAUDE.md` out of the way
|
/// Puts the current guide at the board root — **at both claimed names** (ruled 2026-08-09,
|
||||||
/// when the decision called for it.
|
/// card dc1314bb) — first moving a displaced file out of the way wherever a decision calls for it.
|
||||||
///
|
///
|
||||||
/// **Called inside `BoardStore.performWrite`**, so both halves ride one watcher bracket: the
|
/// **One call, up to two independent decisions, one identical body.** `targetFilenames` is
|
||||||
/// rescue and the guide land as a single app-mediated reload rather than as two foreign-looking
|
/// processed in order, `filename` then `agentsFilename`: each gets its own `inspect` (a fresh disk
|
||||||
/// events.
|
/// read) and its own `decide`, exactly as if the other name did not exist, and each that needs a
|
||||||
|
/// write gets the same `content` — the twin promise is upheld by writing one literal to two paths,
|
||||||
|
/// never by copying one file onto the other. **The sequencing is what makes the shared rescue name
|
||||||
|
/// safe**: if both `CLAUDE.md` and `AGENTS.md` are markerless simultaneously, `filename`'s rescue
|
||||||
|
/// to `userFilename` runs first and actually lands on disk before `agentsFilename` is even
|
||||||
|
/// inspected, so its `decide` sees `userFilename` correctly as taken and falls to
|
||||||
|
/// `.skipUserFilenameTaken` — never a lost race, because there is no concurrency here to race.
|
||||||
///
|
///
|
||||||
/// The move is `FileManager.moveItem` and nothing else: it preserves the bytes exactly — the
|
/// **Called inside `BoardStore.performWrite`**, so every half rides one watcher bracket: up to two
|
||||||
/// displaced file may not even be UTF-8 — and it **fails rather than overwrite** if
|
/// rescues and up to two guide writes land as a single app-mediated reload rather than as four
|
||||||
/// `CLAUDE.user.md` appeared between the decision and this call, which is what makes the
|
/// foreign-looking events.
|
||||||
/// "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
|
/// Each move is `FileManager.moveItem` and nothing else: it preserves the bytes exactly — the
|
||||||
/// scheduled heal re-verifies its defect against disk at write time and no-ops when it is
|
/// displaced file may not even be UTF-8 — and it **fails rather than overwrite** if `userFilename`
|
||||||
/// gone"): the board root is re-inspected here, and a guide that has become current since the
|
/// appeared between that file's decision and this call, which is what makes the "user content is
|
||||||
/// decision — an agent wrote it, another window healed it first — returns `nil` rather than
|
/// never destroyed" promise hold against a race rather than merely against a stale read.
|
||||||
/// 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.
|
/// **Each write 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 per file, and a guide that has become current since its decision
|
||||||
|
/// — an agent wrote it, another window healed it first — contributes nothing to the result rather
|
||||||
|
/// than rewriting a file that no longer needs it. Losing the race to a foreign fix is success, and
|
||||||
|
/// the two files' races are independent: one can win while the other loses.
|
||||||
|
///
|
||||||
|
/// - Returns: what this call displaced, one entry per file that needed one — empty when it wrote
|
||||||
|
/// nothing at all.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
static func install(atBoardRoot root: URL) throws(BoardWriteError) -> Displacement? {
|
static func install(atBoardRoot root: URL) throws(BoardWriteError) -> [Displacement] {
|
||||||
let guideURL = root.appendingPathComponent(filename)
|
var displacements: [Displacement] = []
|
||||||
let decision = decide(inspect(atBoardRoot: root))
|
for guideFilename in targetFilenames {
|
||||||
|
if let displaced = try installOne(guideFilename: guideFilename, atBoardRoot: root) {
|
||||||
|
displacements.append(displaced)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return displacements
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One claimed name's whole install — `install(atBoardRoot:)`'s per-file body, factored out so the
|
||||||
|
/// loop above is the only place that knows there are two.
|
||||||
|
private static func installOne(
|
||||||
|
guideFilename: String,
|
||||||
|
atBoardRoot root: URL
|
||||||
|
) throws(BoardWriteError) -> Displacement? {
|
||||||
|
let guideURL = root.appendingPathComponent(guideFilename)
|
||||||
|
let decision = decide(inspect(atBoardRoot: root, guideFilename: guideFilename))
|
||||||
var displaced: Displacement?
|
var displaced: Displacement?
|
||||||
|
|
||||||
switch decision {
|
switch decision {
|
||||||
@@ -283,25 +357,25 @@ enum AgentGuide {
|
|||||||
case .write:
|
case .write:
|
||||||
break
|
break
|
||||||
case .displaceThenWrite:
|
case .displaceThenWrite:
|
||||||
// The rescue, not a squatter: a markerless `CLAUDE.md` is user *content*, and it has a
|
// The rescue, not a squatter: markerless content on a claimed name is user *content*, and
|
||||||
// designated destination (08-agent-integration.md ▸ Ownership).
|
// it has a designated destination (08-agent-integration.md ▸ Ownership).
|
||||||
do {
|
do {
|
||||||
try FileManager.default.moveItem(at: guideURL, to: root.appendingPathComponent(userFilename))
|
try FileManager.default.moveItem(at: guideURL, to: root.appendingPathComponent(userFilename))
|
||||||
} catch {
|
} catch {
|
||||||
throw BoardWriteError(
|
throw BoardWriteError(
|
||||||
operation: .agentGuide,
|
operation: .agentGuide,
|
||||||
path: guideURL.path,
|
path: guideURL.path,
|
||||||
reason: .io(message: "could not move the existing \(filename) aside to \(userFilename): \(error.localizedDescription)")
|
reason: .io(message: "could not move the existing \(guideFilename) aside to \(userFilename): \(error.localizedDescription)")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
EchoLedger.current?.recordMove(from: guideURL, to: root.appendingPathComponent(userFilename))
|
EchoLedger.current?.recordMove(from: guideURL, to: root.appendingPathComponent(userFilename))
|
||||||
displaced = Displacement(name: filename, movedTo: userFilename, wasUserContent: true)
|
displaced = Displacement(name: guideFilename, movedTo: userFilename, wasUserContent: true)
|
||||||
case .displaceSquatterThenWrite:
|
case .displaceSquatterThenWrite:
|
||||||
// The claimed-name displacement (ruled 2026-07-29): a folder or symlink on the app's own
|
// 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.
|
// name, moved aside by the Finder ladder and never destroyed.
|
||||||
guard let freed = try BoardWriter.displaceClaimedName(
|
guard let freed = try BoardWriter.displaceClaimedName(
|
||||||
ClaimedNameSquatter(
|
ClaimedNameSquatter(
|
||||||
name: filename,
|
name: guideFilename,
|
||||||
found: IntegrityRules.node(at: guideURL) ?? .directory,
|
found: IntegrityRules.node(at: guideURL) ?? .directory,
|
||||||
expected: .file
|
expected: .file
|
||||||
),
|
),
|
||||||
@@ -311,7 +385,7 @@ enum AgentGuide {
|
|||||||
// anyway. Nothing displaced, nothing written.
|
// anyway. Nothing displaced, nothing written.
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
displaced = Displacement(name: filename, movedTo: freed, wasUserContent: false)
|
displaced = Displacement(name: guideFilename, movedTo: freed, wasUserContent: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
try BoardWriter.atomicReplace(text: content, at: guideURL, operation: .agentGuide)
|
try BoardWriter.atomicReplace(text: content, at: guideURL, operation: .agentGuide)
|
||||||
@@ -327,14 +401,16 @@ enum AgentGuide {
|
|||||||
/// owns is now under a different name — and only the tone differs: the `CLAUDE.user.md` rescue
|
/// 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
|
/// 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
|
/// displacement gets the relocation-style warning-tone notice (01-storage-format.md § Fractal
|
||||||
/// layout ▸ Rules, ruled 2026-07-29).
|
/// layout ▸ Rules, ruled 2026-07-29). `name` names whichever claimed name — `CLAUDE.md` or
|
||||||
|
/// `AGENTS.md` — this particular displacement was about; a call to `install` that touches both can
|
||||||
|
/// produce two of these, one per name.
|
||||||
struct Displacement: Sendable, Equatable {
|
struct Displacement: Sendable, Equatable {
|
||||||
/// The claimed name that was freed.
|
/// The claimed name that was freed.
|
||||||
let name: String
|
let name: String
|
||||||
/// The name the displaced node now has.
|
/// The name the displaced node now has.
|
||||||
let movedTo: String
|
let movedTo: String
|
||||||
/// Whether this was the markerless-`CLAUDE.md` rescue (silent) rather than a squatter's
|
/// Whether this was the markerless-guide rescue (silent) rather than a squatter's displacement
|
||||||
/// displacement (announced).
|
/// (announced).
|
||||||
let wasUserContent: Bool
|
let wasUserContent: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,7 +428,7 @@ enum AgentGuide {
|
|||||||
/// The marker interpolates `version` rather than spelling the number twice: the constant and the
|
/// 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.
|
/// first line cannot drift apart, and a bump is one edit.
|
||||||
private static let guideBody = """
|
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. -->
|
<!-- lanework-agent-guide v\(version) — created and kept up to date by the Lanework app. This guide is written at two names, CLAUDE.md and AGENTS.md, kept byte-identical. Don't edit either file: both are 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
|
# This folder is a Lanework kanban board
|
||||||
|
|
||||||
@@ -370,12 +446,14 @@ enum AgentGuide {
|
|||||||
<board>/ this folder (the board)
|
<board>/ this folder (the board)
|
||||||
├── index.md board title + settings; body = board description
|
├── index.md board title + settings; body = board description
|
||||||
├── CLAUDE.md this guide (app-maintained)
|
├── CLAUDE.md this guide (app-maintained)
|
||||||
|
├── AGENTS.md byte-identical twin of CLAUDE.md (app-maintained)
|
||||||
├── .trash/ deleted cards and lanes (app-managed — see Deleting)
|
├── .trash/ deleted cards and lanes (app-managed — see Deleting)
|
||||||
├── <uuid>/ a LANE
|
├── <uuid>/ a LANE
|
||||||
│ ├── index.md lane title + order; body = lane notes/policy
|
│ ├── index.md lane title + order; body = lane notes/policy
|
||||||
│ ├── <uuid>/ a CARD
|
│ ├── <uuid>/ a CARD
|
||||||
│ │ ├── index.md card title + order; body = the card's content
|
│ │ ├── index.md card title + order; body = the card's content
|
||||||
│ │ └── attachments/ the card's files (flat, top-level)
|
│ │ ├── attachments/ the card's files (flat, top-level)
|
||||||
|
│ │ └── comments/ the card's comment thread (see Comments)
|
||||||
│ └── <uuid>/ another card
|
│ └── <uuid>/ another card
|
||||||
└── <uuid>/ another lane
|
└── <uuid>/ another lane
|
||||||
```
|
```
|
||||||
@@ -444,8 +522,9 @@ enum AgentGuide {
|
|||||||
custom metadata (`project:`, `tags:`, `claimed-by:` …) is safe to add and
|
custom metadata (`project:`, `tags:`, `claimed-by:` …) is safe to add and
|
||||||
survives every app rewrite. Reserved for Lanework's upcoming tracker sync —
|
survives every app rewrite. Reserved for Lanework's upcoming tracker sync —
|
||||||
preserved but not rendered, don't repurpose them: the card keys `labels`,
|
preserved but not rendered, don't repurpose them: the card keys `labels`,
|
||||||
`assignees`, `due`, the `remote` key (cards and board), `remote-state`
|
`assignees`, `due`, the `remote` key (cards and board), and `remote-state`
|
||||||
(lanes), and a card-level `comments/` folder.
|
(lanes). `comments/` is **not** on this list — it's a shipped feature with
|
||||||
|
its own section below, not a reserved name.
|
||||||
|
|
||||||
## Stamping your work: `modified-by`
|
## Stamping your work: `modified-by`
|
||||||
|
|
||||||
@@ -570,6 +649,37 @@ enum AgentGuide {
|
|||||||
- Subfolders under `attachments/` are tolerated but the app never creates
|
- Subfolders under `attachments/` are tolerated but the app never creates
|
||||||
or lists them — keep attachments top-level.
|
or lists them — keep attachments top-level.
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
Every card has a comment thread at `comments/` inside the card folder —
|
||||||
|
a lightweight, chronological log beside the card itself. The card body
|
||||||
|
is the spec; the thread is where work on it gets journaled (see Use the
|
||||||
|
thread below).
|
||||||
|
|
||||||
|
- A comment is `comments/<lowercase-uuid>/index.md`: frontmatter
|
||||||
|
`schema: 1`, `kind: comment`, `author`, `created`, `modified`. **No
|
||||||
|
`title`, no `order`.** A comment may carry its own `attachments/`,
|
||||||
|
one level down from the card's — the same rules as Attachments above
|
||||||
|
apply there too.
|
||||||
|
- **Chronology is the ordering**: the thread sorts by `created`
|
||||||
|
ascending, so the stamp *is* the position. Write real current UTC
|
||||||
|
(`date -u +%FT%TZ`); give a burst of several comments distinct
|
||||||
|
seconds — ties fall back to folder-name order.
|
||||||
|
- **`author` is self-reported and survives app writes** — the
|
||||||
|
deliberate contrast with `modified-by` above, which the app clears on
|
||||||
|
every write of its own. Write your name into `author` once; it
|
||||||
|
sticks through every later app-mediated rewrite of the card or board.
|
||||||
|
- **`comments/.draft` and `comments/.trash` are the app's** — the
|
||||||
|
unposted composer draft and the undo backing store. Never write into
|
||||||
|
either, and never "delete" a comment by moving it there yourself:
|
||||||
|
content no live undo step owns is swept as residue at the next
|
||||||
|
window open. To retract a comment, post a follow-up saying so — never
|
||||||
|
rewrite or remove an existing one.
|
||||||
|
- A comment counts as **edited** when `modified` differs from
|
||||||
|
`created` — that's the whole rule, no separate flag. Fixing a typo
|
||||||
|
is fine; once the conversation has moved past something you wrote,
|
||||||
|
post a follow-up instead of rewriting it.
|
||||||
|
|
||||||
## Hard rules (the app fails loudly on violations)
|
## Hard rules (the app fails loudly on violations)
|
||||||
|
|
||||||
- Frontmatter must parse as YAML. The board's own `index.md` must carry
|
- Frontmatter must parse as YAML. The board's own `index.md` must carry
|
||||||
@@ -611,9 +721,40 @@ enum AgentGuide {
|
|||||||
- **Commit your own changes, with clear messages** — nothing else will
|
- **Commit your own changes, with clear messages** — nothing else will
|
||||||
commit them for you, and a semantic message ("Move card 'Fix login' to
|
commit them for you, and a semantic message ("Move card 'Fix login' to
|
||||||
Doing") is the history the user will actually read.
|
Doing") is the history the user will actually read.
|
||||||
- **Leave the app-maintained files to the app** — this guide and the
|
- **Leave the app-maintained files to the app** — this guide (both
|
||||||
seeded `.gitignore` are rewritten by Lanework when they need to be;
|
`CLAUDE.md` and its `AGENTS.md` twin) and the seeded `.gitignore` are
|
||||||
don't edit or delete them, and don't commit changes to the user's other
|
rewritten by Lanework when they need to be; don't edit or delete
|
||||||
files that you didn't make.
|
them, and don't commit changes to the user's other files that you
|
||||||
|
didn't make.
|
||||||
|
|
||||||
|
## Use the thread: journal your work
|
||||||
|
|
||||||
|
The card's body is the spec; its comment thread (see Comments above)
|
||||||
|
is the journal. Edit the body when scope, constraints, or done-when
|
||||||
|
change. Everything else — progress, in-the-moment thinking, questions —
|
||||||
|
belongs in the thread.
|
||||||
|
|
||||||
|
- **Starting work on a card**: post a comment with your plan and every
|
||||||
|
decision already made, rejected alternatives included, written for a
|
||||||
|
reader with none of your context — the user checking in later,
|
||||||
|
another agent, or your own future session picking the card back up
|
||||||
|
cold.
|
||||||
|
- **While working**: record decisions as you make them, not
|
||||||
|
reconstructed afterward. Outcomes live elsewhere — commits, diffs —
|
||||||
|
so the thread's job is the *why*, the routes you didn't take, and
|
||||||
|
any limitation you knowingly accepted.
|
||||||
|
- **Questions**: post them as comments. The app narrates arrivals by
|
||||||
|
path shape ("Comment on '⟨card⟩'"), so a posted question genuinely
|
||||||
|
reaches the user live. Answers come back as later comments —
|
||||||
|
**re-read the whole thread before resuming any card**, not just the
|
||||||
|
last entry.
|
||||||
|
- **Finishing**: close with verification evidence — what you ran, what
|
||||||
|
it showed — and name anything you did differently from what the
|
||||||
|
card asked.
|
||||||
|
- **Comments never stamp the card**: posting one leaves the card's own
|
||||||
|
`modified` and `modified-by` alone, the same as the app's own posts.
|
||||||
|
|
||||||
|
Per-board process — lane cadence, commit conventions, anything specific
|
||||||
|
to this board — belongs in `CLAUDE.user.md`, not here.
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,9 +215,15 @@ public enum IntegrityRules: Sendable {
|
|||||||
/// `CLAUDE.md`'s squatter is displaced by the **agent guide's** own heal, which already owns
|
/// `CLAUDE.md`'s squatter is displaced by the **agent guide's** own heal, which already owns
|
||||||
/// that file's whole decision (`AgentGuide.Decision.displaceSquatterThenWrite`); `.trash`'s is
|
/// that file's whole decision (`AgentGuide.Decision.displaceSquatterThenWrite`); `.trash`'s is
|
||||||
/// its own scheduled heal, because nothing else ever writes that name.
|
/// its own scheduled heal, because nothing else ever writes that name.
|
||||||
|
///
|
||||||
|
/// **`AGENTS.md` joined 2026-08-09** (card dc1314bb, 08-agent-integration.md ▸ The agent guide):
|
||||||
|
/// the byte-identical twin of `CLAUDE.md`, claimed and displaced on exactly the same terms — its
|
||||||
|
/// squatter is the agent guide's own heal too (`AgentGuide.install` runs the same decision against
|
||||||
|
/// both names), so it is not answered by `squattedClaimedName(atBoardRoot:)` below either.
|
||||||
public static let claimedRootNames: [ClaimedName] = [
|
public static let claimedRootNames: [ClaimedName] = [
|
||||||
ClaimedName(name: trashFolderName, expected: .directory, displacesSquatters: true),
|
ClaimedName(name: trashFolderName, expected: .directory, displacesSquatters: true),
|
||||||
ClaimedName(name: "CLAUDE.md", expected: .file, displacesSquatters: true),
|
ClaimedName(name: "CLAUDE.md", expected: .file, displacesSquatters: true),
|
||||||
|
ClaimedName(name: "AGENTS.md", expected: .file, displacesSquatters: true),
|
||||||
ClaimedName(name: "CLAUDE.user.md", expected: .file, displacesSquatters: false),
|
ClaimedName(name: "CLAUDE.user.md", expected: .file, displacesSquatters: false),
|
||||||
ClaimedName(name: gitignoreFileName, expected: .file, displacesSquatters: false),
|
ClaimedName(name: gitignoreFileName, expected: .file, displacesSquatters: false),
|
||||||
]
|
]
|
||||||
@@ -291,9 +297,9 @@ public enum IntegrityRules: Sendable {
|
|||||||
/// (01-storage-format.md § Fractal layout ▸ Rules, ruled 2026-07-29).
|
/// (01-storage-format.md § Fractal layout ▸ Rules, ruled 2026-07-29).
|
||||||
///
|
///
|
||||||
/// Only names whose `displacesSquatters` is `true` can produce one, and only `.trash` is
|
/// Only names whose `displacesSquatters` is `true` can produce one, and only `.trash` is
|
||||||
/// answered here: `CLAUDE.md`'s squatter is the agent guide's, detected by `AgentGuide.inspect`
|
/// answered here: `CLAUDE.md` and its `AGENTS.md` twin's squatters are the agent guide's,
|
||||||
/// in the same read that decides everything else about that file, so answering it twice would be
|
/// detected by `AgentGuide.inspect` in the same read that decides everything else about each
|
||||||
/// two mechanisms racing to displace one node.
|
/// file, so answering either here too would be two mechanisms racing to displace one node.
|
||||||
public static func squattedClaimedName(atBoardRoot root: URL) -> ClaimedNameSquatter? {
|
public static func squattedClaimedName(atBoardRoot root: URL) -> ClaimedNameSquatter? {
|
||||||
guard let claimed = claimedRootNames.first(where: { $0.name == trashFolderName }),
|
guard let claimed = claimedRootNames.first(where: { $0.name == trashFolderName }),
|
||||||
let found = node(at: root.appendingPathComponent(claimed.name)),
|
let found = node(at: root.appendingPathComponent(claimed.name)),
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ private func guideURL(in fixture: WriterFixture) -> URL {
|
|||||||
fixture.root.appendingPathComponent(AgentGuide.filename)
|
fixture.root.appendingPathComponent(AgentGuide.filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `AGENTS.md` twin's URL — `guideURL`'s counterpart, for the tests that pin the twin promise.
|
||||||
|
private func agentsURL(in fixture: WriterFixture) -> URL {
|
||||||
|
fixture.root.appendingPathComponent(AgentGuide.agentsFilename)
|
||||||
|
}
|
||||||
|
|
||||||
private func userFileURL(in fixture: WriterFixture) -> URL {
|
private func userFileURL(in fixture: WriterFixture) -> URL {
|
||||||
fixture.root.appendingPathComponent(AgentGuide.userFilename)
|
fixture.root.appendingPathComponent(AgentGuide.userFilename)
|
||||||
}
|
}
|
||||||
@@ -252,9 +257,12 @@ struct AgentGuideStoreTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
// Deliberately *not* the app's own text: a current marker is the whole gate, and a file
|
// Deliberately *not* the app's own text: a current marker is the whole gate, and a file
|
||||||
// carrying one must survive verbatim even when its body differs from what this version
|
// carrying one must survive verbatim even when its body differs from what this version
|
||||||
// would write.
|
// would write. Both claimed names, so the twin doesn't itself count as work needing a
|
||||||
|
// bracket — this test is about the current-guide gate, not the twin write.
|
||||||
let url = try writeRoot(AgentGuide.filename, Data(guideText(version: AgentGuide.version).utf8), in: fixture)
|
let url = try writeRoot(AgentGuide.filename, Data(guideText(version: AgentGuide.version).utf8), in: fixture)
|
||||||
|
let agentsURLValue = try writeRoot(AgentGuide.agentsFilename, Data(guideText(version: AgentGuide.version).utf8), in: fixture)
|
||||||
let before = try stat(url)
|
let before = try stat(url)
|
||||||
|
let agentsBefore = try stat(agentsURLValue)
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let brackets = GuideBracketLog()
|
let brackets = GuideBracketLog()
|
||||||
brackets.attach(to: store)
|
brackets.attach(to: store)
|
||||||
@@ -262,8 +270,11 @@ struct AgentGuideStoreTests {
|
|||||||
store.refreshAgentGuide()
|
store.refreshAgentGuide()
|
||||||
|
|
||||||
let after = try stat(url)
|
let after = try stat(url)
|
||||||
|
let agentsAfter = try stat(agentsURLValue)
|
||||||
#expect(after.bytes == before.bytes)
|
#expect(after.bytes == before.bytes)
|
||||||
#expect(after.modified == before.modified)
|
#expect(after.modified == before.modified)
|
||||||
|
#expect(agentsAfter.bytes == agentsBefore.bytes)
|
||||||
|
#expect(agentsAfter.modified == agentsBefore.modified)
|
||||||
#expect(brackets.begins == 0, "no write means no bracket and no commit")
|
#expect(brackets.begins == 0, "no write means no bracket and no commit")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,17 +341,21 @@ struct AgentGuideStoreTests {
|
|||||||
#expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
|
#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
|
/// "Otherwise the guide write is skipped with a log — user content is never destroyed." Three
|
||||||
/// files the user owns, both still theirs afterwards, and no guide on this board at all.
|
/// files the user owns (both claimed guide names, foreign, plus the rescue destination already
|
||||||
|
/// taken), all 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")
|
@Test("A taken CLAUDE.user.md leaves both files alone and writes no guide")
|
||||||
func takenUserFilenameSkipsTheWrite() throws {
|
func takenUserFilenameSkipsTheWrite() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let mine = "# Board rules\n"
|
let mine = "# Board rules\n"
|
||||||
|
let mineToo = "# Board rules, the AGENTS.md copy\n"
|
||||||
let theirs = "# Older board rules\n"
|
let theirs = "# Older board rules\n"
|
||||||
let guide = try writeRoot(AgentGuide.filename, Data(mine.utf8), in: fixture)
|
let guide = try writeRoot(AgentGuide.filename, Data(mine.utf8), in: fixture)
|
||||||
|
let agentsGuide = try writeRoot(AgentGuide.agentsFilename, Data(mineToo.utf8), in: fixture)
|
||||||
let user = try writeRoot(AgentGuide.userFilename, Data(theirs.utf8), in: fixture)
|
let user = try writeRoot(AgentGuide.userFilename, Data(theirs.utf8), in: fixture)
|
||||||
let guideBefore = try stat(guide)
|
let guideBefore = try stat(guide)
|
||||||
|
let agentsGuideBefore = try stat(agentsGuide)
|
||||||
let userBefore = try stat(user)
|
let userBefore = try stat(user)
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let brackets = GuideBracketLog()
|
let brackets = GuideBracketLog()
|
||||||
@@ -350,6 +365,8 @@ struct AgentGuideStoreTests {
|
|||||||
|
|
||||||
#expect(try stat(guide).bytes == guideBefore.bytes)
|
#expect(try stat(guide).bytes == guideBefore.bytes)
|
||||||
#expect(try stat(guide).modified == guideBefore.modified)
|
#expect(try stat(guide).modified == guideBefore.modified)
|
||||||
|
#expect(try stat(agentsGuide).bytes == agentsGuideBefore.bytes)
|
||||||
|
#expect(try stat(agentsGuide).modified == agentsGuideBefore.modified)
|
||||||
#expect(try stat(user).bytes == userBefore.bytes)
|
#expect(try stat(user).bytes == userBefore.bytes)
|
||||||
#expect(try stat(user).modified == userBefore.modified)
|
#expect(try stat(user).modified == userBefore.modified)
|
||||||
#expect(brackets.begins == 0)
|
#expect(brackets.begins == 0)
|
||||||
@@ -539,6 +556,10 @@ struct AgentGuideStoreTests {
|
|||||||
// one, renames around it, or overwrites it.
|
// one, renames around it, or overwrites it.
|
||||||
try writeRoot(AgentGuide.filename, Data("# mine\n".utf8), in: fixture)
|
try writeRoot(AgentGuide.filename, Data("# mine\n".utf8), in: fixture)
|
||||||
try fixture.file("\(AgentGuide.userFilename)/inside.txt", Data("inside".utf8))
|
try fixture.file("\(AgentGuide.userFilename)/inside.txt", Data("inside".utf8))
|
||||||
|
// The twin is already current throughout: this test's whole point is the memo behavior on
|
||||||
|
// `CLAUDE.md`'s own skip/rescue arc, so `AGENTS.md` sits out every reload as a quiet
|
||||||
|
// `.leaveAlone` — never contributing to the signature, never itself opening a bracket.
|
||||||
|
try writeRoot(AgentGuide.agentsFilename, Data(AgentGuide.content.utf8), in: fixture)
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let brackets = GuideBracketLog()
|
let brackets = GuideBracketLog()
|
||||||
brackets.attach(to: store)
|
brackets.attach(to: store)
|
||||||
@@ -552,6 +573,7 @@ struct AgentGuideStoreTests {
|
|||||||
#expect(brackets.begins == 0, "no write was ever attempted")
|
#expect(brackets.begins == 0, "no write was ever attempted")
|
||||||
#expect(store.banners.oneShots.isEmpty, "and no row was posted, once or four times")
|
#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.filename) == Data("# mine\n".utf8))
|
||||||
|
#expect(try fixture.data(AgentGuide.agentsFilename) == Data(AgentGuide.content.utf8), "the already-current twin stayed untouched too")
|
||||||
#expect(try fixture.data("\(AgentGuide.userFilename)/inside.txt") == Data("inside".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.
|
// The name frees up: a different picture, so a fresh attempt — and the rescue finally runs.
|
||||||
@@ -561,6 +583,7 @@ struct AgentGuideStoreTests {
|
|||||||
|
|
||||||
#expect(try fixture.data(AgentGuide.userFilename) == Data("# mine\n".utf8))
|
#expect(try fixture.data(AgentGuide.userFilename) == Data("# mine\n".utf8))
|
||||||
#expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
|
#expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
|
||||||
|
#expect(try fixture.data(AgentGuide.agentsFilename) == Data(AgentGuide.content.utf8), "still exactly current — this bracket was entirely CLAUDE.md's")
|
||||||
#expect(brackets.begins == 1)
|
#expect(brackets.begins == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,6 +601,127 @@ struct AgentGuideStoreTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - 3b. The AGENTS.md twin (ruled 2026-08-09, card dc1314bb)
|
||||||
|
|
||||||
|
/// The twin promise, exercised where it costs something: both claimed names get the identical guide,
|
||||||
|
/// upgrade independently, and heal independently — the "byte-identical, no drift" claim proved on
|
||||||
|
/// disk rather than merely read off the source.
|
||||||
|
@MainActor
|
||||||
|
@Suite("Agent guide ▸ the twin")
|
||||||
|
struct AgentGuideTwinTests {
|
||||||
|
|
||||||
|
@Test("A board with neither claimed name gets both, byte-identical, in one bracket")
|
||||||
|
func freshBoardGetsBothFilesByteIdentical() 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 claude = try fixture.data(AgentGuide.filename)
|
||||||
|
let agents = try fixture.data(AgentGuide.agentsFilename)
|
||||||
|
#expect(claude == Data(AgentGuide.content.utf8))
|
||||||
|
#expect(agents == claude, "byte-identical, not merely equal-content")
|
||||||
|
#expect(brackets.begins == 1, "one bracket for both files, one app-mediated reload")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Both claimed names at an older version are upgraded to the current one, identically")
|
||||||
|
func bothOlderFilesAreUpgradedIdentically() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try writeRoot(AgentGuide.filename, Data(guideText(version: 4).utf8), in: fixture)
|
||||||
|
try writeRoot(AgentGuide.agentsFilename, Data(guideText(version: 4).utf8), in: fixture)
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
store.refreshAgentGuide()
|
||||||
|
|
||||||
|
let claude = try fixture.data(AgentGuide.filename)
|
||||||
|
let agents = try fixture.data(AgentGuide.agentsFilename)
|
||||||
|
#expect(claude == Data(AgentGuide.content.utf8))
|
||||||
|
#expect(agents == claude)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The per-file independence the design leans on: one claimed name being current must never gate
|
||||||
|
/// the other's write, and vice versa.
|
||||||
|
@Test("A stale AGENTS.md is upgraded even while CLAUDE.md is already current")
|
||||||
|
func staleAgentsFileUpgradesAloneWhenClaudeIsCurrent() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let claudeURL = try writeRoot(AgentGuide.filename, Data(AgentGuide.content.utf8), in: fixture)
|
||||||
|
try writeRoot(AgentGuide.agentsFilename, Data(guideText(version: 4).utf8), in: fixture)
|
||||||
|
let claudeBefore = try stat(claudeURL)
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
store.refreshAgentGuide()
|
||||||
|
|
||||||
|
let claudeAfter = try stat(claudeURL)
|
||||||
|
#expect(claudeAfter.bytes == claudeBefore.bytes)
|
||||||
|
#expect(claudeAfter.modified == claudeBefore.modified, "current guide untouched, even while its twin is rewritten")
|
||||||
|
#expect(try fixture.data(AgentGuide.agentsFilename) == Data(AgentGuide.content.utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A stale CLAUDE.md is upgraded even while AGENTS.md is already current")
|
||||||
|
func staleClaudeFileUpgradesAloneWhenAgentsIsCurrent() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try writeRoot(AgentGuide.filename, Data(guideText(version: 4).utf8), in: fixture)
|
||||||
|
let agentsURLBefore = try writeRoot(AgentGuide.agentsFilename, Data(AgentGuide.content.utf8), in: fixture)
|
||||||
|
let agentsBefore = try stat(agentsURLBefore)
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
store.refreshAgentGuide()
|
||||||
|
|
||||||
|
let agentsAfter = try stat(agentsURLBefore)
|
||||||
|
#expect(agentsAfter.bytes == agentsBefore.bytes)
|
||||||
|
#expect(agentsAfter.modified == agentsBefore.modified)
|
||||||
|
#expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A folder or symlink squatting `AGENTS.md` is displaced exactly as one squatting `CLAUDE.md`
|
||||||
|
/// is — the level-uniform claimed-name rule extended to the twin.
|
||||||
|
@Test("A folder squatting AGENTS.md is displaced whole, contents intact, and the guide takes its place")
|
||||||
|
func squattedAgentsFileIsDisplaced() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.file("\(AgentGuide.agentsFilename)/inside.txt", Data("inside".utf8))
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
store.refreshAgentGuide()
|
||||||
|
|
||||||
|
#expect(try fixture.data("AGENTS 2.md/inside.txt") == Data("inside".utf8))
|
||||||
|
#expect(try fixture.data(AgentGuide.agentsFilename) == Data(AgentGuide.content.utf8))
|
||||||
|
#expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The design decision on the shared rescue name**: both claimed names funnel a markerless
|
||||||
|
/// foreign file to the same single `CLAUDE.user.md` — there is no second `AGENTS.user.md`. When
|
||||||
|
/// both are foreign at once, `filename` (processed first) wins the rescue and `agentsFilename`
|
||||||
|
/// finds the name taken and skips — the standing "two files the user owns, both left alone"
|
||||||
|
/// contract, just reachable from either starting name.
|
||||||
|
@Test("Markerless content on both claimed names at once: the first rescues, the second skips")
|
||||||
|
func simultaneousMarkerlessContentOnBothNamesRescuesOnlyTheFirst() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let claudeMine = "# My CLAUDE.md notes\n"
|
||||||
|
let agentsMine = "# My AGENTS.md notes\n"
|
||||||
|
try writeRoot(AgentGuide.filename, Data(claudeMine.utf8), in: fixture)
|
||||||
|
try writeRoot(AgentGuide.agentsFilename, Data(agentsMine.utf8), in: fixture)
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
store.refreshAgentGuide()
|
||||||
|
|
||||||
|
// `filename` is processed first: its content is rescued to the shared destination, and the
|
||||||
|
// guide takes its place.
|
||||||
|
#expect(try fixture.data(AgentGuide.userFilename) == Data(claudeMine.utf8))
|
||||||
|
#expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
|
||||||
|
// `agentsFilename` finds `CLAUDE.user.md` taken by the time it's inspected: its own content
|
||||||
|
// is left exactly where it was, untouched — never destroyed, never silently adopted either.
|
||||||
|
#expect(try fixture.data(AgentGuide.agentsFilename) == Data(agentsMine.utf8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - 4. Through the registry, with a real watcher
|
// MARK: - 4. Through the registry, with a real watcher
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -769,4 +913,59 @@ struct AgentGuideContentTests {
|
|||||||
#expect(!content.contains("moves don't stamp"))
|
#expect(!content.contains("moves don't stamp"))
|
||||||
#expect(!content.contains("moves-don't-stamp"))
|
#expect(!content.contains("moves-don't-stamp"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **v14: the Comments chapter** — mechanics only (01-storage-format.md § Enhanced schema is the
|
||||||
|
/// authority; conduct is a separate section, tested below). Each pin is a phrase an agent reading
|
||||||
|
/// the guide would actually see.
|
||||||
|
@Test("v14 teaches the comment thread's mechanics")
|
||||||
|
func v14CommentsVocabularyIsPresent() {
|
||||||
|
let content = AgentGuide.content
|
||||||
|
#expect(content.contains("## Comments"))
|
||||||
|
#expect(content.contains("comments/<lowercase-uuid>/index.md"))
|
||||||
|
#expect(content.contains("**No\n `title`, no `order`.**"))
|
||||||
|
#expect(content.contains("**Chronology is the ordering**"))
|
||||||
|
#expect(content.contains("**`author` is self-reported and survives app writes**"))
|
||||||
|
#expect(content.contains("**`comments/.draft` and `comments/.trash` are the app's**"))
|
||||||
|
#expect(content.contains("post a follow-up saying so — never\n rewrite or remove an existing one"))
|
||||||
|
#expect(content.contains("counts as **edited** when `modified` differs from"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **v14: the conduct chapter** — the guide's first word on *when* to post, not just how
|
||||||
|
/// (08-agent-integration.md ▸ Use the thread). Card body vs. thread, START-with-a-plan, questions
|
||||||
|
/// as comments, re-read before resuming, verification on close, and the no-stamp guarantee.
|
||||||
|
@Test("v14 teaches thread conduct: journal your work")
|
||||||
|
func v14ConductVocabularyIsPresent() {
|
||||||
|
let content = AgentGuide.content
|
||||||
|
#expect(content.contains("## Use the thread: journal your work"))
|
||||||
|
#expect(content.contains("The card's body is the spec; its comment thread"))
|
||||||
|
#expect(content.contains("**Starting work on a card**"))
|
||||||
|
#expect(content.contains("written for a\n reader with none of your context"))
|
||||||
|
#expect(content.contains("**Questions**: post them as comments."))
|
||||||
|
#expect(content.contains("**re-read the whole thread before resuming any card**"))
|
||||||
|
#expect(content.contains("**Finishing**: close with verification evidence"))
|
||||||
|
#expect(content.contains("**Comments never stamp the card**"))
|
||||||
|
#expect(content.contains("belongs in `CLAUDE.user.md`, not here."))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **v14: `comments/` leaves the reserved-keys list** — it shipped, so it is no longer a name
|
||||||
|
/// agents are told to avoid; the tracker-integration keys are what remains reserved.
|
||||||
|
@Test("v14 corrects the reserved-keys list: comments/ is gone, the tracker keys remain")
|
||||||
|
func v14ReservedKeysCorrectionIsPresent() {
|
||||||
|
let content = AgentGuide.content
|
||||||
|
#expect(content.contains("`comments/` is **not** on this list"))
|
||||||
|
#expect(content.contains("`labels`,\n`assignees`, `due`, the `remote` key (cards and board), and `remote-state`"))
|
||||||
|
#expect(!content.contains("and a card-level `comments/` folder"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **v14: the `AGENTS.md` twin** — named in the marker line, the Layout diagram, and the Git
|
||||||
|
/// section's "leave the app-maintained files alone" bullet, so an agent reading either copy of
|
||||||
|
/// the guide learns the other exists.
|
||||||
|
@Test("v14 names the AGENTS.md twin in the marker, the layout, and Git")
|
||||||
|
func v14TwinVocabularyIsPresent() {
|
||||||
|
let content = AgentGuide.content
|
||||||
|
#expect(content.contains("This guide is written at two names, CLAUDE.md and AGENTS.md, kept byte-identical."))
|
||||||
|
#expect(content.contains("AGENTS.md byte-identical twin of CLAUDE.md (app-maintained)"))
|
||||||
|
#expect(content.contains("comments/ the card's comment thread (see Comments)"))
|
||||||
|
#expect(content.contains("this guide (both\n `CLAUDE.md` and its `AGENTS.md` twin) and the seeded `.gitignore`"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -842,6 +842,23 @@ struct BoardWriterCreateBoardTests {
|
|||||||
#expect(guideText == AgentGuide.content, "the exact shipped guide, not a hand-rolled write")
|
#expect(guideText == AgentGuide.content, "the exact shipped guide, not a hand-rolled write")
|
||||||
#expect(!fixture.exists("MyBoard.kanban/\(AgentGuide.userFilename)"), "nothing existed to rescue at a brand-new root")
|
#expect(!fixture.exists("MyBoard.kanban/\(AgentGuide.userFilename)"), "nothing existed to rescue at a brand-new root")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **The twin** (ruled 2026-08-09, card dc1314bb): `createBoard` writes `AGENTS.md` alongside
|
||||||
|
/// `CLAUDE.md`, byte-identical — a fresh board never lives even a moment with only one of the two
|
||||||
|
/// claimed guide names.
|
||||||
|
@Test("createBoard installs the AGENTS.md twin too, byte-identical to CLAUDE.md")
|
||||||
|
func createBoardInstallsTheAgentsMdTwin() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let root = fixture.url("MyBoard.kanban")
|
||||||
|
|
||||||
|
try BoardWriter.createBoard(at: root, title: "My Board")
|
||||||
|
|
||||||
|
let claudeText = try fixture.data("MyBoard.kanban/\(AgentGuide.filename)")
|
||||||
|
let agentsText = try fixture.data("MyBoard.kanban/\(AgentGuide.agentsFilename)")
|
||||||
|
#expect(agentsText == claudeText, "byte-identical, not merely equal-content")
|
||||||
|
#expect(agentsText == Data(AgentGuide.content.utf8))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Create lane / card
|
// MARK: - Create lane / card
|
||||||
|
|||||||
@@ -89,6 +89,11 @@ struct IntegrityReservedNameTests {
|
|||||||
#expect(guide.expected == .file)
|
#expect(guide.expected == .file)
|
||||||
#expect(guide.displacesSquatters)
|
#expect(guide.displacesSquatters)
|
||||||
|
|
||||||
|
// The AGENTS.md twin (ruled 2026-08-09): claimed on the identical terms as CLAUDE.md.
|
||||||
|
let agentsTwin = try #require(IntegrityRules.claimedRootNames.first { $0.name == "AGENTS.md" })
|
||||||
|
#expect(agentsTwin.expected == .file)
|
||||||
|
#expect(agentsTwin.displacesSquatters)
|
||||||
|
|
||||||
// The standing exception: a rescue *destination* is never itself freed by a second
|
// The standing exception: a rescue *destination* is never itself freed by a second
|
||||||
// displacement, which would cascade renames (08-agent-integration.md ▸ Ownership).
|
// displacement, which would cascade renames (08-agent-integration.md ▸ Ownership).
|
||||||
let userFile = try #require(IntegrityRules.claimedRootNames.first { $0.name == "CLAUDE.user.md" })
|
let userFile = try #require(IntegrityRules.claimedRootNames.first { $0.name == "CLAUDE.user.md" })
|
||||||
|
|||||||
@@ -30,16 +30,18 @@ import Testing
|
|||||||
|
|
||||||
/// 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** and the **seeded `.gitignore`**, which is what any board the
|
/// It carries a **current agent guide, at both claimed names**, and the **seeded `.gitignore`**,
|
||||||
/// app has opened once looks like (08-agent-integration.md ▸ The agent guide; 06-history-undo.md ▸
|
/// which is what any board the app has opened once looks like (08-agent-integration.md ▸ The agent
|
||||||
/// Repository hygiene). Without them the store's own scheduled heals — the guide refresh and the
|
/// guide; 06-history-undo.md ▸ Repository hygiene). Without them the store's own scheduled heals —
|
||||||
/// seed, which run on every successful reload beside this file's relocation — would write those two
|
/// the guide refresh (both `CLAUDE.md` and its `AGENTS.md` twin) and the seed, which run on every
|
||||||
/// files on the first reload and open brackets of their own, and the bracket counts below would stop
|
/// successful reload beside this file's relocation — would write those files on the first reload and
|
||||||
/// being claims about the relocation.
|
/// open brackets of their own, and the bracket counts below would stop being claims about the
|
||||||
|
/// relocation.
|
||||||
private func makeCardBoard(gitignore: String = BoardWriter.gitignoreSeed) throws -> WriterFixture {
|
private func makeCardBoard(gitignore: String = BoardWriter.gitignoreSeed) 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.file(AgentGuide.filename, Data(AgentGuide.content.utf8))
|
||||||
|
try fixture.file(AgentGuide.agentsFilename, Data(AgentGuide.content.utf8))
|
||||||
try fixture.file(IntegrityRules.gitignoreFileName, Data(gitignore.utf8))
|
try fixture.file(IntegrityRules.gitignoreFileName, Data(gitignore.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"))
|
||||||
|
|||||||
Reference in New Issue
Block a user