Realign code with the 2026-07-31 findings-resolution rulings
The full bullet list from Implementation card bf080d9a — both ruling batches, including the three appended mid-session by16ef377: - Restore subjects compose the inverse, never nest: crossing "Undo: S" emits "Redo: S" and vice versa; parity, not stack depth, reads a legacy double prefix (GitHistoryProvider.restoreSubject). - Git-operation failures join the one-shot failure banner tier: BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error tone at failure rank merged with write one-shots by recency; the postLoss compromise is retired at both AppModel wirings. - order/schema optional below the board root: append-at-end reading (ordered siblings first, folder-name tie-break among the order-less), schema reads 1, both coerce-tier logged; the root keeps its requirements. Ranks.resolvedOrders materializes finite ranks so models and placement math stay untouched; first Writer rewrite stamps a real rank on touch, placement against an order-less sibling stamps that sibling inline in the same bracket. Agent guide v10 teaches optional keys and zero-read filing. Hostile-YAML order shapes become coercion tests; Fixtures/Valid/optional-keys.kanban replaces the four retired Malformed boards. - .gitignore is the relocation-heal noise gate: GitignoreRules pure matcher (standard semantics, board-root file only), loader consults it once per walk so matched loose files keep the stray posture; seeded (.DS_Store + .*.lanework-*) at board creation and template instantiation, healed in when missing at open — repo-nested included; empty file honored, existing files never edited; the committer's obedience via libgit2 status is pinned by test. - Comments crash-residue sweep gates on step ownership: HistoryStep derives backing from its own undo expectations, backedContent unions both stacks, the sweep purges per-entry only what no live step owns. - Skip-purge decoupled (16ef377): a stale-skipped coarse step strands whole in NativeHistoryProvider.strandedSteps — still backing, retired only at session end; clean exits purge as before. - Coarse close step named "Changes to '<card>'"; the fine body-edit wording never leaks onto the board menu. - Branch-switch settle clears every open card window's fine stack on Save All and Discard alike; the empty fold registers no coarse step. - Close flush awaits its covering snapshot (quiesce + one generation bump, 1s bound), and an explicit flush now queues behind an in-flight one instead of skipping — the audit-caught interleaving could lose a close flush permanently when the debounce fired inside the close sequence; regression tests force both races. - Commit comment bullets sort chronologically by created, not UUID. - The production-unwired CardBodyEditSession.editSessionDidChange seam is deleted with its seam-only tests. - Composition-root pins: beginSession composes the committer with the store's own EchoLedger and binds the announcer (the miswire class). - Deliberate 06 conformance pass over every 2026-07-31-tagged sentence: fixed Change-custom-key subjects (the retired named generic was the only producer), the unbuilt Replace attachment vocabulary, heal commits now authored Lanework Integrity, the config reader scopes identity to plain [user] sections, add-git re-runs detection at create (a stale mode-none could initialize inside the user's repo), and add-git failures answer at the form or the banner. Structural residue filed on the Redesign board. 2554 tests / 439 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
+102
-15
@@ -812,6 +812,15 @@ public final class AppModel {
|
||||
git.activateAutoCommit { [weak store] committer in
|
||||
guard let store else { return }
|
||||
committer.currentSnapshot = { [weak store] in store?.snapshot }
|
||||
// **The flush awaits the snapshot that covers it** (06 ▸ Rules ▸ Auto-commit, ruled
|
||||
// 2026-07-31): "the composer diffs `store.snapshot` against HEAD, so the close flush
|
||||
// awaits a snapshot generation covering its changed paths before the committer runs —
|
||||
// the commit's subject can never be outrun by its own reload". Both halves of that
|
||||
// await are reads of the store the composer is already diffing, which is why they are
|
||||
// wired here rather than reached for: the engine holds the *policy* (when to wait, how
|
||||
// long), the session supplies the two facts (`GitAutoCommitter.awaitCoveringSnapshot`).
|
||||
committer.awaitReloadQuiescence = { [weak store] in await store?.awaitQuiescence() }
|
||||
committer.snapshotGeneration = { [weak store] in store?.snapshotGeneration }
|
||||
// 02-architecture.md ▸ Write-failure surfacing, through the strip the board window
|
||||
// already renders: a genuine commit failure means "your edits are saved, history has
|
||||
// stopped advancing", which is exactly what the standing suspension row says. Lock
|
||||
@@ -839,6 +848,14 @@ public final class AppModel {
|
||||
git.didAddGit = { [weak self] in
|
||||
self?.bindHistoryProvider(for: ref)
|
||||
}
|
||||
// **The form-anchored posture's fallback half** (06 ▸ Interaction with external writers,
|
||||
// ruled 2026-07-31): add-git answers inline in the form that asked, and lands here instead
|
||||
// when that form has been dismissed before the answer arrived — "inline is the primary
|
||||
// surface, never a silence trap". The banner enumeration is the same one branch switch and
|
||||
// undo restore post into, one row per failure.
|
||||
git.reportFailure = { [weak store] failure in
|
||||
store?.banners.postGitFailure(.addGit, reason: failure.message)
|
||||
}
|
||||
}
|
||||
// **The binding 13-native-undo.md ▸ Rules' "registration at the Writer boundary" needs**: the
|
||||
// store is that boundary — every app-mediated mutation goes out through one of its write
|
||||
@@ -900,17 +917,16 @@ public final class AppModel {
|
||||
self?.resumeCardSessionStaging(for: ref)
|
||||
}
|
||||
// **A restore that failed cleanly** (06 ▸ Interaction with external writers: "surfaces as a
|
||||
// one-shot banner failure naming the operation and the error, the tree left as it was").
|
||||
// one-shot banner failure naming the operation and the error, the tree left as it was") —
|
||||
// now literally that, at the failure rank in the error tone (02 ▸ The banner surface, settled
|
||||
// 2026-07-31: the one-shot class's second, message-carrying shape). The loss-row compromise
|
||||
// this line used to carry is retired: a ⌘Z that didn't happen is an action that didn't
|
||||
// happen, not content that didn't arrive.
|
||||
//
|
||||
// Posted as a **loss row**, and the compromise is recorded rather than hidden: the true
|
||||
// failure class (`OneShotBanner`) carries a `BoardWriteError`, whose `operation` is the closed
|
||||
// `WriteOperation` vocabulary — and a git operation is deliberately not one of those
|
||||
// (`BoardStore.performWholesale`'s own note says so). The loss row is the nearest honest
|
||||
// class: warning tone, one-shot lifecycle, never auto-expires, and a free-form message that
|
||||
// can name both halves 06 asks for. A message-carrying failure class is the right fix and is
|
||||
// a banner-surface change, not this card's.
|
||||
provider.reportFailure = { [weak store] failure in
|
||||
store?.banners.postLoss(failure.description)
|
||||
// The closure passes the *direction* and libgit2's own message and stops there — "Undo
|
||||
// failed — …" is BannerCenter's sentence, from the closed `GitOperation` vocabulary.
|
||||
provider.reportFailure = { [weak store] direction, failure in
|
||||
store?.banners.postGitFailure(.restore(direction), reason: failure.message)
|
||||
}
|
||||
provider.runBracketed = { [weak store] subject, work in
|
||||
guard let store else { return await work() }
|
||||
@@ -978,11 +994,16 @@ public final class AppModel {
|
||||
store?.banners.updateOperation(id, label: label)
|
||||
}
|
||||
switcher.endProgress = { [weak store] id in store?.banners.endOperation(id) }
|
||||
// The loss row, on `GitHistoryProvider.reportFailure`'s recorded compromise — see it for why
|
||||
// a git failure cannot be a `OneShotBanner` today.
|
||||
// The failure rank's git shape, as on the restore path above: a switch that didn't happen is
|
||||
// an action that didn't happen ("Couldn't switch branches — …", BannerCenter's words from
|
||||
// the operation alone).
|
||||
switcher.reportFailure = { [weak store] failure in
|
||||
store?.banners.postLoss(failure.description)
|
||||
store?.banners.postGitFailure(.branchSwitch, reason: failure.message)
|
||||
}
|
||||
// **The recovery notice stays a loss row**, and the ruling is explicit about why (02 ▸ The
|
||||
// banner surface): "recovery notices report a success, not a failure, and stay warning-tone".
|
||||
// "A branch switch was interrupted — the previous state is restored" is the app tidying up
|
||||
// after itself, with nothing for the user to do — the loss class's own register.
|
||||
switcher.reportRecovery = { [weak store] message in
|
||||
store?.banners.postLoss(message)
|
||||
}
|
||||
@@ -1009,7 +1030,16 @@ public final class AppModel {
|
||||
let outcome = await gate.settleAll()
|
||||
// The switch's flush runs next and must find a tree it can settle whole — see
|
||||
// `releaseCardSessionStaging(for:)` for why the modal's own predicate is not enough.
|
||||
if outcome == .proceed { self.releaseCardSessionStaging(for: ref) }
|
||||
//
|
||||
// **And the fine undo stacks go with it** (06 ▸ Branch switching, ruled 2026-07-31): the
|
||||
// same `.proceed`, the same seam, for the same reason one rung up — what a window is
|
||||
// holding describes the branch being left. "Cancel keeps the current branch and the
|
||||
// sessions" is this `if`, unchanged: a cancelled or failed settle clears nothing, exactly
|
||||
// as it releases nothing.
|
||||
if outcome == .proceed {
|
||||
self.releaseCardSessionStaging(for: ref)
|
||||
self.discardCardWindowUndoStacks(for: ref)
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
@@ -1019,6 +1049,23 @@ public final class AppModel {
|
||||
Task { await switcher.recoverInterruptedOperation() }
|
||||
}
|
||||
|
||||
/// **The three buttons, as a seam** — `SessionSettleStep.ask(message:)` in production.
|
||||
///
|
||||
/// `SessionSettleGate` already keeps the presentation behind a closure for its own reason
|
||||
/// ("presenting three buttons is AppKit's job and cannot be asserted without a display … the
|
||||
/// presentation is a seam and the decision is testable"), and every gate this model builds pointed
|
||||
/// that closure straight at the alert — so the *composition* around the gate, which is what
|
||||
/// `releaseCardSessionStaging(for:)` and `discardCardWindowUndoStacks(for:)` hang off, could only
|
||||
/// be exercised by a board with nothing to settle. Lifting the ask one level up is what lets a test
|
||||
/// answer Save All, Discard and Cancel over real card windows without a modal on screen.
|
||||
///
|
||||
/// `@ObservationIgnored` because nothing renders from it, and internal because it is a test seam
|
||||
/// rather than API: production never assigns it.
|
||||
@ObservationIgnored
|
||||
var settleAsk: @MainActor (String) async -> SessionSettleChoice = {
|
||||
await SessionSettleStep.ask(message: $0)
|
||||
}
|
||||
|
||||
/// **The save-or-discard step for one board**, built from its open card windows
|
||||
/// (06-history-undo.md ▸ Rules ▸ Undo restore vs open Edit sessions; ▸ Branch switching).
|
||||
///
|
||||
@@ -1055,7 +1102,10 @@ public final class AppModel {
|
||||
)
|
||||
}
|
||||
},
|
||||
ask: { await SessionSettleStep.ask(message: message) },
|
||||
ask: { [weak self] in
|
||||
guard let self else { return await SessionSettleStep.ask(message: message) }
|
||||
return await self.settleAsk(message)
|
||||
},
|
||||
focus: { [weak self] id in
|
||||
guard let self, let session = self.sessions[ref] else { return }
|
||||
guard let cardRef = session.cardRefs.first(where: { $0.cardID == id }) else { return }
|
||||
@@ -1212,6 +1262,43 @@ public final class AppModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// **The branch switch's settle empties every open card window's fine undo stack**
|
||||
/// (06-history-undo.md ▸ Branch switching, ruled 2026-07-31).
|
||||
///
|
||||
/// > "The settle also clears each open card window's fine undo stack: pre-switch steps describe
|
||||
/// > the branch being left — Save All and Discard alike end with every window's stack empty, the
|
||||
/// > board-stack discard-and-reseed precedent one level down; the windows stay open, following
|
||||
/// > their cards onto the new branch with fresh stacks."
|
||||
///
|
||||
/// ### Why here, beside the staging release
|
||||
///
|
||||
/// Because it is the same fact about the same moment. `releaseCardSessionStaging(for:)` lets go of
|
||||
/// what a window is holding *on disk*; this lets go of what it is holding *in memory*, and both
|
||||
/// are true of a session whose branch is about to be replaced under it. Running them from one
|
||||
/// `.proceed` is also what makes "Cancel clears nothing" a property of one `if` rather than a rule
|
||||
/// two call sites have to keep in step (`wireBranchSwitching`).
|
||||
///
|
||||
/// **The board stack is not touched**, and it is not an omission: the switch discards and reseeds
|
||||
/// it from the new HEAD's first-parent ancestry a few steps later, inside the bracket
|
||||
/// (`GitBranchSwitcher.reseedUndo` → `GitHistoryProvider.reseed`). Doing it here would be the same
|
||||
/// discard, one level up, at the wrong moment — before the checkout that decides what to reseed
|
||||
/// *from*.
|
||||
///
|
||||
/// **The restore path deliberately does not call this.** An undo restore materializes a diff and
|
||||
/// leaves the branch where it is, so a window's steps still describe the branch they were made on;
|
||||
/// what protects them there is 13-native-undo.md's field-level staleness predicate, which is a
|
||||
/// per-step question rather than a wholesale one.
|
||||
///
|
||||
/// The downcast is the honest shape rather than a shortcut: `CardSessionFlushing` is the *close
|
||||
/// flush's* seam — end the session, say whether it holds unsaved content, offer the settle's two
|
||||
/// writes — and a fine undo stack is none of those things. The one type that has one is the card
|
||||
/// window's own session, which is what every registration passes.
|
||||
func discardCardWindowUndoStacks(for ref: BoardWindowRef) {
|
||||
for cardRef in sessions[ref]?.cardRefs ?? [] {
|
||||
(cardSessions[cardRef] as? CardWindowSession)?.undo.discardSteps()
|
||||
}
|
||||
}
|
||||
|
||||
/// **The next session begins** — the other half of `releaseCardSessionStaging(for:)`, run when the
|
||||
/// operation behind the settle has finished with the tree.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user