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
This commit is contained in:
2026-08-01 07:43:45 -04:00
parent 16ef3779e8
commit 274ccd9ff5
75 changed files with 5619 additions and 791 deletions
+127 -8
View File
@@ -93,8 +93,14 @@ public final class GitHistoryProvider: HistoryProviding {
public var runBracketed: (@MainActor (_ announcing: String, _ work: @escaping () async -> Void) async -> Void)?
/// A genuine restore failure surfaced as 02's one-shot banner by whoever wires it.
///
/// **The direction travels with the failure** (02-architecture.md The banner surface, settled
/// 2026-07-31): the one-shot failure class's second shape names the operation in the user's
/// words "Undo failed", "Redo failed" and this object is the only one that knows which key
/// was pressed. Everything past that boundary is the banner's: the closure receives the
/// direction and libgit2's own message, never a sentence composed here.
@ObservationIgnored
public var reportFailure: (@MainActor (GitOperationFailure) -> Void)?
public var reportFailure: (@MainActor (HistoryDirection, GitOperationFailure) -> Void)?
// MARK: - The cached stack
@@ -326,7 +332,11 @@ public final class GitHistoryProvider: HistoryProviding {
guard let index = crossableIndex() else { return }
let crossed = ancestry[index]
guard let target = crossed.parentOID else { return }
let landed = await restore(to: target, message: "Undo: \(crossed.subject)")
let landed = await restore(
.undo,
to: target,
message: Self.restoreSubject(.undo, crossing: crossed.subject)
)
guard landed else { return }
redoCommits.append(crossed)
pointerOID = target
@@ -334,7 +344,11 @@ public final class GitHistoryProvider: HistoryProviding {
case .redo:
guard let target = redoCommits.last else { return }
let landed = await restore(to: target.oid, message: "Redo: \(target.subject)")
let landed = await restore(
.redo,
to: target.oid,
message: Self.restoreSubject(.redo, crossing: target.subject)
)
guard landed else { return }
redoCommits.removeLast()
// The commit just restored *to* is the one the next Z crosses again the classic dance,
@@ -349,7 +363,11 @@ public final class GitHistoryProvider: HistoryProviding {
/// `message` is both the commit's subject and the bracket's completion announcement
/// (10-accessibility.md Live board announcements: "bracketed operations announce once, at
/// completion") one sentence, so the trail and the speech cannot disagree about what happened.
private func restore(to target: String, message: String) async -> Bool {
///
/// `direction` is carried for one reason: a failure here is the banner's git-operation shape,
/// and it is named by the key the user pressed rather than by the subject the restore would have
/// carried (`reportFailure`).
private func restore(_ direction: HistoryDirection, to target: String, message: String) async -> Bool {
let root = boardRoot
let excluded = healPaths
@@ -358,7 +376,7 @@ public final class GitHistoryProvider: HistoryProviding {
guard let preliminary = await Task.detached(priority: .userInitiated, operation: {
GitRestoreOperation.plan(at: root, target: target, excluding: excluded)
}).value else {
report("this board's repository could not be read")
report(direction, "this board's repository could not be read")
return false
}
@@ -429,7 +447,7 @@ public final class GitHistoryProvider: HistoryProviding {
case let .held(pause):
Self.logger.notice("restore held: \(pause.rawValue, privacy: .public)")
case let .failed(failure):
self.reportFailure?(failure)
self.reportFailure?(direction, failure)
}
}
@@ -478,10 +496,111 @@ public final class GitHistoryProvider: HistoryProviding {
return nil
}
private func report(_ message: String) {
reportFailure?(GitOperationFailure(
private func report(_ direction: HistoryDirection, _ message: String) {
reportFailure?(direction, GitOperationFailure(
operation: GitRestoreOperation.operationName,
message: message
))
}
// MARK: - The restore subject
/// **What a restore commit is called** a pure function of the crossed subject and the
/// direction, so the rule can be read (and pinned) without a repository.
///
/// The base rule is 06's oldest: a crossing commits the state it restored as "Undo: subject"
/// or "Redo: subject". **Subjects don't nest** (06 Commit messages, settled 2026-07-31):
/// when the crossed subject already carries a restore prefix the post-relaunch case, where the
/// reseed has made old restore commits ordinary steps the composer "emits the *inverse* label
/// instead of stacking: crossing 'Undo: S' yields 'Redo: S', crossing 'Redo: S' yields
/// 'Undo: S'", which "caps prefixes at one across any number of relaunches".
///
/// ### Why the two directions read the crossed subject differently
///
/// The label states what the new commit's tree *does* to the base subject S: "Undo: S" is the
/// state where S is out, "Redo: S" the state where S is in. An undo restores the crossed
/// commit's **parent** the state before it so it emits that commit's inverse; a redo
/// restores the target commit **itself**, so it emits that commit's own reading. That is what
/// makes 06's sentence true ("undoing the restore that undid a move *re-applies* the move") and
/// its mirror true with it: Z back across an "Undo: S" step lands on the tree where S is out,
/// and says "Undo: S" the truer label, rather than the "Redo: S" the Z that crossed it
/// already used for the opposite tree.
///
/// ### The legacy double prefix
///
/// "Undo: Undo: S" exists in the wild the shipped nesting build made them and the honest
/// reading is this same one applied twice: the inner "Undo:" took S out, the outer one took
/// *that* back, so the commit's tree is the one where S is in. Undoing across it therefore emits
/// **"Undo: S"** the tree it restores is the one without S, and saying "Redo: S" there would be
/// exactly the euphemism 06 rules out ("This is the truer label, not a euphemism"), while
/// "Redo: Undo: S" would keep the nesting the ruling caps at one. So each "Undo: " prefix flips
/// the reading, each "Redo: " prefix leaves it, and what comes out carries exactly one.
///
/// The sniff is on the subject string, deliberately (06), so "a foreign commit that happens to
/// open with a prefix gets the inverse label too; that's cosmetic the restore itself is
/// unaffected".
public nonisolated static func restoreSubject(
_ direction: HistoryDirection,
crossing subject: String
) -> String {
let reading = RestoreSubjectReading(of: subject)
let emitted = switch direction {
case .undo: reading.polarity.inverse
case .redo: reading.polarity
}
return "\(emitted.label): \(reading.base)"
}
}
// MARK: - Helpers
/// What a subject says about its own base subject: is that change *in* the tree the subject
/// describes, or has it been taken back out? Every restore label is one of these two readings, which
/// is why the composer can invert rather than stack (`GitHistoryProvider.restoreSubject(_:crossing:)`).
private enum RestorePolarity {
/// The base subject's change is in the tree every ordinary commit, and every "Redo: S".
case applied
/// The base subject's change has been taken back out "Undo: S".
case reverted
var inverse: RestorePolarity { self == .applied ? .reverted : .applied }
/// The word that states this reading in a subject.
var label: String { self == .applied ? "Redo" : "Undo" }
/// The same word as a prefix the only two this composer emits, and the only two it reads, so
/// that reading and writing can never drift apart.
var prefix: String { "\(label): " }
}
/// One subject read as "a base subject, plus what its restore prefixes say about it".
///
/// Stripping is greedy because the legacy nesting build's subjects are (`restoreSubject`), and a
/// prefix only counts while something is left for it to be *about*: a bare "Undo: " is somebody's
/// subject, not a label with nothing after it.
private struct RestoreSubjectReading {
let base: String
let polarity: RestorePolarity
init(of subject: String) {
var base = subject
var polarity = RestorePolarity.applied
while true {
let read: RestorePolarity
if base.hasPrefix(RestorePolarity.reverted.prefix) {
read = .reverted
} else if base.hasPrefix(RestorePolarity.applied.prefix) {
read = .applied
} else {
break
}
let rest = String(base.dropFirst(read.prefix.count))
guard !rest.isEmpty else { break }
base = rest
// "Undo: " flips what the rest of the subject was saying; "Redo: " restates it.
if read == .reverted { polarity = polarity.inverse }
}
self.base = base
self.polarity = polarity
}
}