Orphaned .backgrounds/ files get tidied — a repoint trims the one it leaves behind, and every open sweeps what got away

Follow-up to d0c5461's .backgrounds/ folder: generating, pasting, or
choosing solid now trims the app's own prior file in .backgrounds/ as
part of the same write when it repoints or unsets background.image
away from it — silent, best-effort, never blocking the gesture that
triggered it. A scheduled heal at every board open sweeps whatever
that trim declined or missed: any .backgrounds/ file the board's
current background.image no longer names, announced with a loss-row
notice in the loose-file relocation's own voice. Legacy root-level
references are untouched by both paths — the tidy scopes to
.backgrounds/ only, since that is the one folder the app can prove it
wrote into.

Removal is via FileManager.trashItem, matching the attachment-removal
precedent (recoverable, never a hard delete).

Supersedes applySolidBackground's earlier "facets.png survives on
disk" contract for the settled case: the ruling reads "unsets" as one
more shape of "repoints away from a .backgrounds/ file", so a settled
solid choice now trims the generated picture it displaces, same as
switching producers does. The superseded test and doc comment are
updated to the new behavior; the echo-window (no-reload-yet) case is
unaffected and still leaves the file in place.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-09 09:44:30 -04:00
parent d905e73960
commit ca0328be2e
9 changed files with 858 additions and 26 deletions
+48
View File
@@ -602,6 +602,27 @@ public final class BannerCenter {
postLoss(message)
}
/// **The orphaned background-image sweep** (01-storage-format.md § Validation and healing, ruled
/// 2026-08-09: "the open-time heal posts the calm notice-family banner the loose-file relocation
/// heal uses state what was tidied"): a file in `.backgrounds/` that nothing on the board named
/// any more was moved to the system Trash, and this is the row that says so.
///
/// **A loss row, on `postRelocatedLooseFiles`' exact reasoning**: the app removed a file on its own
/// initiative, on a board it opened rather than on a gesture the user made that must be said out
/// loud, must not evaporate unread, and must not rank as an error, because nothing failed.
///
/// **Only the sweep posts this.** The in-flow replace-in-place trim a background apply makes on
/// its own prior file is deliberately silent the ruling's "temp-file posture": the app
/// superseding its own file inside a gesture the user already sees succeed (a fresh picture lands)
/// is not news the way a heal discovered on open is.
///
/// `names` are the bare filenames the sweep actually removed, in the order it removed them. A
/// sweep that removed nothing posts nothing.
public func postTidiedBackgroundImages(_ names: [String]) {
guard let message = Self.tidiedBackgroundImagesMessage(for: names) else { return }
postLoss(message)
}
/// **The skip notice** (01-storage-format.md § Malformed input, ruled 2026-07-31): the decision
/// surface offered Skip on a defect the app has no honest repair for, the user consented, the
/// board opened without that item "the file stays on disk untouched, tolerated-invisible like
@@ -973,6 +994,15 @@ public final class BannerCenter {
// failure the user did not provoke is exactly the one they have no other way to learn
// about.
"Couldn't move '\(filename)' into attachments"
case let .tidyBackgroundImage(filename):
// **The successful sweep's own verb, negated** `postTidiedBackgroundImages`' "Removed
// '\(filename)' " said the other way round, `.relocateLooseFile`'s own precedent: the
// failure reads as the same sentence's negation rather than a different event. It stays
// in the ordinary one-shot precedence class for `.relocateLooseFile`'s reason too this
// is work the app started on its own, and a failure the user did not provoke is exactly
// the one they have no other way to learn about. In practice only the sweep's own failure
// ever reaches here; the in-flow trim swallows its own (the ruling's "temp-file posture").
"Couldn't remove '\(filename)'"
case .agentGuide:
// **"the agent guide", not "CLAUDE.md"**: the file is the app's, written for agents, and
// most users will never have opened it a filename here would name something they have
@@ -1284,6 +1314,24 @@ public final class BannerCenter {
return "Repaired duplicate id — \(sole(only))"
}
/// The orphaned background-image sweep's line, `migratedTombstonesMessage`'s voice: the act
/// first, the subject after an em dash, plurals folded, a tail naming the cause without it the
/// sentence would read as something that happened *to* the board rather than the app's own
/// housekeeping.
///
/// - **One**: "Removed 'facets 2.png' it was no longer the board's background".
/// - **Several**: "Removed 3 background images they were no longer the board's background".
///
/// `nil` when nothing was removed a sweep that swept nothing is not news.
public nonisolated static func tidiedBackgroundImagesMessage(for names: [String]) -> String? {
guard !names.isEmpty else { return nil }
let subject = names.count == 1 ? "'\(names[0])'" : "\(names.count) background images"
let tail = names.count == 1
? "it was no longer the board's background"
: "they were no longer the board's background"
return "Removed \(subject)\(tail)"
}
/// The lossy export's line, in the relocation family's voice the act first, the cause after an
/// em dash, plurals folded into their counts.
///
+200 -13
View File
@@ -2056,8 +2056,27 @@ public final class BoardStore: HealHost {
/// alternative a temp copy, a versioned name buys byte-perfect undo of a picture nobody asked
/// to keep at the price of litter in a folder the user owns.
///
/// ### The prior `.backgrounds/` file is trimmed, not just orphaned
///
/// **The replace-in-place half of the orphan tidy** (01-storage-format.md § Validation and
/// healing, ruled 2026-08-09): once the new picture and the field both land, `snapshot`'s own prior
/// image read before this write, the same value the undo step above closes over is trimmed
/// from `.backgrounds/` when it named a file of ours different from the one this write just landed
/// on (`tidyReplacedBackgroundImage`). Switching producers (a settled paste, then a generate) no
/// longer leaves the earlier picture behind forever; a regeneration over the *same* name is
/// unaffected, because there is no "old" file distinct from the new one to trim.
///
/// **This sharpens the undo note above, not contradicts it**: an undo that restores a prior
/// `.backgrounds/` reference now restores a field that may point at bytes the Trash holds rather
/// than the board folder the missing-file degrade `BoardBackdropImage` already has for a
/// hand-deleted file, and recoverable exactly as one, on the ruling's own "temp-file posture". A
/// board's own `snapshot` is what decides whether the trim fires at all: back-to-back gestures
/// inside one reload's echo window (`theTwoProducersStayApart`) see a `nil` prior and trim nothing,
/// the same staleness that already gates the echo naming above.
///
/// Failure is `performWrite`'s: the banner is posted before the rethrow, which is swallowed here
/// like every other gesture with no second thing to do about it.
/// like every other gesture with no second thing to do about it. The trim's own failure is
/// swallowed independently and never reaches this banner at all see `tidyReplacedBackgroundImage`.
///
/// - Parameter png: the encoded image, already rendered (`FacetsGenerator.pngData`).
/// - Parameter colorHex: the ground colour of that render (`FacetsRecipe.primaryColorHex`)
@@ -2088,6 +2107,10 @@ public final class BoardStore: HealHost {
) { document in
Self.pointBackground(at: target.reference, color: colorHex, in: &document)
}
// The replace-in-place half of the orphan tidy (ruled 2026-08-09) see its own note.
Self.tidyReplacedBackgroundImage(
priorImage: priorImage.value, newReference: target.reference, inFolder: backgroundsFolder
)
}
guard landed != nil else { return false }
generatedBackgroundEcho = (
@@ -2133,15 +2156,21 @@ public final class BoardStore: HealHost {
/// undo-restores-both-fields shape, the same `WriteOperation.setBoardBackground`, the same
/// swallowed failure.
///
/// ### `facets.png` survives on disk
/// ### `facets.png` is trimmed when the switch has settled
///
/// Choosing a solid colour over a generated background does **not** delete the picture the
/// generator wrote. Undo restores the `image` *field*, and a field cannot point an undo back at
/// bytes this gesture just erased so the file has to survive for the same reason
/// `applyGeneratedBackground`'s own overwrite-in-place does. A board that regenerates after
/// choosing solid still finds `facets.png` free to overwrite in place; the file becomes an orphan
/// only when nothing in the frontmatter ever points at it again, which is the same quiet leftover
/// a hand-deleted `image:` line already leaves.
/// **Superseded by the ruling below** (01-storage-format.md § Validation and healing, ruled
/// 2026-08-09): earlier, choosing a solid colour over a generated background never deleted the
/// picture the generator wrote, on exactly the reasoning `applyGeneratedBackground`'s own overwrite-
/// in-place note still gives for a same-name regeneration undo restores the `image` *field*, and
/// a field cannot point an undo back at bytes this gesture erased. The ruling reads "unsets" as one
/// more shape of "repoints away from a `.backgrounds/` file", so this gesture now trims that file
/// too, the same `tidyReplacedBackgroundImage` call `applyGeneratedBackground` and
/// `applyPastedBackground` make see that method's own note, including the accepted cost: an undo
/// back to the picture this gesture unset now restores a field that may point at bytes the Trash
/// holds rather than the board folder, gracefully, the same missing-file degrade a hand-deleted file
/// already has. The trim only fires once `snapshot` has caught up with the write it is trimming
/// two solid-colour gestures back to back inside one reload's echo window trim nothing on the
/// second, `applyGeneratedBackground`'s own staleness gate, restated here.
///
/// - Parameter colorHex: the solid colour to write, `#RRGGBB` one of the Theme tab's Solid color
/// swatches, which read `FacetsRecipe.primaryColorHex` at the filters' tone/saturation level.
@@ -2152,6 +2181,9 @@ public final class BoardStore: HealHost {
let root = rootURL
let priorImage = snapshot.backgroundImage
let priorColor = snapshot.background
let backgroundsFolder = root.appendingPathComponent(
BoardBackdrop.backgroundsFolderName, isDirectory: true
)
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
try BoardWriter.updateIndex(
@@ -2159,6 +2191,11 @@ public final class BoardStore: HealHost {
) { document in
Self.pointSolidBackground(color: colorHex, in: &document)
}
// The replace-in-place half of the orphan tidy (ruled 2026-08-09) see this method's own
// note. `newReference: nil`, the unset case: solid never writes an `image` subkey.
Self.tidyReplacedBackgroundImage(
priorImage: priorImage.value, newReference: nil, inFolder: backgroundsFolder
)
}
guard landed != nil else { return false }
@@ -2257,6 +2294,140 @@ public final class BoardStore: HealHost {
return (fresh, "\(BoardBackdrop.backgroundsFolderName)/\(fresh)")
}
// MARK: - The orphan tidy replace-in-place
/// **Trims the `.backgrounds/` file a background apply just repointed or unset away from** the
/// replace-in-place half of the orphan tidy (01-storage-format.md § Validation and healing, ruled
/// 2026-08-09: "when the app's own write repoints or unsets the board background away from a
/// `.backgrounds/` file, remove the now-unreferenced old file as part of the operation"), shared by
/// every gesture that can move `background.image` off one of the app's own files
/// `applyGeneratedBackground`, `applyPastedBackground`, `applySolidBackground` each of which calls
/// this as the last line inside its own write bracket, after both of its own writes have landed.
///
/// ### Silent and best-effort, the ruling's own "temp-file posture"
///
/// This rides inside a bracket whose actual job is landing the *new* state, so a failure to trim
/// the *old* file must never fail that write or surface a banner about it the user pressed
/// Generate, Paste, or a colour well, and what failed, as far as they are concerned, is whichever of
/// those the surrounding bracket's own banner already names. `try?` swallows whatever
/// `BoardWriter.trashBoardImage` throws; a file that could not be trimmed is simply left for the
/// scheduled sweep (`tidyBackgroundImages`) to find on the next open the same self-healing shape
/// every heal in this file has when a single attempt does not land.
///
/// `healMarked: false`: this is not a heal discovering work on its own, it is part of the very
/// gesture the user is watching succeed `relocateLooseFiles`' own distinction between its
/// scheduled caller and its inline one, applied here.
///
/// ### A no-op in three shapes, each deliberate
///
/// - `priorImage` is `nil` no field to have named an old file, or (`theTwoProducersStayApart`'s
/// echo window) `snapshot` has not yet caught up with the very write this trim would be reacting
/// to.
/// - `priorImage` names the file the write just landed on (`newReference` unchanged)
/// `applyGeneratedBackground`'s own overwrite-in-place case; there is no "old" file distinct from
/// the new one.
/// - `BoardBackdrop.qualifiedBareName(of:)` answers `nil` `priorImage` is not one of the app's own
/// `.backgrounds/` files at all: unqualified (a legacy reference, ruled "stays where it is no
/// migration"), a hand-placed image elsewhere in the board, or a path this tidy is not scoped to
/// touch (01's own "Tidy scopes to `.backgrounds/` ONLY").
private static func tidyReplacedBackgroundImage(
priorImage: String?, newReference: String?, inFolder backgroundsFolder: URL
) {
guard let priorImage, priorImage != newReference,
let bareName = BoardBackdrop.qualifiedBareName(of: priorImage)
else { return }
try? BoardWriter.trashBoardImage(
named: bareName, inFolder: backgroundsFolder, healMarked: false,
operation: .tidyBackgroundImage(filename: bareName)
)
}
// MARK: - The orphan tidy the open-time sweep
/// **Sweeps `.backgrounds/` for a file the board's current background no longer names** the
/// open-time half of the orphan tidy (01-storage-format.md § Validation and healing, ruled
/// 2026-08-09: "any file in `.backgrounds/` not referenced by the board's current
/// `background.image` is swept"), the safety net behind the replace-in-place trim
/// (`tidyReplacedBackgroundImage`): every case that trim declines the echo window, a foreign
/// write that repointed the field outside the app, a trim that failed and was swallowed leaves
/// exactly the picture this sweep is built to notice and clean up on the next open.
///
/// **Scheduling is the engine's** (`HealScheduler`), on every other scheduled heal's own terms: the
/// resting-clear, the lock-and-writability gate, the signature compare, the armed-before-attempt
/// memo, the one bracket, the banner posture and the clear-on-success are its six steps, and this
/// method is only what is genuinely this heal's which files are candidates, and what the notice
/// names.
///
/// **No `Defect` case** (`IntegrityRules.Defect.Class.orphanedBackgroundImages`'s own note): what is
/// orphaned is two board-root readings taken together at the moment of healing, not something a
/// tree walk over lanes and cards could carry forward `seedGitignore`'s and `refreshAgentGuide`'s
/// shape, not `relocateLooseCardFiles`'s.
///
/// **Announced, unlike the trim** (`HealScheduler.HealNotice.tidiedBackgroundImages`): a heal
/// discovered on open is news the ruling asks to be said out loud, the loose-file relocation's own
/// posture "state what was tidied" where the in-flow trim rides silently inside a gesture the
/// user already watches succeed.
///
/// **The write half re-verifies against disk**: `BoardWriter.trashBoardImage` re-checks each name's
/// existence at write time and no-ops when it is gone, so a file that vanished under the write
/// (a hand delete, a losing race with a foreign write) contributes no line to the notice.
public func tidyBackgroundImages() {
let root = rootURL
let backgroundsFolder = root.appendingPathComponent(
BoardBackdrop.backgroundsFolderName, isDirectory: true
)
let referenced = BoardBackdrop.qualifiedBareName(of: snapshot.backgroundImage.value)
let candidates = Self.orphanedBackgroundFileNames(inFolder: backgroundsFolder, keeping: referenced)
var tidied: [String] = []
heals.run(
.orphanedBackgroundImages,
signature: Set(candidates.map { "background:\($0)" }),
on: self
) { () throws(BoardWriteError) -> Void in
for name in candidates {
let landed = try BoardWriter.trashBoardImage(
named: name, inFolder: backgroundsFolder, healMarked: true,
operation: .tidyBackgroundImage(filename: name)
)
// Vanished under the write the reload is the authority on what is there, and this
// contributes no line, `relocateLooseCardFiles`'s own rule.
guard landed != nil else { continue }
tidied.append(name)
}
} posting: {
.tidiedBackgroundImages(tidied)
}
}
/// The `.backgrounds/` files nothing currently names `tidyBackgroundImages`'s candidate list, read
/// fresh at the moment of healing rather than carried from a tree walk (see that method's own
/// note).
///
/// A **missing** `.backgrounds/` lists as `[]`, not an error: nothing has ever been written there,
/// an ordinary state on a board that has never generated or pasted a background. `.skipsHiddenFiles`
/// is what keeps this from ever proposing the app's own crashed-write residue
/// (`.facets.png.lanework-<uuid>`, `BoardWriter.atomicWrite`'s dot-temp) or a stray `.DS_Store`
/// the same hidden-entry exclusion `BoardLoader.directoryCandidates` uses at every other level.
/// Sub-folders are excluded the same way `relocateLooseFiles` excludes them from a card: this sweep
/// only ever considers `.backgrounds/`'s own direct, regular-file contents, never anything nested.
///
/// - Parameter keeping: the bare name `background.image` currently resolves to inside this folder
/// (`BoardBackdrop.qualifiedBareName(of:)`), `nil` when the board's current reference is not one
/// of ours every file in the folder is then a candidate.
nonisolated static func orphanedBackgroundFileNames(inFolder backgroundsFolder: URL, keeping referenced: String?) -> [String] {
guard let entries = try? FileManager.default.contentsOfDirectory(
at: backgroundsFolder, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles]
) else { return [] }
return entries
.filter { url in
url.lastPathComponent != referenced
&& (try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true
}
.map(\.lastPathComponent)
.sorted()
}
// MARK: - Pasted background
/// **Applies a pasted picture as this board's backdrop** Edit Paste as Board Background
@@ -2275,15 +2446,22 @@ public final class BoardStore: HealHost {
/// other subkey survives untouched, which is `setBackgroundImage`'s whole per-subkey contract.
/// - **The name carries the payload's own extension** (`Pasted Background.png`, `.jpeg`, ),
/// because the format rule keeps a file-shaped flavor verbatim (`PastedImage`). A board pasted
/// twice in two formats therefore leaves the first file behind the same quiet leftover
/// choosing a solid colour over a generated background already leaves, and for the same reason:
/// undo restores the *field*, and a field cannot point an undo back at bytes this gesture
/// deleted.
/// twice in two formats no longer leaves the first file behind once `snapshot` has caught up
/// with the first paste see the trim note below, which is `applyGeneratedBackground`'s own,
/// restated.
///
/// **The undo restores the field, not the bytes** `applyGeneratedBackground`'s own note,
/// unchanged and for its reason: a re-paste over this board's own `.backgrounds/Pasted
/// Background.png` overwrites pixels nothing kept a copy of.
///
/// **The prior `.backgrounds/` file is trimmed once landed, on the same terms** the
/// replace-in-place half of the orphan tidy (ruled 2026-08-09): once both writes land,
/// `tidyReplacedBackgroundImage` trims `snapshot`'s prior image when it names a file of ours other
/// than the one this write just landed on. `theTwoProducersStayApart`'s echo window is unaffected
/// by construction a `nil` prior (the snapshot has not caught up with the write it would be
/// trimming) trims nothing, so a paste landing right after a generate leaves that generate's file
/// exactly where `applyGeneratedBackground`'s own memo says it should stay findable.
///
/// - Returns: whether bytes reached disk, which is the same question as "is an echo reload
/// coming".
@discardableResult
@@ -2305,6 +2483,11 @@ public final class BoardStore: HealHost {
) { document in
document.setBackgroundImage(target.reference)
}
// The replace-in-place half of the orphan tidy (ruled 2026-08-09) see this method's own
// note.
Self.tidyReplacedBackgroundImage(
priorImage: priorImage.value, newReference: target.reference, inFolder: backgroundsFolder
)
}
guard landed != nil else { return false }
generatedBackgroundEcho = (
@@ -4952,6 +5135,9 @@ public final class BoardStore: HealHost {
/// The rest of the order is immaterial: they touch disjoint files (a card's loose files, a
/// `deleted:` key inside an `index.md`, `CLAUDE.md`), each opens its own bracket, and each is
/// re-armed by the reload the others' writes produce, so none can see another's work half-done.
/// **The background-image sweep joins this immaterial tail** (ruled 2026-08-09): it reads
/// `background.image` and writes only inside `.backgrounds/`, a file and a folder none of the
/// other five ever touch, so it has nothing to race and nothing to be raced by.
public func runScheduledHeals() {
displaceClaimedNames()
seedGitignore()
@@ -4959,6 +5145,7 @@ public final class BoardStore: HealHost {
migrateLegacyTombstones()
remintDuplicateIdentities()
refreshAgentGuide()
tidyBackgroundImages()
}
// MARK: - Selection (delegated)
+7
View File
@@ -171,6 +171,11 @@ public final class HealScheduler {
/// This row is what replaced the retired user-gated Repair banner: the same information, none
/// of the waiting.
case remintedDuplicateIDs(titles: [String?])
/// Loss row, warning tone: `.backgrounds/` files nothing on the board named any more were
/// moved to the system Trash, named by filename (ruled 2026-08-09 the open-time heal half
/// of the orphan tidy; the in-flow trim rides silently inside the gesture that triggers it and
/// posts nothing here).
case tidiedBackgroundImages([String])
}
private static func post(_ notice: HealNotice, to banners: BannerCenter) {
@@ -185,6 +190,8 @@ public final class HealScheduler {
banners.postDisplacedClaimedNames(displacements)
case let .remintedDuplicateIDs(titles):
banners.postRemintedDuplicateIDs(titles)
case let .tidiedBackgroundImages(names):
banners.postTidiedBackgroundImages(names)
}
}
+73 -4
View File
@@ -2767,6 +2767,58 @@ public enum BoardWriter: Sendable {
return trashedURL as URL?
}
/// Moves a file out of `.backgrounds/` to the **system** Trash `trashAttachment`'s body one
/// folder over, and the shared primitive both orphan-tidy mechanisms use (01-storage-format.md §
/// Validation and healing, ruled 2026-08-09): the replace-in-place trim a background apply makes
/// on the file it just repointed or unset away from (`BoardStore.applyGeneratedBackground`,
/// `applyPastedBackground`, `applySolidBackground`), and the open-time sweep of whatever
/// `.backgrounds/` holds that the board's current `background.image` no longer names
/// (`BoardStore.tidyBackgroundImages`).
///
/// **`FileManager.trashItem`, never `removeItem`** `trashAttachment`'s own reasoning, applied to
/// a file the app rather than the user put there: an app-written background is recoverable exactly
/// the way a trashed attachment is, by the user, in Finder, with no help from this app, which is
/// the ruling's own "removal via `FileManager.trashItem` (recoverable, matches
/// attachment-removal precedent)".
///
/// **A name no longer present in `backgroundsFolder` is not a failure** `trashAttachment`'s rule
/// again: the reload (or, for the in-flow caller, the disk itself a moment later) is the authority
/// on what is there, and a file that vanished between the scan and the write a hand delete, a
/// losing race with a foreign write contributes nothing to report.
///
/// - Parameter healMarked: whether the receipt this drops is marked as a heal's
/// (06-history-undo.md Commit messages; `relocateLooseFiles`' own parameter, restated here).
/// `true` for the scheduled sweep, which is app-initiated work discovered on its own; `false` for
/// the in-flow trim, which rides inside the gesture that triggered it rather than a heal's own
/// bracket.
/// - Returns: where the file now sits inside the Trash, or `nil` when there was nothing to move.
@discardableResult
static func trashBoardImage(
named name: String,
inFolder backgroundsFolder: URL,
healMarked: Bool,
operation: WriteOperation
) throws(BoardWriteError) -> URL? {
let fileURL = backgroundsFolder.appendingPathComponent(name)
guard FileManager.default.fileExists(atPath: fileURL.path) else { return nil }
var trashedURL: NSURL?
do {
try FileManager.default.trashItem(at: fileURL, resultingItemURL: &trashedURL)
EchoLedger.current?.recordDeletion(at: fileURL)
if healMarked {
EchoLedger.current?.markHeal(at: fileURL)
}
} catch {
throw BoardWriteError(
operation: operation,
path: fileURL.path,
reason: .io(message: "could not move file to the Trash: \(error.localizedDescription)")
)
}
return trashedURL as URL?
}
// MARK: - Move/copy pre-flight
/// The rank a moved or copied root lands on: the caller's explicit value a drop between
@@ -3084,6 +3136,22 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
/// never the Finder-renamed one it would have landed under.
case relocateLooseFile(filename: String)
/// A `.backgrounds/` file being moved to the **system** Trash because nothing names it any more
/// the orphan tidy's write, both mechanisms (01-storage-format.md § Validation and healing, ruled
/// 2026-08-09): the replace-in-place trim a background apply makes on the file it just repointed
/// or unset away from, and the open-time sweep of whatever `.backgrounds/` holds that the board's
/// current `background.image` no longer names.
///
/// Its own case on `.relocateLooseFile`'s reasoning, doubled: this is work the *app* started on
/// its own, on a file the user never named, and a banner saying the app "couldn't move 'facets
/// 2.png' to the Trash" without this case would borrow `.removeAttachment`'s sentence for a
/// gesture a click on a Remove row the user never made. In practice only the sweep's own
/// failure ever reaches a banner: the in-flow trim swallows whatever this throws (`try?`, the
/// ruling's own "temp-file posture"), so this case's phrasing exists for the sweep and for the
/// developer-facing `description` either mechanism's failure would log. `filename` is the bare
/// name inside `.backgrounds/`, the one the sweep's own successful notice would have used.
case tidyBackgroundImage(filename: String)
/// The board-root `CLAUDE.md` being written or upgraded and the rescue move that precedes it
/// when a markerless one has to be displaced to `CLAUDE.user.md` (08-agent-integration.md The
/// agent guide; `AgentGuide`).
@@ -3275,8 +3343,8 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
// the name the user is looking at and the only one its banner should say and there is no
// document to enrich from anyway, since the failure it describes is the read of one.
case .createBoard, .createLane, .createCard, .importAttachment, .importBoard, .listAttachments,
.removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide, .seedGitignore,
.mintBoardIndex, .stampSchema, .setBoardBackground,
.removeAttachment, .renumberChildren, .relocateLooseFile, .tidyBackgroundImage, .agentGuide,
.seedGitignore, .mintBoardIndex, .stampSchema, .setBoardBackground,
.displaceClaimedName, .repairDuplicateID, .saveCommentDraft, .postComment,
.editComment, .deleteComment, .purgeCommentTrash:
self
@@ -3345,8 +3413,8 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
.style, .resize, .collapse, .expand, .rename, .duplicateBoard, .saveAsTemplate, .shareBoard, .paste,
.exportBoard, .importBoard,
.importAttachment,
.listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .seedGitignore,
.mintBoardIndex, .stampSchema, .setBoardBackground,
.listAttachments, .removeAttachment, .relocateLooseFile, .tidyBackgroundImage, .agentGuide,
.seedGitignore, .mintBoardIndex, .stampSchema, .setBoardBackground,
.displaceClaimedName,
.repairDuplicateID, .toggleTask, .editBody, .rawSource, .saveCommentDraft, .postComment,
.editComment, .deleteComment, .purgeCommentTrash:
@@ -3388,6 +3456,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case let .removeAttachment(filename): "move attachment '\(filename)' to the Trash"
case .renumberChildren: "renumber children"
case let .relocateLooseFile(filename): "relocate loose file '\(filename)'"
case let .tidyBackgroundImage(filename): "tidy background image '\(filename)'"
case .agentGuide: "update the agent guide"
case .seedGitignore: "seed the board's .gitignore"
case .mintBoardIndex: "create this board's index.md"
+7
View File
@@ -778,6 +778,12 @@ public enum IntegrityRules: Sendable {
/// card window closes; crash residue sweeps at the next card-window open, armed-then-cleared
/// like every heal memo"). The board walk never opens a thread, so no tree walk could report
/// it.
///
/// `orphanedBackgroundImages` joins this no-`Defect`-case group for the identical reason: what
/// is orphaned is a property of **two** board-root readings taken together at the moment of
/// healing the `.backgrounds/` folder's current listing and `background.image`'s current
/// value (`BoardStore.tidyBackgroundImages`, ruled 2026-08-09) not something a tree walk over
/// lanes and cards could carry forward to the heal that acts on it.
public enum Class: Sendable, Equatable, Hashable, CaseIterable {
case looseCardFiles
case legacyTombstone
@@ -786,6 +792,7 @@ public enum IntegrityRules: Sendable {
case staleAgentGuide
case missingGitignore
case commentTrashResidue
case orphanedBackgroundImages
}
/// The scheduled-heal class this defect belongs to, or **`nil` where there is no heal** the
+20
View File
@@ -96,6 +96,26 @@ enum BoardBackdrop {
/// leftover an overwritten `facets.png` already could.
static let backgroundsFolderName = ".backgrounds"
/// **The bare filename `reference` names, when it is a direct child of `.backgrounds/`** the
/// read the orphan tidy needs (01-storage-format.md § Validation and healing, ruled 2026-08-09:
/// "Tidy scopes to `.backgrounds/` ONLY app-written by construction") to tell "one of ours,
/// still in service" from everything else a `background.image` could name.
///
/// `nil` for every reading that is not that one shape, folded together on purpose a missing
/// reference, a legacy bare name at board root, a hand-placed image in a subfolder of its own, a
/// reference naming a grandchild of `.backgrounds/` (`.backgrounds/art/x.png`) because every one
/// of those means the same thing to a caller asking "which file in `.backgrounds/` is spoken for
/// right now": none of them are, so every file the folder holds is a candidate. The shared
/// prefix-and-single-component test is exactly `boardImageName`'s own "only the qualified form
/// reads as ours" rule, restated as a pure function so both tidy mechanisms the in-flow trim and
/// the open-time sweep read it the same way without either owning the other's copy.
static func qualifiedBareName(of reference: String?) -> String? {
guard let reference, reference.hasPrefix("\(backgroundsFolderName)/") else { return nil }
let rest = reference.dropFirst(backgroundsFolderName.count + 1)
guard !rest.isEmpty, !rest.contains("/") else { return nil }
return String(rest)
}
/// Whether this board paints a background of its own **the window-chrome predicate**
/// (`BoardWindowHost`, `HostedWindowController.setExtendsContentUnderTitlebar`): a board with one
/// runs its content under a transparent title bar, and a board without one keeps the standard