The full bullet list from Implementation card bf080d9a — both ruling batches, including the three appended mid-session by16ef377: - Restore subjects compose the inverse, never nest: crossing "Undo: S" emits "Redo: S" and vice versa; parity, not stack depth, reads a legacy double prefix (GitHistoryProvider.restoreSubject). - Git-operation failures join the one-shot failure banner tier: BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error tone at failure rank merged with write one-shots by recency; the postLoss compromise is retired at both AppModel wirings. - order/schema optional below the board root: append-at-end reading (ordered siblings first, folder-name tie-break among the order-less), schema reads 1, both coerce-tier logged; the root keeps its requirements. Ranks.resolvedOrders materializes finite ranks so models and placement math stay untouched; first Writer rewrite stamps a real rank on touch, placement against an order-less sibling stamps that sibling inline in the same bracket. Agent guide v10 teaches optional keys and zero-read filing. Hostile-YAML order shapes become coercion tests; Fixtures/Valid/optional-keys.kanban replaces the four retired Malformed boards. - .gitignore is the relocation-heal noise gate: GitignoreRules pure matcher (standard semantics, board-root file only), loader consults it once per walk so matched loose files keep the stray posture; seeded (.DS_Store + .*.lanework-*) at board creation and template instantiation, healed in when missing at open — repo-nested included; empty file honored, existing files never edited; the committer's obedience via libgit2 status is pinned by test. - Comments crash-residue sweep gates on step ownership: HistoryStep derives backing from its own undo expectations, backedContent unions both stacks, the sweep purges per-entry only what no live step owns. - Skip-purge decoupled (16ef377): a stale-skipped coarse step strands whole in NativeHistoryProvider.strandedSteps — still backing, retired only at session end; clean exits purge as before. - Coarse close step named "Changes to '<card>'"; the fine body-edit wording never leaks onto the board menu. - Branch-switch settle clears every open card window's fine stack on Save All and Discard alike; the empty fold registers no coarse step. - Close flush awaits its covering snapshot (quiesce + one generation bump, 1s bound), and an explicit flush now queues behind an in-flight one instead of skipping — the audit-caught interleaving could lose a close flush permanently when the debounce fired inside the close sequence; regression tests force both races. - Commit comment bullets sort chronologically by created, not UUID. - The production-unwired CardBodyEditSession.editSessionDidChange seam is deleted with its seam-only tests. - Composition-root pins: beginSession composes the committer with the store's own EchoLedger and binds the announcer (the miswire class). - Deliberate 06 conformance pass over every 2026-07-31-tagged sentence: fixed Change-custom-key subjects (the retired named generic was the only producer), the unbuilt Replace attachment vocabulary, heal commits now authored Lanework Integrity, the config reader scopes identity to plain [user] sections, add-git re-runs detection at create (a stale mode-none could initialize inside the user's repo), and add-git failures answer at the form or the banner. Structural residue filed on the Redesign board. 2554 tests / 439 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
322 lines
19 KiB
Swift
322 lines
19 KiB
Swift
import Foundation
|
|
|
|
/// The immutable snapshot shape `BoardLoader` produces and every view/store reads
|
|
/// (02-architecture.md § Layering). `BoardModel`, `Lane`, and `Card` are plain value types —
|
|
/// no class, no shared mutable state — encoding the frontmatter tables from
|
|
/// 01-storage-format.md § Frontmatter as data. Level is position (root = board, depth 1 =
|
|
/// lane, depth 2 = card); there is deliberately no `type`/`level` discriminator field —
|
|
/// the three distinct Swift types encode it structurally.
|
|
|
|
/// The immutable identity of a lane or card folder: its exact name, byte-for-byte — compared as
|
|
/// a UUID *value*.
|
|
///
|
|
/// **`rawValue` is the folder's exact spelling.** It builds URLs, it must round-trip
|
|
/// byte-perfect (it is the primary key on disk), and it is the display-order tie-break
|
|
/// (`Ranks.sortedForDisplay`), so it is never normalized on the way in. Deliberately **not**
|
|
/// Foundation's `UUID`, which re-renders as uppercase and would silently corrupt that
|
|
/// round-trip.
|
|
///
|
|
/// **Equality and hashing are by UUID value, not by spelling** (01-storage-format.md § Fractal
|
|
/// layout ▸ Rules, settled): *two case-spellings of one UUID are one identity everywhere* —
|
|
/// selection membership, the import-boundary collision check, every `Set`/`Dictionary` keyed by
|
|
/// this type. That matches default-APFS case-insensitivity, where the two spellings name one
|
|
/// folder anyway. Lowercasing `rawValue` *is* the canonical form: `BoardLoader` only ever mints
|
|
/// an `ItemID` for a folder name that passed the shape gate (`isUUIDShaped` — hex, `8-4-4-4-12`,
|
|
/// any case, any version), and `BoardWriter` only ever mints one for a name it just wrote, so
|
|
/// the string is always ASCII hex and hyphens, where case folding is exactly UUID-value
|
|
/// canonicalization. Nothing asserts that — the type stays total on any string a caller hands it;
|
|
/// off-shape input simply compares by its own lowercasing, which is the harmless reading.
|
|
///
|
|
/// Consequences, all intended: `RawRepresentable` is unaffected — only `==` and `hash(into:)`
|
|
/// are hand-written, and `rawValue` still reads back exactly as it was stored;
|
|
/// SwiftUI `Identifiable` diffing keys on this same value equality, so a case-respelled folder
|
|
/// is the *same* row rather than a delete plus an insert; and a `Set<ItemID>` holding both
|
|
/// spellings collapses them to one member, keeping whichever arrived first.
|
|
///
|
|
/// Board roots don't get one of these: a board's folder name is a human/Finder-assigned
|
|
/// `.kanban` package name, not a UUID (01-storage-format.md § Board naming) — its identity is
|
|
/// `BoardModel.rootURL`, resolved elsewhere via a security-scoped bookmark
|
|
/// (02-architecture.md § Per-board app state), not a folder-name key.
|
|
public struct ItemID: Hashable, Sendable, RawRepresentable {
|
|
public let rawValue: String
|
|
|
|
public init(rawValue: String) {
|
|
self.rawValue = rawValue
|
|
}
|
|
|
|
/// The comparison key: `rawValue` case-folded, through the **one** canonicalization
|
|
/// (`IntegrityRules.canonicalIdentity`, settled 2026-07-29 — the fold). Computed rather than
|
|
/// stored so `ItemID` stays one string wide and `rawValue` remains the single source of truth
|
|
/// for what is on disk.
|
|
///
|
|
/// The Writer compares *paths* rather than model values and reaches the same function directly;
|
|
/// before the fold it carried a private copy of this line, which is one line too many for a rule
|
|
/// that decides whether two folders are the same item.
|
|
var canonicalValue: String { IntegrityRules.canonicalIdentity(rawValue) }
|
|
|
|
public static func == (lhs: ItemID, rhs: ItemID) -> Bool {
|
|
lhs.canonicalValue == rhs.canonicalValue
|
|
}
|
|
|
|
public func hash(into hasher: inout Hasher) {
|
|
hasher.combine(canonicalValue)
|
|
}
|
|
}
|
|
|
|
extension ItemID: CustomStringConvertible {
|
|
public var description: String { rawValue }
|
|
}
|
|
|
|
/// A board: `<root>/index.md` plus every lane beneath it. `<root>` is the `.kanban` package
|
|
/// (or an extension-less folder — both open).
|
|
public struct BoardModel: Sendable, Equatable {
|
|
/// The board's identity — see `ItemID`'s doc comment for why boards don't have one of
|
|
/// those instead.
|
|
public let rootURL: URL
|
|
|
|
public let schema: Int
|
|
public let title: FieldValue<String>
|
|
public let created: FieldValue<Date>
|
|
public let modified: FieldValue<Date>
|
|
public let modifiedBy: FieldValue<String>
|
|
|
|
/// Legal per the common frontmatter table but meaningless at board level — a board can't
|
|
/// tombstone itself out of its own window (01-storage-format.md § Deletion). `BoardLoader`
|
|
/// ignores it (and emits a `LoadWarning`) rather than acting on it; the model keeps the
|
|
/// field only so the value still round-trips. There is deliberately no `isDeleted` here —
|
|
/// contrast `Lane`/`Card`.
|
|
public let deleted: FieldValue<Date>
|
|
|
|
public let background: FieldValue<String>
|
|
public let icon: FieldValue<String>
|
|
public let iconColor: FieldValue<String>
|
|
|
|
/// `{order: N}` — picker position when this board lives in a template store
|
|
/// (09-templates.md). Opaque by design: exposed as the engine's raw `YAMLValue`, never
|
|
/// parsed into a dedicated Swift shape, so future subkeys need no model change.
|
|
public let template: YAMLValue?
|
|
|
|
/// Lanes in display order (`Ranks.sortedForDisplay`, folder-name tie-break).
|
|
///
|
|
/// **A lane carrying a legacy `deleted:` key is here, live** (01-storage-format.md § Deletion,
|
|
/// lane clause re-ruled 2026-07-29): the key is ignored outright — no migration, no notice, no
|
|
/// write — so `Lane.isDeleted` describes the bytes and decides nothing. A *card* carrying one
|
|
/// still rides along flagged until its migration relocates it (`LoadResult.legacyTombstones` —
|
|
/// see `BoardLoader`'s "The migration window" note).
|
|
public let lanes: [Lane]
|
|
|
|
/// The board's **materialized trash**, card side: the card folders sitting directly in
|
|
/// `<root>/.trash/`, in display order (01-storage-format.md § Deletion, resettled
|
|
/// 2026-07-28; 03-board-ui.md § Trash).
|
|
///
|
|
/// **A sibling container of `lanes`, not a lane.** `.trash/` is a reserved, app-claimed name
|
|
/// at board root that "holds card and lane folders interleaved directly, no `index.md` of its
|
|
/// own", so it has no identity, no title, no `order`, and no frontmatter to model: the
|
|
/// container *is* the list. That is why this is `[Card]` rather than a `Lane` or a `Trash`
|
|
/// struct — there is nothing for either to carry that this array does not.
|
|
///
|
|
/// **The container's other kind is `trashedLanes`** (re-ruled 2026-07-29 — lanes trash too).
|
|
/// The two are separate arrays rather than one list of a sum type because they are separate
|
|
/// *things*: a trashed card is an ordinary card that every card-shaped surface already reads,
|
|
/// and a trashed lane is an opaque row that none of them may. Interleaving the two is a
|
|
/// rendering question (03-board-ui.md § Trash: "lane rows and cards interleave in the one trash
|
|
/// column by `modified` descending"), and both arrays carry the stamp that answers it —
|
|
/// `BoardModel.trashEntries` is the one merge.
|
|
///
|
|
/// **Display order is `modified` descending** — `Ranks.sortedForTrash`, tie-broken by title then
|
|
/// folder name (re-ruled 2026-07-31, retiring the arrival rank mint). The trash is the one
|
|
/// container in the app whose sequence is not a rank sequence: the delete move stamps, and that
|
|
/// stamp *is* the position, with each entry's `order` riding along untouched for its restore.
|
|
/// There is deliberately no `deleted:` key on anything in here — a trashed card is an ordinary
|
|
/// card in a special place.
|
|
///
|
|
/// Empty when `.trash/` is absent (the overwhelmingly common case — the folder is minted by
|
|
/// the first delete), and empty when it holds nothing the loader recognizes as a card.
|
|
///
|
|
/// **Defaulted so a snapshot can be built without one.** The one construction site is
|
|
/// `BoardLoader.load`, which always supplies it; the default keeps the memberwise initializer
|
|
/// usable from tests and future fixtures that have no trash to describe.
|
|
public var trash: [Card] = []
|
|
|
|
/// The board's materialized trash, **lane side**: the lane folders sitting directly in
|
|
/// `<root>/.trash/`, in display order (03-board-ui.md § Trash, re-ruled 2026-07-29 — "Lanes
|
|
/// trash too", retiring the design's sole destructive delete).
|
|
///
|
|
/// See `TrashedLane` for why a trashed lane is not a `Lane`, and `trash` for why the container's
|
|
/// two kinds are two arrays.
|
|
public var trashedLanes: [TrashedLane] = []
|
|
|
|
/// The full parsed `index.md`. Unknown/reserved keys (`labels`, `assignees`, `due`,
|
|
/// `remote`, …) ride along uninterpreted via `document.unknownFields` so a future writer
|
|
/// can round-trip them without this model knowing what they mean.
|
|
public let document: FrontmatterDocument
|
|
|
|
/// The board description (free Markdown) — equivalent to `document.body`.
|
|
public var body: String { document.body }
|
|
}
|
|
|
|
/// A lane: `<root>/<guid>/index.md` plus every card beneath it.
|
|
public struct Lane: Identifiable, Sendable, Equatable {
|
|
public let id: ItemID
|
|
|
|
public let schema: Int
|
|
public let title: FieldValue<String>
|
|
public let created: FieldValue<Date>
|
|
public let modified: FieldValue<Date>
|
|
public let modifiedBy: FieldValue<String>
|
|
public let deleted: FieldValue<Date>
|
|
public let background: FieldValue<String>
|
|
public let icon: FieldValue<String>
|
|
public let iconColor: FieldValue<String>
|
|
|
|
/// Rank among lanes, ascending = left-to-right — **the reading, not necessarily the key**
|
|
/// (01-storage-format.md § Ordering, re-ruled 2026-07-31: `order` is optional below the board
|
|
/// root, and "missing or unusable reads as append-at-end").
|
|
///
|
|
/// A lane whose `index.md` carries a usable `order` reads as that number. One that carries none
|
|
/// — no key, an explicit null, a non-numeric or non-finite value — reads as a rank past every
|
|
/// ordered sibling, materialized by `Ranks.resolvedOrders(of:stored:name:)` and tie-broken among
|
|
/// the other order-less lanes by folder name. Either way this is a plain, finite `Double` by the
|
|
/// time a `Lane` exists, which is why it is not a `FieldValue<Double>`: resolving the reading is
|
|
/// the loader's job, and the snapshot carries answers rather than shapes.
|
|
///
|
|
/// The value is real enough to write down, and the Writer does exactly that on the file's first
|
|
/// touch (`IntegrityRules.OnTouchHeal.rankStamped`) — so the stamp changes nothing on screen.
|
|
public let order: Double
|
|
|
|
/// Width multiplier ≥ 1 (default 1 when missing or malformed). Lenient — it styles layout,
|
|
/// it isn't structure — so a malformed value is preserved (`.malformed`) rather than
|
|
/// failing the load (01-storage-format.md § Frontmatter).
|
|
public let width: FieldValue<Int>
|
|
|
|
/// Cards in this lane, in display order (`Ranks.sortedForDisplay`, folder-name tie-break).
|
|
/// A card still carrying a legacy `deleted:` key rides along flagged (`Card.isDeleted`) until
|
|
/// its migration relocates it into `BoardModel.trash` — see `BoardModel.lanes`.
|
|
public let cards: [Card]
|
|
|
|
/// The full parsed `index.md`; unknown/reserved keys ride along uninterpreted.
|
|
public let document: FrontmatterDocument
|
|
|
|
/// The lane description / WIP policy / notes — equivalent to `document.body`.
|
|
public var body: String { document.body }
|
|
|
|
/// Whether this lane carries a legacy `deleted:` key — presence, not validity
|
|
/// (`FieldValue.isMissing` already treats an explicit `deleted: null` as absent).
|
|
///
|
|
/// **Retired, and on a lane the key is now inert** (01-storage-format.md § Deletion, lane clause
|
|
/// re-ruled 2026-07-29): the app never writes `deleted:`, and a lane found carrying one "simply
|
|
/// loads live with the key ignored — no migration machinery, no key-strip write, no notice",
|
|
/// preserved verbatim like any unhandled key and logged (`LoadWarning.laneLevelDeletedIgnored`).
|
|
/// So this reads the key without meaning anything by it: nothing hides a lane, nothing rewrites
|
|
/// it, and the field survives only so the value round-trips.
|
|
public var isDeleted: Bool { !deleted.isMissing }
|
|
}
|
|
|
|
/// A card: `<root>/<guid>/<guid>/index.md`, plus the *names* of its attachments. Structurally
|
|
/// still a leaf — `comments/` (future, out-of-scope) and the attachment files' contents live
|
|
/// alongside `index.md` on disk and are not modeled here; `attachments` is the one thing the
|
|
/// snapshot reaches inside a card folder for, because two board-window surfaces need it before
|
|
/// any card window exists (see its own doc comment).
|
|
public struct Card: Identifiable, Sendable, Equatable {
|
|
public let id: ItemID
|
|
|
|
public let schema: Int
|
|
public let title: FieldValue<String>
|
|
public let created: FieldValue<Date>
|
|
public let modified: FieldValue<Date>
|
|
public let modifiedBy: FieldValue<String>
|
|
public let deleted: FieldValue<Date>
|
|
public let background: FieldValue<String>
|
|
public let icon: FieldValue<String>
|
|
public let iconColor: FieldValue<String>
|
|
|
|
/// Rank within its lane, ascending = top-to-bottom — the reading, not necessarily the key. See
|
|
/// `Lane.order`'s doc comment; the same reasoning applies here, and a card is where it matters
|
|
/// most: the minimum legal agent card is a `mkdir` plus one `index.md` with no `order` at all
|
|
/// (08-agent-integration.md), and it lands at the bottom of its lane.
|
|
public let order: Double
|
|
|
|
/// The card's attachment file names — **flat: top-level regular files only, in Finder
|
|
/// order** (01-storage-format.md § Attachments: "the app's attachment surfaces … are flat:
|
|
/// top-level files only", and "subfolders are tolerated, preserved verbatim, never created
|
|
/// by the app, and not surfaced"). Empty when the card has no `attachments/` folder, and
|
|
/// empty when it has one that couldn't be listed — the field is cosmetic, so a
|
|
/// directory-listing race degrades to "nothing to show" rather than failing a load.
|
|
///
|
|
/// Names, not URLs: the board-window consumer only needs to know *whether*, *how many*, and
|
|
/// in *what order* — the face's quiet paperclip indicator (03-board-ui.md § Card face). The
|
|
/// card window's sidebar does its own listing through `BoardWriter.listAttachments`, since it
|
|
/// acts on the files rather than showing them; that call answers through the very same
|
|
/// enumeration (`BoardLoader.attachmentNames(in:)`), so the two surfaces agree by
|
|
/// construction.
|
|
///
|
|
/// Freshness is the watcher's, by construction: it reloads on any change anywhere under the
|
|
/// board root, so an attachment added in Finder rebuilds the snapshot exactly like an edited
|
|
/// `index.md` does — no separate invalidation path to keep honest.
|
|
public let attachments: [String]
|
|
|
|
/// The full parsed `index.md`; unknown/reserved keys ride along uninterpreted.
|
|
public let document: FrontmatterDocument
|
|
|
|
/// The card's content — the whole point. Equivalent to `document.body`.
|
|
public var body: String { document.body }
|
|
|
|
/// A tombstoned card. See `Lane.isDeleted`'s doc comment — the same "presence, not
|
|
/// validity" rule applies here. A card's key is still migration input (a card carrying it
|
|
/// relocates into `.trash/` with the key removed), which is the one half of the legacy rule
|
|
/// that survives; a trashed card carries no `deleted:` key at all, so this reads `false` for
|
|
/// every card in the container that replaced the flag.
|
|
public var isDeleted: Bool { !deleted.isMissing }
|
|
}
|
|
|
|
/// A lane in the board's trash: `<root>/.trash/<guid>/index.md` — **an opaque unit**
|
|
/// (03-board-ui.md § Trash, re-ruled 2026-07-29: "A trashed lane is an opaque unit: one distinct
|
|
/// dimmed row showing its title and held-card count … never expandable; its cards are invisible to
|
|
/// search and not individually addressable — it restores whole or purges whole").
|
|
///
|
|
/// ### Why it is not a `Lane`
|
|
///
|
|
/// A `Lane` carries its cards, its styling and its width because the board renders all three. None
|
|
/// of that is true here: the row shows a title and a count, takes no styling accents, and its
|
|
/// subtree is deliberately **not walked into the snapshot** — the loader stops at the trash entry
|
|
/// exactly as it stops at a card under a lane. A `Lane` with an empty `cards` array would be a lie
|
|
/// the first consumer to read it would believe; this type can only answer what the design says the
|
|
/// row knows.
|
|
///
|
|
/// The kind itself is `kind:`'s to answer, never position's: the trash is flat, and an empty lane
|
|
/// folder is shape-identical to a card folder (01-storage-format.md § Deletion —
|
|
/// `IntegrityRules.trashKind`).
|
|
public struct TrashedLane: Identifiable, Sendable, Equatable {
|
|
public let id: ItemID
|
|
|
|
public let schema: Int
|
|
public let title: FieldValue<String>
|
|
|
|
/// **When the row entered the trash** — and therefore *where it sits*: the trash sorts by
|
|
/// `modified` descending (01-storage-format.md § Deletion, re-ruled 2026-07-31), and the trash
|
|
/// move is the container-changing write that stamps it. Missing on a foreign mover that skipped
|
|
/// the restamp, which sorts it below every dated sibling (`Ranks.isOrderedForTrash`).
|
|
public let modified: FieldValue<Date>
|
|
|
|
/// The lane's rank **among the board's lanes**, riding along untouched — the trash move rewrites
|
|
/// no `order` at all, so this is still the strip position a restore would want and the value the
|
|
/// undo of a restore puts back. It is deliberately *not* what orders this row in the column
|
|
/// (`modified` is), and it is the same optional-but-always-read field a live lane carries
|
|
/// (`Lane.order`): an entry that reaches the trash without one reads as append-at-end over the
|
|
/// container's own entries, which is inert here by construction — a restore computes a fresh
|
|
/// rank at its destination.
|
|
public let order: Double
|
|
|
|
/// **How many cards the lane is holding** — the row's whole other half ("Doing — 5 cards").
|
|
///
|
|
/// Counted from disk at load, never derived from a walked subtree: the count is the one fact
|
|
/// about the freight the snapshot carries, and counting is what keeps the entry opaque. The
|
|
/// unit counted is what the loader *would* render as a card — an identity-shaped child holding
|
|
/// its own `index.md` — so the row's number and a restore's outcome agree.
|
|
public let heldCards: Int
|
|
|
|
/// The full parsed `index.md`; unknown/reserved keys ride along uninterpreted, so a restore
|
|
/// (an ordinary move out) returns the lane exactly as it went in.
|
|
public let document: FrontmatterDocument
|
|
}
|