import Foundation

/// The board-embedded agent guide: a `CLAUDE.md` the app silently maintains at every board root,
/// teaching any file-capable agent how to read and write the board directly (08-agent-integration.md
/// ▸ The agent guide). The folder is the API, and this is the file an agent working inside the
/// folder will actually find.
///
/// **App-owned and version-gated.** The first line carries `lanework-agent-guide vN`. The guide is
/// rewritten when it is **missing or older**, and left **byte-for-byte untouched** when it is
/// current or newer — "never downgraded" (08): a newer app version may have written it, and an
/// older copy of the app opening the board must not walk it back. Untouched means untouched: a
/// current guide is never reopened for writing, so its mtime — and, on git boards, the tree — is
/// undisturbed by an open.
///
/// **The marker is read from the first line and nowhere else**, which is 08's own wording and a
/// deliberate divergence from the pathfinder's anywhere-in-the-file match: a user's own `CLAUDE.md`
/// that merely *quotes* the marker (a note about this feature, a pasted excerpt) would otherwise
/// read as an app-owned guide and be silently overwritten.
///
/// **A markerless `CLAUDE.md` is displaced, never clobbered** (08 ▸ Ownership). It is user content
/// sitting on a name the app claims, so it moves — byte-preserving, `FileManager.moveItem` — to
/// `CLAUDE.user.md` when that name is free, and the guide is written in its place. When the name is
/// taken the guide write is **skipped entirely**: user content is never destroyed, and a board
/// without a guide is a board that merely lacks a courtesy. `CLAUDE.user.md` is otherwise never
/// written, read, upgraded or validated by this app — that one rescue is its only creation
/// (08 ▸ `CLAUDE.user.md`).
///
/// **A folder or symlink squatting `CLAUDE.md` is displaced too** (ruled 2026-07-29 — the
/// claimed-names rule, 01-storage-format.md § Fractal layout ▸ Rules): Lanework owns the board, so
/// an invalid artifact on a name the app claims is a defect rather than a resident. It moves aside
/// by the Finder-style rename ladder (`CLAUDE.md` → `CLAUDE.md 2`) — preserved verbatim, a symlink
/// moved as a link and never followed — and the guide is written on the freed name, with a
/// warning-tone notice naming old and new. This replaced an untouchable-skip; what survives from it
/// is displacement-never-destruction.
///
/// **Nothing here is a user-facing event.** Every refusal below is a log line and nothing more; the
/// only thing that can reach a banner is a genuine I/O failure of the write itself, because
/// `BoardStore.performWrite` posts every `BoardWriteError` it sees. The scheduling — when this is
/// consulted, and why it is safe to consult on every reload — lives at
/// `BoardStore.refreshAgentGuide()`.
///
/// **`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 {

    // MARK: - The 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 `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
    /// 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"

    /// The guide the app ships. **v4 was the pathfinder's**, and real boards carry it; v5 is the
    /// rewrite's guide (lanes, `.trash/`, `attachments/`, `modified-by`, the `CLAUDE.user.md`
    /// pointer) and supersedes it on the next open. v6 adds the one-folder-at-a-time move warning:
    /// a real agent incident (2026-07-29) showed `mv <lane>/*` sweeping the lane's own `index.md`
    /// along with the cards and destroying the destination lane's identity — the guide now says
    /// *why* the named-folder form is load-bearing, not just what to type. v7 teaches two more
    /// things settled after v6 shipped: the refined stamp-discipline predicate
    /// (01-storage-format.md ▸ `modified`'s scope, ruled 2026-07-29, refined 2026-07-30) — a
    /// within-container reorder rewrites only `order`, while a move that changes an item's
    /// container (another lane, another board, into or out of `.trash/`) stamps `modified` and
    /// `modified-by` like any content edit, the trash move included, since it's the same rule and
    /// not a special case — and the card-level `attachments` claimed name
    /// (01-storage-format.md § Fractal layout ▸ Rules, "level-uniform"): that name belongs to the
    /// app's own folder, so a *file* by that name is a defect the app displaces on sight. **v8
    /// retires the trash's arrival rank** (01-storage-format.md § Deletion and 03-board-ui.md ▸
    /// Trash, re-ruled 2026-07-31; 08-agent-integration.md's own line): the trash sorts by `modified`
    /// descending, so there is no rank to mint on the way in — the guide's smallest-`order`-minus-1024
    /// formula is replaced by "restamp `modified`, leave `order` alone", which is the same stamp
    /// discipline v7 already taught, now doing the ordering as well. **v9 teaches the one-line
    /// value**, from a real agent incident (2026-07-31): a card's `title` was a double-quoted
    /// scalar wrapped across two lines, and a hand copy of that card onto another board took the
    /// first line without its continuation. An unclosed quote does not stop at the key it began
    /// on — it runs to the end of the block — so a board failed to load over a file whose only
    /// defect was a missing second line, and the parser's complaint pointed at the *last* key it
    /// swallowed rather than the title. The guide now teaches long values as one long line, says
    /// why the wrapped shape is the dangerous one to copy, and names the unterminated quote beside
    /// the unquoted colon in Hard rules. **v10 makes `order` and `schema` optional below the board
    /// root** (01-storage-format.md § Frontmatter and § Ordering, re-ruled 2026-07-31): the guide's
    /// whole point is that filing a card must need nothing but the schema
    /// (08-agent-integration.md's masterplan requirement), and until now that was untrue — a card
    /// needed a rank, and a rank needed a scan of every sibling in the lane. The guide now teaches
    /// the zero-read minimum (`mkdir` plus one `index.md`, no `order`, no `schema`; it lands at the
    /// lane's bottom and the app stamps a real rank on its first touch) while still teaching
    /// *writing* `order` as the way to control position, which is the only way to control it.
    /// **v11 rewrites Git for the excision** (strategy/01-git-excision.md, ruled 2026-08-08):
    /// app-managed git is gone — the app never runs git, and the auto-commit the old section
    /// promised no longer exists, so shipping v10's text would document machinery to agents that
    /// isn't there. The section now teaches repo-resident etiquette alone — the format stays
    /// deliberately git-friendly, a board may live in a repository of the user's own, and there an
    /// agent stages only its own paths, commits its own changes with clear messages, and leaves the
    /// app-maintained files to the app. The stamping section drops its auto-commit clause the same
    /// way. **v12 names the lane's `collapsed` key** (03-board-ui.md § Lane ▸ Collapsed lanes): a lane
    /// folded to a slim strip is document state exactly like `width`, so an agent can fold and unfold
    /// lanes by editing frontmatter — and, more to the point, has to know that `collapsed: true` is why
    /// a lane it wrote a card into is not showing it. One clause beside `width` in Frontmatter, with the
    /// remove-to-expand rule stated because writing `false` is the mistake the key invites.
    /// **v13 names the card's `hero` key** (03-board-ui.md § Card face ▸ Hero image): a card face draws
    /// one of the card's own attachments as a banner, and since there is no in-app setter this version,
    /// 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 —
    /// because a path is exactly what an agent that has just written `![](attachments/x.png)` into a
    /// body will reach for. **v14 teaches the comment thread — mechanics and conduct — and ships the
    /// `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

    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 one claimed guide name (`CLAUDE.md` or `AGENTS.md`), as the filesystem
    /// answers it — no policy, so the rule below can be a pure function of it.
    enum Existing: Equatable {
        /// Nothing at that path (or nothing this process can read there — the same non-event).
        case missing

        /// A regular file. `text` is its strict UTF-8 decoding, `nil` when it does not decode:
        /// reads are strict everywhere in this app (01-storage-format.md § Encoding), and a file
        /// the app cannot read is a file whose marker it cannot honestly claim to have checked.
        case file(text: String?)

        /// A symlink, a directory, or any other node that is not a regular file — **a squatter on a
        /// claimed name**, and since 2026-07-29 not a resident (01-storage-format.md § Fractal
        /// layout ▸ Rules: "Lanework owns the board, so an invalid artifact on a claimed name is a
        /// defect, not a resident").
        ///
        /// It is moved aside by the Finder-style rename ladder (`CLAUDE.md` → `CLAUDE.md 2`),
        /// **preserved verbatim, never destroyed** — a symlink moved as a link, never followed —
        /// and the guide is then written on the freed name. This case used to mean "skipped"; the
        /// ruling upgraded the skip to a displacement, and the invariant that survives is
        /// displacement-never-destruction.
        case squatted
    }

    /// 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 {
        var existing: Existing

        /// Whether `CLAUDE.user.md` is free — **no file, no folder, no symlink** of that name. The
        /// rescue move re-checks this atomically anyway (`FileManager.moveItem` fails rather than
        /// overwrite), so this is the decision's input, not its safety.
        var userFilenameIsFree: Bool

        /// This picture as the heal engine's comparable value (`HealScheduler`'s memo unit).
        ///
        /// The file's *text* is hashed rather than carried: two states are the same picture exactly
        /// when the same bytes are on the same name, and a memo holding a whole guide's prose for the
        /// life of a session would be the one place in this store that grows with a file's size.
        var signature: String {
            let existing = switch existing {
            case .missing: "missing"
            case .squatted: "squatted"
            case let .file(text): "file:\(text.map(EchoLedger.hash(of:)) ?? "undecodable")"
            }
            return "guide:\(existing):\(userFilenameIsFree)"
        }
    }

    /// The four outcomes, and the only four.
    enum Decision: Equatable {
        /// The guide on disk is current or newer. Nothing is opened for writing.
        case leaveAlone

        /// Missing, or an older marker: write the guide.
        case write

        /// A markerless `CLAUDE.md`: rescue it to `CLAUDE.user.md`, then write the guide.
        case displaceThenWrite

        /// A markerless `CLAUDE.md` with `CLAUDE.user.md` already taken — the ruling's
        /// skipped-with-a-log case. Two files the user owns, both left alone.
        ///
        /// **The one standing exception to the squatter displacement, and it stands** (ruled
        /// 2026-07-29): this displacement has a designated *destination*, and freeing a destination
        /// by a second displacement would cascade renames.
        case skipUserFilenameTaken

        /// `CLAUDE.md` is a symlink, a folder, or some other non-file: move it aside by the
        /// Finder-style rename ladder, then write the guide on the freed name (ruled 2026-07-29 —
        /// the claimed-name squatter rule; it replaced a skip).
        case displaceSquatterThenWrite
    }

    /// The whole rule, as a pure function of `state` — so "never downgrade", "never clobber" and
    /// "never touch a symlink" are pinned by the suite without a filesystem in the way.
    ///
    /// One edge worth naming rather than special-casing: a **zero-byte** `CLAUDE.md` is markerless,
    /// so it takes the displacement path like any other foreign file. Uniformity is the point —
    /// every rule that decides whether to destroy something answers "no" the same way.
    static func decide(_ state: State) -> Decision {
        switch state.existing {
        case .missing:
            .write
        case .squatted:
            .displaceSquatterThenWrite
        case let .file(text):
            if let text, let installed = installedVersion(of: text) {
                installed >= version ? .leaveAlone : .write
            } else {
                state.userFilenameIsFree ? .displaceThenWrite : .skipUserFilenameTaken
            }
        }
    }

    // MARK: - Reading the board root

    /// Reads the state of one guide filename (`filename` or `agentsFilename`) plus the shared rescue
    /// name's freedom. 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, 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
    /// **dangling** symlink is a node that is *there* — it holds the name, and it is displaced as a
    /// link rather than followed — while `fileExists` follows the link, finds nothing, and would
    /// call the name free.
    static func inspect(atBoardRoot root: URL, guideFilename: String) -> State {
        State(
            existing: existingNode(at: root.appendingPathComponent(guideFilename)),
            userFilenameIsFree: IntegrityRules.node(at: root.appendingPathComponent(userFilename)) == nil
        )
    }

    private static func existingNode(at url: URL) -> Existing {
        guard let node = IntegrityRules.node(at: url) else { return .missing }
        guard node == .file else { return .squatted }
        // A regular file whose *contents* cannot be read reads as undecodable rather than as
        // missing, and the difference is the whole promise: `.missing` would overwrite it, while
        // undecodable displaces it — and the rescue move needs no read permission on the file to
        // preserve it byte for byte.
        guard let data = try? Data(contentsOf: url) else { return .file(text: nil) }
        return .file(text: String(data: data, encoding: .utf8))
    }

    // MARK: - Writing it

    /// Puts the current guide at the board root — **at both claimed names** (ruled 2026-08-09,
    /// card dc1314bb) — first moving a displaced file out of the way wherever a decision calls for it.
    ///
    /// **One call, up to two independent decisions, one identical body.** `targetFilenames` is
    /// processed in order, `filename` then `agentsFilename`: each gets its own `inspect` (a fresh disk
    /// 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.
    ///
    /// **Called inside `BoardStore.performWrite`**, so every half rides one watcher bracket: up to two
    /// rescues and up to two guide writes land as a single app-mediated reload rather than as four
    /// foreign-looking events.
    ///
    /// Each 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 `userFilename`
    /// appeared between that file's 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.
    ///
    /// **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
    static func install(atBoardRoot root: URL) throws(BoardWriteError) -> [Displacement] {
        var displacements: [Displacement] = []
        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?

        switch decision {
        case .leaveAlone, .skipUserFilenameTaken:
            // Nothing to write: either the defect healed itself under us, or the standing exception
            // applies and both of the user's files stay exactly where they are.
            return nil
        case .write:
            break
        case .displaceThenWrite:
            // The rescue, not a squatter: markerless content on a claimed name is user *content*, and
            // it has a designated destination (08-agent-integration.md ▸ Ownership).
            do {
                try FileManager.default.moveItem(at: guideURL, to: root.appendingPathComponent(userFilename))
            } catch {
                throw BoardWriteError(
                    operation: .agentGuide,
                    path: guideURL.path,
                    reason: .io(message: "could not move the existing \(guideFilename) aside to \(userFilename): \(error.localizedDescription)")
                )
            }
            EchoLedger.current?.recordMove(from: guideURL, to: root.appendingPathComponent(userFilename))
            displaced = Displacement(name: guideFilename, movedTo: userFilename, wasUserContent: true)
        case .displaceSquatterThenWrite:
            // The claimed-name displacement (ruled 2026-07-29): a folder or symlink on the app's own
            // name, moved aside by the Finder ladder and never destroyed.
            guard let freed = try BoardWriter.displaceClaimedName(
                ClaimedNameSquatter(
                    name: guideFilename,
                    found: IntegrityRules.node(at: guideURL) ?? .directory,
                    expected: .file
                ),
                atBoardRoot: root
            ) else {
                // Gone under us — re-decide rather than write blind, which the next reload does
                // anyway. Nothing displaced, nothing written.
                return nil
            }
            displaced = Displacement(name: guideFilename, movedTo: freed, wasUserContent: false)
        }

        try BoardWriter.atomicReplace(text: content, at: guideURL, operation: .agentGuide)
        // Heal-marked: the guide's refresh is app-initiated work, and the ledger records it as the
        // heal it is rather than as anyone's edit.
        EchoLedger.current?.markHeal(at: guideURL)
        return displaced
    }

    /// What an install moved out of the way, for the notice that names old and new.
    ///
    /// Two shapes ride one type because the *user-facing* fact is the same in both — a file the user
    /// owns is now under a different name — and only the tone differs: the `CLAUDE.user.md` rescue
    /// is the settled, silent ownership rule (08-agent-integration.md), while a squatter's
    /// displacement gets the relocation-style warning-tone notice (01-storage-format.md § Fractal
    /// layout ▸ Rules, ruled 2026-07-29). `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 {
        /// The claimed name that was freed.
        let name: String
        /// The name the displaced node now has.
        let movedTo: String
        /// Whether this was the markerless-guide rescue (silent) rather than a squatter's displacement
        /// (announced).
        let wasUserContent: Bool
    }

    // MARK: - The guide itself

    /// The bytes written to `CLAUDE.md`: the prose below plus the closing newline a multi-line
    /// literal does not carry. Files the app creates end with LF (01-storage-format.md § Encoding
    /// and line endings), and this one is no exception for being prose.
    static let content = guideBody + "\n"

    /// **The one swappable string.** Its wording is a separate concern from this file's mechanism —
    /// what the guide must teach is 08-agent-integration.md ▸ The agent guide's list, and revising
    /// it is a `version` bump plus a new literal here, with nothing else to change.
    ///
    /// The marker interpolates `version` rather than spelling the number twice: the constant and the
    /// first line cannot drift apart, and a bump is one edit.
    private static let guideBody = """
    <!-- lanework-agent-guide v\(version) — created and kept up to date by the Lanework app. 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

    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)
    ├── AGENTS.md                byte-identical twin of CLAUDE.md (app-maintained)
    ├── .trash/                  deleted cards and lanes (app-managed — see Deleting)
    ├── <uuid>/                  a LANE
    │   ├── index.md             lane title + order; body = lane notes/policy
    │   ├── <uuid>/              a CARD
    │   │   ├── index.md         card title + order; body = the card's content
    │   │   ├── attachments/     the card's files (flat, top-level)
    │   │   └── comments/        the card's comment thread (see Comments)
    │   └── <uuid>/              another card
    └── <uuid>/                  another lane
    ```

    - Depth alone defines meaning: depth 1 = lane, depth 2 = card. There is no
      type field.
    - Folder names are lowercase UUIDs and are the item's permanent identity.
      **Never rename a folder.** Titles live in frontmatter only.
    - Every `index.md` is YAML frontmatter between `---` lines, then a Markdown
      body. Files are plain UTF-8, **no BOM**; keep each file's existing line
      endings, and end new files with LF.

    ## Reading the board

    - Lanes run left→right by ascending `order`; cards top→bottom by ascending
      `order` within their lane. Ties break by folder name. An item with no
      `order` sorts after every item that has one — see Creating a card.
    - Lane titles carry the workflow semantics (e.g. To Do → In Progress →
      Done). Read the board's and lanes' index.md bodies for descriptions and
      per-lane policy before deciding where a card belongs.
    - `.trash/` holds deleted cards and lanes; everything else at board root that isn't a
      UUID-named folder is not part of the board's content.

    ## Frontmatter

    All levels: `schema` (always `1`; **required at the board's own `index.md`**,
    optional below it — a lane or card without one is read as schema 1), `title`
    (optional — an item without one renders as untitled, so give cards real
    titles), `created` and `modified` (ISO-8601 with timezone, e.g.
    `2026-07-24T18:00:00Z`), `background` (color), `icon` (SF Symbol name),
    `iconColor` (color, tints `icon`). Lanes and cards may set `order` (a number;
    floats are fine) — **optional, and the way to control position**: an item
    without one goes last. Lanes may set `width` (integer ≥ 1, multiplier of the
    standard lane width) and `collapsed` (`true` folds the lane to a slim strip
    in the app; its cards are still there, just not drawn). **To expand a lane,
    remove the `collapsed` key** rather than writing `collapsed: false` — an
    absent key is the default, and the app removes it too. A lane's `width`
    rides along untouched while it is folded.

    Cards may set `hero` — **the bare filename of one of that card's own
    attachments** (`hero: sketch.png`), which the app draws as a banner across
    the top of the card's face. **A filename, never a path**: `attachments/` is
    implied, so `hero: attachments/sketch.png` and any other value containing a
    `/` name nothing and draw nothing. The file has to sit directly in that
    card's `attachments/` folder (Attachments below); a name that is missing,
    unreadable or not an image draws no banner and is otherwise harmless, so a
    hero set before the file arrives simply starts working when it does. There
    is no control for this in the app — writing the key is how a hero gets set.

    **Quote any `title` containing a colon** — `title: Fix: the thing` is
    invalid YAML; write `title: "Fix: the thing"`. The same goes for any value
    containing `: ` or starting with `#`, `[`, `{`, or a quote — when in doubt,
    double-quote.

    **Keep every value on one line.** A double-quoted scalar may legally
    continue on the following line (`title: "Long title` then `  the rest"`),
    and some tools emit that shape for long titles — but it is the most common
    way frontmatter gets broken by hand: the continuation reads as a line of
    its own, so an edit or a copy that takes only the first one leaves the
    quote unclosed, and an unclosed quote runs on to swallow every key below
    it. The whole file then fails to parse, not just the title. Titles have no
    length limit — write a long one as one long line, and when you copy a card
    between boards, copy its frontmatter block whole.

    Unknown keys are preserved verbatim by the app and invisible in its UI —
    custom metadata (`project:`, `tags:`, `claimed-by:` …) is safe to add and
    survives every app rewrite. Reserved for Lanework's upcoming tracker sync —
    preserved but not rendered, don't repurpose them: the card keys `labels`,
    `assignees`, `due`, the `remote` key (cards and board), and `remote-state`
    (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`

    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. 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. On a
    board that lives in a git repository of the user's own, committing your
    changes yourself (see Git below) records exact authorship as well.

    ## Creating a card

    1. Pick the lane folder.
    2. Create a folder named a fresh lowercase UUID:
       `id=$(uuidgen | tr 'A-Z' 'a-z')`.
    3. Write `<lane>/$id/index.md` (timestamp: `date -u +%FT%TZ`):

    ```markdown
    ---
    schema: 1
    kind: card
    title: Short imperative card title
    order: 3072
    created: 2026-07-24T18:00:00Z
    modified: 2026-07-24T18:00:00Z
    modified-by: claude
    ---
    The card's content — any Markdown.
    ```

    **`order` is what places the card, and computing it means reading the
    lane**: bottom of the lane = max existing card `order` + 1024; top =
    min − 1024; between two cards = their midpoint. (Empty lane: any number,
    conventionally 1024.) Write it whenever the position matters.

    **You can also file a card without reading the lane at all.** The minimum
    legal card is a `mkdir` and one `index.md` containing nothing but a title —
    no `order`, no `schema`:

    ```markdown
    ---
    title: Short imperative card title
    ---
    The card's content.
    ```

    It lands at the bottom of the lane (an item with no `order` sorts after
    every item that has one; two such items sort by folder name), and the app
    writes a real `order` into it the next time it rewrites that file. Prefer
    the full frontmatter above — `kind`, the timestamps and `modified-by` are
    all worth having — but when you are filing into a 200-card lane and the
    position doesn't matter, the short form costs one write and no reads.

    Creating a lane is the same one level up (body optional; `kind: lane`;
    `order` ranks lanes left→right, and is optional in the same way).

    **Always write `kind`** at creation — `kind: card`, `kind: lane`,
    `kind: board` at board root. Depth already says what an item is on the
    board, but `.trash/` is flat, and there the value is the only thing that
    tells a trashed lane from a card. Omitting it is healable, never fatal: the
    app fills a missing `kind` in the next time it rewrites that file.

    ## Moving and reordering

    - **The stamp rule**: a move that changes an item's container — another
      lane, another board, or into/out of `.trash/` — stamps `modified` and
      re-stamps `modified-by`, the same as any content edit. A reorder that
      keeps an item in the same container (a card among its lane's cards, a
      lane among the board's) rewrites only `order`; leave `modified` and
      `modified-by` alone. The trash move isn't an exception to this — it
      stamps because every container change stamps.
    - Move to another lane: `mv <laneA>/<card-uuid> <laneB>/` — the folder move
      IS the move. Then set the card's `order` to place it among the
      destination's cards, update `modified`, and re-stamp `modified-by`.
    - Move folders **one at a time, by name** — never `mv <laneA>/* <laneB>/`.
      A lane folder holds its own `index.md` beside its cards, so a glob
      sweeps the lane's identity file along with them and overwrites the
      destination lane's.
    - Reorder within a lane: rewrite only that card's `order` — don't touch
      `modified` or `modified-by`.

    ## Editing and deleting

    - Edit bodies freely; update `modified` on every write. Preserve frontmatter
      keys you don't recognize and don't reformat content you didn't change.
    - **Delete a card or a lane = move its folder into `<board>/.trash/`**:
      `mv <lane>/<card-uuid> <board>/.trash/`, or `mv <lane-uuid>
      <board>/.trash/` for a whole lane (create `.trash/` if missing). A lane
      travels with its cards inside it. It's a container change like any other
      move (Moving and reordering above): stamp `modified` and re-stamp
      `modified-by` — and here the stamp is also the position. **The trash
      sorts by `modified`, newest first**, so a restamped arrival lands on top;
      there is no rank to mint, and you should leave `order` exactly as it is —
      it rides along for the restore. Restore is the same move in reverse — a
      card into a lane, a lane back to board root, with a fresh `order`,
      stamped the same way.
    - **Stamp `kind: lane` when you trash a lane that lacks it.** `.trash/` is
      flat, so an empty lane folder looks exactly like a card folder; the `kind`
      value is what tells them apart in there.
    - Never write a `deleted:` key — that convention is retired; the app
      migrates any it finds.
    - Remove a folder outright (`rm -r`) only when you mean permanent,
      unrecoverable deletion — the trash is the recoverable path for both
      cards and lanes.

    ## Attachments

    - A card's files live in `attachments/` inside the card folder, flat at its
      top level. **Put files there, never beside `index.md`** — the app
      relocates loose files into `attachments/` and tells the user it did.
      The name `attachments` itself belongs to that folder — never create a
      *file* called `attachments` in a card; the app treats one as a defect
      and displaces it on sight.
    - To attach a file: create `attachments/` if missing and copy the file in.
      If the name is taken, pick a free one Finder-style (`shot.png` →
      `shot 2.png`) — never overwrite.
    - Reference attachments from the card body by relative path:
      `![](attachments/sketch.png)`. To put that same picture on the card's
      face, add `hero: sketch.png` to the frontmatter — the bare name, without
      the folder (Frontmatter above).
    - Subfolders under `attachments/` are tolerated but the app never creates
      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)

    - Frontmatter must parse as YAML. The board's own `index.md` must carry
      `schema: 1`; everywhere else `schema` and `order` are optional and a
      missing one is read, never refused. Never write a `schema` other than
      `1`. The classic violations are an unquoted colon in a title and a
      quoted value left unclosed across a line break (see Frontmatter above).
    - Files must be UTF-8 without BOM.
    - Never create a card folder without an `index.md`.
    - Never rename UUID folders.
    - Prefer atomic writes (write a temp file, then rename over the target) —
      the app reloads on every filesystem event and can catch half-written
      files.

    ## Colors and icons

    `background` takes a palette name (preferred) or `#RRGGBB[AA]` hex. `icon`
    is any SF Symbol name; `iconColor` takes a palette name (preferred) or hex
    to tint it.

    - Icon tint palette: `obsidian`, `aluminum`, `soapstone`, `chalk`,
      `carnation`, `rich-grapefruit`, `smokey-tangerine`, `rich-lime`,
      `fern`, `light-jade`, `light-teal`, `deep-sky-blue`, `rich-indigo`,
      `pale-violet`, `rich-magenta`, `deep-cool-granite`.
    - Background palette: `obsidian`, `shale`, `aluminum`, `chalk`,
      `light-cayenne`, `light-mocha`, `smokey-mocha`, `smokey-lime`,
      `smokey-fern`, `dark-jade`, `dark-teal`, `smokey-ocean`,
      `smokey-indigo`, `smokey-rich-eggplant`, `smokey-magenta`,
      `intense-cool-shale`.

    ## Git

    Lanework itself never runs git: the app manages no repository, makes no
    commits, and never reads `.git`. But the format is deliberately
    git-friendly — one file per card, stable UUID folder names, byte-faithful
    rewrites — and a board may live inside a repository of the user's own.
    When it does:

    - **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.
    - **Commit your own changes, with clear messages** — nothing else will
      commit them for you, and a semantic message ("Move card 'Fix login' to
      Doing") is the history the user will actually read.
    - **Leave the app-maintained files to the app** — this guide (both
      `CLAUDE.md` and its `AGENTS.md` twin) and the seeded `.gitignore` are
      rewritten by Lanework when they need to be; don't edit or delete
      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.
    """
}
