The message engine outlives its substrate — harvested to Kanban/Changes/ as the change narrator

Step 2 of strategy/01-git-excision.md: CommitMessageEngine and the composer seam relocate to a neutral module renamed away from commit vocabulary (ChangeNarrator, ChangeNarrationRequest, ChangeNarrating, SemanticChangeNarration, ChangeAuthorship), GitChangedPath extracts from GitCommitOperation as ChangedPath, and the one git tie severs — authorship's foreign case carries a display name, not a GitIdentity. The spec tests transplant as ChangeNarratorTests, alive until the journal work begins. 3,009 tests green.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-08 10:38:43 -04:00
parent 6d1872ad8b
commit ae7be98eaa
8 changed files with 207 additions and 182 deletions
+6 -6
View File
@@ -53,20 +53,20 @@ public struct HarvestedReceipt: Sendable, Equatable {
public struct CommitSplit: Sendable, Equatable {
/// Changes nobody vouched for an agent, a text editor, a terminal, or a blind window at launch.
public var foreign: [GitChangedPath] = []
public var foreign: [ChangedPath] = []
/// The scheduled healers' paths, heal-marked in the ledger by the Writer operations that made
/// them (`EchoLedger.markHeal`).
public var heal: [GitChangedPath] = []
public var heal: [ChangedPath] = []
/// The user acting through the app.
public var user: [GitChangedPath] = []
public var user: [ChangedPath] = []
public init() {}
/// One class of one window's changes, ready to become a commit.
public struct Group: Sendable, Equatable {
public let paths: [GitChangedPath]
public let paths: [ChangedPath]
/// Which class it is carried rather than re-derived, so the planner never has to ask a
/// list whether it contains its own members.
public let kind: Kind
@@ -171,7 +171,7 @@ public enum CommitAttribution {
/// The **nearest** receipt wins, so a rewritten `index.md` inside a moved folder answers with
/// its own content receipt rather than with the move above it.
public static func split(
_ paths: [GitChangedPath],
_ paths: [ChangedPath],
under boardRoot: URL,
receipts: [String: HarvestedReceipt]
) -> CommitSplit {
@@ -236,7 +236,7 @@ public enum CommitAttribution {
/// does which is why the agent guide teaches re-stamping on move.
///
/// A window of nothing but rename departures leaves no stamp to agree on and falls back too.
public static func foreignIdentity(for paths: [GitChangedPath], under boardRoot: URL) -> GitIdentity {
public static func foreignIdentity(for paths: [ChangedPath], under boardRoot: URL) -> GitIdentity {
var stamps: Set<String> = []
for path in paths {
if path.isDeletion {
-148
View File
@@ -1,148 +0,0 @@
import Foundation
// MARK: - Authorship
/// Which of the three classes a commit is the axis the message engine is allowed to know about.
///
/// The composer receives it because 06-history-undo.md Commit messages gives one rule that turns
/// on it (the root commit's fixed subject) and one that deliberately does not: "**Foreign commits
/// speak the same vocabulary.** Origin lives in the author field (structural attribution), not in
/// message prose a foreign move reads 'Move card ' exactly like an app-mediated one". The
/// composer card therefore gets the fact and is expected to ignore it for phrasing; having it means
/// it never has to be plumbed later, and having it *named* means the rule about not using it has
/// something to point at.
public enum CommitAuthorship: Sendable, Equatable {
/// The user acting through the app.
case user
/// A scheduled heal's own commit (ruled 2026-07-29).
case heal
/// Everything else, carrying the author it will be committed under.
case foreign(GitIdentity)
}
// MARK: - The request
/// Everything the message engine is handed for one commit.
///
/// **A struct rather than an argument list**, because the point of this seam is that the semantic
/// composer plugs into it without reshaping the engine: any input it turns out to need joins this
/// type rather than every call site. It did need two `previousSnapshot` and `agentGuideText`, both
/// below and that is exactly what this shape was for.
///
/// **Everything here is a value, and that is the design.** The composer (`CommitMessageEngine`) reads
/// no file and opens no repository: the flush resolves the environment once the board as HEAD has
/// it, the board as the app has it, the guide's bytes and the message is then a pure function of
/// this struct. Resolving once per *flush* rather than once per planned commit also means a
/// three-way-split window materializes HEAD's tree once, not three times.
public struct CommitMessageRequest: Sendable {
/// The board this is a commit in.
public let boardRoot: URL
/// **The changed-path list** (06 Commit messages Non-snapshot files commit too: "beside the
/// snapshot diff it receives the changed-path list, and non-snapshot paths compose *path-shaped
/// events*"), narrowed to the paths *this* commit stages.
public let changedPaths: [GitChangedPath]
/// Which class this commit is.
public let authorship: CommitAuthorship
/// Whether this is the repository's first commit the one commit with a subject of its own
/// ("Initial board state", 06 Rules Abnormal repo states).
public let isRootCommit: Bool
/// **The current half** of the composer's "last-committed vs. current" diff the board as the
/// app last read it, or, where no store is attached, as the flush read it off disk itself.
///
/// `nil` only when neither could answer: a board whose working tree does not load at all, which
/// is a commit that will have to be described by its paths.
public let snapshot: BoardModel?
/// **The last-committed half**: the board as HEAD's tree has it (`GitHeadSnapshot`).
///
/// `nil` on an unborn HEAD where `isRootCommit` already says everything and on a HEAD whose
/// tree does not load as a board. It is read from the repository rather than carried forward from
/// the last commit the app made, because the app is not the only writer and because launch
/// catch-up has no carried value to offer: see `GitHeadSnapshot` for the whole of that argument.
public let previousSnapshot: BoardModel?
/// **The board-root `CLAUDE.md` as it now reads**, when this commit touches it the one
/// non-snapshot file with a subject of its own ("Update agent guide (vN)", 06 Commit messages).
///
/// The *text*, not the version: N is "a pure function of file content"
/// (`AgentGuide.installedVersion`), and keeping the parse on the composer's side is what keeps
/// that rule where the message vocabulary is. `nil` when the guide is not in this commit, cannot
/// be read, or has been deleted.
public let agentGuideText: String?
/// **When each of this commit's comments was created** keyed by the comment folder's
/// board-root-relative path, as `CommitMessageEngine.commentFolder(of:)` spells it.
///
/// The second value on this struct that a *file* has to be read for, and it is here for
/// `agentGuideText`'s reason exactly: "a commit's comment bullets sort chronologically by the
/// comments' own `created`, folder name on ties" (06 Rules Auto-commit, blessed 2026-07-31),
/// and `created` lives in a comment's own `index.md` because comments are window-scoped and the
/// board snapshot never carries them (01-storage-format.md § Enhanced schema). The flush resolves
/// it once (`GitAutoCommitter.commentTimestamps(for:boardRoot:)`) and the engine stays a pure
/// function of values.
///
/// **Missing is normal, not a defect.** A comment whose folder left the tree in this very commit
/// (the close purge), one whose `index.md` does not parse, one written by hand with no `created`
/// at all each is simply absent here and sorts after its dated siblings in folder-name order,
/// which is `CommentThread.sorted`'s own fallback for the same field.
public let commentTimestamps: [String: Date]
public init(
boardRoot: URL,
changedPaths: [GitChangedPath],
authorship: CommitAuthorship,
isRootCommit: Bool,
snapshot: BoardModel?,
previousSnapshot: BoardModel? = nil,
agentGuideText: String? = nil,
commentTimestamps: [String: Date] = [:]
) {
self.boardRoot = boardRoot
self.changedPaths = changedPaths
self.authorship = authorship
self.isRootCommit = isRootCommit
self.snapshot = snapshot
self.previousSnapshot = previousSnapshot
self.agentGuideText = agentGuideText
self.commentTimestamps = commentTimestamps
}
}
// MARK: - The seam
/// **What a commit says** (06-history-undo.md Commit messages).
///
/// The implementation is `SemanticCommitMessage` below, over `CommitMessageEngine`: "a pure, testable
/// function" composing from a structural diff of two board snapshots, with the whole
/// Add/Delete/Move/Rename/Edit vocabulary, plural folding, path-shaped events for non-snapshot files,
/// and the trash pair. The protocol survives its interim purpose because it is still what lets a test
/// inject a fake and assert *when* a message was asked for without asserting what it said.
///
/// `Sendable` because composition runs off the main actor, inside the same detached task that stages
/// and commits the message has to be in hand before `git_commit_create` is called, and none of the
/// work is main-actor work.
public protocol CommitMessageComposing: Sendable {
func message(for request: CommitMessageRequest) -> String
}
// MARK: - The wired composer
/// **The semantic composer**, and the committer's default (`GitAutoCommitter.composer`).
///
/// A one-line conformance over `CommitMessageEngine`, deliberately: the vocabulary is worth a file of
/// its own and nothing about it should have to know that a protocol exists. The type stays because
/// the seam takes an existential, and because a *named* default is what makes "the engine's composer
/// is the semantic one" assertable.
public struct SemanticCommitMessage: CommitMessageComposing {
public init() {}
public func message(for request: CommitMessageRequest) -> String {
CommitMessageEngine.message(for: request)
}
}
File diff suppressed because it is too large Load Diff
+32 -30
View File
@@ -130,7 +130,7 @@ public final class GitAutoCommitter {
/// (06 Commit messages). Settable so a test can inject a fake and assert *that* a message was
/// asked for without asserting what it said.
@ObservationIgnored
public var composer: any CommitMessageComposing = SemanticCommitMessage()
public var composer: any ChangeNarrating = SemanticChangeNarration()
/// The board as the app last read it, for the composer's "current" half. `nil` where no store is
/// attached, which is every storeless test.
@@ -676,7 +676,7 @@ public final class GitAutoCommitter {
let boardRoot: URL
let excludedFolders: [String]
let receipts: [String: HarvestedReceipt]
let composer: any CommitMessageComposing
let composer: any ChangeNarrating
let snapshot: BoardModel?
}
@@ -740,7 +740,7 @@ public final class GitAutoCommitter {
/// Once per *flush*, not once per planned commit: a window that splits three ways
/// (foreign heal user) composes all three messages against the same HEAD, so materializing
/// HEAD's tree three times would be three answers to one question. Each message is then narrowed
/// to its own commit by `CommitMessageRequest.changedPaths`, which the split already narrows.
/// to its own commit by `ChangeNarrationRequest.changedPaths`, which the split already narrows.
private struct Composition: Sendable {
var previous: BoardModel?
var current: BoardModel?
@@ -749,13 +749,13 @@ public final class GitAutoCommitter {
}
/// Reads the two snapshots and the guide's bytes the only impure step in the message path, kept
/// here so `CommitMessageEngine` can be a pure function of values.
/// here so `ChangeNarrator` can be a pure function of values.
///
/// **Skipped entirely when nothing in the window could touch the model**, which is the ordinary
/// stray-only and guide-only window: those compose path-shaped events, and materializing a board
/// twice to describe a changed `.gitignore` would be work with no reader.
private nonisolated static func composition(
for changed: [GitChangedPath],
for changed: [ChangedPath],
input: FlushInput
) -> Composition {
var composition = Composition()
@@ -769,7 +769,7 @@ public final class GitAutoCommitter {
// entirely (01-storage-format.md § Enhanced schema), so HEAD's tree has nothing to say about
// them but the verbs name the *card* ("Comment on 'Fix login'"), and only a board knows a
// card's title. So a comment-only window loads the current board and skips the materialization.
let touchesModel = changed.contains { CommitMessageEngine.Paths.mightAffectSnapshot($0.path) }
let touchesModel = changed.contains { ChangeNarrator.Paths.mightAffectSnapshot($0.path) }
let namesACard = changed.contains { CommentPath.classify($0.path) != nil }
// **The chronology the bullets sort by** (06 Rules Auto-commit, blessed 2026-07-31) the
// one field of a comment the composer needs and the board snapshot cannot carry. Read beside
@@ -791,7 +791,7 @@ public final class GitAutoCommitter {
}
/// **When each comment this window touched was created**, keyed by its folder the chronology
/// `CommitMessageEngine` sorts a commit's comment bullets by (06 Rules Auto-commit, blessed
/// `ChangeNarrator` sorts a commit's comment bullets by (06 Rules Auto-commit, blessed
/// 2026-07-31: "by the comments' own `created`, folder name on ties").
///
/// One `index.md` per touched comment folder, read off the **working tree** which is the state
@@ -806,13 +806,13 @@ public final class GitAutoCommitter {
/// way a flush does, instead of hand-assembling a map the flush could never produce
/// (`WriterFixture.snapshot()`'s reason, restated one field down).
nonisolated static func commentTimestamps(
for changed: [GitChangedPath],
for changed: [ChangedPath],
boardRoot: URL
) -> [String: Date] {
var timestamps: [String: Date] = [:]
var seen: Set<String> = []
for path in changed {
guard let folder = CommitMessageEngine.commentFolder(of: path.path), seen.insert(folder).inserted
guard let folder = ChangeNarrator.commentFolder(of: path.path), seen.insert(folder).inserted
else { continue }
let index = boardRoot
.appendingPathComponent(folder)
@@ -828,7 +828,7 @@ public final class GitAutoCommitter {
/// The three-way split turned into commits or, on an unborn HEAD, the one commit 06 fixes.
private nonisolated static func plan(
_ changed: [GitChangedPath],
_ changed: [ChangedPath],
reading: GitRepositoryReading,
input: FlushInput,
composition: Composition
@@ -836,11 +836,11 @@ public final class GitAutoCommitter {
let user = GitCommitOperation.userIdentity(at: input.boardRoot)
func request(
_ paths: [GitChangedPath],
_ authorship: CommitAuthorship,
_ paths: [ChangedPath],
_ authorship: ChangeAuthorship,
isRootCommit: Bool = false
) -> CommitMessageRequest {
CommitMessageRequest(
) -> ChangeNarrationRequest {
ChangeNarrationRequest(
boardRoot: input.boardRoot,
changedPaths: paths,
authorship: authorship,
@@ -862,7 +862,7 @@ public final class GitAutoCommitter {
guard !reading.isUnborn else {
return [PlannedCommit(
paths: changed.map(\.path),
message: input.composer.message(for: request(changed, .user, isRootCommit: true)),
message: input.composer.narrative(for: request(changed, .user, isRootCommit: true)),
author: user,
committer: user,
kind: .root
@@ -871,28 +871,28 @@ public final class GitAutoCommitter {
let split = CommitAttribution.split(changed, under: input.boardRoot, receipts: input.receipts)
return split.ordered.map { group in
let authorship: CommitAuthorship
// One combined switch over `group.kind`, producing both `authorship` (what the message
// seam is allowed to know) and `author` (who the commit is actually by) the foreign
// branch resolves the identity once and both derive from it.
let authorship: ChangeAuthorship
let author: GitIdentity
switch group.kind {
case .foreign:
authorship = .foreign(
CommitAttribution.foreignIdentity(for: group.paths, under: input.boardRoot)
)
let identity = CommitAttribution.foreignIdentity(for: group.paths, under: input.boardRoot)
authorship = .foreign(identity.name)
author = identity
// **A heal is authored `Lanework Integrity <integrity@lanework.invalid>`** (06 Commit
// messages Healing mutations commit separately, ruled 2026-07-31): "a heal is a third
// origin not the user's gesture, not a foreign writer and the separation exists for
// audit, so the trail filters by author like every origin". This authored heals as the
// *user* until that ruling, which left the separate commit filterable only by message
// shape and the shape vocabulary deliberately never says "healed".
case .heal: authorship = .heal
case .user: authorship = .user
}
// The committer stays the user throughout 06's recorded-by convention, which is why
// only the author varies here.
let author: GitIdentity
switch authorship {
case let .foreign(identity): author = identity
case .heal: author = CommitAttribution.integrityIdentity
case .user: author = user
case .heal:
authorship = .heal
author = CommitAttribution.integrityIdentity
case .user:
authorship = .user
author = user
}
let kind: PlannedCommitKind
switch group.kind {
@@ -900,9 +900,11 @@ public final class GitAutoCommitter {
case .heal: kind = .heal
case .user: kind = .user
}
// The committer stays the user throughout 06's recorded-by convention, which is why
// only the author varies above.
return PlannedCommit(
paths: group.paths.map(\.path),
message: input.composer.message(for: request(group.paths, authorship)),
message: input.composer.narrative(for: request(group.paths, authorship)),
author: author,
committer: user,
kind: kind
+6 -50
View File
@@ -88,50 +88,6 @@ public struct GitRepositoryReading: Sendable, Equatable {
}
}
// MARK: - Changed paths
/// One path `git status` reports as differing between HEAD and the working tree.
///
/// Board-root-relative and file-granular, which is the unit both consumers want: staging adds or
/// removes exactly these, and attribution asks a question per *file* (06 Interaction with external
/// writers: "classify every observed change, per file").
public struct GitChangedPath: Sendable, Equatable, Hashable {
/// The path, relative to the board root, in git's own spelling (`/` separators, no leading dot).
public let path: String
/// Whether the file is **gone** from the working tree.
///
/// The `modified-by` rule turns on this bit "any true deletion in the window falls back to
/// `Lanework External` a deletion leaves no file to stamp" which is why the rename half
/// below is a separate fact rather than folded in here.
public let isDeletion: Bool
/// Whether this path is one end of a **rename** libgit2 paired up.
///
/// "**A folder move is not a deletion**: items match by id across the whole board so a moved
/// card attributes by its stamp like any changed file" (06). A paired departure is therefore a
/// deletion on disk that the window must not be demoted by.
public let isRename: Bool
/// Whether the path is **new in this commit** git's own `GIT_DELTA_ADDED` (and a rename's
/// arriving end), surfaced rather than inferred.
///
/// It exists for the comment verb family (01-storage-format.md § Enhanced schema): comments are
/// window-scoped and the board snapshot never carries them, so "Comment on 'X'" and "Edit comment
/// on 'X'" cannot be told apart by a diff of two snapshots the only thing that distinguishes a
/// comment folder arriving from one being rewritten is whether HEAD already had it, which is
/// exactly the question this diff already answered.
public let isArrival: Bool
public init(path: String, isDeletion: Bool, isRename: Bool, isArrival: Bool = false) {
self.path = path
self.isDeletion = isDeletion
self.isRename = isRename
self.isArrival = isArrival
}
}
// MARK: - A planned commit
/// **Which of 06's classes a planned commit belongs to** carried through the libgit2 work so a
@@ -174,7 +130,7 @@ public struct GitLandedCommit: Sendable, Equatable {
/// two-commit split; ruled 2026-07-29: the heal's third class).
public struct PlannedCommit: Sendable, Equatable {
/// Board-root-relative paths, exactly as `GitChangedPath.path` spells them.
/// Board-root-relative paths, exactly as `ChangedPath.path` spells them.
public let paths: [String]
public let message: String
@@ -380,7 +336,7 @@ enum GitCommitOperation {
/// but history stops advancing; surfaced per 02-architecture.md Write-failure surfacing."
/// (Found by test rather than by reading: the failure suite went green-by-silence when discovery
/// moved from `git_status` to staging.)
nonisolated static func surveyChangedPaths(at boardRoot: URL) -> [GitChangedPath]? {
nonisolated static func surveyChangedPaths(at boardRoot: URL) -> [ChangedPath]? {
_ = startUp
guard let repository = open(boardRoot) else { return nil }
defer { git_repository_free(repository) }
@@ -393,11 +349,11 @@ enum GitCommitOperation {
/// The survey, with "could not look" folded into "nothing to do" for the callers that have no
/// failure channel and want the safe answer: `GitRepository.create`'s branch line, and the tests'
/// clean-tree assertions.
nonisolated static func changedPaths(at boardRoot: URL) -> [GitChangedPath] {
nonisolated static func changedPaths(at boardRoot: URL) -> [ChangedPath] {
surveyChangedPaths(at: boardRoot) ?? []
}
private static func changedPaths(in repository: OpaquePointer, index: OpaquePointer) -> [GitChangedPath]? {
private static func changedPaths(in repository: OpaquePointer, index: OpaquePointer) -> [ChangedPath]? {
var pathspec = git_strarray()
guard git_index_add_all(index, &pathspec, GIT_INDEX_ADD_DEFAULT.rawValue, nil, nil) == 0 else {
return nil
@@ -426,12 +382,12 @@ enum GitCommitOperation {
_ = git_diff_find_similar(diff, &findOptions)
}
var found: [String: GitChangedPath] = [:]
var found: [String: ChangedPath] = [:]
func record(_ path: String?, isDeletion: Bool, isRename: Bool, isArrival: Bool = false) {
guard let path, !path.isEmpty else { return }
let existing = found[path]
found[path] = GitChangedPath(
found[path] = ChangedPath(
path: path,
// Present wins where two deltas disagree: staging asks "is it there now", and the
// `modified-by` demotion must not fire for a file the window ends with.