Make the card window the commit unit on Pro boards

Phase C of the two-level undo card: the committer stages around the
whole open card folder — comments included — so gestures in an open
window never land in interim commits; window close flushes the session
as one semantically-named commit ("Edit card 'X'" with the thread as
body bullets, "Mixed update — N changes to card 'X'" when events mix),
with the two-commit foreign/user split preserved and the
comments/.trash purge riding the same bracket. Comment gestures lose
their per-gesture commits structurally (they write inside the held
folder). Branch-switch settle releases every window's staging before
checkout and re-arms on resume.

Fixes two latent pro-m1 defects: the committer was composed without
the store's EchoLedger, so every production commit classified foreign
and was authored Lanework External; and interim flushes dropped
harvest receipts they had not spent, unvouching the session's own
writes at close. Also lands 06's mixed-subject re-ruling (the retired
"Update board" fallback) and phase B's two files missed by the
previous commit's pathspec.

2444 tests in 422 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-07-31 20:23:45 -04:00
parent 9119aa1e9a
commit a381fac742
11 changed files with 1709 additions and 105 deletions
+77 -12
View File
@@ -35,9 +35,35 @@ import Foundation
/// window still commits with its strays named.
enum CommitMessageEngine {
/// **06's own mixed-window fallback**: "genuinely mixed windows fall back to 'Update board'
/// always with a bulleted body naming every event".
static let mixedSubject = "Update board"
/// **A genuinely mixed window says so** (06 Commit messages, re-ruled 2026-07-31 "retiring
/// the bare 'Update board' fallback"):
///
/// > **"Mixed update N changes"**, always with a bulleted body naming every event, so the
/// > oneline log stays scannable and never dresses a grab-bag as one thing; when every event in
/// > the window shares one item the card-window session flush's usual shape the subject keeps
/// > the name: **"Mixed update N changes to card 'title'"**.
///
/// The named case is the reason this exists: a card window's close flush is *by construction* a
/// window of one card's changes, and "Update board" was the one subject that could not say which
/// card a whole session belonged to.
///
/// - Parameters:
/// - count: how many events the body will list the subject counts changes, not commits.
/// - item: the rendered noun phrase every event shares ("card 'Fix login'"), or `nil` when they
/// genuinely span the board.
static func mixedSubject(_ count: Int, item: String? = nil) -> String {
let changes = count == 1 ? "1 change" : "\(count) changes"
guard let item else { return "Mixed update — \(changes)" }
return "Mixed update — \(changes) to \(item)"
}
/// The subject a window with no describable event at all falls to.
///
/// Vanishingly rare and deliberately kept: the tree's commit condition is the *tree*, not the
/// snapshot diff, so a window whose every path composed nothing still commits rather than leaving
/// the tree dirty (06 Commit messages Non-snapshot files commit too). "0 changes" would be a
/// lie about a commit that does contain something; this says what it honestly is.
static let unnamedSubject = "Update board"
/// **An untitled item reads "(untitled)" never a bare `""`** (06 Commit messages: "a
/// pathfinder edge fixed, not carried").
@@ -65,7 +91,7 @@ enum CommitMessageEngine {
// commits the whole tree as *Initial board state*, never a folded diff-from-empty: there is
// no last-committed snapshot to diff against."
guard !request.isRootCommit else { return GitRepository.initialCommitSubject }
return assemble(events(for: request))
return assemble(events(for: request), request: request)
}
/// Every event this commit is composed of model events in board reading order, then the
@@ -91,14 +117,16 @@ enum CommitMessageEngine {
/// - One event: its own subject, plus its detail line where it has one.
/// - Several: a subject chosen from the **headline** events, and a bullet naming *every* event
/// "so the oneline log stays scannable and the full message stays complete".
private static func assemble(_ events: [Event]) -> String {
guard !events.isEmpty else { return mixedSubject }
private static func assemble(_ events: [Event], request: CommitMessageRequest) -> String {
guard !events.isEmpty else { return unnamedSubject }
if events.count == 1 {
guard let detail = events[0].detail else { return events[0].subject }
return "\(events[0].subject)\n\n\(detail)"
}
let body = events.map { "- \($0.bullet)" }.joined(separator: "\n")
return "\(subject(for: headline(of: events)))\n\n\(body)"
let subject = subject(for: headline(of: events))
?? mixedSubject(events.count, item: sharedItem(of: events, request: request))
return "\(subject)\n\n\(body)"
}
/// The events allowed to *choose* the subject, in the order 06 ranks them.
@@ -116,15 +144,52 @@ enum CommitMessageEngine {
}
/// "A single event is the subject; several events of one kind fold into a plural subject, with
/// shared destinations preserved; genuinely mixed windows fall back to 'Update board'."
private static func subject(for headline: [Event]) -> String {
guard let first = headline.first else { return mixedSubject }
/// shared destinations preserved" or `nil`, which is the genuinely mixed window the caller names
/// with `mixedSubject(_:item:)`.
private static func subject(for headline: [Event]) -> String? {
guard let first = headline.first else { return nil }
if headline.count == 1 { return first.subject }
guard Set(headline.map(\.kind)).count == 1 else { return mixedSubject }
guard Set(headline.map(\.kind)).count == 1 else { return nil }
let destinations = Set(headline.compactMap(\.destination))
return first.kind.plural(headline.count, destination: destinations.count == 1 ? destinations.first : nil)
}
/// **The one item every event in this window belongs to, rendered** "card 'Fix login'" or
/// `nil` when they span more than one.
///
/// Read from the events' own **paths** rather than from a field each event would have to remember
/// to carry: an event's paths are the only thing in this engine that is always true about where it
/// came from, and a card's folder is the one component that survives a lane move. A window with a
/// path that belongs to no card at all the board's own `index.md`, a lane, a stray, the agent
/// guide is by definition not one card's window, so a single `nil` answer decides the whole
/// question.
private static func sharedItem(of events: [Event], request: CommitMessageRequest) -> String? {
var folder: String?
for event in events {
for path in event.paths {
guard let card = cardFolder(of: path) else { return nil }
if let folder, folder != card { return nil }
folder = card
}
}
guard let folder else { return nil }
let title = cardTitlesByPath(request)[folder] ?? untitledPlaceholder
return "card \(quotedSubject(title))"
}
/// The `<lane>/<card>` (or `.trash/<card>`) folder a board-root-relative path belongs to, or `nil`
/// when it belongs to no card the board's `index.md`, a lane's, a root stray.
///
/// Comment paths answer through `CommentPath`, which already knows the thread's two containers, so
/// the one rule about where a card lives is not spelled twice.
private static func cardFolder(of path: String) -> String? {
if let comment = CommentPath.classify(path) { return comment.cardPath }
let components = path.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
guard components.count >= 2, BoardLoader.isUUIDShaped(components[1]) else { return nil }
guard components[0] == Paths.trashFolder || BoardLoader.isUUIDShaped(components[0]) else { return nil }
return "\(components[0])/\(components[1])"
}
// MARK: - The structural diff
private static func modelEvents(
@@ -983,7 +1048,7 @@ enum CommitMessageEngine {
case .relabelBoard: return "Relabel board"
case .assignBoard: return "Assign board"
case .dueBoard: return "Set due date on board"
case .updateBoard: return CommitMessageEngine.mixedSubject
case .updateBoard: return CommitMessageEngine.unnamedSubject
case .agentGuide: return "Update agent guide"
case .updatePath: return "Update \(count) files"