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 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
This commit is contained in:
+102
-15
@@ -812,6 +812,15 @@ public final class AppModel {
|
||||
git.activateAutoCommit { [weak store] committer in
|
||||
guard let store else { return }
|
||||
committer.currentSnapshot = { [weak store] in store?.snapshot }
|
||||
// **The flush awaits the snapshot that covers it** (06 ▸ Rules ▸ Auto-commit, ruled
|
||||
// 2026-07-31): "the composer diffs `store.snapshot` against HEAD, so the close flush
|
||||
// awaits a snapshot generation covering its changed paths before the committer runs —
|
||||
// the commit's subject can never be outrun by its own reload". Both halves of that
|
||||
// await are reads of the store the composer is already diffing, which is why they are
|
||||
// wired here rather than reached for: the engine holds the *policy* (when to wait, how
|
||||
// long), the session supplies the two facts (`GitAutoCommitter.awaitCoveringSnapshot`).
|
||||
committer.awaitReloadQuiescence = { [weak store] in await store?.awaitQuiescence() }
|
||||
committer.snapshotGeneration = { [weak store] in store?.snapshotGeneration }
|
||||
// 02-architecture.md ▸ Write-failure surfacing, through the strip the board window
|
||||
// already renders: a genuine commit failure means "your edits are saved, history has
|
||||
// stopped advancing", which is exactly what the standing suspension row says. Lock
|
||||
@@ -839,6 +848,14 @@ public final class AppModel {
|
||||
git.didAddGit = { [weak self] in
|
||||
self?.bindHistoryProvider(for: ref)
|
||||
}
|
||||
// **The form-anchored posture's fallback half** (06 ▸ Interaction with external writers,
|
||||
// ruled 2026-07-31): add-git answers inline in the form that asked, and lands here instead
|
||||
// when that form has been dismissed before the answer arrived — "inline is the primary
|
||||
// surface, never a silence trap". The banner enumeration is the same one branch switch and
|
||||
// undo restore post into, one row per failure.
|
||||
git.reportFailure = { [weak store] failure in
|
||||
store?.banners.postGitFailure(.addGit, reason: failure.message)
|
||||
}
|
||||
}
|
||||
// **The binding 13-native-undo.md ▸ Rules' "registration at the Writer boundary" needs**: the
|
||||
// store is that boundary — every app-mediated mutation goes out through one of its write
|
||||
@@ -900,17 +917,16 @@ public final class AppModel {
|
||||
self?.resumeCardSessionStaging(for: ref)
|
||||
}
|
||||
// **A restore that failed cleanly** (06 ▸ Interaction with external writers: "surfaces as a
|
||||
// one-shot banner failure naming the operation and the error, the tree left as it was").
|
||||
// one-shot banner failure naming the operation and the error, the tree left as it was") —
|
||||
// now literally that, at the failure rank in the error tone (02 ▸ The banner surface, settled
|
||||
// 2026-07-31: the one-shot class's second, message-carrying shape). The loss-row compromise
|
||||
// this line used to carry is retired: a ⌘Z that didn't happen is an action that didn't
|
||||
// happen, not content that didn't arrive.
|
||||
//
|
||||
// Posted as a **loss row**, and the compromise is recorded rather than hidden: the true
|
||||
// failure class (`OneShotBanner`) carries a `BoardWriteError`, whose `operation` is the closed
|
||||
// `WriteOperation` vocabulary — and a git operation is deliberately not one of those
|
||||
// (`BoardStore.performWholesale`'s own note says so). The loss row is the nearest honest
|
||||
// class: warning tone, one-shot lifecycle, never auto-expires, and a free-form message that
|
||||
// can name both halves 06 asks for. A message-carrying failure class is the right fix and is
|
||||
// a banner-surface change, not this card's.
|
||||
provider.reportFailure = { [weak store] failure in
|
||||
store?.banners.postLoss(failure.description)
|
||||
// The closure passes the *direction* and libgit2's own message and stops there — "Undo
|
||||
// failed — …" is BannerCenter's sentence, from the closed `GitOperation` vocabulary.
|
||||
provider.reportFailure = { [weak store] direction, failure in
|
||||
store?.banners.postGitFailure(.restore(direction), reason: failure.message)
|
||||
}
|
||||
provider.runBracketed = { [weak store] subject, work in
|
||||
guard let store else { return await work() }
|
||||
@@ -978,11 +994,16 @@ public final class AppModel {
|
||||
store?.banners.updateOperation(id, label: label)
|
||||
}
|
||||
switcher.endProgress = { [weak store] id in store?.banners.endOperation(id) }
|
||||
// The loss row, on `GitHistoryProvider.reportFailure`'s recorded compromise — see it for why
|
||||
// a git failure cannot be a `OneShotBanner` today.
|
||||
// The failure rank's git shape, as on the restore path above: a switch that didn't happen is
|
||||
// an action that didn't happen ("Couldn't switch branches — …", BannerCenter's words from
|
||||
// the operation alone).
|
||||
switcher.reportFailure = { [weak store] failure in
|
||||
store?.banners.postLoss(failure.description)
|
||||
store?.banners.postGitFailure(.branchSwitch, reason: failure.message)
|
||||
}
|
||||
// **The recovery notice stays a loss row**, and the ruling is explicit about why (02 ▸ The
|
||||
// banner surface): "recovery notices report a success, not a failure, and stay warning-tone".
|
||||
// "A branch switch was interrupted — the previous state is restored" is the app tidying up
|
||||
// after itself, with nothing for the user to do — the loss class's own register.
|
||||
switcher.reportRecovery = { [weak store] message in
|
||||
store?.banners.postLoss(message)
|
||||
}
|
||||
@@ -1009,7 +1030,16 @@ public final class AppModel {
|
||||
let outcome = await gate.settleAll()
|
||||
// The switch's flush runs next and must find a tree it can settle whole — see
|
||||
// `releaseCardSessionStaging(for:)` for why the modal's own predicate is not enough.
|
||||
if outcome == .proceed { self.releaseCardSessionStaging(for: ref) }
|
||||
//
|
||||
// **And the fine undo stacks go with it** (06 ▸ Branch switching, ruled 2026-07-31): the
|
||||
// same `.proceed`, the same seam, for the same reason one rung up — what a window is
|
||||
// holding describes the branch being left. "Cancel keeps the current branch and the
|
||||
// sessions" is this `if`, unchanged: a cancelled or failed settle clears nothing, exactly
|
||||
// as it releases nothing.
|
||||
if outcome == .proceed {
|
||||
self.releaseCardSessionStaging(for: ref)
|
||||
self.discardCardWindowUndoStacks(for: ref)
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
@@ -1019,6 +1049,23 @@ public final class AppModel {
|
||||
Task { await switcher.recoverInterruptedOperation() }
|
||||
}
|
||||
|
||||
/// **The three buttons, as a seam** — `SessionSettleStep.ask(message:)` in production.
|
||||
///
|
||||
/// `SessionSettleGate` already keeps the presentation behind a closure for its own reason
|
||||
/// ("presenting three buttons is AppKit's job and cannot be asserted without a display … the
|
||||
/// presentation is a seam and the decision is testable"), and every gate this model builds pointed
|
||||
/// that closure straight at the alert — so the *composition* around the gate, which is what
|
||||
/// `releaseCardSessionStaging(for:)` and `discardCardWindowUndoStacks(for:)` hang off, could only
|
||||
/// be exercised by a board with nothing to settle. Lifting the ask one level up is what lets a test
|
||||
/// answer Save All, Discard and Cancel over real card windows without a modal on screen.
|
||||
///
|
||||
/// `@ObservationIgnored` because nothing renders from it, and internal because it is a test seam
|
||||
/// rather than API: production never assigns it.
|
||||
@ObservationIgnored
|
||||
var settleAsk: @MainActor (String) async -> SessionSettleChoice = {
|
||||
await SessionSettleStep.ask(message: $0)
|
||||
}
|
||||
|
||||
/// **The save-or-discard step for one board**, built from its open card windows
|
||||
/// (06-history-undo.md ▸ Rules ▸ Undo restore vs open Edit sessions; ▸ Branch switching).
|
||||
///
|
||||
@@ -1055,7 +1102,10 @@ public final class AppModel {
|
||||
)
|
||||
}
|
||||
},
|
||||
ask: { await SessionSettleStep.ask(message: message) },
|
||||
ask: { [weak self] in
|
||||
guard let self else { return await SessionSettleStep.ask(message: message) }
|
||||
return await self.settleAsk(message)
|
||||
},
|
||||
focus: { [weak self] id in
|
||||
guard let self, let session = self.sessions[ref] else { return }
|
||||
guard let cardRef = session.cardRefs.first(where: { $0.cardID == id }) else { return }
|
||||
@@ -1212,6 +1262,43 @@ public final class AppModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// **The branch switch's settle empties every open card window's fine undo stack**
|
||||
/// (06-history-undo.md ▸ Branch switching, ruled 2026-07-31).
|
||||
///
|
||||
/// > "The settle also clears each open card window's fine undo stack: pre-switch steps describe
|
||||
/// > the branch being left — Save All and Discard alike end with every window's stack empty, the
|
||||
/// > board-stack discard-and-reseed precedent one level down; the windows stay open, following
|
||||
/// > their cards onto the new branch with fresh stacks."
|
||||
///
|
||||
/// ### Why here, beside the staging release
|
||||
///
|
||||
/// Because it is the same fact about the same moment. `releaseCardSessionStaging(for:)` lets go of
|
||||
/// what a window is holding *on disk*; this lets go of what it is holding *in memory*, and both
|
||||
/// are true of a session whose branch is about to be replaced under it. Running them from one
|
||||
/// `.proceed` is also what makes "Cancel clears nothing" a property of one `if` rather than a rule
|
||||
/// two call sites have to keep in step (`wireBranchSwitching`).
|
||||
///
|
||||
/// **The board stack is not touched**, and it is not an omission: the switch discards and reseeds
|
||||
/// it from the new HEAD's first-parent ancestry a few steps later, inside the bracket
|
||||
/// (`GitBranchSwitcher.reseedUndo` → `GitHistoryProvider.reseed`). Doing it here would be the same
|
||||
/// discard, one level up, at the wrong moment — before the checkout that decides what to reseed
|
||||
/// *from*.
|
||||
///
|
||||
/// **The restore path deliberately does not call this.** An undo restore materializes a diff and
|
||||
/// leaves the branch where it is, so a window's steps still describe the branch they were made on;
|
||||
/// what protects them there is 13-native-undo.md's field-level staleness predicate, which is a
|
||||
/// per-step question rather than a wholesale one.
|
||||
///
|
||||
/// The downcast is the honest shape rather than a shortcut: `CardSessionFlushing` is the *close
|
||||
/// flush's* seam — end the session, say whether it holds unsaved content, offer the settle's two
|
||||
/// writes — and a fine undo stack is none of those things. The one type that has one is the card
|
||||
/// window's own session, which is what every registration passes.
|
||||
func discardCardWindowUndoStacks(for ref: BoardWindowRef) {
|
||||
for cardRef in sessions[ref]?.cardRefs ?? [] {
|
||||
(cardSessions[cardRef] as? CardWindowSession)?.undo.discardSteps()
|
||||
}
|
||||
}
|
||||
|
||||
/// **The next session begins** — the other half of `releaseCardSessionStaging(for:)`, run when the
|
||||
/// operation behind the settle has finished with the tree.
|
||||
///
|
||||
|
||||
@@ -428,6 +428,15 @@ enum TemplateEngine {
|
||||
// The lane list is the loader's own level detection — `directoryCandidates` (hidden
|
||||
// entries and symlinks already out) narrowed by the identity predicate — so a stray
|
||||
// folder at board level is never descended into here either.
|
||||
// **The seed, before the normalization it governs** (01-storage-format.md § Fractal
|
||||
// layout ▸ Rules and 06-history-undo.md ▸ Repository hygiene, ruled 2026-07-31: every
|
||||
// board carries a `.gitignore`, and an instantiated board is a board created today). A
|
||||
// template that carries one of its own copies through and is honored — the seed writes
|
||||
// only into a free name — which is 09-templates.md's "strays copy through" doing the
|
||||
// right thing by itself; a template written before the ruling gains the seed here rather
|
||||
// than a reload later, so the new board's very first walk has its noise gate.
|
||||
try BoardWriter.seedGitignoreIfAbsent(atBoardRoot: root)
|
||||
|
||||
let lanes = ((try? BoardLoader.directoryCandidates(in: root)) ?? [])
|
||||
.filter { BoardLoader.isUUIDShaped($0.lastPathComponent) }
|
||||
for lane in lanes {
|
||||
|
||||
@@ -108,6 +108,19 @@ public enum CommitAttribution {
|
||||
/// and the generic external author".
|
||||
public static let agentEmailDomain = "agents.lanework.invalid"
|
||||
|
||||
/// **Who a heal commit is by** (06 ▸ Commit messages ▸ Healing mutations commit separately, ruled
|
||||
/// 2026-07-31 — "the third pinned synthetic, joining Lanework External and the agent-slug family;
|
||||
/// strings are API"):
|
||||
///
|
||||
/// > a heal is a third origin — not the user's gesture, not a foreign writer — and the separation
|
||||
/// > exists for audit, so the trail filters by author like every origin; the committer stays the
|
||||
/// > user (the recorded-by convention above).
|
||||
///
|
||||
/// It replaced authoring heals as the user, which made the separate commit filterable only by
|
||||
/// message shape — and the shape vocabulary deliberately never says "healed".
|
||||
public static let integrityAuthorName = "Lanework Integrity"
|
||||
public static let integrityAuthorEmail = "[email protected]"
|
||||
|
||||
/// The frontmatter key a foreign writer refines its own attribution with
|
||||
/// (01-storage-format.md; 08-agent-integration.md teaches it).
|
||||
static let modifiedByKey = "modified-by"
|
||||
@@ -116,6 +129,13 @@ public enum CommitAttribution {
|
||||
GitIdentity(name: externalAuthorName, email: externalAuthorEmail)
|
||||
}
|
||||
|
||||
/// The heal class's author (`integrityAuthorName`). The *committer* beside it is still the user's
|
||||
/// identity, every time — "every commit the app makes, foreign-authored included, records the
|
||||
/// user's app as its committer" (06).
|
||||
public static var integrityIdentity: GitIdentity {
|
||||
GitIdentity(name: integrityAuthorName, email: integrityAuthorEmail)
|
||||
}
|
||||
|
||||
/// **A `modified-by` stamp, as an author** (06): "that commit is authored as **X** with the
|
||||
/// synthetic email `<slug>@agents.lanework.invalid` (display name verbatim, email local part
|
||||
/// slugified)".
|
||||
|
||||
@@ -75,6 +75,23 @@ public struct CommitMessageRequest: Sendable {
|
||||
/// be read, or has been deleted.
|
||||
public let agentGuideText: String?
|
||||
|
||||
/// **When each of this commit's comments was created** — keyed by the comment folder's
|
||||
/// board-root-relative path, as `CommitMessageEngine.commentFolder(of:)` spells it.
|
||||
///
|
||||
/// The second value on this struct that a *file* has to be read for, and it is here for
|
||||
/// `agentGuideText`'s reason exactly: "a commit's comment bullets sort chronologically — by the
|
||||
/// comments' own `created`, folder name on ties" (06 ▸ Rules ▸ Auto-commit, blessed 2026-07-31),
|
||||
/// and `created` lives in a comment's own `index.md` because comments are window-scoped and the
|
||||
/// board snapshot never carries them (01-storage-format.md § Enhanced schema). The flush resolves
|
||||
/// it once (`GitAutoCommitter.commentTimestamps(for:boardRoot:)`) and the engine stays a pure
|
||||
/// function of values.
|
||||
///
|
||||
/// **Missing is normal, not a defect.** A comment whose folder left the tree in this very commit
|
||||
/// (the close purge), one whose `index.md` does not parse, one written by hand with no `created`
|
||||
/// at all — each is simply absent here and sorts after its dated siblings in folder-name order,
|
||||
/// which is `CommentThread.sorted`'s own fallback for the same field.
|
||||
public let commentTimestamps: [String: Date]
|
||||
|
||||
public init(
|
||||
boardRoot: URL,
|
||||
changedPaths: [GitChangedPath],
|
||||
@@ -82,7 +99,8 @@ public struct CommitMessageRequest: Sendable {
|
||||
isRootCommit: Bool,
|
||||
snapshot: BoardModel?,
|
||||
previousSnapshot: BoardModel? = nil,
|
||||
agentGuideText: String? = nil
|
||||
agentGuideText: String? = nil,
|
||||
commentTimestamps: [String: Date] = [:]
|
||||
) {
|
||||
self.boardRoot = boardRoot
|
||||
self.changedPaths = changedPaths
|
||||
@@ -91,6 +109,7 @@ public struct CommitMessageRequest: Sendable {
|
||||
self.snapshot = snapshot
|
||||
self.previousSnapshot = previousSnapshot
|
||||
self.agentGuideText = agentGuideText
|
||||
self.commentTimestamps = commentTimestamps
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,11 @@ enum CommitMessageEngine {
|
||||
/// container with no identity of its own (`BoardModel.trash`).
|
||||
static let trashDestination = "the trash"
|
||||
|
||||
/// What a key's old (or new) side reads as when the frontmatter simply did not carry it — the
|
||||
/// `(untitled)` convention applied to a value rather than to a name, so "sprint: (none) → 42"
|
||||
/// says *added* without a second sentence shape for it.
|
||||
static let absentValuePlaceholder = "(none)"
|
||||
|
||||
// MARK: - Entry point
|
||||
|
||||
/// One request in, one whole message out — a subject, and a body when there is more to say.
|
||||
@@ -242,8 +247,12 @@ enum CommitMessageEngine {
|
||||
previous: previous.document,
|
||||
current: current.document,
|
||||
noun: "board",
|
||||
// The trio's board phrasing is count-less and title-less ("Relabel board") — there is one
|
||||
// board and naming it adds nothing. The custom-key event is the one place 06 spells the
|
||||
// board's title out ("Change custom key on board '⟨title⟩'"), so it gets it separately.
|
||||
itemTitle: nil,
|
||||
kinds: (.relabelBoard, .assignBoard, .dueBoard, .updateBoard),
|
||||
customKeyItemTitle: title(current.title),
|
||||
kinds: (.relabelBoard, .assignBoard, .dueBoard, .changeKeyBoard),
|
||||
paths: paths
|
||||
)
|
||||
return events
|
||||
@@ -380,7 +389,8 @@ enum CommitMessageEngine {
|
||||
current: new.document,
|
||||
noun: "lane",
|
||||
itemTitle: new.displayTitle,
|
||||
kinds: (.relabelLane, .assignLane, .dueLane, .updateLane),
|
||||
customKeyItemTitle: new.displayTitle,
|
||||
kinds: (.relabelLane, .assignLane, .dueLane, .changeKeyLane),
|
||||
paths: paths
|
||||
)
|
||||
}
|
||||
@@ -637,7 +647,8 @@ enum CommitMessageEngine {
|
||||
current: new.card.document,
|
||||
noun: "card",
|
||||
itemTitle: new.displayTitle,
|
||||
kinds: (.relabelCard, .assignCard, .dueCard, .updateCard),
|
||||
customKeyItemTitle: new.displayTitle,
|
||||
kinds: (.relabelCard, .assignCard, .dueCard, .changeKeyCard),
|
||||
paths: paths
|
||||
)
|
||||
return events
|
||||
@@ -684,9 +695,19 @@ enum CommitMessageEngine {
|
||||
|
||||
/// **The full schema-1 surface, plus the reserved trio, deliberately** (06 ▸ The external gap,
|
||||
/// closed): "label, assignee, and due changes compose … even though 01-storage-format.md reserves
|
||||
/// those keys out of this version's UI — external writers … are exactly who touches them. A change
|
||||
/// to any other unmodeled or custom key composes a named generic ('Update card 'X'') — **never a
|
||||
/// board-level shrug when the touched item is identifiable**."
|
||||
/// those keys out of this version's UI — external writers … are exactly who touches them."
|
||||
///
|
||||
/// **A change to any other unmodeled or custom key says what it is** (06, re-ruled 2026-07-31 —
|
||||
/// "first lines self-describe; generics are a last resort, kept very rare"):
|
||||
///
|
||||
/// > **"Change custom key on card 'X'"** (board and lane likewise — "Change custom key on board
|
||||
/// > '⟨title⟩'"; several keys fold plural), the body naming each key with its old → new values.
|
||||
///
|
||||
/// The named generic it retired ("Update card 'X'") is 06's own last resort and survives only
|
||||
/// "for a change in a known file that is neither a vocabulary event nor a key change — a shape
|
||||
/// that should almost never occur". Nothing composes that shape today: a known file whose diff is
|
||||
/// neither is the *bookkeeping* rule's silence, which composes nothing at all. So the vocabulary
|
||||
/// keeps no case for it, and the zero-event floor stays `unnamedSubject`'s.
|
||||
///
|
||||
/// Read off `unknownFields`, which is exactly "every key the schema does not own" — so the
|
||||
/// bookkeeping keys are excluded by construction rather than by a list kept in step: `modified`,
|
||||
@@ -701,7 +722,8 @@ enum CommitMessageEngine {
|
||||
current: FrontmatterDocument,
|
||||
noun: String,
|
||||
itemTitle: String?,
|
||||
kinds: (label: Kind, assignee: Kind, due: Kind, generic: Kind),
|
||||
customKeyItemTitle: String,
|
||||
kinds: (label: Kind, assignee: Kind, due: Kind, customKey: Kind),
|
||||
paths: [String]
|
||||
) -> [Event] {
|
||||
let before = Dictionary(previous.unknownFields.map { ($0.key, $0.rawValue) }, uniquingKeysWith: { _, last in last })
|
||||
@@ -726,11 +748,24 @@ enum CommitMessageEngine {
|
||||
if changed(Keys.labels) { events.append(event(kinds.label, "Relabel")) }
|
||||
if changed(Keys.assignees) { events.append(event(kinds.assignee, "Assign")) }
|
||||
if changed(Keys.due) { events.append(event(kinds.due, "Set due date on")) }
|
||||
// Everything else the schema does not own — one named generic for the item, however many
|
||||
// custom keys an agent touched in the same window.
|
||||
|
||||
// **Everything else the schema does not own says what it is** (re-ruled 2026-07-31). The
|
||||
// *item* is still what the subject names — one event however many keys moved — but the verb
|
||||
// now describes the change instead of shrugging at it, and the keys themselves ride the body.
|
||||
let trio: Set<String> = [Keys.labels, Keys.assignees, Keys.due]
|
||||
if Set(before.keys).union(after.keys).subtracting(trio).contains(where: changed) {
|
||||
events.append(event(kinds.generic, "Update"))
|
||||
let keys = Set(before.keys).union(after.keys).subtracting(trio).filter(changed).sorted()
|
||||
if !keys.isEmpty {
|
||||
let subject = keys.count == 1 ? "custom key" : "\(keys.count) custom keys"
|
||||
events.append(Event(
|
||||
kind: kinds.customKey,
|
||||
subject: "Change \(subject) on \(noun) \(quotedSubject(customKeyItemTitle))",
|
||||
bullet: "Change \(subject) on \(noun) \(quoted(customKeyItemTitle))",
|
||||
detail: keys
|
||||
.map { "\($0): \(before[$0] ?? absentValuePlaceholder) → \(after[$0] ?? absentValuePlaceholder)" }
|
||||
.joined(separator: "\n"),
|
||||
destination: customKeyItemTitle,
|
||||
paths: paths
|
||||
))
|
||||
}
|
||||
return events
|
||||
}
|
||||
@@ -750,10 +785,17 @@ enum CommitMessageEngine {
|
||||
/// lane is one event, not one line per file inside it);
|
||||
/// - the departing end of a **rename**, which its arrival already speaks for — the loose-file
|
||||
/// relocation, the remint, a displaced squatter.
|
||||
///
|
||||
/// **One path shape is not a generic** (06 ▸ Commit messages ▸ Vocabulary, "Replace added
|
||||
/// 2026-07-31"): "a changed file under a card's `attachments/` with an unchanged listing is a
|
||||
/// content replacement, named from the path alone: 'Replace attachment 'photo.png' — card 'X'',
|
||||
/// never the anonymous path generic". An unchanged listing is exactly "no model event claimed
|
||||
/// this path", so the rule needs no second question of the snapshot — see `replacedAttachment`.
|
||||
private static func pathEvents(for request: CommitMessageRequest, claimedBy model: [Event]) -> [Event] {
|
||||
let claimed = Set(model.flatMap(\.paths))
|
||||
var comments: [String: CommentGroup] = [:]
|
||||
var events: [Event] = []
|
||||
lazy var cardTitles = cardTitlesByPath(request)
|
||||
|
||||
for changed in request.changedPaths.sorted(by: { $0.path < $1.path }) {
|
||||
let path = changed.path
|
||||
@@ -763,7 +805,11 @@ enum CommitMessageEngine {
|
||||
// schema): a trashed card's thread lives under `.trash/`, which the model-silence rule
|
||||
// would otherwise swallow whole.
|
||||
if let comment = CommentPath.classify(path) {
|
||||
comments[CommentGroup.key(comment), default: CommentGroup(comment: comment)].add(changed)
|
||||
let folder = commentFolder(of: path) ?? comment.cardPath
|
||||
comments[
|
||||
CommentGroup.key(comment),
|
||||
default: CommentGroup(comment: comment, folder: folder)
|
||||
].add(changed)
|
||||
continue
|
||||
}
|
||||
guard !Paths.isModelSilent(path) else { continue }
|
||||
@@ -778,6 +824,21 @@ enum CommitMessageEngine {
|
||||
events.append(Event(kind: .agentGuide, subject: "Update agent guide (v\(version))", paths: [path]))
|
||||
continue
|
||||
}
|
||||
|
||||
// **Replace**, before the anonymous generic — the file is still listed, so the model had
|
||||
// nothing to say and the path says it instead.
|
||||
if let replaced = replacedAttachment(at: path) {
|
||||
let card = cardTitles[replaced.cardFolder] ?? untitledPlaceholder
|
||||
events.append(Event(
|
||||
kind: .replaceFile,
|
||||
subject: "Replace attachment \(quotedSubject(replaced.file)) — card \(quotedSubject(card))",
|
||||
bullet: "Replace attachment \(quoted(replaced.file)) — card \(quoted(card))",
|
||||
destination: card,
|
||||
paths: [path]
|
||||
))
|
||||
continue
|
||||
}
|
||||
|
||||
events.append(Event(
|
||||
kind: .updatePath,
|
||||
subject: "Update \(quotedSubject(path))",
|
||||
@@ -788,12 +849,33 @@ enum CommitMessageEngine {
|
||||
return commentEvents(comments, model: model, request: request) + events
|
||||
}
|
||||
|
||||
/// **A card attachment rewritten in place** — the `Replace` shape, read from the path alone.
|
||||
///
|
||||
/// `<lane>/<card>/attachments/<file>`, and nothing else: an attachment one folder deeper is not a
|
||||
/// shape this app writes, and a trashed card's is unreachable here (everything under `.trash/` is
|
||||
/// model-silent and never gets this far). Whether the *listing* changed is the caller's question,
|
||||
/// already answered — an added or removed file was claimed by its own Attach/Remove event before
|
||||
/// this path was ever reached.
|
||||
private static func replacedAttachment(at path: String) -> (cardFolder: String, file: String)? {
|
||||
let components = path.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
|
||||
guard components.count == 4,
|
||||
components[2] == Paths.attachmentsFolder,
|
||||
let folder = cardFolder(of: path)
|
||||
else { return nil }
|
||||
return (folder, components[3])
|
||||
}
|
||||
|
||||
// MARK: - The comment verb family
|
||||
|
||||
/// Every changed path inside **one comment folder**, gathered so a comment that had its
|
||||
/// `index.md` and two attachments rewritten is one event rather than three.
|
||||
private struct CommentGroup {
|
||||
let comment: CommentPath
|
||||
|
||||
/// This comment's own folder, board-root-relative — the key its `created` is looked up under
|
||||
/// (`CommitMessageRequest.commentTimestamps`) and the name the chronology's tie-break reads.
|
||||
let folder: String
|
||||
|
||||
var paths: [String] = []
|
||||
var hasArrival = false
|
||||
var hasSurvivor = false
|
||||
@@ -806,6 +888,11 @@ enum CommitMessageEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder's last component — the UUID a comment is named by, or `.draft`.
|
||||
var folderName: String {
|
||||
folder.split(separator: "/", omittingEmptySubsequences: true).last.map(String.init) ?? folder
|
||||
}
|
||||
|
||||
mutating func add(_ changed: GitChangedPath) {
|
||||
paths.append(changed.path)
|
||||
if changed.isArrival { hasArrival = true }
|
||||
@@ -813,6 +900,29 @@ enum CommitMessageEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// **The comment folder a changed path sits in**, board-root-relative — `<card>/comments/<uuid>`,
|
||||
/// `<card>/comments/.trash/<uuid>` or `<card>/comments/.draft` — or `nil` for a path that is not
|
||||
/// inside a thread at all.
|
||||
///
|
||||
/// Shared with the flush that reads each of those folders' `created`
|
||||
/// (`GitAutoCommitter.commentTimestamps(for:boardRoot:)`), so the key a timestamp is *filed*
|
||||
/// under and the key it is *looked up* by have one definition. Everything about where a thread
|
||||
/// lives is still `CommentPath.classify`'s; this only says how many of the path's components that
|
||||
/// classification consumed.
|
||||
static func commentFolder(of path: String) -> String? {
|
||||
guard let comment = CommentPath.classify(path) else { return nil }
|
||||
let components = path.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
|
||||
// `classify` has already guaranteed these counts — `<lane>/<card>/comments/<entry>` for a
|
||||
// comment or the draft, one deeper for `comments/.trash/<uuid>`.
|
||||
let depth: Int
|
||||
switch comment.kind {
|
||||
case .comment, .draft: depth = 4
|
||||
case .trashed: depth = 5
|
||||
}
|
||||
guard components.count >= depth else { return nil }
|
||||
return components.prefix(depth).joined(separator: "/")
|
||||
}
|
||||
|
||||
/// **The comment verb family** (01-storage-format.md § Enhanced schema, the `kind: comment` block:
|
||||
/// "foreign comment changes are described by **path shape** — the 'Update agent guide (vN)'
|
||||
/// mechanism: a changed path under `…/comments/<uuid>/` composes 'Comment on ⟨card title⟩' / 'Edit
|
||||
@@ -850,7 +960,7 @@ enum CommitMessageEngine {
|
||||
})
|
||||
|
||||
var events: [Event] = []
|
||||
for key in groups.keys.sorted() {
|
||||
for key in chronological(groups, timestamps: request.commentTimestamps) {
|
||||
guard let group = groups[key], !group.paths.isEmpty else { continue }
|
||||
guard !relocated.contains(group.comment.cardPath) else { continue }
|
||||
let card = titles[group.comment.cardPath] ?? untitledPlaceholder
|
||||
@@ -891,6 +1001,47 @@ enum CommitMessageEngine {
|
||||
return events
|
||||
}
|
||||
|
||||
/// **A commit's comment bullets sort chronologically** (06-history-undo.md ▸ Rules ▸ Auto-commit,
|
||||
/// blessed 2026-07-31):
|
||||
///
|
||||
/// > "by the comments' own `created`, folder name on ties — event order reads as the conversation
|
||||
/// > did, never UUID-arbitrary."
|
||||
///
|
||||
/// The ladder is `CommentThread.sorted`'s, one layer up and over folders rather than over parsed
|
||||
/// comments: `created` ascending, **the undated after the dated**, folder name on ties — compared
|
||||
/// as the canonical lowercase spelling, the corpus-wide rule for every folder-name tie-break
|
||||
/// (01-storage-format.md § Ordering). The thread the bullets describe is sorted by that ladder on
|
||||
/// screen; a commit describing the same thread in a different order would be the same events told
|
||||
/// out of sequence.
|
||||
///
|
||||
/// **Chronology is global rather than per card**, recorded as a judgment call. A window's comment
|
||||
/// events almost always belong to one card — the card-window session flush is that window by
|
||||
/// construction — and where they do not, "the order the events happened in" is still the honest
|
||||
/// reading of a list of events; grouping by card first would sort a conversation by which lane its
|
||||
/// card sits in. The group key breaks the last tie so the order is total, which keeps a message
|
||||
/// reproducible rather than hash-ordered.
|
||||
private static func chronological(
|
||||
_ groups: [String: CommentGroup],
|
||||
timestamps: [String: Date]
|
||||
) -> [String] {
|
||||
groups.keys.sorted { lhs, rhs in
|
||||
guard let left = groups[lhs], let right = groups[rhs] else { return lhs < rhs }
|
||||
switch (timestamps[left.folder], timestamps[right.folder]) {
|
||||
case let (leftDate?, rightDate?) where leftDate != rightDate:
|
||||
return leftDate < rightDate
|
||||
case (.some, .none):
|
||||
return true
|
||||
case (.none, .some):
|
||||
return false
|
||||
default:
|
||||
break
|
||||
}
|
||||
let leftName = IntegrityRules.canonicalIdentity(left.folderName)
|
||||
let rightName = IntegrityRules.canonicalIdentity(right.folderName)
|
||||
return leftName == rightName ? lhs < rhs : leftName < rightName
|
||||
}
|
||||
}
|
||||
|
||||
/// The card folders this commit says moved, arrived or went — the set a comment path checks
|
||||
/// itself against before speaking.
|
||||
private static func relocatedCardFolders(in model: [Event]) -> Set<String> {
|
||||
@@ -962,17 +1113,17 @@ enum CommitMessageEngine {
|
||||
|
||||
/// The verb-plus-noun grouping that decides what folds with what — **06's vocabulary, one case
|
||||
/// each**: Add / Delete / Move / Rename / Edit / Restyle / Resize / Reorder over cards, lanes and
|
||||
/// the board, Attach / Remove for attachment files, Repair for the remint, the trash pair, the
|
||||
/// reserved metadata trio, and the two path shapes.
|
||||
/// the board, Attach / Remove / **Replace** for attachment files, Repair for the remint, the trash
|
||||
/// pair, the reserved metadata trio, the custom-key change, and the two path shapes.
|
||||
enum Kind: Hashable {
|
||||
case addCard, deleteCard, restoreCard, purgeCard, moveCard, renameCard, editCard, restyleCard
|
||||
case relabelCard, assignCard, dueCard, updateCard
|
||||
case attachFile, removeFile, reorderCards, repairDuplicate
|
||||
case relabelCard, assignCard, dueCard, changeKeyCard
|
||||
case attachFile, removeFile, replaceFile, reorderCards, repairDuplicate
|
||||
|
||||
case addLane, deleteLane, restoreLane, purgeLane, renameLane, editLane, restyleLane, resizeLane
|
||||
case relabelLane, assignLane, dueLane, updateLane, reorderLanes
|
||||
case relabelLane, assignLane, dueLane, changeKeyLane, reorderLanes
|
||||
|
||||
case renameBoard, editBoard, restyleBoard, relabelBoard, assignBoard, dueBoard, updateBoard
|
||||
case renameBoard, editBoard, restyleBoard, relabelBoard, assignBoard, dueBoard, changeKeyBoard
|
||||
|
||||
case agentGuide, updatePath
|
||||
|
||||
@@ -1014,13 +1165,18 @@ enum CommitMessageEngine {
|
||||
case .relabelCard: return "Relabel \(count) cards"
|
||||
case .assignCard: return "Assign \(count) cards"
|
||||
case .dueCard: return "Set due date on \(count) cards"
|
||||
case .updateCard: return "Update \(count) cards"
|
||||
case .changeKeyCard:
|
||||
guard let destination else { return "Change custom keys on \(count) cards" }
|
||||
return "Change custom keys on card \(CommitMessageEngine.quotedSubject(destination))"
|
||||
case .attachFile:
|
||||
guard let destination else { return "Attach \(count) files" }
|
||||
return "Attach \(count) files to card \(CommitMessageEngine.quotedSubject(destination))"
|
||||
case .removeFile:
|
||||
guard let destination else { return "Remove \(count) files" }
|
||||
return "Remove \(count) files from card \(CommitMessageEngine.quotedSubject(destination))"
|
||||
case .replaceFile:
|
||||
guard let destination else { return "Replace \(count) attachments" }
|
||||
return "Replace \(count) attachments — card \(CommitMessageEngine.quotedSubject(destination))"
|
||||
case .reorderCards:
|
||||
guard let destination else { return "Reorder cards in \(count) lanes" }
|
||||
return "Reorder cards in \(CommitMessageEngine.truncated(destination))"
|
||||
@@ -1037,7 +1193,9 @@ enum CommitMessageEngine {
|
||||
case .relabelLane: return "Relabel \(count) lanes"
|
||||
case .assignLane: return "Assign \(count) lanes"
|
||||
case .dueLane: return "Set due date on \(count) lanes"
|
||||
case .updateLane: return "Update \(count) lanes"
|
||||
case .changeKeyLane:
|
||||
guard let destination else { return "Change custom keys on \(count) lanes" }
|
||||
return "Change custom keys on lane \(CommitMessageEngine.quotedSubject(destination))"
|
||||
|
||||
// A board has one title, one description, one style — and a lane reorder is a single
|
||||
// whole-board event. None of these can actually recur; the switch stays exhaustive.
|
||||
@@ -1048,7 +1206,11 @@ enum CommitMessageEngine {
|
||||
case .relabelBoard: return "Relabel board"
|
||||
case .assignBoard: return "Assign board"
|
||||
case .dueBoard: return "Set due date on board"
|
||||
case .updateBoard: return CommitMessageEngine.unnamedSubject
|
||||
// One board, so a plural of *events* here is a plural of keys already folded into one
|
||||
// event — unreachable in practice, and count-less if it ever is.
|
||||
case .changeKeyBoard:
|
||||
guard let destination else { return "Change custom keys on board" }
|
||||
return "Change custom keys on board \(CommitMessageEngine.quotedSubject(destination))"
|
||||
|
||||
case .agentGuide: return "Update agent guide"
|
||||
case .updatePath: return "Update \(count) files"
|
||||
@@ -1108,7 +1270,9 @@ enum CommitMessageEngine {
|
||||
/// Every `index.md` in the board's fractal layout, and everything inside `.trash/`. An
|
||||
/// attachment is deliberately **not** here: the model carries attachment *names*, so an added
|
||||
/// or removed file composes its own event, while a rewritten one — same name, new bytes — has
|
||||
/// nothing in the snapshot to show for it and rightly composes "Update '⟨path⟩'".
|
||||
/// nothing in the snapshot to show for it and composes **Replace** off its path instead
|
||||
/// (`replacedAttachment(at:)`, 06 ▸ Commit messages ▸ Vocabulary, added 2026-07-31 — "never
|
||||
/// the anonymous path generic").
|
||||
/// Whether a path could make the *snapshot* differ at all — every path the model speaks for,
|
||||
/// plus attachments, whose names it carries.
|
||||
///
|
||||
|
||||
@@ -134,6 +134,45 @@ public final class GitAutoCommitter {
|
||||
@ObservationIgnored
|
||||
public var currentSnapshot: (@MainActor () -> BoardModel?)?
|
||||
|
||||
/// **The reload pipeline settling** — `BoardStore.awaitQuiescence()`, and `nil` on a storeless
|
||||
/// committer.
|
||||
///
|
||||
/// Read only by `awaitCoveringSnapshot()`, whose whole correctness rests on it: it is what makes
|
||||
/// the *next* walk a walk that started after this flush's changes were on disk.
|
||||
@ObservationIgnored
|
||||
public var awaitReloadQuiescence: (@MainActor () async -> Void)?
|
||||
|
||||
/// **Which generation the board `currentSnapshot` answers with is at** —
|
||||
/// `BoardStore.snapshotGeneration`, incremented by every landed reload.
|
||||
///
|
||||
/// `nil` — the closure absent, or answering `nil` because the store has gone — means there is no
|
||||
/// snapshot to be outrun by, and the covering await becomes the no-op it is on every storeless
|
||||
/// committer.
|
||||
@ObservationIgnored
|
||||
public var snapshotGeneration: (@MainActor () -> Int?)?
|
||||
|
||||
/// **How long an explicit flush waits for its covering reload** before composing from the snapshot
|
||||
/// it already has.
|
||||
///
|
||||
/// A bound rather than an open-ended wait, and recorded as a judgment call: 06 rules that the
|
||||
/// flush awaits its covering snapshot and does not say what happens if that reload never lands. It
|
||||
/// normally lands within the watcher's ~200 ms debounce, and it is *scheduled unconditionally* by
|
||||
/// the write bracket that closed (`FolderWatcher.endBracket`, "the mandatory single post-bracket
|
||||
/// reload … even if not one filesystem event was seen"), so the wait is short and certain in every
|
||||
/// ordinary case. What it must not be is unbounded: this flush runs on the close and quit paths,
|
||||
/// and a board whose watcher stream failed to start (`BoardStoreRegistry.acquire` logs and carries
|
||||
/// on) would otherwise make the app unquittable. So the wait ends, generously, and the commit is
|
||||
/// composed from the snapshot in hand — one stale subject in a degraded configuration, against a
|
||||
/// hang.
|
||||
@ObservationIgnored
|
||||
public var coveringSnapshotDeadline: Duration = .seconds(1)
|
||||
|
||||
/// How often the wait re-reads the generation. Polled rather than signalled for
|
||||
/// `CloseFlushCoordinator.drainCardWindows`' reason: the point of this wait is that it *ends*, and
|
||||
/// a continuation resumed by a reload that never lands has no way to.
|
||||
@ObservationIgnored
|
||||
public var coveringSnapshotPollInterval: Duration = .milliseconds(10)
|
||||
|
||||
/// **A genuine commit failure** — disk full, repo corruption (06: "files stay safe on disk but
|
||||
/// history stops advancing; surfaced per 02-architecture.md ▸ Write-failure surfacing, retried
|
||||
/// on the next debounce"). Wired to `BannerCenter.suspendHistory(reason:)`.
|
||||
@@ -208,6 +247,23 @@ public final class GitAutoCommitter {
|
||||
@ObservationIgnored
|
||||
private var holdsForeignChanges = false
|
||||
|
||||
/// **Whether an app write has closed with no reload landed since** — the covering await's entry
|
||||
/// gate (`awaitCoveringSnapshot()`).
|
||||
///
|
||||
/// Set at every write-bracket close and cleared by every landing, so it answers exactly "is
|
||||
/// `currentSnapshot` known to be behind the tree". Without it an explicit flush on a quiet board
|
||||
/// would wait out the whole deadline for a reload nothing has any reason to schedule.
|
||||
///
|
||||
/// **The one corner it does not cover, recorded rather than discovered**: a reload that was
|
||||
/// already *in flight* when the write bracket closed walked the pre-write tree, and its landing
|
||||
/// clears this flag all the same — the store's landing signal carries no such distinction
|
||||
/// (`HistoryCommitSeam.reloadDidLand`). A flush inside that gap composes from a snapshot one walk
|
||||
/// behind, which is the pre-ruling behaviour for a window narrower than it used to be: the write
|
||||
/// bracket's own mandatory post-bracket reload is already scheduled and lands ~200 ms later, and
|
||||
/// closing the gap properly needs a fact only `BoardStore` has (whether a walk was running).
|
||||
@ObservationIgnored
|
||||
private var holdsUncoveredWrites = false
|
||||
|
||||
/// Open **card-window sessions**, each answering with the folder to stage around *right now*.
|
||||
///
|
||||
/// A closure per session rather than a stored URL, because a card can move lane, or into the
|
||||
@@ -222,6 +278,10 @@ public final class GitAutoCommitter {
|
||||
@ObservationIgnored
|
||||
private var isFlushing = false
|
||||
|
||||
/// Explicit flushes suspended behind the one in flight, resumed together by `endFlushing()`.
|
||||
@ObservationIgnored
|
||||
private var flushWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
|
||||
|
||||
init(boardRoot: URL, ledger: EchoLedger) {
|
||||
@@ -259,6 +319,9 @@ public final class GitAutoCommitter {
|
||||
/// this is the only moment at which the committer can still see them (`HarvestedReceipt`).
|
||||
public func noteWriteBracketClosed() {
|
||||
harvest()
|
||||
// The snapshot the composer diffs is now known to be behind the tree until a reload lands —
|
||||
// see `holdsUncoveredWrites` and `awaitCoveringSnapshot()`.
|
||||
holdsUncoveredWrites = true
|
||||
arm()
|
||||
}
|
||||
|
||||
@@ -269,6 +332,7 @@ public final class GitAutoCommitter {
|
||||
/// *file* at flush time, because this is one bit about a whole reload.
|
||||
public func noteReloadLanded(sawForeignChange: Bool) {
|
||||
if sawForeignChange { holdsForeignChanges = true }
|
||||
holdsUncoveredWrites = false
|
||||
arm()
|
||||
}
|
||||
|
||||
@@ -307,7 +371,7 @@ public final class GitAutoCommitter {
|
||||
public func noteWillWrite() {
|
||||
guard holdsForeignChanges, !isFlushing, let input = makeInput() else { return }
|
||||
isFlushing = true
|
||||
defer { isFlushing = false }
|
||||
defer { endFlushing() }
|
||||
pending?.cancel()
|
||||
pending = nil
|
||||
// One attempt, no lock backoff: this path cannot suspend, and a held lock here simply means
|
||||
@@ -398,9 +462,110 @@ public final class GitAutoCommitter {
|
||||
/// store tears down". `CloseFlushCoordinator.committerFlush` is this, and by the time it runs the
|
||||
/// sessions have ended, so nothing is staged around any more.
|
||||
public func flushNow() async {
|
||||
await awaitCoveringSnapshot()
|
||||
// **Queued behind an in-flight flush, never skipped** — see `awaitFlushInFlight()`.
|
||||
await awaitFlushInFlight()
|
||||
await flush()
|
||||
}
|
||||
|
||||
/// **Suspends until no flush is running** — what makes `flushNow()` a promise rather than an
|
||||
/// attempt (06-history-undo.md ▸ Rules ▸ Auto-commit: "nothing settled is ever left unsaved or
|
||||
/// uncommitted by closing").
|
||||
///
|
||||
/// ### The bug this exists for
|
||||
///
|
||||
/// `flush()` skips when one is already running, which is exactly right for the **debounce** — a
|
||||
/// timer firing into a commit already in progress has nothing to add, and coalescing is the
|
||||
/// cadence rule. It was catastrophically wrong for the **explicit** flush, which is the close
|
||||
/// flush, the quit flush, the branch switch's pre-checkout flush and File ▸ Duplicate's: those
|
||||
/// callers are not asking for a commit *soon*, they are asking to be told when the pipeline is
|
||||
/// empty, and a `return` gave them that answer while it was still full.
|
||||
///
|
||||
/// It was reachable, and by a *narrow* margin in one direction and a wide one in the other. The
|
||||
/// close sequence releases each session's stage-around and then nudges the committer
|
||||
/// (`endCardSession`), which arms a fresh debounce; `CloseFlushCoordinator` then spends up to its
|
||||
/// card-drain deadline before reaching `committerFlush`. With the two intervals both at two
|
||||
/// seconds the debounce fired *into* the drain's last moments about half the time — and the flush
|
||||
/// it started had, in the worst case, planned its commit while the session's folder was still
|
||||
/// staged around. So the in-flight flush committed nothing of the session, the close flush skipped
|
||||
/// behind it, and teardown stopped the committer: the window's whole session was left uncommitted,
|
||||
/// permanently, with no later flush anywhere that could have picked it up. Even in the benign
|
||||
/// interleaving `closeBoard` returned — and at quit, `applicationShouldTerminate` replied — while
|
||||
/// the commit was still detached work in flight.
|
||||
///
|
||||
/// ### The shape
|
||||
///
|
||||
/// A queue of waiters rather than a lock, `BoardStore.awaitQuiescence()`'s own shape and for its
|
||||
/// reason: this type is `@MainActor`, so there is no data race to exclude — only a *suspension* to
|
||||
/// wait out — and the thing a caller wants is "tell me when it is over", which is what a resumed
|
||||
/// continuation is. The loop re-checks rather than trusting one resumption, so a flush that armed
|
||||
/// another on its way out cannot slip between the resume and the caller's own attempt.
|
||||
private func awaitFlushInFlight() async {
|
||||
while isFlushing {
|
||||
await withCheckedContinuation { flushWaiters.append($0) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends one flush and releases whoever was queued behind it. The single exit for both flushing
|
||||
/// paths — the debounced one and the synchronous flush-before-overwrite — so a waiter can never be
|
||||
/// left suspended by a path that forgot it.
|
||||
private func endFlushing() {
|
||||
isFlushing = false
|
||||
let waiters = flushWaiters
|
||||
flushWaiters.removeAll()
|
||||
for waiter in waiters { waiter.resume() }
|
||||
}
|
||||
|
||||
/// **The flush awaits the snapshot that covers it** (06-history-undo.md ▸ Rules ▸ Auto-commit,
|
||||
/// ruled 2026-07-31).
|
||||
///
|
||||
/// > "The composer diffs `store.snapshot` against HEAD, so the close flush awaits a snapshot
|
||||
/// > generation covering its changed paths before the committer runs — the commit's subject can
|
||||
/// > never be outrun by its own reload; the cadence margin (2 s debounce vs 200 ms watcher) is the
|
||||
/// > practical cushion, never the guarantee."
|
||||
///
|
||||
/// ### What "covering its changed paths" means to this store
|
||||
///
|
||||
/// A reload is a **whole tree walk** — the store has no changed-path channel at all
|
||||
/// (02-architecture.md; `BoardStore.refreshCommentIndex`'s own note) — so a walk that *started*
|
||||
/// after this flush's writes were on disk covers every path they touched, by construction. There
|
||||
/// is nothing narrower to ask for and nothing narrower to wait on, and that is what makes the
|
||||
/// generation counter a sufficient answer rather than an approximation of one.
|
||||
///
|
||||
/// Two steps, in this order, are what turn it into a guarantee:
|
||||
///
|
||||
/// 1. **Quiesce.** A walk already in flight may have started *before* the writes, so its landing
|
||||
/// proves nothing. `BoardStore.awaitQuiescence()` returns when none is running and none is
|
||||
/// owed, which is the moment after which every walk is a walk that started later.
|
||||
/// 2. **Wait for one generation.** The write bracket that produced these changes already
|
||||
/// scheduled the reload that will supply it — unconditionally, whether or not FSEvents said
|
||||
/// anything (`FolderWatcher.endBracket`) — so this is a bounded wait on work already in the
|
||||
/// pipeline, not a hope.
|
||||
///
|
||||
/// ### Why only the explicit flush
|
||||
///
|
||||
/// This is `flushNow()`'s alone: the close and quit paths, the branch switch's pre-checkout flush,
|
||||
/// File ▸ Duplicate's pending-work step, and the undo restore's. Those are the flushes that run
|
||||
/// *because* something just finished, which is exactly when the snapshot can still be one walk
|
||||
/// behind. The debounced flush is re-armed by both the write and the reload and fires two seconds
|
||||
/// after the later of them — 06's own "practical cushion", doing the job it is enough for — and
|
||||
/// `noteWillWrite()` cannot await at all, being the synchronous flush-before-overwrite.
|
||||
private func awaitCoveringSnapshot() async {
|
||||
guard holdsUncoveredWrites, let read = snapshotGeneration else { return }
|
||||
await awaitReloadQuiescence?()
|
||||
// Re-read the gate: the quiescence may itself have been the covering landing.
|
||||
guard holdsUncoveredWrites, let base = read() else { return }
|
||||
|
||||
let started = ContinuousClock.now
|
||||
while let current = read(), current == base {
|
||||
guard ContinuousClock.now - started < coveringSnapshotDeadline else {
|
||||
Self.logger.notice("the covering reload did not land in time; composing from the snapshot in hand")
|
||||
return
|
||||
}
|
||||
try? await Task.sleep(for: coveringSnapshotPollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
/// Arms (or re-arms) the debounce. Every signal funnels through here, so "debounced past drag and
|
||||
/// typing churn" is one timer rather than a rule each call site remembers.
|
||||
private func arm(after interval: Duration? = nil) {
|
||||
@@ -414,10 +579,13 @@ public final class GitAutoCommitter {
|
||||
}
|
||||
}
|
||||
|
||||
/// One flush. **Skipping when one is already running is the debounce's rule and only the
|
||||
/// debounce's** — an explicit `flushNow()` has already waited its turn (`awaitFlushInFlight()`)
|
||||
/// before it gets here, so this guard can only ever coalesce a timer.
|
||||
private func flush() async {
|
||||
guard !isFlushing else { return }
|
||||
isFlushing = true
|
||||
defer { isFlushing = false }
|
||||
defer { endFlushing() }
|
||||
pending?.cancel()
|
||||
pending = nil
|
||||
|
||||
@@ -512,6 +680,7 @@ public final class GitAutoCommitter {
|
||||
var previous: BoardModel?
|
||||
var current: BoardModel?
|
||||
var agentGuideText: String?
|
||||
var commentTimestamps: [String: Date] = [:]
|
||||
}
|
||||
|
||||
/// Reads the two snapshots and the guide's bytes — the only impure step in the message path, kept
|
||||
@@ -537,6 +706,12 @@ public final class GitAutoCommitter {
|
||||
// card's title. So a comment-only window loads the current board and skips the materialization.
|
||||
let touchesModel = changed.contains { CommitMessageEngine.Paths.mightAffectSnapshot($0.path) }
|
||||
let namesACard = changed.contains { CommentPath.classify($0.path) != nil }
|
||||
// **The chronology the bullets sort by** (06 ▸ Rules ▸ Auto-commit, blessed 2026-07-31) — the
|
||||
// one field of a comment the composer needs and the board snapshot cannot carry. Read beside
|
||||
// the guide's bytes, for the guide's reason, and only for a window that names a comment at all.
|
||||
if namesACard {
|
||||
composition.commentTimestamps = commentTimestamps(for: changed, boardRoot: input.boardRoot)
|
||||
}
|
||||
guard touchesModel || namesACard else { return composition }
|
||||
|
||||
// **The store's snapshot when there is one, disk when there is not.** A storeless committer is
|
||||
@@ -550,6 +725,42 @@ public final class GitAutoCommitter {
|
||||
return composition
|
||||
}
|
||||
|
||||
/// **When each comment this window touched was created**, keyed by its folder — the chronology
|
||||
/// `CommitMessageEngine` sorts a commit's comment bullets by (06 ▸ Rules ▸ Auto-commit, blessed
|
||||
/// 2026-07-31: "by the comments' own `created`, folder name on ties").
|
||||
///
|
||||
/// One `index.md` per touched comment folder, read off the **working tree** — which is the state
|
||||
/// this commit is about to stage, and the only place a comment's own fields exist at all. A folder
|
||||
/// this window *removed* (the close purge) has nothing left to read, and a comment whose
|
||||
/// frontmatter does not parse or carries no `created` answers nothing either: all three are
|
||||
/// absent from the map and sort after their dated siblings, which is `CommentThread.sorted`'s own
|
||||
/// fallback for the same field. Nothing here is a defect and nothing is reported — a commit
|
||||
/// message is the wrong place to discover one (`CommentThread.searchableBodies`' rule, kept).
|
||||
///
|
||||
/// Internal rather than private so the composer's own suite can resolve the chronology exactly the
|
||||
/// way a flush does, instead of hand-assembling a map the flush could never produce
|
||||
/// (`WriterFixture.snapshot()`'s reason, restated one field down).
|
||||
nonisolated static func commentTimestamps(
|
||||
for changed: [GitChangedPath],
|
||||
boardRoot: URL
|
||||
) -> [String: Date] {
|
||||
var timestamps: [String: Date] = [:]
|
||||
var seen: Set<String> = []
|
||||
for path in changed {
|
||||
guard let folder = CommitMessageEngine.commentFolder(of: path.path), seen.insert(folder).inserted
|
||||
else { continue }
|
||||
let index = boardRoot
|
||||
.appendingPathComponent(folder)
|
||||
.appendingPathComponent(IntegrityRules.indexFileName)
|
||||
guard let data = try? Data(contentsOf: index),
|
||||
let document = try? BoardLoader.parseDocument(data, path: folder),
|
||||
let created = document.created.value
|
||||
else { continue }
|
||||
timestamps[folder] = created
|
||||
}
|
||||
return timestamps
|
||||
}
|
||||
|
||||
/// The three-way split turned into commits — or, on an unborn HEAD, the one commit 06 fixes.
|
||||
private nonisolated static func plan(
|
||||
_ changed: [GitChangedPath],
|
||||
@@ -571,7 +782,8 @@ public final class GitAutoCommitter {
|
||||
isRootCommit: isRootCommit,
|
||||
snapshot: composition.current,
|
||||
previousSnapshot: composition.previous,
|
||||
agentGuideText: composition.agentGuideText
|
||||
agentGuideText: composition.agentGuideText,
|
||||
commentTimestamps: composition.commentTimestamps
|
||||
)
|
||||
}
|
||||
|
||||
@@ -600,16 +812,23 @@ public final class GitAutoCommitter {
|
||||
authorship = .foreign(
|
||||
CommitAttribution.foreignIdentity(for: group.paths, under: input.boardRoot)
|
||||
)
|
||||
// **A heal is authored by the user**, recorded as a judgment call: DESIGN fixes that a
|
||||
// heal's paths commit *separately* and says nothing about who they are by. The healer is
|
||||
// the app acting on the user's behalf — its writes are app-mediated, receipt and all — so
|
||||
// authoring them as the user is the honest reading, and authoring them as `Lanework
|
||||
// External` would blame the outside world for the app's own repair.
|
||||
// **A heal is authored `Lanework Integrity <integrity@lanework.invalid>`** (06 ▸ Commit
|
||||
// messages ▸ Healing mutations commit separately, ruled 2026-07-31): "a heal is a third
|
||||
// origin — not the user's gesture, not a foreign writer — and the separation exists for
|
||||
// audit, so the trail filters by author like every origin". This authored heals as the
|
||||
// *user* until that ruling, which left the separate commit filterable only by message
|
||||
// shape — and the shape vocabulary deliberately never says "healed".
|
||||
case .heal: authorship = .heal
|
||||
case .user: authorship = .user
|
||||
}
|
||||
// The committer stays the user throughout — 06's recorded-by convention, which is why
|
||||
// only the author varies here.
|
||||
let author: GitIdentity
|
||||
if case let .foreign(identity) = authorship { author = identity } else { author = user }
|
||||
switch authorship {
|
||||
case let .foreign(identity): author = identity
|
||||
case .heal: author = CommitAttribution.integrityIdentity
|
||||
case .user: author = user
|
||||
}
|
||||
let kind: PlannedCommitKind
|
||||
switch group.kind {
|
||||
case .foreign: kind = .foreign
|
||||
|
||||
@@ -90,15 +90,25 @@ public final class GitBranchSwitcher {
|
||||
/// A clean failure — "surfaces as a one-shot banner failure naming the operation and the error,
|
||||
/// the tree left as it was" (06 ▸ Interaction with external writers).
|
||||
///
|
||||
/// The banner rather than the popover's inline caption, deliberately, and 06 draws the line: the
|
||||
/// popover-anchored answer is for operations that answer *at the form* (add-git, verify-remote),
|
||||
/// The banner rather than an inline caption, deliberately, and 06 draws the line: the
|
||||
/// form-anchored answer is for operations that answer *at the form* (add-git, verify-remote —
|
||||
/// forms that live in the board settings sheet since the 2026-07-31 popover/sheet split),
|
||||
/// while "the banner enumeration stays the posture for board-wholesale brackets that outlive any
|
||||
/// one surface" — which a branch switch is by construction, since its bracket locks the board and
|
||||
/// its completion is announced.
|
||||
///
|
||||
/// **Which row that is, settled 2026-07-31** (02-architecture.md ▸ The banner surface): the
|
||||
/// one-shot failure class's message-carrying git shape — error tone, failure rank, dismissable
|
||||
/// and untimed. What travels is the operation and the underlying message; the sentence
|
||||
/// ("Couldn't switch branches — …") is `BannerCenter`'s, which is why nothing here composes one.
|
||||
@ObservationIgnored
|
||||
public var reportFailure: (@MainActor (GitOperationFailure) -> Void)?
|
||||
|
||||
/// The own-leftovers recovery's banner (`GitOperationStamp.interruptionMessage`).
|
||||
///
|
||||
/// **A warning-tone loss row, not a failure** (02 ▸ The banner surface, settled 2026-07-31):
|
||||
/// "recovery notices report a success, not a failure, and stay warning-tone" — the abort put the
|
||||
/// previous state back, and the row exists so the user learns that it happened.
|
||||
@ObservationIgnored
|
||||
public var reportRecovery: (@MainActor (String) -> Void)?
|
||||
|
||||
|
||||
@@ -93,8 +93,14 @@ public final class GitHistoryProvider: HistoryProviding {
|
||||
public var runBracketed: (@MainActor (_ announcing: String, _ work: @escaping () async -> Void) async -> Void)?
|
||||
|
||||
/// A genuine restore failure — surfaced as 02's one-shot banner by whoever wires it.
|
||||
///
|
||||
/// **The direction travels with the failure** (02-architecture.md ▸ The banner surface, settled
|
||||
/// 2026-07-31): the one-shot failure class's second shape names the operation in the user's
|
||||
/// words — "Undo failed", "Redo failed" — and this object is the only one that knows which key
|
||||
/// was pressed. Everything past that boundary is the banner's: the closure receives the
|
||||
/// direction and libgit2's own message, never a sentence composed here.
|
||||
@ObservationIgnored
|
||||
public var reportFailure: (@MainActor (GitOperationFailure) -> Void)?
|
||||
public var reportFailure: (@MainActor (HistoryDirection, GitOperationFailure) -> Void)?
|
||||
|
||||
// MARK: - The cached stack
|
||||
|
||||
@@ -326,7 +332,11 @@ public final class GitHistoryProvider: HistoryProviding {
|
||||
guard let index = crossableIndex() else { return }
|
||||
let crossed = ancestry[index]
|
||||
guard let target = crossed.parentOID else { return }
|
||||
let landed = await restore(to: target, message: "Undo: \(crossed.subject)")
|
||||
let landed = await restore(
|
||||
.undo,
|
||||
to: target,
|
||||
message: Self.restoreSubject(.undo, crossing: crossed.subject)
|
||||
)
|
||||
guard landed else { return }
|
||||
redoCommits.append(crossed)
|
||||
pointerOID = target
|
||||
@@ -334,7 +344,11 @@ public final class GitHistoryProvider: HistoryProviding {
|
||||
|
||||
case .redo:
|
||||
guard let target = redoCommits.last else { return }
|
||||
let landed = await restore(to: target.oid, message: "Redo: \(target.subject)")
|
||||
let landed = await restore(
|
||||
.redo,
|
||||
to: target.oid,
|
||||
message: Self.restoreSubject(.redo, crossing: target.subject)
|
||||
)
|
||||
guard landed else { return }
|
||||
redoCommits.removeLast()
|
||||
// The commit just restored *to* is the one the next ⌘Z crosses again — the classic dance,
|
||||
@@ -349,7 +363,11 @@ public final class GitHistoryProvider: HistoryProviding {
|
||||
/// `message` is both the commit's subject and the bracket's completion announcement
|
||||
/// (10-accessibility.md ▸ Live board announcements: "bracketed operations announce once, at
|
||||
/// completion") — one sentence, so the trail and the speech cannot disagree about what happened.
|
||||
private func restore(to target: String, message: String) async -> Bool {
|
||||
///
|
||||
/// `direction` is carried for one reason: a failure here is the banner's git-operation shape,
|
||||
/// and it is named by the key the user pressed rather than by the subject the restore would have
|
||||
/// carried (`reportFailure`).
|
||||
private func restore(_ direction: HistoryDirection, to target: String, message: String) async -> Bool {
|
||||
let root = boardRoot
|
||||
let excluded = healPaths
|
||||
|
||||
@@ -358,7 +376,7 @@ public final class GitHistoryProvider: HistoryProviding {
|
||||
guard let preliminary = await Task.detached(priority: .userInitiated, operation: {
|
||||
GitRestoreOperation.plan(at: root, target: target, excluding: excluded)
|
||||
}).value else {
|
||||
report("this board's repository could not be read")
|
||||
report(direction, "this board's repository could not be read")
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -429,7 +447,7 @@ public final class GitHistoryProvider: HistoryProviding {
|
||||
case let .held(pause):
|
||||
Self.logger.notice("restore held: \(pause.rawValue, privacy: .public)")
|
||||
case let .failed(failure):
|
||||
self.reportFailure?(failure)
|
||||
self.reportFailure?(direction, failure)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,10 +496,111 @@ public final class GitHistoryProvider: HistoryProviding {
|
||||
return nil
|
||||
}
|
||||
|
||||
private func report(_ message: String) {
|
||||
reportFailure?(GitOperationFailure(
|
||||
private func report(_ direction: HistoryDirection, _ message: String) {
|
||||
reportFailure?(direction, GitOperationFailure(
|
||||
operation: GitRestoreOperation.operationName,
|
||||
message: message
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: - The restore subject
|
||||
|
||||
/// **What a restore commit is called** — a pure function of the crossed subject and the
|
||||
/// direction, so the rule can be read (and pinned) without a repository.
|
||||
///
|
||||
/// The base rule is 06's oldest: a crossing commits the state it restored as "Undo: ⟨subject⟩"
|
||||
/// or "Redo: ⟨subject⟩". **Subjects don't nest** (06 ▸ Commit messages, settled 2026-07-31):
|
||||
/// when the crossed subject already carries a restore prefix — the post-relaunch case, where the
|
||||
/// reseed has made old restore commits ordinary steps — the composer "emits the *inverse* label
|
||||
/// instead of stacking: crossing 'Undo: S' yields 'Redo: S', crossing 'Redo: S' yields
|
||||
/// 'Undo: S'", which "caps prefixes at one across any number of relaunches".
|
||||
///
|
||||
/// ### Why the two directions read the crossed subject differently
|
||||
///
|
||||
/// The label states what the new commit's tree *does* to the base subject S: "Undo: S" is the
|
||||
/// state where S is out, "Redo: S" the state where S is in. An undo restores the crossed
|
||||
/// commit's **parent** — the state before it — so it emits that commit's inverse; a redo
|
||||
/// restores the target commit **itself**, so it emits that commit's own reading. That is what
|
||||
/// makes 06's sentence true ("undoing the restore that undid a move *re-applies* the move") and
|
||||
/// its mirror true with it: ⇧⌘Z back across an "Undo: S" step lands on the tree where S is out,
|
||||
/// and says "Undo: S" — the truer label, rather than the "Redo: S" the ⌘Z that crossed it
|
||||
/// already used for the opposite tree.
|
||||
///
|
||||
/// ### The legacy double prefix
|
||||
///
|
||||
/// "Undo: Undo: S" exists in the wild — the shipped nesting build made them — and the honest
|
||||
/// reading is this same one applied twice: the inner "Undo:" took S out, the outer one took
|
||||
/// *that* back, so the commit's tree is the one where S is in. Undoing across it therefore emits
|
||||
/// **"Undo: S"** — the tree it restores is the one without S, and saying "Redo: S" there would be
|
||||
/// exactly the euphemism 06 rules out ("This is the truer label, not a euphemism"), while
|
||||
/// "Redo: Undo: S" would keep the nesting the ruling caps at one. So each "Undo: " prefix flips
|
||||
/// the reading, each "Redo: " prefix leaves it, and what comes out carries exactly one.
|
||||
///
|
||||
/// The sniff is on the subject string, deliberately (06), so "a foreign commit that happens to
|
||||
/// open with a prefix gets the inverse label too; that's cosmetic — the restore itself is
|
||||
/// unaffected".
|
||||
public nonisolated static func restoreSubject(
|
||||
_ direction: HistoryDirection,
|
||||
crossing subject: String
|
||||
) -> String {
|
||||
let reading = RestoreSubjectReading(of: subject)
|
||||
let emitted = switch direction {
|
||||
case .undo: reading.polarity.inverse
|
||||
case .redo: reading.polarity
|
||||
}
|
||||
return "\(emitted.label): \(reading.base)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// What a subject says about its own base subject: is that change *in* the tree the subject
|
||||
/// describes, or has it been taken back out? Every restore label is one of these two readings, which
|
||||
/// is why the composer can invert rather than stack (`GitHistoryProvider.restoreSubject(_:crossing:)`).
|
||||
private enum RestorePolarity {
|
||||
/// The base subject's change is in the tree — every ordinary commit, and every "Redo: S".
|
||||
case applied
|
||||
/// The base subject's change has been taken back out — "Undo: S".
|
||||
case reverted
|
||||
|
||||
var inverse: RestorePolarity { self == .applied ? .reverted : .applied }
|
||||
|
||||
/// The word that states this reading in a subject.
|
||||
var label: String { self == .applied ? "Redo" : "Undo" }
|
||||
|
||||
/// The same word as a prefix — the only two this composer emits, and the only two it reads, so
|
||||
/// that reading and writing can never drift apart.
|
||||
var prefix: String { "\(label): " }
|
||||
}
|
||||
|
||||
/// One subject read as "a base subject, plus what its restore prefixes say about it".
|
||||
///
|
||||
/// Stripping is greedy because the legacy nesting build's subjects are (`restoreSubject`), and a
|
||||
/// prefix only counts while something is left for it to be *about*: a bare "Undo: " is somebody's
|
||||
/// subject, not a label with nothing after it.
|
||||
private struct RestoreSubjectReading {
|
||||
let base: String
|
||||
let polarity: RestorePolarity
|
||||
|
||||
init(of subject: String) {
|
||||
var base = subject
|
||||
var polarity = RestorePolarity.applied
|
||||
while true {
|
||||
let read: RestorePolarity
|
||||
if base.hasPrefix(RestorePolarity.reverted.prefix) {
|
||||
read = .reverted
|
||||
} else if base.hasPrefix(RestorePolarity.applied.prefix) {
|
||||
read = .applied
|
||||
} else {
|
||||
break
|
||||
}
|
||||
let rest = String(base.dropFirst(read.prefix.count))
|
||||
guard !rest.isEmpty else { break }
|
||||
base = rest
|
||||
// "Undo: " flips what the rest of the subject was saying; "Redo: " restates it.
|
||||
if read == .reverted { polarity = polarity.inverse }
|
||||
}
|
||||
self.base = base
|
||||
self.polarity = polarity
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@ import Foundation
|
||||
///
|
||||
/// 1. **Repo-local `.git/config` wins when present.** "Standard git semantics, readable in-sandbox
|
||||
/// because it lives under the board root, and the natural state of adopted/cloned boards." The
|
||||
/// popover's name/email fields (a later card) write exactly that file: "the setting *is* the
|
||||
/// file, portable to any git client, per-board by nature".
|
||||
/// identity fields write exactly that file: "the setting *is* the file, portable to any git
|
||||
/// client, per-board by nature". Their home is the **board settings sheet** since the 2026-07-31
|
||||
/// popover/sheet split (03-board-ui.md); they are hosted in the popover's git section until that
|
||||
/// sheet is built, which changes nothing about this file.
|
||||
/// 2. **Absent repo config, the derived default**: "the macOS account's full name plus
|
||||
/// `shortname@hostname` — git's own no-config fallback shape, zero ceremony."
|
||||
///
|
||||
@@ -142,8 +144,19 @@ enum GitConfigFile {
|
||||
}
|
||||
|
||||
/// The parse, over text — the pure half, and where the format's edges are decided.
|
||||
///
|
||||
/// **Reads take the last plain-section value** (06-history-undo.md ▸ Interaction with external
|
||||
/// writers, blessed 2026-07-31): "the reader — like git itself — takes the last plain-section
|
||||
/// value, which is exactly what an append produces."
|
||||
///
|
||||
/// *Plain* is load-bearing and is the whole of the subsection rule. `[user "work"]` is a different
|
||||
/// key in git's own model — `user.work.name`, not `user.name` — so its values are not answers to
|
||||
/// this question at all, and reading one would sign the user's commits with an identity they
|
||||
/// filed under a name this app never asked about. Last-wins still holds inside the plain
|
||||
/// sections: a later `[user]` overrides an earlier one, which is how an appended section wins
|
||||
/// without the writer ever touching what came before it.
|
||||
static func identity(inConfigText text: String) -> (name: String?, email: String?) {
|
||||
var section: String?
|
||||
var isPlainUserSection = false
|
||||
var name: String?
|
||||
var email: String?
|
||||
|
||||
@@ -152,17 +165,16 @@ enum GitConfigFile {
|
||||
if line.isEmpty || line.hasPrefix("#") || line.hasPrefix(";") { continue }
|
||||
|
||||
if line.hasPrefix("[") {
|
||||
// `[user]`, and `[user "work"]` — a subsection is somebody else's scope, so the
|
||||
// header's first token is what names the section.
|
||||
let header = line.drop(while: { $0 == "[" }).prefix(while: { $0 != "]" })
|
||||
section = header
|
||||
let section = header
|
||||
.split(separator: " ", maxSplits: 1)
|
||||
.first
|
||||
.map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
|
||||
isPlainUserSection = section == "user" && !header.contains("\"")
|
||||
continue
|
||||
}
|
||||
|
||||
guard section == "user", let separator = line.firstIndex(of: "=") else { continue }
|
||||
guard isPlainUserSection, let separator = line.firstIndex(of: "=") else { continue }
|
||||
let key = line[line.startIndex..<separator].trimmingCharacters(in: .whitespaces).lowercased()
|
||||
let value = unquoted(line[line.index(after: separator)...].trimmingCharacters(in: .whitespaces))
|
||||
switch key {
|
||||
@@ -177,10 +189,10 @@ enum GitConfigFile {
|
||||
|
||||
// MARK: Writing
|
||||
|
||||
/// **The popover's identity fields, landing in the file** (06-history-undo.md ▸ Interaction with
|
||||
/// external writers: "The board popover's git section exposes name/email fields that **write that
|
||||
/// repo-local config** — the setting *is* the file, portable to any git client, per-board by
|
||||
/// nature").
|
||||
/// **The identity fields, landing in the file** (06-history-undo.md ▸ Interaction with external
|
||||
/// writers: "The board settings sheet's identity section … exposes name/email fields that **write
|
||||
/// that repo-local config** — the setting *is* the file, portable to any git client, per-board by
|
||||
/// nature"; the fields are popover-hosted until that sheet is built).
|
||||
///
|
||||
/// This is the **only** thing in the app that writes `user.name`/`user.email` anywhere, and that
|
||||
/// is the design's own line: the derived default "is passed as an explicit per-commit signature,
|
||||
@@ -225,10 +237,9 @@ enum GitConfigFile {
|
||||
/// `user.name`), and editing keys inside one would be this app rewriting a setting the user
|
||||
/// aimed somewhere else — much the worse error, whatever the read side does with it.
|
||||
///
|
||||
/// (The read side, `identity(inConfigText:)`, deliberately takes the last matching value it
|
||||
/// meets whichever section it is in — its own recorded call. The two agree in practice for
|
||||
/// every file this writer has touched, because a plain section it *adds* goes at the end, so
|
||||
/// its keys are the last ones the reader meets.)
|
||||
/// (The read side, `identity(inConfigText:)`, scopes itself to plain sections for the same
|
||||
/// reason and takes the last one's value, so the two halves agree by construction rather than
|
||||
/// by coincidence.)
|
||||
var isPlainUserSection = false
|
||||
/// Where a key the file does not yet have would be inserted: just after the last line of the
|
||||
/// plain `[user]` section, or `nil` while there is no such section.
|
||||
|
||||
@@ -50,9 +50,10 @@ public struct GitOperationFailure: Error, Sendable, Equatable, CustomStringConve
|
||||
///
|
||||
/// This is the pathfinder's `GitSource` shape, kept because it was right, with the pathfinder's
|
||||
/// *policy* deliberately left behind: nothing here auto-initializes anything and nothing commits on
|
||||
/// its own schedule. The one seed it does write — a `.gitignore`, at init and never again
|
||||
/// (06 ▸ Repository hygiene) — is the app's last word on that file rather than the start of a
|
||||
/// relationship with it.
|
||||
/// its own schedule. It writes no seed of its own any more: the `.gitignore` outgrew git on
|
||||
/// 2026-07-31 and belongs to the board now (`BoardWriter.gitignoreSeed`, seeded at creation and
|
||||
/// healed in at open), so all that survives here is a last-chance check that the file exists before
|
||||
/// the initial commit freezes the tree — see `seedGitignoreIfAbsent(at:)`.
|
||||
enum GitRepository {
|
||||
|
||||
/// **The root commit's own subject** (06-history-undo.md ▸ Rules ▸ Abnormal repo states,
|
||||
@@ -72,20 +73,11 @@ enum GitRepository {
|
||||
/// writing it is exactly `git symbolic-ref HEAD refs/heads/main` before anything else touches
|
||||
/// the repo.
|
||||
///
|
||||
/// DESIGN is silent on the name; `main` is git's own modern default and the pathfinder's choice.
|
||||
/// **The initial branch is `main`** (06 ▸ Rules ▸ Opt-in init, blessed 2026-07-31): "the host's
|
||||
/// `init.defaultBranch` lives in config layers the sandbox can't read, so add-git sets it
|
||||
/// deterministically — git's modern default, the pathfinder's choice."
|
||||
static let initialBranchName = "main"
|
||||
|
||||
/// **The whole of the seeded `.gitignore`** (06-history-undo.md ▸ Repository hygiene: "Adding git
|
||||
/// to a board writes a minimal `.gitignore` (`.DS_Store`) if none exists").
|
||||
///
|
||||
/// One line, because one line is what the rule says and because every additional entry would be
|
||||
/// the app deciding something about a file it is about to stop having opinions on. `.DS_Store` is
|
||||
/// the entry that earns its place: the Finder writes one into every folder a user looks at, and
|
||||
/// on a board that means one per lane and one per card, each churning as icons and window
|
||||
/// positions move — noise that would otherwise be committed by the whole-tree stage, forever,
|
||||
/// under the user's own name.
|
||||
static let seededGitignore = ".DS_Store\n"
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
|
||||
|
||||
// MARK: Opt-in init
|
||||
@@ -97,24 +89,39 @@ enum GitRepository {
|
||||
/// is protected from the moment git exists" — so the two halves are one operation and a failure
|
||||
/// in either is one failure.
|
||||
///
|
||||
/// Between them sits the one seed the app ever writes: a minimal `.gitignore`, if the board has
|
||||
/// none, in the initial commit rather than after it (`seedGitignoreIfAbsent`).
|
||||
/// Between them sits a last-chance `.gitignore` check — the file is the board's rather than
|
||||
/// git's since 2026-07-31, so it is almost always already there; when it is not, seeding it here
|
||||
/// puts it *in* the initial commit rather than after it (`seedGitignoreIfAbsent`).
|
||||
///
|
||||
/// **It refuses a board that already has a `.git`.** The app "never mutates repo state it didn't
|
||||
/// create" (06), and `git_repository_init` over an existing repository is a re-initialization —
|
||||
/// harmless in the common case and precisely the kind of thing that rule exists to forbid. The
|
||||
/// caller (`HistoryStore.addGit`) has already established mode `none`; this is the check that
|
||||
/// makes it impossible rather than merely unlikely.
|
||||
/// **Create re-runs full detection and refuses anything but mode none** (06 ▸ Rules ▸ Detection,
|
||||
/// ruled 2026-07-31): "as hardening, add-git's create re-runs full detection and refuses unless it
|
||||
/// reads clean none, so the forbidden nested init is impossible even on a raced or stale read."
|
||||
///
|
||||
/// The caller (`HistoryStore.addGit`) has already established mode `none` from the mode it
|
||||
/// detected at board open, which can be minutes old — a `git init` in a terminal at the board root
|
||||
/// *or anywhere above it* between the two would otherwise slip past a root-only check and
|
||||
/// initialize a repository inside the user's, which is the one init 06 forbids outright. The whole
|
||||
/// walk runs again here, at the moment of the write, so the refusal is structural rather than
|
||||
/// probable. (Detection has no *unverifiable* answer yet — 06's denial-is-not-absence distinction
|
||||
/// is not built — so "clean none" is spelled `.none` for now.)
|
||||
///
|
||||
/// Returns the branch the root commit landed on, which is the popover's display line.
|
||||
nonisolated static func create(at boardRoot: URL) -> Result<String, GitOperationFailure> {
|
||||
let operation = "Adding git to this board"
|
||||
|
||||
guard !BoardGitMode.hasGitEntry(at: boardRoot) else {
|
||||
switch BoardGitMode.detect(boardRoot: boardRoot) {
|
||||
case .none:
|
||||
break
|
||||
case .git:
|
||||
return .failure(GitOperationFailure(
|
||||
operation: operation,
|
||||
message: "this board already has a git repository"
|
||||
))
|
||||
case .repoNested:
|
||||
return .failure(GitOperationFailure(
|
||||
operation: operation,
|
||||
message: "this board lives inside a repository; Lanework leaves it to that repository"
|
||||
))
|
||||
}
|
||||
|
||||
let gitDirectory: URL
|
||||
@@ -189,33 +196,32 @@ enum GitRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// **The `.gitignore` seed, written at init and never again** (06-history-undo.md ▸ Repository
|
||||
/// hygiene: "the app never edits an existing one and never manages the file afterward — it's the
|
||||
/// user's from then on").
|
||||
/// **The last-chance `.gitignore` seed, immediately before the initial commit.**
|
||||
///
|
||||
/// Three properties, and they are the feature:
|
||||
/// The seed itself stopped being git's on 2026-07-31 (06-history-undo.md ▸ Repository hygiene,
|
||||
/// re-ruled: "`.gitignore` seeded on every board, never touched after … git or not"). Every board
|
||||
/// the app creates is born with one, and every board it opens is healed into having one
|
||||
/// (`BoardStore.seedGitignore`) — and add-git can only run on a board that is *open* and writable,
|
||||
/// so by the time this line is reached the file is essentially always already there and this call
|
||||
/// writes nothing.
|
||||
///
|
||||
/// - **Only when absent.** A board that already carries a `.gitignore` — from a template, from a
|
||||
/// clone, from the user — is left byte for byte alone. `fileExists` rather than a read, so a
|
||||
/// *directory* wearing the name is left alone too (`IntegrityRules.claimedRootNames` marks
|
||||
/// `.gitignore` as one of the two claimed names whose squatters are never displaced, precisely
|
||||
/// because nothing in the app reads this file).
|
||||
/// - **Only here.** This is the one call site, on the one path that creates a repository. Nothing
|
||||
/// re-checks it, no heal restores it, no later version of the app appends to it: a user who
|
||||
/// deletes the seeded line has deleted it.
|
||||
/// - **Only on the app's own init.** Adoption seeds nothing — an adopted repository is somebody
|
||||
/// else's init, and 06's rule is about what the app writes when *it* creates one. A repo-nested
|
||||
/// board seeds nothing either, and structurally cannot: `HistoryStore.addGit` refuses any mode
|
||||
/// but `none`, so this function is unreachable from there.
|
||||
/// **It stays anyway, and stays here — before the stage below.** The one case it still answers is
|
||||
/// the one that cannot be fixed afterwards: if the board's seed heal has not landed (a transient
|
||||
/// failure that armed its memo, a picture that has not changed since), the initial commit would
|
||||
/// otherwise capture every `.DS_Store` the Finder has left under the board *into history*, where
|
||||
/// this app has no operation that could ever remove it (06 ▸ Deleting never forgets). One
|
||||
/// `lstat` on the one path that mints a repository is a cheap insurance policy against a
|
||||
/// permanent record.
|
||||
///
|
||||
/// Seeding is `BoardWriter.seedGitignoreIfAbsent`'s — one seed text, one write-only-when-free
|
||||
/// rule, `lstat` semantics — so this cannot drift from what board creation and the heal write.
|
||||
///
|
||||
/// A write that fails is not a failure of add-git. The repository exists, the commit that follows
|
||||
/// simply will not carry a `.gitignore`, and a board with none is an ordinary board — surfacing a
|
||||
/// banner about a courtesy file would be louder than the thing it reports.
|
||||
/// simply will not carry a `.gitignore`, and the board's own heal will try again at the next
|
||||
/// open — surfacing a banner about a courtesy file would be louder than the thing it reports.
|
||||
private static func seedGitignoreIfAbsent(at boardRoot: URL) {
|
||||
let url = boardRoot.appendingPathComponent(".gitignore")
|
||||
guard !FileManager.default.fileExists(atPath: url.path) else { return }
|
||||
do {
|
||||
try Data(seededGitignore.utf8).write(to: url, options: .atomic)
|
||||
try BoardWriter.seedGitignoreIfAbsent(atBoardRoot: boardRoot)
|
||||
} catch {
|
||||
logger.notice("could not seed .gitignore at \(boardRoot.path, privacy: .public): \(String(describing: error), privacy: .public)")
|
||||
}
|
||||
|
||||
@@ -58,14 +58,26 @@ public final class HistoryStore {
|
||||
/// click from running `git_repository_init` twice.
|
||||
public private(set) var isAddingGit = false
|
||||
|
||||
/// The last add-git failure, or `nil` if the last attempt succeeded (or there hasn't been one).
|
||||
/// The last add-git failure while the form that asked is still on screen, or `nil`.
|
||||
///
|
||||
/// Surfaced inline in the popover rather than as a banner: the popover is where the operation
|
||||
/// was asked for and is still open when it answers, and 02-architecture.md's one-shot banner
|
||||
/// vocabulary is for failures of writes the user made *elsewhere*. DESIGN does not settle
|
||||
/// add-git's failure surface either way.
|
||||
/// **Form-anchored operations answer at the form first** (06 ▸ Interaction with external writers,
|
||||
/// ruled 2026-07-31): "add-git — and later sheet-asked operations like verify-remote — fail into
|
||||
/// an inline caption in the sheet's relevant section while the sheet is up … 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."
|
||||
///
|
||||
/// So this property is exactly the *inline* half: it is set only while `isFormVisible`, and
|
||||
/// dismissing the form clears it ("dismissing the sheet dismisses the stale error"). The other
|
||||
/// half is `reportFailure`, which posts the banner when the answer arrives to an empty room.
|
||||
///
|
||||
/// The form is the popover's git section today and the board settings sheet once that exists —
|
||||
/// the ruling's container moved in the 2026-07-31 popover/sheet split, its substance did not, and
|
||||
/// `noteFormVisible(_:)` is the one line the sheet will re-point.
|
||||
public private(set) var lastFailure: GitOperationFailure?
|
||||
|
||||
/// Whether the form add-git was asked from is on screen right now (`noteFormVisible(_:)`).
|
||||
public private(set) var isFormVisible = false
|
||||
|
||||
/// **The auto-commit engine** (06-history-undo.md ▸ Rules ▸ Auto-commit), or `nil` on a board
|
||||
/// there is no repository to commit into.
|
||||
///
|
||||
@@ -152,6 +164,22 @@ public final class HistoryStore {
|
||||
@ObservationIgnored
|
||||
public var didAddGit: (@MainActor () -> Void)?
|
||||
|
||||
/// **The banner half of the form-anchored posture** — where a form-asked failure goes when the
|
||||
/// form is gone (`BannerCenter.postGitFailure`). `nil` on a storeless `HistoryStore`, which has no
|
||||
/// strip to post to; the inline half still works there.
|
||||
@ObservationIgnored
|
||||
public var reportFailure: (@MainActor (GitOperationFailure) -> Void)?
|
||||
|
||||
/// **The form appeared or was dismissed.** Dismissal clears the stale inline error, which is the
|
||||
/// ruling's own sentence ("dismissing the sheet dismisses the stale error, retry is right there").
|
||||
///
|
||||
/// A `Bool` rather than a count because there is one such form per board at a time: the popover is
|
||||
/// built fresh on each open and the settings sheet is modal to its board window.
|
||||
public func noteFormVisible(_ visible: Bool) {
|
||||
isFormVisible = visible
|
||||
if !visible { lastFailure = nil }
|
||||
}
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
|
||||
|
||||
init(boardRoot: URL, mode: BoardGitMode, ledger: EchoLedger) {
|
||||
@@ -283,7 +311,15 @@ public final class HistoryStore {
|
||||
Self.logger.notice("add-git initialized a repository at \(root.path, privacy: .public)")
|
||||
return true
|
||||
case .failure(let failure):
|
||||
lastFailure = failure
|
||||
// **Inline while the form is up, the banner when it is not** (06, ruled 2026-07-31) — the
|
||||
// answer can outlive the surface that asked for it, and a failure with nowhere to land
|
||||
// would be the silence trap the ruling names.
|
||||
if isFormVisible {
|
||||
lastFailure = failure
|
||||
} else {
|
||||
lastFailure = nil
|
||||
reportFailure?(failure)
|
||||
}
|
||||
Self.logger.error("add-git failed: \(failure.description, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -107,6 +107,11 @@ extension BoardStore {
|
||||
let named = subject ?? name
|
||||
let step = HistoryStep(
|
||||
name: name,
|
||||
// **What this step holds in a trash, read off what it already declares** — the sweep's
|
||||
// gate (`HistoryStep.backing`, ruled 2026-07-31). Derived here rather than passed by each
|
||||
// call site because the expectations *are* the claim: a step whose undo needs a folder
|
||||
// present under `comments/.trash/` is a step whose undo is the move back out of it.
|
||||
backing: HistoryStep.backing(declaredBy: undoExpects),
|
||||
retirement: retirement,
|
||||
undo: { [weak self] direction in
|
||||
BoardStore.cross(self, direction, named, undoExpects, undo)
|
||||
@@ -130,9 +135,9 @@ extension BoardStore {
|
||||
// MARK: The window close's coarse step
|
||||
|
||||
/// **Registers one card-window session as one board step** — 13-native-undo.md ▸ Rules' window
|
||||
/// close ("the session's net effect registers on the board stack as one coarse step, 'Edit card
|
||||
/// ⟨title⟩', values-based, whose undo restores the card subtree to its session-start state —
|
||||
/// deleted comments included — and whose redo reapplies the net effect").
|
||||
/// close ("the session's net effect registers on the board stack as one coarse step named
|
||||
/// 'Changes to ⟨card⟩', values-based, whose undo restores the card subtree to its session-start
|
||||
/// state — deleted comments included — and whose redo reapplies the net effect").
|
||||
///
|
||||
/// Everything about *what* the step does is `CardWindowUndo.netEffect()`'s; everything about
|
||||
/// whether there is a board to register it on is this method's:
|
||||
@@ -152,10 +157,11 @@ extension BoardStore {
|
||||
/// - **A session with no net change registers nothing**, which is `netEffect()`'s `nil`.
|
||||
///
|
||||
/// - Parameter retiring: the deferred `comments/.trash/` purge (13 ▸ Interaction with the trash).
|
||||
/// - Returns: whether the purge now has an owner — a live step holding it until the step leaves
|
||||
/// the board stack, or a substrate that declined to keep the step and therefore ran it already
|
||||
/// (`GitHistoryProvider.register`). `false` means nothing was registered and the caller still
|
||||
/// owes the purge.
|
||||
/// - Returns: whether the purge now has an owner — a live step holding it until the substrate
|
||||
/// lets the step go (which a stale skip deliberately does not do; 13's skip-purge decoupling,
|
||||
/// ruled 2026-07-31), or a substrate that declined to keep the step and therefore ran it
|
||||
/// already (`GitHistoryProvider.register`). `false` means nothing was registered and the caller
|
||||
/// still owes the purge.
|
||||
@discardableResult
|
||||
func registerCardSession(
|
||||
_ window: CardWindowUndo,
|
||||
@@ -164,9 +170,18 @@ extension BoardStore {
|
||||
) -> Bool {
|
||||
guard let target = Self.cardBodyTarget(cardID, in: snapshot) else { return false }
|
||||
guard let net = window.netEffect() else { return false }
|
||||
// **The title the step is registered with, not the one the session opened on** (the phrase is
|
||||
// 13's "Changes to '⟨card⟩'", ruled 2026-07-31). Read once and spent twice, which is the
|
||||
// reason: the menu row and the skip banner must quote *one* name for one step, and a step
|
||||
// whose row said "Changes to 'Fix login'" while its banner said "'Fix log-in' changed outside
|
||||
// Lanework" would be two names for one card. Registration time is also the honest instant —
|
||||
// the row appears in the Edit menu the moment this returns, beside a board already showing
|
||||
// this title, and a session that renamed the card is a session whose rename is *in* the net
|
||||
// effect the row is offering to walk back.
|
||||
let title = Self.cardTitle(at: target, in: snapshot)
|
||||
registerStep(
|
||||
HistoryPhrase.cardSession,
|
||||
subject: Self.cardTitle(at: target, in: snapshot),
|
||||
HistoryPhrase.cardSession(title),
|
||||
subject: title,
|
||||
retiring: retiring,
|
||||
undoExpects: net.undoExpects,
|
||||
redoExpects: net.redoExpects,
|
||||
|
||||
@@ -167,8 +167,11 @@ public final class BoardUndoManager: UndoManager {
|
||||
/// `NSSearchField` — vends no manager of its own to the window, so `undo:` falls through to the
|
||||
/// window's, and a reflexive ⌘Z over a typo in the search field or the board-rename field would
|
||||
/// cross a *board* step. 06 rules that out by name: "Control-class text fields route the same way
|
||||
/// (settled): the search field ... and the popover's text fields ... own ⌘Z/⇧⌘Z as field-local text
|
||||
/// undo while focused — a reflexive undo over a typo must never become a tree checkout." So the
|
||||
/// (settled): the search field ..., the popover's rename field, and the settings sheet's text fields
|
||||
/// (commit identity, credentials, remote URL) own ⌘Z/⇧⌘Z as field-local text undo while focused — a
|
||||
/// reflexive undo over a typo must never become a tree checkout." (That list is 06's own since the
|
||||
/// 2026-07-31 popover/sheet split; the rule is per *responder class*, so it covers each of them
|
||||
/// wherever it is hosted, and the sheet's arrival adds nothing here.) So the
|
||||
/// window-level answer is the board's stack **only when no text-editing surface holds the
|
||||
/// keyboard**, and a per-window text manager otherwise — which is also exactly what AppKit would
|
||||
/// have created for such a window on its own, so nothing about typing in a field changes.
|
||||
|
||||
@@ -101,6 +101,39 @@ public final class CardWindowUndo {
|
||||
writes[id] = write
|
||||
}
|
||||
|
||||
/// **Empties this window's fine stack, registering nothing** — the branch switch's settle step
|
||||
/// (06-history-undo.md ▸ Branch switching, ruled 2026-07-31).
|
||||
///
|
||||
/// > "The settle also clears each open card window's fine undo stack: pre-switch steps describe
|
||||
/// > the branch being left — Save All and Discard alike end with every window's stack empty, the
|
||||
/// > board-stack discard-and-reseed precedent one level down; the windows stay open, following
|
||||
/// > their cards onto the new branch with fresh stacks."
|
||||
///
|
||||
/// ### Closing the stack is not closing the window
|
||||
///
|
||||
/// This is the whole reason it is a method rather than a call to `stack.clear()` at the call site.
|
||||
/// A window *close* owes the board one coarse step folded from this stack
|
||||
/// (`BoardStore.registerCardSession`, `netEffect()`); a settle clear owes it nothing, because the
|
||||
/// window is not closing — it stays open on the other side of the checkout and its next gesture
|
||||
/// starts a fresh stack. So nothing here registers, nothing folds, and the fold that runs at the
|
||||
/// window's eventual close sees only what the *new* branch's session did: `netEffect()` reads
|
||||
/// `stack.pendingSteps`, which this empties, and `registerCardSession` answers `false` over an
|
||||
/// empty fold exactly as it does for a session with no net change.
|
||||
///
|
||||
/// The raw writes go with the steps. They are keyed by step id and nothing on either stack names
|
||||
/// them any more, so keeping them would be a table that only grows — and a redone step's half of
|
||||
/// the fold, which is the one reason the table outlives the undo stack, has nothing left to be
|
||||
/// half of once both stacks are empty.
|
||||
///
|
||||
/// **Nothing retires here that would not have retired anyway.** `NativeHistoryProvider.clear()`
|
||||
/// runs each dropped step's retirement, and a window's fine steps carry none: the deferred
|
||||
/// `comments/.trash/` purge is the *coarse* step's (13-native-undo.md ▸ Interaction with the
|
||||
/// trash), registered on the board's stack at close and never on this one.
|
||||
func discardSteps() {
|
||||
stack.clear()
|
||||
writes.removeAll()
|
||||
}
|
||||
|
||||
// MARK: - The fold
|
||||
|
||||
/// **The session's net effect, or `nil` when there is none** — what the window's close registers
|
||||
@@ -111,7 +144,11 @@ public final class CardWindowUndo {
|
||||
///
|
||||
/// - **undo** — every live step's undo, newest first. Replaying the session backwards lands on the
|
||||
/// state it started from, deleted comments included: their backing is still in
|
||||
/// `comments/.trash/` because this step's own existence is what defers the purge.
|
||||
/// `comments/.trash/` because this step's own existence is what defers the purge — and, since
|
||||
/// the sweep gate (13 ▸ Interaction with the trash, ruled 2026-07-31), what keeps the *next*
|
||||
/// window open from sweeping it as residue. That claim needs nothing of its own here: the fold
|
||||
/// below carries each delete's `.present(.trashedComment(…))` into the coarse step's
|
||||
/// expectations, and `HistoryStep.backing(declaredBy:)` reads it straight off them.
|
||||
/// - **redo** — every live step's redo, oldest first. The session, replayed.
|
||||
/// - **the undo's expectations** — the state the session's writes left, folded **last-write-wins**
|
||||
/// per field: what must still be true for the whole step to be safe to cross.
|
||||
|
||||
@@ -25,6 +25,10 @@ import Foundation
|
||||
/// destination clause a commit subject carries has no place in a title that has to stay short enough
|
||||
/// for a menu row.
|
||||
///
|
||||
/// **One deliberate exception, and it is the only one**: `cardSession(_:)` names its card
|
||||
/// ("Changes to 'Fix login'"), because the coarse close step is the one phrase whose *scope* is what
|
||||
/// distinguishes it — see that member.
|
||||
///
|
||||
/// Pure, and its own type rather than a `String` built at each call site, because a phrase composed
|
||||
/// in eleven places is a vocabulary that drifts in eleven places.
|
||||
public enum HistoryPhrase {
|
||||
@@ -105,15 +109,37 @@ public enum HistoryPhrase {
|
||||
// MARK: The card-window session
|
||||
|
||||
/// **The coarse close step's phrase** — one card window's whole session, as the board's stack sees
|
||||
/// it: 13-native-undo.md ▸ Rules names it "Edit card 'Fix login'", so the menu title is the same
|
||||
/// verb and noun every other card edit uses, dropping the item clause a menu row has no space for.
|
||||
/// it: **"Changes to '⟨card⟩'"** (13-native-undo.md ▸ Rules, ruled 2026-07-31).
|
||||
///
|
||||
/// Deliberately *not* a new verb. The session is an edit to a card — the fine-grained things
|
||||
/// inside it (a comment posted, a colour chosen, a paragraph rewritten) are the window stack's
|
||||
/// story, and a board-level row that tried to enumerate them would be the "Mixed update" problem
|
||||
/// in a menu (06-history-undo.md ▸ Commit messages). It reads identically to a body-edit step
|
||||
/// because on the board's stack it *is* the card's edit.
|
||||
public static let cardSession = name(.edit, kind: .card)
|
||||
/// > "the session's net effect registers on the board stack as **one coarse step named
|
||||
/// > "Changes to '⟨card⟩'"** … the board row reads "Undo Changes to 'Fix login'": plural and
|
||||
/// > scope-flavoured, distinct from every fine verb, honest about folding many kinds; the fine
|
||||
/// > body-edit wording never leaks onto the board menu."
|
||||
///
|
||||
/// ### Why this one phrase carries its item
|
||||
///
|
||||
/// Every other phrase here drops the item clause a commit subject carries, because a verb plus a
|
||||
/// noun already says what the row is (the type's note). This step has no such verb: what it folds
|
||||
/// is a comment posted, a colour chosen, a paragraph rewritten, all at once, and a row that named
|
||||
/// any one of them would be lying about the other two — while enumerating them would be the
|
||||
/// "Mixed update" problem in a menu (06-history-undo.md ▸ Commit messages). So the *scope* is the
|
||||
/// phrase, and a scope is only legible when it names what it is the scope **of**.
|
||||
///
|
||||
/// ### It is not `Edit Card`, and that is the point
|
||||
///
|
||||
/// This member read `name(.edit, kind: .card)` until the ruling, and the fine body-edit step still
|
||||
/// does (`registerBodyEdit`) — two different steps on two different stacks, one row apart in the
|
||||
/// Edit menu, saying the same six characters. The board menu now says "Changes to 'Fix login'" and
|
||||
/// the window menu says "Edit Card": the coarse row names the session, the fine row names the
|
||||
/// gesture, and neither can be mistaken for the other.
|
||||
///
|
||||
/// - Parameter title: the card's title **as the step is registered** — `nil` renders the same
|
||||
/// placeholder the card's own window title bar renders ("Untitled" is a rendering, never a
|
||||
/// value — 03-board-ui.md § Card face), so a menu row and the window it came from name the card
|
||||
/// the same way.
|
||||
public static func cardSession(_ title: String?) -> String {
|
||||
"Changes to '\(title ?? "Untitled")'"
|
||||
}
|
||||
|
||||
// MARK: Composition
|
||||
|
||||
|
||||
@@ -111,10 +111,18 @@ public struct HistoryStep {
|
||||
/// It exists for one consumer, and the design names it precisely: the window-close coarse step
|
||||
/// defers a card's `comments/.trash/` purge, because "the coarse close step's undo restores
|
||||
/// deleted comments, so their backing lives as long as the step does — the purge runs when the
|
||||
/// coarse step leaves the board stack (undone-and-superseded, dropped off the end, or gone
|
||||
/// stale) or the board session ends" (13-native-undo.md ▸ Interaction with the trash, re-ruled
|
||||
/// 2026-07-31). A step is the only object that knows all three of those moments, and it knows
|
||||
/// none of them itself — so the *provider* reports them, through this.
|
||||
/// coarse step leaves the board stack **cleanly** — undone-and-superseded, or dropped off the end
|
||||
/// — or when the board session ends" (13-native-undo.md ▸ Interaction with the trash, re-ruled
|
||||
/// 2026-07-31). A step is the only object that knows those moments, and it knows none of them
|
||||
/// itself — so the *provider* reports them, through this.
|
||||
///
|
||||
/// **A stale skip is deliberately not one of them** (the skip-purge decoupling, ruled
|
||||
/// 2026-07-31): "a stale-skipped step's backing instead survives to board-session end ... the
|
||||
/// skip banner says nothing was applied, and an irreversible purge riding that gesture would be
|
||||
/// surprise loss". Leaving the stack and leaving history for good stopped being the same event
|
||||
/// there, and this latch marks the second — which is why a substrate that keeps a skipped step's
|
||||
/// hold alive holds the *step* (`NativeHistoryProvider.strandedSteps`) rather than running this
|
||||
/// early and re-arming the sweep from somewhere else.
|
||||
///
|
||||
/// **A reference type inside a value type, deliberately.** `reversed` copies the step every time
|
||||
/// it crosses, and the two copies must not each run the work: sharing one latch is what makes
|
||||
@@ -150,6 +158,26 @@ public struct HistoryStep {
|
||||
/// The menu phrase, unprefixed — see the type's note.
|
||||
public let name: String
|
||||
|
||||
/// **What this step holds as undo backing** — deleted content that exists on disk only because
|
||||
/// *this step's undo would move it back out*, and which is therefore not residue for as long as
|
||||
/// the step is crossable (13-native-undo.md ▸ Interaction with the trash, ruled 2026-07-31).
|
||||
///
|
||||
/// > "`comments/.trash/` content referenced by a live coarse step on the board stack is a step's
|
||||
/// > **backing, not residue** — the open-time sweep consults the stack and skips owned content,
|
||||
/// > re-arming when the owning step leaves the stack (which is exactly when the deferred purge
|
||||
/// > wanted to run; one condition, two consumers). Reopening a window can therefore never destroy
|
||||
/// > its prior session's undo backing."
|
||||
///
|
||||
/// The pair with `retirement` is the whole mechanism: this says *what* is being held, that says
|
||||
/// *when the hold ends*, and the two are read by the two consumers the ruling names — the
|
||||
/// crash-residue sweep (`BoardStore.sweepCommentTrashResidue(inCard:)`, through
|
||||
/// `HistoryProviding.backedContent`) and the deferred purge itself.
|
||||
///
|
||||
/// **Declared by the step's own expectations rather than by hand** — see `backing(declaredBy:)`.
|
||||
/// Empty for every board gesture, which is all but a comment delete and the coarse step folding
|
||||
/// one.
|
||||
public let backing: Set<HistoryAnchor>
|
||||
|
||||
/// What this step owes when it leaves history — see `Retirement`. `nil` for every step that owes
|
||||
/// nothing, which is all but the card-window close step.
|
||||
public let retirement: Retirement?
|
||||
@@ -165,26 +193,58 @@ public struct HistoryStep {
|
||||
public init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
backing: Set<HistoryAnchor> = [],
|
||||
retirement: Retirement? = nil,
|
||||
undo: @escaping @MainActor (HistoryDirection) -> HistoryStepOutcome,
|
||||
redo: @escaping @MainActor (HistoryDirection) -> HistoryStepOutcome
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.backing = backing
|
||||
self.retirement = retirement
|
||||
self.undo = undo
|
||||
self.redo = redo
|
||||
}
|
||||
|
||||
/// **The backing a step's own registration already names**: every target its **undo** expects to
|
||||
/// find *in a trash*.
|
||||
///
|
||||
/// Derived rather than passed, so no call site can forget it and no call site can say something
|
||||
/// its expectations contradict. The reading is exact: a step whose undo requires a folder to be
|
||||
/// present under `comments/.trash/` is a step whose undo *is* the move back out of it — which is
|
||||
/// the definition of backing, spelled in the currency the step already carries.
|
||||
///
|
||||
/// **`.trashedComment` is the whole vocabulary**, deliberately. The board's own `.trash/` holds
|
||||
/// the same relationship (a delete step's undo restores from it) and is deliberately absent: the
|
||||
/// board trash is a UI surface the user empties on purpose, with a confirm, and nothing sweeps it
|
||||
/// behind their back — so there is no consumer for the answer. `comments/.trash/` is the one
|
||||
/// trash the app purges on its own schedule (01-storage-format.md § Enhanced schema), which is
|
||||
/// exactly why it is the one that needs asking.
|
||||
public static func backing(declaredBy undoExpects: [HistoryExpectation]) -> Set<HistoryAnchor> {
|
||||
var backing: Set<HistoryAnchor> = []
|
||||
for expectation in undoExpects where expectation.presence == .present {
|
||||
guard case .trashedComment = expectation.anchor else { continue }
|
||||
backing.insert(expectation.anchor)
|
||||
}
|
||||
return backing
|
||||
}
|
||||
|
||||
/// The same step read backwards — what a provider puts on the opposite stack once this one has
|
||||
/// applied. The name does not change, which is the whole of "Undo Move Card" becoming "Redo Move
|
||||
/// Card": the phrase names the *gesture*, not the direction.
|
||||
///
|
||||
/// **The identity and the retirement travel with it**, both for the same reason: a step that has
|
||||
/// crossed is the same step, so the fold that keyed state on it must still find that state, and
|
||||
/// the purge it defers must still be owed exactly once.
|
||||
/// **The identity, the backing and the retirement travel with it**, all three for one reason: a
|
||||
/// step that has crossed is the same step, so the fold that keyed state on it must still find that
|
||||
/// state, the content it holds must still be held, and the purge it defers must still be owed
|
||||
/// exactly once.
|
||||
///
|
||||
/// The backing claim is constant across the crossing rather than swapped with the closures, and
|
||||
/// that is the honest reading: an undone coarse step has *already* moved its comments back out of
|
||||
/// `comments/.trash/`, so there is nothing there to sweep — and its redo will put them back,
|
||||
/// after which its undo needs them again. A claim that lapsed while the step sat on the redo stack
|
||||
/// would be a claim that lapsed exactly when the step was still crossable.
|
||||
public var reversed: HistoryStep {
|
||||
HistoryStep(id: id, name: name, retirement: retirement, undo: redo, redo: undo)
|
||||
HistoryStep(id: id, name: name, backing: backing, retirement: retirement, undo: redo, redo: undo)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,14 +280,31 @@ public struct HistoryStep {
|
||||
/// - **No routing.** Which surface ⌘Z reaches is focus's answer, not the substrate's
|
||||
/// (06 ▸ Undo routing, tier-independent) — `BoardUndoRouting`.
|
||||
///
|
||||
/// ### One obligation every implementation shares: retire what you drop
|
||||
/// ### One obligation every implementation shares: retire what you let go of
|
||||
///
|
||||
/// A step may owe work for as long as it is crossable and no longer (`HistoryStep.Retirement` — the
|
||||
/// deferred `comments/.trash/` purge). Only the substrate knows when a step stops being crossable, so
|
||||
/// **every implementation must call `retirement?.run()` on every step it lets go**: the redo stack it
|
||||
/// clears on a `register`, a step it drops as stale, everything in `clear()`, and — for a substrate
|
||||
/// that keeps no steps at all — the step handed to `register` itself. Nothing else in the app can
|
||||
/// observe that moment, and a step dropped in silence would defer its work forever.
|
||||
/// A step may owe work for as long as the substrate holds it and no longer (`HistoryStep.Retirement`
|
||||
/// — the deferred `comments/.trash/` purge). Only the substrate knows when it has stopped holding
|
||||
/// one, so **every implementation must call `retirement?.run()` on every step it lets go**: the redo
|
||||
/// stack it clears on a `register`, everything in `clear()`, and — for a substrate that keeps no
|
||||
/// steps at all — the step handed to `register` itself. Nothing else in the app can observe that
|
||||
/// moment, and a step dropped in silence would defer its work forever.
|
||||
///
|
||||
/// **Held is not the same as crossable**, since the skip-purge decoupling (13 ▸ Interaction with the
|
||||
/// trash, ruled 2026-07-31): a step a stale skip popped is crossable by nothing, and its hold
|
||||
/// nonetheless stands to the session's end, because "the skip banner says nothing was applied, and an
|
||||
/// irreversible purge riding that gesture would be surprise loss". A substrate that pops stale steps
|
||||
/// therefore has a place to put them (`NativeHistoryProvider.strandedSteps`); one that never keeps a
|
||||
/// step is untouched by the distinction, which is why the git provider needed no change.
|
||||
///
|
||||
/// ### And its mirror: say what you are still holding
|
||||
///
|
||||
/// The same fact, asked the other way round — `backedContent`. Retirement is the *moment* a hold
|
||||
/// ends; that is the *inventory* of holds standing right now, which is what a sweep needs before it
|
||||
/// removes anything (13 ▸ Interaction with the trash, ruled 2026-07-31: "one condition, two
|
||||
/// consumers"). The two must be answered off the same steps or the pair stops being one condition —
|
||||
/// which is the whole reason a stranded step is kept whole rather than filleted into a retirement
|
||||
/// here and a set of anchors there. It defaults to nothing, so a substrate that keeps no steps needs
|
||||
/// no line of code.
|
||||
@MainActor
|
||||
public protocol HistoryProviding: AnyObject {
|
||||
|
||||
@@ -262,4 +339,32 @@ public protocol HistoryProviding: AnyObject {
|
||||
/// Drops every step in both directions — session-only persistence (13 ▸ Rules), run at the board
|
||||
/// session's teardown. Also what a substrate that must re-seed (a branch switch, 06) calls first.
|
||||
func clear()
|
||||
|
||||
/// **The undo backing every step this substrate still holds** — the union of `HistoryStep.backing`
|
||||
/// over everything it has not yet let go of.
|
||||
///
|
||||
/// Read by the crash-residue sweep before it removes anything
|
||||
/// (`BoardStore.sweepCommentTrashResidue(inCard:)`): "content referenced by a live coarse step on
|
||||
/// the board stack is a step's backing, not residue" (13 ▸ Interaction with the trash, ruled
|
||||
/// 2026-07-31). Both directions count, because both are live: a step sitting on the redo stack has
|
||||
/// not retired, and 13's own release condition — "undone-and-superseded" — is the moment it does.
|
||||
/// So does a step a stale skip stranded, which is crossable in *neither* direction and holding all
|
||||
/// the same: the skip-purge decoupling put its backing's release at the session's end, and a sweep
|
||||
/// that could not see it would take the release back (`NativeHistoryProvider.strandedSteps`).
|
||||
///
|
||||
/// A *point-in-time* answer, computed on demand rather than cached: the stacks are the truth, and
|
||||
/// a second copy of this could only ever be a stale one.
|
||||
var backedContent: Set<HistoryAnchor> { get }
|
||||
}
|
||||
|
||||
extension HistoryProviding {
|
||||
|
||||
/// **A substrate that keeps no steps holds no backing** — the git provider's honest answer
|
||||
/// (`GitHistoryProvider.register` retires every step on arrival, which is what makes "purge rides
|
||||
/// the close flush" true on Pro), and a test fake's.
|
||||
///
|
||||
/// A default on the requirement rather than a free function, so the dispatch is the substrate's:
|
||||
/// an implementation that *does* keep steps overrides it and every caller through `any
|
||||
/// HistoryProviding` sees the override.
|
||||
public var backedContent: Set<HistoryAnchor> { [] }
|
||||
}
|
||||
|
||||
@@ -39,8 +39,16 @@ import Foundation
|
||||
/// A step that applies is pushed onto the opposite stack **reversed** — its two halves swapped
|
||||
/// (`HistoryStep.reversed`) — which gives the whole classic dance (undo → redo → undo …) with one
|
||||
/// rule. Both stacks therefore hold steps oriented so that *crossing them means calling `undo`*, and
|
||||
/// a skipped step leaves nothing behind at all: it is popped and never re-pushed, which is 13's
|
||||
/// a skipped step leaves nothing crossable behind: it is popped and never re-pushed, which is 13's
|
||||
/// "popped from the stack ... and ⌘Z falls through to the next step".
|
||||
///
|
||||
/// ### A third collection, which is not a stack
|
||||
///
|
||||
/// What a stale skip leaves behind is not *nothing*, though, and that is the one place this type
|
||||
/// keeps state a stack cannot express: a skipped step that was holding undo backing goes on holding
|
||||
/// it until the session ends (`strandedSteps`; 13 ▸ Interaction with the trash, ruled 2026-07-31 —
|
||||
/// the skip-purge decoupling). Nothing can cross a stranded step and nothing can see one in the Edit
|
||||
/// menu; it is a hold outliving the history it belonged to.
|
||||
@MainActor
|
||||
public final class NativeHistoryProvider: HistoryProviding {
|
||||
|
||||
@@ -48,6 +56,26 @@ public final class NativeHistoryProvider: HistoryProviding {
|
||||
private var undoSteps: [HistoryStep] = []
|
||||
private var redoSteps: [HistoryStep] = []
|
||||
|
||||
/// **Steps a stale skip popped that were still holding something** — off both stacks, uncrossable
|
||||
/// forever, and *not yet retired* (13-native-undo.md ▸ Interaction with the trash, ruled
|
||||
/// 2026-07-31):
|
||||
///
|
||||
/// > "the purge runs when the coarse step leaves the board stack **cleanly** — undone-and-superseded,
|
||||
/// > or dropped off the end — or when the board session ends; **a stale-skipped step's backing
|
||||
/// > instead survives to board-session end** ... the skip banner says nothing was applied, and an
|
||||
/// > irreversible purge riding that gesture would be surprise loss — the skip is exactly when the
|
||||
/// > user may want to inspect what the collision left."
|
||||
///
|
||||
/// **The whole step is kept, not a copy of what it was holding**, and that is the ruling's "one
|
||||
/// condition, two consumers" taken literally: the sweep's ownership question (`backedContent`) and
|
||||
/// the deferred purge's moment (`HistoryStep.Retirement`) are two readings of one object's hold,
|
||||
/// so they must not become two facts able to disagree. A skip that ran the retirement and re-armed
|
||||
/// the sweep from a side-table would be saying *released* to one consumer and *still held* to the
|
||||
/// other, for the same instant. Deferring the latch itself says it once.
|
||||
///
|
||||
/// Only steps that owe something are stranded — see `strand(_:)`.
|
||||
private var strandedSteps: [HistoryStep] = []
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: - HistoryProviding
|
||||
@@ -73,16 +101,44 @@ public final class NativeHistoryProvider: HistoryProviding {
|
||||
/// membership question, and the two arrays already answer it.
|
||||
public var pendingSteps: [HistoryStep] { undoSteps }
|
||||
|
||||
/// **What every step this substrate still holds is holding** — the sweep's gate
|
||||
/// (`HistoryProviding.backedContent`; 13-native-undo.md ▸ Interaction with the trash, ruled
|
||||
/// 2026-07-31).
|
||||
///
|
||||
/// All *three* collections, unlike `pendingSteps` above, and the difference is the question: that
|
||||
/// one asks what ⌘Z would cross *now*, this one asks what has not been released. A step on the
|
||||
/// redo stack has not retired — "undone-and-superseded" is a release condition precisely because
|
||||
/// being undone is not one — so its backing is still owed; and a stranded step is the case where
|
||||
/// the two questions come furthest apart, holding backing it will never again cross for
|
||||
/// (`strandedSteps`, the skip-purge decoupling ruled 2026-07-31). The readings differing is what
|
||||
/// makes them two members.
|
||||
///
|
||||
/// Leaving the stranded out would defeat the ruling by the back door: the step is off the stack,
|
||||
/// so the next window open's sweep would call its backing residue and purge exactly the content
|
||||
/// the skip was meant to leave standing.
|
||||
public var backedContent: Set<HistoryAnchor> {
|
||||
var backing: Set<HistoryAnchor> = []
|
||||
for step in undoSteps { backing.formUnion(step.backing) }
|
||||
for step in redoSteps { backing.formUnion(step.backing) }
|
||||
for step in strandedSteps { backing.formUnion(step.backing) }
|
||||
return backing
|
||||
}
|
||||
|
||||
/// Records one undoable step and clears the redo stack — the classic rule, and the one every
|
||||
/// substrate shares.
|
||||
///
|
||||
/// The cleared steps are **retired** on the way out (`HistoryStep.Retirement`): this is the
|
||||
/// "undone-and-superseded" half of the deferred purge's release condition, and it is the only
|
||||
/// moment the app can see it.
|
||||
/// "undone-and-superseded" half of the deferred purge's release condition — a *clean* exit in the
|
||||
/// ruling's own word — and it is the only moment the app can see it.
|
||||
///
|
||||
/// `strandedSteps` is deliberately untouched: superseding is something that happens to a step on
|
||||
/// the redo stack, and a stranded step is on no stack to be superseded from. Its one exit is the
|
||||
/// session's end.
|
||||
public func register(_ step: HistoryStep) {
|
||||
undoSteps.append(step)
|
||||
retire(redoSteps)
|
||||
let dropped = redoSteps
|
||||
redoSteps.removeAll()
|
||||
retire(dropped)
|
||||
}
|
||||
|
||||
public func undo() { cross(.undo) }
|
||||
@@ -92,17 +148,38 @@ public final class NativeHistoryProvider: HistoryProviding {
|
||||
/// Session teardown, the add-git substrate swap, a branch reseed — every step goes, so every step
|
||||
/// retires: "the purge runs when the coarse step leaves the board stack ... or the board session
|
||||
/// ends" (13 ▸ Interaction with the trash).
|
||||
///
|
||||
/// **This is where the stranded finally pay**, and the only place they can: "a stale-skipped
|
||||
/// step's backing instead survives to board-session end" (ruled 2026-07-31) names exactly this
|
||||
/// moment, and `AppModel`'s teardown reaches it with the store still alive — the `clear()` is a
|
||||
/// line above the store's release, for the reason the deferred purge needs it to be.
|
||||
public func clear() {
|
||||
retire(undoSteps)
|
||||
retire(redoSteps)
|
||||
let dropped = undoSteps + redoSteps + strandedSteps
|
||||
undoSteps.removeAll()
|
||||
redoSteps.removeAll()
|
||||
strandedSteps.removeAll()
|
||||
retire(dropped)
|
||||
}
|
||||
|
||||
/// **Off the stack first, then retired** — the order every caller above keeps, and the one
|
||||
/// `backedContent` depends on: a retirement is where the deferred purge runs, and a step that
|
||||
/// retired while still listed would be answering that it still holds the very content it is
|
||||
/// releasing.
|
||||
private func retire(_ steps: [HistoryStep]) {
|
||||
for step in steps { step.retirement?.run() }
|
||||
}
|
||||
|
||||
/// Keeps a skipped step's *hold* alive without keeping the step crossable — see `strandedSteps`.
|
||||
///
|
||||
/// **A step holding nothing is dropped outright**, which is every board gesture: with no backing
|
||||
/// to spare and no retirement to defer there is no consumer for the entry, and stranding it would
|
||||
/// be bookkeeping that only grows. The predicate is the pair the ruling's two consumers read, so
|
||||
/// it can never spare a step neither of them would ask about.
|
||||
private func strand(_ step: HistoryStep) {
|
||||
guard step.retirement != nil || !step.backing.isEmpty else { return }
|
||||
strandedSteps.append(step)
|
||||
}
|
||||
|
||||
// MARK: - The crossing
|
||||
|
||||
/// Crosses one step, and keeps going while the steps it crosses decline as **stale** — 13's
|
||||
@@ -119,9 +196,13 @@ public final class NativeHistoryProvider: HistoryProviding {
|
||||
push(step.reversed, onto: direction.opposite)
|
||||
return
|
||||
case .skipped:
|
||||
// Dropped for good — the third of the deferred purge's release conditions ("gone
|
||||
// stale"), and the reason a skip is reported here rather than merely counted.
|
||||
step.retirement?.run()
|
||||
// **Off the stack, but not released** — the skip-purge decoupling (13 ▸ Interaction
|
||||
// with the trash, ruled 2026-07-31). A stale skip is the one exit that is not clean:
|
||||
// "the skip banner says nothing was applied, and an irreversible purge riding that
|
||||
// gesture would be surprise loss — the skip is exactly when the user may want to
|
||||
// inspect what the collision left". So the step stops being crossable here and its
|
||||
// hold outlives it, to the session's end (`strandedSteps`).
|
||||
strand(step)
|
||||
continue
|
||||
case .failed:
|
||||
push(step, onto: direction)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -370,7 +370,8 @@ public final class BoardStore: HealHost {
|
||||
losses: banners.losses,
|
||||
suspension: banners.historySuspension,
|
||||
operations: banners.operations,
|
||||
signposts: banners.signposts
|
||||
signposts: banners.signposts,
|
||||
gitFailures: banners.gitFailures
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4055,10 +4056,60 @@ public final class BoardStore: HealHost {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The board's noise definition
|
||||
|
||||
/// Puts the seeded `.gitignore` on a board that has none, and does nothing at all to a board
|
||||
/// that has one — 06-history-undo.md ▸ Repository hygiene's whole scheduling ("a board missing
|
||||
/// the file gains it by scheduled heal at open (the guide-refresh cadence)", re-ruled
|
||||
/// 2026-07-31).
|
||||
///
|
||||
/// **`refreshAgentGuide()`'s twin, deliberately**, down to the shape of this method: the file is
|
||||
/// a board-root courtesy the app owns, its defect is *presence* rather than anything a tree walk
|
||||
/// could report, and the decision is one `lstat`. What differs is that there is no version to
|
||||
/// compare and no displacement to make — a name already held by anything at all is left exactly
|
||||
/// as it stands (`BoardWriter.seedGitignoreIfAbsent`), including by a directory, because an
|
||||
/// unreadable noise definition costs the board nothing but the exclusions it never had.
|
||||
///
|
||||
/// **Silent.** No banner row, on the guide's reasoning exactly: a courtesy file the user did not
|
||||
/// create and may not know exists, whose absence changed nothing they can see. Only a genuine I/O
|
||||
/// failure reaches the strip, through `performWrite`.
|
||||
///
|
||||
/// **The memo is armed before the attempt and cleared on success** (`HealScheduler`'s steps 4 and
|
||||
/// 6), which is what makes a foreign deletion healable: the picture "missing" is restored by the
|
||||
/// delete, and a standing memo would make that deletion the one thing this could not answer —
|
||||
/// while a failing write still gets exactly one attempt per changed picture rather than one per
|
||||
/// reload. "Deletion is answered by re-seeding" is 06's own wording.
|
||||
///
|
||||
/// **It registers no undo step**, like every heal: nobody asked for it.
|
||||
public func seedGitignore() {
|
||||
let root = rootURL
|
||||
// An empty signature is the engine's resting state and costs no bracket — which matters here
|
||||
// more than anywhere, because this heal runs at every open and reload tail of every board,
|
||||
// and a bracket schedules a reload whether or not anything was written.
|
||||
let signature: Set<String> = IntegrityRules
|
||||
.node(at: root.appendingPathComponent(IntegrityRules.gitignoreFileName)) == nil
|
||||
? ["gitignore:missing"]
|
||||
: []
|
||||
|
||||
heals.run(.missingGitignore, signature: signature, on: self) { () throws(BoardWriteError) -> Void in
|
||||
// The disk re-verify is the Writer's own: a file that appeared under us — another window,
|
||||
// an agent, a clone finishing — makes this a no-op rather than an overwrite.
|
||||
try BoardWriter.seedGitignoreIfAbsent(atBoardRoot: root)
|
||||
}
|
||||
}
|
||||
|
||||
/// **Every scheduled heal, in order** — the engine's two seams call exactly this
|
||||
/// (02-architecture.md ▸ Components ▸ HealScheduler: "fires uniformly at the reload tail and at
|
||||
/// registry acquire, closing today's asymmetry where tombstone migration never fires at open").
|
||||
///
|
||||
/// **The `.gitignore` seed goes before the relocation, and that ordering is load-bearing in the
|
||||
/// same way** (01-storage-format.md § Fractal layout ▸ Rules, ruled 2026-07-31): the seed *is*
|
||||
/// the noise gate the relocation obeys, so a pass that relocated first would act, once, on a
|
||||
/// board whose noise definition it was about to write. The window it closes is narrow by
|
||||
/// construction — the walk that produced this pass's work list ran before either heal, so the
|
||||
/// seed's own patterns cannot filter it until the next load, and both of them name hidden files
|
||||
/// the carve-out never touches anyway — but the order costs nothing and states the dependency.
|
||||
///
|
||||
/// **The claimed-name displacement goes first, and that ordering is load-bearing**: a card's
|
||||
/// migration mints `<root>/.trash/`, which cannot be created while a file or symlink holds that
|
||||
/// name — so a migration attempted ahead of the displacement fails *and arms its memo against an
|
||||
@@ -4084,6 +4135,7 @@ public final class BoardStore: HealHost {
|
||||
/// re-armed by the reload the others' writes produce, so none can see another's work half-done.
|
||||
public func runScheduledHeals() {
|
||||
displaceClaimedNames()
|
||||
seedGitignore()
|
||||
relocateLooseCardFiles()
|
||||
migrateLegacyTombstones()
|
||||
remintDuplicateIdentities()
|
||||
|
||||
@@ -261,6 +261,12 @@ extension BoardStore {
|
||||
/// old stale-after-close skip scenario cannot arise". What reaches the board is the close step
|
||||
/// that folds it, whose undo restores the comment from a `comments/.trash/` the same step is
|
||||
/// keeping alive (`registerCardSession`).
|
||||
///
|
||||
/// The `.present(trashed)` half of the undo's expectations is also this step's **backing claim**,
|
||||
/// read off it by `HistoryStep.backing(declaredBy:)` and carried into the coarse step by the fold:
|
||||
/// it is what tells the next window open's residue sweep that the folder in `comments/.trash/` is
|
||||
/// a live step's, not a dead session's leftovers (13 ▸ Interaction with the trash, ruled
|
||||
/// 2026-07-31).
|
||||
@discardableResult
|
||||
public func deleteComment(_ commentID: ItemID, inCard id: ItemID, on window: CardWindowUndo? = nil) -> Bool {
|
||||
guard let card = commentSubject(id) else { return false }
|
||||
@@ -309,10 +315,16 @@ extension BoardStore {
|
||||
/// The call site moved with that re-ruling and this method did not change: the window's close no
|
||||
/// longer purges on its own, it hands this work to the coarse close step as that step's
|
||||
/// **retirement** (`HistoryStep.Retirement`), and the purge runs when the step leaves the board
|
||||
/// stack — undone-and-superseded, dropped, gone stale — or when the board session ends. On a git
|
||||
/// board the step is never kept, so the retirement fires at the close flush, which is where the
|
||||
/// purge always ran there ("purge rides the close flush"). The crash-residue sweep at the next
|
||||
/// card-window open is unchanged.
|
||||
/// stack **cleanly** — undone-and-superseded, or dropped off the end — or when the board session
|
||||
/// ends. A **stale skip is not a clean exit** and does not purge (the decoupling ruled
|
||||
/// 2026-07-31): that step's backing survives to session end instead, where `clear()` retires it
|
||||
/// and this method finally runs. On a git board the step is never kept, so the retirement fires at
|
||||
/// the close flush, which is where the purge always ran there ("purge rides the close flush").
|
||||
///
|
||||
/// **It empties the folder whole, and its two callers are exactly the moments that is right**: a
|
||||
/// close that registered no step (nothing took the hold), and the retirement of the step that
|
||||
/// did (the hold has just ended). The *sweep* cannot assume either, which is why it purges per
|
||||
/// entry behind the ownership gate — `sweepCommentTrashResidue(inCard:)`.
|
||||
///
|
||||
/// One bracket, no step. Leftover comment steps on a stack are not pruned here and must not be:
|
||||
/// invalidation is lazy (13 ▸ Rules), so they stay on the stack, look full, and skip with the
|
||||
@@ -327,10 +339,49 @@ extension BoardStore {
|
||||
/// **The crash-residue sweep**, run when a card window opens (§ Enhanced schema: "crash residue
|
||||
/// sweeps at the next card-window open, armed-then-cleared like every heal memo").
|
||||
///
|
||||
/// The same six steps every scheduled heal gets, through the same engine: **rest** when the trash
|
||||
/// is empty (which is every open on a board that closed cleanly, and costs no bracket at all),
|
||||
/// defer under a read-only lock, compare the signature, arm before attempting, one bracket, clear
|
||||
/// on success.
|
||||
/// ### Residue is what no live step owns — the stack is asked first
|
||||
///
|
||||
/// The ruling of 2026-07-31 (13-native-undo.md ▸ Interaction with the trash; § Enhanced schema's
|
||||
/// "residue defined as content no live coarse step owns"):
|
||||
///
|
||||
/// > "residue is defined by the purge-deferral condition itself: `comments/.trash/` content
|
||||
/// > referenced by a live coarse step on the board stack is a step's **backing, not residue** —
|
||||
/// > the open-time sweep consults the stack and skips owned content, re-arming when the owning
|
||||
/// > step leaves the stack (which is exactly when the deferred purge wanted to run; one condition,
|
||||
/// > two consumers). **Reopening a window can therefore never destroy its prior session's undo
|
||||
/// > backing.** Unowned content sweeps as before."
|
||||
///
|
||||
/// The defect it closes is the reopen: close a window that deleted a comment (the coarse step
|
||||
/// registers, its undo restores from `comments/.trash/`), open the same card again, and this sweep
|
||||
/// used to empty the trash out from under a step still sitting on the board's stack — leaving a
|
||||
/// ⌘Z that could only skip.
|
||||
///
|
||||
/// So the gate is `HistoryProviding.backedContent`, and it is the same fact the deferred purge
|
||||
/// waits on, asked as an inventory instead of as a moment. Nothing here has to *re-arm* by hand:
|
||||
/// when the owning step retires, its retirement runs the deferred purge and the content is gone;
|
||||
/// and a sweep that found nothing left to do rests, which clears the memo (`HealScheduler` step 1)
|
||||
/// so a later picture is a fresh one.
|
||||
///
|
||||
/// **A step a stale skip popped still counts as an owner** (the skip-purge decoupling, ruled
|
||||
/// 2026-07-31): "a stale-skipped step's backing instead survives to board-session end ... the skip
|
||||
/// is exactly when the user may want to inspect what the collision left". Nothing here says so —
|
||||
/// that is `backedContent`'s answer, and keeping it there is what makes the pair one condition
|
||||
/// read twice rather than two conditions kept in step by hand.
|
||||
///
|
||||
/// ### Entries, not the container
|
||||
///
|
||||
/// Which is why this no longer calls `BoardWriter.purgeCommentTrash(inCard:)` — that empties the
|
||||
/// folder whole, and the whole folder is exactly what this may not assume it owns. The per-entry
|
||||
/// primitive is the same one an undone create removes its folder with, over the entries the
|
||||
/// listing already narrowed to identity shape; a hand-editor's stray in there keeps the verbatim
|
||||
/// posture either way.
|
||||
///
|
||||
/// The rest is the same six steps every scheduled heal gets, through the same engine: **rest**
|
||||
/// when there is nothing unowned (which is every open on a board that closed cleanly, and costs no
|
||||
/// bracket at all), defer under a read-only lock, compare the signature, arm before attempting,
|
||||
/// one bracket, clear on success. The signature is computed from **the entries actually purged**,
|
||||
/// so the picture the memo remembers is the work that was attempted rather than everything the
|
||||
/// trash happened to hold.
|
||||
///
|
||||
/// **Silent** — `HealNotice.none`. `comments/.trash/` is "never a UI surface", and the residue is
|
||||
/// the app's own leftovers from a session that died; there is nothing here a user could act on.
|
||||
@@ -341,13 +392,21 @@ extension BoardStore {
|
||||
public func sweepCommentTrashResidue(inCard id: ItemID) {
|
||||
guard let card = commentSubject(id) else { return }
|
||||
let folder = card.folder
|
||||
let residue = CommentThread.trashedCommentIDs(inCard: folder)
|
||||
// The board's stack, never a window's: a window's own fine comment steps die with the window
|
||||
// that owns them, and the window this sweep runs for has not made a gesture yet. What can
|
||||
// outlive a close is the coarse step the close folded the session into, and that is here.
|
||||
let backed = history?.backedContent ?? []
|
||||
let residue = CommentThread.trashedCommentIDs(inCard: folder).filter { commentID in
|
||||
!backed.contains(.trashedComment(commentID, inCard: id))
|
||||
}
|
||||
heals.run(
|
||||
.commentTrashResidue,
|
||||
signature: Set(residue.map { "comment-trash:\(card.path)/\($0.rawValue)" }),
|
||||
on: self
|
||||
) { () throws(BoardWriteError) -> Void in
|
||||
_ = try BoardWriter.purgeCommentTrash(inCard: folder)
|
||||
for commentID in residue {
|
||||
try BoardWriter.purgeItem(at: CommentThread.trashedCommentFolder(commentID, inCard: folder))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,8 +76,15 @@ enum AgentGuide {
|
||||
/// 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.
|
||||
static let version = 9
|
||||
/// 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.
|
||||
static let version = 10
|
||||
|
||||
// MARK: - The version marker
|
||||
|
||||
@@ -367,7 +374,8 @@ enum AgentGuide {
|
||||
## Reading the board
|
||||
|
||||
- Lanes run left→right by ascending `order`; cards top→bottom by ascending
|
||||
`order` within their lane. Ties break by folder name.
|
||||
`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.
|
||||
@@ -376,13 +384,15 @@ enum AgentGuide {
|
||||
|
||||
## Frontmatter
|
||||
|
||||
All levels: `schema` (required, always `1`), `title` (optional — an item
|
||||
without one renders as untitled, so give cards real titles), `created` and
|
||||
`modified` (ISO-8601 with timezone, e.g. `2026-07-24T18:00:00Z`),
|
||||
`background` (color), `icon` (SF Symbol name), `iconColor` (color, tints
|
||||
`icon`). Lanes and cards additionally require `order` (a number; floats are
|
||||
fine). Lanes may set `width` (integer ≥ 1, multiplier of the standard lane
|
||||
width).
|
||||
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).
|
||||
|
||||
**Quote any `title` containing a colon** — `title: Fix: the thing` is
|
||||
invalid YAML; write `title: "Fix: the thing"`. The same goes for any value
|
||||
@@ -418,9 +428,7 @@ enum AgentGuide {
|
||||
|
||||
## Creating a card
|
||||
|
||||
1. Pick the lane folder. Compute `order`: bottom of the lane = max existing
|
||||
card `order` + 1024; top = min − 1024; between two cards = their
|
||||
midpoint. (Empty lane: any number, conventionally 1024.)
|
||||
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`):
|
||||
@@ -438,8 +446,31 @@ enum AgentGuide {
|
||||
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).
|
||||
`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
|
||||
@@ -508,10 +539,11 @@ enum AgentGuide {
|
||||
|
||||
## Hard rules (the app fails loudly on violations)
|
||||
|
||||
- Frontmatter must parse as YAML; `schema` (plus `order` on lanes and
|
||||
cards) is required. Keep `schema: 1`. The classic violations are an
|
||||
unquoted colon in a title and a quoted value left unclosed across a
|
||||
line break (see Frontmatter above).
|
||||
- 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.
|
||||
|
||||
@@ -30,9 +30,9 @@ import os
|
||||
/// - `attachmentNames(in:)` — `attachments/`, feeding `Card.attachments`. The board window's face
|
||||
/// needs it before a card window exists (the quiet paperclip indicator — 03-board-ui.md § Card
|
||||
/// face), and the snapshot is where it reads from.
|
||||
/// - `looseFileNames(in:)` — the card folder *itself*, feeding `LoadResult.looseCardFiles`. This is
|
||||
/// the loose-file carve-out's **detection** half (01-storage-format.md § Fractal layout ▸ Rules,
|
||||
/// settled 2026-07-28): a regular file sitting beside a card's `index.md` belongs in
|
||||
/// - `looseFileNames(in:ignoring:)` — the card folder *itself*, feeding `LoadResult.looseCardFiles`.
|
||||
/// This is the loose-file carve-out's **detection** half (01-storage-format.md § Fractal layout ▸
|
||||
/// Rules, settled 2026-07-28): a regular file sitting beside a card's `index.md` belongs in
|
||||
/// `attachments/`, and the app relocates it. Detection stays read-only *here* — this loader is a
|
||||
/// pure function of the tree and writes nothing, ever (the Repair precedent); the relocation is a
|
||||
/// Writer-mediated app write the store schedules off the snapshot
|
||||
@@ -40,6 +40,10 @@ import os
|
||||
///
|
||||
/// Everything else about a card folder's contents remains outside this loader's business.
|
||||
///
|
||||
/// **A third read, once per walk rather than per card**: the board root's `.gitignore`, which is the
|
||||
/// noise gate the detection above obeys (§ Rules, ruled 2026-07-31 — `ignoreRules(atBoardRoot:)`). A
|
||||
/// file it matches is not a defect at all; it keeps the ordinary stray posture.
|
||||
///
|
||||
/// Symlinks: a lane/card candidate that is itself a symlink is treated as a stray and never
|
||||
/// followed, whether it points to a file or a directory — this loader does not resolve
|
||||
/// cross-volume or cyclic trees.
|
||||
@@ -49,7 +53,7 @@ import os
|
||||
/// `<root>/.trash/` is a **reserved, app-claimed board-root name** holding card *and lane* folders
|
||||
/// interleaved directly (01-storage-format.md § Deletion, resettled 2026-07-28; lanes joined
|
||||
/// 2026-07-29) — no `index.md` of its own. The walk therefore treats it as a second container beside
|
||||
/// the lanes, parsing its UUID-shaped children with the same fail-fast on `schema`/`order` and the
|
||||
/// the lanes, parsing its UUID-shaped children through the same `schema`/`order` rulebook and the
|
||||
/// same skip-and-warn rules the lane walk uses. Being reserved, it is **never a stray** and never
|
||||
/// warns; absent, the trash is simply empty.
|
||||
///
|
||||
@@ -142,8 +146,41 @@ public enum BoardLoader: Sendable {
|
||||
/// table itself is `IntegrityRules`', with every other reserved name.
|
||||
static let reservedCardChildNames: Set<String> = IntegrityRules.reservedCardChildNames
|
||||
|
||||
/// The board's noise definition, at board root (01-storage-format.md § Fractal layout ▸ Rules,
|
||||
/// ruled 2026-07-31). The name is `IntegrityRules`', with the rest of the claimed names.
|
||||
static let gitignoreFileName = IntegrityRules.gitignoreFileName
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "loader")
|
||||
|
||||
// MARK: - The noise gate
|
||||
|
||||
/// **The board-root `.gitignore`, parsed** — the one file the loose-file carve-out consults
|
||||
/// before calling anything relocatable (01-storage-format.md § Fractal layout ▸ Rules, ruled
|
||||
/// 2026-07-31: "`.gitignore` is the noise gate").
|
||||
///
|
||||
/// Read **once per walk**, at the top of `load`, and handed down to every card: a board with 400
|
||||
/// cards reads this file once, not 400 times. `nil` when the board has none — and `nil` and an
|
||||
/// empty file mean exactly the same thing to every caller (nothing is excluded), which is the
|
||||
/// ruling's own escape hatch working by construction rather than by a branch.
|
||||
///
|
||||
/// **Board root only.** "Nested `.gitignore` files are ordinary strays the heal never consults" —
|
||||
/// a `.gitignore` inside a lane or a card is a file like any other, and one inside a card is
|
||||
/// itself a hidden entry the carve-out never touches.
|
||||
///
|
||||
/// Reading it here does not make the loader impure: this is a read like `index.md`'s, on a file
|
||||
/// the walk's result depends on. Nothing is written, and an unreadable or non-UTF-8 file answers
|
||||
/// `nil` — "cannot tell" reads as "excludes nothing", which is the direction that loses no file
|
||||
/// (an unrelocated stray stays exactly where it is; the alternative would silently move things
|
||||
/// the user meant to be noise).
|
||||
static func ignoreRules(atBoardRoot root: URL) -> GitignoreRules? {
|
||||
guard let data = try? Data(contentsOf: root.appendingPathComponent(gitignoreFileName)),
|
||||
let text = String(data: data, encoding: .utf8)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return GitignoreRules(parsing: text)
|
||||
}
|
||||
|
||||
// MARK: - Entry point
|
||||
|
||||
public static func load(
|
||||
@@ -157,7 +194,10 @@ public enum BoardLoader: Sendable {
|
||||
throw BoardLoadError(path: indexFileName, reason: .boardRootMissingIndex)
|
||||
}
|
||||
let boardDocument = try readDocument(at: boardIndexURL, path: indexFileName)
|
||||
let boardSchema = try validatedSchema(in: boardDocument, path: indexFileName)
|
||||
// **The root's own `schema` stays required** (01-storage-format.md § Malformed input,
|
||||
// re-ruled 2026-07-31): it is the this-really-is-a-board gate, and the one `schema` on the
|
||||
// board that does not read as 1 when absent.
|
||||
let boardSchema = try validatedRootSchema(in: boardDocument, path: indexFileName)
|
||||
|
||||
var warnings: [LoadWarning] = []
|
||||
func warn(_ warning: LoadWarning) {
|
||||
@@ -182,8 +222,18 @@ public enum BoardLoader: Sendable {
|
||||
// `logger.info`, not `warning`: the value rendered as its default, nothing is degraded, and the
|
||||
// line exists to be findable later rather than to be noticed now ("no banner, no behavior
|
||||
// change").
|
||||
func noteCoercions(in document: FrontmatterDocument, at path: String) {
|
||||
let fields = document.coercedFields
|
||||
//
|
||||
// `plus` carries the records only the rulebook can make: `order` and `schema` below the root
|
||||
// (re-ruled 2026-07-31 — the optional-key readings). They cannot come from
|
||||
// `document.coercedFields`, which reads *present* fields that had no sensible reading and by
|
||||
// construction knows nothing about a key that is simply absent, nor about which level the
|
||||
// file sits at.
|
||||
func noteCoercions(
|
||||
in document: FrontmatterDocument,
|
||||
at path: String,
|
||||
plus extra: [CoercedField] = []
|
||||
) {
|
||||
let fields = extra + document.coercedFields
|
||||
guard !fields.isEmpty else { return }
|
||||
defects.append(.coercedFrontmatter(CoercedFrontmatter(path: path, fields: fields)))
|
||||
for field in fields {
|
||||
@@ -214,6 +264,11 @@ public enum BoardLoader: Sendable {
|
||||
|
||||
noteCoercions(in: boardDocument, at: indexFileName)
|
||||
|
||||
// **The noise gate, read once for the whole walk** (01-storage-format.md § Fractal layout ▸
|
||||
// Rules, ruled 2026-07-31): the board's `.gitignore` is what decides which loose files are
|
||||
// work and which are ordinary strays, and it is one file per board — not one per card.
|
||||
let ignoreRules = ignoreRules(atBoardRoot: boardRoot)
|
||||
|
||||
// Every lane the walk read, in folder order — **not** `Lane` values yet. The board-wide
|
||||
// identity dedupe below decides which folders render at all, and a `Lane` is built only on
|
||||
// the far side of that decision, because a `Lane` carrying a withheld card would be exactly
|
||||
@@ -236,11 +291,18 @@ public enum BoardLoader: Sendable {
|
||||
|
||||
let lanePath = laneName + "/" + indexFileName
|
||||
let laneDocument = try readDocument(at: laneURL.appendingPathComponent(indexFileName), path: lanePath)
|
||||
let laneSchema = try validatedSchema(in: laneDocument, path: lanePath)
|
||||
let laneOrder = try validatedOrder(in: laneDocument, path: lanePath)
|
||||
noteCoercions(in: laneDocument, at: lanePath)
|
||||
// Below the root both keys are optional (re-ruled 2026-07-31): a missing `schema` reads
|
||||
// as 1, a missing or unusable `order` as append-at-end. Both readings are coerce-tier —
|
||||
// recorded here, logged, and acted on by nothing until the file's next Writer touch.
|
||||
let laneSchema = try resolvedSchema(in: laneDocument, path: lanePath)
|
||||
let laneOrder = IntegrityRules.resolvedOrder(in: laneDocument)
|
||||
noteCoercions(
|
||||
in: laneDocument,
|
||||
at: lanePath,
|
||||
plus: [laneSchema.coerced, laneOrder.coerced].compactMap { $0 }
|
||||
)
|
||||
|
||||
var cards: [Card] = []
|
||||
var walkedCards: [WalkedCard] = []
|
||||
for cardURL in try directoryCandidates(in: laneURL) {
|
||||
let cardName = cardURL.lastPathComponent
|
||||
let cardRelPath = laneName + "/" + cardName
|
||||
@@ -254,7 +316,7 @@ public enum BoardLoader: Sendable {
|
||||
}
|
||||
|
||||
let card = try parseCard(at: cardURL, path: cardRelPath)
|
||||
noteCoercions(in: card.document, at: cardRelPath + "/" + indexFileName)
|
||||
noteCoercions(in: card.document, at: cardRelPath + "/" + indexFileName, plus: card.coercions)
|
||||
|
||||
// **The card-level claimed name** (01-storage-format.md § Fractal layout ▸ Rules,
|
||||
// extended 2026-07-29 — "the rule is level-uniform"): a file or symlink wearing
|
||||
@@ -269,8 +331,11 @@ public enum BoardLoader: Sendable {
|
||||
)
|
||||
}
|
||||
|
||||
// Noticed, never acted on: the relocation is the store's, through the Writer.
|
||||
let loose = looseFileNames(in: cardURL)
|
||||
// Noticed, never acted on: the relocation is the store's, through the Writer. What
|
||||
// the board's `.gitignore` excludes never becomes a defect at all — it keeps the
|
||||
// stray posture, so there is nothing here for the store to schedule and nothing for
|
||||
// a banner to announce.
|
||||
let loose = looseFileNames(in: cardURL, ignoring: ignoreRules)
|
||||
if !loose.isEmpty {
|
||||
defects.append(.looseCardFiles(LooseCardFiles(
|
||||
laneID: ItemID(rawValue: laneName),
|
||||
@@ -292,9 +357,18 @@ public enum BoardLoader: Sendable {
|
||||
logger.info("\(cardRelPath, privacy: .public): legacy 'deleted' key — card to be relocated into \(trashFolderName, privacy: .public)/")
|
||||
}
|
||||
|
||||
cards.append(card)
|
||||
walkedCards.append(card)
|
||||
}
|
||||
|
||||
// **The append-at-end reading, settled per container** (01-storage-format.md § Ordering,
|
||||
// re-ruled 2026-07-31): every card's rank is the one it wrote down, or — where it wrote
|
||||
// none — a materialized rank past every ordered sibling, ordered among the other
|
||||
// order-less ones by folder name. Settled here, over the lane's whole card list, because
|
||||
// that is the smallest scope in which "past every ordered sibling" is a fact.
|
||||
let cardOrders = Ranks.resolvedOrders(
|
||||
of: walkedCards, stored: \.storedOrder, name: { $0.id.rawValue })
|
||||
let cards = zip(walkedCards, cardOrders).map { $0.rendered(order: $1) }
|
||||
|
||||
// **A lane's legacy `deleted:` is tolerate-tier, not work** (01-storage-format.md
|
||||
// § Deletion, lane clause re-ruled 2026-07-29): "a lane carrying `deleted:` simply loads
|
||||
// live with the key ignored — no migration machinery, no key-strip write, no notice
|
||||
@@ -307,20 +381,34 @@ public enum BoardLoader: Sendable {
|
||||
|
||||
walkedLanes.append(WalkedLane(
|
||||
name: laneName,
|
||||
schema: laneSchema,
|
||||
order: laneOrder,
|
||||
schema: laneSchema.schema,
|
||||
storedOrder: laneOrder.order,
|
||||
document: laneDocument,
|
||||
cards: Ranks.sortedForDisplay(cards, order: \.order, name: { $0.id.rawValue })
|
||||
))
|
||||
}
|
||||
|
||||
var trash: [Card] = []
|
||||
var trashedLanes: [TrashedLane] = []
|
||||
/// Every trash entry as the dedupe needs it, kind-blind — the container is one flat list to
|
||||
/// the identity rule, whatever the snapshot splits it into. Carried with the two keys the
|
||||
/// container's order is stated in (`Ranks.isOrderedForTrash`), never `order`: the trash is
|
||||
/// sorted by `modified` descending since 2026-07-31.
|
||||
var trashEntries: [(id: ItemID, title: String?, modified: Date?)] = []
|
||||
// The strip's own append-at-end reading — the card rule one level up, and the reason the
|
||||
// lane loop above carried a *stored* order: a lane with no rank sorts past every ranked one,
|
||||
// which cannot be known until the last lane folder has been read.
|
||||
for (index, order) in Ranks.resolvedOrders(
|
||||
of: walkedLanes, stored: \.storedOrder, name: \.name
|
||||
).enumerated() {
|
||||
walkedLanes[index].order = order
|
||||
}
|
||||
|
||||
/// Every trash entry as the walk read it, kind decided but rank not yet — the `WalkedCard`
|
||||
/// intermediate's job in the flat container, in a tuple because the split into the snapshot's
|
||||
/// two arrays happens on the far side of the append-at-end reading.
|
||||
var walkedTrash: [(
|
||||
id: ItemID,
|
||||
kind: IntegrityRules.ObjectKind,
|
||||
schema: Int,
|
||||
storedOrder: Double?,
|
||||
heldCards: Int,
|
||||
attachments: [String],
|
||||
document: FrontmatterDocument
|
||||
)] = []
|
||||
var trashKinds: [ItemID: IntegrityRules.ObjectKind] = [:]
|
||||
for entryURL in trashCandidates(in: boardRoot) {
|
||||
let entryName = entryURL.lastPathComponent
|
||||
@@ -333,14 +421,18 @@ public enum BoardLoader: Sendable {
|
||||
warn(.missingIndex(path: entryRelPath))
|
||||
continue
|
||||
}
|
||||
// The two kinds are validated identically — `schema` and `order` are required of both
|
||||
// (`IntegrityRules.requiresOrder`) — so the strict parse happens once, before the
|
||||
// discriminator, and a malformed entry fails fast whichever kind it turns out to be.
|
||||
// The two kinds are read identically — the same optional-`schema`/optional-`order`
|
||||
// rulebook below the root — so the parse happens once, before the discriminator, and a
|
||||
// schema newer than this app fails fast whichever kind the entry turns out to be.
|
||||
let entryPath = entryRelPath + "/" + indexFileName
|
||||
let document = try readDocument(at: entryURL.appendingPathComponent(indexFileName), path: entryPath)
|
||||
let schema = try validatedSchema(in: document, path: entryPath)
|
||||
let order = try validatedOrder(in: document, path: entryPath)
|
||||
noteCoercions(in: document, at: entryPath)
|
||||
let schema = try resolvedSchema(in: document, path: entryPath)
|
||||
let order = IntegrityRules.resolvedOrder(in: document)
|
||||
noteCoercions(
|
||||
in: document,
|
||||
at: entryPath,
|
||||
plus: [schema.coerced, order.coerced].compactMap { $0 }
|
||||
)
|
||||
|
||||
// **The trash's discriminator, applied where the flat container needs it**
|
||||
// (01-storage-format.md § Deletion, re-ruled 2026-07-29): the *value* is trusted
|
||||
@@ -362,30 +454,60 @@ public enum BoardLoader: Sendable {
|
||||
)
|
||||
let id = ItemID(rawValue: entryName)
|
||||
trashKinds[id] = kind
|
||||
trashEntries.append((id: id, title: document.title.value, modified: document.modified.value))
|
||||
|
||||
switch kind {
|
||||
// **The subtree is counted, never walked** (03-board-ui.md § Trash: an opaque unit
|
||||
// showing its title and held-card count). The count is the same listing the shape
|
||||
// fallback asks for, so a `kind: lane` entry pays for exactly one directory read and a
|
||||
// kindless one pays for none extra — and a card pays for its attachment listing only,
|
||||
// which is why each side is read under its own arm rather than unconditionally.
|
||||
//
|
||||
// Neither `kind: board` nor `kind: comment` reaches here as itself — `trashKind` treats
|
||||
// both as unrecognized and answers by shape — so the non-lane arm is the card answer and
|
||||
// nothing else.
|
||||
let isLane = kind == .lane
|
||||
walkedTrash.append((
|
||||
id: id,
|
||||
kind: kind,
|
||||
schema: schema.schema,
|
||||
storedOrder: order.order,
|
||||
heldCards: isLane ? children().count : 0,
|
||||
attachments: isLane ? [] : attachmentNames(in: entryURL),
|
||||
document: document
|
||||
))
|
||||
}
|
||||
|
||||
// The trash's own append-at-end reading, over the container as one flat list. `order` decides
|
||||
// nothing about where a trash row *sits* — that is `modified`'s job since 2026-07-31 — but
|
||||
// every entry carries a rank for its eventual restore, and an entry that carries none reads
|
||||
// like every other order-less file rather than getting a container-specific rule of its own.
|
||||
var trash: [Card] = []
|
||||
var trashedLanes: [TrashedLane] = []
|
||||
/// Every trash entry as the dedupe needs it, kind-blind — the container is one flat list to
|
||||
/// the identity rule, whatever the snapshot splits it into. Carried with the two keys the
|
||||
/// container's order is stated in (`Ranks.isOrderedForTrash`), never `order`: the trash is
|
||||
/// sorted by `modified` descending since 2026-07-31.
|
||||
var trashEntries: [(id: ItemID, title: String?, modified: Date?)] = []
|
||||
let trashOrders = Ranks.resolvedOrders(
|
||||
of: walkedTrash, stored: { $0.storedOrder }, name: { $0.id.rawValue })
|
||||
for (entry, order) in zip(walkedTrash, trashOrders) {
|
||||
let document = entry.document
|
||||
trashEntries.append((
|
||||
id: entry.id, title: document.title.value, modified: document.modified.value))
|
||||
switch entry.kind {
|
||||
case .lane:
|
||||
// **The subtree is counted, never walked** (03-board-ui.md § Trash: an opaque unit
|
||||
// showing its title and held-card count). The count is the same listing the shape
|
||||
// fallback asks for, so a `kind: lane` entry pays for exactly one directory read and
|
||||
// a kindless one pays for none extra.
|
||||
trashedLanes.append(TrashedLane(
|
||||
id: id,
|
||||
schema: schema,
|
||||
id: entry.id,
|
||||
schema: entry.schema,
|
||||
title: document.title,
|
||||
modified: document.modified,
|
||||
order: order,
|
||||
heldCards: children().count,
|
||||
heldCards: entry.heldCards,
|
||||
document: document
|
||||
))
|
||||
case .card, .board, .comment:
|
||||
// Neither `kind: board` nor `kind: comment` reaches here as itself — `trashKind`
|
||||
// treats both as unrecognized and answers by shape — so this arm is the card answer
|
||||
// and nothing else.
|
||||
trash.append(Card(
|
||||
id: id,
|
||||
schema: schema,
|
||||
id: entry.id,
|
||||
schema: entry.schema,
|
||||
title: document.title,
|
||||
created: document.created,
|
||||
modified: document.modified,
|
||||
@@ -395,7 +517,7 @@ public enum BoardLoader: Sendable {
|
||||
icon: document.icon,
|
||||
iconColor: document.iconColor,
|
||||
order: order,
|
||||
attachments: attachmentNames(in: entryURL),
|
||||
attachments: entry.attachments,
|
||||
document: document
|
||||
))
|
||||
}
|
||||
@@ -579,7 +701,13 @@ public enum BoardLoader: Sendable {
|
||||
private struct WalkedLane {
|
||||
let name: String
|
||||
let schema: Int
|
||||
let order: Double
|
||||
/// The rank the file actually carries, `nil` where it carries none this app can use.
|
||||
let storedOrder: Double?
|
||||
/// The append-at-end reading, filled in once every sibling lane has been read
|
||||
/// (`Ranks.resolvedOrders(of:stored:name:)` — 01-storage-format.md § Ordering, re-ruled
|
||||
/// 2026-07-31). A `var` on a private walk value for the same reason the type exists at all:
|
||||
/// the container settles it, and the `Lane` is built on the far side of that.
|
||||
var order: Double = 0
|
||||
let document: FrontmatterDocument
|
||||
/// Already in display order — the traversal the dedupe's last tie-break is stated in.
|
||||
let cards: [Card]
|
||||
@@ -648,35 +776,67 @@ public enum BoardLoader: Sendable {
|
||||
return children.filter { isUUIDShaped($0.lastPathComponent) && hasIndex($0) }
|
||||
}
|
||||
|
||||
/// One card folder read into a `Card` — the lane walk's card parse.
|
||||
/// One card as the walk read it, before its container's ranks resolved — a `Card` minus the
|
||||
/// append-at-end reading, which cannot be settled until every sibling's stored `order` is known
|
||||
/// (01-storage-format.md § Ordering, re-ruled 2026-07-31). `WalkedLane`'s shape, one level down
|
||||
/// and for the same kind of reason: a value the container decides is not a value the item can
|
||||
/// carry while it is still being read.
|
||||
private struct WalkedCard {
|
||||
let id: ItemID
|
||||
let schema: Int
|
||||
/// The rank the file actually carries, `nil` where it carries none this app can use — the
|
||||
/// input to `Ranks.resolvedOrders(of:stored:name:)`.
|
||||
let storedOrder: Double?
|
||||
let attachments: [String]
|
||||
let document: FrontmatterDocument
|
||||
/// This card's coerce-tier records for the strict fields, which only the rulebook can make
|
||||
/// (a missing key leaves no trace in `document.coercedFields`).
|
||||
let coercions: [CoercedField]
|
||||
|
||||
var title: FieldValue<String> { document.title }
|
||||
var isDeleted: Bool { !document.deleted.isMissing }
|
||||
|
||||
func rendered(order: Double) -> Card {
|
||||
Card(
|
||||
id: id,
|
||||
schema: schema,
|
||||
title: document.title,
|
||||
created: document.created,
|
||||
modified: document.modified,
|
||||
modifiedBy: document.modifiedBy,
|
||||
deleted: document.deleted,
|
||||
background: document.background,
|
||||
icon: document.icon,
|
||||
iconColor: document.iconColor,
|
||||
order: order,
|
||||
attachments: attachments,
|
||||
document: document
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// One card folder read into a `WalkedCard` — the lane walk's card parse.
|
||||
///
|
||||
/// The trash's own walk reads its entries inline instead, because the container is flat and its
|
||||
/// kind is `kind:`'s to answer before a `Card` can be built at all: the two share their strict
|
||||
/// `schema`/`order` validation (`IntegrityRules`' rulebook, one rule) rather than sharing a
|
||||
/// function that has already decided what it is reading.
|
||||
/// kind is `kind:`'s to answer before a `Card` can be built at all: the two share their
|
||||
/// `schema`/`order` rulebook (`IntegrityRules`, one rule) rather than sharing a function that
|
||||
/// has already decided what it is reading.
|
||||
///
|
||||
/// `path` is root-relative and names the *folder*; the errors this throws name its `index.md`.
|
||||
/// Callers guard `isUUIDShaped` and `hasIndex` first, exactly as the lane walk always has.
|
||||
private static func parseCard(at cardURL: URL, path: String) throws(BoardLoadError) -> Card {
|
||||
private static func parseCard(at cardURL: URL, path: String) throws(BoardLoadError) -> WalkedCard {
|
||||
let cardPath = path + "/" + indexFileName
|
||||
let document = try readDocument(at: cardURL.appendingPathComponent(indexFileName), path: cardPath)
|
||||
let schema = try validatedSchema(in: document, path: cardPath)
|
||||
let order = try validatedOrder(in: document, path: cardPath)
|
||||
let schema = try resolvedSchema(in: document, path: cardPath)
|
||||
let order = IntegrityRules.resolvedOrder(in: document)
|
||||
|
||||
return Card(
|
||||
return WalkedCard(
|
||||
id: ItemID(rawValue: cardURL.lastPathComponent),
|
||||
schema: schema,
|
||||
title: document.title,
|
||||
created: document.created,
|
||||
modified: document.modified,
|
||||
modifiedBy: document.modifiedBy,
|
||||
deleted: document.deleted,
|
||||
background: document.background,
|
||||
icon: document.icon,
|
||||
iconColor: document.iconColor,
|
||||
order: order,
|
||||
schema: schema.schema,
|
||||
storedOrder: order.order,
|
||||
attachments: attachmentNames(in: cardURL),
|
||||
document: document
|
||||
document: document,
|
||||
coercions: [schema.coerced, order.coerced].compactMap { $0 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -782,7 +942,7 @@ public enum BoardLoader: Sendable {
|
||||
/// relocation is `BoardWriter.relocateLooseFiles`, run through the store's write bracket. A load
|
||||
/// is a pure function of the tree and stays one.
|
||||
///
|
||||
/// Four exclusions, three of them `attachmentNames(in:)`' own and for its reasons:
|
||||
/// Five exclusions, three of them `attachmentNames(in:)`' own and for its reasons:
|
||||
///
|
||||
/// - **Directories.** The carve-out is exactly *files*. A stray folder in a card — a nested
|
||||
/// clone, a hand-made subfolder — keeps the verbatim posture, because "relocating a directory
|
||||
@@ -795,14 +955,32 @@ public enum BoardLoader: Sendable {
|
||||
/// would surface it in a card's attachment list — the loudest possible way to be wrong about
|
||||
/// a file nobody wrote on purpose. It is also what keeps a crashed write's dot-prefixed
|
||||
/// residue out of the relocation.
|
||||
///
|
||||
/// **This survived the noise-gate ruling** (2026-07-31), which retired "nothing hardcoded"
|
||||
/// exclusions in favour of the seeded `.gitignore`, because it is not a noise heuristic and
|
||||
/// never was a `.DS_Store` special case: `attachmentNames(in:)` skips hidden entries too, so a
|
||||
/// relocated hidden file would land in a folder whose listing can never show it — the move
|
||||
/// would take a file the user can see in Finder beside `index.md` and put it somewhere the app
|
||||
/// is structurally unable to surface. The carve-out exists to put a card's files where the app
|
||||
/// *shows* them; a hidden file has no such destination. What the ruling retires is the app
|
||||
/// having a second opinion about *which* visible files are noise, and it never had one.
|
||||
/// - **The reserved card-level names** (`reservedCardChildNames`), case-insensitively.
|
||||
/// - **Whatever the board's `.gitignore` excludes** (§ Rules, ruled 2026-07-31 — the noise
|
||||
/// gate): "a file matching the board-root `.gitignore` … keeps the ordinary stray posture:
|
||||
/// skipped, preserved verbatim, logged, never relocated, never announced". Matched against the
|
||||
/// file's **board-relative** path (`<lane>/<card>/<name>`), because that is the path git would
|
||||
/// match and because an anchored pattern (`/notes.txt`) has to mean the board root rather than
|
||||
/// every card in it. `rules` is `nil` on a board that carries no such file, which excludes
|
||||
/// nothing — the pre-ruling behaviour, and the same answer an empty file gives.
|
||||
///
|
||||
/// Finder order (`localizedStandardCompare`), like every other name listing here, so the notice
|
||||
/// the store posts names files the way the board would sort them.
|
||||
///
|
||||
/// Failure is silent (`[]`): a permissions race here must never be the reason a board refuses
|
||||
/// to open, and "nothing to relocate" is the safe reading of "cannot tell".
|
||||
static func looseFileNames(in cardFolder: URL) -> [String] {
|
||||
/// `rules` has no default on purpose: the gate is the ruling, and a caller that could forget to
|
||||
/// pass it would be a second, quieter definition of what counts as noise.
|
||||
static func looseFileNames(in cardFolder: URL, ignoring rules: GitignoreRules?) -> [String] {
|
||||
guard let entries = try? FileManager.default.contentsOfDirectory(
|
||||
at: cardFolder,
|
||||
includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey],
|
||||
@@ -811,19 +989,40 @@ public enum BoardLoader: Sendable {
|
||||
return []
|
||||
}
|
||||
|
||||
let cardPath = boardRelativeCardPath(of: cardFolder)
|
||||
return entries
|
||||
.filter { url in
|
||||
guard !reservedCardChildNames.contains(url.lastPathComponent.lowercased()),
|
||||
let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey])
|
||||
let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]),
|
||||
values.isRegularFile == true, values.isSymbolicLink != true
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return values.isRegularFile == true && values.isSymbolicLink != true
|
||||
// The noise gate. Logged rather than silent, because "skipped, preserved verbatim,
|
||||
// logged" is the stray posture this file is being *given*, and a file that stays put
|
||||
// for a reason nobody can see is the one the user files a bug about.
|
||||
let name = url.lastPathComponent
|
||||
guard rules?.isIgnored(relativePath: cardPath + "/" + name) == true else { return true }
|
||||
logger.info(
|
||||
"\(cardPath, privacy: .public)/\(name, privacy: .public): matched by the board's \(gitignoreFileName, privacy: .public) — left where it is, not relocated"
|
||||
)
|
||||
return false
|
||||
}
|
||||
.map(\.lastPathComponent)
|
||||
.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
|
||||
}
|
||||
|
||||
/// A card folder's board-relative path — `<lane>/<card>` — read off the URL rather than passed
|
||||
/// in, so the one caller that has no walk behind it (the Writer's import-boundary normalization)
|
||||
/// gets the same answer the walk would have given.
|
||||
///
|
||||
/// The depth is fixed by the schema: a card is `<root>/<lane>/<card>` and nothing else
|
||||
/// (`BoardWriter.checkIsCardFolder` enforces exactly this shape before any relocation), so two
|
||||
/// components are the whole of it.
|
||||
private static func boardRelativeCardPath(of cardFolder: URL) -> String {
|
||||
cardFolder.deletingLastPathComponent().lastPathComponent + "/" + cardFolder.lastPathComponent
|
||||
}
|
||||
|
||||
/// Whether `name` has a UUID's shape — hex, `8-4-4-4-12`, **any case and any version** —
|
||||
/// gating lane/card level detection (01-storage-format.md § Fractal layout ▸ Rules, "Name
|
||||
/// shape gates level detection"). This is *the* identity predicate, and it is deliberately
|
||||
@@ -943,11 +1142,13 @@ public enum BoardLoader: Sendable {
|
||||
/// (05-card-window.md ▸ Raw source outlet: "Apply validates through the same fail-fast parse the
|
||||
/// loader uses (detailed alert on error, stays in source mode) before writing byte-for-byte").
|
||||
///
|
||||
/// **Exactly the three checks `load(boardRoot:)` runs on a card**, in its order and through its
|
||||
/// own functions: decode + parse (`parseDocument`), then `schema` (present, well-formed, not
|
||||
/// newer than this app) and `order` (present, well-formed) — the two fields a card must carry.
|
||||
/// Nothing card-shaped is checked beyond that, because nothing else *is*: `title` is optional,
|
||||
/// unknown keys are the whole point of the outlet, and the body is free text.
|
||||
/// **Exactly the checks `load(boardRoot:)` runs on a card**, in its order and through its own
|
||||
/// functions: decode + parse (`parseDocument`), then `schema` at the below-the-root reading
|
||||
/// (missing reads as 1; malformed, or newer than this app, still refuses). Nothing card-shaped
|
||||
/// is checked beyond that, because nothing else *is*: `title` is optional, **`order` is optional
|
||||
/// too** since 2026-07-31 — a card applied without one lands at its lane's bottom and gains a
|
||||
/// rank on its next touch — unknown keys are the whole point of the outlet, and the body is free
|
||||
/// text.
|
||||
///
|
||||
/// It deliberately does **not** check `uneditableShape`: that refusal exists for surgical
|
||||
/// span edits (`BoardWriter.updateIndex`), and raw source replaces the whole file — a flow-mapping
|
||||
@@ -968,13 +1169,18 @@ public enum BoardLoader: Sendable {
|
||||
}
|
||||
|
||||
/// The per-field validators are `IntegrityRules`' — the rulebook (02-architecture.md ▸
|
||||
/// Components). These two forward so the walk above reads as it always did.
|
||||
private static func validatedSchema(in document: FrontmatterDocument, path: String) throws(BoardLoadError) -> Int {
|
||||
try IntegrityRules.validatedSchema(in: document, path: path, supportedSchema: supportedSchema)
|
||||
/// Components). These forward so the walk above reads as it always did.
|
||||
private static func validatedRootSchema(
|
||||
in document: FrontmatterDocument, path: String
|
||||
) throws(BoardLoadError) -> Int {
|
||||
try IntegrityRules.validatedRootSchema(in: document, path: path, supportedSchema: supportedSchema)
|
||||
}
|
||||
|
||||
private static func validatedOrder(in document: FrontmatterDocument, path: String) throws(BoardLoadError) -> Double {
|
||||
try IntegrityRules.validatedOrder(in: document, path: path)
|
||||
/// `schema` below the root — missing reads as 1, coerce-tier (re-ruled 2026-07-31).
|
||||
private static func resolvedSchema(
|
||||
in document: FrontmatterDocument, path: String
|
||||
) throws(BoardLoadError) -> (schema: Int, coerced: CoercedField?) {
|
||||
try IntegrityRules.resolvedSchema(in: document, path: path, supportedSchema: supportedSchema)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1153,11 +1359,23 @@ public struct BoardLoadError: Error, Sendable, Equatable, CustomStringConvertibl
|
||||
/// frontmatter block that isn't a mapping. `line` is 1-based within the file when the
|
||||
/// underlying error carries one.
|
||||
case unparseableYAML(message: String, line: Int?)
|
||||
/// **The board root's own missing `schema`** — the this-really-is-a-board gate (re-ruled
|
||||
/// 2026-07-31). Below the root a missing `schema` reads as 1 instead, coerce-tier.
|
||||
case missingSchema
|
||||
case malformedSchema(raw: String)
|
||||
/// `schema` is present, valid, and greater than this app's `supportedSchema`.
|
||||
case schemaNewerThanApp(found: Int)
|
||||
/// `order` is required on lanes and cards, never on the board itself.
|
||||
|
||||
/// **Retired, and nothing throws these any more** (01-storage-format.md § Ordering and
|
||||
/// § Malformed input, re-ruled 2026-07-31): below the board root a missing, null, non-numeric
|
||||
/// or non-finite `order` reads as append-at-end — coerce-tier, logged, bytes preserved
|
||||
/// (`IntegrityRules.resolvedOrder`) — and the board root never carried a rank to begin with.
|
||||
///
|
||||
/// They stay in the vocabulary rather than being deleted because this enum *is* the
|
||||
/// load-failure surface every banner, announcement and decision-surface row is written
|
||||
/// against, and a reason that can no longer occur costs those surfaces nothing while removing
|
||||
/// one would rewrite them for a rule that changed underneath, not for a shape they render
|
||||
/// differently.
|
||||
case missingOrder
|
||||
case malformedOrder(raw: String)
|
||||
/// The board root exists but is a file, not a directory.
|
||||
|
||||
@@ -169,11 +169,19 @@ public struct Lane: Identifiable, Sendable, Equatable {
|
||||
public let icon: FieldValue<String>
|
||||
public let iconColor: FieldValue<String>
|
||||
|
||||
/// Rank among lanes, ascending = left-to-right. Strict, per the frontmatter table's
|
||||
/// required field: `BoardLoader` fails the whole load (`missingOrder`/`malformedOrder`)
|
||||
/// rather than construct a `Lane` with a bad `order` — by the time one exists here it is
|
||||
/// always valid. Validity is the loader's job, not this type's; that is why it is a plain
|
||||
/// `Double` and not `FieldValue<Double>`.
|
||||
/// 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,
|
||||
@@ -222,8 +230,10 @@ public struct Card: Identifiable, Sendable, Equatable {
|
||||
public let icon: FieldValue<String>
|
||||
public let iconColor: FieldValue<String>
|
||||
|
||||
/// Rank within its lane, ascending = top-to-bottom. Strict — see `Lane.order`'s doc
|
||||
/// comment; the same reasoning applies here.
|
||||
/// 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
|
||||
@@ -291,8 +301,10 @@ public struct TrashedLane: Identifiable, Sendable, Equatable {
|
||||
/// 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 the same required, strictly validated field a live lane carries
|
||||
/// (`Lane.order`).
|
||||
/// (`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").
|
||||
|
||||
@@ -116,10 +116,33 @@ public enum BoardWriter: Sendable {
|
||||
let operation = operation.withTitle(document.title.value)
|
||||
try checkEditable(document, at: indexURL, operation: operation)
|
||||
|
||||
let orderBefore = document.order
|
||||
edits(&document)
|
||||
// After `edits`, so a caller that wrote its own `kind` is left alone, and before the stamps,
|
||||
// which outrank everything for their own reason.
|
||||
IntegrityRules.healOnTouch(&document, kind: kind ?? derivedKind(ofItemFolder: folder))
|
||||
|
||||
let itemKind = kind ?? derivedKind(ofItemFolder: folder)
|
||||
|
||||
// **The inline sibling stamp** (01-storage-format.md § Ordering, re-ruled 2026-07-31): a
|
||||
// write that rewrites this item's *rank* is placement math landing, and placement math ranks
|
||||
// against the append-at-end reading of any order-less sibling — so those siblings are made to
|
||||
// hold that reading, here, inside the caller's own bracket and commit. Sequence-preserving
|
||||
// and a no-op on every board this app wrote; see `stampAppendAtEndOrders`.
|
||||
//
|
||||
// Two exclusions. `.renumberChildren` *is* the rescale — it rewrites every sibling with a
|
||||
// fresh rank already, and re-entering here would recurse. Anything that is not a lane or a
|
||||
// card has no rank ladder to be placed in: a board root's siblings are other people's
|
||||
// folders, and a comment's are chronology.
|
||||
if document.order != orderBefore, operation != .renumberChildren, itemKind == .lane || itemKind == .card {
|
||||
try stampAppendAtEndOrders(
|
||||
of: folder.deletingLastPathComponent(), excluding: folder, operation: operation)
|
||||
}
|
||||
|
||||
// After `edits`, so a caller that wrote its own `kind` or `order` is left alone, and before
|
||||
// the stamps, which outrank everything for their own reason.
|
||||
IntegrityRules.healOnTouch(
|
||||
&document,
|
||||
kind: itemKind,
|
||||
rank: appendAtEndRank(ofItemFolder: folder, kind: itemKind)
|
||||
)
|
||||
// The reorders-don't-stamp predicate, read off the operation. An order-only rewrite restates
|
||||
// the container's own arrangement and leaves both provenance keys exactly as it found them —
|
||||
// a standing `modified-by` survives a reorder, which is the pairing 01 spells out.
|
||||
@@ -166,6 +189,51 @@ public enum BoardWriter: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// **The rank an order-less file reads as** — the on-touch stamp's value (01-storage-format.md
|
||||
/// § Ordering, re-ruled 2026-07-31: "The rank materializes on touch: the first Writer rewrite of
|
||||
/// the file stamps a real rank").
|
||||
///
|
||||
/// It is `Ranks.resolvedOrders`' answer for this folder among its siblings — the very rank the
|
||||
/// board is already rendering it at — so the stamp writes down what the reading already said and
|
||||
/// nothing moves on screen. The loader and this share the function, which is what makes that
|
||||
/// true rather than merely intended.
|
||||
///
|
||||
/// `nil` — stamp nothing — in four cases, each of them "this file has no rank to have":
|
||||
///
|
||||
/// - **The kind has no ladder**: a board root (no siblings), a comment ("Ordering is chronology,
|
||||
/// not ranks"), or a folder whose position has no answer at all.
|
||||
/// - **The sibling scan failed** (an unreadable neighbour, a parse error). An on-touch heal is
|
||||
/// opportunistic by construction — it rides a host write that has its own reason to succeed —
|
||||
/// so a failure here declines to stamp rather than failing that write. The next touch tries
|
||||
/// again.
|
||||
/// - **The item is not among its own visible siblings**: it carries a legacy `deleted:` key, and
|
||||
/// a tombstone is inert to ordering (§ Deletion). The migration relocates it; a rank invented
|
||||
/// on the way would be a fact about a position it no longer has.
|
||||
///
|
||||
/// Never called on a file that already carries a usable `order` — `healOnTouch` takes it as an
|
||||
/// `@autoclosure`, so the directory of reads below is paid for only where it is the answer.
|
||||
private static func appendAtEndRank(
|
||||
ofItemFolder folder: URL,
|
||||
kind: IntegrityRules.ObjectKind?
|
||||
) -> Double? {
|
||||
switch kind {
|
||||
case .lane, .card: break
|
||||
case .board, .comment, nil: return nil
|
||||
}
|
||||
// The operation is only what a failure would be *named*, and this one discards failures.
|
||||
guard let siblings = try? visibleSiblings(
|
||||
of: folder.deletingLastPathComponent(),
|
||||
operation: .renumberChildren,
|
||||
requireEditable: false
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
let identity = IntegrityRules.canonicalIdentity(folder.lastPathComponent)
|
||||
return siblings
|
||||
.first { IntegrityRules.canonicalIdentity($0.folder.lastPathComponent) == identity }?
|
||||
.order
|
||||
}
|
||||
|
||||
// MARK: - Atomic replace
|
||||
|
||||
/// Writes `text` over `fileURL` atomically: a hidden temp file in the **same directory**,
|
||||
@@ -241,6 +309,11 @@ public enum BoardWriter: Sendable {
|
||||
/// - Extension-less board folders are exactly as legal a target as a `.kanban`-suffixed one
|
||||
/// (§ Document packaging, "Extension-less board folders still open") — this call never
|
||||
/// looks at `rootURL`'s extension.
|
||||
/// - **The board is born with its noise definition** (06-history-undo.md ▸ Repository hygiene,
|
||||
/// re-ruled 2026-07-31: "Board creation writes the minimal seed"): `.gitignore` lands beside
|
||||
/// `index.md`, after it, so the file that makes a folder a board is written first and a failure
|
||||
/// to seed can never leave a half-made board. Seeding is `seedGitignoreIfAbsent`'s, so a
|
||||
/// creation into a folder that somehow already carries one leaves it alone.
|
||||
public static func createBoard(at rootURL: URL, title: String?) throws(BoardWriteError) {
|
||||
let operation = WriteOperation.createBoard
|
||||
let indexURL = rootURL.appendingPathComponent(BoardLoader.indexFileName)
|
||||
@@ -264,6 +337,7 @@ public enum BoardWriter: Sendable {
|
||||
at: indexURL,
|
||||
operation: operation
|
||||
)
|
||||
try seedGitignoreIfAbsent(atBoardRoot: rootURL)
|
||||
}
|
||||
|
||||
/// Creates a lane in a board: mints a fresh lowercase-UUIDv4 folder directly under
|
||||
@@ -308,7 +382,16 @@ public enum BoardWriter: Sendable {
|
||||
) throws(BoardWriteError) -> ItemID {
|
||||
try checkIsDirectory(parentFolder, describedAs: "parent folder", operation: operation)
|
||||
|
||||
let siblings = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: false)
|
||||
// **The inline sibling stamp, before the append** (01-storage-format.md § Ordering, re-ruled
|
||||
// 2026-07-31). A create is placement math like any other — "after the current visible
|
||||
// siblings" is a rank *relative to* the last of them — so an order-less sibling has to hold
|
||||
// the rank this append is computed against, or the new item would file itself above a card
|
||||
// that re-materializes past it on the next load. It is the one placement that cannot ride
|
||||
// `updateIndex`'s hook: this path mints a file rather than rewriting one.
|
||||
//
|
||||
// `excluding: nil` — the new identity does not exist yet, so there is nothing to skip, and
|
||||
// the container the stamp reads is exactly the one the append is about to be computed over.
|
||||
let siblings = try stampAppendAtEndOrders(of: parentFolder, excluding: nil, operation: operation)
|
||||
let order = Ranks.append(toVisible: siblings.map(\.order))
|
||||
|
||||
let folder = try mintUUIDFolder(in: parentFolder, operation: operation)
|
||||
@@ -428,6 +511,66 @@ public enum BoardWriter: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The board's noise definition
|
||||
|
||||
/// **The whole of the seeded `.gitignore`** (06-history-undo.md ▸ Repository hygiene, re-ruled
|
||||
/// 2026-07-31: "Board creation writes the minimal seed — `.DS_Store` plus the writer's temp
|
||||
/// pattern (`.*.lanework-*`)").
|
||||
///
|
||||
/// Two lines, and each one earns its place:
|
||||
///
|
||||
/// - **`.DS_Store`** — the Finder writes one into every folder a user looks at, and on a board
|
||||
/// that means one per lane and one per card, each churning as icons and window positions move.
|
||||
/// On a git board it would be committed by the whole-tree stage, forever, under the user's own
|
||||
/// name; on every board it is now also the plainest thing the loose-file heal must not treat as
|
||||
/// a card's attachment.
|
||||
/// - **`.*.lanework-*`** — this file's own temp pattern, and the one entry the app is entitled to
|
||||
/// have an opinion about: `atomicReplace` writes `.<name>.lanework-<uuid>` beside its target,
|
||||
/// so a crashed write's residue is the app's litter rather than the user's file. The two must
|
||||
/// stay in step; changing the temp name means changing this line.
|
||||
///
|
||||
/// Nothing else. "Fine-tuning what counts as noise over time means fine-tuning the seed" — and
|
||||
/// after the seed the file is the user's, never edited by the app again.
|
||||
///
|
||||
/// Files the app creates end with LF (01-storage-format.md § Encoding and line endings).
|
||||
public static let gitignoreSeed = ".DS_Store\n.*.lanework-*\n"
|
||||
|
||||
/// **Puts the seed on a board that has no `.gitignore`, and does nothing to one that has**
|
||||
/// (06-history-undo.md ▸ Repository hygiene, re-ruled 2026-07-31: "`.gitignore` seeded on every
|
||||
/// board, never touched after").
|
||||
///
|
||||
/// The three properties are the feature:
|
||||
///
|
||||
/// - **Only when the name is free.** `IntegrityRules.node(at:)` — `lstat`, never `fileExists` —
|
||||
/// so a *directory* or a **dangling symlink** wearing the name is left alone too, rather than
|
||||
/// written through. A board that already carries a `.gitignore` (from a template, a clone, the
|
||||
/// user, or an earlier open) is left byte for byte alone, and an **empty** one is honored
|
||||
/// exactly as it stands: it is the ruling's own escape hatch for wanting no exclusions.
|
||||
/// - **Never an edit.** There is no merge, no append, no "and while we're here". The app writes
|
||||
/// this file at most once per board and then reads it forever.
|
||||
/// - **Universal.** Board creation calls it, template instantiation calls it, and a scheduled
|
||||
/// heal calls it at every open for boards born before the ruling — repo-nested boards included,
|
||||
/// because the file serves the heal there, not any app-managed git.
|
||||
///
|
||||
/// A foreign deletion is answered by re-seeding on the next open, which is what makes the
|
||||
/// scheduled heal's memo (armed before the attempt, cleared on success) load-bearing rather than
|
||||
/// tidy: the picture "missing" must stay healable.
|
||||
///
|
||||
/// - Returns: whether this call wrote the file — `false` when the name was already taken, which
|
||||
/// is a success with nothing to do (the heal's disk re-verify, and the reason a second open
|
||||
/// costs no write).
|
||||
@discardableResult
|
||||
public static func seedGitignoreIfAbsent(atBoardRoot root: URL) throws(BoardWriteError) -> Bool {
|
||||
let url = root.appendingPathComponent(IntegrityRules.gitignoreFileName)
|
||||
guard IntegrityRules.node(at: url) == nil else { return false }
|
||||
try atomicReplace(text: gitignoreSeed, at: url, operation: .seedGitignore)
|
||||
// Heal-marked, on the agent guide's reasoning exactly: this is app-initiated work on a file
|
||||
// the user did not create, and its commit is its own (06-history-undo.md ▸ Commit messages —
|
||||
// a non-snapshot path composes "Update '.gitignore'").
|
||||
EchoLedger.current?.markHeal(at: url)
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Renumber
|
||||
|
||||
/// Renumbers a parent's visible children to whole multiples of 1024 — the renumber fallback
|
||||
@@ -442,7 +585,10 @@ public enum BoardWriter: Sendable {
|
||||
/// rewriting the lane.
|
||||
/// - **Display order is the assignment order** (`Ranks.isOrderedForDisplay`: `order`
|
||||
/// ascending, folder name breaking ties) — the same rule the loader sorts by, so a
|
||||
/// renumber is guaranteed to be sequence-preserving: nothing visibly moves.
|
||||
/// renumber is guaranteed to be sequence-preserving: nothing visibly moves. An order-less
|
||||
/// sibling takes part on its append-at-end reading (`visibleSiblings`) and comes out of the
|
||||
/// batch with a real rank, which makes the rescale the heavier sibling of
|
||||
/// `stampAppendAtEndOrders` rather than a second rule.
|
||||
/// - **Nothing is stamped.** A rescale is order-only, so no sibling's `modified` moves and no
|
||||
/// sibling's `modified-by` is cleared (01-storage-format.md § Ordering, verbatim: "order-only
|
||||
/// rewrites, so no `modified` stamp and no `modified-by` clear"). That falls out of
|
||||
@@ -478,20 +624,27 @@ public enum BoardWriter: Sendable {
|
||||
/// - **Strays are untouched**: non-UUID-shaped folders, and UUID-shaped folders without an
|
||||
/// `index.md` (an interrupted two-step create — the loader's own `.missingIndex` warning
|
||||
/// tolerates exactly this), are skipped here for the same reasons `BoardLoader` skips them.
|
||||
/// - A visible sibling's missing or malformed `order` fails the *whole* operation, naming
|
||||
/// that sibling's file, before anything is written — the same discover-before-you-write
|
||||
/// guarantee `renumberVisibleChildren`'s batch depends on.
|
||||
/// - **A missing or unusable `order` is no longer a failure** (01-storage-format.md § Ordering,
|
||||
/// re-ruled 2026-07-31): the sibling reads as append-at-end, exactly as the loader reads it
|
||||
/// (`Ranks.resolvedOrders(of:stored:name:)`), and `order` carries that reading while
|
||||
/// `hasStoredOrder` records whether the file actually says so. The scan is still strict about
|
||||
/// everything it was strict about before — an unreadable or unparseable sibling still fails the
|
||||
/// whole operation before anything is written, which is the discover-before-you-write guarantee
|
||||
/// `renumberVisibleChildren`'s batch depends on.
|
||||
/// - `requireEditable` scopes the readable-but-uneditable pre-flight to the caller that
|
||||
/// will actually *rewrite* the siblings: renumber passes `true` (it must not discover an
|
||||
/// unwritable sibling halfway through the batch), the creates pass `false` — a create
|
||||
/// only *reads* its siblings' orders, and a flow-mapping sibling that loads and renders
|
||||
/// normally (01-storage-format.md § Frontmatter) must not block creating a new item
|
||||
/// beside it.
|
||||
///
|
||||
/// The returned ranks are **in listing order, not display order**; every caller that needs the
|
||||
/// latter sorts through `Ranks.sortedForDisplay`, as they always did.
|
||||
private static func visibleSiblings(
|
||||
of parentFolder: URL,
|
||||
operation: WriteOperation,
|
||||
requireEditable: Bool
|
||||
) throws(BoardWriteError) -> [(folder: URL, order: Double)] {
|
||||
) throws(BoardWriteError) -> [(folder: URL, order: Double, hasStoredOrder: Bool)] {
|
||||
let candidates: [URL]
|
||||
do {
|
||||
candidates = try BoardLoader.directoryCandidates(in: parentFolder)
|
||||
@@ -503,7 +656,7 @@ public enum BoardWriter: Sendable {
|
||||
)
|
||||
}
|
||||
|
||||
var visible: [(folder: URL, order: Double)] = []
|
||||
var visible: [(folder: URL, storedOrder: Double?)] = []
|
||||
for folder in candidates where BoardLoader.isUUIDShaped(folder.lastPathComponent) {
|
||||
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
|
||||
guard FileManager.default.fileExists(atPath: indexURL.path) else { continue }
|
||||
@@ -513,25 +666,88 @@ public enum BoardWriter: Sendable {
|
||||
if requireEditable {
|
||||
try checkEditable(document, at: indexURL, operation: operation)
|
||||
}
|
||||
visible.append((folder: folder, storedOrder: IntegrityRules.resolvedOrder(in: document).order))
|
||||
}
|
||||
|
||||
switch document.order {
|
||||
case .missing:
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: indexURL.path,
|
||||
reason: .unreadable(message: "missing required 'order' field")
|
||||
)
|
||||
case let .malformed(raw):
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: indexURL.path,
|
||||
reason: .unreadable(message: "malformed 'order' field: \(raw)")
|
||||
)
|
||||
case let .valid(order):
|
||||
visible.append((folder: folder, order: order))
|
||||
// **The loader's own reading, computed from the same bytes** — which is what lets a placement
|
||||
// the app computed against the *snapshot* be made true on disk by simply stamping it
|
||||
// (`stampAppendAtEndOrders(of:excluding:operation:)`).
|
||||
let resolved = Ranks.resolvedOrders(
|
||||
of: visible, stored: { $0.storedOrder }, name: { $0.folder.lastPathComponent })
|
||||
return zip(visible, resolved).map {
|
||||
(folder: $0.folder, order: $1, hasStoredOrder: $0.storedOrder != nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The append-at-end reading's inline stamp
|
||||
|
||||
/// **Writes down the append-at-end reading for every order-less child of `parentFolder`**
|
||||
/// (01-storage-format.md § Ordering, re-ruled 2026-07-31: "placement math that must rank an item
|
||||
/// *relative to* an order-less sibling stamps that sibling inline, inside the gesture's bracket
|
||||
/// and commit — the midpoint-renumber precedent").
|
||||
///
|
||||
/// ## Why a placement cannot skip it
|
||||
///
|
||||
/// An order-less file renders at a rank nobody wrote down. Placement math ranks the arriving item
|
||||
/// against that rank — a midpoint between two order-less siblings, an append past the last one —
|
||||
/// and the answer is only *true on disk* if the siblings hold the ranks the math assumed. Without
|
||||
/// the stamp the arriving item would be filed against ranks that evaporate on the next load, and
|
||||
/// the order-less siblings would re-materialize past it: the card lands in the wrong place,
|
||||
/// silently. Stamping is what closes that gap, and it is the same shape as the renumber that
|
||||
/// closes the midpoint-exhaustion gap — inline, inside the caller's write bracket, composing no
|
||||
/// event of its own.
|
||||
///
|
||||
/// ## Why nothing moves when it lands
|
||||
///
|
||||
/// The value written is `Ranks.resolvedOrders`' answer for that file — the rank the board was
|
||||
/// *already rendering it at*. So this is sequence-preserving by construction, exactly like a
|
||||
/// renumber, and it is idempotent: run it twice and the second call finds nothing to do.
|
||||
///
|
||||
/// ## Cost, stated
|
||||
///
|
||||
/// One `index.md` read per visible sibling, on any write that rewrites a rank — the same read the
|
||||
/// reload following that write performs anyway. A board this app wrote has no order-less children
|
||||
/// at all, so the pass writes nothing; it is the hand-filed and agent-filed cases that pay.
|
||||
///
|
||||
/// ## The reading is the container's, and the caller must not narrow it
|
||||
///
|
||||
/// `excluding` skips the **write**, never the reading: the ranks stamped are resolved over the
|
||||
/// container exactly as the loader resolved them, placed item included. Narrowing the resolution
|
||||
/// would re-base the materialized ranks against a different maximum than the snapshot the
|
||||
/// placement was computed from, and the two would disagree about where the order-less siblings
|
||||
/// sit. The corollary is that a placement whose item is **not yet in the container** — a
|
||||
/// cross-parent move, a copy, a create — must stamp *before* the folder lands, so that what this
|
||||
/// reads is what the loader read. Each of those call sites does.
|
||||
///
|
||||
/// - Parameter excluding: the item the caller is about to place, when it is already a child here.
|
||||
/// It is skipped because its own rank is about to be written by the caller anyway, and stamping
|
||||
/// it first would be two writes to one file inside one gesture.
|
||||
/// - Returns: the container's visible siblings and their resolved ranks — the same values a
|
||||
/// re-scan would answer, so a caller that needs them next (the creates' append) can use these
|
||||
/// rather than reading the directory twice.
|
||||
@discardableResult
|
||||
static func stampAppendAtEndOrders(
|
||||
of parentFolder: URL,
|
||||
excluding placed: URL?,
|
||||
operation: WriteOperation
|
||||
) throws(BoardWriteError) -> [(folder: URL, order: Double, hasStoredOrder: Bool)] {
|
||||
// `requireEditable: false`: only the order-less siblings are rewritten, so only *they* owe
|
||||
// the pre-flight — a flow-mapping sibling that already carries a rank must not block a
|
||||
// placement it has nothing to do with. `updateIndex` refuses an uneditable one loudly, which
|
||||
// is the honest answer for a file the placement genuinely depends on.
|
||||
let siblings = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: false)
|
||||
let excluded = placed.map { IntegrityRules.canonicalIdentity($0.lastPathComponent) }
|
||||
|
||||
for sibling in siblings where !sibling.hasStoredOrder {
|
||||
guard IntegrityRules.canonicalIdentity(sibling.folder.lastPathComponent) != excluded else { continue }
|
||||
// `.renumberChildren`: this *is* a rescale of the container's own arrangement — order-only,
|
||||
// so no `modified` stamp and no `modified-by` clear (the reorders-don't-stamp rule), and
|
||||
// the one operation `updateIndex` does not re-enter this pass for.
|
||||
try updateIndex(inItemFolder: sibling.folder, operation: .renumberChildren) { document in
|
||||
document.set(FrontmatterKeys.order, to: .double(sibling.order))
|
||||
}
|
||||
}
|
||||
return visible
|
||||
return siblings
|
||||
}
|
||||
|
||||
// MARK: - Move
|
||||
@@ -655,6 +871,14 @@ public enum BoardWriter: Sendable {
|
||||
return MoveResult(id: ItemID(rawValue: sourceName), reminted: [])
|
||||
}
|
||||
|
||||
// **Before the folder lands** (01-storage-format.md § Ordering, re-ruled 2026-07-31): the
|
||||
// destination's order-less children are stamped with the reading they had *without* this
|
||||
// item in the container — which is the reading the caller's placement math was computed
|
||||
// against, since the snapshot it read did not hold this item here either. Doing it after the
|
||||
// move would re-base those ranks against the arriving item's own (foreign-lane) rank.
|
||||
// The same-parent degenerate reorder above needs none of this: it never leaves the container,
|
||||
// so `updateIndex`'s own hook stamps against the unchanged reading.
|
||||
try stampAppendAtEndOrders(of: destinationParent, excluding: nil, operation: operation)
|
||||
let rank = try destinationOrder(order, inParent: destinationParent, operation: operation)
|
||||
|
||||
let isImport = !isSameLocation(sourceBoardRoot, destinationBoardRoot)
|
||||
@@ -870,6 +1094,10 @@ public enum BoardWriter: Sendable {
|
||||
// contract refuses here, where nothing has been materialized and there is nothing to undo.
|
||||
try checkCopiedDescendantsAreStampable(of: sourceFolder, operation: operation)
|
||||
|
||||
// `moveItem`'s pre-arrival stamp, for the same reason: the copy is not in the destination's
|
||||
// reading yet, and the placement it is about to take was computed against a reading that did
|
||||
// not hold it (01-storage-format.md § Ordering, re-ruled 2026-07-31).
|
||||
try stampAppendAtEndOrders(of: destinationParent, excluding: nil, operation: operation)
|
||||
let rank = try destinationOrder(order, inParent: destinationParent, operation: operation)
|
||||
|
||||
let rootName = freshUUIDName(in: destinationParent, avoiding: [])
|
||||
@@ -2064,13 +2292,33 @@ public enum BoardWriter: Sendable {
|
||||
/// user's own paste made and the app immediately cleaned up.
|
||||
///
|
||||
/// A card with nothing loose is one directory listing and no write at all.
|
||||
///
|
||||
/// **The destination board's noise gate applies here too** (01-storage-format.md § Fractal layout
|
||||
/// ▸ Rules, ruled 2026-07-31): the arriving card is already at its final path, so its files are
|
||||
/// the destination board's to have an opinion about — and a paste that swept a file the very next
|
||||
/// walk would have left alone would be the app disagreeing with itself one gesture apart. The
|
||||
/// rules are read from the board root the card's own path implies (`checkIsCardFolder` proves the
|
||||
/// `<root>/<lane>/<card>` shape before anything moves), which for a batch means one small file
|
||||
/// read per card; the lane-level face below reads it once for the whole lane.
|
||||
@discardableResult
|
||||
public static func normalizeLooseFiles(inCard cardFolder: URL) throws(BoardWriteError) -> [ImportedAttachment] {
|
||||
try normalizeLooseFiles(
|
||||
inCard: cardFolder,
|
||||
ignoring: BoardLoader.ignoreRules(atBoardRoot: boardRoot(ofCard: cardFolder))
|
||||
)
|
||||
}
|
||||
|
||||
/// The rules-in-hand form, so a batch reads the board's `.gitignore` once rather than per card.
|
||||
@discardableResult
|
||||
static func normalizeLooseFiles(
|
||||
inCard cardFolder: URL,
|
||||
ignoring rules: GitignoreRules?
|
||||
) throws(BoardWriteError) -> [ImportedAttachment] {
|
||||
// `healMarked: false` — an **inline** heal batches with the gesture that triggered it
|
||||
// (01-storage-format.md § Validation and healing), so its paths are the paste's, not a
|
||||
// heal's, and splitting them out would name a commit for work the user asked for.
|
||||
try relocateLooseFiles(
|
||||
BoardLoader.looseFileNames(in: cardFolder),
|
||||
BoardLoader.looseFileNames(in: cardFolder, ignoring: rules),
|
||||
inCard: cardFolder,
|
||||
healMarked: false
|
||||
)
|
||||
@@ -2084,13 +2332,23 @@ public enum BoardWriter: Sendable {
|
||||
/// reached: the carve-out is card-level and one level deep, exactly as 01 states it.
|
||||
@discardableResult
|
||||
public static func normalizeLooseFiles(inLane laneFolder: URL) throws(BoardWriteError) -> [ImportedAttachment] {
|
||||
let rules = BoardLoader.ignoreRules(atBoardRoot: laneFolder.deletingLastPathComponent())
|
||||
var moved: [ImportedAttachment] = []
|
||||
for card in childCandidates(of: laneFolder) {
|
||||
moved.append(contentsOf: try normalizeLooseFiles(inCard: card))
|
||||
moved.append(contentsOf: try normalizeLooseFiles(inCard: card, ignoring: rules))
|
||||
}
|
||||
return moved
|
||||
}
|
||||
|
||||
/// The board root a card folder implies — `<root>/<lane>/<card>` with two components taken off.
|
||||
///
|
||||
/// Purely arithmetic on the path, and deliberately not a validation: the callers that reach it
|
||||
/// run `checkIsCardFolder` on the same URL, and a folder that is not a card has no board root to
|
||||
/// derive anyway — the rules read from wherever this points would simply find no file.
|
||||
private static func boardRoot(ofCard cardFolder: URL) -> URL {
|
||||
cardFolder.deletingLastPathComponent().deletingLastPathComponent()
|
||||
}
|
||||
|
||||
/// Refuses any folder that is not a **card**: UUID-shaped, *under* a UUID-shaped parent.
|
||||
///
|
||||
/// `checkIsUUIDShaped` is the guard every other item write leans on, and it is the wrong one
|
||||
@@ -2681,6 +2939,19 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
/// second phrasing for "couldn't move a file you have never seen" would explain nothing.
|
||||
case agentGuide
|
||||
|
||||
/// The board-root `.gitignore` being seeded — at creation, at instantiation, and by the
|
||||
/// scheduled heal that gives a board born before the ruling the file it now needs
|
||||
/// (06-history-undo.md ▸ Repository hygiene, re-ruled 2026-07-31; `seedGitignoreIfAbsent`).
|
||||
///
|
||||
/// **No payload**, for `.agentGuide`'s reason: there is one such file per board, its name is
|
||||
/// fixed, and it is not an item with a title. Its own case beside `.agentGuide` rather than a
|
||||
/// fold into it, on the vocabulary's standing reasoning — one is the agent guide and the other is
|
||||
/// the board's noise definition, and a banner saying the app "couldn't update the agent guide"
|
||||
/// after a failed seed would name a file that was never touched.
|
||||
///
|
||||
/// It never describes an *edit*: the app writes this file only when nothing holds the name.
|
||||
case seedGitignore
|
||||
|
||||
/// A wrong-kinded node being moved off a board-root name the app claims — a file or symlink
|
||||
/// squatting `.trash` (01-storage-format.md § Fractal layout ▸ Rules, ruled 2026-07-29: "moved
|
||||
/// aside by a scheduled heal via the Finder-style rename ladder").
|
||||
@@ -2804,7 +3075,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
// `index.md` carries no `title` to enrich from, and the title these five hold is the *card's*,
|
||||
// filled in by the caller from the window the gesture came from.
|
||||
case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments,
|
||||
.removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide,
|
||||
.removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide, .seedGitignore,
|
||||
.displaceClaimedName, .repairDuplicateID, .saveCommentDraft, .postComment,
|
||||
.editComment, .deleteComment, .purgeCommentTrash:
|
||||
self
|
||||
@@ -2867,7 +3138,8 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
// plain container reason its board-level twin does.
|
||||
case .createBoard, .createLane, .createCard, .move, .copy, .delete, .purge, .migrateTombstone,
|
||||
.style, .resize, .rename, .duplicateBoard, .saveAsTemplate, .paste, .importAttachment,
|
||||
.listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .displaceClaimedName,
|
||||
.listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .seedGitignore,
|
||||
.displaceClaimedName,
|
||||
.repairDuplicateID, .toggleTask, .editBody, .rawSource, .saveCommentDraft, .postComment,
|
||||
.editComment, .deleteComment, .purgeCommentTrash:
|
||||
false
|
||||
@@ -2903,6 +3175,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case .renumberChildren: "renumber children"
|
||||
case let .relocateLooseFile(filename): "relocate loose file '\(filename)'"
|
||||
case .agentGuide: "update the agent guide"
|
||||
case .seedGitignore: "seed the board's .gitignore"
|
||||
case let .displaceClaimedName(name): "move a stray '\(name)' aside"
|
||||
case let .repairDuplicateID(title): Self.phrase("repair the duplicate id of", title)
|
||||
case let .toggleTask(title): Self.phrase("toggle a checkbox in", title)
|
||||
|
||||
@@ -8,10 +8,14 @@ public struct FrontmatterField: Sendable, Equatable {
|
||||
public let rawValue: String
|
||||
}
|
||||
|
||||
/// The result of reading a typed field. `malformed` is what keeps strict fields (`schema`,
|
||||
/// `order`) from being silently coerced — they fail the load instead. Lenient fields (colors,
|
||||
/// icons, `width`) coerce where a sensible reading exists (01-storage-format.md § Frontmatter)
|
||||
/// and only fall back to `.malformed` — rendered as the field's default — when none does.
|
||||
/// The result of reading a typed field — **the document's reading, before any policy**. Lenient
|
||||
/// fields (colors, icons, `width`) coerce where a sensible reading exists (01-storage-format.md
|
||||
/// § Frontmatter) and only fall back to `.malformed` — rendered as the field's default — when none
|
||||
/// does. Structural fields (`schema`, `order`) have no coercion at all at this layer: a value that
|
||||
/// is not an integer, or not a finite number, is `.malformed` and nothing here decides what that
|
||||
/// costs. **What it costs is the rulebook's** (`IntegrityRules`): a malformed `schema` refuses the
|
||||
/// load, while a malformed or missing `order` below the board root reads as append-at-end
|
||||
/// (re-ruled 2026-07-31) — one shape, two policies, and this type carries neither.
|
||||
public enum FieldValue<Value: Sendable & Equatable>: Sendable, Equatable {
|
||||
case missing
|
||||
case valid(Value)
|
||||
@@ -76,11 +80,16 @@ extension FrontmatterDocument {
|
||||
/// "the one place where an observed-in-the-wild shape can later be promoted to a heuristic heal or
|
||||
/// a notice".
|
||||
///
|
||||
/// **The strict fields are absent, and so is `deleted`.** `schema` and `order` are the *refuse*
|
||||
/// tier — a malformed one fails the load loudly (`IntegrityRules.validatedSchema`/`validatedOrder`),
|
||||
/// so there is no silent recovery to leave a trace of. `deleted` is the odd one out on purpose: its
|
||||
/// rule is *presence, not validity* (a malformed `deleted` still deletes — `Lane`/`Card.isDeleted`),
|
||||
/// so nothing falls back to a default and the migration reports it under its own defect anyway.
|
||||
/// **The structural fields are absent, and so is `deleted`.** `schema` and `order` are read
|
||||
/// through the rulebook rather than here (`IntegrityRules.resolvedSchema`/`resolvedOrder`),
|
||||
/// because their readings depend on something this layer cannot see — *which level the file sits
|
||||
/// at*: the root's `schema` refuses, a lane's or card's defaults to 1, and `order` below the root
|
||||
/// reads as append-at-end (re-ruled 2026-07-31). Those two make their own `CoercedField`s, which
|
||||
/// the loader concatenates with this list; they also cover the shape a pure document reading
|
||||
/// cannot produce at all — an **absent** key, which has no `rawText` to record. `deleted` is the
|
||||
/// odd one out on purpose: its rule is *presence, not validity* (a malformed `deleted` still
|
||||
/// deletes — `Lane`/`Card.isDeleted`), so nothing falls back to a default and the migration
|
||||
/// reports it under its own defect anyway.
|
||||
///
|
||||
/// A document whose fields all read cleanly answers `[]`, which is the overwhelmingly common case
|
||||
/// and costs one pass over the lenient fields.
|
||||
@@ -103,17 +112,20 @@ extension FrontmatterDocument {
|
||||
return found
|
||||
}
|
||||
|
||||
// MARK: - Strict (structure — the loader fails fast on `.malformed`)
|
||||
// MARK: - Structural (no coercion here — `IntegrityRules` decides what `.malformed` costs)
|
||||
|
||||
public var schema: FieldValue<Int> {
|
||||
read(FrontmatterKeys.schema) { value, _ in if case let .int(value) = value { value } else { nil } }
|
||||
}
|
||||
|
||||
/// A non-finite reading (`.nan`, `.inf`) has no place in the total order the tie-break and
|
||||
/// midpoint math assume (01-storage-format.md § Frontmatter, settled) — it is the same loud
|
||||
/// malformed-input rejection as a non-numeric value, not a `.valid(Double.nan)` silently
|
||||
/// poisoning every comparison downstream. An `Int` reading is always finite, so only the
|
||||
/// `.double` case needs the check.
|
||||
/// midpoint math assume (01-storage-format.md § Frontmatter, settled) — it is `.malformed` here,
|
||||
/// never a `.valid(Double.nan)` silently poisoning every comparison downstream. An `Int` reading
|
||||
/// is always finite, so only the `.double` case needs the check.
|
||||
///
|
||||
/// Below the board root `.malformed` and `.missing` are one reading — append-at-end, coerce-tier
|
||||
/// (re-ruled 2026-07-31, `IntegrityRules.resolvedOrder`) — but they stay two shapes here, because
|
||||
/// the coerce record wants the text as written and only one of them has any.
|
||||
public var order: FieldValue<Double> {
|
||||
read(FrontmatterKeys.order) { value, _ in
|
||||
switch value {
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
import Foundation
|
||||
|
||||
/// **The board's noise definition, parsed** — a board-root `.gitignore` read as git itself reads
|
||||
/// one, and asked one question: is this board-relative path ignored?
|
||||
///
|
||||
/// The file outgrew git on 2026-07-31 (01-storage-format.md § Fractal layout ▸ Rules, "`.gitignore`
|
||||
/// is the noise gate"; 06-history-undo.md ▸ Repository hygiene): it is now the one definition of
|
||||
/// noise the **loose-file relocation heal** obeys, on every board, git or not. A file matching it
|
||||
/// keeps the ordinary stray posture — "skipped, preserved verbatim, logged, never relocated, never
|
||||
/// announced" — and "the exclusion list is exactly that file, nothing hardcoded". On a Pro board the
|
||||
/// same file governs the committer through libgit2, so ignored noise neither relocates nor commits:
|
||||
/// one definition, two consumers. This type is the app's half of that, because the load path cannot
|
||||
/// reach for libgit2 — the free tier opens a board without so much as a `fileExists` under `.git`
|
||||
/// (12-editions.md ▸ The free tier and `.git`), and the heal runs on boards that have no repository
|
||||
/// at all.
|
||||
///
|
||||
/// **Pure: no I/O, no filesystem, no `URL`.** It parses text and answers about paths. Reading the
|
||||
/// bytes is `BoardLoader.ignoreRules(atBoardRoot:)`' job, once per walk — the loader stays a pure
|
||||
/// function of the tree, and this stays a pure function of the file.
|
||||
///
|
||||
/// ## The semantics, which are git's
|
||||
///
|
||||
/// Implemented from `gitignore(5)`, in its own order:
|
||||
///
|
||||
/// - A **blank line** matches nothing; it exists to space the file out.
|
||||
/// - A line beginning with `#` is a **comment**. `\#` begins a pattern with a literal `#`.
|
||||
/// - **Trailing spaces are ignored** unless escaped (`\ `).
|
||||
/// - A leading `!` **negates**, re-including a path an earlier pattern excluded. `\!` is a literal.
|
||||
/// - A trailing `/` makes the pattern **directory-only**.
|
||||
/// - A `/` at the beginning or in the middle **anchors** the pattern to the board root; a pattern
|
||||
/// with no interior separator matches **at any depth** (`.DS_Store` matches every one of them).
|
||||
/// - `*` matches any run of characters but never `/`; `?` matches exactly one non-`/` character;
|
||||
/// `[…]` is a character class, negatable with `!` or `^` and carrying ranges (`[0-9]`).
|
||||
/// - A path segment that is exactly `**` matches **zero or more** segments: leading `**/` matches at
|
||||
/// any depth, a trailing `/**` matches everything inside, and `a/**/b` matches `a/b` as well as
|
||||
/// `a/x/y/b`. Asterisks anywhere else are ordinary `*`s, exactly as git says.
|
||||
/// - **Last match wins.** The verdict is the last pattern in file order that matched, negations
|
||||
/// included — which is why the rules are kept in file order and evaluated in it.
|
||||
/// - **An ignored directory is final.** A path under an excluded directory is excluded whatever a
|
||||
/// later negation says about it — git does not descend into an ignored directory, so a rule about
|
||||
/// something inside it is never consulted. `isIgnored(relativePath:isDirectory:)` walks the
|
||||
/// ancestors for exactly this reason.
|
||||
///
|
||||
/// ## The deliberate divergences, and they are three
|
||||
///
|
||||
/// - **Only this file.** Nested `.gitignore` files are never consulted, `.git/info/exclude` and the
|
||||
/// user's global excludes are never read, and `core.excludesFile` does not exist here. That is the
|
||||
/// ruling's own wording — "nested `.gitignore` files are ordinary strays the heal never consults"
|
||||
/// — and it is what makes the noise definition something the user can see in one place.
|
||||
/// - **Case-sensitive matching**, always. Git's is `core.ignorecase`'s to decide, which is a
|
||||
/// repository setting on a file this app reads on boards that have no repository. The board's own
|
||||
/// name comparisons go the other way (`IntegrityRules.reservedCardChildNames` is lowercased
|
||||
/// "because the filesystem this runs on usually is"), but a *pattern* is the user's text and
|
||||
/// folding it would silently widen what they wrote.
|
||||
/// - **No POSIX bracket expressions** (`[[:digit:]]`): a `[` that does not close is a literal `[`,
|
||||
/// and `[:digit:]` reads as the ordinary class it looks like. Nothing realistic in a board's noise
|
||||
/// file uses them, and inventing a second character-class grammar to hold them would be the
|
||||
/// over-engineering this type exists to avoid.
|
||||
public struct GitignoreRules: Sendable, Equatable {
|
||||
|
||||
/// The file's patterns, **in file order** — which is the whole of last-match-wins.
|
||||
private let patterns: [Pattern]
|
||||
|
||||
/// Parses a whole `.gitignore` body.
|
||||
///
|
||||
/// **An empty file parses to no patterns and ignores nothing** — the escape hatch 06 names by
|
||||
/// hand ("the escape hatch for wanting no exclusions is an *empty* file, which the app honors and
|
||||
/// never rewrites"), and the same answer a board carrying no `.gitignore` at all gets. The two
|
||||
/// are deliberately indistinguishable to every consumer: one of them is a statement and the other
|
||||
/// is a silence, but neither excludes anything.
|
||||
///
|
||||
/// Lines are split on **any** newline (`Character.isNewline`), which is git's own reading of a
|
||||
/// CRLF file — `dir.c` terminates each pattern before the `\r` — arrived at from the other
|
||||
/// direction: Swift treats `\r\n` as a single grapheme, so splitting on newline-ness drops the
|
||||
/// carriage return by construction rather than by trimming it afterwards. A board hand-edited on
|
||||
/// Windows must not end up carrying patterns nobody can match. A leading UTF-8 BOM is skipped for
|
||||
/// the reason git skips one: it is a byte-order mark, not the first character of a pattern.
|
||||
public init(parsing text: String) {
|
||||
var body = Substring(text)
|
||||
if body.hasPrefix("\u{FEFF}") { body = body.dropFirst() }
|
||||
patterns = body.split(whereSeparator: \.isNewline).compactMap(Pattern.init(line:))
|
||||
}
|
||||
|
||||
/// Whether the file said anything at all. `true` for a board with no `.gitignore` and for one
|
||||
/// whose `.gitignore` is empty or all comments — the honored-empty state.
|
||||
public var isEmpty: Bool { patterns.isEmpty }
|
||||
|
||||
/// Whether `relativePath` — **board-relative**, `/`-separated, no leading slash (`<lane>/<card>/notes.txt`)
|
||||
/// — is ignored by this file.
|
||||
///
|
||||
/// `isDirectory` decides the directory-only patterns (`build/`) and nothing else. It describes
|
||||
/// what is on disk at that path, which is the caller's to know: the loose-file gate always passes
|
||||
/// `false`, because the carve-out is exactly *files*.
|
||||
///
|
||||
/// **Ancestors are consulted first.** A path inside an excluded directory is excluded, and no
|
||||
/// negation reaches it — git never descends into an ignored directory, so it never sees the rule
|
||||
/// that would have re-included the file. Everything below `build/` is ignored even where the file
|
||||
/// goes on to say `!build/keep.txt`.
|
||||
public func isIgnored(relativePath: String, isDirectory: Bool = false) -> Bool {
|
||||
guard !patterns.isEmpty else { return false }
|
||||
let segments = relativePath.split(separator: "/").map(String.init)
|
||||
guard !segments.isEmpty else { return false }
|
||||
|
||||
for depth in 1 ..< segments.count where verdict(for: Array(segments.prefix(depth)), isDirectory: true) == true {
|
||||
return true
|
||||
}
|
||||
return verdict(for: segments, isDirectory: isDirectory) == true
|
||||
}
|
||||
|
||||
/// The last-match-wins verdict for one path: `true` ignored, `false` explicitly re-included,
|
||||
/// `nil` matched by nothing.
|
||||
///
|
||||
/// `nil` and `false` behave identically at every call site today; they are kept apart because the
|
||||
/// file's grammar keeps them apart — "this was never mentioned" and "this was mentioned and then
|
||||
/// taken back" are different statements, and a future consumer (a listing that shows *why*) would
|
||||
/// need the difference the moment it existed.
|
||||
private func verdict(for segments: [String], isDirectory: Bool) -> Bool? {
|
||||
var answer: Bool?
|
||||
for pattern in patterns {
|
||||
guard !pattern.directoryOnly || isDirectory else { continue }
|
||||
guard pattern.matches(segments) else { continue }
|
||||
answer = !pattern.isNegated
|
||||
}
|
||||
return answer
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - One pattern
|
||||
|
||||
extension GitignoreRules {
|
||||
|
||||
/// One line of the file, compiled: what it matches, whether it re-includes, and whether it only
|
||||
/// speaks about directories.
|
||||
///
|
||||
/// **Anchoring is baked into `segments`** rather than carried as a flag: an unanchored pattern is
|
||||
/// exactly its anchored self with a leading `**`, which is what `gitignore(5)` says in prose
|
||||
/// ("the pattern may also match at any level below") and what removes a branch from the matcher.
|
||||
fileprivate struct Pattern: Sendable, Equatable {
|
||||
let segments: [Segment]
|
||||
let isNegated: Bool
|
||||
let directoryOnly: Bool
|
||||
|
||||
/// `nil` for a line that is not a pattern at all — blank, or a comment.
|
||||
init?(line: Substring) {
|
||||
var text = Self.trimmingTrailingSpaces(line)
|
||||
guard !text.isEmpty, text.first != "#" else { return nil }
|
||||
|
||||
if text.first == "!" {
|
||||
isNegated = true
|
||||
text = text.dropFirst()
|
||||
} else {
|
||||
isNegated = false
|
||||
}
|
||||
|
||||
// The trailing separator is the directory-only marker, and it is *not* an interior
|
||||
// separator for the anchoring question below: `foo/` matches a directory named `foo` at
|
||||
// any depth, while `a/b/` is anchored.
|
||||
if text.hasSuffix("/") {
|
||||
directoryOnly = true
|
||||
text = text.dropLast()
|
||||
} else {
|
||||
directoryOnly = false
|
||||
}
|
||||
guard !text.isEmpty else { return nil }
|
||||
|
||||
let anchored = text.contains("/")
|
||||
let parsed = text
|
||||
.split(separator: "/", omittingEmptySubsequences: true)
|
||||
.map(Segment.init(text:))
|
||||
guard !parsed.isEmpty else { return nil }
|
||||
segments = anchored ? parsed : [.globstar] + parsed
|
||||
}
|
||||
|
||||
/// Whether this pattern matches the whole of `path`.
|
||||
func matches(_ path: [String]) -> Bool {
|
||||
Self.match(segments[...], path[...])
|
||||
}
|
||||
|
||||
/// The segment walk, with `**`'s zero-or-more the only place it backtracks.
|
||||
private static func match(_ pattern: ArraySlice<Segment>, _ path: ArraySlice<String>) -> Bool {
|
||||
guard let head = pattern.first else { return path.isEmpty }
|
||||
let tail = pattern.dropFirst()
|
||||
|
||||
if case .globstar = head {
|
||||
// **A trailing `**` matches everything *inside*** (`gitignore(5)`), so it needs a
|
||||
// segment to consume: `a/**` matches `a/b`, never the bare `a`. Where `a` itself is
|
||||
// ignored, some other pattern said so.
|
||||
guard !tail.isEmpty else { return !path.isEmpty }
|
||||
var remaining = path
|
||||
while true {
|
||||
if match(tail, remaining) { return true }
|
||||
guard !remaining.isEmpty else { return false }
|
||||
remaining = remaining.dropFirst()
|
||||
}
|
||||
}
|
||||
|
||||
guard case let .literalOrWildcards(tokens) = head,
|
||||
let name = path.first,
|
||||
Segment.match(tokens[...], Array(name)[...])
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return match(tail, path.dropFirst())
|
||||
}
|
||||
|
||||
/// Drops the trailing spaces git drops — every one that is not `\`-escaped.
|
||||
///
|
||||
/// Only spaces, and only trailing: git trims exactly this (`trim_trailing_spaces`), so a
|
||||
/// pattern ending in a tab keeps it, and a filename that really does end in a space stays
|
||||
/// reachable by writing `foo\ `.
|
||||
private static func trimmingTrailingSpaces(_ line: Substring) -> Substring {
|
||||
var end = line.endIndex
|
||||
while end > line.startIndex {
|
||||
let previous = line.index(before: end)
|
||||
guard line[previous] == " " else { break }
|
||||
// Escaped when preceded by an odd number of backslashes.
|
||||
var backslashes = 0
|
||||
var scan = previous
|
||||
while scan > line.startIndex {
|
||||
scan = line.index(before: scan)
|
||||
guard line[scan] == "\\" else { break }
|
||||
backslashes += 1
|
||||
}
|
||||
guard backslashes.isMultiple(of: 2) else { break }
|
||||
end = previous
|
||||
}
|
||||
return line[line.startIndex ..< end]
|
||||
}
|
||||
}
|
||||
|
||||
/// One `/`-separated piece of a pattern.
|
||||
fileprivate enum Segment: Sendable, Equatable {
|
||||
/// Exactly `**` — zero or more path segments. "Other consecutive asterisks are considered
|
||||
/// regular asterisks" (`gitignore(5)`), which is why this case is reserved for the whole
|
||||
/// segment and never for a `**` sitting inside one.
|
||||
case globstar
|
||||
/// Everything else, tokenized once at parse time.
|
||||
case literalOrWildcards([Token])
|
||||
|
||||
init(text: Substring) {
|
||||
self = text == "**" ? .globstar : .literalOrWildcards(Token.tokenize(text))
|
||||
}
|
||||
|
||||
/// Matches one path segment against one pattern segment — `*`'s backtracking, iteratively,
|
||||
/// because a pattern is small and a recursion per `*` is not worth the stack.
|
||||
static func match(_ tokens: ArraySlice<Token>, _ name: ArraySlice<Character>) -> Bool {
|
||||
var t = tokens.startIndex
|
||||
var n = name.startIndex
|
||||
// Where to resume from if a `*` guessed short: the star itself and the character it was
|
||||
// last asked to swallow up to.
|
||||
var starToken: Int?
|
||||
var starName = name.startIndex
|
||||
|
||||
while n < name.endIndex {
|
||||
if t < tokens.endIndex {
|
||||
switch tokens[t] {
|
||||
case .anyRun:
|
||||
starToken = t
|
||||
starName = n
|
||||
t += 1
|
||||
continue
|
||||
case .anyCharacter:
|
||||
t += 1
|
||||
n += 1
|
||||
continue
|
||||
case let .literal(character) where character == name[n]:
|
||||
t += 1
|
||||
n += 1
|
||||
continue
|
||||
case let .characterClass(group) where group.matches(name[n]):
|
||||
t += 1
|
||||
n += 1
|
||||
continue
|
||||
case .literal, .characterClass:
|
||||
break
|
||||
}
|
||||
}
|
||||
guard let star = starToken else { return false }
|
||||
starName += 1
|
||||
n = starName
|
||||
t = star + 1
|
||||
}
|
||||
|
||||
// Trailing `*`s can still match nothing at all.
|
||||
while t < tokens.endIndex, tokens[t] == .anyRun { t += 1 }
|
||||
return t == tokens.endIndex
|
||||
}
|
||||
}
|
||||
|
||||
/// One element of a pattern segment.
|
||||
fileprivate enum Token: Sendable, Equatable {
|
||||
case literal(Character)
|
||||
/// `?`
|
||||
case anyCharacter
|
||||
/// `*` — any run, `/` excluded by construction (a token never sees a separator).
|
||||
case anyRun
|
||||
/// `[…]`
|
||||
case characterClass(CharacterClass)
|
||||
|
||||
/// Compiles one segment's characters. A `\` escapes whatever follows it; a trailing `\` is a
|
||||
/// literal backslash (there is nothing left for it to escape); an unterminated `[` is a
|
||||
/// literal `[`, which is git's own reading and the only one that cannot lose a character.
|
||||
static func tokenize(_ text: Substring) -> [Token] {
|
||||
var tokens: [Token] = []
|
||||
var index = text.startIndex
|
||||
while index < text.endIndex {
|
||||
let character = text[index]
|
||||
switch character {
|
||||
case "\\":
|
||||
let next = text.index(after: index)
|
||||
guard next < text.endIndex else {
|
||||
tokens.append(.literal("\\"))
|
||||
index = next
|
||||
continue
|
||||
}
|
||||
tokens.append(.literal(text[next]))
|
||||
index = text.index(after: next)
|
||||
case "?":
|
||||
tokens.append(.anyCharacter)
|
||||
index = text.index(after: index)
|
||||
case "*":
|
||||
// Consecutive asterisks inside a segment are one ordinary `*`.
|
||||
if tokens.last != .anyRun { tokens.append(.anyRun) }
|
||||
index = text.index(after: index)
|
||||
case "[":
|
||||
if let (group, end) = CharacterClass.parse(text, from: index) {
|
||||
tokens.append(.characterClass(group))
|
||||
index = end
|
||||
} else {
|
||||
tokens.append(.literal("["))
|
||||
index = text.index(after: index)
|
||||
}
|
||||
default:
|
||||
tokens.append(.literal(character))
|
||||
index = text.index(after: index)
|
||||
}
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
}
|
||||
|
||||
/// A `[…]` group: members, ranges, and the leading `!`/`^` negation.
|
||||
fileprivate struct CharacterClass: Sendable, Equatable {
|
||||
enum Member: Sendable, Equatable {
|
||||
case single(Character)
|
||||
case range(ClosedRange<Character>)
|
||||
}
|
||||
|
||||
let isNegated: Bool
|
||||
let members: [Member]
|
||||
|
||||
func matches(_ character: Character) -> Bool {
|
||||
let hit = members.contains { member in
|
||||
switch member {
|
||||
case let .single(value): value == character
|
||||
case let .range(range): range.contains(character)
|
||||
}
|
||||
}
|
||||
return hit != isNegated
|
||||
}
|
||||
|
||||
/// Parses from the `[` at `start`, answering the group and the index just past its `]`, or
|
||||
/// `nil` when the group never closes.
|
||||
///
|
||||
/// A `]` **immediately after** the opening bracket (or its negation mark) is a literal member
|
||||
/// rather than the terminator, which is the POSIX rule git inherits — `[]]` matches a bracket.
|
||||
static func parse(_ text: Substring, from start: Substring.Index) -> (CharacterClass, Substring.Index)? {
|
||||
var index = text.index(after: start)
|
||||
var negated = false
|
||||
if index < text.endIndex, text[index] == "!" || text[index] == "^" {
|
||||
negated = true
|
||||
index = text.index(after: index)
|
||||
}
|
||||
|
||||
var members: [Member] = []
|
||||
var first = true
|
||||
while index < text.endIndex {
|
||||
let character = text[index]
|
||||
if character == "]", !first {
|
||||
return (CharacterClass(isNegated: negated, members: members), text.index(after: index))
|
||||
}
|
||||
first = false
|
||||
|
||||
var value = character
|
||||
if character == "\\" {
|
||||
let next = text.index(after: index)
|
||||
guard next < text.endIndex else { break }
|
||||
value = text[next]
|
||||
index = next
|
||||
}
|
||||
|
||||
// A `-` between two members is a range; one at either end of the group is a literal.
|
||||
let afterValue = text.index(after: index)
|
||||
if afterValue < text.endIndex, text[afterValue] == "-" {
|
||||
let upperIndex = text.index(after: afterValue)
|
||||
if upperIndex < text.endIndex, text[upperIndex] != "]" {
|
||||
var upper = text[upperIndex]
|
||||
var end = upperIndex
|
||||
if upper == "\\" {
|
||||
let escaped = text.index(after: upperIndex)
|
||||
guard escaped < text.endIndex else { break }
|
||||
upper = text[escaped]
|
||||
end = escaped
|
||||
}
|
||||
// An inverted range (`z-a`) is nonsense; git's matcher never matches one, and
|
||||
// `ClosedRange` would trap on it.
|
||||
if value <= upper { members.append(.range(value ... upper)) }
|
||||
index = text.index(after: end)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
members.append(.single(value))
|
||||
index = text.index(after: index)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,6 +116,13 @@ public enum IntegrityRules: Sendable {
|
||||
/// The file every level's content lives in.
|
||||
public static let indexFileName = "index.md"
|
||||
|
||||
/// **The board's noise definition** (01-storage-format.md § Fractal layout ▸ Rules, ruled
|
||||
/// 2026-07-31; 06-history-undo.md ▸ Repository hygiene) — a board-root **file** name, claimed
|
||||
/// because the app seeds it on every board and then reads it on every walk: it is the one list of
|
||||
/// files the loose-file relocation heal leaves alone, and on a Pro board the same file is what
|
||||
/// keeps that noise out of history. Seeded once, never edited afterwards.
|
||||
public static let gitignoreFileName = ".gitignore"
|
||||
|
||||
/// A card's **comment thread** (01-storage-format.md § Enhanced schema) — a plain reserved
|
||||
/// child, never a level and never identity; the identities are the UUID folders inside it.
|
||||
public static let commentsFolderName = "comments"
|
||||
@@ -180,11 +187,24 @@ public enum IntegrityRules: Sendable {
|
||||
/// Whether a wrong-kinded node on this name is displaced by the scheduled heal (ruled
|
||||
/// 2026-07-29 — "Lanework owns the board"), or left exactly where it is.
|
||||
///
|
||||
/// `false` for the two names that are *destinations* or not the app's to police:
|
||||
/// `false` for the two names that are *destinations* or that the app can do without:
|
||||
/// `CLAUDE.user.md` is where a markerless `CLAUDE.md` is rescued **to**, and freeing a
|
||||
/// destination by a second displacement would cascade renames (the settled skip stands —
|
||||
/// 08-agent-integration.md); `.gitignore` is seeded once and then the user's to edit
|
||||
/// (06-history-undo.md ▸ Repository hygiene), and nothing in the app reads it.
|
||||
/// (06-history-undo.md ▸ Repository hygiene).
|
||||
///
|
||||
/// **`.gitignore`'s `false` survived the 2026-07-31 re-ruling deliberately**, and the reason
|
||||
/// changed underneath it. It used to be "nothing in the app reads it", which stopped being
|
||||
/// true the day the file became the loose-file heal's noise gate (01-storage-format.md
|
||||
/// § Fractal layout ▸ Rules) — by the level-uniform timing principle (a name "joins the
|
||||
/// scheduled class the day the name becomes load-bearing", which is how `comments` graduated)
|
||||
/// a folder squatting it would now be a candidate. It stays undisplaced because **the app
|
||||
/// degrades to correctness without the file**: an unreadable noise definition means no
|
||||
/// exclusions, which is exactly the behaviour boards had before the ruling and exactly what an
|
||||
/// empty file — the ruling's own escape hatch — asks for. Nothing breaks while the name is
|
||||
/// held, so nothing is displaced; that is the "proactive only when the defect is load-bearing
|
||||
/// *now*" half of § Validation and healing. Neither 01 nor 06 asks for a displacement here,
|
||||
/// and inventing one would move a node of the user's to buy a courtesy file.
|
||||
public let displacesSquatters: Bool
|
||||
}
|
||||
|
||||
@@ -199,7 +219,7 @@ public enum IntegrityRules: Sendable {
|
||||
ClaimedName(name: trashFolderName, expected: .directory, displacesSquatters: true),
|
||||
ClaimedName(name: "CLAUDE.md", expected: .file, displacesSquatters: true),
|
||||
ClaimedName(name: "CLAUDE.user.md", expected: .file, displacesSquatters: false),
|
||||
ClaimedName(name: ".gitignore", expected: .file, displacesSquatters: false),
|
||||
ClaimedName(name: gitignoreFileName, expected: .file, displacesSquatters: false),
|
||||
]
|
||||
|
||||
/// **The card-level claimed names** — the same table one level down (01-storage-format.md § Fractal
|
||||
@@ -451,9 +471,15 @@ public enum IntegrityRules: Sendable {
|
||||
|
||||
// MARK: - Per-field validation (the rulebook)
|
||||
|
||||
/// `schema`, validated: present, well-formed, not newer than this app (01-storage-format.md
|
||||
/// § Malformed input). Required at every level.
|
||||
public static func validatedSchema(
|
||||
/// `schema` **at the board root**, validated: present, well-formed, not newer than this app
|
||||
/// (01-storage-format.md § Malformed input).
|
||||
///
|
||||
/// **The root keeps its requirement** (re-ruled 2026-07-31): the root's stamp is the board's
|
||||
/// identity claim — the this-really-is-a-board gate — so a root without one is the loud
|
||||
/// rejection it always was, and the decision surface's minted repair (`stamp schema: 1`) is
|
||||
/// what fixes it. Below the root the key is optional and defaults instead
|
||||
/// (`resolvedSchema(in:path:supportedSchema:)`).
|
||||
public static func validatedRootSchema(
|
||||
in document: FrontmatterDocument,
|
||||
path: String,
|
||||
supportedSchema: Int
|
||||
@@ -464,51 +490,111 @@ public enum IntegrityRules: Sendable {
|
||||
case let .malformed(raw):
|
||||
throw BoardLoadError(path: path, reason: .malformedSchema(raw: raw))
|
||||
case let .valid(value):
|
||||
guard value <= supportedSchema else {
|
||||
throw BoardLoadError(path: path, reason: .schemaNewerThanApp(found: value))
|
||||
}
|
||||
try checkNotNewerThanApp(value, path: path, supportedSchema: supportedSchema)
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
/// `order`, validated: present and well-formed. Required on lanes and cards, **never** on the
|
||||
/// board itself — which is the whole of the per-kind difference in the tables today.
|
||||
public static func validatedOrder(
|
||||
in document: FrontmatterDocument,
|
||||
path: String
|
||||
) throws(BoardLoadError) -> Double {
|
||||
switch document.order {
|
||||
case .missing:
|
||||
throw BoardLoadError(path: path, reason: .missingOrder)
|
||||
case let .malformed(raw):
|
||||
throw BoardLoadError(path: path, reason: .malformedOrder(raw: raw))
|
||||
case let .valid(value):
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an object of `kind` must carry `order` — the per-kind field table, as a rule rather
|
||||
/// than as two hand-written call sites in the loader's walk.
|
||||
/// `schema` **below the board root**, read: a missing (or explicitly null) key reads as **1**
|
||||
/// (01-storage-format.md § Frontmatter, re-ruled 2026-07-31 — "below the root a missing `schema`
|
||||
/// reads as 1 — coerce-tier, logged, bytes preserved — because the walk validating the file
|
||||
/// against schema 1 is exactly what makes the reading reliable").
|
||||
///
|
||||
/// **A comment carries none, and never gains one** (01-storage-format.md § Enhanced schema:
|
||||
/// "**No `title`, no `order`**" — "Ordering is chronology, not ranks", because a conversation's
|
||||
/// semantics *are* chronology and tracker-synced comments carry independent clocks where minted
|
||||
/// ranks would interleave arbitrarily).
|
||||
public static func requiresOrder(_ kind: ObjectKind) -> Bool {
|
||||
switch kind {
|
||||
case .board, .comment: false
|
||||
case .lane, .card: true
|
||||
/// Two things stay fail-fast here, and both are the same rule they always were:
|
||||
///
|
||||
/// - **A schema newer than the app**, at any level: there is no honest reading of a file written
|
||||
/// by a Lanework that knows keys this one does not, and defaulting would be guessing at content
|
||||
/// rather than recovering it.
|
||||
/// - **A malformed `schema`** — `schema: one`, `schema: [1]`. The ruling made the *absent* key
|
||||
/// optional, not the unreadable one: absence is what a hand-filed item legitimately looks like,
|
||||
/// while a value that is there and unreadable is a claim the app cannot check, and reading it
|
||||
/// as 1 would be inventing agreement.
|
||||
///
|
||||
/// - Returns: the reading, plus the coerce-tier record when one was made — the field, and the
|
||||
/// text as written (`nil` when the key was present and valid, which is every file this app
|
||||
/// ever wrote). The *path* is the loader's to attach (`CoercedFrontmatter`).
|
||||
public static func resolvedSchema(
|
||||
in document: FrontmatterDocument,
|
||||
path: String,
|
||||
supportedSchema: Int
|
||||
) throws(BoardLoadError) -> (schema: Int, coerced: CoercedField?) {
|
||||
switch document.schema {
|
||||
case .missing:
|
||||
return (defaultSchema, coercedRecord(for: FrontmatterKeys.schema, in: document, raw: nil))
|
||||
case let .malformed(raw):
|
||||
throw BoardLoadError(path: path, reason: .malformedSchema(raw: raw))
|
||||
case let .valid(value):
|
||||
try checkNotNewerThanApp(value, path: path, supportedSchema: supportedSchema)
|
||||
return (value, nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// The reading a `schema`-less file below the root gets — the schema this document *is* being
|
||||
/// validated against, which is the argument for the default rather than a coincidence.
|
||||
public static let defaultSchema = 1
|
||||
|
||||
private static func checkNotNewerThanApp(
|
||||
_ value: Int,
|
||||
path: String,
|
||||
supportedSchema: Int
|
||||
) throws(BoardLoadError) {
|
||||
guard value <= supportedSchema else {
|
||||
throw BoardLoadError(path: path, reason: .schemaNewerThanApp(found: value))
|
||||
}
|
||||
}
|
||||
|
||||
/// `order` **below the board root**, read: `nil` where the file carries no rank this app can
|
||||
/// use — which the caller reads as **append-at-end** (`Ranks.resolvedOrders(of:stored:name:)`).
|
||||
///
|
||||
/// **Four shapes, one reading** (01-storage-format.md § Ordering, re-ruled 2026-07-31 —
|
||||
/// "Missing or unusable `order` reads as append-at-end"): a missing key, an explicitly null one
|
||||
/// (`order:` bare, `order: null` — the null-as-missing rule), a non-numeric value, and a
|
||||
/// non-finite one (`.nan`, `.inf`, which has no place in the total order the tie-break and
|
||||
/// midpoint math assume). None of them is a load failure any more; all of them are coerce-tier —
|
||||
/// bytes preserved verbatim, the reading logged, the rank materializing on the file's first
|
||||
/// touch (`healOnTouch`).
|
||||
///
|
||||
/// It never throws, and has no `path`: with nothing left to refuse, there is nothing to name.
|
||||
///
|
||||
/// **The board root never asks.** A board carries no rank — it is not among siblings — so the
|
||||
/// key is meaningless there and this is simply not called on it, exactly as it never was.
|
||||
public static func resolvedOrder(
|
||||
in document: FrontmatterDocument
|
||||
) -> (order: Double?, coerced: CoercedField?) {
|
||||
switch document.order {
|
||||
case .valid(let value):
|
||||
return (value, nil)
|
||||
case .malformed(let raw):
|
||||
return (nil, coercedRecord(for: FrontmatterKeys.order, in: document, raw: raw))
|
||||
case .missing:
|
||||
return (nil, coercedRecord(for: FrontmatterKeys.order, in: document, raw: nil))
|
||||
}
|
||||
}
|
||||
|
||||
/// One coerce-tier record: the field and **the value exactly as written**, which for an absent
|
||||
/// key is the empty string — there is no text to record, and inventing a rendering of one would
|
||||
/// defeat the record's whole purpose (a shape observed in the wild, promotable later to a
|
||||
/// heuristic heal). An explicitly-null key answers with its own (empty) source span, so
|
||||
/// "started the key and never gave it a value" is preserved as exactly that.
|
||||
private static func coercedRecord(
|
||||
for key: String,
|
||||
in document: FrontmatterDocument,
|
||||
raw: String?
|
||||
) -> CoercedField {
|
||||
CoercedField(key: key, raw: raw ?? document.rawValue(for: key) ?? "")
|
||||
}
|
||||
|
||||
/// **Per-kind index validation** — the loader's own checks, in its own order, over bytes that
|
||||
/// need not be on disk yet (02-architecture.md ▸ Components: "the card validator generalized per
|
||||
/// kind — board, lane, card, the enhanced schema's comment when it lands").
|
||||
///
|
||||
/// Exactly the checks `BoardLoader.load` runs on an object of that kind, through its own
|
||||
/// functions: decode + parse, then `schema`, then `order` where the kind requires it. Nothing
|
||||
/// further is checked, because nothing else *is*: `title` is optional, unknown keys are the
|
||||
/// point of the outlet the card validator serves, and the body is free text.
|
||||
/// functions: decode + parse, then `schema` — at the root's strictness for a board, at the
|
||||
/// below-the-root reading for everything else. Nothing further is checked, because nothing else
|
||||
/// *is*: `title` is optional, `order` is optional below the root since 2026-07-31 (a rankless
|
||||
/// card applied through the raw-source outlet lands at its lane's bottom and gains a rank on its
|
||||
/// next touch, exactly as a hand-filed one does), unknown keys are the point of the outlet the
|
||||
/// card validator serves, and the body is free text.
|
||||
///
|
||||
/// It deliberately does **not** check `uneditableShape`: that refusal exists for surgical span
|
||||
/// edits, and the raw-source Apply this serves replaces the whole file — a flow-mapping
|
||||
@@ -520,9 +606,11 @@ public enum IntegrityRules: Sendable {
|
||||
supportedSchema: Int
|
||||
) throws(BoardLoadError) -> FrontmatterDocument {
|
||||
let document = try BoardLoader.parseDocument(data, path: path)
|
||||
_ = try validatedSchema(in: document, path: path, supportedSchema: supportedSchema)
|
||||
if requiresOrder(kind) {
|
||||
_ = try validatedOrder(in: document, path: path)
|
||||
switch kind {
|
||||
case .board:
|
||||
_ = try validatedRootSchema(in: document, path: path, supportedSchema: supportedSchema)
|
||||
case .lane, .card, .comment:
|
||||
_ = try resolvedSchema(in: document, path: path, supportedSchema: supportedSchema)
|
||||
}
|
||||
return document
|
||||
}
|
||||
@@ -551,6 +639,23 @@ public enum IntegrityRules: Sendable {
|
||||
/// add a key that is redundant with position everywhere else.
|
||||
case kindBackfilled(ObjectKind)
|
||||
|
||||
/// `order` was missing or unusable and has been stamped with the rank the board was
|
||||
/// **already rendering it at** — the append-at-end reading, materialized
|
||||
/// (01-storage-format.md § Ordering, re-ruled 2026-07-31: "The rank materializes on touch:
|
||||
/// the first Writer rewrite of the file stamps a real rank").
|
||||
///
|
||||
/// Nothing moves on screen when it lands, and that is the design rather than a happy
|
||||
/// accident: the stamped value is `Ranks.resolvedOrders`' own answer for this file, so the
|
||||
/// heal writes down what the reading already said. It is on-touch for the reason the whole
|
||||
/// class is — the defect is latent, the file is being rewritten anyway, and a sweep that
|
||||
/// rewrote a board to add ranks nobody asked for would be churn.
|
||||
///
|
||||
/// **Unlike `kind`, an unusable *present* value is replaced.** `order: banana` and
|
||||
/// `order: .nan` are read as order-less (the value-names-the-kind posture has no twin here:
|
||||
/// a rank must be a number for the midpoint math to mean anything), so the stamp is the same
|
||||
/// heal in both cases and the unreadable text does not survive the rewrite.
|
||||
case rankStamped(Double)
|
||||
|
||||
/// A key written twice collapsed to its winning (last) occurrence — the span editor's
|
||||
/// duplicate-key twin removal (`FrontmatterDocument.set`). Named here because it *is* an
|
||||
/// on-touch heal and was only ever documented as an editing detail: last-wins is the read
|
||||
@@ -569,28 +674,43 @@ public enum IntegrityRules: Sendable {
|
||||
/// `updateIndex` seam, which consults IntegrityRules for pending on-touch work on the file it is
|
||||
/// rewriting").
|
||||
///
|
||||
/// Only the `kind` backfill is applied *here*; the other two members of the class are applied by
|
||||
/// `FrontmatterDocument`'s own editor on every key it writes, and are named in `OnTouchHeal`
|
||||
/// rather than re-implemented. That is the honest shape of "the same class, named, no behavior
|
||||
/// change".
|
||||
/// Two of the four members are applied *here* — the `kind` backfill and the rank stamp; the
|
||||
/// other two are applied by `FrontmatterDocument`'s own editor on every key it writes, and are
|
||||
/// named in `OnTouchHeal` rather than re-implemented. That is the honest shape of "the same
|
||||
/// class, named, no behavior change".
|
||||
///
|
||||
/// - **Missing only.** A present `kind` is never rewritten, never corroborated, and never
|
||||
/// stripped — the value names the kind and consumers trust it outright. An explicit `kind:`
|
||||
/// with nothing after it reads as missing, like every other null (the null-as-missing rule),
|
||||
/// and so backfills.
|
||||
/// - **`kind`: missing only.** A present `kind` is never rewritten, never corroborated, and
|
||||
/// never stripped — the value names the kind and consumers trust it outright. An explicit
|
||||
/// `kind:` with nothing after it reads as missing, like every other null (the null-as-missing
|
||||
/// rule), and so backfills.
|
||||
/// - **`kind == nil` stamps nothing.** Position cannot always answer (`Placement.unknown`), and
|
||||
/// a guessed kind written to disk would be worse than an absent one: the trash discriminator
|
||||
/// trusts what it finds.
|
||||
/// - Parameter kind: `@autoclosure` so a caller whose answer costs a directory listing (the
|
||||
/// trash's shape fallback) pays for it only on a file that actually needs the backfill.
|
||||
/// - **`order`: missing *or unusable*.** The reading is stated over usability
|
||||
/// (`resolvedOrder(in:)`), so the stamp is too.
|
||||
/// - **`rank == nil` stamps nothing** — the board root (which has no siblings and no rank), a
|
||||
/// comment (which never gains one — "Ordering is chronology, not ranks"), and any folder whose
|
||||
/// position has no answer. The same posture as `kind`'s: a rank invented for something that is
|
||||
/// not a lane or a card would be a fact nobody asked for.
|
||||
/// - Parameters kind, rank: `@autoclosure` so a caller whose answer costs a directory listing
|
||||
/// (the trash's shape fallback; the rank's own sibling scan) pays for it only on a file that
|
||||
/// actually needs the heal — which, on a board this app wrote, is never.
|
||||
@discardableResult
|
||||
public static func healOnTouch(
|
||||
_ document: inout FrontmatterDocument,
|
||||
kind: @autoclosure () -> ObjectKind?
|
||||
kind: @autoclosure () -> ObjectKind?,
|
||||
rank: @autoclosure () -> Double? = nil
|
||||
) -> [OnTouchHeal] {
|
||||
guard document.kind.isMissing, let kind = kind() else { return [] }
|
||||
document.set(FrontmatterKeys.kind, to: .string(kind.rawValue))
|
||||
return [.kindBackfilled(kind)]
|
||||
var applied: [OnTouchHeal] = []
|
||||
if document.kind.isMissing, let kind = kind() {
|
||||
document.set(FrontmatterKeys.kind, to: .string(kind.rawValue))
|
||||
applied.append(.kindBackfilled(kind))
|
||||
}
|
||||
if document.order.value == nil, let rank = rank() {
|
||||
document.set(FrontmatterKeys.order, to: .double(rank))
|
||||
applied.append(.rankStamped(rank))
|
||||
}
|
||||
return applied
|
||||
}
|
||||
|
||||
// MARK: - The typed defect stream
|
||||
@@ -640,6 +760,12 @@ public enum IntegrityRules: Sendable {
|
||||
/// It is a class here because the engine treats it exactly like the others — same gates,
|
||||
/// same memo, same clear-on-success.
|
||||
///
|
||||
/// `missingGitignore` is the third, and the guide's twin in every respect: the board's noise
|
||||
/// definition is one board-root file's presence, read at the moment of healing
|
||||
/// (`BoardWriter.seedGitignoreIfAbsent`) rather than reported by a tree walk — "a board
|
||||
/// missing the file gains it by scheduled heal at open (the guide-refresh cadence)"
|
||||
/// (06-history-undo.md ▸ Repository hygiene, re-ruled 2026-07-31).
|
||||
///
|
||||
/// `commentTrashResidue` is the second such class, for the same reason one level down: the
|
||||
/// residue is whatever a crashed session left in one card's `comments/.trash/`, read at the
|
||||
/// moment the card window opens (01-storage-format.md § Enhanced schema — "purged when the
|
||||
@@ -652,6 +778,7 @@ public enum IntegrityRules: Sendable {
|
||||
case claimedNameSquatted
|
||||
case duplicateIdentity
|
||||
case staleAgentGuide
|
||||
case missingGitignore
|
||||
case commentTrashResidue
|
||||
}
|
||||
|
||||
|
||||
@@ -119,11 +119,63 @@ enum Ranks: Sendable {
|
||||
return (1...count).map { Double($0) * gap }
|
||||
}
|
||||
|
||||
// MARK: - The append-at-end reading
|
||||
|
||||
/// **Every sibling's rank, with the order-less ones read as append-at-end** — the whole of the
|
||||
/// optional-`order` ruling's read side (01-storage-format.md § Ordering, re-ruled 2026-07-31:
|
||||
/// "Missing or unusable `order` reads as append-at-end … order-less items sort after every
|
||||
/// ordered sibling, among themselves by the folder-name tie-break — deterministic with zero
|
||||
/// sibling reads, which is what makes the minimum agent card legal").
|
||||
///
|
||||
/// - Parameter stored: the `order` the file actually carries, or `nil` where it carries none the
|
||||
/// loader can use — missing, explicitly null, non-numeric, non-finite. The four shapes are one
|
||||
/// answer here on purpose: the reading is stated over *usability*, not over which way a value
|
||||
/// failed to be usable (`IntegrityRules.resolvedOrder` is where the four are told apart, for
|
||||
/// the coerce-tier record).
|
||||
/// - Parameter name: the folder name — the tie-break the design states this ordering in.
|
||||
/// - Returns: one rank per sibling, **positionally aligned with `siblings`** (never reordered:
|
||||
/// callers sort afterwards, through `sortedForDisplay`, exactly as they always did).
|
||||
///
|
||||
/// **The materialized ranks are `append`'s own arithmetic**, and that is the load-bearing
|
||||
/// property rather than a convenience: the k-th order-less sibling reads as `max + 1024·k` over
|
||||
/// the ranks actually written down, which is exactly where `append(toVisible:)` would have put it
|
||||
/// had it been filed by the app. So the reading a board *renders* is a rank ladder the Writer can
|
||||
/// stamp verbatim — which is what the on-touch and inline stamps do
|
||||
/// (`BoardWriter.stampAppendAtEndOrders`), and why stamping one changes nothing on screen.
|
||||
///
|
||||
/// An empty ranked set bases at `0`, so a container of nothing but order-less items reads
|
||||
/// `1024, 2048, …` — the same board convention `append` gives an empty container's first child.
|
||||
///
|
||||
/// **Accepted cost, stated by the ruling**: two order-less siblings sort by folder name rather
|
||||
/// than by intent until something touches them.
|
||||
static func resolvedOrders<T>(
|
||||
of siblings: [T],
|
||||
stored: (T) -> Double?,
|
||||
name: (T) -> String
|
||||
) -> [Double] {
|
||||
let storedOrders = siblings.map(stored)
|
||||
var resolved = storedOrders.map { $0 ?? 0 }
|
||||
let orderless = storedOrders.indices.filter { storedOrders[$0] == nil }
|
||||
guard !orderless.isEmpty else { return resolved }
|
||||
|
||||
let base = storedOrders.compactMap { $0 }.max() ?? 0
|
||||
let queue = orderless.sorted { name(siblings[$0]) < name(siblings[$1]) }
|
||||
for (step, index) in queue.enumerated() {
|
||||
resolved[index] = base + gap * Double(step + 1)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
// MARK: - Display order
|
||||
|
||||
/// Ascending display order: primary key `order`, ties broken by folder
|
||||
/// name (lexicographic) for deterministic rendering. Shared by the
|
||||
/// loader and the writer so both apply the same tie-break rule.
|
||||
///
|
||||
/// An order-less sibling reaches here already carrying its append-at-end
|
||||
/// reading (`resolvedOrders(of:stored:name:)`), so this comparator needs no
|
||||
/// case for one: "after every ordered sibling, then by folder name" *is*
|
||||
/// this rule applied to the materialized ranks.
|
||||
static func isOrderedForDisplay<T>(
|
||||
_ lhs: T, before rhs: T,
|
||||
order: (T) -> Double, name: (T) -> String
|
||||
|
||||
@@ -235,8 +235,15 @@ struct BoardGitControls: View {
|
||||
// MARK: - Commit identity
|
||||
|
||||
/// **The name and email that repo-local `.git/config` carries** (06-history-undo.md ▸ Interaction
|
||||
/// with external writers: "The board popover's git section exposes name/email fields that write that
|
||||
/// repo-local config — the setting *is* the file, portable to any git client, per-board by nature").
|
||||
/// with external writers: "The board settings sheet's identity section … exposes name/email fields
|
||||
/// that write that repo-local config — the setting *is* the file, portable to any git client,
|
||||
/// per-board by nature").
|
||||
///
|
||||
/// **Its home is the sheet, and the sheet is not built yet.** The 2026-07-31 popover/sheet split moved
|
||||
/// every setup-shaped control — add-git, branch creation, these fields, remote and credentials — out
|
||||
/// of the popover and into a board settings sheet (03-board-ui.md ▸ Board settings sheet), leaving the
|
||||
/// popover the daily face. This view is the fields, hosted where they were; the sheet's card moves
|
||||
/// them, along with the visibility-scoped poll below, which 06 says "rides with the fields".
|
||||
///
|
||||
/// ### The placeholder is the whole of the identity rule made visible
|
||||
///
|
||||
@@ -270,8 +277,11 @@ private struct BoardGitIdentityFields: View {
|
||||
case email
|
||||
}
|
||||
|
||||
/// How often an open popover re-reads the config file. Slow enough to be free, fast enough that a
|
||||
/// terminal `git config user.email …` shows up while the user is still looking at the popover.
|
||||
/// **The fields re-read the config at 2 s while the surface is visible** (06 ▸ Interaction with
|
||||
/// external writers, blessed 2026-07-31): "the watcher never delivers `.git`, so no board event
|
||||
/// can carry a terminal-side config edit — the unfocused-resync courtesy needs its own signal, and
|
||||
/// a visibility-scoped poll is the 15 s paused-state re-read's shape at sheet cadence (a focused
|
||||
/// field keeps its keystrokes; dismissing the sheet stops the poll)."
|
||||
private static let pollInterval: Duration = .seconds(2)
|
||||
|
||||
var body: some View {
|
||||
|
||||
@@ -424,6 +424,13 @@ private struct BoardGitAddAction: View {
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
// **The form add-git answers at** (06 ▸ Interaction with external writers, ruled 2026-07-31 —
|
||||
// "Form-anchored operations answer at the form first"; its container moved to the board
|
||||
// settings sheet in the 2026-07-31 split, and these two lines are what that sheet re-points).
|
||||
// Appearing claims the inline surface; disappearing gives it up, which both dismisses the
|
||||
// stale error and sends any answer still in flight to the banner instead of to nobody.
|
||||
.onAppear { git.noteFormVisible(true) }
|
||||
.onDisappear { git.noteFormVisible(false) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,12 +43,18 @@ import Observation
|
||||
/// `NSUndoManager` of its own, which is what makes it session-scoped). What lives here is the other
|
||||
/// half of 05 ▸ Edit's undo sentence: the **session**, whose end is the effective Save.
|
||||
/// `endEditSession()` is that moment — the Edit→Preview flip, raw-source entry, or the window
|
||||
/// closing — and it is deliberately a named call rather than a side effect of `flush()`, because
|
||||
/// pro-m1's auto-commit coalesces exactly here: every debounced tick inside one session rides its
|
||||
/// own `performWrite` bracket, and the committer's rule is one commit per *session*, "never per save
|
||||
/// tick" (06-history-undo.md ▸ Rules ▸ Auto-commit). In the free tier there is no committer, so
|
||||
/// the two calls do the same work today; the seam is what keeps them from having to be pulled apart
|
||||
/// later.
|
||||
/// closing — and it is deliberately a named call rather than a side effect of `flush()`, because it
|
||||
/// is where this session's **one undo step** is registered: every debounced tick inside a session
|
||||
/// rides its own `performWrite` bracket, and 13-native-undo.md's rule is one step per session, never
|
||||
/// per save tick (`registerUndo`).
|
||||
///
|
||||
/// **The commit boundary is no longer here** (06-history-undo.md ▸ Rules ▸ Auto-commit, widened
|
||||
/// 2026-07-31 — "Board history sees card-window sessions, not gestures"): the committer's
|
||||
/// stage-around used to open and close with this flip, and it now opens with the *window* and
|
||||
/// releases when the window's session ends, so a comment posted with the body in Preview is inside
|
||||
/// the same one commit as the body. What this type still owes that model is `isEditing`, read as
|
||||
/// unsaved-content state rather than announced (`CardWindowHost`); the announcement seam it used to
|
||||
/// wire went with the widening.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class CardBodyEditSession {
|
||||
@@ -110,23 +116,6 @@ public final class CardBodyEditSession {
|
||||
@ObservationIgnored
|
||||
public var registerUndo: ((_ priorBody: String, _ newBody: String) -> Void)?
|
||||
|
||||
/// **The session boundary, announced** — called with `true` when an Edit session opens and
|
||||
/// `false` when it ends, and with nothing in between.
|
||||
///
|
||||
/// **No longer the committer's stage-around boundary** (06-history-undo.md ▸ Rules ▸ Auto-commit,
|
||||
/// widened 2026-07-31 — "Board history sees card-window sessions, not gestures"): the exclusion
|
||||
/// used to open and close with this flip, and it now opens with the *window* and releases when its
|
||||
/// session ends, so a comment posted with the body in Preview is inside the same one commit as the
|
||||
/// body. `CardWindowHost` therefore no longer wires this to anything, and the Edit→Preview flip is
|
||||
/// a save point rather than a commit point.
|
||||
///
|
||||
/// The seam stays, unwired, because it is the only announcement of the boundary this type makes
|
||||
/// and the ordering it carries — that the flush precedes the announcement — is a property worth
|
||||
/// keeping proved (`AutoCommitTests`). A closure for `save`'s reason exactly: this type is a
|
||||
/// buffer and a clock, and it stays testable by having no idea what a repository is.
|
||||
@ObservationIgnored
|
||||
public var editSessionDidChange: ((_ isEditing: Bool) -> Void)?
|
||||
|
||||
/// What disk said before this session's **first** landed save — the step's before-value, held
|
||||
/// from the first write until the session ends.
|
||||
///
|
||||
@@ -194,16 +183,16 @@ public final class CardBodyEditSession {
|
||||
/// because its card's body was empty (`CardBodyMode.opening(body:)`).
|
||||
///
|
||||
/// Idempotent, because the mode can be re-asserted by a menu validation pass or a re-published
|
||||
/// focus value, and a second announcement would register a session that is already registered.
|
||||
/// focus value, and `isEditing` is observed: assigning `true` over `true` would still notify, and
|
||||
/// the notification is a view rebuild for a session that never changed (`adopt(diskBody:)`'s
|
||||
/// equal-assignment note, one property along).
|
||||
public func beginEditSession() {
|
||||
guard !isEditing else { return }
|
||||
isEditing = true
|
||||
editSessionDidChange?(true)
|
||||
}
|
||||
|
||||
/// The end of one Edit session — the flip back to Preview, raw-source entry, or the window
|
||||
/// closing. Flushes, and marks the boundary pro-m1's auto-commit coalesces on (see the type's
|
||||
/// doc comment).
|
||||
/// closing. Flushes, then registers the session's one undo step (see the type's doc comment).
|
||||
@discardableResult
|
||||
public func endEditSession() -> CardBodyWriteOutcome {
|
||||
let outcome = flush()
|
||||
@@ -216,14 +205,9 @@ public final class CardBodyEditSession {
|
||||
registerUndo?(origin, disk)
|
||||
}
|
||||
sessionOriginBody = nil
|
||||
// **Last**, after the flush and after the undo step: the committer's nudge must find the
|
||||
// session's final bytes already on disk, or the commit it arms would carry the file as it
|
||||
// stood one keystroke ago. Guarded on `isEditing` so a window closing from Preview — which
|
||||
// calls this too, and should — announces nothing.
|
||||
if isEditing {
|
||||
isEditing = false
|
||||
editSessionDidChange?(false)
|
||||
}
|
||||
// Guarded on `isEditing` for `beginEditSession`'s reason — a window closing from Preview
|
||||
// calls this too, and should, but has no session flag to move.
|
||||
if isEditing { isEditing = false }
|
||||
return outcome
|
||||
}
|
||||
|
||||
@@ -243,10 +227,7 @@ public final class CardBodyEditSession {
|
||||
cancelPending()
|
||||
text = disk
|
||||
sessionOriginBody = nil
|
||||
if isEditing {
|
||||
isEditing = false
|
||||
editSessionDidChange?(false)
|
||||
}
|
||||
if isEditing { isEditing = false }
|
||||
}
|
||||
|
||||
/// `DirtyBufferGuard`'s `attemptSave`: the same flush, with a real failure raised instead of
|
||||
|
||||
@@ -105,6 +105,14 @@ public final class CardComments {
|
||||
|
||||
/// The crash-residue sweep, run once when the window opens —
|
||||
/// `BoardStore.sweepCommentTrashResidue(inCard:)`.
|
||||
///
|
||||
/// **It is a sweep of *residue*, not of the trash** (13-native-undo.md ▸ Interaction with the
|
||||
/// trash, ruled 2026-07-31): content a live board step still restores from is that step's backing
|
||||
/// and is left standing, so reopening a card can never destroy the last session's undo. **Nor the
|
||||
/// bytes a collision left**, since the same day's skip-purge decoupling: a coarse step popped as
|
||||
/// stale applied nothing and destroys nothing, and what it was holding stands until the board
|
||||
/// session ends. The gate lives store-side, where the stack is; this seam is unchanged and still
|
||||
/// called exactly once, by `open()`.
|
||||
@ObservationIgnored
|
||||
public var sweepTrashResidue: (() -> Void)?
|
||||
|
||||
|
||||
Reference in New Issue
Block a user