Build the semantic commit-message engine

CommitMessageEngine replaces the interim composer as the wired
default: a pure total function from two snapshots + changed paths to
a message. Full vocabulary — Add / Delete / Move / Rename / Edit /
Restyle / Resize / Reorder over cards, lanes, board; Attach / Remove;
Repair for the duplicate remint (detected as a heal-classed
rename-paired arrival whose id the previous snapshot never held —
the loader withholds duplicates, so the shape is a bare arrival);
the trash triple by diff shape alone (into .trash = Delete, out =
Restore, leaving the tree = Permanently delete); Relabel / Assign /
Set due date plus the named generic for custom keys. Plural folding
with shared destinations, implied events as body bullets never
subjects, ~40-char subject truncation, "(untitled)". Bookkeeping
(sequence-preserving renumbers, stamps, backfilled kind) composes
nothing. Non-snapshot paths compose path-shaped events — CLAUDE.md
reads "Update agent guide (vN)" via the marker line (the m10 card's
deferred bullet lands here), everything else "Update '<path>'".

The comment verb family per 01's ruling (comments shipped, so 06
gains the verbs): Comment on / Edit comment on / Delete comment on /
Draft comment on / Permanently delete comment on '<card>', grouped
one event per comment folder, classified ahead of the model-silence
rules, card title resolved from either snapshot. GIT_DELTA_ADDED is
surfaced as GitChangedPath.isArrival — post vs edit is unanswerable
from snapshots that exclude comments by ruling. A card moving with
its thread swallows the comment events (implied-events one level
down).

The previous snapshot is HEAD's tree, materialized per flush into a
temp dir (index.md blobs in full, other blobs zero-byte — the model
reads attachment names, never bytes) and re-parsed through the one
BoardLoader; never a value carried forward. changedPaths is a hard
filter per split commit, which also earns the stage-around and kills
phantom events. Launch catch-up and foreign windows compose through
the same engine.

