diff --git a/KanbanPro/Assets.xcassets/AccentColor.colorset/Contents.json b/KanbanPro/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..7a9d8ed
--- /dev/null
+++ b/KanbanPro/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1 @@
+{"colors":[{"idiom":"universal"}],"info":{"version":1,"author":"xcode"}}
diff --git a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/Contents.json b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/Contents.json
new file mode 100644
index 0000000..57d0737
--- /dev/null
+++ b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/Contents.json
@@ -0,0 +1,68 @@
+{
+ "images": [
+ {
+ "filename": "icon-mac-16.png",
+ "idiom": "mac",
+ "size": "16x16",
+ "scale": "1x"
+ },
+ {
+ "filename": "icon-mac-16@2x.png",
+ "idiom": "mac",
+ "size": "16x16",
+ "scale": "2x"
+ },
+ {
+ "filename": "icon-mac-32.png",
+ "idiom": "mac",
+ "size": "32x32",
+ "scale": "1x"
+ },
+ {
+ "filename": "icon-mac-32@2x.png",
+ "idiom": "mac",
+ "size": "32x32",
+ "scale": "2x"
+ },
+ {
+ "filename": "icon-mac-128.png",
+ "idiom": "mac",
+ "size": "128x128",
+ "scale": "1x"
+ },
+ {
+ "filename": "icon-mac-128@2x.png",
+ "idiom": "mac",
+ "size": "128x128",
+ "scale": "2x"
+ },
+ {
+ "filename": "icon-mac-256.png",
+ "idiom": "mac",
+ "size": "256x256",
+ "scale": "1x"
+ },
+ {
+ "filename": "icon-mac-256@2x.png",
+ "idiom": "mac",
+ "size": "256x256",
+ "scale": "2x"
+ },
+ {
+ "filename": "icon-mac-512.png",
+ "idiom": "mac",
+ "size": "512x512",
+ "scale": "1x"
+ },
+ {
+ "filename": "icon-mac-512@2x.png",
+ "idiom": "mac",
+ "size": "512x512",
+ "scale": "2x"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
\ No newline at end of file
diff --git a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-128.png b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-128.png
new file mode 100644
index 0000000..de4e8b2
Binary files /dev/null and b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-128.png differ
diff --git a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-128@2x.png b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-128@2x.png
new file mode 100644
index 0000000..94cc0b3
Binary files /dev/null and b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-128@2x.png differ
diff --git a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-16.png b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-16.png
new file mode 100644
index 0000000..c7c3736
Binary files /dev/null and b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-16.png differ
diff --git a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-16@2x.png b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-16@2x.png
new file mode 100644
index 0000000..984dce5
Binary files /dev/null and b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-16@2x.png differ
diff --git a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-256.png b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-256.png
new file mode 100644
index 0000000..94cc0b3
Binary files /dev/null and b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-256.png differ
diff --git a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-256@2x.png b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-256@2x.png
new file mode 100644
index 0000000..c08a4bb
Binary files /dev/null and b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-256@2x.png differ
diff --git a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-32.png b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-32.png
new file mode 100644
index 0000000..984dce5
Binary files /dev/null and b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-32.png differ
diff --git a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-32@2x.png b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-32@2x.png
new file mode 100644
index 0000000..fa30a35
Binary files /dev/null and b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-32@2x.png differ
diff --git a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-512.png b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-512.png
new file mode 100644
index 0000000..c08a4bb
Binary files /dev/null and b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-512.png differ
diff --git a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-512@2x.png b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-512@2x.png
new file mode 100644
index 0000000..e9ec593
Binary files /dev/null and b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-512@2x.png differ
diff --git a/KanbanPro/Assets.xcassets/Contents.json b/KanbanPro/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..2673658
--- /dev/null
+++ b/KanbanPro/Assets.xcassets/Contents.json
@@ -0,0 +1 @@
+{"info":{"version":1,"author":"xcode"}}
diff --git a/KanbanPro/Edition/ProEdition.swift b/KanbanPro/Edition/ProEdition.swift
new file mode 100644
index 0000000..6d5b8f6
--- /dev/null
+++ b/KanbanPro/Edition/ProEdition.swift
@@ -0,0 +1,36 @@
+import Foundation
+
+/// **Pro's source root.** This directory — and this file's presence in it — *is* the edition
+/// split (12-editions.md ▸ Targets): the `Kanban` target compiles `Kanban/` alone, the
+/// `KanbanPro` target compiles `Kanban/` **plus** `KanbanPro/`. Nothing in the shared tree is
+/// `#if`-ed on an edition, because "the editions differ at the binary level" (12) is only true if
+/// the difference is a file one target builds and the other does not.
+///
+/// **What lands here, and when.** Nothing yet — this is a reserved root, split out ahead of the
+/// code so pro-m1 is a set of new files rather than a second pass over the project structure:
+///
+/// - `KanbanPro/Git/` — the git `HistoryProviding` implementation (06-history-undo.md: undo as
+/// forward restore commits over HEAD's first-parent ancestry), init/adoption, branches, commit
+/// identity, repository hygiene. Brings the libgit2 dependency with it; the base target must
+/// never gain one, which `scripts/verify-editions.sh` checks against the signed binary.
+/// - `KanbanPro/Remote/` — pull/push, push-on-commit (07-sync-collab.md). The
+/// `com.apple.security.network.client` entitlement Pro already carries is for this, and base's
+/// lack of it is the other half of what the verification script checks.
+/// - `KanbanPro/Auth/` — remote credentials over the declared keychain access group
+/// (07 ▸ Auth: Keychain, SSH, TOFU).
+///
+/// **What does not land here**: anything the edition matrix marks ✓ for all three editions. The
+/// board experience, agent attribution and accessibility are shared code and stay in `Kanban/`.
+///
+/// The type itself is a namespace, not state — the composition root that picks a
+/// `HistoryProviding` per edition arrives with the providers it chooses between (base's native
+/// stack in m8-native-undo, Pro's git stack in pro-m1), and inventing a seam here before either
+/// exists would be guessing at its shape.
+enum ProEdition {
+ /// The edition's own bundle identifier (12 ▸ Targets, ruled 2026-07-27: base keeps
+ /// `dev.rzen.indie.Kanban`, Pro mints its own). Stated as a constant so the value has one
+ /// home in Pro-only code; the running app reads its identity from `Bundle.main`, which is
+ /// already how the App Support directory is per-edition without a line of edition code
+ /// (`BoardRegistry`, `ClipboardStore`).
+ static let bundleIdentifier = "dev.rzen.indie.KanbanPro"
+}
diff --git a/KanbanPro/Info.plist b/KanbanPro/Info.plist
new file mode 100644
index 0000000..3867a92
--- /dev/null
+++ b/KanbanPro/Info.plist
@@ -0,0 +1,107 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Lanework Pro
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ Lanework Pro
+ CFBundlePackageType
+ $(PRODUCT_BUNDLE_PACKAGE_TYPE)
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ 1
+ LSMinimumSystemVersion
+ $(MACOSX_DEPLOYMENT_TARGET)
+ NSHumanReadableCopyright
+ © 2026 rzen
+ NSPrincipalClass
+ NSApplication
+
+ UTImportedTypeDeclarations
+
+
+ UTTypeIdentifier
+ dev.rzen.indie.kanban-board
+ UTTypeConformsTo
+
+ com.apple.package
+ public.directory
+
+ UTTypeDescription
+ Lanework Board
+ UTTypeTagSpecification
+
+ public.filename-extension
+ kanban
+
+
+
+
+ UTTypeIdentifier
+ dev.rzen.indie.kanban.cards
+ UTTypeConformsTo
+
+ public.data
+
+ UTTypeDescription
+ Lanework Cards
+
+
+ UTTypeIdentifier
+ dev.rzen.indie.kanban.lanes
+ UTTypeConformsTo
+
+ public.data
+
+ UTTypeDescription
+ Lanework Lanes
+
+
+
+ UTTypeIdentifier
+ dev.rzen.indie.kanban.clipboard
+ UTTypeConformsTo
+
+ public.data
+
+ UTTypeDescription
+ Lanework Clipboard
+
+
+ CFBundleDocumentTypes
+
+
+ CFBundleTypeName
+ Lanework Board
+ CFBundleTypeRole
+ Editor
+ LSItemContentTypes
+
+ dev.rzen.indie.kanban-board
+
+ LSTypeIsPackage
+
+
+
+
+
diff --git a/KanbanPro/KanbanPro.entitlements b/KanbanPro/KanbanPro.entitlements
new file mode 100644
index 0000000..51d27c0
--- /dev/null
+++ b/KanbanPro/KanbanPro.entitlements
@@ -0,0 +1,27 @@
+
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.files.user-selected.read-write
+
+ com.apple.security.files.bookmarks.app-scope
+
+
+ com.apple.security.network.client
+
+
+ keychain-access-groups
+
+ $(AppIdentifierPrefix)dev.rzen.indie.KanbanPro
+
+
+
diff --git a/KanbanTests/InertGitTests.swift b/KanbanTests/InertGitTests.swift
new file mode 100644
index 0000000..a3d0ed6
--- /dev/null
+++ b/KanbanTests/InertGitTests.swift
@@ -0,0 +1,261 @@
+import Foundation
+import Testing
+@testable import Kanban
+
+/// **Base's inert-`.git` posture, stated against real bytes on disk** (12-editions.md ▸ Base and
+/// `.git`): "any `.git` is inert — opening a board that has one works normally, but the app never
+/// reads history, never commits, never touches `.git` in any way."
+///
+/// Two halves of that posture are already pinned elsewhere and are referenced, not repeated:
+/// `FolderWatcherTests` ▸ ".git filtering" proves the watcher ignores churn under a `.git` at any
+/// depth, and `BoardLoaderStrayTests.strayFilesAndHiddenEntriesAreIgnoredWithoutWarning` proves a
+/// `.git` at the board root loads as an ordinary stray with no warning. Both are *input* claims:
+/// what the app does with events and entries it is handed.
+///
+/// This file states the **output** claim, which no existing test covers, and which is the one the
+/// edition split has to be able to demonstrate: a full session of ordinary base editing leaves
+/// every byte and every mtime under `.git` exactly as it found them. It is asserted the only way
+/// that is worth anything — by snapshotting the whole `.git` subtree from the filesystem before
+/// the edits and re-reading it afterwards, never through the app's own read path
+/// (`WriterTestSupport.swift`'s standing rule).
+///
+/// Note what "untouched" is worth as an mtime assertion specifically: bytes alone would pass even
+/// if the app rewrote a file with identical content, and re-writing git's index with identical
+/// bytes is exactly the kind of thing an accidental git dependency would do. The mtimes are the
+/// assertion that nothing *opened for writing* down there at all.
+
+// MARK: - Subtree snapshots
+
+/// One entry under `.git`: its path relative to the board root, its bytes (nil for directories),
+/// and its on-disk modification date. Directories carry an mtime too — a file created or removed
+/// inside a directory moves *that directory's* mtime, so including them catches a write the
+/// per-file comparison would miss because the file it added is not in the "before" set.
+private struct SubtreeEntry: Equatable, CustomStringConvertible {
+ let relativePath: String
+ let data: Data?
+ let modified: Date
+
+ var description: String {
+ "\(relativePath) (\(data.map { "\($0.count) bytes" } ?? "directory"), modified \(modified))"
+ }
+}
+
+/// Every entry beneath `root/subtree`, hidden entries included, sorted by path. `.git` is itself
+/// hidden and everything inside it is reached through it, so `.skipsHiddenFiles` is deliberately
+/// *not* passed — a snapshot that skipped hidden files would snapshot nothing at all.
+private func snapshotSubtree(_ root: URL, _ subtree: String) throws -> [SubtreeEntry] {
+ let base = root.appendingPathComponent(subtree, isDirectory: true)
+ let manager = FileManager.default
+ guard let walker = manager.enumerator(atPath: base.path) else {
+ Issue.record("could not enumerate \(subtree)")
+ return []
+ }
+
+ var entries: [SubtreeEntry] = []
+ for case let relative as String in walker {
+ let url = base.appendingPathComponent(relative)
+ let attributes = try manager.attributesOfItem(atPath: url.path)
+ guard let modified = attributes[.modificationDate] as? Date else {
+ Issue.record("no modification date for \(subtree)/\(relative)")
+ continue
+ }
+ let isDirectory = (attributes[.type] as? FileAttributeType) == .typeDirectory
+ entries.append(SubtreeEntry(
+ relativePath: "\(subtree)/\(relative)",
+ data: isDirectory ? nil : try Data(contentsOf: url),
+ modified: modified
+ ))
+ }
+
+ // The directory the subtree hangs from, which the enumerator above does not yield.
+ let rootAttributes = try manager.attributesOfItem(atPath: base.path)
+ if let modified = rootAttributes[.modificationDate] as? Date {
+ entries.append(SubtreeEntry(relativePath: subtree, data: nil, modified: modified))
+ }
+
+ return entries.sorted { $0.relativePath < $1.relativePath }
+}
+
+// MARK: - Fixture construction
+
+/// A `.git` directory with the shape a real one has — a ref file, a binary index, a loose object
+/// two levels down, a packed-refs file, and an empty `objects/pack` — written under `parent`.
+/// The bytes are sentinels: recognizable, non-UTF-8 in the index's case, and nothing the app has
+/// any reader for.
+@discardableResult
+private func makeGitDirectory(in fixture: WriterFixture, under parent: String) throws -> String {
+ let prefix = parent.isEmpty ? ".git" : "\(parent)/.git"
+
+ try fixture.file("\(prefix)/HEAD", Data("ref: refs/heads/main\n".utf8))
+ try fixture.file("\(prefix)/config", Data("[core]\n\trepositoryformatversion = 0\n".utf8))
+ // Deliberately not valid UTF-8 — a real `.git/index` is binary, and a byte comparison that
+ // only ever sees text is not testing the thing that matters.
+ try fixture.file("\(prefix)/index", Data([0x44, 0x49, 0x52, 0x43, 0x00, 0x00, 0x00, 0x02, 0xFF, 0xFE, 0x00, 0x01]))
+ try fixture.file("\(prefix)/objects/ab/cdef0123456789", Data([0x78, 0x01, 0xCB, 0xC8, 0x4F, 0x00, 0x00]))
+ try fixture.file("\(prefix)/refs/heads/main", Data("0123456789abcdef0123456789abcdef01234567\n".utf8))
+ try fixture.file("\(prefix)/packed-refs", Data("# pack-refs with: peeled fully-peeled sorted\n".utf8))
+ try FileManager.default.createDirectory(
+ at: fixture.url("\(prefix)/objects/pack"),
+ withIntermediateDirectories: true
+ )
+
+ return prefix
+}
+
+/// The board every test here edits: a root, two lanes, three cards, a `.git` at the board root
+/// **and** a second one nested inside a card folder (12: "any `.git` is inert", not just the
+/// root's own — a repo-nested board or a clone dropped inside a card is the same promise).
+private struct InertGitBoard {
+ let fixture: WriterFixture
+ let laneA: String
+ let laneB: String
+ let card1: String
+ let card2: String
+ /// The card carrying the nested repo — the one the move test drags across lanes.
+ let cardWithRepo: String
+
+ init() throws {
+ fixture = try WriterFixture()
+ laneA = Ident.lane1
+ laneB = Ident.lane2
+ card1 = Ident.card1
+ card2 = Ident.card2
+ cardWithRepo = Ident.card3
+
+ try fixture.item("", Item.board)
+ try fixture.item(laneA, Item.rich(order: "1024", title: "Doing"))
+ try fixture.item(laneB, Item.rich(order: "2048", title: "Done"))
+ try fixture.item("\(laneA)/\(card1)", Item.rich(order: "1024", title: "First"))
+ try fixture.item("\(laneA)/\(card2)", Item.rich(order: "2048", title: "Second"))
+ try fixture.item("\(laneA)/\(cardWithRepo)", Item.rich(order: "3072", title: "Has a clone"))
+
+ try makeGitDirectory(in: fixture, under: "")
+ try makeGitDirectory(in: fixture, under: "\(laneA)/\(cardWithRepo)")
+ }
+
+ var root: URL { fixture.root }
+ func url(_ relativePath: String) -> URL { fixture.url(relativePath) }
+ func tearDown() { fixture.tearDown() }
+}
+
+// MARK: - The posture
+
+struct BaseInertGitTests {
+ @Test("A full session of ordinary edits leaves every byte and mtime under .git untouched")
+ func anEditingSessionNeverTouchesGit() throws {
+ let board = try InertGitBoard()
+ defer { board.tearDown() }
+
+ let before = try snapshotSubtree(board.root, ".git")
+ #expect(before.count == 12, "the fixture's own shape — files, directories and the root")
+
+ // A session's worth of every write the base app can make, in one go. `.git` is at the
+ // board root, so anything that walks or renumbers the root's children walks past it.
+ try BoardWriter.updateIndex(inItemFolder: board.url(""), operation: .rename(title: nil)) { document in
+ document.set(FrontmatterKeys.title, to: .string("Renamed board"))
+ }
+ let newLane = try BoardWriter.createLane(inBoard: board.root, title: "Later")
+ let newCard = try BoardWriter.createCard(inLane: board.url(newLane.rawValue), title: "Fresh")
+ try BoardWriter.writeBody(inItemFolder: board.url("\(newLane.rawValue)/\(newCard.rawValue)"), body: "Body text.\n")
+ try BoardWriter.updateIndex(
+ inItemFolder: board.url("\(board.laneA)/\(board.card1)"),
+ operation: .style(title: nil)
+ ) { document in
+ document.set(FrontmatterKeys.title, to: .string("Retitled"))
+ }
+ _ = try BoardWriter.moveItem(
+ at: board.url("\(board.laneA)/\(board.card2)"),
+ toParent: board.url(board.laneB),
+ sourceBoardRoot: board.root,
+ destinationBoardRoot: board.root,
+ order: nil
+ )
+ _ = try BoardWriter.copyItem(
+ at: board.url("\(board.laneA)/\(board.card1)"),
+ toParent: board.url(board.laneB),
+ order: nil,
+ stamps: .fork
+ )
+ try BoardWriter.deleteItem(at: board.url("\(board.laneA)/\(board.card1)"))
+ try BoardWriter.restoreItem(at: board.url("\(board.laneA)/\(board.card1)"))
+ // The renumbers are the pointed ones: both walk a parent's whole directory listing, which
+ // is where a `.git` entry actually gets looked at.
+ try BoardWriter.renumberVisibleChildren(of: board.root)
+ try BoardWriter.renumberVisibleChildren(of: board.url(board.laneA))
+
+ #expect(try snapshotSubtree(board.root, ".git") == before)
+ }
+
+ @Test("The board loads and renders normally with a .git at its root, without a warning")
+ func aGitBearingBoardLoadsLikeAnyOther() throws {
+ let board = try InertGitBoard()
+ defer { board.tearDown() }
+
+ let before = try snapshotSubtree(board.root, ".git")
+
+ let result = try BoardLoader.load(boardRoot: board.root)
+
+ #expect(result.model.lanes.map(\.id.rawValue) == [board.laneA, board.laneB])
+ #expect(result.model.lanes[0].cards.map(\.id.rawValue) == [board.card1, board.card2, board.cardWithRepo])
+ #expect(result.warnings.isEmpty, "a `.git` is a stray like any other — strays are silent")
+ // A load is a read, but a read that opened `.git` would still move its atimes and would
+ // still be the app "touching" history; the mtime equality is what is checkable, and a
+ // loader that decided to *repair* something down there would break it.
+ #expect(try snapshotSubtree(board.root, ".git") == before)
+ }
+
+ @Test("A nested .git rides along a card move byte- and mtime-verbatim")
+ func aNestedRepositorySurvivesACardMove() throws {
+ let board = try InertGitBoard()
+ defer { board.tearDown() }
+
+ let nested = "\(board.laneA)/\(board.cardWithRepo)/.git"
+ let before = try snapshotSubtree(board.root, nested)
+
+ let result = try BoardWriter.moveItem(
+ at: board.url("\(board.laneA)/\(board.cardWithRepo)"),
+ toParent: board.url(board.laneB),
+ sourceBoardRoot: board.root,
+ destinationBoardRoot: board.root,
+ order: nil
+ )
+
+ // Same identity, new parent — the folder moved whole, `.git` inside it.
+ let moved = "\(board.laneB)/\(result.id.rawValue)/.git"
+ let after = try snapshotSubtree(board.root, moved)
+
+ #expect(after.map(\.data) == before.map(\.data))
+ #expect(after.map(\.modified) == before.map(\.modified))
+ #expect(
+ after.map { $0.relativePath.replacingOccurrences(of: moved, with: nested) } == before.map(\.relativePath)
+ )
+ }
+
+ @Test("A copy of a card carrying a .git reproduces it verbatim and leaves the original alone")
+ func aCopyCarriesTheNestedRepositoryWithoutTouchingTheOriginal() throws {
+ let board = try InertGitBoard()
+ defer { board.tearDown() }
+
+ let nested = "\(board.laneA)/\(board.cardWithRepo)/.git"
+ let before = try snapshotSubtree(board.root, nested)
+
+ let copyID = try BoardWriter.copyItem(
+ at: board.url("\(board.laneA)/\(board.cardWithRepo)"),
+ toParent: board.url(board.laneB),
+ order: nil,
+ stamps: .fork
+ )
+
+ #expect(try snapshotSubtree(board.root, nested) == before, "the source is never touched")
+
+ // The copy's `.git` is the same tree with the same bytes (mtimes are a copy's to set —
+ // `FileManager.copyItem` preserves them, but the promise being made here is about content
+ // and shape, not about a copy having been a rename).
+ let copied = try snapshotSubtree(board.root, "\(board.laneB)/\(copyID.rawValue)/.git")
+ #expect(copied.map(\.data) == before.map(\.data))
+ #expect(
+ copied.map { $0.relativePath.replacingOccurrences(of: "\(board.laneB)/\(copyID.rawValue)/.git", with: nested) }
+ == before.map(\.relativePath)
+ )
+ }
+}
diff --git a/README.md b/README.md
index 8c3f4b1..3e6b684 100644
--- a/README.md
+++ b/README.md
@@ -50,8 +50,13 @@ The Xcode project is generated — `project.yml` is the source of truth, not the
```sh
xcodegen generate
-xcodebuild build -project Kanban.xcodeproj -scheme Kanban -destination 'platform=macOS'
-xcodebuild test -project Kanban.xcodeproj -scheme Kanban -destination 'platform=macOS,arch=arm64'
+xcodebuild build -project Kanban.xcodeproj -scheme Kanban -destination 'platform=macOS'
+xcodebuild test -project Kanban.xcodeproj -scheme Kanban -destination 'platform=macOS,arch=arm64'
+xcodebuild build -project Kanban.xcodeproj -scheme LaneworkPro -destination 'platform=macOS'
+xcodebuild test -project Kanban.xcodeproj -scheme LaneworkPro -destination 'platform=macOS,arch=arm64'
+scripts/verify-editions.sh
```
macOS 26+, Swift 6 (strict concurrency), SwiftUI, sandboxed. Internal codename `Kanban` (target, scheme, bundle id `dev.rzen.indie.Kanban`); the app ships under the display name **Lanework**.
+
+Two app targets are built from one source tree (DESIGN/12-editions.md): **Lanework** compiles `Kanban/` alone, **Lanework Pro** (target `KanbanPro`, scheme `LaneworkPro`, bundle id `dev.rzen.indie.KanbanPro`) compiles `Kanban/` plus the Pro-only source root `KanbanPro/`. There is no edition flag and no `#if` in shared code — an edition difference is a file one target builds and the other does not — and the difference is checkable on the signed products: base carries no libgit2 and no network-client entitlement, which `scripts/verify-editions.sh` asserts against the built bundles. The unit suite is edition-agnostic and runs twice, once hosted by each app (`KanbanTests`, `KanbanProTests` — the same sources, bound to the Pro module by `-module-alias`). Both editions declare the same `dev.rzen.indie.kanban-board` UTI, so any board opens in either app.
diff --git a/project.yml b/project.yml
index 1e1a8da..b39d4d9 100644
--- a/project.yml
+++ b/project.yml
@@ -25,17 +25,29 @@ settings:
DEVELOPMENT_TEAM: ${APPLE_TEAM_ID}
ENABLE_USER_SCRIPT_SANDBOXING: NO
+# The edition split (12-editions.md ▸ Targets). Two app targets compiled from one source tree:
+# base builds `Kanban/` alone, Pro builds `Kanban/` *plus* `KanbanPro/`. There is no build flag
+# and no `#if` in shared code — "why a real split and not feature flags" (12) is answered by the
+# file list, so an edition difference is always a file one target compiles and the other does not.
+# libgit2 arrives on the Pro target with pro-m1; the target, its entitlements and its source root
+# exist now so that milestone lands without re-splitting anything.
+#
+# YAML anchors (`&name` / `*name`) carry the genuinely identical parts across the two apps and the
+# two unit-test bundles. They are resolved by the YAML parser before XcodeGen sees the file, so
+# they cost nothing at generate time and cannot drift the way copy-paste does.
targets:
+ # MARK: - Lanework (base)
+
Kanban:
type: application
platform: macOS
sources:
- Kanban
- dependencies:
+ dependencies: &appDependencies
- package: Yams
- package: swift-markdown
product: Markdown
- postBuildScripts:
+ postBuildScripts: &updateBuildInfo
- script: '"${SRCROOT}/../indie-skills/skills/app-versioning/scripts/update_build_info.sh"'
name: Update Build Info
shell: /bin/sh
@@ -45,6 +57,8 @@ targets:
- $(DWARF_DSYM_FOLDER_PATH)/$(DWARF_DSYM_FILE_NAME)/Contents/Info.plist
settings:
base:
+ # Base keeps the current identifier — it is the app that ships first, so nothing
+ # re-wires (12 ▸ Targets, ruled 2026-07-27).
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.Kanban
MARKETING_VERSION: "2.0"
INFOPLIST_FILE: Kanban/Info.plist
@@ -53,10 +67,57 @@ targets:
SWIFT_STRICT_CONCURRENCY: complete
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
+ # MARK: - Lanework Pro
+
+ KanbanPro:
+ type: application
+ platform: macOS
+ sources:
+ # The shared tree, minus the three files that are base's *identity* rather than its code.
+ # (XcodeGen already keeps `Info.plist` and `.entitlements` out of build phases; excluding
+ # them here keeps them out of the Pro target's file list too, so there is exactly one
+ # plist and one entitlements file visible per app.)
+ - path: Kanban
+ excludes:
+ - Info.plist
+ - Kanban.entitlements
+ - Assets.xcassets
+ - Assets.xcassets/**
+ # Pro's own source root — reserved now, filled by pro-m1. See KanbanPro/Edition/ProEdition.swift.
+ - path: KanbanPro
+ excludes:
+ - Info.plist
+ - KanbanPro.entitlements
+ dependencies: *appDependencies
+ postBuildScripts: *updateBuildInfo
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.KanbanPro
+ MARKETING_VERSION: "2.0"
+ INFOPLIST_FILE: KanbanPro/Info.plist
+ CODE_SIGN_ENTITLEMENTS: KanbanPro/KanbanPro.entitlements
+ GENERATE_INFOPLIST_FILE: false
+ SWIFT_STRICT_CONCURRENCY: complete
+ ASSETCATALOG_COMPILER_APPICON_NAME: AppIconPro
+ # Spelled out because the whole shared-test-sources arrangement below hangs on it: the two
+ # apps must have *different* Swift module names, since both emit `.swiftmodule`
+ # into the same `Build/Products//` and equal names would have one app silently
+ # overwrite the other's testable interface.
+ PRODUCT_MODULE_NAME: KanbanPro
+
+ # MARK: - Unit tests
+ #
+ # One suite, run twice — once hosted by each app. Every test in `KanbanTests/` is
+ # edition-agnostic (it exercises the storage format, the live store and the UI model, none of
+ # which differ between editions), so duplicating the *sources* would be duplicating the thing
+ # that is identical; what is worth running twice is the suite against each *binary*, which is
+ # what these two targets do. The Pro bundle compiles the very same files — it is the same
+ # `sources` list, by anchor — against `KanbanPro`.
+
KanbanTests:
type: bundle.unit-test
platform: macOS
- sources:
+ sources: &unitTestSources
- KanbanTests
- path: Fixtures
type: folder
@@ -69,6 +130,30 @@ targets:
GENERATE_INFOPLIST_FILE: true
SWIFT_STRICT_CONCURRENCY: complete
+ KanbanProTests:
+ type: bundle.unit-test
+ platform: macOS
+ sources: *unitTestSources
+ dependencies:
+ - target: KanbanPro
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.KanbanProTests
+ GENERATE_INFOPLIST_FILE: true
+ SWIFT_STRICT_CONCURRENCY: complete
+ # The shared sources say `@testable import Kanban`, and they should: they are testing the
+ # app, not an edition. `-module-alias` (Swift 5.7+) resolves that import to the Pro
+ # module for this bundle only — the supported way to bind one source name to a different
+ # module, and it keeps the 56 test files edition-blind instead of littering them with
+ # conditional imports.
+ OTHER_SWIFT_FLAGS: -module-alias Kanban=KanbanPro
+
+ # MARK: - UI tests
+ #
+ # Base-only, deliberately: the single test here is `testAppLaunches`, and a second copy would
+ # double the slowest, most environment-dependent part of the suite to re-assert something the
+ # Pro unit bundle already proves (it launches the Pro app as its test host on every run).
+
KanbanUITests:
type: bundle.ui-testing
platform: macOS
@@ -83,6 +168,8 @@ targets:
SWIFT_STRICT_CONCURRENCY: complete
schemes:
+ # `Kanban` is unchanged, on purpose: `xcodebuild … -scheme Kanban` is the established command
+ # for this repo and base is the app it has always meant.
Kanban:
build:
targets:
@@ -101,3 +188,21 @@ schemes:
config: Debug
archive:
config: Release
+
+ LaneworkPro:
+ build:
+ targets:
+ KanbanPro: all
+ run:
+ config: Debug
+ test:
+ config: Debug
+ gatherCoverageData: false
+ targets:
+ - KanbanProTests
+ profile:
+ config: Release
+ analyze:
+ config: Debug
+ archive:
+ config: Release
diff --git a/scripts/verify-editions.sh b/scripts/verify-editions.sh
new file mode 100755
index 0000000..70b4d81
--- /dev/null
+++ b/scripts/verify-editions.sh
@@ -0,0 +1,282 @@
+#!/bin/bash
+#
+# verify-editions.sh — the edition split, checked against the *built binaries*.
+#
+# 12-editions.md ▸ Distribution: "the editions differ at the binary level — base ships without
+# libgit2 and without the network-client entitlement". That is a claim about a signed app bundle,
+# so it cannot be a unit test: a test runs inside the app and can only report what the app's own
+# code believes. This script reads the products instead — the signature's entitlements, the Mach-O
+# load commands and symbol table, and the bundled Info.plist — and is the check the release
+# pipeline should run before either edition is submitted.
+#
+# Usage:
+# scripts/verify-editions.sh # resolves the Debug products via xcodebuild
+# scripts/verify-editions.sh
+# CONFIGURATION=Release scripts/verify-editions.sh
+#
+# Exits non-zero if any check fails.
+
+set -uo pipefail
+
+PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+CONFIGURATION="${CONFIGURATION:-Debug}"
+
+failures=0
+checks=0
+
+pass() { checks=$((checks + 1)); printf ' ok %s\n' "$1"; }
+fail() { checks=$((checks + 1)); failures=$((failures + 1)); printf ' FAIL %s\n' "$1"; }
+note() { printf ' %s\n' "$1"; }
+head2() { printf '\n%s\n' "$1"; }
+
+# ---------------------------------------------------------------------------- locating the apps
+
+if [ "$#" -ge 2 ]; then
+ BASE_APP="$1"
+ PRO_APP="$2"
+else
+ products_dir="$(
+ xcodebuild -project "$PROJECT_DIR/Kanban.xcodeproj" -scheme Kanban \
+ -configuration "$CONFIGURATION" -destination 'platform=macOS' \
+ -showBuildSettings 2>/dev/null |
+ awk -F' = ' '/ BUILT_PRODUCTS_DIR = /{print $2; exit}'
+ )"
+ if [ -z "${products_dir:-}" ]; then
+ echo "could not resolve BUILT_PRODUCTS_DIR — build first, or pass the two .app paths" >&2
+ exit 2
+ fi
+ BASE_APP="$products_dir/Kanban.app"
+ PRO_APP="$products_dir/KanbanPro.app"
+fi
+
+for app in "$BASE_APP" "$PRO_APP"; do
+ if [ ! -d "$app" ]; then
+ echo "no app bundle at $app — build both schemes first (Kanban, LaneworkPro)" >&2
+ exit 2
+ fi
+done
+
+BASE_BIN="$BASE_APP/Contents/MacOS/$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$BASE_APP/Contents/Info.plist")"
+PRO_BIN="$PRO_APP/Contents/MacOS/$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$PRO_APP/Contents/Info.plist")"
+
+echo "Lanework edition verification ($CONFIGURATION)"
+note "base: $BASE_APP"
+note "pro: $PRO_APP"
+
+# ------------------------------------------------------------------------------------ helpers
+
+# The signature's entitlements as an XML plist. Reads the *signature*, not the source
+# `.entitlements` file — the whole point is that the shipped binary is what gets checked.
+entitlements_of() {
+ codesign -d --entitlements - --xml "$1" 2>/dev/null
+}
+
+# The entitlement *keys* the signature actually carries, one per line. Extracted from the XML
+# rather than queried with PlistBuddy because PlistBuddy needs a seekable file, and writing the
+# signature out to a temp file only to read one key back is a step this does not need.
+entitlement_keys() {
+ entitlements_of "$1" | grep -o '[^<]*' | sed 's/<[^>]*>//g'
+}
+
+has_entitlement() {
+ entitlement_keys "$1" | grep -qxF "$2"
+}
+
+plist_value() {
+ /usr/libexec/PlistBuddy -c "Print :$2" "$1/Contents/Info.plist" 2>/dev/null
+}
+
+# Every dylib the binary and its embedded frameworks link, plus every undefined symbol — the two
+# places a libgit2 dependency can hide (dynamically linked, or statically linked and visible only
+# as defined symbols).
+linked_libraries() {
+ local app="$1"
+ find "$app/Contents/MacOS" "$app/Contents/Frameworks" -type f -perm -u+x 2>/dev/null |
+ while read -r file; do otool -L "$file" 2>/dev/null; done
+}
+
+git_symbols() {
+ local bin="$1"
+ # `git_` is libgit2's exported prefix (git_repository_open, git_commit_create, …). Anchored so
+ # ordinary Swift symbols with "git" inside a longer name do not register.
+ nm -a "$bin" 2>/dev/null | awk '{print $NF}' | grep -E '^_?git_[a-z_]+$' | sort -u
+}
+
+# ------------------------------------------------------- base: no libgit2, no network, no keychain
+
+head2 "Base — no git machinery"
+
+base_libs="$(linked_libraries "$BASE_APP")"
+if printf '%s' "$base_libs" | grep -qiE 'git2|libgit'; then
+ fail "base links a libgit2 dylib"
+ printf '%s' "$base_libs" | grep -iE 'git2|libgit' | sed 's/^/ /'
+else
+ pass "base links no libgit2 dylib (otool -L over the executable and embedded frameworks)"
+fi
+
+base_git_syms="$(git_symbols "$BASE_BIN")"
+if [ -n "$base_git_syms" ]; then
+ fail "base's symbol table contains libgit2 symbols"
+ printf '%s\n' "$base_git_syms" | sed 's/^/ /'
+else
+ pass "base's symbol table contains no libgit2 symbols (nm -a, /^_?git_[a-z_]+\$/)"
+fi
+
+head2 "Base — entitlements"
+
+if entitlements_of "$BASE_APP" | grep -q ''; then
+ pass "base is signed with an entitlements dictionary"
+else
+ fail "base has no readable entitlements — is it signed?"
+fi
+
+if has_entitlement "$BASE_APP" "com.apple.security.network.client"; then
+ fail "base carries com.apple.security.network.client"
+else
+ pass "base does not carry com.apple.security.network.client"
+fi
+
+if has_entitlement "$BASE_APP" "keychain-access-groups"; then
+ fail "base carries keychain-access-groups"
+else
+ pass "base does not carry keychain-access-groups"
+fi
+
+for key in com.apple.security.app-sandbox \
+ com.apple.security.files.user-selected.read-write \
+ com.apple.security.files.bookmarks.app-scope; do
+ if has_entitlement "$BASE_APP" "$key"; then
+ pass "base carries $key"
+ else
+ fail "base is missing $key"
+ fi
+done
+
+# Anything beyond the three above would be entitlement creep — base's posture is "current minimal
+# entitlements exactly" (12 ▸ Targets). Four families of key are the toolchain's, not the app's,
+# and are discounted rather than counted:
+#
+# get-task-allow Debug builds, so the debugger can attach.
+# application-identifier injected by signing when a profile is involved.
+# developer.team-identifier likewise.
+# temporary-exception.{files,mach} injected into a *test host* so the sandboxed app can load
+# XCTest. Present after `xcodebuild test`, absent from an
+# archive — which is why they are named out loud below rather
+# than quietly filtered.
+toolchain_injected='^(com\.apple\.security\.get-task-allow|com\.apple\.application-identifier|com\.apple\.developer\.team-identifier|com\.apple\.security\.temporary-exception\.[a-z.-]*)$'
+settled_minimum='^(com\.apple\.security\.app-sandbox|com\.apple\.security\.files\.user-selected\.read-write|com\.apple\.security\.files\.bookmarks\.app-scope)$'
+
+base_extra="$(entitlement_keys "$BASE_APP" | grep -vE "$settled_minimum" | grep -vE "$toolchain_injected")"
+if [ -n "$base_extra" ]; then
+ fail "base carries entitlements beyond the settled minimum"
+ printf '%s\n' "$base_extra" | sed 's/^/ /'
+else
+ pass "base carries no entitlements beyond the settled minimum"
+fi
+
+base_test_exceptions="$(entitlement_keys "$BASE_APP" | grep -E '^com\.apple\.security\.temporary-exception\.')"
+if [ -n "$base_test_exceptions" ]; then
+ note "(built for testing — Xcode's own test-host exceptions are present and discounted:)"
+ printf '%s\n' "$base_test_exceptions" | sed 's/^/ /'
+fi
+
+# ------------------------------------------------------------------------- pro: the added grants
+
+head2 "Pro — entitlements"
+
+for key in com.apple.security.app-sandbox \
+ com.apple.security.files.user-selected.read-write \
+ com.apple.security.files.bookmarks.app-scope \
+ com.apple.security.network.client \
+ keychain-access-groups; do
+ if has_entitlement "$PRO_APP" "$key"; then
+ pass "pro carries $key"
+ else
+ fail "pro is missing $key"
+ fi
+done
+
+# ------------------------------------------------------------------------------ bundle identity
+
+head2 "Bundle identity"
+
+check_value() {
+ local label="$1" actual="$2" expected="$3"
+ if [ "$actual" = "$expected" ]; then
+ pass "$label = $expected"
+ else
+ fail "$label = '$actual', expected '$expected'"
+ fi
+}
+
+check_value "base CFBundleIdentifier" "$(plist_value "$BASE_APP" CFBundleIdentifier)" "dev.rzen.indie.Kanban"
+check_value "pro CFBundleIdentifier" "$(plist_value "$PRO_APP" CFBundleIdentifier)" "dev.rzen.indie.KanbanPro"
+check_value "base CFBundleDisplayName" "$(plist_value "$BASE_APP" CFBundleDisplayName)" "Lanework"
+check_value "pro CFBundleDisplayName" "$(plist_value "$PRO_APP" CFBundleDisplayName)" "Lanework Pro"
+check_value "base CFBundleShortVersionString" "$(plist_value "$BASE_APP" CFBundleShortVersionString)" "2.0"
+check_value "pro CFBundleShortVersionString" "$(plist_value "$PRO_APP" CFBundleShortVersionString)" "2.0"
+
+base_icon="$(plist_value "$BASE_APP" CFBundleIconName)"
+pro_icon="$(plist_value "$PRO_APP" CFBundleIconName)"
+if [ -n "$base_icon" ] && [ -n "$pro_icon" ] && [ "$base_icon" != "$pro_icon" ]; then
+ pass "distinct app icon assets ($base_icon / $pro_icon)"
+else
+ fail "app icon assets are not distinct (base '$base_icon', pro '$pro_icon')"
+fi
+
+# ------------------------------------------------------------------------------ the shared UTI
+
+head2 "The board UTI — one format, either app"
+
+# Base exports `dev.rzen.indie.kanban-board`, Pro imports the identical declaration (12 ▸
+# Distribution: "both editions declare the same .kanban package UTI verbatim … base remains the
+# exporter"). Compared as normalized plist fragments so a drifted conformance list or tag spec
+# fails here rather than in the field, where it would show up as a board that opens in one app and
+# not the other.
+uti_declaration() {
+ local app="$1" key="$2"
+ /usr/libexec/PlistBuddy -x -c "Print :$key" "$app/Contents/Info.plist" 2>/dev/null |
+ tr -d ' \t' | grep -v '^$'
+}
+
+base_uti="$(uti_declaration "$BASE_APP" UTExportedTypeDeclarations)"
+pro_uti="$(uti_declaration "$PRO_APP" UTImportedTypeDeclarations)"
+
+if [ -n "$base_uti" ]; then
+ pass "base exports its type declarations"
+else
+ fail "base has no UTExportedTypeDeclarations"
+fi
+
+if [ -n "$pro_uti" ]; then
+ pass "pro imports its type declarations"
+else
+ fail "pro has no UTImportedTypeDeclarations"
+fi
+
+if [ "$base_uti" = "$pro_uti" ]; then
+ pass "the two declarations are verbatim identical"
+else
+ fail "the two declarations differ"
+ diff <(printf '%s\n' "$base_uti") <(printf '%s\n' "$pro_uti") | sed 's/^/ /'
+fi
+
+for app_label in "base:$BASE_APP" "pro:$PRO_APP"; do
+ label="${app_label%%:*}"
+ app="${app_label#*:}"
+ if /usr/libexec/PlistBuddy -c 'Print :CFBundleDocumentTypes:0:LSItemContentTypes:0' "$app/Contents/Info.plist" 2>/dev/null |
+ grep -q '^dev\.rzen\.indie\.kanban-board$'; then
+ pass "$label opens dev.rzen.indie.kanban-board"
+ else
+ fail "$label does not claim dev.rzen.indie.kanban-board as a document type"
+ fi
+done
+
+# ------------------------------------------------------------------------------------- verdict
+
+head2 "$((checks - failures))/$checks checks passed"
+if [ "$failures" -gt 0 ]; then
+ echo "EDITION VERIFICATION FAILED"
+ exit 1
+fi
+echo "EDITION VERIFICATION PASSED"