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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user