48 new tests (35 pure + comment family + engine-level); 2293 tests /
394 suites green; InertGitTests untouched.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-31 14:54:17 -04:00
parent 3c07c26fda
commit 563999655f
8 changed files with 2479 additions and 50 deletions
+147 -1
View File
@@ -103,7 +103,8 @@ struct AutoCommitDebounceTests {
#expect(committer.commitCount == 1)
#expect(isClean(at: fixture.root), "a flush leaves nothing dirty — branch switch depends on it")
#expect(try headSubject(at: fixture.root) == InterimCommitMessage.fallbackSubject)
// The message is the semantic composer's, end to end no placeholder anywhere in the path.
#expect(try headSubject(at: fixture.root) == "Add card 'Second'")
}
@Test("A burst of changes inside one window is one commit, not one per change")
@@ -893,6 +894,151 @@ struct EditSessionBoundaryTests {
}
}
// MARK: - Semantic messages, through the whole engine
/// The composer's own vocabulary is proved without a repository in `CommitMessageTests`. What is
/// proved here is the wiring: that a **real commit**, made by the real engine over real libgit2,
/// carries the composed message HEAD's tree read for the last-committed half, the working tree for
/// the current one, the split's own paths narrowing each message to its own commit.
@MainActor
@Suite("Auto-commit ▸ semantic messages")
struct AutoCommitMessageTests {
@Test("A foreign change composes identically to an app-mediated one — only the author differs")
func originIsNotInTheProse() async throws {
// 06 The external gap, closed: "Origin lives in the author field (structural attribution),
// not in message prose." Two boards, the same rename, one vouched for and one not.
func rename(vouchedFor: Bool) async throws -> CommitRecord {
let (fixture, git, ledger) = try await makeGitBoard()
defer { fixture.tearDown() }
let committer = try quickCommitter(git)
let text = plain(order: "1024", title: "Renamed")
try fixture.item("\(Ident.lane1)/\(Ident.card1)", text)
if vouchedFor {
ledger.recordWrite(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)")
.appendingPathComponent(BoardLoader.indexFileName),
text: text
)
committer.noteWriteBracketClosed()
} else {
committer.noteReloadLanded(sawForeignChange: true)
}
await committer.flushNow()
return try #require(try history(at: fixture.root).first)
}
let app = try await rename(vouchedFor: true)
let foreign = try await rename(vouchedFor: false)
#expect(app.subject == "Rename card 'First' → 'Renamed'")
#expect(foreign.subject == app.subject, "the message engine is origin-agnostic by design")
// and the author is the only thing that differs.
#expect(app.authorEmail != CommitAttribution.externalAuthorEmail)
#expect(foreign.authorEmail == CommitAttribution.externalAuthorEmail)
}
@Test("Board-open catch-up carries a real composed message, not a placeholder")
func launchCatchUpComposes() async throws {
// "Changes found pending at board open diff HEAD's tree against the working tree through the
// same composer, instead of committing blind" (06). Nothing signals this window: no reload
// landed, no bracket closed, and no snapshot was ever handed to the committer the previous
// board can only have come from HEAD.
let (fixture, git, _) = try await makeGitBoard()
defer { fixture.tearDown() }
let committer = try quickCommitter(git)
try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Written while closed"))
committer.start()
try await waitUntil { committer.commitCount == 1 }
#expect(try headSubject(at: fixture.root) == "Add card 'Written while closed'")
#expect(isClean(at: fixture.root))
}
@Test("The guide write auto-commits as 'Update agent guide (vN)'")
func theGuideComposesItsVersion() async throws {
// The m10 agent-guide card's deferred git bullet, landing here: N is read from the marker
// line of the bytes on disk (`AgentGuide.installedVersion`), never tagged at the write site.
let (fixture, git, _) = try await makeGitBoard()
defer { fixture.tearDown() }
let committer = try quickCommitter(git)
// An older guide, committed so the window is a genuine guide *upgrade*: HEAD's bytes carry
// v1 and the working tree's carry the version this build ships.
try fixture.file(AgentGuide.filename, Data("<!-- lanework-agent-guide v1 -->\nOld.\n".utf8))
committer.noteReloadLanded(sawForeignChange: true)
await committer.flushNow()
#expect(try headSubject(at: fixture.root) == "Update agent guide (v1)")
_ = try AgentGuide.install(atBoardRoot: fixture.root)
committer.noteWriteBracketClosed()
await committer.flushNow()
#expect(try headSubject(at: fixture.root) == "Update agent guide (v\(AgentGuide.version))")
}
@Test("A split window's two commits each describe only their own paths")
func eachCommitDescribesItsOwnPaths() async throws {
// Both messages compose against the same HEAD, so the only thing that can keep them apart is
// the changed-path list each commit stages the filter, proved end to end.
let (fixture, git, ledger) = try await makeGitBoard()
defer { fixture.tearDown() }
let committer = try quickCommitter(git)
try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Touched by an agent"))
let text = plain(order: "2048", title: "Added by the user")
let card = try fixture.item("\(Ident.lane1)/\(Ident.card2)", text)
ledger.recordWrite(at: card.appendingPathComponent(BoardLoader.indexFileName), text: text)
committer.noteWriteBracketClosed()
committer.noteReloadLanded(sawForeignChange: true)
await committer.flushNow()
let log = try history(at: fixture.root)
#expect(committer.commitCount == 2)
// Newest first: the user's overwrite lands after the foreign version it might have buried.
#expect(log.first?.subject == "Add card 'Added by the user'")
#expect(log.dropFirst().first?.subject == "Rename card 'First' → 'Touched by an agent'")
#expect(log.dropFirst().first?.authorEmail == CommitAttribution.externalAuthorEmail)
}
@Test("A comment lands in the trail by its own verb, through real libgit2")
func commentsComposeTheirFamily() async throws {
// The one part of the comment family that cannot be proved without a repository: "is this
// path new" is `GIT_DELTA_ADDED`, read off the real diff the fact that tells a post from an
// edit where the snapshot has nothing to say (01-storage-format.md § Enhanced schema).
let (fixture, git, _) = try await makeGitBoard()
defer { fixture.tearDown() }
let committer = try quickCommitter(git)
let comment = "\(Ident.lane1)/\(Ident.card1)/comments/cccccccc-0000-4000-8000-000000000001"
try fixture.file("\(comment)/index.md", Data("---\nschema: 1\nkind: comment\n---\nLooks good.\n".utf8))
committer.noteReloadLanded(sawForeignChange: true)
await committer.flushNow()
#expect(try headSubject(at: fixture.root) == "Comment on 'First'")
try fixture.file("\(comment)/index.md", Data("---\nschema: 1\nkind: comment\n---\nOn reflection.\n".utf8))
committer.noteReloadLanded(sawForeignChange: true)
await committer.flushNow()
#expect(try headSubject(at: fixture.root) == "Edit comment on 'First'")
}
@Test("A stray-only window names the stray rather than shrugging")
func straysAreNamed() async throws {
let (fixture, git, _) = try await makeGitBoard()
defer { fixture.tearDown() }
let committer = try quickCommitter(git)
try fixture.file("notes.txt", Data("scratch\n".utf8))
committer.noteReloadLanded(sawForeignChange: true)
await committer.flushNow()
#expect(try headSubject(at: fixture.root) == "Update 'notes.txt'")
}
}
// MARK: - Attribution, as a pure function
@Suite("Auto-commit ▸ attribution rules")