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 .automerge files in the user’s Minuta/ 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). SVGReportRenderer imports 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):

  1. --storage <path> flag
  2. MINUTA_STORAGE env var
  3. Contents of <realHome>/Library/Application Support/minuta/storage-path.txt, if present and the path is readable
  4. ~/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-version is absent (indicating pre-Phase-0 app). minuta doctor surfaces 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 .nosync extension). Final name: .minuta.writelock.nosync.
  • Dropbox: add the file path to .dropboxignore during Phase 0 (app writes this file on first run if the storage folder is inside a Dropbox tree — detection by walking up for a .dropbox marker).
  • 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. (WidgetData not needed.)
  • Core services:
    • AutomergeStorageService (actor) — after B1 fix
    • HybridStorageService — reads legacy JSON fallback
    • TimeTrackingService (actor) — plus new getOrCreateTag
    • DefaultExportService (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) at SVGReportRenderer.swift:5-9). Requires smoke test of CTFont font resolution from a bundle-less executable (see §Verification).

Not Reused (app-only)

  • Minuta/Sources/Services/WebKitReportService.swift@MainActor, requires NSApplication.shared.run() to pump WKWebView navigation 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 via TagColorGenerator.color(for:) if missing. Used by minuta start --tag <name>. Matches the CLI’s stated “creates tag if missing” semantics. The app’s StartTimerIntent at Minuta/Sources/AppIntents.swift:32-40 currently only looks up — we intentionally diverge, because CLI users expect start --tag NewTag to just work; the app can adopt the same helper later if desired.

  • UUID image filenamesTimeTrackingService.addImage (Shared/.../TimeTrackingService.swift:149-162) changes {recordId}_{index}.{ext} to {recordId}_{uuid}.{ext}. See B2. Also: deprecate (keep, but stop calling from addImage) the public helper TimeRecord.imageFilename(at:extension:) at Shared/.../Models/TimeRecord.swift:46-50 and add TimeRecord.imageFilename(extension:) that returns {recordId}_{UUID()}.ext. Update Shared/Tests/MinutaSharedTests/Mocks/MockTimeTrackingService.swift:115-116 in lockstep. Audit confirmed no call site parses the filename for its index — all iteration uses Array.enumerated() on record.images, so mixed old-index / new-UUID arrays render identically in insertion order.

  • AutomergeStorageService re-read+merge on save — see B1. Pure additive behavior; no API change.

  • AutomergeStorageService.saveStoragePath() (invoked from app’s StorageLocationManager on 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. SVGReportRenderer from a bundle-less executable — pre-verified by inspection. SVGReportRenderer.swift:509,511 requests only "Helvetica" and "Helvetica-Bold" via CTFontCreateWithName. 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. AsyncParsableCommand actor bridging — pre-verified by reasoning. swift-argument-parser’s AsyncParsableCommand.main() awaits run() inside a top-level Task and calls exit(0) after run() returns. TimeTrackingService actor methods are all async and awaited by callers; AutomergeStorageService.saveRecordDocument is synchronous-write inside an actor (no fire-and-forget Task). 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 -o omitted).
  • stderr: everything else — progress, -v debug logs, warnings, OSLog default stream.

This keeps minuta export csv | sort | head working cleanly even with -v.

Exit codes

  • 0 success
  • 1 generic error (bad flag, not found)
  • 2 storage unreachable / path-resolution failed
  • 3 validation error (e.g., end before start)
  • 4 concurrent-write retry limit exceeded (reserved; should be unreachable after B1, but we keep it for retry-loop escape)
  • 5 version-marker absent (B4) — app needs upgrading to Phase-0+ release

Testing Strategy

