The full bullet list from Implementation card bf080d9a — both ruling batches, including the three appended mid-session by16ef377: - Restore subjects compose the inverse, never nest: crossing "Undo: S" emits "Redo: S" and vice versa; parity, not stack depth, reads a legacy double prefix (GitHistoryProvider.restoreSubject). - Git-operation failures join the one-shot failure banner tier: BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error tone at failure rank merged with write one-shots by recency; the postLoss compromise is retired at both AppModel wirings. - order/schema optional below the board root: append-at-end reading (ordered siblings first, folder-name tie-break among the order-less), schema reads 1, both coerce-tier logged; the root keeps its requirements. Ranks.resolvedOrders materializes finite ranks so models and placement math stay untouched; first Writer rewrite stamps a real rank on touch, placement against an order-less sibling stamps that sibling inline in the same bracket. Agent guide v10 teaches optional keys and zero-read filing. Hostile-YAML order shapes become coercion tests; Fixtures/Valid/optional-keys.kanban replaces the four retired Malformed boards. - .gitignore is the relocation-heal noise gate: GitignoreRules pure matcher (standard semantics, board-root file only), loader consults it once per walk so matched loose files keep the stray posture; seeded (.DS_Store + .*.lanework-*) at board creation and template instantiation, healed in when missing at open — repo-nested included; empty file honored, existing files never edited; the committer's obedience via libgit2 status is pinned by test. - Comments crash-residue sweep gates on step ownership: HistoryStep derives backing from its own undo expectations, backedContent unions both stacks, the sweep purges per-entry only what no live step owns. - Skip-purge decoupled (16ef377): a stale-skipped coarse step strands whole in NativeHistoryProvider.strandedSteps — still backing, retired only at session end; clean exits purge as before. - Coarse close step named "Changes to '<card>'"; the fine body-edit wording never leaks onto the board menu. - Branch-switch settle clears every open card window's fine stack on Save All and Discard alike; the empty fold registers no coarse step. - Close flush awaits its covering snapshot (quiesce + one generation bump, 1s bound), and an explicit flush now queues behind an in-flight one instead of skipping — the audit-caught interleaving could lose a close flush permanently when the debounce fired inside the close sequence; regression tests force both races. - Commit comment bullets sort chronologically by created, not UUID. - The production-unwired CardBodyEditSession.editSessionDidChange seam is deleted with its seam-only tests. - Composition-root pins: beginSession composes the committer with the store's own EchoLedger and binds the announcer (the miswire class). - Deliberate 06 conformance pass over every 2026-07-31-tagged sentence: fixed Change-custom-key subjects (the retired named generic was the only producer), the unbuilt Replace attachment vocabulary, heal commits now authored Lanework Integrity, the config reader scopes identity to plain [user] sections, add-git re-runs detection at create (a stale mode-none could initialize inside the user's repo), and add-git failures answer at the form or the banner. Structural residue filed on the Redesign board. 2554 tests / 439 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
282 lines
14 KiB
Swift
282 lines
14 KiB
Swift
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"
|
|
|
|
/// **Who a heal commit is by** (06 ▸ Commit messages ▸ Healing mutations commit separately, ruled
|
|
/// 2026-07-31 — "the third pinned synthetic, joining Lanework External and the agent-slug family;
|
|
/// strings are API"):
|
|
///
|
|
/// > 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; the committer stays the
|
|
/// > user (the recorded-by convention above).
|
|
///
|
|
/// It replaced authoring heals as the user, which made the separate commit filterable only by
|
|
/// message shape — and the shape vocabulary deliberately never says "healed".
|
|
public static let integrityAuthorName = "Lanework Integrity"
|
|
public static let integrityAuthorEmail = "[email protected]"
|
|
|
|
/// 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)
|
|
}
|
|
|
|
/// The heal class's author (`integrityAuthorName`). The *committer* beside it is still the user's
|
|
/// identity, every time — "every commit the app makes, foreign-authored included, records the
|
|
/// user's app as its committer" (06).
|
|
public static var integrityIdentity: GitIdentity {
|
|
GitIdentity(name: integrityAuthorName, email: integrityAuthorEmail)
|
|
}
|
|
|
|
/// **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 <[email protected]>`.
|
|
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)
|
|
}
|
|
}
|