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
+187 -23
View File
@@ -83,6 +83,11 @@ enum CommitMessageEngine {
/// container with no identity of its own (`BoardModel.trash`).
static let trashDestination = "the trash"
/// What a key's old (or new) side reads as when the frontmatter simply did not carry it the
/// `(untitled)` convention applied to a value rather than to a name, so "sprint: (none) 42"
/// says *added* without a second sentence shape for it.
static let absentValuePlaceholder = "(none)"
// MARK: - Entry point
/// One request in, one whole message out a subject, and a body when there is more to say.
@@ -242,8 +247,12 @@ enum CommitMessageEngine {
previous: previous.document,
current: current.document,
noun: "board",
// The trio's board phrasing is count-less and title-less ("Relabel board") there is one
// board and naming it adds nothing. The custom-key event is the one place 06 spells the
// board's title out ("Change custom key on board 'title'"), so it gets it separately.
itemTitle: nil,
kinds: (.relabelBoard, .assignBoard, .dueBoard, .updateBoard),
customKeyItemTitle: title(current.title),
kinds: (.relabelBoard, .assignBoard, .dueBoard, .changeKeyBoard),
paths: paths
)
return events
@@ -380,7 +389,8 @@ enum CommitMessageEngine {
current: new.document,
noun: "lane",
itemTitle: new.displayTitle,
kinds: (.relabelLane, .assignLane, .dueLane, .updateLane),
customKeyItemTitle: new.displayTitle,
kinds: (.relabelLane, .assignLane, .dueLane, .changeKeyLane),
paths: paths
)
}
@@ -637,7 +647,8 @@ enum CommitMessageEngine {
current: new.card.document,
noun: "card",
itemTitle: new.displayTitle,
kinds: (.relabelCard, .assignCard, .dueCard, .updateCard),
customKeyItemTitle: new.displayTitle,
kinds: (.relabelCard, .assignCard, .dueCard, .changeKeyCard),
paths: paths
)
return events
@@ -684,9 +695,19 @@ enum CommitMessageEngine {
/// **The full schema-1 surface, plus the reserved trio, deliberately** (06 The external gap,
/// closed): "label, assignee, and due changes compose even though 01-storage-format.md reserves
/// those keys out of this version's UI external writers are exactly who touches them. A change
/// to any other unmodeled or custom key composes a named generic ('Update card 'X'') **never a
/// board-level shrug when the touched item is identifiable**."
/// those keys out of this version's UI external writers are exactly who touches them."
///
/// **A change to any other unmodeled or custom key says what it is** (06, re-ruled 2026-07-31
/// "first lines self-describe; generics are a last resort, kept very rare"):
///
/// > **"Change custom key on card 'X'"** (board and lane likewise "Change custom key on board
/// > 'title'"; several keys fold plural), the body naming each key with its old new values.
///
/// The named generic it retired ("Update card 'X'") is 06's own last resort and survives only
/// "for a change in a known file that is neither a vocabulary event nor a key change a shape
/// that should almost never occur". Nothing composes that shape today: a known file whose diff is
/// neither is the *bookkeeping* rule's silence, which composes nothing at all. So the vocabulary
/// keeps no case for it, and the zero-event floor stays `unnamedSubject`'s.
///
/// Read off `unknownFields`, which is exactly "every key the schema does not own" so the
/// bookkeeping keys are excluded by construction rather than by a list kept in step: `modified`,
@@ -701,7 +722,8 @@ enum CommitMessageEngine {
current: FrontmatterDocument,
noun: String,
itemTitle: String?,
kinds: (label: Kind, assignee: Kind, due: Kind, generic: Kind),
customKeyItemTitle: String,
kinds: (label: Kind, assignee: Kind, due: Kind, customKey: Kind),
paths: [String]
) -> [Event] {
let before = Dictionary(previous.unknownFields.map { ($0.key, $0.rawValue) }, uniquingKeysWith: { _, last in last })
@@ -726,11 +748,24 @@ enum CommitMessageEngine {
if changed(Keys.labels) { events.append(event(kinds.label, "Relabel")) }
if changed(Keys.assignees) { events.append(event(kinds.assignee, "Assign")) }
if changed(Keys.due) { events.append(event(kinds.due, "Set due date on")) }
// Everything else the schema does not own one named generic for the item, however many
// custom keys an agent touched in the same window.
// **Everything else the schema does not own says what it is** (re-ruled 2026-07-31). The
// *item* is still what the subject names one event however many keys moved but the verb
// now describes the change instead of shrugging at it, and the keys themselves ride the body.
let trio: Set<String> = [Keys.labels, Keys.assignees, Keys.due]
if Set(before.keys).union(after.keys).subtracting(trio).contains(where: changed) {
events.append(event(kinds.generic, "Update"))
let keys = Set(before.keys).union(after.keys).subtracting(trio).filter(changed).sorted()
if !keys.isEmpty {
let subject = keys.count == 1 ? "custom key" : "\(keys.count) custom keys"
events.append(Event(
kind: kinds.customKey,
subject: "Change \(subject) on \(noun) \(quotedSubject(customKeyItemTitle))",
bullet: "Change \(subject) on \(noun) \(quoted(customKeyItemTitle))",
detail: keys
.map { "\($0): \(before[$0] ?? absentValuePlaceholder)\(after[$0] ?? absentValuePlaceholder)" }
.joined(separator: "\n"),
destination: customKeyItemTitle,
paths: paths
))
}
return events
}
@@ -750,10 +785,17 @@ enum CommitMessageEngine {
/// lane is one event, not one line per file inside it);
/// - the departing end of a **rename**, which its arrival already speaks for the loose-file
/// relocation, the remint, a displaced squatter.
///
/// **One path shape is not a generic** (06 Commit messages Vocabulary, "Replace added
/// 2026-07-31"): "a changed file under a card's `attachments/` with an unchanged listing is a
/// content replacement, named from the path alone: 'Replace attachment 'photo.png' card 'X'',
/// never the anonymous path generic". An unchanged listing is exactly "no model event claimed
/// this path", so the rule needs no second question of the snapshot see `replacedAttachment`.
private static func pathEvents(for request: CommitMessageRequest, claimedBy model: [Event]) -> [Event] {
let claimed = Set(model.flatMap(\.paths))
var comments: [String: CommentGroup] = [:]
var events: [Event] = []
lazy var cardTitles = cardTitlesByPath(request)
for changed in request.changedPaths.sorted(by: { $0.path < $1.path }) {
let path = changed.path
@@ -763,7 +805,11 @@ enum CommitMessageEngine {
// schema): a trashed card's thread lives under `.trash/`, which the model-silence rule
// would otherwise swallow whole.
if let comment = CommentPath.classify(path) {
comments[CommentGroup.key(comment), default: CommentGroup(comment: comment)].add(changed)
let folder = commentFolder(of: path) ?? comment.cardPath
comments[
CommentGroup.key(comment),
default: CommentGroup(comment: comment, folder: folder)
].add(changed)
continue
}
guard !Paths.isModelSilent(path) else { continue }
@@ -778,6 +824,21 @@ enum CommitMessageEngine {
events.append(Event(kind: .agentGuide, subject: "Update agent guide (v\(version))", paths: [path]))
continue
}
// **Replace**, before the anonymous generic the file is still listed, so the model had
// nothing to say and the path says it instead.
if let replaced = replacedAttachment(at: path) {
let card = cardTitles[replaced.cardFolder] ?? untitledPlaceholder
events.append(Event(
kind: .replaceFile,
subject: "Replace attachment \(quotedSubject(replaced.file)) — card \(quotedSubject(card))",
bullet: "Replace attachment \(quoted(replaced.file)) — card \(quoted(card))",
destination: card,
paths: [path]
))
continue
}
events.append(Event(
kind: .updatePath,
subject: "Update \(quotedSubject(path))",
@@ -788,12 +849,33 @@ enum CommitMessageEngine {
return commentEvents(comments, model: model, request: request) + events
}
/// **A card attachment rewritten in place** the `Replace` shape, read from the path alone.
///
/// `<lane>/<card>/attachments/<file>`, and nothing else: an attachment one folder deeper is not a
/// shape this app writes, and a trashed card's is unreachable here (everything under `.trash/` is
/// model-silent and never gets this far). Whether the *listing* changed is the caller's question,
/// already answered an added or removed file was claimed by its own Attach/Remove event before
/// this path was ever reached.
private static func replacedAttachment(at path: String) -> (cardFolder: String, file: String)? {
let components = path.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
guard components.count == 4,
components[2] == Paths.attachmentsFolder,
let folder = cardFolder(of: path)
else { return nil }
return (folder, components[3])
}
// MARK: - The comment verb family
/// Every changed path inside **one comment folder**, gathered so a comment that had its
/// `index.md` and two attachments rewritten is one event rather than three.
private struct CommentGroup {
let comment: CommentPath
/// This comment's own folder, board-root-relative the key its `created` is looked up under
/// (`CommitMessageRequest.commentTimestamps`) and the name the chronology's tie-break reads.
let folder: String
var paths: [String] = []
var hasArrival = false
var hasSurvivor = false
@@ -806,6 +888,11 @@ enum CommitMessageEngine {
}
}
/// The folder's last component the UUID a comment is named by, or `.draft`.
var folderName: String {
folder.split(separator: "/", omittingEmptySubsequences: true).last.map(String.init) ?? folder
}
mutating func add(_ changed: GitChangedPath) {
paths.append(changed.path)
if changed.isArrival { hasArrival = true }
@@ -813,6 +900,29 @@ enum CommitMessageEngine {
}
}
/// **The comment folder a changed path sits in**, board-root-relative `<card>/comments/<uuid>`,
/// `<card>/comments/.trash/<uuid>` or `<card>/comments/.draft` or `nil` for a path that is not
/// inside a thread at all.
///
/// Shared with the flush that reads each of those folders' `created`
/// (`GitAutoCommitter.commentTimestamps(for:boardRoot:)`), so the key a timestamp is *filed*
/// under and the key it is *looked up* by have one definition. Everything about where a thread
/// lives is still `CommentPath.classify`'s; this only says how many of the path's components that
/// classification consumed.
static func commentFolder(of path: String) -> String? {
guard let comment = CommentPath.classify(path) else { return nil }
let components = path.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
// `classify` has already guaranteed these counts `<lane>/<card>/comments/<entry>` for a
// comment or the draft, one deeper for `comments/.trash/<uuid>`.
let depth: Int
switch comment.kind {
case .comment, .draft: depth = 4
case .trashed: depth = 5
}
guard components.count >= depth else { return nil }
return components.prefix(depth).joined(separator: "/")
}
/// **The comment verb family** (01-storage-format.md § Enhanced schema, the `kind: comment` block:
/// "foreign comment changes are described by **path shape** the 'Update agent guide (vN)'
/// mechanism: a changed path under `/comments/<uuid>/` composes 'Comment on card title' / 'Edit
@@ -850,7 +960,7 @@ enum CommitMessageEngine {
})
var events: [Event] = []
for key in groups.keys.sorted() {
for key in chronological(groups, timestamps: request.commentTimestamps) {
guard let group = groups[key], !group.paths.isEmpty else { continue }
guard !relocated.contains(group.comment.cardPath) else { continue }
let card = titles[group.comment.cardPath] ?? untitledPlaceholder
@@ -891,6 +1001,47 @@ enum CommitMessageEngine {
return events
}
/// **A commit's comment bullets sort chronologically** (06-history-undo.md Rules Auto-commit,
/// blessed 2026-07-31):
///
/// > "by the comments' own `created`, folder name on ties event order reads as the conversation
/// > did, never UUID-arbitrary."
///
/// The ladder is `CommentThread.sorted`'s, one layer up and over folders rather than over parsed
/// comments: `created` ascending, **the undated after the dated**, folder name on ties compared
/// as the canonical lowercase spelling, the corpus-wide rule for every folder-name tie-break
/// (01-storage-format.md § Ordering). The thread the bullets describe is sorted by that ladder on
/// screen; a commit describing the same thread in a different order would be the same events told
/// out of sequence.
///
/// **Chronology is global rather than per card**, recorded as a judgment call. A window's comment
/// events almost always belong to one card the card-window session flush is that window by
/// construction and where they do not, "the order the events happened in" is still the honest
/// reading of a list of events; grouping by card first would sort a conversation by which lane its
/// card sits in. The group key breaks the last tie so the order is total, which keeps a message
/// reproducible rather than hash-ordered.
private static func chronological(
_ groups: [String: CommentGroup],
timestamps: [String: Date]
) -> [String] {
groups.keys.sorted { lhs, rhs in
guard let left = groups[lhs], let right = groups[rhs] else { return lhs < rhs }
switch (timestamps[left.folder], timestamps[right.folder]) {
case let (leftDate?, rightDate?) where leftDate != rightDate:
return leftDate < rightDate
case (.some, .none):
return true
case (.none, .some):
return false
default:
break
}
let leftName = IntegrityRules.canonicalIdentity(left.folderName)
let rightName = IntegrityRules.canonicalIdentity(right.folderName)
return leftName == rightName ? lhs < rhs : leftName < rightName
}
}
/// The card folders this commit says moved, arrived or went the set a comment path checks
/// itself against before speaking.
private static func relocatedCardFolders(in model: [Event]) -> Set<String> {
@@ -962,17 +1113,17 @@ enum CommitMessageEngine {
/// The verb-plus-noun grouping that decides what folds with what **06's vocabulary, one case
/// each**: Add / Delete / Move / Rename / Edit / Restyle / Resize / Reorder over cards, lanes and
/// the board, Attach / Remove for attachment files, Repair for the remint, the trash pair, the
/// reserved metadata trio, and the two path shapes.
/// the board, Attach / Remove / **Replace** for attachment files, Repair for the remint, the trash
/// pair, the reserved metadata trio, the custom-key change, and the two path shapes.
enum Kind: Hashable {
case addCard, deleteCard, restoreCard, purgeCard, moveCard, renameCard, editCard, restyleCard
case relabelCard, assignCard, dueCard, updateCard
case attachFile, removeFile, reorderCards, repairDuplicate
case relabelCard, assignCard, dueCard, changeKeyCard
case attachFile, removeFile, replaceFile, reorderCards, repairDuplicate
case addLane, deleteLane, restoreLane, purgeLane, renameLane, editLane, restyleLane, resizeLane
case relabelLane, assignLane, dueLane, updateLane, reorderLanes
case relabelLane, assignLane, dueLane, changeKeyLane, reorderLanes
case renameBoard, editBoard, restyleBoard, relabelBoard, assignBoard, dueBoard, updateBoard
case renameBoard, editBoard, restyleBoard, relabelBoard, assignBoard, dueBoard, changeKeyBoard
case agentGuide, updatePath
@@ -1014,13 +1165,18 @@ enum CommitMessageEngine {
case .relabelCard: return "Relabel \(count) cards"
case .assignCard: return "Assign \(count) cards"
case .dueCard: return "Set due date on \(count) cards"
case .updateCard: return "Update \(count) cards"
case .changeKeyCard:
guard let destination else { return "Change custom keys on \(count) cards" }
return "Change custom keys on card \(CommitMessageEngine.quotedSubject(destination))"
case .attachFile:
guard let destination else { return "Attach \(count) files" }
return "Attach \(count) files to card \(CommitMessageEngine.quotedSubject(destination))"
case .removeFile:
guard let destination else { return "Remove \(count) files" }
return "Remove \(count) files from card \(CommitMessageEngine.quotedSubject(destination))"
case .replaceFile:
guard let destination else { return "Replace \(count) attachments" }
return "Replace \(count) attachments — card \(CommitMessageEngine.quotedSubject(destination))"
case .reorderCards:
guard let destination else { return "Reorder cards in \(count) lanes" }
return "Reorder cards in \(CommitMessageEngine.truncated(destination))"
@@ -1037,7 +1193,9 @@ enum CommitMessageEngine {
case .relabelLane: return "Relabel \(count) lanes"
case .assignLane: return "Assign \(count) lanes"
case .dueLane: return "Set due date on \(count) lanes"
case .updateLane: return "Update \(count) lanes"
case .changeKeyLane:
guard let destination else { return "Change custom keys on \(count) lanes" }
return "Change custom keys on lane \(CommitMessageEngine.quotedSubject(destination))"
// A board has one title, one description, one style and a lane reorder is a single
// whole-board event. None of these can actually recur; the switch stays exhaustive.
@@ -1048,7 +1206,11 @@ enum CommitMessageEngine {
case .relabelBoard: return "Relabel board"
case .assignBoard: return "Assign board"
case .dueBoard: return "Set due date on board"
case .updateBoard: return CommitMessageEngine.unnamedSubject
// One board, so a plural of *events* here is a plural of keys already folded into one
// event unreachable in practice, and count-less if it ever is.
case .changeKeyBoard:
guard let destination else { return "Change custom keys on board" }
return "Change custom keys on board \(CommitMessageEngine.quotedSubject(destination))"
case .agentGuide: return "Update agent guide"
case .updatePath: return "Update \(count) files"
@@ -1108,7 +1270,9 @@ enum CommitMessageEngine {
/// Every `index.md` in the board's fractal layout, and everything inside `.trash/`. An
/// attachment is deliberately **not** here: the model carries attachment *names*, so an added
/// or removed file composes its own event, while a rewritten one same name, new bytes has
/// nothing in the snapshot to show for it and rightly composes "Update 'path'".
/// nothing in the snapshot to show for it and composes **Replace** off its path instead
/// (`replacedAttachment(at:)`, 06 Commit messages Vocabulary, added 2026-07-31 "never
/// the anonymous path generic").
/// Whether a path could make the *snapshot* differ at all every path the model speaks for,
/// plus attachments, whose names it carries.
///