Four layers:

  1. Unit tests — argument parsing, output formatters, filter predicates, JSON schemas. No disk.
  2. Integration tests — temp Minuta/ directory from Minuta/UITests/Fixtures/standard/; CLI binary invoked via Process.
  3. 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.
  4. Concurrent-write stress — new MinutaSharedTests (not CLI-specific) that spawns two AutomergeStorageService actors 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 the minuta binary: every command, every flag, exit codes, stdout/stderr contract, --json schema examples. This is the canonical command reference; --help output 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_STORAGE env var, shell completion setup.
  • minuta.tools/src/routes/docs/300-services/304-cli/automation/+page.md — cron / launchd recipes, the TZ=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.nosync sentinel, cache mtime invalidation. This is the central place this behavior is now documented.
  • 500-architecture/ — new page 506-cross-process-safety/+page.md covering the single-host vs. cross-host model (flock = single-host, CRDT = cross-host), the .minuta-version marker (B4), and storage-path discovery (B3, getpwuid rationale + 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 ## CLI section: swift run minuta … for dev, Homebrew install, MINUTA_STORAGE env 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 the flock + re-read+merge invariant so future edits to AutomergeStorageService don’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-version marker, 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.txt so command-line tools can find the folder.

CLI self-documentation

  • minuta --help and minuta <cmd> --help — autogenerated by swift-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-parser definitions (--generate-manual or a swift-argument-parser plugin).
  • minuta doctor output — 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.md in 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, create 506-cross-process-safety, update 200-models image scheme, update root CLAUDE.md Data Storage section, write release notes.
  • Phase 1: create 304-cli/ + installation/ pages, update root CLAUDE.md with CLI commands, update 600-testing with CLI test layers.
  • Phase 2: fill in doctor, conflicts resolve, import hey sections of 304-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.swift platform minimum).
  • Binary distribution:
    • brew tap pointing 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 brew but 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.txt at getpwuid(getuid())->pw_dir + /Library/Application Support/minuta/
  • B4 app writes .minuta-version = 1 marker on launch
  • TimeTrackingService.getOrCreateTag(name:)
  • Concurrent-write stress tests in MinutaSharedTests

Phase 1 — CLI MVP (after Phase 0):

  • Verifications V1 + V2 pass
  • start, stop, status
  • records list (human mode)
  • tags list, tags add
  • export csv
  • storage path

Phase 2 — CLI full parity:

  • All records and tags CRUD
  • All list commands support --json / --csv
  • import hey
  • conflicts resolve
  • doctor

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)

  1. 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.
  2. Concurrency model rewritten: load-modify-save replaced with load-modify-reread-merge-save under flock. Both app and CLI participate.
  3. Report generation descoped: v1 is SVG-only. WKWebView-from-CLI removed as unworkable without NSApp bootstrapping. PDF/PNG moved to Phase 5.
  4. 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.
  5. Storage path discovery: added explicit app-writes-storage-path.txt design (§B3).
  6. New shared additions: getOrCreateTag, UUID image filenames.
  7. Verification gate (§Verification) — explicit smoke tests.
  8. Time zone: default to local, matching app’s Calendar.current. --tz override.
  9. flock is mandatory, not optional, and both processes take it.
  10. Cache invalidation on mtime change added for the long-running app process.
  11. Doctor command spec’d concretely.
  12. Notarization scope called out explicitly (paid developer membership required).
  13. Platform: Linux dropped from v1 ambitions.
  14. records list date presets expanded to match FilterTests.swift coverage (last-week/last-month added).
  15. tags search added.

Iteration 2 → 3 (verification audit + new-issues audit)

  1. 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 uses getpwuid(getuid())->pw_dir in both app and CLI to bypass the container redirect.
  2. B4 added — version-marker file .minuta-version to prevent old-app + new-CLI silently clobbering. CLI refuses writes if marker absent (exit code 5); reads freely.
  3. Cross-host sync scope clarifiedflock is kernel-local; explicit statement that multi-device sync is Automerge-only territory. Sentinel renamed to .minuta.writelock.nosync for iCloud exclusion; Dropbox/GDrive noted.
  4. B1 verifiedAutomergeDocument.merge signature and semantics confirmed at Shared/.../AutomergeUtilities.swift:17-23; ConflictResolutionService.swift:86-97 uses the same idiom.
  5. 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:) and MockTimeTrackingService updates.
  6. Verification V1 pre-verified by inspection (SVGReportRenderer uses only system fonts Helvetica / Helvetica-Bold).
  7. Verification V2 pre-verified by reasoning (no fire-and-forget Task in the code paths the CLI invokes).
  8. Stdout/stderr contract added explicitly — data to stdout, progress/debug to stderr.
  9. import hey atomicity spelled out — one writelock for the whole batch; --resume for crash recovery.
  10. Doctor check 7 fixed — was conflating flock with PID-file staleness (flock releases on process death). Replaced with LOCK_EX|LOCK_NB acquire-and-release probe; new check 9 reports effective TZ.
  11. Time zone handling for cron/launchd documented — effective TZ printed by doctor and storage path; TZ=America/Los_Angeles minuta … recommended cron pattern.
  12. OSLog logging clarification — CLI adds distinct cli category; subsystem:tools.minuta.app category:cli filters CLI-only.
  13. Flock + sandbox future-proofing note added — current entitlements are empty; Mac App Store would break this model.
  14. Exit code 5 added for B4 (version-marker absent).

Remaining Open Questions

  • Should the app also adopt getOrCreateTag on StartTimerIntent? (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) so subsystem:tools.minuta.app category:cli surfaces only CLI logs. Optional ~/Library/Logs/minuta/cli.log tee in Phase 2 if useful.