Realign code with the 2026-07-31 findings-resolution rulings

The full bullet list from Implementation card bf080d9a — both ruling
batches, including the three appended mid-session by 16ef377:

- 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
This commit is contained in:
2026-08-01 07:43:45 -04:00
parent 16ef3779e8
commit 274ccd9ff5
75 changed files with 5619 additions and 791 deletions
+228 -44
View File
@@ -36,6 +36,79 @@ public struct OneShotBanner: Identifiable, Sendable, Equatable {
}
}
/// **The one-shot failure class's second shape** (02-architecture.md § The banner surface, settled
/// 2026-07-31): "the `BoardWriteError`-shaped write failure, and a message-carrying **git-operation
/// failure** the operation named in the user's words plus the underlying error, phrasing still
/// BannerCenter's because failures rank by what they are, not by which error vocabulary threw
/// them".
///
/// It is a row of its own rather than a `BoardWriteError` with a git-shaped case because "git
/// operations stay off the closed `WriteOperation` vocabulary only the banner tier learns the
/// second shape": a `WriteOperation` is something the Writer does to a file the model knows, and a
/// checkout is not one. What the two shapes *do* share is everything the strip cares about the
/// error tone, the failure rank, and the one-shot's dismissable, untimed lifecycle which is why
/// they sit in one precedence class rather than in two (`BannerCenter.rows(...)`).
///
/// It replaces the shipped compromise, and the compromise is worth naming because its reasoning is
/// the mirror of this one's: a failed restore used to post a `LossBanner` the nearest class with a
/// free-form message which read as warning-tone content-didn't-arrive when what actually happened
/// was an action that didn't happen at all. "A failed undo restore, branch switch, or (pro-m2)
/// pull/push presents in the error tone at the failure rank, never as a warning-tone loss row."
public struct GitFailureBanner: Identifiable, Sendable, Equatable {
public let id: UUID
/// Which operation didn't happen a closed vocabulary, so the sentence is composed here rather
/// than carried in from the git layer (`GitOperation`).
public let operation: GitOperation
/// The underlying error, verbatim libgit2's own message, the free-form tail the phrasing rules
/// allow ("could not write to '': Permission denied"). Never the banner's verb.
public let reason: String
/// When it failed the sort key for "newest first within a class", shared with the write
/// failures it ranks beside.
public let occurredAt: Date
public init(id: UUID = UUID(), operation: GitOperation, reason: String, occurredAt: Date = Date()) {
self.id = id
self.operation = operation
self.reason = reason
self.occurredAt = occurredAt
}
}
/// **The git operations the banner can name** the closed vocabulary behind `GitFailureBanner`,
/// and the git half of the rule the `WriteOperation` enum already carries for writes: "the banner
/// owns all user-facing phrasing and localization from that vocabulary", and an operation added
/// without a sentence to say about it is a compile-time hole rather than a silent default
/// (`BannerCenter.actionPhrase(for:)` switches over it exhaustively).
///
/// Deliberately small. 02 names the members: "a failed undo restore, branch switch, or (pro-m2)
/// pull/push" the first three are here because they have producers today
/// (`GitHistoryProvider`, `GitBranchSwitcher`), and pull/push join by adding a case and a sentence
/// when the remote half ships.
///
/// No associated values: a branch switch's target is in the in-progress row the failure replaces
/// ("Switching to 'main'"), and a failure that named it again would be the only member of this
/// vocabulary carrying a payload a case can grow one the day a sentence genuinely needs it.
public enum GitOperation: Sendable, Equatable, CaseIterable {
case undo
case redo
case branchSwitch
/// **Add-git, when the form that asked has been dismissed** (06-history-undo.md Interaction with
/// external writers, ruled 2026-07-31): "if the sheet has been dismissed before the answer
/// arrives, the failure falls back to the one-shot banner above inline is the primary surface,
/// never a silence trap." Inline is `HistoryStore.lastFailure`; this is the fallback.
case addGit
/// The pair Z and Z map to so a caller that knows a `HistoryDirection` (the provider's
/// crossing) never has to spell the mapping itself, and never gets it backwards.
public static func restore(_ direction: HistoryDirection) -> GitOperation {
switch direction {
case .undo: .undo
case .redo: .redo
}
}
}
/// A loss row: content that didn't arrive though nothing failed (02-architecture.md § The banner
/// surface, "Loss rows are the warning-tone class for non-failure losses", settled 2026-07-28)
/// folders skipped from a Finder drop, the app's own relocation and repair notices, their future kin.
@@ -144,7 +217,7 @@ public struct InProgressOperation: Identifiable, Sendable {
/// One row in a window's banner strip.
///
/// The seven cases are the whole vocabulary of 02-architecture.md § The banner surface, and they
/// The eight cases are the whole vocabulary of 02-architecture.md § The banner surface, and they
/// divide into three lifecycles that the view renders differently and that the ordering rule
/// treats as classes:
///
@@ -152,11 +225,13 @@ public struct InProgressOperation: Identifiable, Sendable {
/// ongoing state and carry no dismiss control "an error never evaporates unread" has a twin,
/// "a condition is never dismissed while it is still true". Each leaves when the thing it
/// describes stops being true.
/// - **One-shots dismiss**: `oneShot`, `loss`, and `signpost`. Each reports something that already
/// happened, so only the user can clear it. `loss` shares this lifecycle deliberately (settled
/// 2026-07-28) even though it reports no failure: "a loss the user didn't notice is the harm, so
/// it never auto-expires" is the same reasoning that keeps a one-shot from evaporating unread,
/// aimed at a row that isn't an error at all.
/// - **One-shots dismiss**: `oneShot`, `gitFailure`, `loss`, and `signpost`. Each reports something
/// that already happened, so only the user can clear it. `gitFailure` is the same failure class as
/// `oneShot` in a second shape (settled 2026-07-31) same tone, same rank, same lifecycle, a
/// message where the other carries a `BoardWriteError`. `loss` shares the lifecycle deliberately
/// (settled 2026-07-28) even though it reports no failure: "a loss the user didn't notice is the
/// harm, so it never auto-expires" is the same reasoning that keeps a one-shot from evaporating
/// unread, aimed at a row that isn't an error at all.
/// - **In-progress rows complete or fail**: `inProgress`. Completion clears the row; failure swaps
/// it for a one-shot (`BannerCenter.endOperation(_:)` + `post(_:)`).
///
@@ -173,6 +248,10 @@ public enum BannerRow: Identifiable, Sendable {
case reloadBreakage(BoardLoadError)
/// A write that did not happen. Dismissable, error tone.
case oneShot(OneShotBanner)
/// A git operation that did not happen an undo restore, a branch switch, and (pro-m2) a pull
/// or a push. Dismissable, error tone, and it ranks *with* `oneShot`: the one-shot failure
/// class's second shape (settled 2026-07-31, see `GitFailureBanner`).
case gitFailure(GitFailureBanner)
/// Content that didn't arrive though nothing failed folders skipped from a Finder drop, the
/// app's own relocation and repair notices. Dismissable, warning tone: below the true failures above it,
/// above the ambient notices below it (settled 2026-07-28, see `LossBanner`).
@@ -192,6 +271,7 @@ public enum BannerRow: Identifiable, Sendable {
case .readOnlyLock: "read-only-lock"
case .reloadBreakage: "reload-breakage"
case let .oneShot(banner): "one-shot:\(banner.id.uuidString)"
case let .gitFailure(banner): "git-failure:\(banner.id.uuidString)"
case let .loss(loss): "loss:\(loss.id.uuidString)"
case .historySuspended: "history-suspension"
case let .inProgress(operation): "operation:\(operation.id.uuidString)"
@@ -201,7 +281,10 @@ public enum BannerRow: Identifiable, Sendable {
public var tone: BannerTone {
switch self {
case .readOnlyLock, .reloadBreakage, .oneShot: .error
// A git operation that failed is an action that didn't happen, so it takes the failure
// tone with the write failures it ranks beside "never as a warning-tone loss row"
// (02-architecture.md § The banner surface, settled 2026-07-31).
case .readOnlyLock, .reloadBreakage, .oneShot, .gitFailure: .error
case .historySuspended, .loss: .warning
case .inProgress, .signpost: .info
}
@@ -221,6 +304,7 @@ public enum BannerRow: Identifiable, Sendable {
case let .readOnlyLock(reason): BannerCenter.headline(for: reason)
case let .reloadBreakage(error): BannerCenter.headline(for: error)
case let .oneShot(banner): BannerCenter.headline(for: banner.error)
case let .gitFailure(banner): BannerCenter.headline(for: banner)
case let .loss(loss): loss.message
case let .historySuspended(suspension): BannerCenter.headline(for: suspension)
case let .inProgress(operation): operation.label
@@ -234,6 +318,7 @@ public enum BannerRow: Identifiable, Sendable {
public var dismissID: UUID? {
switch self {
case let .oneShot(banner): banner.id
case let .gitFailure(banner): banner.id
case let .loss(loss): loss.id
case let .signpost(signpost): signpost.id
case .readOnlyLock, .reloadBreakage, .historySuspended, .inProgress: nil
@@ -300,11 +385,12 @@ public enum BannerRowControl: Identifiable, Sendable {
///
/// ### What lives here and what does not
///
/// A center holds the state nothing else does: dismissable one-shot write failures, loss rows, the
/// history suspension, in-progress operations, and passive signposts. It deliberately does **not**
/// hold the read-only lock or the reload breakage those are `BoardStore`'s truths, and copying
/// them here would create a second place for them to be stale. `BoardStore.bannerRows` composes both
/// halves through `rows(lock:breakage:oneShots:losses:suspension:operations:signposts:)`, which is a
/// A center holds the state nothing else does: dismissable one-shot failures in both shapes (write
/// and git-operation), loss rows, the history suspension, in-progress operations, and passive
/// signposts. It deliberately does **not** hold the read-only lock or the reload breakage those
/// are `BoardStore`'s truths, and copying them here would create a second place for them to be
/// stale. `BoardStore.bannerRows` composes both halves through
/// `rows(lock:breakage:oneShots:losses:suspension:operations:signposts:gitFailures:)`, which is a
/// *pure function* precisely so the precedence rule can be tested without a store, a window, or a
/// filesystem.
///
@@ -315,7 +401,10 @@ public enum BannerRowControl: Identifiable, Sendable {
/// diagnostic `reason`; `headline(for:)` switches over that enum **exhaustively, with no
/// `default`**, so a Writer operation added without a sentence to say about it is a compile-time
/// hole rather than a silent fallback. The same rule covers locks, breakage, and the history
/// suspension: their user-facing lines are here, not on the error types.
/// suspension: their user-facing lines are here, not on the error types. **And the git operations
/// too** (settled 2026-07-31): `GitOperation` is that vocabulary's git-side twin "the operation
/// named in the user's words plus the underlying error, phrasing still BannerCenter's" which is
/// why `GitOperationFailure.operation`, a developer-facing string, never reaches a row.
///
/// ### One center per window, not per board
///
@@ -334,6 +423,17 @@ public final class BannerCenter {
/// turn still order deterministically.
public private(set) var oneShots: [OneShotBanner] = []
/// The failure class's git-shaped half, newest first like `oneShots` a failed undo restore,
/// a failed branch switch, and (pro-m2) a failed pull or push (settled 2026-07-31, see
/// `GitFailureBanner`).
///
/// **A second array rather than a second case inside `OneShotBanner`**: the two shapes have no
/// payload in common one carries the closed `WriteOperation` vocabulary and a path, the other
/// a git operation and libgit2's message and every reader of a write failure would have to
/// start asking whether it was one. They meet where the design says they meet, in the strip's
/// order: `rows(...)` merges them into one precedence class, newest first across both.
public private(set) var gitFailures: [GitFailureBanner] = []
/// Newest first, like `oneShots` content that didn't arrive though nothing failed,
/// dismissable and untimed for the same reason a one-shot failure is (settled 2026-07-28, see
/// `LossBanner`).
@@ -361,6 +461,19 @@ public final class BannerCenter {
oneShots.insert(OneShotBanner(error: error), at: 0)
}
/// **Records a git operation that didn't happen** the failure class's second shape (settled
/// 2026-07-31, see `GitFailureBanner`): an undo or redo restore that failed cleanly
/// (06-history-undo.md Interaction with external writers), a branch switch that could not run
/// (06 Branch switching), and the remote pair when pro-m2 wires them.
///
/// `reason` is the underlying error and nothing else libgit2's own message, or the app's own
/// diagnosis of a repository it could not read. **Every user-facing word is composed here**
/// (`headline(for:)`), which is what keeps a caller from inventing a verb: the git layer's
/// `GitOperationFailure.operation` is a developer-facing string and never reaches the strip.
public func postGitFailure(_ operation: GitOperation, reason: String) {
gitFailures.insert(GitFailureBanner(operation: operation, reason: reason), at: 0)
}
/// Posts a loss row content that didn't arrive though nothing failed (settled 2026-07-28, see
/// `LossBanner`). Newest first, like the one-shots it shares a lifecycle with.
public func postLoss(_ message: String) {
@@ -575,20 +688,23 @@ public final class BannerCenter {
nonisolated static let mixedTrashDragMessage =
"Cards and lanes leave the trash separately \u{2014} restore one kind at a time"
/// Removes a dismissable row: a one-shot failure, a loss row, or a signpost. **An id that names
/// an in-progress operation is ignored** rather than ending it, because "dismiss" and "cancel"
/// are different promises and a row that offers one must never quietly do the other.
/// Removes a dismissable row: a one-shot failure in either shape, a loss row, or a signpost.
/// **An id that names an in-progress operation is ignored** rather than ending it, because
/// "dismiss" and "cancel" are different promises and a row that offers one must never quietly do
/// the other.
public func dismiss(_ id: UUID) {
oneShots.removeAll { $0.id == id }
gitFailures.removeAll { $0.id == id }
losses.removeAll { $0.id == id }
signposts.removeAll { $0.id == id }
}
/// Removes every dismissable row one-shots, losses, and signposts alike. The strip's own
/// "clear all" affordance later; today it is what a window uses when it re-homes its rows
/// Removes every dismissable row both failure shapes, losses, and signposts alike. The strip's
/// own "clear all" affordance later; today it is what a window uses when it re-homes its rows
/// elsewhere (m6).
public func dismissAllDismissableRows() {
oneShots.removeAll()
gitFailures.removeAll()
losses.removeAll()
signposts.removeAll()
}
@@ -684,10 +800,17 @@ public final class BannerCenter {
/// a condition and a one-shot are not comparable by recency in any way a user would read as
/// order the condition's `since` is when it *started* being true, not when it happened
/// so they are ordered by kind, and recency orders only the one-shots among themselves.
/// - **The failure rank holds both shapes, interleaved by recency** (settled 2026-07-31):
/// "failures rank by what they are, not by which error vocabulary threw them", so a failed
/// undo posted a second ago sits above a failed move from a minute ago and below one from a
/// second before it. Ties two rows sharing a `Date` to the microsecond put the write
/// failure first; the two shapes are posted from different call sites, so a tie is an
/// accident of the clock rather than an order anyone can read.
///
/// `signposts` carries a default because its producer is m6's card window and nothing posts one
/// today; every other class has a live producer and is spelled out at every call site `losses`
/// included, since a Finder drop that skipped folders already posts one (`postSkippedFolders`).
/// `signposts` and `gitFailures` carry defaults: the first because its producer is m6's card
/// window, the second because a center that hosts no git surface (a card window's own) can never
/// hold one. Every other class is spelled out at every call site `losses` included, since a
/// Finder drop that skipped folders already posts one (`postSkippedFolders`).
public nonisolated static func rows(
lock: ReadOnlyLockReason?,
breakage: BoardLoadError?,
@@ -695,7 +818,8 @@ public final class BannerCenter {
losses: [LossBanner],
suspension: HistorySuspension?,
operations: [InProgressOperation],
signposts: [InfoSignpost] = []
signposts: [InfoSignpost] = [],
gitFailures: [GitFailureBanner] = []
) -> [BannerRow] {
var rows: [BannerRow] = []
@@ -708,10 +832,13 @@ public final class BannerCenter {
rows.append(.reloadBreakage(breakage))
}
let ordered = newestFirst(oneShots)
rows.append(contentsOf: ordered.lazy.filter { !$0.isAttachmentImport }.map(BannerRow.oneShot))
let ordered = newestFirst(oneShots, by: \.occurredAt)
rows.append(contentsOf: failureRank(
writes: ordered.filter { !$0.isAttachmentImport },
git: newestFirst(gitFailures, by: \.occurredAt)
))
rows.append(contentsOf: newestFirst(losses).map(BannerRow.loss))
rows.append(contentsOf: newestFirst(losses, by: \.occurredAt).map(BannerRow.loss))
if let suspension {
rows.append(.historySuspended(suspension))
@@ -722,31 +849,47 @@ public final class BannerCenter {
return rows
}
/// Newest first, and **stable**: `sorted(by:)` is not, and two failures posted in the same run
/// loop turn can share a `Date` to the microsecond. Ties fall back to the input order, which
/// `post(_:)` already maintains newest-first so a tie renders in the order it was posted
/// rather than in whatever order the sort happened to leave.
private nonisolated static func newestFirst(_ banners: [OneShotBanner]) -> [OneShotBanner] {
banners
.enumerated()
.sorted { lhs, rhs in
lhs.element.occurredAt == rhs.element.occurredAt
? lhs.offset < rhs.offset
: lhs.element.occurredAt > rhs.element.occurredAt
/// **The failure rank, both shapes** (settled 2026-07-31): the non-attachment write failures and
/// the git-operation failures, merged into the one precedence class they share "failures rank
/// by what they are, not by which error vocabulary threw them".
///
/// A merge rather than a re-sort, because both inputs arrive newest-first already; ties keep the
/// write failure first, which is arbitrary and says so (`rows(...)`).
private nonisolated static func failureRank(
writes: [OneShotBanner],
git: [GitFailureBanner]
) -> [BannerRow] {
var merged: [BannerRow] = []
var writes = writes[...]
var git = git[...]
while let write = writes.first, let failure = git.first {
if failure.occurredAt > write.occurredAt {
merged.append(.gitFailure(failure))
git = git.dropFirst()
} else {
merged.append(.oneShot(write))
writes = writes.dropFirst()
}
.map(\.element)
}
merged.append(contentsOf: writes.map(BannerRow.oneShot))
merged.append(contentsOf: git.map(BannerRow.gitFailure))
return merged
}
/// The same stable newest-first ordering as the overload above, for loss rows the two classes
/// share a lifecycle, and `postLoss` already maintains newest-first on insertion the way
/// `post(_:)` does.
private nonisolated static func newestFirst(_ losses: [LossBanner]) -> [LossBanner] {
losses
/// Newest first, and **stable**: `sorted(by:)` is not, and two rows posted in the same run loop
/// turn can share a `Date` to the microsecond. Ties fall back to the input order, which every
/// `post` maintains newest-first on insertion so a tie renders in the order it was posted
/// rather than in whatever order the sort happened to leave.
///
/// One function over a date key rather than one per class: the three dismissable classes order
/// by exactly the same rule, and three copies of it were three places for it to drift.
private nonisolated static func newestFirst<Row>(_ rows: [Row], by occurredAt: KeyPath<Row, Date>) -> [Row] {
rows
.enumerated()
.sorted { lhs, rhs in
lhs.element.occurredAt == rhs.element.occurredAt
lhs.element[keyPath: occurredAt] == rhs.element[keyPath: occurredAt]
? lhs.offset < rhs.offset
: lhs.element.occurredAt > rhs.element.occurredAt
: lhs.element[keyPath: occurredAt] > rhs.element[keyPath: occurredAt]
}
.map(\.element)
}
@@ -764,6 +907,39 @@ public final class BannerCenter {
return cause.isEmpty ? action : "\(action)\(cause)"
}
/// The user-facing line for a git operation that didn't happen: what the app could not do, then
/// why the write failure's shape exactly (settled 2026-07-31), because the two are one class.
///
/// The cause is the underlying error verbatim, on `causePhrase(for:)`'s reasoning read one layer
/// down: libgit2's messages are specific in a way no re-phrasing of ours would be, and the
/// alternative to showing one is a shrug. An empty or whitespace-only reason leaves the action
/// clause alone rather than trailing a dash into nothing.
public nonisolated static func headline(for failure: GitFailureBanner) -> String {
let action = actionPhrase(for: failure.operation)
let cause = trimmed(failure.reason)
return cause.isEmpty ? action : "\(action)\(cause)"
}
/// **Exhaustive by construction no `default`**, the `WriteOperation` rule applied to the git
/// vocabulary: an operation added to `GitOperation` without a sentence here fails to compile.
///
/// The undo pair is named by **the command the user pressed** the skipped-step row's rule
/// (`skippedStepMessage`), and for its reason: Z is the gesture, and "Couldn't restore an
/// earlier state" would describe machinery the user has no model of. The branch switch is named
/// by the control they used (the popover's branch picker) and stays plural "branches" rather
/// than naming the target, which the in-progress row this failure replaces already said
/// ("Switching to 'main'") and which `GitOperation` deliberately doesn't carry.
private nonisolated static func actionPhrase(for operation: GitOperation) -> String {
switch operation {
case .undo: "Undo failed"
case .redo: "Redo failed"
case .branchSwitch: "Couldn't switch branches"
// Named by the control the user pressed, like the pair above "Add Git" is the button, and
// the row is only ever seen by someone who pressed it and looked away.
case .addGit: "Couldn't add git to this board"
}
}
/// **Exhaustive by construction no `default`.** A `WriteOperation` case added without a
/// sentence here fails to compile, which is the settled contract ("a new Writer operation
/// without a banner rendering is a compile-time hole, not a silent default").
@@ -853,6 +1029,14 @@ public final class BannerCenter {
// touched, and nothing is lost: the board works exactly as well without the guide, which
// is why every *refusal* to write it is a log line and only a real I/O failure gets here.
"Couldn't update the agent guide"
case .seedGitignore:
// **The agent guide's sentence, one file over**, and for its reasons: `.gitignore` is a
// courtesy the app writes once, most users will never open it, and nothing about the
// board's own files is at stake a board without one simply excludes nothing, which is
// how every board behaved until 2026-07-31. It names the file rather than a role because
// this one *does* have a name users know from git, and "the ignore list" would be the
// app inventing a word for something already called something.
"Couldn't write this board's .gitignore"
case let .displaceClaimedName(name):
// **The name, quoted, and what the app wanted with it** the failure's mirror of the
// success row ("Renamed '.trash' to '.trash 2' Lanework needs that name"). It names