Build the auto-commit engine
Every settled change on a git-mode board commits, debounced 2s past drag/typing churn, staged whole-root with .gitignore respected. GitCommitOperation reaches the vendored libgit2 directly (same 1.9.2 pin SwiftGitX resolves — importable, not duplicated) for signature-capable commits; add-git's config materialization is gone, identity resolves at commit time (repo-local config, else derived default) per the 2026-07-31 ruling in 06. CommitAttribution classifies per file off EchoLedger receipts: user identity on app-mediated windows, Lanework External <[email protected]> on foreign, the modified-by refinement (<slug>@agents.lanework .invalid) when every foreign file agrees, heal-marked receipts split into their own commit — window split foreign → heal → user. Edit-session granularity: ~700ms saves stay uncommitted, staging excludes open session folders (closure-resolved so mid-session moves stage around the new location), session end nudges the debounce so each session lands exactly one body commit. Flush-before-overwrite gates on known-foreign windows and commits synchronously ahead of the write; close/quit flush the pipeline via CloseFlushCoordinator's committerFlush. index.lock backs off briefly then re-debounces silently; clean tree no-ops; genuine failures ride the standing history-suspension banner and retry next debounce. Abnormal repo states (detached HEAD, merge/rebase/cherry-pick in progress) hold the engine with a 15s re-check; unborn HEAD commits "Initial board state" whole-tree; dirty tree at open catches up through the same engine. Message seam (CommitMessageComposing) ships interim — the semantic composer is the next card. Discovery diffs HEAD against an in-memory index with rename detection (git status alone never pairs a bare mv), and a failed survey reads as "could not look", never "nothing changed". 46 new tests / 8 suites, all real repositories via bundled libgit2. 2240 tests / 383 suites green; InertGitTests untouched. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - A harvested receipt
|
||||
|
||||
/// **One EchoLedger receipt, copied out for the committer** (02-architecture.md ▸ Components
|
||||
/// ▸ EchoLedger; 06-history-undo.md ▸ Interaction with external writers).
|
||||
///
|
||||
/// ### Why a copy and not a read
|
||||
///
|
||||
/// The ledger's receipts are **consumed** by the landing reload that classifies them — "one write,
|
||||
/// one echo", which is what buys the announcer its silence. The committer asks its question two
|
||||
/// seconds later, by which time several reloads have landed and every receipt for the user's own
|
||||
/// card edit is gone. Reading the live ledger at flush time would therefore attribute the user's own
|
||||
/// work to `Lanework External`, which is the one misattribution this whole mechanism exists to
|
||||
/// prevent.
|
||||
///
|
||||
/// So the committer harvests at the **close of each write bracket** — the moment a receipt describes
|
||||
/// a completed write and nothing has had a chance to consume it — and keeps its own copy for the
|
||||
/// life of the debounce window. Supersession still works: a later bracket's harvest overwrites the
|
||||
/// same key with the newer hash, exactly as the ledger's own `recordWrite` does.
|
||||
///
|
||||
/// The satisfaction check stays the ledger's rule, re-applied against disk at commit time, so the
|
||||
/// two races 02 settles land the same way here: byte-identical foreign bytes over a fresh app write
|
||||
/// classify app-mediated, and a foreign edit that misses the hash classifies foreign.
|
||||
public struct HarvestedReceipt: Sendable, Equatable {
|
||||
|
||||
public let receipt: EchoLedger.Receipt
|
||||
|
||||
/// **Whether the write that dropped it was a heal** — the flag 06 (ruled 2026-07-29) keys the
|
||||
/// third commit class on: "a debounce window holding a scheduled heal's changes alongside anyone
|
||||
/// else's splits the heal's paths into their own commit".
|
||||
public let isHeal: Bool
|
||||
|
||||
public init(receipt: EchoLedger.Receipt, isHeal: Bool) {
|
||||
self.receipt = receipt
|
||||
self.isHeal = isHeal
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The split
|
||||
|
||||
/// One debounce window's changed paths, divided into the commits they will become.
|
||||
///
|
||||
/// **Three classes, committed in this order** — foreign, then heal, then the user's:
|
||||
///
|
||||
/// - *Foreign first* is 06's own ordering, stated as a consequence of flush-before-overwrite:
|
||||
/// "flush-before-overwrite already orders them: foreign first, then the user's overwrite". The log
|
||||
/// then reads causally — what arrived, then what the user did about it.
|
||||
/// - *Heal in the middle* is a judgment call, recorded: DESIGN fixes the heal's **separation** and
|
||||
/// not its position. A scheduled heal repairs what a load found, so it follows the foreign change
|
||||
/// that usually caused it and precedes the user's gesture, which is the order the three actually
|
||||
/// happened in.
|
||||
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] = []
|
||||
|
||||
/// The scheduled healers' paths, heal-marked in the ledger by the Writer operations that made
|
||||
/// them (`EchoLedger.markHeal`).
|
||||
public var heal: [GitChangedPath] = []
|
||||
|
||||
/// The user acting through the app.
|
||||
public var user: [GitChangedPath] = []
|
||||
|
||||
public init() {}
|
||||
|
||||
/// One class of one window's changes, ready to become a commit.
|
||||
public struct Group: Sendable, Equatable {
|
||||
public let paths: [GitChangedPath]
|
||||
/// 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
|
||||
|
||||
public enum Kind: Sendable, Equatable { case foreign, heal, user }
|
||||
}
|
||||
|
||||
/// The classes in commit order, empty ones dropped — what the planner turns into `PlannedCommit`s.
|
||||
public var ordered: [Group] {
|
||||
[
|
||||
Group(paths: foreign, kind: .foreign),
|
||||
Group(paths: heal, kind: .heal),
|
||||
Group(paths: user, kind: .user)
|
||||
].filter { !$0.paths.isEmpty }
|
||||
}
|
||||
|
||||
public var isEmpty: Bool { foreign.isEmpty && heal.isEmpty && user.isEmpty }
|
||||
}
|
||||
|
||||
// MARK: - CommitAttribution
|
||||
|
||||
/// **Who a commit is by** (06-history-undo.md ▸ Interaction with external writers: "Commit
|
||||
/// attribution is structural, not just a message convention").
|
||||
///
|
||||
/// A pure enum of statics over values: the changed paths, the harvested receipts, and the bytes on
|
||||
/// disk. Nothing here opens a repository, so every rule below is provable from a fixture rather than
|
||||
/// from a commit graph.
|
||||
public enum CommitAttribution {
|
||||
|
||||
// MARK: The pinned identities
|
||||
|
||||
/// **API, not decoration** (06): "The strings are API (users script against them; the `.invalid`
|
||||
/// TLD honestly marks a non-routable synthetic identity) — they change with the deliberateness
|
||||
/// of a schema change."
|
||||
public static let externalAuthorName = "Lanework External"
|
||||
public static let externalAuthorEmail = "[email protected]"
|
||||
|
||||
/// The domain a self-reported `modified-by` stamp authors under — "distinct from both the user
|
||||
/// and the generic external author".
|
||||
public static let agentEmailDomain = "agents.lanework.invalid"
|
||||
|
||||
/// The frontmatter key a foreign writer refines its own attribution with
|
||||
/// (01-storage-format.md; 08-agent-integration.md teaches it).
|
||||
static let modifiedByKey = "modified-by"
|
||||
|
||||
public static var externalIdentity: GitIdentity {
|
||||
GitIdentity(name: externalAuthorName, email: externalAuthorEmail)
|
||||
}
|
||||
|
||||
/// **A `modified-by` stamp, as an author** (06): "that commit is authored as **X** with the
|
||||
/// synthetic email `<slug>@agents.lanework.invalid` (display name verbatim, email local part
|
||||
/// slugified)".
|
||||
///
|
||||
/// The local part is lowercased on top of the slug — a judgment call, recorded: DESIGN says
|
||||
/// "slugified" without fixing case, addresses are conventionally lower, and the guide's own
|
||||
/// example stamp is `modified-by: claude`. The display name is untouched, so `Claude Code` still
|
||||
/// renders as `Claude Code <claude-code@agents.lanework.invalid>`.
|
||||
public static func agentIdentity(named displayName: String) -> GitIdentity {
|
||||
let name = displayName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let local = GitIdentity.addressComponent(name, fallback: "agent").lowercased()
|
||||
return GitIdentity(name: name.isEmpty ? externalAuthorName : name, email: "\(local)@\(agentEmailDomain)")
|
||||
}
|
||||
|
||||
// MARK: - Classification
|
||||
|
||||
/// **Every changed file, sorted into its commit** — the per-file rule 06 states, applied to the
|
||||
/// paths `git status` reported.
|
||||
///
|
||||
/// A path is the app's when the ledger holds a receipt for it (or for a folder above it) that
|
||||
/// **disk still satisfies**; it is a heal when that receipt is heal-marked; it is foreign
|
||||
/// otherwise. "No receipt anywhere → foreign" is the launch-catch-up doctrine and the whole of
|
||||
/// *the app never vouches for changes it didn't witness*.
|
||||
///
|
||||
/// ### Why the walk goes up the folders
|
||||
///
|
||||
/// Because the ledger keys some facts at *folders* while git only ever reports *files*. A card
|
||||
/// the app moved between lanes has one `.move` receipt on its folder and no receipt at all on
|
||||
/// the `index.md` that travelled inside it; a card the app deleted has one `.absence` receipt on
|
||||
/// its folder and git reports every file underneath as gone. Asking only the file's own key
|
||||
/// would classify both as foreign — the user's own delete, attributed to an agent.
|
||||
///
|
||||
/// 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],
|
||||
under boardRoot: URL,
|
||||
receipts: [String: HarvestedReceipt]
|
||||
) -> CommitSplit {
|
||||
var split = CommitSplit()
|
||||
for path in paths {
|
||||
let absolute = EchoLedger.key(boardRoot.appendingPathComponent(path.path))
|
||||
switch vouched(forAbsolutePath: absolute, boardRoot: boardRoot, receipts: receipts) {
|
||||
case .none: split.foreign.append(path)
|
||||
case .some(true): split.heal.append(path)
|
||||
case .some(false): split.user.append(path)
|
||||
}
|
||||
}
|
||||
return split
|
||||
}
|
||||
|
||||
/// `nil` when nothing vouches for this path; otherwise whether the vouching receipt was a heal.
|
||||
private static func vouched(
|
||||
forAbsolutePath absolute: String,
|
||||
boardRoot: URL,
|
||||
receipts: [String: HarvestedReceipt]
|
||||
) -> Bool? {
|
||||
let root = EchoLedger.key(boardRoot)
|
||||
var candidate = absolute
|
||||
while candidate.hasPrefix(root), candidate.count >= root.count {
|
||||
if let held = receipts[candidate] {
|
||||
switch held.receipt {
|
||||
case let .content(hash):
|
||||
// Content is a claim about *these* bytes, so only the file's own key may answer
|
||||
// with it. A content receipt sitting on an ancestor would be a claim about a
|
||||
// folder's bytes, which is not a thing.
|
||||
if candidate == absolute {
|
||||
return hash == hashOfFile(atPath: absolute) ? held.isHeal : nil
|
||||
}
|
||||
case .absence:
|
||||
return exists(candidate) ? nil : held.isHeal
|
||||
case let .move(from, to):
|
||||
if candidate == to { return exists(candidate) ? held.isHeal : nil }
|
||||
if candidate == from { return exists(candidate) ? nil : held.isHeal }
|
||||
}
|
||||
}
|
||||
guard candidate != root else { break }
|
||||
let parent = (candidate as NSString).deletingLastPathComponent
|
||||
guard parent != candidate else { break }
|
||||
candidate = parent
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - The foreign author
|
||||
|
||||
/// **`modified-by` refines foreign attribution** (06): the whole rule, as one function.
|
||||
///
|
||||
/// > when every file changed in a foreign debounce window carries the same `modified-by: X`,
|
||||
/// > that commit is authored as **X** … Any disagreement between stamps, any unstamped changed
|
||||
/// > file, or any true deletion in the window falls back to `Lanework External`.
|
||||
///
|
||||
/// **A folder move is not a deletion.** A rename's departure end is a file that is gone from
|
||||
/// disk and has no stamp to read, but it is not "a deletion [that] leaves no file to stamp" —
|
||||
/// its arrival end is right there in the same window, carrying whatever the writer stamped on
|
||||
/// it. So a paired departure is skipped rather than demoting the window. A bare `mv` that
|
||||
/// re-stamps nothing still demotes, through the unstamped-file clause, exactly as 06 says it
|
||||
/// 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 {
|
||||
var stamps: Set<String> = []
|
||||
for path in paths {
|
||||
if path.isDeletion {
|
||||
guard path.isRename else { return externalIdentity }
|
||||
continue
|
||||
}
|
||||
guard let stamp = modifiedBy(atRelativePath: path.path, under: boardRoot) else {
|
||||
return externalIdentity
|
||||
}
|
||||
stamps.insert(stamp)
|
||||
}
|
||||
guard stamps.count == 1, let name = stamps.first else { return externalIdentity }
|
||||
return agentIdentity(named: name)
|
||||
}
|
||||
|
||||
/// The `modified-by` a changed file carries, or `nil` for a file that carries none — **which
|
||||
/// every non-`index.md` path does, by construction**: a stray, an attachment, and `CLAUDE.md`
|
||||
/// have no frontmatter to stamp, so they are unstamped changed files and demote the window.
|
||||
static func modifiedBy(atRelativePath relativePath: String, under boardRoot: URL) -> String? {
|
||||
guard relativePath == BoardLoader.indexFileName
|
||||
|| relativePath.hasSuffix("/" + BoardLoader.indexFileName) else { return nil }
|
||||
let url = boardRoot.appendingPathComponent(relativePath)
|
||||
guard let text = try? String(contentsOf: url, encoding: .utf8),
|
||||
let document = try? FrontmatterDocument.parse(text),
|
||||
let raw = document.rawValue(for: modifiedByKey) else { return nil }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
// MARK: - Disk
|
||||
|
||||
private static func exists(_ path: String) -> Bool {
|
||||
FileManager.default.fileExists(atPath: path)
|
||||
}
|
||||
|
||||
/// The hash of what is at `path` now, or `nil` when nothing is — the same digest the ledger's
|
||||
/// receipts were minted with, so the comparison is the ledger's own.
|
||||
private static func hashOfFile(atPath path: String) -> String? {
|
||||
guard let data = FileManager.default.contents(atPath: path) else { return nil }
|
||||
return EchoLedger.hash(of: data)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user