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 = "external@lanework.invalid" /// 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 `@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 `. 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 = [] 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) } }