Plan: CLI Tool with Feature Parity
A companion command-line tool (minuta) that operates on the same on-disk Automerge data as the app, with full feature parity for headless/automation use cases.
Status: Iteration 3 — final. Two audit rounds complete; fixes verified.
Changes summary at the bottom.
Goals
- Feature parity with the iOS/macOS app for every non-UI operation: start/stop timers, CRUD on records and tags, filtering, CSV/SVG export, HEY import, conflict resolution. (PDF/PNG deferred — see §Report Generation.)
- Common data layer: both app and CLI read/write the exact same
.automergefiles in the user’sMinuta/folder, with no new format and no sync-between-them layer. - Safe concurrency with the running app — but only after the shared storage layer is fixed (§Cross-Process Safety). Shipping the CLI exposes a latent clobber bug; the fix is required and lands in
Shared/, so the app benefits too. - No network, ever (consistent with the app’s “App should not load anything from web”).
- Unix-friendly: stdout/stderr separation, machine-readable
--json, non-zero exit codes, pipes.
Non-Goals
- Replicating the app’s UI (no TUI).
- Introducing a new data format or server.
- Multi-user/multi-tenant support.
- Shortcuts/AppIntents replacement — the CLI itself is the automation surface.
- Linux support (v1).
SVGReportRendererimports AppKit/UIKit; v1 is macOS-only.
Blockers Before Implementation Starts
The audits surfaced three P0 issues that must be resolved in the shared Swift package before CLI work begins. Each is a pre-existing app bug that merely becomes visible once a second writer exists.
B1. AutomergeStorageService does not re-read before save → clobbers concurrent writes
Evidence: Shared/Sources/MinutaShared/Services/AutomergeStorageService.swift:46 caches tagsDocument in memory on first load and never reloads. :107 does the same for recordDocuments[key]. saveRecordDocument at :123-131 writes the cached doc with .atomic — whole-file replace, which clobbers any intervening write.
Today this is latent because only one process writes at a time. It also has implications for folder-sync tools (Dropbox) that replace the file while the app has a stale cache. With the CLI, it becomes a routine data-loss path.
Fix (in Shared/, applies to both app and CLI):
// Pseudocode for the save path
private func saveRecordDocument(year: Int, month: Int) throws {
let key = monthKey(year, month)
guard let inMemory = recordDocuments[key] else { return }
inMemory.commitWith(timestamp: Date())
// Re-read disk state and merge before writing
let url = recordDocumentURL(year: year, month: month)
if fileManager.fileExists(atPath: url.path) {
let onDiskData = try Data(contentsOf: url)
let onDisk = try AutomergeDocument.load(from: onDiskData)
try AutomergeDocument.merge(inMemory, with: onDisk) // already exists in AutomergeUtilities
}
try ensureDirectoryExists(at: baseURL)
try inMemory.save().write(to: url, options: .atomic)
} The AutomergeUtilities.merge helper already exists and is used by ConflictResolutionService.
Lock: also take an flock(LOCK_EX) on a sentinel file (<storage>/.minuta.writelock.nosync) around the read-merge-save critical section. Both app and CLI must take it; the lock is advisory-but-honored by both. Without mutual lock, a sufficiently unlucky interleave still loses data between file read and atomic rename.
The app’s entitlements file (Minuta/Sources/Minuta.entitlements:4) is empty — no sandbox — so POSIX flock(2) on a file inside a user-picked folder works normally. If the app ever adopts App Sandbox (e.g., for Mac App Store), this needs re-evaluation.
Tests: add concurrent-write tests in MinutaSharedTests that spawn two AutomergeStorageService instances pointing at the same dir and hammer both.
B2. Image filename {recordId}_{index}.ext collides under concurrent add
Evidence: Shared/Sources/MinutaShared/Services/TimeTrackingService.swift:154 computes let index = record.images.count, then calls storage.saveImage(_:for:filename:) with recordId_index.ext. Two writers both reading images.count == 2 both write recordId_2.ext, both append to the Automerge list, merge yields […, _2.ext, _2.ext], one file’s bytes win, data is lost.
Fix (in Shared/): switch image filenames to {recordId}_{uuid}.{ext}. The record’s images: [String] array stores full filenames already, so no schema change needed. Migration: existing index-style filenames keep working (they remain on disk); only new images get the UUID scheme. Add a one-time pass at app launch that renames any index-style files to UUID-style if desired — optional, not required for correctness.
B3. CLI cannot discover the app’s custom storage folder
Evidence: Minuta/Sources/Services/StorageLocationManager.swift:113-149 persists the user’s folder as a security-scoped bookmark in UserDefaults.standard under the app’s bundle-id container. The CLI is a different binary with a different bundle id; it has no access to that UserDefaults domain, and even if it did, resolving a security-scoped bookmark from a different process is not supported.
Mac Catalyst container trap: Minuta/Sources/Minuta.entitlements:4 is <dict/> (no sandbox entitlement), but that does not help here. Mac Catalyst apps are always containerized, regardless of sandbox entitlement — a platform requirement. Inside a Catalyst app, FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask) and NSHomeDirectory() resolve to ~/Library/Containers/tools.minuta.app/Data/... — not the user’s real ~/Library/. The CLI, a native SwiftPM binary, resolves the same APIs to the real user-level paths. They will not agree.
Fix (in Minuta/ app): compute the real user home explicitly, bypassing the Catalyst container redirect:
import Darwin
let realHome = String(cString: getpwuid(getuid()).pointee.pw_dir)
let configDir = URL(fileURLWithPath: realHome)
.appendingPathComponent("Library/Application Support/minuta")
try FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true)
let storagePathFile = configDir.appendingPathComponent("storage-path.txt")
try resolvedStoragePath.path.write(to: storagePathFile, atomically: true, encoding: .utf8) The CLI reads from the same absolute path via the same getpwuid resolution (even though a non-Catalyst CLI wouldn’t strictly need the bypass, using identical resolution code keeps the two in sync).
Write is triggered whenever StorageLocationManager.saveBookmark(for:) or useDefaultLocation() changes the effective path.
Resolution order (CLI side):
--storage <path>flagMINUTA_STORAGEenv var- Contents of
<realHome>/Library/Application Support/minuta/storage-path.txt, if present and the path is readable ~/Documents/Minuta/default
If step 3 points at a folder that isn’t currently accessible (unmounted drive, stale Dropbox path), the CLI fails fast with exit code 2 rather than silently falling through to step 4 and writing to a different location than the app.
B4. Version skew — old app + new CLI silently reintroduces the B1 bug
The CLI ships separately from the app (Homebrew). A user on app v1.0 (pre-Phase-0) + CLI v1.0 (expects Phase-0 fixes) is the stomp scenario we fixed in B1 — except the CLI thinks it’s safe because it takes the writelock, and the app, unaware of the lock and the re-read-merge path, clobbers anyway.
Fix: the Phase-0 app release writes a version marker file <storage>/.minuta-version containing a numeric schema version (start at 1). The CLI:
- Refuses to write if
.minuta-versionis absent (indicating pre-Phase-0 app).minuta doctorsurfaces this prominently; other commands fail with exit code 5 and a message pointing at the upgrade path. - Reads freely even without the marker (safe — worst case is a stale view).
- Writes the marker itself on first successful write, so a user who never opens the app after installing Phase-0 isn’t permanently blocked, as long as they install a CLI version that’s Phase-0-or-newer.
This also guards future schema bumps.
Cross-Host Sync Scope
flock(2) is kernel-local. It protects concurrent writers on one machine (e.g., user running minuta in Terminal while the app is open). It does not protect against two machines editing via Dropbox/iCloud simultaneously — sync services copy file content, not lock state.
That’s fine — that case is exactly what Automerge CRDT is for. The sync service drops a new YYYY/MM.automerge on disk; next time the app or CLI opens that file, the re-read-merge path in B1 pulls in the remote changes. Automerge merges are commutative.
The sentinel file name is .minuta.writelock. To avoid it being synced needlessly:
- iCloud Drive: suffix
.nosync(Apple convention — iCloud ignores files with.nosyncextension). Final name:.minuta.writelock.nosync. - Dropbox: add the file path to
.dropboxignoreduring Phase 0 (app writes this file on first run if the storage folder is inside a Dropbox tree — detection by walking up for a.dropboxmarker). - Google Drive for Desktop: no per-file exclude mechanism. Accept that it will sync the file; content is a single byte or empty, so it’s harmless noise.
Plan scope: single-host safety is mandatory (B1 + flock). Cross-host safety is handled by CRDT merge on every read+write. Document explicitly in README.
Architecture Overview
┌─────────────────────────────────────────────────────────────┐
│ Shared/ (Swift Package — MinutaShared) │
│ ────────────────────────────────────────────────────────── │
│ Models: Tag, TimeRecord, ExportOptions, ReportData ... │
│ Services: TimeTrackingService, AutomergeStorageService, │
│ ExportService, ImportService, │
│ ConflictResolutionService, │
│ ReportDataBuilder (extracted from ReportData) │
│ Utils: DurationFormatter, TagColorGenerator, ... │
│ ──────── NEW shared additions ──────── │
│ TimeTrackingService.getOrCreateTag(name:) │
│ AutomergeStorageService re-read+merge on save │
│ AutomergeStorageService file lock on write │
│ UUID image filenames (TimeTrackingService.addImage) │
└───────────┬─────────────────────────────┬───────────────────┘
│ │
┌────────┴─────────┐ ┌─────────┴──────────┐
│ Minuta/ │ │ Cli/ │
│ SwiftUI, Intents │ │ swift-argument- │
│ WebKitReport* │ │ parser │
│ + writes │ │ + reads │
│ storage-path.txt │ │ storage-path.txt │
└────────┬─────────┘ └─────────┬──────────┘
│ │
└────────────┬────────────────┘
▼
<storage>/
├─ .minuta.writelock ← new, flock(2) sentinel
├─ tags.automerge
└─ records/YYYY/MM.automerge
│
└─ synced by Dropbox/iCloud/etc. * The CLI does not link WebKitReportService. See §Report Generation.
Package Structure
The audit surfaced a third option that’s strictly better: keep everything in Shared/Package.swift but put the executable in its own target with its own dependency list. swift-argument-parser lives only on the CLI target; the MinutaShared library product doesn’t inherit it, so the iOS app doesn’t pull it.
Shared/Package.swift
products:
- library "MinutaShared" (unchanged)
- executable "minuta" ← new
targets:
- MinutaShared (unchanged deps: Automerge)
- MinutaCLI ← new
deps: MinutaShared, swift-argument-parser
path: Sources/MinutaCLI
- MinutaSharedTests (unchanged)
- MinutaCLITests ← new
deps: MinutaCLI, MinutaShared
path: Tests/MinutaCLITests Single swift build builds both; fixtures shareable with MinutaSharedTests; iOS binary unaffected because SwiftPM does not link executable-target dependencies into library consumers.
What We Reuse vs Rebuild
Reuse from MinutaShared
- Models:
Tag,TimeRecord,ExportOptions,ExportFormat,ReportData,ReportOptions. (WidgetDatanot needed.) - Core services:
AutomergeStorageService(actor) — after B1 fixHybridStorageService— reads legacy JSON fallbackTimeTrackingService(actor) — plus newgetOrCreateTagDefaultExportService(CSV)HEYImportService(HEY CSV import)ConflictResolutionService(merges(conflicted copy)files)
- Automerge wrappers:
AutomergeTag,AutomergeTimeRecord,AutomergeUtilities. - Utilities:
DurationFormatter,DateFormatters,TagColorGenerator,OKLCH,CSVParser,HistoryGrouping,Logger. ReportData.build(...)for data aggregation (filtering, time series, comment items).SVGReportRenderer— builds on macOS CLI via AppKit branch (#if canImport(AppKit)atSVGReportRenderer.swift:5-9). Requires smoke test ofCTFontfont resolution from a bundle-less executable (see §Verification).
Not Reused (app-only)
Minuta/Sources/Services/WebKitReportService.swift—@MainActor, requiresNSApplication.shared.run()to pumpWKWebViewnavigation callbacks. A bare SwiftPM executable has no NSApp main loop, so awaited WebKit callbacks hang. Out-of-scope for v1.Minuta/Sources/Services/ReportImageProvider.swift— trivially reimplemented in CLI (26 lines) with the same interface.StorageLocationManager,WidgetDataService, all SwiftUI views,AppIntents.swift.
Report Generation (revised from iteration 1)
Iteration 1 proposed WKWebView-from-CLI. Audits confirmed that won’t work without bootstrapping NSApplication, which blows up scope.
v1 plan: SVG + CSV only. The CLI writes SVG via SVGReportRenderer and CSV via DefaultExportService. PDF/PNG users run qlmanage -t -s 1200 -f png report.svg or similar downstream (documented in --help).
v2 plan (future): bootstrap NSApplication and run a synchronous RunLoop-pumped WKWebView pipeline. This is a real project, not a bullet point. Deferred until there’s demand.
New Shared-Package Additions (from implementer audit)
TimeTrackingService.getOrCreateTag(name: String) -> Tag— look up by case-insensitive name, create with auto-color viaTagColorGenerator.color(for:)if missing. Used byminuta start --tag <name>. Matches the CLI’s stated “creates tag if missing” semantics. The app’sStartTimerIntentatMinuta/Sources/AppIntents.swift:32-40currently only looks up — we intentionally diverge, because CLI users expectstart --tag NewTagto just work; the app can adopt the same helper later if desired.UUID image filenames —
TimeTrackingService.addImage(Shared/.../TimeTrackingService.swift:149-162) changes{recordId}_{index}.{ext}to{recordId}_{uuid}.{ext}. See B2. Also: deprecate (keep, but stop calling fromaddImage) the public helperTimeRecord.imageFilename(at:extension:)atShared/.../Models/TimeRecord.swift:46-50and addTimeRecord.imageFilename(extension:)that returns{recordId}_{UUID()}.ext. UpdateShared/Tests/MinutaSharedTests/Mocks/MockTimeTrackingService.swift:115-116in lockstep. Audit confirmed no call site parses the filename for its index — all iteration usesArray.enumerated()onrecord.images, so mixed old-index / new-UUID arrays render identically in insertion order.AutomergeStorageServicere-read+merge on save — see B1. Pure additive behavior; no API change.AutomergeStorageService.saveStoragePath()(invoked from app’sStorageLocationManageron folder selection) — writes the resolved path to~/Library/Application Support/minuta/storage-path.txt. See B3.
Every one of these is small, independently testable, and a strict improvement for the app even before the CLI ships.
Command Surface
Top-level: minuta <command> [flags] [args]. Global flags: --storage <path>, --json, -v, -q.
All times are in local time zone by default, matching the app (Calendar.current throughout the codebase). --tz <name> overrides per-invocation.
Calendar.current and TimeZone.current resolve from process environment. launchd and cron commonly run with TZ=UTC and a stripped locale — minuta export report --today at 03:00 local under TZ=UTC returns yesterday’s window. minuta storage path and minuta doctor both print the effective TZ so users can diagnose this. The recommended cron pattern is explicit: TZ=America/Los_Angeles minuta export csv --today.
Timer commands
minuta start [--tag <name>] [--comment <text>] [--at <iso-datetime>]
Start a new timer. If --at is omitted, uses now.
If --tag names an unknown tag, it's created with auto-color
(via getOrCreateTag, CLI-exclusive behavior).
Outputs: new record ID.
minuta stop [--id <record-id>] [--all]
Stop a running timer. Default: stop all running (matches StopTimerIntent).
Outputs: stopped record summaries.
minuta status
Show running timers with current duration (one-shot; exits).
Equivalent to "Running Timers" section in the app. Record commands
minuta records list [filters]
Filters:
--from <date> inclusive (local TZ unless --tz)
--to <date> inclusive
--tag <name> repeatable, multi-tag filter
--untagged include untagged
--running / --no-running
--today | --this-week | --this-month | --last-week | --last-month
Output: table | --json | --csv
minuta records add --start <datetime> [--end <datetime>] [--tag <name>] [--comment <text>]
minuta records edit <id> [fields…] [--clear-tag] [--clear-comment]
minuta records delete <id> [--force]
minuta records show <id>
minuta records image add <record-id> <path-to-image>
minuta records image list <record-id>
minuta records image remove <record-id> <index>
minuta records image export <record-id> <index> <output-path> Tag commands
minuta tags list [--archived] [--all]
minuta tags search <query>
minuta tags add <name> [--color <hex>] # auto-color if omitted
minuta tags rename <name> <new-name>
minuta tags recolor <name> <hex>
minuta tags archive <name>
minuta tags unarchive <name>
minuta tags delete <name> [--force] # warns about orphaned records Export / import / reports
minuta export csv [filters] [-o <path>]
Filters: same as `records list`.
If -o omitted, writes to stdout.
minuta export report --format svg --from <date> --to <date> \
[--tag <name>]... [--untagged] [--title <text>] [-o <path>]
v1 supports SVG only. PDF/PNG deferred.
For PDF/PNG downstream: qlmanage -t -s 1200 -f png report.svg
minuta import hey <path-to-hey.csv> [--dry-run] [--resume]
Lock semantics: one writelock held for the whole batch. Tags written first,
then records. Progress printed to stderr.
Crash recovery: on next run, --resume detects a partial import (records
already present with ID match) and picks up where it stopped. Maintenance
minuta doctor
Concrete checks:
1. storage-path.txt present and pointing at a readable folder
2. write test (create+delete a sentinel in storage dir)
3. tags.automerge loads cleanly
4. every YYYY/MM.automerge loads cleanly (report corrupt ones)
5. count orphan image files (on disk, not referenced by any record)
6. count (conflicted copy) files and other sync artifacts
7. .minuta-version marker present (checks Phase-0 app compatibility)
8. writelock sentinel acquirable: LOCK_EX|LOCK_NB, immediately release.
If it blocks, another minuta process is writing; report which.
9. report effective TZ and calendar used by --today/--this-week, so
cron/launchd users can diagnose day-boundary surprises.
Output: table of checks with PASS / WARN / FAIL; exits 0 only if all PASS.
minuta conflicts resolve [--dry-run]
Run ConflictResolutionService over the storage folder.
minuta storage path
Print resolved storage URL (and which step of the resolution order
produced it: flag, env, config file, or default). Shell completion
minuta --generate-completion-script bash|zsh|fish Built into swift-argument-parser.
Cross-Process Safety: Detailed Model
Given B1’s fix, the single-host write path becomes:
1. flock(LOCK_EX) on <storage>/.minuta.writelock.nosync
2. Load in-memory doc (from cache or disk)
3. Apply the user's change (setField / insert / delete)
4. Re-read the same file from disk → onDiskDoc
5. inMemory.merge(onDiskDoc) // Automerge merge is commutative
6. inMemory.save() → .atomic write
7. flock(LOCK_UN) Automerge semantics: AutomergeDocument.merge(primary, with: other) (Shared/.../Automerge/AutomergeUtilities.swift:17-23) mutates primary in place, and Automerge merges are commutative and associative — so “which is primary” matters for cache-identity, not for data. ConflictResolutionService.swift:86-97 uses the same idiom. Audit confirmed.
The lock bounds the critical section across the on-disk state at steps 4–6. Without it, another writer can slip a write in between step 4 and step 6.
Read path unchanged; Automerge handles stale-read tolerance inherently because every writer re-merges.
Also required: invalidate the in-memory cache when the file on disk has a newer mtime than what we last saw. Both app and CLI need this; without it, a long-lived process (the app) keeps serving stale data from its LRU cache even after the CLI writes. Simplest implementation: stat the file before returning from loadRecordDocument; if mtime > recorded-mtime, drop the cached doc and reload.
For one-shot CLI commands, this is trivial (each invocation reloads). For the app, it’s a minor change in the same PR as B1.
Verification Before Shipping
The plan has two claims that warranted verification. Both have been pre-verified by code inspection during iteration 2 audit:
V1.
SVGReportRendererfrom a bundle-less executable — pre-verified by inspection.SVGReportRenderer.swift:509,511requests only"Helvetica"and"Helvetica-Bold"viaCTFontCreateWithName. Both ship system-wide on macOS and resolve from any process without a bundle/Info.plist. The SVG output itself uses CSS-apple-system, Helvetica, Arial, sans-serif(:32); CTFont is only used for width measurement at encode-time. Smoke test remains in Phase 1 as a regression guard against future font changes.V2.
AsyncParsableCommandactor bridging — pre-verified by reasoning. swift-argument-parser’sAsyncParsableCommand.main()awaitsrun()inside a top-levelTaskand callsexit(0)afterrun()returns.TimeTrackingServiceactor methods are allasyncand awaited by callers;AutomergeStorageService.saveRecordDocumentis synchronous-write inside an actor (no fire-and-forgetTask). So any code path the CLI invokes is completion-awaited. Risk is nil. Keep the smoke test as a sanity check.
Output Contract
Default (human, TTY)
Aligned tables, ANSI color on tag names (derived from TagColorGenerator → OKLCH → RGB). Auto-strip color if stdout is not a TTY (isatty(1)).
--json mode
Stable schema. Example for records list:
{
"records": [
{
"id": "…",
"startTime": "2026-04-21T09:00:00Z",
"endTime": "2026-04-21T10:30:00Z",
"duration": 5400,
"tag": { "id": "…", "name": "Work", "color": "#aabbcc" },
"comment": "…",
"images": ["…"]
}
]
} Times in JSON are always UTC ISO 8601. Display-mode times are local.
stdout / stderr
- stdout: data output only (tables,
--json,--csv, report SVG when-oomitted). - stderr: everything else — progress,
-vdebug logs, warnings, OSLog default stream.
This keeps minuta export csv | sort | head working cleanly even with -v.
Exit codes
0success1generic error (bad flag, not found)2storage unreachable / path-resolution failed3validation error (e.g., end before start)4concurrent-write retry limit exceeded (reserved; should be unreachable after B1, but we keep it for retry-loop escape)5version-marker absent (B4) — app needs upgrading to Phase-0+ release
Testing Strategy
Four layers:
- Unit tests — argument parsing, output formatters, filter predicates, JSON schemas. No disk.
- Integration tests — temp
Minuta/directory fromMinuta/UITests/Fixtures/standard/; CLI binary invoked viaProcess. - Cross-client tests — CLI writes to a temp dir, then
HybridStorageService(the app’s path) loads the same dir and asserts invariants. Exercise: create record, check app sees it; app creates record, check CLI sees it; both write concurrently, check both writes survive. - Concurrent-write stress — new
MinutaSharedTests(not CLI-specific) that spawns twoAutomergeStorageServiceactors on the same dir with overlapping edits; verifies no clobbering. This proves B1’s fix.
Fixtures shared with Minuta/UITests/Fixtures/ via SwiftPM Resources (or copied symlink at test-time).
Documentation Updates
Each phase ships with matching doc changes. Project convention: docs live under minuta.tools/src/routes/docs/ as +page.md with YAML frontmatter (title, description, excluded: true).
New doc pages
minuta.tools/src/routes/docs/300-services/304-cli/+page.md— reference for theminutabinary: every command, every flag, exit codes, stdout/stderr contract,--jsonschema examples. This is the canonical command reference;--helpoutput stays terse and links here.minuta.tools/src/routes/docs/300-services/304-cli/installation/+page.md— Homebrew tap, direct-download, Gatekeeper workaround for unsigned Phase-1–3 builds,MINUTA_STORAGEenv var, shell completion setup.minuta.tools/src/routes/docs/300-services/304-cli/automation/+page.md— cron / launchd recipes, theTZ=America/Los_Angeles minuta …pattern, piping examples (minuta export csv --today | …).
Updates to existing doc pages
500-architecture/505-automerge-storage/+page.md— add the load-modify-reread-merge-save pattern (B1), the.minuta.writelock.nosyncsentinel, cache mtime invalidation. This is the central place this behavior is now documented.500-architecture/— new page506-cross-process-safety/+page.mdcovering the single-host vs. cross-host model (flock = single-host, CRDT = cross-host), the.minuta-versionmarker (B4), and storage-path discovery (B3,getpwuidrationale + Catalyst container trap).200-models/— note the image-filename scheme change to{recordId}_{uuid}.ext(B2). Keep the old-format note since legacy filenames stay valid.300-services/index — link the new CLI section.600-testing/+page.md— add the four CLI test layers and the concurrent-write stress tests.900-log/911-completed/+page.md— log Phase 0 shipped, then each CLI phase as it lands.
Updates to CLAUDE.md files
- Root
CLAUDE.md— add a## CLIsection:swift run minuta …for dev, Homebrew install,MINUTA_STORAGEenv var. Add CLI to Project Structure. Note the Phase 0 shared-layer changes in the Data Storage section (writelock, version marker,storage-path.txt). Shared/CLAUDE.md(if we create one, or inline in root) — call out theflock+ re-read+merge invariant so future edits toAutomergeStorageServicedon’t silently remove it.
App-side user-visible changes
- Release notes for the app version that ships Phase 0: mention
storage-path.txt, the.minuta-versionmarker, the fact that a CLI exists, and the concurrent-write safety improvement (which benefits Dropbox/iCloud users even without the CLI). - Settings > About / Help screen — link to the CLI docs page once Phase 1 ships.
- App’s existing Data / storage-location docs — note that the app now writes
storage-path.txtso command-line tools can find the folder.
CLI self-documentation
minuta --helpandminuta <cmd> --help— autogenerated byswift-argument-parser. Keep descriptions short; every command’s long-form help ends with a pointer to the online docs page.- Man pages (Phase 3) — generated from the same
swift-argument-parserdefinitions (--generate-manualor aswift-argument-parserplugin). minuta doctoroutput — already user-facing; keep phrasing terse and actionable.
Changelog and release notes
- App changelog entry for the Phase 0 release: user-visible (“folder picker now writes a small config file so command-line tools can find your data”; “improved safety when Dropbox/iCloud sync happens during a write”) + technical (for contributors: the storage actor re-reads and merges before save).
- CLI has its own
CHANGELOG.mdin the repo; every release tags a version, lists commands added, breaking changes. First entry: v0.1.0 MVP (Phase 1). - Each Phase’s completion gets an entry in
minuta.tools/src/routes/docs/900-log/so the site’s changelog reflects shipped work.
Documentation tasks as Phase deliverables
- Phase 0: update
505-automerge-storage, create506-cross-process-safety, update200-modelsimage scheme, update rootCLAUDE.mdData Storage section, write release notes. - Phase 1: create
304-cli/+installation/pages, update rootCLAUDE.mdwith CLI commands, update600-testingwith CLI test layers. - Phase 2: fill in
doctor,conflicts resolve,import heysections of304-cli/. - Phase 3: create
304-cli/automation/, generate and include man pages. - Phase 4: installation page updated with signed-binary notes (remove Gatekeeper workaround).
Docs that aren’t updated alongside their matching code change block the phase’s PR. Same bar the project already applies (“Always update docs when making changes” — root CLAUDE.md).
Platform & Distribution
- macOS 14+ (matches
Shared/Package.swiftplatform minimum). - Binary distribution:
brew tappointing to a tagged GitHub Release.- Direct download from GitHub Releases.
swift run minuta …for dev.
- Signing & notarization — requires a paid Apple Developer membership ($99/yr) and a Developer ID Application certificate. Pre-release gate for Phase 4. Before that, unsigned binaries can be installed via
brewbut users will need to right-click → Open the first time or disable Gatekeeper per-binary (xattr -d com.apple.quarantine). Documented in README.
Rollout
Phase 0 — Shared-layer prerequisites (lands in Shared/ and Minuta/, ships with the next app release):
- B1 re-read+merge save path + flock(2) + cache mtime invalidation
- B2 UUID image filenames (+
TimeRecord.imageFilename(extension:)helper) - B3 app writes
storage-path.txtatgetpwuid(getuid())->pw_dir+/Library/Application Support/minuta/ - B4 app writes
.minuta-version = 1marker on launch TimeTrackingService.getOrCreateTag(name:)- Concurrent-write stress tests in
MinutaSharedTests
Phase 1 — CLI MVP (after Phase 0):
- Verifications V1 + V2 pass
start,stop,statusrecords list(human mode)tags list,tags addexport csvstorage path
Phase 2 — CLI full parity:
- All records and tags CRUD
- All list commands support
--json/--csv import heyconflicts resolvedoctor
Phase 3 — Reports:
export report --format svg- Man pages
- Shell completion
Phase 4 — Distribution:
- Developer ID signing + notarization
- Homebrew tap
- GitHub Release automation
Phase 5 — Optional: export report --format pdf|png via bootstrapped NSApplication + WKWebView pipeline. Only if demanded.
Each phase is independently shippable.
Iteration History
Iteration 1 → 2 (architect / skeptic / implementer audits)
- Added Blockers Before Implementation section (§B1–B3) — three P0 fixes that must land in
Shared/before CLI work. Each is a pre-existing latent bug. - Concurrency model rewritten: load-modify-save replaced with load-modify-reread-merge-save under
flock. Both app and CLI participate. - Report generation descoped: v1 is SVG-only. WKWebView-from-CLI removed as unworkable without NSApp bootstrapping. PDF/PNG moved to Phase 5.
- Package structure: adopted “Option 3” from architect audit — CLI as an executable target in the same package, deps scoped to the target so the iOS binary isn’t affected.
- Storage path discovery: added explicit app-writes-
storage-path.txtdesign (§B3). - New shared additions:
getOrCreateTag, UUID image filenames. - Verification gate (§Verification) — explicit smoke tests.
- Time zone: default to local, matching app’s
Calendar.current.--tzoverride. flockis mandatory, not optional, and both processes take it.- Cache invalidation on mtime change added for the long-running app process.
- Doctor command spec’d concretely.
- Notarization scope called out explicitly (paid developer membership required).
- Platform: Linux dropped from v1 ambitions.
records listdate presets expanded to matchFilterTests.swiftcoverage (last-week/last-month added).tags searchadded.
Iteration 2 → 3 (verification audit + new-issues audit)
- B3 revised for Mac Catalyst container redirect — the original
~/Library/Application Support/...path doesn’t work from a Catalyst app (containerized regardless of sandbox entitlement). Now usesgetpwuid(getuid())->pw_dirin both app and CLI to bypass the container redirect. - B4 added — version-marker file
.minuta-versionto prevent old-app + new-CLI silently clobbering. CLI refuses writes if marker absent (exit code 5); reads freely. - Cross-host sync scope clarified —
flockis kernel-local; explicit statement that multi-device sync is Automerge-only territory. Sentinel renamed to.minuta.writelock.nosyncfor iCloud exclusion; Dropbox/GDrive noted. - B1 verified —
AutomergeDocument.mergesignature and semantics confirmed atShared/.../AutomergeUtilities.swift:17-23;ConflictResolutionService.swift:86-97uses the same idiom. - B2 verified — code audit confirms no call site parses filenames for indices; mixed index/UUID arrays coexist. Added explicit deprecation of
TimeRecord.imageFilename(at:extension:)andMockTimeTrackingServiceupdates. - Verification V1 pre-verified by inspection (
SVGReportRendereruses only system fonts Helvetica / Helvetica-Bold). - Verification V2 pre-verified by reasoning (no fire-and-forget
Taskin the code paths the CLI invokes). - Stdout/stderr contract added explicitly — data to stdout, progress/debug to stderr.
import heyatomicity spelled out — one writelock for the whole batch;--resumefor crash recovery.- Doctor check 7 fixed — was conflating flock with PID-file staleness (flock releases on process death). Replaced with
LOCK_EX|LOCK_NBacquire-and-release probe; new check 9 reports effective TZ. - Time zone handling for cron/launchd documented — effective TZ printed by
doctorandstorage path;TZ=America/Los_Angeles minuta …recommended cron pattern. - OSLog logging clarification — CLI adds distinct
clicategory;subsystem:tools.minuta.app category:clifilters CLI-only. - Flock + sandbox future-proofing note added — current entitlements are empty; Mac App Store would break this model.
- Exit code 5 added for B4 (version-marker absent).
Remaining Open Questions
- Should the app also adopt
getOrCreateTagonStartTimerIntent? (Out of scope for this plan; consistency win later.) - Widget cache invalidation when CLI writes while app is backgrounded — probably fine because widget reads from the same Automerge files on refresh. Verify during Phase 1.
- Log routing:
os.Logger(subsystem: "tools.minuta.app", category: "storage")from an unsigned CLI does emit records (unified logging accepts any subsystem string), but Console.app’s subsystem filter is populated from bundled apps’Info.plist, so CLI records are harder to find when filtering by subsystem from the app side. Use a distinct category for the CLI (AppLogger.cli, added in Phase 1) sosubsystem:tools.minuta.app category:clisurfaces only CLI logs. Optional~/Library/Logs/minuta/cli.logtee in Phase 2 if useful.