Files
lanework/Kanban/LiveStore/BoardStoreComments.swift
T
rzen 274ccd9ff5 Realign code with the 2026-07-31 findings-resolution rulings
The full bullet list from Implementation card bf080d9a — both ruling
batches, including the three appended mid-session by 16ef377:

- Restore subjects compose the inverse, never nest: crossing "Undo: S"
  emits "Redo: S" and vice versa; parity, not stack depth, reads a
  legacy double prefix (GitHistoryProvider.restoreSubject).
- Git-operation failures join the one-shot failure banner tier:
  BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error
  tone at failure rank merged with write one-shots by recency; the
  postLoss compromise is retired at both AppModel wirings.
- order/schema optional below the board root: append-at-end reading
  (ordered siblings first, folder-name tie-break among the order-less),
  schema reads 1, both coerce-tier logged; the root keeps its
  requirements. Ranks.resolvedOrders materializes finite ranks so
  models and placement math stay untouched; first Writer rewrite
  stamps a real rank on touch, placement against an order-less sibling
  stamps that sibling inline in the same bracket. Agent guide v10
  teaches optional keys and zero-read filing. Hostile-YAML order
  shapes become coercion tests; Fixtures/Valid/optional-keys.kanban
  replaces the four retired Malformed boards.
- .gitignore is the relocation-heal noise gate: GitignoreRules pure
  matcher (standard semantics, board-root file only), loader consults
  it once per walk so matched loose files keep the stray posture;
  seeded (.DS_Store + .*.lanework-*) at board creation and template
  instantiation, healed in when missing at open — repo-nested
  included; empty file honored, existing files never edited; the
  committer's obedience via libgit2 status is pinned by test.
- Comments crash-residue sweep gates on step ownership: HistoryStep
  derives backing from its own undo expectations, backedContent unions
  both stacks, the sweep purges per-entry only what no live step owns.
- Skip-purge decoupled (16ef377): a stale-skipped coarse step strands
  whole in NativeHistoryProvider.strandedSteps — still backing, retired
  only at session end; clean exits purge as before.
- Coarse close step named "Changes to '<card>'"; the fine body-edit
  wording never leaks onto the board menu.
- Branch-switch settle clears every open card window's fine stack on
  Save All and Discard alike; the empty fold registers no coarse step.
- Close flush awaits its covering snapshot (quiesce + one generation
  bump, 1s bound), and an explicit flush now queues behind an
  in-flight one instead of skipping — the audit-caught interleaving
  could lose a close flush permanently when the debounce fired inside
  the close sequence; regression tests force both races.
- Commit comment bullets sort chronologically by created, not UUID.
- The production-unwired CardBodyEditSession.editSessionDidChange seam
  is deleted with its seam-only tests.
- Composition-root pins: beginSession composes the committer with the
  store's own EchoLedger and binds the announcer (the miswire class).
