This document carries only what is specific to GrotTrack. The campaign model itself — run contract, run modes, routing, authority, child lane briefs, contract freezing, the structural failure patterns, the unattended blocker contract, the goal-file template, the pre-flight checklist — lives in the Agent fan-out protocol (canonical) doc and is not repeated here. Read both before designing a wave. If a section below could be pasted into another project unchanged, it is in the wrong document.
Written 2026-08-14 at migration time, from the codebase and git history. Sections marked (unevidenced) are conventions set at migration rather than lessons already paid for; treat them as provisional and correct them the first time a run contradicts them.
The exclusive resource: this app can only be exercised on one Mac, serially
GrotTrack is a menu-bar app that reads the Accessibility API, captures the screen through ScreenCaptureKit, and talks to Chrome over native messaging. Every one of those is a machine-global, singleton resource. There is no simulator, no headless mode, and no second instance.
- TCC permissions are granted per code signature, per machine, and prompt interactively.
Accessibility and Screen Recording grants attach to the built binary. An unsigned local build
(
CODE_SIGN_IDENTITY="-") is a different identity from a signed one, so the grant does not carry across and macOS re-prompts. A lane cannot answer that prompt. - The SwiftData store is a single file in the user’s container. Two running instances write the same store.
- The native messaging host is registered at one absolute path,
~/Library/Application Support/Google/Chrome/NativeMessagingHosts/com.grottrack.tabtracker.json, pointing at one binary inside oneGrotTrack.app. Chrome launches whatever that path names.
Therefore: at most one lane may run the app at a time, and running it is a main-thread action, not
a lane action. Lanes write code, run swiftlint, run xcodebuild build and xcodebuild test
— all of which are safe in parallel because they touch only DerivedData and the repo. Anything that
launches GrotTrack, grants a permission, or exercises the Chrome bridge end-to-end is serialised
through the root, on the one Mac, with Rob able to answer a prompt. A lane that needs this stops and
returns the request; it does not try to automate a TCC dialog.
GrotTrack.xcodeproj is generated and untracked — project.yml is the integration file
.gitignore excludes *.xcodeproj. The project file does not exist in a fresh clone and is
regenerated by xcodegen generate. Two consequences that decide lane shape:
- Every lane runs
xcodegen generatebefore its first build, and again after any change toproject.yml. A stale project file is the cheapest way to get a build failure that looks like a code failure — the new file simply is not in the target. project.ymlis a single-owner file. It is this repo’s composition root for target and file membership, plusCFBundleVersion(release-please rewrites it as agenericextra-file). Adding a source file usually needs no edit, since targets are directory-globbed — but a new target, a new Info.plist key, an entitlement or a dependency does. Those edits belong to one lane or to the wiring pass, never to several lanes in the same wave.
Recurring defect #1: SwiftLint violations land, then get their own follow-up commit
The single most repeated failure in this repo’s history. At least eight commits exist whose entire
content is fixing lint the previous commit introduced — b5ee874, bbd1af5, 71efe35, 0d61e90,
cd7b58b, cf19601, c58eead, and b95101b (which bundles it with a CI upgrade). The cause is
structural, not carelessness: swiftlint lint is a separate command from xcodebuild build, so a
lane that builds and tests cleanly still ships violations, and .github/workflows/build.yml:45 runs
Lint before Build — so the whole gate goes red on formatting while the code is fine.
.swiftlint.yml opts in to four rules beyond the defaults that catch people out — force_unwrapping,
implicitly_unwrapped_optional, empty_count, empty_string — and sets line_length to warn at 150
/ error at 200, file_length to warn at 500 / error at 1000. Test code is included, not excluded,
so a table-driven test with a long literal row trips line_length like anything else.
Rule: swiftlint lint is part of a lane’s own definition of done, run before it reports, not left
to the wiring pass. Aggregating eight lanes’ violations into one cleanup commit is how this repo got
eight cleanup commits.
Recurring defect #2: Swift 6 strict concurrency breaks at Apple framework boundaries
SWIFT_STRICT_CONCURRENCY = complete. The failures are not in this code’s own actors — they are where
this code touches a framework Apple has not fully annotated. 85d58a0 is the canonical instance: a
CI build failure fixed by @preconcurrency import ScreenCaptureKit. ff215b5 is another pass of
concurrency-safety fixes found in review rather than by the compiler.
Two properties make this expensive in a fan-out: the failure often appears only in a different
build configuration than the one the lane ran, and the fix (@preconcurrency, nonisolated, an
@unchecked Sendable wrapper) is a judgement about a boundary, not a mechanical edit.
Rule: a lane that adds a new Apple-framework dependency — a new import of a ScreenCaptureKit, AXUIElement, AppKit or SwiftData surface not already used in the file — freezes its isolation decision with the root before writing against it. Getting this wrong silently pushes work onto the main actor, which in a background tracker is a performance defect nobody notices until the app hitches.
Recurring defect #3: macOS actively fights a background menu-bar tracker
Three separate shipped bugs, all the same shape — the OS suspending or killing an app that has no window and no Dock icon:
188bb39— App Nap suspended activity tracking and screenshot capture.b8365aa— macOS Automatic Termination was killing the app outright.dd0b6cc— startup logic sat in a view lifecycle callback, so tracking did not begin until the user opened the menu bar popover.
None of these are reachable by xcodebuild test. They need the built app, running, ignored for
minutes. Rule: any change to app lifecycle, MenuBarExtra wiring, AppCoordinator startup, or
activity/screenshot scheduling carries a manual soak as its acceptance check — build, run, leave it
alone for the relevant interval, then confirm data actually landed. A lane cannot do this (see the
exclusive resource above); it states the soak it needs and the root runs it.
Recurring defect #4: timeline coordinate and index arithmetic
ef73c2c (index space mismatch), 98bf3f4 (scroll and zoom bugs), ff215b5 (zoom math), 8a1f1aa
and ef513b1 (two rewrites of the scroll/selection model). The timeline maps three different spaces
onto each other — wall-clock time, scroll offset in points, and array indices into filtered event
lists — and the bugs are all in the conversions.
Rule: this is the one area of the codebase where test-first is not negotiable. The arithmetic has
edge cases nobody holds in their head (empty ranges, a single event, zoom at the extremes, a filtered
list whose indices no longer match the unfiltered one), and the failures are visual rather than
crashing, so they survive review. GrotTrackTests/ already carries the pattern — see
TimeBlockAggregatorTests, SessionDetectorTests.
The release pipeline is unverifiable locally, and has failed five separate ways
0d09fbd (re-sign Sparkle framework binaries for notarization), 6a5b1c7 (hardened runtime missing
on non-primary targets), 5432bd0 (CFBundleVersion used the wrong version so Sparkle never saw
updates), 6ba449e (added CI signing validation because there was none), becf5ed and 0fabfa8
(pipeline and stale-Xcode-selection fixes).
The common property: none of it can be tested without a signing identity and a real notarization
round-trip, which lanes do not have. A change to .github/workflows/release.yml, to signing,
entitlements, hardened runtime, Sparkle, or the version fields is therefore root-only work, verified
by an actual release, and it is legitimate for such a task to sit Parked awaiting the next one.
Version lives in three files kept in sync by release-please (release-please-config.json
extra-files): grot-track-extension/package.json $.version, grot-track-extension/wxt.config.ts
(the x-release-please-version marker comment), and project.yml. Never hand-bump any of them.
Lane conventions (unevidenced — set at migration, correct on first contradiction)
- Boundaries are directory-shaped, because the codebase is:
GrotTrack/Services/,GrotTrack/Views/<area>/,GrotTrack/Models/,grot-track-extension/. One lane per directory is the default carve. - Shared-by-construction, never parallel:
project.yml,GrotTrackApp.swift(it holds bothAppCoordinatorand the@mainApp struct, so it is the composition root), the SwiftData schema registration inGrotTrackApp.init(),.github/workflows/, andSharedConstants.swift/NativeMessageHost.swift(compiled into both the app andGrotTrackNativeHost, so an edit lands in two targets at once). - The escape hatch: a lane that needs an edit outside its boundary returns the exact edit — file, and the change as a diff or a precise description — and does not make it. The root applies it in the wiring pass. A lane blocked on a TCC prompt, a signing identity, the Chrome Web Store dashboard, or a decision the brief does not cover stops and returns the question; it does not invent an answer.
arch.txtis binding, and bidirectional.AGENTS.mdstates that architecture decisions live there and must be respected — and that changing one means updatingarch.txtin the same change. A lane that finds itself contradictingarch.txthas found a decision to escalate, not a file to edit.
Run-end against this tracker
Task state is the record. Landed work is Done with the SHA in its final summary; work stopped
against the exclusive resource, a signing identity or a Chrome Web Store review is Parked with the
concrete resume boundary (what was true when it stopped, and what has to become true to continue) —
“waiting on Rob” alone is not a boundary. Discovered work is a new task labelled needs-triage.
The closing terminal message carries only what no single task captures. Nothing durable may live only there.