- Deliberate 06 conformance pass over every 2026-07-31-tagged
  sentence: fixed Change-custom-key subjects (the retired named
  generic was the only producer), the unbuilt Replace attachment
  vocabulary, heal commits now authored Lanework Integrity, the config
  reader scopes identity to plain [user] sections, add-git re-runs
  detection at create (a stale mode-none could initialize inside the
  user's repo), and add-git failures answer at the form or the banner.
  Structural residue filed on the Redesign board.

2554 tests / 439 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-01 07:43:45 -04:00

495 lines
28 KiB
Swift

import Foundation
// MARK: - CommentTarget
/// Which of a card's two **authoring surfaces** a write is aimed at: the composer's draft, or one
/// posted comment being edited inline (05-card-window.md ▸ The comments column — the hover-target
/// carve-out's two destinations).
///
/// It exists because the pair is a *choice the view makes* and the store must not re-derive: which
/// surface the pointer was over when a file was dropped is knowledge only the window has, and the
/// alternative — two near-identical store methods — would put the choice in the call site's name
/// instead of in a value a test can hold.
///
/// `comments/.trash/` is deliberately not a case: a deleted comment is undo's backing store and
/// "never a UI surface" (01-storage-format.md § Enhanced schema), so there is no gesture that could
/// aim at one.
public enum CommentTarget: Sendable, Equatable {
/// `comments/.draft/` — the composer's backing file.
case draft
/// `comments/<uuid>/` — a posted comment with an inline edit session open over it.
case comment(ItemID)
/// Where the target lives, given the card's folder. One resolution, so a view and a write can
/// never disagree about which folder "the composer" means.
public func folder(inCard cardFolder: URL) -> URL {
switch self {
case .draft: CommentThread.draftFolder(inCard: cardFolder)
case let .comment(id): CommentThread.commentFolder(id, inCard: cardFolder)
}
}
}
// MARK: - The comment gestures
/// **The comment thread at the store boundary** — every comment write the app makes, bracketed, with
/// its inverse registered where 13-native-undo.md says one belongs.
///
/// An extension in its own file for `BoardStoreHistory.swift`'s reason, and with its reason for
/// living on `BoardStore` at all: comments are read by the *card window* and written through the
/// *board's* store — one store per board, whatever the window (02-architecture.md § Components), so a
/// comment posted here takes the same bracket, the same receipts and the same banner surface as every
/// other write in the app.
///
/// **Which stack the steps land on is not the store's answer** (re-ruled 2026-07-31 — the
/// session-coarsening model, 13 ▸ Rules ▸ two levels): every gesture below takes the issuing window's
/// stack as a parameter, because a comment gesture is a *card window's* gesture and "the board stack
/// never carries a granular comment step" (13 ▸ Interaction with the trash). Board history sees the
/// window's session as one coarse step when the window closes (`registerCardSession`).
///
/// ### What registers a step, and what deliberately does not
///
/// - **`postComment`** — inverse: the rename back to `.draft`. Move-based, no capture.
/// - **`deleteComment`** — inverse: the move back out of `comments/.trash/`. Move-based, no capture.
/// - **`registerCommentEdit`** — one *session*, at its commit point, holding the bytes it opened on
/// (added 2026-07-31 with the window stack, which is where 13 now puts the inline edit). The
/// per-tick `editComment` write registers nothing, exactly as a card body's per-tick save does not.
/// - **`saveCommentDraft` registers nothing**: a draft is a durable file being edited in place, with
/// no session boundary to coalesce at and no meaning for "undo" that emptying it does not already
/// have (05-card-window.md ▸ The comments column).
/// - **`purgeCommentTrash` registers nothing** — the permanent-delete posture. It is also what makes
/// the interaction with the stack correct for free: leftover comment steps go stale after a purge
/// and skip with the ordinary info-tone banner, because the folders their expectations name are
/// gone. **The expectations validate disk, never the snapshot** (`HistoryStaleness`), which is
/// exactly why this works — comments are not in the snapshot at all.
@MainActor
extension BoardStore {
// MARK: Reading
/// One card's thread, read fresh from disk — **window-scoped**, never cached on the store and
/// never part of `snapshot` (01-storage-format.md § Enhanced schema: "the board snapshot never
/// loads comment content", so the board walk stays O(cards)).
///
/// `.empty` for an id that names no card on the board — the same vanished-target answer every
/// other card-scoped call gives (`boardItem`).
public func commentThread(inCard id: ItemID) -> CommentThread {
guard let card = commentSubject(id) else { return .empty }
return CommentThread.load(inCard: card.folder, path: card.path)
}
/// **The composer's own file, read** — `comments/.draft/`, excluded from the thread listing and
/// therefore asked for by name (`CommentThread.loadDraft`).
///
/// `nil` for a card with no draft, an unreadable one, or an id that names no card — the same
/// vanished-target answer `commentThread(inCard:)` gives, and the same "nothing to restore".
public func commentDraft(inCard id: ItemID) -> CommentDraft? {
guard let card = commentSubject(id) else { return nil }
return CommentThread.loadDraft(inCard: card.folder)
}
/// **Which of this card's comments the app itself just wrote** — the ledger's receipts, classified
/// by path shape and retired (`EchoLedger.vouchedComments(inCard:cardPath:)`).
///
/// The card window asks this on every landed reload, before deciding whether its thread's changes
/// are worth announcing: "app-mediated echoes never announce" (10-accessibility.md ▸ Live board
/// announcements) needs a per-comment answer, and the board reload cannot give one because comments
/// are not in the snapshot it compares.
///
/// `[]` for a card that is gone — the same vanished-target answer every other card-scoped call
/// gives, and the conservative direction: nothing vouched for means everything speaks.
public func vouchedComments(inCard id: ItemID) -> Set<ItemID> {
guard let card = commentSubject(id) else { return [] }
return echoes.vouchedComments(inCard: card.folder, cardPath: card.path)
}
// MARK: The draft
/// Saves the composer's draft — one bracket, no step.
///
/// - Returns: what landed, or `nil` when the write failed (the banner is already
/// `performWrite`'s) or the card is gone.
@discardableResult
public func saveCommentDraft(inCard id: ItemID, body: String) -> CommentDraftOutcome? {
guard let card = commentSubject(id) else { return nil }
return try? performWrite { () throws(BoardWriteError) -> CommentDraftOutcome in
try BoardWriter.saveCommentDraft(inCard: card.folder, body: body, cardTitle: card.title)
}
}
/// **Posts the draft, and registers the one step the gesture owes** (⌘↩ or the Comment button).
///
/// The step's predicate is the brief the ruling gives it: the undo needs the posted folder still
/// at its path **and `.draft` still absent** — a draft the user has started typing since is not
/// this step's to overwrite — and the redo needs the mirror. Both are `HistoryExpectation`s over
/// disk, and the `.absent` half is the same one an undone create uses.
///
/// **The redo replays the captured identity and the captured instant**, never fresh ones: a redo
/// that re-minted would post a *different* comment, and any step registered above this one naming
/// the posted id would name nothing.
///
/// **The step lands on the issuing window's stack** (13 ▸ Rules ▸ two levels, re-ruled
/// 2026-07-31): the composer is a card window's surface, so the window passes its own
/// (`CardWindowHost.configureComments`). `nil` is the board's stack — a caller with no window,
/// which in the app is nobody and in a test is the shortest way to drive the write.
@discardableResult
public func postComment(inCard id: ItemID, on window: CardWindowUndo? = nil) -> ItemID? {
guard let card = commentSubject(id) else { return nil }
guard let posted = try? performWrite({ () throws(BoardWriteError) -> PostedComment in
try BoardWriter.postComment(inCard: card.folder, cardTitle: card.title)
}) else {
return nil
}
let title = card.title
// **Anchored to the card, not to its folder** (13 ▸ Rules, ruled 2026-07-31): both halves of
// the move name the card's identity plus the thread position, and the card's folder is
// resolved at apply time — so a lane move under an open window leaves this step, and the
// coarse step folding it, exactly as sound as they were.
let cardAnchor = HistoryAnchor.card(id)
let operation = WriteOperation.postComment(title: title)
registerStep(
HistoryPhrase.comment,
subject: title,
on: window,
undoExpects: [.present(.comment(posted.id, inCard: id)), .absent(.commentDraft(inCard: id))],
redoExpects: [.present(.commentDraft(inCard: id)), .absent(.comment(posted.id, inCard: id))]
) { store in
try BoardWriter.unpostComment(
posted.id,
inCard: try store.requiredFolder(for: cardAnchor, operation),
cardTitle: title
)
} redo: { store in
try BoardWriter.repostComment(
as: posted.id,
inCard: try store.requiredFolder(for: cardAnchor, operation),
stamping: posted.posted,
cardTitle: title
)
}
return posted.id
}
// MARK: Editing
/// An inline edit session's save — one bracket, **no step**, exactly as a card body's ~700 ms
/// tick writes no step: a session is not a save (`registerCommentEdit`, below).
///
/// - Returns: whether bytes were written; `false` also for a card or comment that is gone.
@discardableResult
public func editComment(_ commentID: ItemID, inCard id: ItemID, body: String) -> Bool {
guard let card = commentSubject(id) else { return false }
let folder = CommentThread.commentFolder(commentID, inCard: card.folder)
let wrote = try? performWrite { () throws(BoardWriteError) -> Bool in
try BoardWriter.editComment(at: folder, body: body, cardTitle: card.title)
}
return wrote ?? false
}
/// **Registers one inline edit session as one step** — `registerBodyEdit`'s shape one level down,
/// and its reasons verbatim.
///
/// The window stack is what made this possible and what made it necessary. 13-native-undo.md's
/// no-byte-capture rule is about the *board* stack, where a comment's granular history has no
/// business ("the board stack never carries a granular comment step"); the re-ruled two-level
/// model puts every gesture issued in a card window on that window's own stack at fine grain,
/// "comment post/delete/**edit**" named among them. So an inline edit registers where the body's
/// Edit session registers, at the same kind of boundary — Save, ⌘↩, or the close flush — with the
/// bytes the session opened on (`CommentEditSession.sessionStart`).
///
/// Cancel registers nothing, and needs no rule of its own: it writes the session-start bytes back,
/// so the session's net effect is nothing and there is nothing to undo.
///
/// Its predicate is the body's: "body steps compare bytes" (13 ▸ Rules), against the comment's own
/// folder — so a foreign edit to that comment skips the step, and a foreign edit to the card or to
/// a sibling comment leaves it alone.
public func registerCommentEdit(
_ commentID: ItemID,
inCard id: ItemID,
priorBody: String,
newBody: String,
on window: CardWindowUndo? = nil
) {
guard priorBody != newBody, let card = commentSubject(id) else { return }
let title = card.title
let comment = HistoryAnchor.comment(commentID, inCard: id)
let operation = WriteOperation.editComment(title: title)
registerStep(
HistoryPhrase.name(.edit, kind: .comment),
subject: title,
on: window,
undoExpects: [.present(comment, .body(newBody))],
redoExpects: [.present(comment, .body(priorBody))]
) { store in
_ = try BoardWriter.editComment(
at: try store.requiredFolder(for: comment, operation),
body: priorBody,
cardTitle: title
)
} redo: { store in
_ = try BoardWriter.editComment(
at: try store.requiredFolder(for: comment, operation),
body: newBody,
cardTitle: title
)
}
}
// MARK: Delete and its inverse
/// **Deletes a comment — a move into `comments/.trash/`, immediate, no confirm, undoable**
/// (01-storage-format.md § Enhanced schema; 05-card-window.md ▸ The comments column).
///
/// The step is the move read backwards, `moveToTrash`'s registration one level down and without
/// its rank half: a comment's trash has no order, so there is no `.order` after-value to compare
/// and existence is the whole predicate. The container rides in the *anchor* exactly as it rides
/// in the path at board level — the undo expects the comment in `comments/.trash/`, the redo
/// expects it back in the thread — so a foreign restore or a foreign re-delete skips the right
/// half by itself, while the card's own lane is nowhere in either claim.
///
/// **Both sides of the move are declared**, `postComment`'s shape (added 2026-07-31 with the
/// window stack): the undo needs the trashed folder there *and the live path free*, because the
/// move back would otherwise land on top of whatever now sits at it — and because the close fold
/// reads these lists as the state the session left. A move that named only its destination would
/// leave the session's fold claiming a comment is still at a path it has left, and the coarse step
/// would be stale the moment it was registered (`CardWindowUndo.netEffect`).
///
/// **The step lands on the issuing window's stack** — `postComment`'s rule, and the one 13's
/// comments paragraph states outright: "the step lives on the card window's own stack ... so the
/// 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 }
let folder = card.folder
let title = card.title
let live = CommentThread.commentFolder(commentID, inCard: folder)
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
_ = try BoardWriter.deleteComment(at: live, cardTitle: title)
}
guard landed != nil else { return false }
// `postComment`'s anchoring, for its reason: the pair of anchors is the move, and the card's
// folder is resolved at apply time rather than baked into the step (13 ▸ Rules, ruled
// 2026-07-31).
let cardAnchor = HistoryAnchor.card(id)
let comment = HistoryAnchor.comment(commentID, inCard: id)
let trashed = HistoryAnchor.trashedComment(commentID, inCard: id)
let operation = WriteOperation.deleteComment(title: title)
registerStep(
HistoryPhrase.name(.delete, kind: .comment),
subject: title,
on: window,
undoExpects: [.present(trashed), .absent(comment)],
redoExpects: [.present(comment), .absent(trashed)]
) { store in
try BoardWriter.restoreComment(
commentID,
inCard: try store.requiredFolder(for: cardAnchor, operation),
cardTitle: title
)
} redo: { store in
_ = try BoardWriter.deleteComment(
at: try store.requiredFolder(for: comment, operation),
cardTitle: title
)
}
return true
}
// MARK: The purge, and the crash residue it leaves
/// **Empties one card's `comments/.trash/`** — **when undo no longer needs it** (01-storage-format.md
/// § Enhanced schema, re-ruled 2026-07-31; 13-native-undo.md ▸ Interaction with the trash).
///
/// 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 **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
/// ordinary info-tone banner the first time one is crossed.
public func purgeCommentTrash(inCard id: ItemID) {
guard let card = commentSubject(id) else { return }
try? performWrite { () throws(BoardWriteError) -> Void in
_ = try BoardWriter.purgeCommentTrash(inCard: card.folder)
}
}
/// **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").
///
/// ### 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.
///
/// The memo is board-wide and keyed by class, so two cards' residue swept in turn re-arm each
/// other's picture. That is harmless rather than tolerated: the picture *is* the work, the write
/// half re-verifies against disk, and a sweep with nothing to remove is a no-op.
public func sweepCommentTrashResidue(inCard id: ItemID) {
guard let card = commentSubject(id) else { return }
let folder = card.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
for commentID in residue {
try BoardWriter.purgeItem(at: CommentThread.trashedCommentFolder(commentID, inCard: folder))
}
}
}
// MARK: The authoring surfaces' attachments
/// **Imports files into the draft's or one comment's `attachments/`** — the composer's and the
/// inline editor's drop carve-out and paperclip (05-card-window.md ▸ The comments column).
///
/// One bracket, **no step**: an attachment import registers nothing at card level either
/// (`importAttachments`), and 13-native-undo.md's inventory does not grow because a file landed
/// one folder deeper.
///
/// A vanished card, or a target folder that is not an authoring surface, writes nothing — the
/// Writer's own guard, reached through the ordinary bracket so a failure banners like any other.
public func importCommentAttachments(_ urls: [URL], inCard id: ItemID, target: CommentTarget) {
guard !urls.isEmpty, let card = commentSubject(id) else { return }
let folder = target.folder(inCard: card.folder)
try? performWrite { () throws(BoardWriteError) -> Void in
_ = try BoardWriter.importCommentAttachments(urls, intoComment: folder)
}
}
/// **Moves one authoring chip's file to the system Trash** — never a hard delete, the sidebar
/// row's rule one level down (05-card-window.md ▸ The comments column).
///
/// A name that is no longer there is a silent no-op rather than a failure: the reload is the
/// authority on what a folder holds (`BoardWriter.trashAttachment`).
public func removeCommentAttachment(named name: String, inCard id: ItemID, target: CommentTarget) {
guard !name.isEmpty, let card = commentSubject(id) else { return }
let folder = target.folder(inCard: card.folder)
try? performWrite { () throws(BoardWriteError) -> Void in
_ = try BoardWriter.removeCommentAttachment(named: name, fromComment: folder)
}
}
// MARK: The thread's claimed names
/// Displaces the claimed names one thread read found squatted — `comments/.draft`,
/// `comments/.trash`, and a comment's own `attachments` (01-storage-format.md § Fractal layout
/// ▸ Rules, the level-uniform displacement, applied at the two levels the thread adds).
///
/// **One bracket over the batch, and no memo**, which is the one place this heal differs from its
/// board-level twin (`displaceClaimedNames`) — deliberately, and worth stating:
/// `HealScheduler`'s memo is keyed by defect *class*, and these squatters are the same class as
/// the board walk's. Sharing the key would have one card's thread picture overwrite the board's
/// and back again on every reload, so the two would spend their memos fighting instead of guarding.
/// What replaces the memo here is the trigger: a thread is re-read when its window opens or its
/// files change, not on a timer, so there is no hot loop for a memo to break.
///
/// - Parameter squatters: the defects a `CommentThread` read reported (its `defects`, filtered).
/// - Returns: what was actually moved aside, for the caller's warning-tone notice — the Writer
/// re-verifies each against disk and answers `nil` for one that freed itself.
@discardableResult
public func displaceCommentClaimedNames(_ squatters: [ClaimedNameSquatter]) -> [BannerCenter.Displacement] {
guard !squatters.isEmpty else { return [] }
let root = rootURL
var displaced: [BannerCenter.Displacement] = []
try? performWrite { () throws(BoardWriteError) -> Void in
for squatter in squatters {
guard let freed = try BoardWriter.displaceClaimedName(squatter, atBoardRoot: root) else {
continue
}
displaced.append(BannerCenter.Displacement(name: squatter.name, movedTo: freed))
}
}
return displaced
}
// MARK: - Resolving the card
/// Where a card's thread lives and what to call the gesture — the three facts every comment write
/// needs, resolved off the snapshot the way every other card-scoped call resolves them
/// (`boardItem`): `nil` for an id that names no live card, which is the vanished-target guard.
///
/// The path is rebuilt from `rootURL` rather than remembered, so a board renamed mid-session
/// writes at its new location.
private func commentSubject(_ id: ItemID) -> (folder: URL, path: String, title: String?)? {
guard let item = Self.boardItem(id, in: snapshot), let cardID = item.cardID else { return nil }
return (
folder: ItemPath.card(lane: item.laneID, id: cardID).folder(under: rootURL),
path: "\(item.laneID.rawValue)/\(cardID.rawValue)",
title: item.title
)
}
}