▍ humdrum codex / ticktock v0.0.2
license AGPL-3.0

docs: add autotrack daemon + activity/calendar timeline spec and plan

03f5912c457d1e1c56ff430d4c672139427c4cfe
Kevin Kortum <kevinkortum@me.com> · 2026-07-08 11:58

parent f1a8ae47

2 files changed

docs/superpowers/plans/2026-07-08-tt-autotrack-daemon-and-activity-timeline.md +2625 −0
@@ -0,0 +1,2625 @@
+# tt autotrack daemon + activity/calendar timeline — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Port the Python autotrack daemon into `tt` as `tt autotrack` (writer) and render its activity log plus filtered calendar events in the grid's ACTIVITY column (reader), so `ticktock-old` can be archived.
+
+**Architecture:** Two subsystems inside the one `tt` binary. Writer: `internal/autotrack` — pure segmentation state machine (`Classify`/`CtxKey`/`Step`), byte-compatible jsonl log writer, darwin-only OS sampling behind build tags, poll loop with signal flush, wired to a new `autotrack` cobra subcommand and a launchd agent. Reader: `internal/activity` — tolerant jsonl + `cal-events` JSON parsing with a config-driven calendar filter, painted into the grid via a new `paintActivity` that mirrors `paintLogged`.
+
+**Tech Stack:** Go 1.26 (stdlib only for new code — `encoding/json`, `os/exec`, `os/signal`, `net/url`), existing charm stack (bubbletea/lipgloss) for the grid, cobra for the CLI, `swiftc` for the two vendored native helpers (`winctx`, `cal-events`), launchd for the daemon.
+
+**Design spec (source of truth):** `docs/superpowers/specs/2026-07-08-tt-autotrack-daemon-and-activity-timeline-design.md`
+
+## Global Constraints
+
+Copied from the spec — every task's requirements implicitly include these:
+
+- Go 1.26, `module ticktock`, binary `tt`.
+- All OS integration is darwin-only behind `//go:build darwin` build tags, with a `!darwin` stub (`stub.go` returns empty context) so the package builds and tests run everywhere.
+- The pure segmentation and log/calendar parsing are portable and unit-tested on any platform.
+- Shared directories unchanged: config `~/.config/ticktock/config.json`, data `~/.local/share/ticktock/activity-YYYY-MM-DD.jsonl` — the Go daemon writes the same stream the Python one did, and the reader sees one continuous history.
+- Activity record encoding: `{start, end, secs, app, title, url, domain, idle}`, ISO-8601 seconds precision — **byte-compatible with the Python output** (`json.dumps(..., ensure_ascii=False)`: `", "` / `": "` separators, that exact key order, no HTML escaping).
+- The daemon writes NOTHING to tock — only its own staging log (suggest-then-confirm). tock stays the source of truth.
+- The Python daemon in `ticktock-old` stays untouched as rollback: repoint the plist back to `autotrack.py` if the Go daemon misbehaves. Archive `ticktock-old` only after a verified normal day.
+- Native Swift helpers (`winctx`, `cal-events`) are built from vendored source via Makefile `swiftc` targets that **skip gracefully** (print a note, continue) when `swiftc` is unavailable — the daemon degrades to System Events titles and the reader shows no calendar lane.
+- Every OS call has a timeout and returns empty on failure — never panics the loop.
+- The reader skips malformed jsonl lines rather than failing the view; missing file / missing helper / denied access are never user-facing errors.
+- **VCS: this is a jj-colocated repo (`.jj/` present). Every commit step uses `jj commit -m "type: msg"` (which finalizes the current working copy and starts a new one). NEVER run `git add`, `git commit`, or any other mutating git command.**
+
+## File Structure
+
+```
+internal/config/config.go            MODIFY  + Tracking block (Task 1), + Calendars block (Task 5)
+internal/config/config_test.go       MODIFY  new tests; DeepEqual fix in Task 5
+internal/autotrack/segment.go        CREATE  Ctx, Segment, Domain, Away, Classify, Key, CtxKey (T2), Step (T3)
+internal/autotrack/segment_test.go   CREATE  table tests (T2, T3)
+internal/autotrack/log.go            CREATE  Record, EncodeLine, LogPath, WriteSegment (T4)
+internal/autotrack/log_test.go       CREATE  byte-parity + min-secs tests (T4)
+internal/autotrack/sample.go         CREATE  Sample, parseHIDIdle, parseLocked, parseWinctx, HelperPath (T10)
+internal/autotrack/sample_test.go    CREATE  parser + HelperPath tests (T10)
+internal/autotrack/darwin.go         CREATE  //go:build darwin — osascript/ioreg/winctx sampling (T10)
+internal/autotrack/stub.go           CREATE  //go:build !darwin — empty ReadSample (T10)
+internal/autotrack/run.go            CREATE  Run poll loop + signal flush (T11)
+internal/autotrack/run_test.go       CREATE  flush-on-signal test (T11)
+internal/activity/activity.go        CREATE  Segment, LoadDay (T6)
+internal/activity/activity_test.go   CREATE  tolerant-parse + round-trip tests (T6)
+internal/activity/calendar.go        CREATE  Event, CalendarFilter, parseEvents, LoadCalendar (T7)
+internal/activity/calendar_test.go   CREATE  parse + filter tests (T7)
+internal/tui/grid/paint.go           MODIFY  Cell.Act, ActKind, paintActivity (T8)
+internal/tui/grid/paint_test.go      MODIFY  paintActivity tests (T8)
+internal/tui/grid/model.go           MODIFY  ActivitySource, WithActivity, load/rebuild wiring (T9)
+internal/tui/grid/view.go            MODIFY  render activity lane, gCal accent, renderActCell (T9)
+internal/tui/grid/model_test.go      MODIFY  wiring tests (T9)
+cmd/ticktock/main.go                 MODIFY  autotrack subcommand, timeline WithActivity (T12)
+cmd/ticktock/main_test.go            CREATE  subcommand registration test (T12)
+native/winctx.swift                  CREATE  vendored from ticktock-old (T13)
+native/cal-events.swift              CREATE  vendored from ticktock-old (T13)
+Makefile                             MODIFY  helpers target (T13)
+deploy/autotrack.plist.template      CREATE  launchd template (T14)
+deploy/install-autotrack.sh          CREATE  render + bootout/bootstrap (T14)
+```
+
+Porting references (read-only, live outside this repo):
+- `/Users/kortum/Developer/Home/ticktock-old/daemon/autotrack.py` — the daemon being ported.
+- `/Users/kortum/Developer/Home/ticktock-old/bin/winctx.swift`, `/Users/kortum/Developer/Home/ticktock-old/bin/cal-events.swift` — helper sources to vendor.
+- `~/.local/share/ticktock/activity-2026-07-07.jsonl` — real log to match byte-for-byte. First line:
+  `{"start": "2026-07-07T08:59:19", "end": "2026-07-07T09:00:09", "secs": 49, "app": "Trace", "title": "", "url": "", "domain": "", "idle": false}`
+
+### Design decisions locked here (flagged deviations/interpretations)
+
+1. **Per-slot ACTIVITY precedence ranks.** The spec lists: calendar label at its start slot > active segment > idle > empty. It doesn't rank calendar *continuation* slots; we rank them just above empty so a meeting stays visible in otherwise-quiet slots but never hides captured activity: `CalLabel(4) > Active(3) > Idle(2) > CalCont(1) > Empty(0)`. Ties (two segments contesting a slot at equal rank) go to the later-painted segment, mirroring `paintLogged`'s "later-start wins".
+2. **Activity label fallback.** Spec says label is `<domain-or-title>`, but real logs have segments with empty title *and* domain (e.g. app "Trace"). Label falls back domain → title → app so those rows aren't blank.
+3. **Byte parity needs a hand-rolled encoder.** Go's `encoding/json` emits `{"start":"…"` (no spaces) and HTML-escapes `<>&`; Python's `json.dumps` emits `{"start": "…", ` and doesn't. `Record.EncodeLine` therefore formats the line manually with `json.Encoder.SetEscapeHTML(false)` for strings.
+4. **Config comparability.** Adding `Calendars` (`[]string` fields) makes `config.Config` non-comparable; the existing `c != Default()` test is updated to `reflect.DeepEqual` in Task 5.
+5. **launchd label.** The *live* agent is `com.humdrum.ticktock.autotrack` (checked `~/Library/LaunchAgents/`); the old repo template says `com.ticktock.autotrack`. Per spec ("label unchanged"), we use the live label `com.humdrum.ticktock.autotrack`.
+6. **Grid window is not expanded by activity.** `BuildGrid` keeps expanding only for tock entries; activity/calendar spans outside the window clamp to the first/last row via `Grid.RowOf` (the lane is display-only in this port).
+
+---
+
+## Task group A — writer, pure core (portable, no OS calls)
+
+### Task 1: `internal/config` — Tracking block
+
+**Files:**
+- Modify: `internal/config/config.go`
+- Test: `internal/config/config_test.go`
+
+**Interfaces:**
+- Consumes: existing `Config`, `Default()`, `LoadFrom(path string) Config`.
+- Produces: `type Tracking struct { PollSecs, IdleGraceSecs, MinSecs int }`; `Config.Tracking Tracking`; defaults `{5, 900, 15}`; JSON keys `tracking.poll_secs`, `tracking.idle_grace_secs`, `tracking.min_secs`. Later tasks call `cfg.Tracking` (run loop) — exact field names matter.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `internal/config/config_test.go`:
+
+```go
+func TestTrackingDefaults(t *testing.T) {
+	c := Default()
+	if c.Tracking.PollSecs != 5 || c.Tracking.IdleGraceSecs != 900 || c.Tracking.MinSecs != 15 {
+		t.Fatalf("bad tracking defaults: %+v", c.Tracking)
+	}
+}
+
+func TestLoadFromMergesTracking(t *testing.T) {
+	p := filepath.Join(t.TempDir(), "config.json")
+	if err := os.WriteFile(p, []byte(`{"tracking":{"poll_secs":10,"min_secs":30}}`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	c := LoadFrom(p)
+	if c.Tracking.PollSecs != 10 {
+		t.Errorf("poll_secs=%d, want 10", c.Tracking.PollSecs)
+	}
+	if c.Tracking.IdleGraceSecs != 900 { // absent key keeps default
+		t.Errorf("idle_grace_secs=%d, want 900", c.Tracking.IdleGraceSecs)
+	}
+	if c.Tracking.MinSecs != 30 {
+		t.Errorf("min_secs=%d, want 30", c.Tracking.MinSecs)
+	}
+}
+
+func TestLoadFromMalformedTrackingFallsBack(t *testing.T) {
+	p := filepath.Join(t.TempDir(), "config.json")
+	if err := os.WriteFile(p, []byte(`{"tracking":"nope"}`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	c := LoadFrom(p)
+	if c.Tracking != Default().Tracking {
+		t.Errorf("malformed tracking should keep defaults, got %+v", c.Tracking)
+	}
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/config -run 'TestTracking|TestLoadFromMergesTracking|TestLoadFromMalformedTracking' -v`
+Expected: FAIL (build error) — `c.Tracking undefined (type Config has no field or method Tracking)`
+
+- [ ] **Step 3: Implement**
+
+In `internal/config/config.go`, replace the `Config` struct and `Default` with:
+
+```go
+// Config controls grid presentation and daemon tuning. Zero value is not
+// valid; use Default.
+type Config struct {
+	SlotMinutes int
+	GridStart   string
+	GridEnd     string
+	Project     string
+	Tracking    Tracking
+}
+
+// Tracking tunes the autotrack daemon; read from config.json's `tracking`
+// object. Defaults 5, 900, 15 match the Python daemon's constants.
+type Tracking struct {
+	PollSecs      int // fine poll → tighter boundaries, catch short switches
+	IdleGraceSecs int // HID gap < this stays active; >= → idle
+	MinSecs       int // drop segments shorter than this
+}
+
+// Default returns the documented fallbacks used when config is absent.
+func Default() Config {
+	return Config{
+		SlotMinutes: 30, GridStart: "07:00", GridEnd: "21:00", Project: "",
+		Tracking: Tracking{PollSecs: 5, IdleGraceSecs: 900, MinSecs: 15},
+	}
+}
+```
+
+Replace the `raw` type with:
+
+```go
+// raw mirrors the on-disk JSON; pointers distinguish "absent" from "zero".
+type raw struct {
+	SlotMinutes *int         `json:"slot_minutes"`
+	GridStart   *string      `json:"grid_start"`
+	GridEnd     *string      `json:"grid_end"`
+	Project     *string      `json:"project"`
+	Tracking    *rawTracking `json:"tracking"`
+}
+
+type rawTracking struct {
+	PollSecs      *int `json:"poll_secs"`
+	IdleGraceSecs *int `json:"idle_grace_secs"`
+	MinSecs       *int `json:"min_secs"`
+}
+```
+
+In `LoadFrom`, the existing whole-file behavior already gives the right fallback for a malformed `tracking` value (unmarshal error ⇒ all defaults, matching the Python `load_tracking` which also falls back wholesale on any error). Only the merge is new — add, before the final `return c`:
+
+```go
+	if r.Tracking != nil {
+		if r.Tracking.PollSecs != nil {
+			c.Tracking.PollSecs = *r.Tracking.PollSecs
+		}
+		if r.Tracking.IdleGraceSecs != nil {
+			c.Tracking.IdleGraceSecs = *r.Tracking.IdleGraceSecs
+		}
+		if r.Tracking.MinSecs != nil {
+			c.Tracking.MinSecs = *r.Tracking.MinSecs
+		}
+	}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/config -v`
+Expected: PASS (all tests including pre-existing `TestDefault`, `TestLoadFromMissingFileReturnsDefaults`, `TestLoadFromMergesAndClampsSlot`)
+
+- [ ] **Step 5: Commit**
+
+```bash
+jj commit -m "feat(config): tracking block (poll/idle-grace/min secs) with python-parity defaults"
+```
+
+---
+
+### Task 2: `internal/autotrack/segment.go` — Ctx, Domain, Classify, CtxKey
+
+**Files:**
+- Create: `internal/autotrack/segment.go`
+- Test: `internal/autotrack/segment_test.go`
+
+**Interfaces:**
+- Consumes: nothing from this repo (stdlib only).
+- Produces (used by Tasks 3, 4, 10, 11, 12):
+  - `type Ctx struct { App, Title, URL, Domain string; Idle bool }` (json tags `app,title,url,domain,idle`)
+  - `type Segment struct { Ctx; Start, End time.Time }`
+  - `func Domain(rawURL string) string`
+  - `func Away(raw *Ctx) bool`
+  - `func Classify(idle int, raw *Ctx, idleGrace int, locked bool) *Ctx`
+  - `type Key struct { Valid, Idle bool; App, Rest string }` (comparable; zero Key = no context)
+  - `func CtxKey(c *Ctx) Key`
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `internal/autotrack/segment_test.go`:
+
+```go
+package autotrack
+
+import "testing"
+
+func TestDomain(t *testing.T) {
+	cases := []struct{ url, want string }{
+		{"https://www.github.com/foo", "github.com"},
+		{"https://mail.google.com/mail", "mail.google.com"},
+		{"HTTPS://WWW.Example.COM/x", "example.com"}, // lowercased, www stripped
+		{"", ""},
+		{"://not-a-url", ""},
+	}
+	for _, c := range cases {
+		if got := Domain(c.url); got != c.want {
+			t.Errorf("Domain(%q)=%q, want %q", c.url, got, c.want)
+		}
+	}
+}
+
+func TestClassify(t *testing.T) {
+	active := &Ctx{App: "Trace", Title: "doc"}
+	away := &Ctx{App: "loginwindow"}
+
+	if got := Classify(0, active, 900, true); got == nil || !got.Idle {
+		t.Errorf("locked ⇒ idle, got %+v", got)
+	}
+	if got := Classify(0, away, 900, false); got == nil || !got.Idle {
+		t.Errorf("away app ⇒ idle, got %+v", got)
+	}
+	if got := Classify(900, active, 900, false); got == nil || !got.Idle {
+		t.Errorf("idle >= grace ⇒ idle, got %+v", got)
+	}
+	if got := Classify(899, active, 900, false); got != active {
+		t.Errorf("present-but-passive under grace passes through, got %+v", got)
+	}
+	if got := Classify(0, nil, 900, false); got != nil {
+		t.Errorf("no foreground app, not locked ⇒ nil, got %+v", got)
+	}
+	if got := Classify(0, nil, 900, true); got == nil || !got.Idle {
+		t.Errorf("locked with nil ctx still idle, got %+v", got)
+	}
+}
+
+func TestCtxKey(t *testing.T) {
+	if CtxKey(nil) != (Key{}) {
+		t.Error("nil ctx should give zero key")
+	}
+	idle1, idle2 := CtxKey(&Ctx{Idle: true}), CtxKey(&Ctx{Idle: true, App: "x"})
+	if idle1 != idle2 {
+		t.Error("idle is its own key regardless of app")
+	}
+	browser := CtxKey(&Ctx{App: "Safari", Title: "GitHub", Domain: "github.com"})
+	if browser != (Key{Valid: true, App: "Safari", Rest: "github.com"}) {
+		t.Errorf("browser key should use domain, got %+v", browser)
+	}
+	editor := CtxKey(&Ctx{App: "Trace", Title: "notes.md"})
+	if editor != (Key{Valid: true, App: "Trace", Rest: "notes.md"}) {
+		t.Errorf("non-browser key should use title, got %+v", editor)
+	}
+	if CtxKey(&Ctx{App: "Trace", Title: "a"}) == CtxKey(&Ctx{App: "Trace", Title: "b"}) {
+		t.Error("different titles should be different activities")
+	}
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/autotrack -v`
+Expected: FAIL (build error) — `no Go files in .../internal/autotrack` or `undefined: Domain`
+
+- [ ] **Step 3: Implement**
+
+Create `internal/autotrack/segment.go`:
+
+```go
+// Package autotrack ports the Python passive activity daemon: poll the
+// frontmost app / window title / browser tab / idle state, coalesce
+// consecutive same-context samples into segments, and append them to a daily
+// staging log (~/.local/share/ticktock/activity-YYYY-MM-DD.jsonl). It writes
+// NOTHING to tock — suggest-then-confirm.
+package autotrack
+
+import (
+	"net/url"
+	"strings"
+	"time"
+)
+
+// Ctx is one classified foreground context. JSON tags match the Python
+// record keys (used by --once output and the winctx helper).
+type Ctx struct {
+	App    string `json:"app"`
+	Title  string `json:"title"`
+	URL    string `json:"url"`
+	Domain string `json:"domain"`
+	Idle   bool   `json:"idle"`
+}
+
+// Segment is an open (End zero) or closed span of one activity.
+type Segment struct {
+	Ctx
+	Start, End time.Time
+}
+
+// awayApps: frontmost one of these ⇒ locked/screensaver ⇒ away.
+var awayApps = map[string]bool{"loginwindow": true, "ScreenSaverEngine": true}
+
+// Away reports whether the raw context is the lock/screensaver window.
+func Away(raw *Ctx) bool { return raw != nil && awayApps[raw.App] }
+
+// Domain extracts the URL's hostname, lowercased, with "www." stripped —
+// parity with Python's urlparse(url).hostname.replace("www.", "").
+func Domain(rawURL string) string {
+	if rawURL == "" {
+		return ""
+	}
+	u, err := url.Parse(rawURL)
+	if err != nil {
+		return ""
+	}
+	return strings.ReplaceAll(strings.ToLower(u.Hostname()), "www.", "")
+}
+
+// Classify maps (idle seconds, foreground context) to the segment context to
+// record. Locked or an away-app ⇒ idle immediately; idle >= idleGrace ⇒ idle;
+// otherwise the active context passes through (present-but-passive, e.g.
+// watching an agent run or being on a call, counts as active). nil when there
+// is nothing to record.
+func Classify(idle int, raw *Ctx, idleGrace int, locked bool) *Ctx {
+	if locked || Away(raw) {
+		return &Ctx{Idle: true}
+	}
+	if idle >= idleGrace {
+		return &Ctx{Idle: true}
+	}
+	return raw
+}
+
+// Key identifies "the same activity". Idle is its own key; active is
+// (app, domain-or-title). The zero Key means no context. Comparable, so the
+// segmentation loop can use != directly.
+type Key struct {
+	Valid bool
+	Idle  bool
+	App   string
+	Rest  string // domain, or title for non-browsers
+}
+
+// CtxKey computes the identity key for a classified context.
+func CtxKey(c *Ctx) Key {
+	if c == nil {
+		return Key{}
+	}
+	if c.Idle {
+		return Key{Valid: true, Idle: true}
+	}
+	rest := c.Domain
+	if rest == "" {
+		rest = c.Title
+	}
+	return Key{Valid: true, App: c.App, Rest: rest}
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/autotrack -v`
+Expected: PASS — `TestDomain`, `TestClassify`, `TestCtxKey`
+
+- [ ] **Step 5: Commit**
+
+```bash
+jj commit -m "feat(autotrack): pure context types — Ctx, Domain, Classify, CtxKey"
+```
+
+---
+
+### Task 3: `internal/autotrack/segment.go` — Step state machine
+
+**Files:**
+- Modify: `internal/autotrack/segment.go` (append `Step`)
+- Test: `internal/autotrack/segment_test.go` (append tests)
+
+**Interfaces:**
+- Consumes: `Ctx`, `Segment`, `Key`, `CtxKey` from Task 2.
+- Produces: `func Step(cur *Segment, curKey Key, now time.Time, idle int, ctx *Ctx, grace int, hard bool) (*Segment, Key, *Segment)` — returns (new open segment, its key, emitted closed segment or nil). Used by Task 11's `Run`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `internal/autotrack/segment_test.go` (add `"time"` to its imports):
+
+```go
+func tt3(h, m, s int) time.Time { return time.Date(2026, 7, 7, h, m, s, 0, time.Local) }
+
+func TestStepSameContextExtends(t *testing.T) {
+	ctx := &Ctx{App: "Trace", Title: "doc"}
+	cur := &Segment{Ctx: *ctx, Start: tt3(9, 0, 0)}
+	now := tt3(9, 0, 5)
+	got, key, emitted := Step(cur, CtxKey(ctx), now, 0, ctx, 900, false)
+	if emitted != nil {
+		t.Fatalf("same context should not emit, got %+v", emitted)
+	}
+	if got == nil || !got.End.Equal(now) || !got.Start.Equal(tt3(9, 0, 0)) {
+		t.Fatalf("open segment should extend to now, got %+v", got)
+	}
+	if key != CtxKey(ctx) {
+		t.Fatalf("key should be unchanged")
+	}
+}
+
+func TestStepContextChangeEmitsAndOpens(t *testing.T) {
+	a := &Ctx{App: "Trace", Title: "doc"}
+	b := &Ctx{App: "Safari", Title: "GitHub", Domain: "github.com"}
+	cur := &Segment{Ctx: *a, Start: tt3(9, 0, 0)}
+	now := tt3(9, 5, 0)
+	got, key, emitted := Step(cur, CtxKey(a), now, 0, b, 900, false)
+	if emitted == nil || emitted.App != "Trace" || !emitted.End.Equal(now) {
+		t.Fatalf("context change should close old segment at now, got %+v", emitted)
+	}
+	if got == nil || got.App != "Safari" || !got.Start.Equal(now) {
+		t.Fatalf("new segment should open at now, got %+v", got)
+	}
+	if key != CtxKey(b) {
+		t.Fatalf("key should follow new ctx")
+	}
+}
+
+func TestStepSoftIdleCreditsGrace(t *testing.T) {
+	a := &Ctx{App: "Trace", Title: "doc"}
+	cur := &Segment{Ctx: *a, Start: tt3(9, 0, 0)}
+	now := tt3(10, 0, 0)
+	// 1000s HID gap, 900s grace, soft ⇒ boundary = now - (1000-900) = now-100s
+	got, _, emitted := Step(cur, CtxKey(a), now, 1000, &Ctx{Idle: true}, 900, false)
+	wantBoundary := now.Add(-100 * time.Second)
+	if emitted == nil || !emitted.End.Equal(wantBoundary) {
+		t.Fatalf("soft idle should close at now-(idle-grace), got %+v", emitted)
+	}
+	if got == nil || !got.Idle || !got.Start.Equal(wantBoundary) {
+		t.Fatalf("idle segment should open at boundary, got %+v", got)
+	}
+}
+
+func TestStepHardIdleSnapsToLastInputAndClamps(t *testing.T) {
+	a := &Ctx{App: "Trace", Title: "doc"}
+	// segment opened 500s ago; idle says last input was 1000s ago ⇒ clamp to Start
+	now := tt3(10, 0, 0)
+	cur := &Segment{Ctx: *a, Start: now.Add(-500 * time.Second)}
+	got, _, emitted := Step(cur, CtxKey(a), now, 1000, &Ctx{Idle: true}, 900, true)
+	if emitted == nil || !emitted.End.Equal(cur.Start) {
+		t.Fatalf("hard-idle boundary must clamp to cur.Start, got %+v", emitted)
+	}
+	if got == nil || !got.Start.Equal(cur.Start) {
+		t.Fatalf("idle segment starts at clamped boundary, got %+v", got)
+	}
+}
+
+func TestStepHardIdleUnclamped(t *testing.T) {
+	a := &Ctx{App: "Trace", Title: "doc"}
+	now := tt3(10, 0, 0)
+	cur := &Segment{Ctx: *a, Start: now.Add(-2000 * time.Second)}
+	// hard idle, 1000s since last input ⇒ boundary = now-1000s (inside segment)
+	_, _, emitted := Step(cur, CtxKey(a), now, 1000, &Ctx{Idle: true}, 900, true)
+	if emitted == nil || !emitted.End.Equal(now.Add(-1000*time.Second)) {
+		t.Fatalf("hard idle should close at now-idle, got %+v", emitted)
+	}
+}
+
+func TestStepNilCtxClosesWithoutOpening(t *testing.T) {
+	a := &Ctx{App: "Trace", Title: "doc"}
+	cur := &Segment{Ctx: *a, Start: tt3(9, 0, 0)}
+	now := tt3(9, 5, 0)
+	got, key, emitted := Step(cur, CtxKey(a), now, 0, nil, 900, false)
+	if emitted == nil || !emitted.End.Equal(now) {
+		t.Fatalf("nil ctx should close open segment at now, got %+v", emitted)
+	}
+	if got != nil || key != (Key{}) {
+		t.Fatalf("nothing should be open after nil ctx, got %+v / %+v", got, key)
+	}
+}
+
+func TestStepFromNothingOpensWithoutEmitting(t *testing.T) {
+	a := &Ctx{App: "Trace", Title: "doc"}
+	now := tt3(9, 0, 0)
+	got, key, emitted := Step(nil, Key{}, now, 0, a, 900, false)
+	if emitted != nil {
+		t.Fatalf("nothing to close, got %+v", emitted)
+	}
+	if got == nil || got.App != "Trace" || !got.Start.Equal(now) || key != CtxKey(a) {
+		t.Fatalf("should open new segment at now, got %+v", got)
+	}
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/autotrack -v`
+Expected: FAIL (build error) — `undefined: Step`
+
+- [ ] **Step 3: Implement**
+
+Append to `internal/autotrack/segment.go`:
+
+```go
+// Step is the pure segmentation state machine. Given the open segment
+// (cur/curKey) and a fresh sample (now, idle seconds, classified ctx), it
+// returns (cur, curKey, emitted) where emitted is a finished segment to
+// persist, or nil.
+//
+// A context change closes the open segment and opens a new one; the same
+// context just extends the open segment's end.
+//
+// Crossing into idle anchors the transition boundary:
+//   - hard idle (screen locked / away app): now - idle — the whole gap is
+//     away, so the active tail is trimmed to the last real input.
+//   - soft idle (HID gap alone): now - (idle - grace) — the grace window is
+//     credited to the active app, so present-but-passive work keeps its time.
+//
+// The boundary is clamped to [cur.Start, now] so a segment never ends before
+// it began nor in the future.
+func Step(cur *Segment, curKey Key, now time.Time, idle int, ctx *Ctx, grace int, hard bool) (*Segment, Key, *Segment) {
+	key := CtxKey(ctx)
+	boundary := now
+	if ctx != nil && ctx.Idle {
+		offset := idle
+		if !hard {
+			offset = idle - grace
+			if offset < 0 {
+				offset = 0
+			}
+		}
+		boundary = now.Add(-time.Duration(offset) * time.Second)
+	}
+	var emitted *Segment
+	if key != curKey {
+		if cur != nil {
+			if boundary.After(now) {
+				boundary = now
+			}
+			if boundary.Before(cur.Start) {
+				boundary = cur.Start
+			}
+			closed := *cur
+			closed.End = boundary
+			emitted = &closed
+		}
+		if ctx != nil {
+			cur = &Segment{Ctx: *ctx, Start: boundary}
+		} else {
+			cur = nil
+		}
+		curKey = key
+	} else if cur != nil {
+		next := *cur
+		next.End = now
+		cur = &next
+	}
+	return cur, curKey, emitted
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/autotrack -v`
+Expected: PASS — all `TestStep*` plus Task 2 tests
+
+- [ ] **Step 5: Commit**
+
+```bash
+jj commit -m "feat(autotrack): Step segmentation state machine with soft/hard idle anchoring"
+```
+
+---
+
+### Task 4: `internal/autotrack/log.go` — Record encoding + WriteSegment
+
+**Files:**
+- Create: `internal/autotrack/log.go`
+- Test: `internal/autotrack/log_test.go`
+
+**Interfaces:**
+- Consumes: `Segment` from Task 2.
+- Produces (used by Tasks 6, 11):
+  - `type Record struct { Start, End time.Time; Secs int; App, Title, URL, Domain string; Idle bool }`
+  - `func (r Record) EncodeLine() string` — the exact Python byte shape.
+  - `func LogPath(dir string, when time.Time) string` — `<dir>/activity-YYYY-MM-DD.jsonl`.
+  - `func WriteSegment(dir string, seg *Segment, minSecs int) error` — drops sub-minSecs, appends one line, creates dir.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `internal/autotrack/log_test.go`:
+
+```go
+package autotrack
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+)
+
+// TestEncodeLineByteParityWithPython pins the exact byte shape of a real line
+// from ~/.local/share/ticktock/activity-2026-07-07.jsonl written by the
+// Python daemon (json.dumps ", "/": " separators, key order, no escaping).
+func TestEncodeLineByteParityWithPython(t *testing.T) {
+	r := Record{
+		Start: time.Date(2026, 7, 7, 8, 59, 19, 0, time.Local),
+		End:   time.Date(2026, 7, 7, 9, 0, 9, 0, time.Local),
+		Secs:  49, App: "Trace",
+	}
+	want := `{"start": "2026-07-07T08:59:19", "end": "2026-07-07T09:00:09", "secs": 49, "app": "Trace", "title": "", "url": "", "domain": "", "idle": false}`
+	if got := r.EncodeLine(); got != want {
+		t.Errorf("byte mismatch:\n got %s\nwant %s", got, want)
+	}
+}
+
+func TestEncodeLineNoHTMLEscaping(t *testing.T) {
+	r := Record{
+		Start: time.Date(2026, 7, 7, 10, 0, 0, 0, time.Local),
+		End:   time.Date(2026, 7, 7, 10, 1, 0, 0, time.Local),
+		Secs:  60, App: "Safari", Title: `R&D <notes> — café "x"`,
+		URL: "https://example.com/a?b=1&c=2", Domain: "example.com",
+	}
+	got := r.EncodeLine()
+	if !strings.Contains(got, `"title": "R&D <notes> — café \"x\""`) {
+		t.Errorf("ensure_ascii=False parity broken: %s", got)
+	}
+	if !strings.Contains(got, `"url": "https://example.com/a?b=1&c=2"`) {
+		t.Errorf("url should not be HTML-escaped: %s", got)
+	}
+}
+
+func TestWriteSegmentDropsShortAndAppends(t *testing.T) {
+	dir := filepath.Join(t.TempDir(), "data") // must be created by WriteSegment
+	start := time.Date(2026, 7, 7, 9, 0, 0, 0, time.Local)
+
+	short := &Segment{Ctx: Ctx{App: "Blip"}, Start: start, End: start.Add(10 * time.Second)}
+	if err := WriteSegment(dir, short, 15); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := os.Stat(LogPath(dir, start)); !os.IsNotExist(err) {
+		t.Fatal("sub-minSecs segment must be dropped (no file written)")
+	}
+
+	long := &Segment{Ctx: Ctx{App: "Trace", Title: "doc"}, Start: start, End: start.Add(50 * time.Second)}
+	if err := WriteSegment(dir, long, 15); err != nil {
+		t.Fatal(err)
+	}
+	if err := WriteSegment(dir, long, 15); err != nil { // append, not truncate
+		t.Fatal(err)
+	}
+	b, err := os.ReadFile(LogPath(dir, start))
+	if err != nil {
+		t.Fatal(err)
+	}
+	lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n")
+	if len(lines) != 2 {
+		t.Fatalf("want 2 appended lines, got %d: %q", len(lines), string(b))
+	}
+	want := `{"start": "2026-07-07T09:00:00", "end": "2026-07-07T09:00:50", "secs": 50, "app": "Trace", "title": "doc", "url": "", "domain": "", "idle": false}`
+	if lines[0] != want {
+		t.Errorf("line mismatch:\n got %s\nwant %s", lines[0], want)
+	}
+	if err := WriteSegment(dir, nil, 15); err != nil {
+		t.Errorf("nil segment must be a no-op, got %v", err)
+	}
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/autotrack -v`
+Expected: FAIL (build error) — `undefined: Record`
+
+- [ ] **Step 3: Implement**
+
+Create `internal/autotrack/log.go`:
+
+```go
+package autotrack
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"time"
+)
+
+// stampLayout matches Python isoformat(timespec="seconds"): local time, no
+// offset, e.g. 2026-07-07T08:59:19.
+const stampLayout = "2006-01-02T15:04:05"
+
+// Record is one on-disk activity line: {start, end, secs, app, title, url,
+// domain, idle} — byte-compatible with the Python daemon's
+// json.dumps(..., ensure_ascii=False) output.
+type Record struct {
+	Start, End              time.Time
+	Secs                    int
+	App, Title, URL, Domain string
+	Idle                    bool
+}
+
+// jsonStr encodes s as a JSON string without HTML escaping (parity with
+// Python, which writes <, >, & and non-ASCII raw).
+func jsonStr(s string) string {
+	var b bytes.Buffer
+	enc := json.NewEncoder(&b)
+	enc.SetEscapeHTML(false)
+	_ = enc.Encode(s) // encoding a plain string cannot fail
+	return string(bytes.TrimRight(b.Bytes(), "\n"))
+}
+
+// EncodeLine renders the record exactly as the Python daemon did, including
+// json.dumps' ", " / ": " separators and key order.
+func (r Record) EncodeLine() string {
+	return fmt.Sprintf(
+		`{"start": %q, "end": %q, "secs": %d, "app": %s, "title": %s, "url": %s, "domain": %s, "idle": %t}`,
+		r.Start.Format(stampLayout), r.End.Format(stampLayout), r.Secs,
+		jsonStr(r.App), jsonStr(r.Title), jsonStr(r.URL), jsonStr(r.Domain), r.Idle)
+}
+
+// LogPath is the daily staging log for the given moment under dir.
+func LogPath(dir string, when time.Time) string {
+	return filepath.Join(dir, "activity-"+when.Format("2006-01-02")+".jsonl")
+}
+
+// WriteSegment appends seg to its start-day's log under dir, creating dir if
+// needed. Segments shorter than minSecs are dropped as noise. nil is a no-op.
+func WriteSegment(dir string, seg *Segment, minSecs int) error {
+	if seg == nil {
+		return nil
+	}
+	secs := seg.End.Sub(seg.Start).Seconds()
+	if secs < float64(minSecs) {
+		return nil
+	}
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		return err
+	}
+	rec := Record{
+		Start: seg.Start, End: seg.End, Secs: int(secs),
+		App: seg.App, Title: seg.Title, URL: seg.URL, Domain: seg.Domain, Idle: seg.Idle,
+	}
+	f, err := os.OpenFile(LogPath(dir, seg.Start), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+	_, err = f.WriteString(rec.EncodeLine() + "\n")
+	return err
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/autotrack -v`
+Expected: PASS — all autotrack tests
+
+- [ ] **Step 5: Commit**
+
+```bash
+jj commit -m "feat(autotrack): byte-compatible Record encoding + WriteSegment daily log appender"
+```
+
+---
+
+## Task group B — reader, pure core (portable)
+
+### Task 5: `internal/config` — Calendars block
+
+**Files:**
+- Modify: `internal/config/config.go`
+- Test: `internal/config/config_test.go` (new tests + `reflect.DeepEqual` fix)
+
+**Interfaces:**
+- Consumes: `Config`, `raw` from Task 1.
+- Produces: `type Calendars struct { Show, Hide []string }`; `Config.Calendars Calendars`; JSON `calendars.show` / `calendars.hide`. Task 12 builds `activity.CalendarFilter{Show: cfg.Calendars.Show, Hide: cfg.Calendars.Hide}` from this.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `internal/config/config_test.go`:
+
+```go
+func TestLoadFromMergesCalendars(t *testing.T) {
+	p := filepath.Join(t.TempDir(), "config.json")
+	if err := os.WriteFile(p, []byte(`{"calendars":{"show":["Work","Focus"],"hide":["Holidays"]}}`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	c := LoadFrom(p)
+	if !reflect.DeepEqual(c.Calendars.Show, []string{"Work", "Focus"}) {
+		t.Errorf("show=%v, want [Work Focus]", c.Calendars.Show)
+	}
+	if !reflect.DeepEqual(c.Calendars.Hide, []string{"Holidays"}) {
+		t.Errorf("hide=%v, want [Holidays]", c.Calendars.Hide)
+	}
+}
+
+func TestCalendarsDefaultEmpty(t *testing.T) {
+	c := Default()
+	if len(c.Calendars.Show) != 0 || len(c.Calendars.Hide) != 0 {
+		t.Errorf("default calendars should be empty (all pass), got %+v", c.Calendars)
+	}
+}
+```
+
+Also update the pre-existing equality test — `Config` becomes non-comparable once it holds slices. In `TestLoadFromMissingFileReturnsDefaults`, replace:
+
+```go
+	if c != Default() {
+```
+
+with:
+
+```go
+	if !reflect.DeepEqual(c, Default()) {
+```
+
+and add `"reflect"` to the test file's imports.
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/config -v`
+Expected: FAIL (build error) — `c.Calendars undefined (type Config has no field or method Calendars)`
+
+- [ ] **Step 3: Implement**
+
+In `internal/config/config.go`, add the `Calendars` field to `Config` (after `Tracking Tracking`):
+
+```go
+	Calendars   Calendars
+```
+
+Add the type below `Tracking`:
+
+```go
+// Calendars selects which calendar names appear in the grid's ACTIVITY lane.
+// Non-empty Show is an allowlist (case-insensitive exact match on calendar
+// name); Hide is a blocklist applied after Show. Both empty ⇒ all pass.
+type Calendars struct {
+	Show []string
+	Hide []string
+}
+```
+
+Add to `raw`:
+
+```go
+	Calendars   *rawCalendars `json:"calendars"`
+```
+
+and the mirror type:
+
+```go
+type rawCalendars struct {
+	Show []string `json:"show"`
+	Hide []string `json:"hide"`
+}
+```
+
+In `LoadFrom`, after the Tracking merge block, add:
+
+```go
+	if r.Calendars != nil {
+		c.Calendars.Show = r.Calendars.Show
+		c.Calendars.Hide = r.Calendars.Hide
+	}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/config -v`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+jj commit -m "feat(config): calendars show/hide block for timeline calendar filtering"
+```
+
+---
+
+### Task 6: `internal/activity` — Segment + LoadDay
+
+**Files:**
+- Create: `internal/activity/activity.go`
+- Test: `internal/activity/activity_test.go`
+
+**Interfaces:**
+- Consumes: `autotrack.WriteSegment`, `autotrack.Segment`, `autotrack.Ctx` (round-trip test only).
+- Produces (used by Tasks 8, 9, 12):
+  - `type Segment struct { Start, End time.Time; App, Title, URL, Domain string; Idle bool }`
+  - `func LoadDay(dataDir, date string) ([]Segment, error)` — tolerant parse; missing file or empty dataDir ⇒ `nil, nil`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `internal/activity/activity_test.go`:
+
+```go
+package activity
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"ticktock/internal/autotrack"
+)
+
+func TestLoadDayParsesAndSkipsMalformed(t *testing.T) {
+	dir := t.TempDir()
+	lines := `{"start": "2026-07-07T08:59:19", "end": "2026-07-07T09:00:09", "secs": 49, "app": "Trace", "title": "", "url": "", "domain": "", "idle": false}
+this line is not json
+{"start": "not-a-time", "end": "2026-07-07T09:10:00", "secs": 1, "app": "Bad", "title": "", "url": "", "domain": "", "idle": false}
+{"start": "2026-07-07T09:30:00", "end": "2026-07-07T10:00:00", "secs": 1800, "app": "", "title": "", "url": "", "domain": "", "idle": true}
+`
+	if err := os.WriteFile(filepath.Join(dir, "activity-2026-07-07.jsonl"), []byte(lines), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	segs, err := LoadDay(dir, "2026-07-07")
+	if err != nil {
+		t.Fatalf("tolerant parse must not error: %v", err)
+	}
+	if len(segs) != 2 {
+		t.Fatalf("want 2 valid segments (malformed skipped), got %d", len(segs))
+	}
+	want := time.Date(2026, 7, 7, 8, 59, 19, 0, time.Local)
+	if segs[0].App != "Trace" || !segs[0].Start.Equal(want) {
+		t.Errorf("seg0 = %+v", segs[0])
+	}
+	if !segs[1].Idle {
+		t.Errorf("seg1 should be idle: %+v", segs[1])
+	}
+}
+
+func TestLoadDayMissingFileIsEmptyNoError(t *testing.T) {
+	segs, err := LoadDay(t.TempDir(), "2026-01-01")
+	if err != nil || len(segs) != 0 {
+		t.Fatalf("missing file ⇒ empty, nil; got %v, %v", segs, err)
+	}
+	segs, err = LoadDay("", "2026-01-01")
+	if err != nil || len(segs) != 0 {
+		t.Fatalf("empty dataDir ⇒ empty, nil; got %v, %v", segs, err)
+	}
+}
+
+// Round-trip: what the writer persists, the reader loads back — the two
+// halves of the port agree on the byte shape.
+func TestRoundTripWithWriter(t *testing.T) {
+	dir := t.TempDir()
+	start := time.Date(2026, 7, 7, 9, 0, 0, 0, time.Local)
+	seg := &autotrack.Segment{
+		Ctx:   autotrack.Ctx{App: "Safari", Title: "GitHub", URL: "https://github.com/x", Domain: "github.com"},
+		Start: start, End: start.Add(120 * time.Second),
+	}
+	if err := autotrack.WriteSegment(dir, seg, 15); err != nil {
+		t.Fatal(err)
+	}
+	segs, err := LoadDay(dir, "2026-07-07")
+	if err != nil || len(segs) != 1 {
+		t.Fatalf("round trip failed: %v, %v", segs, err)
+	}
+	got := segs[0]
+	if got.App != "Safari" || got.Domain != "github.com" || got.URL != "https://github.com/x" ||
+		!got.Start.Equal(start) || !got.End.Equal(start.Add(120*time.Second)) || got.Idle {
+		t.Errorf("round-trip mismatch: %+v", got)
+	}
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/activity -v`
+Expected: FAIL (build error) — `no Go files in .../internal/activity` or `undefined: LoadDay`
+
+- [ ] **Step 3: Implement**
+
+Create `internal/activity/activity.go`:
+
+```go
+// Package activity reads the autotrack staging log and calendar events for
+// display in the grid's ACTIVITY column. All parsing is tolerant: a bad line,
+// missing file, or missing helper never fails the view.
+package activity
+
+import (
+	"bufio"
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"time"
+)
+
+// Segment is one recorded activity span from the daily jsonl log.
+type Segment struct {
+	Start, End              time.Time
+	App, Title, URL, Domain string
+	Idle                    bool
+}
+
+// rawSegment mirrors one jsonl line; timestamps stay strings for tolerant
+// parsing (secs is derivable and unused by the reader).
+type rawSegment struct {
+	Start  string `json:"start"`
+	End    string `json:"end"`
+	App    string `json:"app"`
+	Title  string `json:"title"`
+	URL    string `json:"url"`
+	Domain string `json:"domain"`
+	Idle   bool   `json:"idle"`
+}
+
+// stampLayout matches the daemon's isoformat(timespec="seconds") local stamps.
+const stampLayout = "2006-01-02T15:04:05"
+
+// LoadDay reads activity-<date>.jsonl under dataDir. Malformed lines are
+// skipped — one bad row never fails the view. A missing file (or empty
+// dataDir) yields an empty slice with no error.
+func LoadDay(dataDir, date string) ([]Segment, error) {
+	if dataDir == "" {
+		return nil, nil
+	}
+	f, err := os.Open(filepath.Join(dataDir, "activity-"+date+".jsonl"))
+	if err != nil {
+		return nil, nil
+	}
+	defer f.Close()
+	var segs []Segment
+	sc := bufio.NewScanner(f)
+	sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) // long window titles/URLs
+	for sc.Scan() {
+		var r rawSegment
+		if err := json.Unmarshal(sc.Bytes(), &r); err != nil {
+			continue
+		}
+		start, err := time.ParseInLocation(stampLayout, r.Start, time.Local)
+		if err != nil {
+			continue
+		}
+		end, err := time.ParseInLocation(stampLayout, r.End, time.Local)
+		if err != nil {
+			continue
+		}
+		segs = append(segs, Segment{
+			Start: start, End: end,
+			App: r.App, Title: r.Title, URL: r.URL, Domain: r.Domain, Idle: r.Idle,
+		})
+	}
+	return segs, nil
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/activity -v`
+Expected: PASS — `TestLoadDayParsesAndSkipsMalformed`, `TestLoadDayMissingFileIsEmptyNoError`, `TestRoundTripWithWriter`
+
+- [ ] **Step 5: Commit**
+
+```bash
+jj commit -m "feat(activity): tolerant LoadDay jsonl reader with writer round-trip parity"
+```
+
+---
+
+### Task 7: `internal/activity` — Event, CalendarFilter, LoadCalendar
+
+**Files:**
+- Create: `internal/activity/calendar.go`
+- Test: `internal/activity/calendar_test.go`
+
+**Interfaces:**
+- Consumes: nothing new.
+- Produces (used by Tasks 8, 9, 12):
+  - `type Event struct { Start, End time.Time; Title, Calendar string }`
+  - `type CalendarFilter struct { Show, Hide []string }` with `func (f CalendarFilter) Allow(name string) bool`
+  - `func LoadCalendar(bin, date string, filter CalendarFilter) ([]Event, error)` — runs `<bin> <date>`; missing/denied/bad output ⇒ `nil, nil`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `internal/activity/calendar_test.go`:
+
+```go
+package activity
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+	"time"
+)
+
+// calSample matches the cal-events helper's output shape (ISO-8601 with
+// offset, one object per non-all-day event, tagged with its calendar name).
+const calSample = `[
+  {"title": "Standup", "start": "2026-07-07T09:00:00-05:00", "end": "2026-07-07T09:15:00-05:00", "calendar": "Archer"},
+  {"title": "Dentist", "start": "2026-07-07T13:00:00-05:00", "end": "2026-07-07T14:00:00-05:00", "calendar": "Personal"},
+  {"title": "Focus block", "start": "2026-07-07T15:00:00-05:00", "end": "2026-07-07T17:00:00-05:00", "calendar": "Focus"}
+]`
+
+func TestParseEventsSample(t *testing.T) {
+	events := parseEvents([]byte(calSample), CalendarFilter{})
+	if len(events) != 3 {
+		t.Fatalf("want 3 events, got %d", len(events))
+	}
+	if events[0].Title != "Standup" || events[0].Calendar != "Archer" {
+		t.Errorf("event0 = %+v", events[0])
+	}
+	wantStart, _ := time.Parse(time.RFC3339, "2026-07-07T09:00:00-05:00")
+	if !events[0].Start.Equal(wantStart) {
+		t.Errorf("start = %v, want %v", events[0].Start, wantStart)
+	}
+	if parseEvents([]byte("not json"), CalendarFilter{}) != nil {
+		t.Error("bad output ⇒ empty slice")
+	}
+}
+
+func TestCalendarFilterAllow(t *testing.T) {
+	cases := []struct {
+		name   string
+		filter CalendarFilter
+		cal    string
+		want   bool
+	}{
+		{"empty filter passes all", CalendarFilter{}, "Personal", true},
+		{"show allowlist keeps listed", CalendarFilter{Show: []string{"Archer"}}, "Archer", true},
+		{"show allowlist drops unlisted", CalendarFilter{Show: []string{"Archer"}}, "Personal", false},
+		{"show is case-insensitive", CalendarFilter{Show: []string{"archer"}}, "Archer", true},
+		{"hide drops listed", CalendarFilter{Hide: []string{"Holidays"}}, "Holidays", false},
+		{"hide is case-insensitive", CalendarFilter{Hide: []string{"holidays"}}, "Holidays", false},
+		{"hide applies after show", CalendarFilter{Show: []string{"Archer", "Focus"}, Hide: []string{"Focus"}}, "Focus", false},
+		{"show+hide keeps the rest", CalendarFilter{Show: []string{"Archer", "Focus"}, Hide: []string{"Focus"}}, "Archer", true},
+	}
+	for _, c := range cases {
+		if got := c.filter.Allow(c.cal); got != c.want {
+			t.Errorf("%s: Allow(%q)=%v, want %v", c.name, c.cal, got, c.want)
+		}
+	}
+}
+
+func TestParseEventsAppliesFilter(t *testing.T) {
+	events := parseEvents([]byte(calSample), CalendarFilter{Show: []string{"Archer", "Focus"}})
+	if len(events) != 2 || events[0].Calendar != "Archer" || events[1].Calendar != "Focus" {
+		t.Fatalf("filtered = %+v", events)
+	}
+}
+
+func TestLoadCalendarDegradesGracefully(t *testing.T) {
+	if ev, err := LoadCalendar("", "2026-07-07", CalendarFilter{}); err != nil || ev != nil {
+		t.Errorf("empty bin ⇒ nil, nil; got %v, %v", ev, err)
+	}
+	if ev, err := LoadCalendar("/nonexistent/cal-events", "2026-07-07", CalendarFilter{}); err != nil || ev != nil {
+		t.Errorf("missing helper ⇒ nil, nil; got %v, %v", ev, err)
+	}
+}
+
+func TestLoadCalendarRunsHelper(t *testing.T) {
+	// Stand in for cal-events with a shell script that echoes the sample.
+	bin := filepath.Join(t.TempDir(), "cal-events")
+	script := "#!/bin/sh\ncat <<'EOF'\n" + calSample + "\nEOF\n"
+	if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	events, err := LoadCalendar(bin, "2026-07-07", CalendarFilter{Hide: []string{"Personal"}})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(events) != 2 {
+		t.Fatalf("want 2 events after hide, got %+v", events)
+	}
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/activity -v`
+Expected: FAIL (build error) — `undefined: parseEvents`
+
+- [ ] **Step 3: Implement**
+
+Create `internal/activity/calendar.go`:
+
+```go
+package activity
+
+import (
+	"context"
+	"encoding/json"
+	"os/exec"
+	"strings"
+	"time"
+)
+
+// Event is one calendar event from the cal-events helper.
+type Event struct {
+	Start, End      time.Time
+	Title, Calendar string
+}
+
+// CalendarFilter selects calendars by name, case-insensitively. Non-empty
+// Show is an allowlist; Hide is a blocklist applied after Show. Zero value
+// passes everything.
+type CalendarFilter struct {
+	Show []string
+	Hide []string
+}
+
+// Allow reports whether events from the named calendar should appear.
+func (f CalendarFilter) Allow(name string) bool {
+	n := strings.ToLower(name)
+	if len(f.Show) > 0 {
+		ok := false
+		for _, s := range f.Show {
+			if strings.ToLower(s) == n {
+				ok = true
+				break
+			}
+		}
+		if !ok {
+			return false
+		}
+	}
+	for _, h := range f.Hide {
+		if strings.ToLower(h) == n {
+			return false
+		}
+	}
+	return true
+}
+
+// rawEvent mirrors one cal-events JSON object.
+type rawEvent struct {
+	Title    string `json:"title"`
+	Start    string `json:"start"`
+	End      string `json:"end"`
+	Calendar string `json:"calendar"`
+}
+
+// parseEvents decodes the cal-events JSON array, keeping events that pass
+// filter. Any decode error yields an empty slice (tolerant).
+func parseEvents(b []byte, filter CalendarFilter) []Event {
+	var raws []rawEvent
+	if err := json.Unmarshal(b, &raws); err != nil {
+		return nil
+	}
+	var events []Event
+	for _, r := range raws {
+		start, err := time.Parse(time.RFC3339, r.Start)
+		if err != nil {
+			continue
+		}
+		end, err := time.Parse(time.RFC3339, r.End)
+		if err != nil {
+			continue
+		}
+		if !filter.Allow(r.Calendar) {
+			continue
+		}
+		events = append(events, Event{Start: start, End: end, Title: r.Title, Calendar: r.Calendar})
+	}
+	return events
+}
+
+// LoadCalendar runs the cal-events helper for date (YYYY-MM-DD) and filters
+// the result. Missing helper, denied access, or bad output ⇒ empty slice, no
+// error — the timeline still renders without calendar data.
+func LoadCalendar(bin, date string, filter CalendarFilter) ([]Event, error) {
+	if bin == "" {
+		return nil, nil
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+	out, err := exec.CommandContext(ctx, bin, date).Output()
+	if err != nil {
+		return nil, nil
+	}
+	return parseEvents(out, filter), nil
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/activity -v`
+Expected: PASS — all calendar + activity tests
+
+- [ ] **Step 5: Commit**
+
+```bash
+jj commit -m "feat(activity): cal-events loader with show/hide CalendarFilter"
+```
+
+---
+
+### Task 8: `internal/tui/grid` — Cell.Act + paintActivity
+
+**Files:**
+- Modify: `internal/tui/grid/paint.go`
+- Test: `internal/tui/grid/paint_test.go`
+
+**Interfaces:**
+- Consumes: `Grid` (`RowOf`, `Rows`), `minuteOfDay`, `Cell`, `CellKind` (existing); `activity.Segment`, `activity.Event` (Tasks 6-7).
+- Produces (used by Task 9):
+  - `type ActKind int` with `ActNone, ActCalendar, ActActive, ActIdle`
+  - `Cell` gains field `Act ActKind` (zero `ActNone` for LOGGED cells — no behavior change there)
+  - `func paintActivity(g Grid, segs []activity.Segment, events []activity.Event) []Cell`
+- Per-slot precedence (design decision 1): CalLabel(4) > Active(3) > Idle(2) > CalCont(1) > Empty(0); equal rank ⇒ later-painted wins.
+- Label text: calendar `▪ <title>`; activity `<domain-or-title>` (fallback app); idle `(idle)`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `internal/tui/grid/paint_test.go` (add `"ticktock/internal/activity"` to its imports):
+
+```go
+func TestPaintActivityLabelsAndContinuation(t *testing.T) {
+	g := BuildGrid(30, 7*60, 10*60, nil) // rows 07:00..10:00 = 6
+	segs := []activity.Segment{
+		{Start: at(7, 0), End: at(8, 0), App: "Safari", Title: "GitHub", Domain: "github.com"},
+		{Start: at(8, 0), End: at(8, 30), App: "Trace"}, // empty title+domain → app fallback
+		{Start: at(8, 30), End: at(9, 30), Idle: true},
+	}
+	cells := paintActivity(g, segs, nil)
+	if cells[0].Kind != CellLabel || cells[0].Text != "github.com" || cells[0].Act != ActActive {
+		t.Errorf("row0 = %+v, want github.com active label", cells[0])
+	}
+	if cells[1].Kind != CellCont || cells[1].Act != ActActive {
+		t.Errorf("row1 = %+v, want active continuation", cells[1])
+	}
+	if cells[2].Text != "Trace" {
+		t.Errorf("row2 = %+v, want app fallback label", cells[2])
+	}
+	if cells[3].Kind != CellLabel || cells[3].Text != "(idle)" || cells[3].Act != ActIdle {
+		t.Errorf("row3 = %+v, want (idle) label", cells[3])
+	}
+	if cells[4].Kind != CellCont || cells[4].Act != ActIdle {
+		t.Errorf("row4 = %+v, want idle continuation", cells[4])
+	}
+	if cells[5].Kind != CellEmpty {
+		t.Errorf("row5 = %+v, want empty gap", cells[5])
+	}
+}
+
+func TestPaintActivityTitleWhenNoDomain(t *testing.T) {
+	g := BuildGrid(30, 7*60, 8*60, nil)
+	segs := []activity.Segment{{Start: at(7, 0), End: at(7, 30), App: "Trace", Title: "notes.md"}}
+	cells := paintActivity(g, segs, nil)
+	if cells[0].Text != "notes.md" {
+		t.Errorf("row0 = %+v, want title label", cells[0])
+	}
+}
+
+func TestPaintActivityOverlapPrecedence(t *testing.T) {
+	g := BuildGrid(30, 9*60, 11*60, nil) // rows 09:00,09:30,10:00,10:30
+	segs := []activity.Segment{
+		{Start: at(9, 30), End: at(10, 0), App: "Safari", Domain: "github.com"},
+		{Start: at(10, 0), End: at(10, 30), Idle: true},
+	}
+	events := []activity.Event{
+		{Start: at(9, 0), End: at(11, 0), Title: "Sprint review", Calendar: "Archer"},
+	}
+	cells := paintActivity(g, segs, events)
+	// row0: calendar label — the human-meaningful anchor always wins
+	if cells[0].Kind != CellLabel || cells[0].Text != "▪ Sprint review" || cells[0].Act != ActCalendar {
+		t.Errorf("row0 = %+v, want calendar label", cells[0])
+	}
+	// row1: active segment beats the calendar continuation
+	if cells[1].Act != ActActive || cells[1].Text != "github.com" {
+		t.Errorf("row1 = %+v, want active over cal-cont", cells[1])
+	}
+	// row2: idle beats the calendar continuation
+	if cells[2].Act != ActIdle {
+		t.Errorf("row2 = %+v, want idle over cal-cont", cells[2])
+	}
+	// row3: nothing else there — calendar continuation fills the slot
+	if cells[3].Kind != CellCont || cells[3].Act != ActCalendar {
+		t.Errorf("row3 = %+v, want calendar continuation", cells[3])
+	}
+}
+
+func TestPaintActivityCalendarLabelBeatsActive(t *testing.T) {
+	g := BuildGrid(30, 9*60, 10*60, nil)
+	segs := []activity.Segment{{Start: at(9, 0), End: at(10, 0), App: "Safari", Domain: "github.com"}}
+	events := []activity.Event{{Start: at(9, 0), End: at(9, 30), Title: "Standup", Calendar: "Archer"}}
+	cells := paintActivity(g, segs, events)
+	if cells[0].Text != "▪ Standup" || cells[0].Act != ActCalendar {
+		t.Errorf("row0 = %+v, calendar label must beat active", cells[0])
+	}
+	if cells[1].Act != ActActive {
+		t.Errorf("row1 = %+v, active continues past the event", cells[1])
+	}
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/tui/grid -run TestPaintActivity -v`
+Expected: FAIL (build error) — `undefined: paintActivity` (and `ActActive` etc.)
+
+- [ ] **Step 3: Implement**
+
+In `internal/tui/grid/paint.go`, add `"ticktock/internal/activity"` to the imports, extend `Cell`, and append the painter. Replace the `Cell` declaration with:
+
+```go
+// Cell is one grid-column slot (LOGGED or ACTIVITY).
+type Cell struct {
+	Kind    CellKind
+	Key     string
+	Project string
+	Text    string
+	Act     ActKind // ACTIVITY-lane flavor; ActNone for LOGGED cells
+}
+
+// ActKind flavors an ACTIVITY-column cell for styling.
+type ActKind int
+
+const (
+	ActNone ActKind = iota
+	ActCalendar
+	ActActive
+	ActIdle
+)
+```
+
+Append to `internal/tui/grid/paint.go`:
+
+```go
+// Per-slot precedence ranks for the ACTIVITY lane. A calendar event's label
+// slot is the human-meaningful anchor and always shows; captured activity
+// beats idle; a calendar continuation fills otherwise-empty slots without
+// hiding activity. Equal rank ⇒ later-painted wins (mirrors paintLogged).
+const (
+	rankCalCont  = 1
+	rankIdle     = 2
+	rankActive   = 3
+	rankCalLabel = 4
+)
+
+// activityLabel picks the display text for an active segment:
+// domain, else title, else app (real logs have segments with both empty).
+func activityLabel(s activity.Segment) string {
+	if s.Domain != "" {
+		return s.Domain
+	}
+	if s.Title != "" {
+		return s.Title
+	}
+	return s.App
+}
+
+// paintActivity builds the ACTIVITY column: one label on a segment/event's
+// first covered slot, continuation glyphs on later covered slots, exactly as
+// paintLogged does for tock entries, with rank-based overlap precedence.
+func paintActivity(g Grid, segs []activity.Segment, events []activity.Event) []Cell {
+	cells := make([]Cell, g.Rows)
+	ranks := make([]int, g.Rows)
+	put := func(row, rank int, c Cell) {
+		if row < 0 || row >= g.Rows || rank < ranks[row] {
+			return
+		}
+		cells[row], ranks[row] = c, rank
+	}
+	span := func(start, end time.Time) (int, int) {
+		s := g.RowOf(minuteOfDay(start))
+		e := g.RowOf(minuteOfDay(end) - 1) // end exclusive, same as paintLogged
+		if e < s {
+			e = s
+		}
+		return s, e
+	}
+	for _, sg := range segs {
+		s, e := span(sg.Start, sg.End)
+		rank, act, label := rankActive, ActActive, activityLabel(sg)
+		if sg.Idle {
+			rank, act, label = rankIdle, ActIdle, "(idle)"
+		}
+		put(s, rank, Cell{Kind: CellLabel, Text: label, Act: act})
+		for r := s + 1; r <= e; r++ {
+			put(r, rank, Cell{Kind: CellCont, Act: act})
+		}
+	}
+	for _, ev := range events {
+		s, e := span(ev.Start, ev.End)
+		put(s, rankCalLabel, Cell{Kind: CellLabel, Text: "▪ " + ev.Title, Act: ActCalendar})
+		for r := s + 1; r <= e; r++ {
+			put(r, rankCalCont, Cell{Kind: CellCont, Act: ActCalendar})
+		}
+	}
+	return cells
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/tui/grid -v`
+Expected: PASS — all grid tests including the pre-existing paintLogged/view suites (Cell gained a zero-valued field only)
+
+- [ ] **Step 5: Commit**
+
+```bash
+jj commit -m "feat(grid): paintActivity with calendar/active/idle overlap precedence"
+```
+
+---
+
+### Task 9: `internal/tui/grid` — Model wiring + View rendering
+
+**Files:**
+- Modify: `internal/tui/grid/model.go`
+- Modify: `internal/tui/grid/view.go`
+- Test: `internal/tui/grid/model_test.go`
+
+**Interfaces:**
+- Consumes: `paintActivity`, `ActKind` (Task 8); `activity.LoadDay`, `activity.LoadCalendar`, `activity.CalendarFilter` (Tasks 6-7).
+- Produces (used by Task 12):
+  - `type ActivitySource struct { DataDir, CalBin string; Filter activity.CalendarFilter }` — zero value disables the lane.
+  - `func (m Model) WithActivity(src ActivitySource) Model`
+  - `grid.New` signature unchanged; existing call sites keep compiling.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `internal/tui/grid/model_test.go` (add `"os"`, `"path/filepath"`, and `"ticktock/internal/activity"` to its imports; `"strings"` is already imported):
+
+```go
+func TestEntriesMsgPaintsActivityLane(t *testing.T) {
+	m := New(&fakeStore{}, cfg(), "2026-07-07", form.Suggest{})
+	segs := []activity.Segment{{Start: at(7, 0), End: at(8, 0), App: "Trace", Title: "notes"}}
+	events := []activity.Event{{Start: at(9, 0), End: at(9, 30), Title: "Standup", Calendar: "Archer"}}
+	nm, _ := m.Update(entriesMsg{segs: segs, events: events})
+	got := nm.(Model)
+	if len(got.activity) != got.grid.Rows {
+		t.Fatalf("activity lane not painted: %d cells for %d rows", len(got.activity), got.grid.Rows)
+	}
+	if got.activity[0].Kind != CellLabel || got.activity[0].Act != ActActive {
+		t.Errorf("row0 = %+v, want active label", got.activity[0])
+	}
+	view := got.View()
+	if !strings.Contains(view, "notes") {
+		t.Errorf("view should render the activity label, got:\n%s", view)
+	}
+	if !strings.Contains(view, "▪ Standup") {
+		t.Errorf("view should render the calendar label, got:\n%s", view)
+	}
+}
+
+func TestSelectionBandStillOverridesActivity(t *testing.T) {
+	m := New(&fakeStore{}, cfg(), "2026-07-07", form.Suggest{})
+	segs := []activity.Segment{{Start: at(7, 0), End: at(8, 0), App: "Trace", Title: "notes"}}
+	nm, _ := m.Update(entriesMsg{segs: segs})
+	got := nm.(Model)
+	a := 0
+	got.anchor = &a
+	got.cursor = 1
+	view := got.View()
+	if !strings.Contains(view, "▓") {
+		t.Errorf("selection band must still render over the activity lane:\n%s", view)
+	}
+	if strings.Contains(view, "notes") {
+		t.Errorf("banded rows should not leak the activity label:\n%s", view)
+	}
+}
+
+func TestLoadFetchesActivityFromSource(t *testing.T) {
+	dir := t.TempDir()
+	line := `{"start": "2026-07-07T07:10:00", "end": "2026-07-07T07:40:00", "secs": 1800, "app": "Trace", "title": "notes", "url": "", "domain": "", "idle": false}` + "\n"
+	if err := os.WriteFile(filepath.Join(dir, "activity-2026-07-07.jsonl"), []byte(line), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	m := New(&fakeStore{}, cfg(), "2026-07-07", form.Suggest{}).
+		WithActivity(ActivitySource{DataDir: dir})
+	msg := m.load()()
+	em, ok := msg.(entriesMsg)
+	if !ok {
+		t.Fatalf("load returned %T", msg)
+	}
+	if len(em.segs) != 1 || em.segs[0].App != "Trace" {
+		t.Fatalf("load should fetch the day's segments, got %+v", em.segs)
+	}
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/tui/grid -v`
+Expected: FAIL (build error) — `unknown field segs in struct literal of type entriesMsg` / `undefined: ActivitySource`
+
+- [ ] **Step 3: Implement the Model wiring**
+
+In `internal/tui/grid/model.go`:
+
+Add `"ticktock/internal/activity"` to the imports.
+
+Add fields to `Model` (after `logged  []Cell`):
+
+```go
+	src      ActivitySource
+	segs     []activity.Segment
+	events   []activity.Event
+	activity []Cell
+```
+
+Add after the `Model` struct:
+
+```go
+// ActivitySource tells the grid where to read the ACTIVITY lane's data.
+// The zero value disables the lane (static gap lane, as before).
+type ActivitySource struct {
+	DataDir string // ~/.local/share/ticktock; "" disables activity
+	CalBin  string // path to the cal-events helper; "" disables calendar
+	Filter  activity.CalendarFilter
+}
+
+// WithActivity returns a copy of the model wired to read activity + calendar
+// data on every load.
+func (m Model) WithActivity(src ActivitySource) Model {
+	m.src = src
+	return m
+}
+```
+
+Replace `entriesMsg`:
+
+```go
+type entriesMsg struct {
+	entries []store.Entry
+	segs    []activity.Segment
+	events  []activity.Event
+	err     error
+}
+```
+
+Replace `load`:
+
+```go
+func (m Model) load() tea.Cmd {
+	date, s, src := m.date, m.store, m.src
+	return func() tea.Msg {
+		es, err := s.Day(date)
+		segs, _ := activity.LoadDay(src.DataDir, date)      // tolerant: never fails the view
+		events, _ := activity.LoadCalendar(src.CalBin, date, src.Filter)
+		return entriesMsg{entries: es, segs: segs, events: events, err: err}
+	}
+}
+```
+
+(The `n`/`p`/`t`/`r` day-nav paths all funnel through `m.load()`, so they pick this up with no further change.)
+
+In `rebuild`, after `m.logged = paintLogged(m.grid, m.entries)`, add:
+
+```go
+	m.activity = paintActivity(m.grid, m.segs, m.events)
+```
+
+In `Update`, replace the `entriesMsg` case body:
+
+```go
+	case entriesMsg:
+		m.entries, m.segs, m.events, m.err = msg.entries, msg.segs, msg.events, msg.err
+		m.rebuild()
+		return m, nil
+```
+
+- [ ] **Step 4: Implement the View rendering**
+
+In `internal/tui/grid/view.go`:
+
+Add the calendar accent style to the `var (...)` block (after `gDelHi`):
+
+```go
+	gCal = lipgloss.NewStyle().Foreground(lipgloss.Color("#6CB6FF")) // calendar accent
+```
+
+Replace the ACTIVITY block inside `View`'s row loop:
+
+```go
+		// ACTIVITY column: gap lane, band if selected
+		act := strings.Repeat("░", actWidth)
+		if selecting && row >= lo && row <= hi {
+			act = gBand.Render(strings.Repeat("▓", actWidth))
+		}
+```
+
+with:
+
+```go
+		// ACTIVITY column: painted lane (calendar/active/idle) over the gap
+		// lane; the selection band still overrides while selecting.
+		act := strings.Repeat("░", actWidth)
+		if row < len(m.activity) && m.activity[row].Kind != CellEmpty {
+			act = renderActCell(m.activity[row])
+		}
+		if selecting && row >= lo && row <= hi {
+			act = gBand.Render(strings.Repeat("▓", actWidth))
+		}
+```
+
+Append to `internal/tui/grid/view.go`:
+
+```go
+// padCell pads s with spaces to exactly w runes. The activity cell must be
+// padded BEFORE styling: gridRow's %-*s pads by byte length, which ANSI
+// escapes would defeat, misaligning the column divider.
+func padCell(s string, w int) string {
+	if n := len([]rune(s)); n < w {
+		return s + strings.Repeat(" ", w-n)
+	}
+	return s
+}
+
+// renderActCell paints one ACTIVITY-lane cell: label text or continuation
+// glyph, colored by flavor — calendar accent, idle dimmed, active plain;
+// continuation glyphs render faint.
+func renderActCell(c Cell) string {
+	text := trunc(c.Text, actWidth)
+	if c.Kind == CellCont {
+		text = "│"
+	}
+	text = padCell(text, actWidth)
+	style := lipgloss.NewStyle()
+	switch c.Act {
+	case ActCalendar:
+		style = gCal
+	case ActIdle:
+		style = gDim
+	}
+	if c.Kind == CellCont {
+		style = style.Faint(true)
+	}
+	return style.Render(text)
+}
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `go test ./internal/tui/grid -v && go build ./...`
+Expected: PASS — new tests plus every pre-existing grid test (New's signature is unchanged; `cmd/ticktock` still compiles)
+
+- [ ] **Step 6: Commit**
+
+```bash
+jj commit -m "feat(grid): render activity + calendar lane in the ACTIVITY column"
+```
+
+---
+
+## Task group A — writer, OS integration (darwin)
+
+### Task 10: `internal/autotrack` — OS sampling (parsers, darwin.go, stub.go, HelperPath)
+
+**Files:**
+- Create: `internal/autotrack/sample.go` (portable)
+- Create: `internal/autotrack/darwin.go` (`//go:build darwin`)
+- Create: `internal/autotrack/stub.go` (`//go:build !darwin`)
+- Test: `internal/autotrack/sample_test.go`
+
+**Interfaces:**
+- Consumes: `Ctx`, `Domain` (Task 2).
+- Produces (used by Tasks 11, 12):
+  - `type Sample struct { Idle int; Raw *Ctx; Locked bool }`
+  - `func ReadSample() Sample` (darwin: real sampling; !darwin: empty)
+  - `func HelperPath(name string) string` — bundled helper next to the resolved `tt` binary, else `$PATH`, else `""`.
+  - portable parsers `parseHIDIdle(string) int`, `parseLocked(string) bool`, `parseWinctx([]byte) (app, title string)`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `internal/autotrack/sample_test.go`:
+
+```go
+package autotrack
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+func TestParseHIDIdle(t *testing.T) {
+	out := `+-o IOHIDSystem  <class IOHIDSystem, id 0x100000456>
+    {
+      "HIDIdleTime" = 2216064029
+    }`
+	if got := parseHIDIdle(out); got != 2 { // 2216064029 ns → 2 s
+		t.Errorf("parseHIDIdle=%d, want 2", got)
+	}
+	if got := parseHIDIdle("no idle info here"); got != 0 {
+		t.Errorf("absent key should give 0, got %d", got)
+	}
+	if got := parseHIDIdle(`"HIDIdleTime" = garbage`); got != 0 {
+		t.Errorf("malformed value should give 0, got %d", got)
+	}
+}
+
+func TestParseLocked(t *testing.T) {
+	if !parseLocked(`    "CGSSessionScreenIsLocked" = Yes`) {
+		t.Error("locked output should parse true")
+	}
+	if parseLocked("") || parseLocked(`"SomethingElse"=Yes`) {
+		t.Error("unlocked output should parse false")
+	}
+}
+
+func TestParseWinctx(t *testing.T) {
+	app, title := parseWinctx([]byte(`{"app":"Trace","title":"notes — repo"}`))
+	if app != "Trace" || title != "notes — repo" {
+		t.Errorf("got %q/%q", app, title)
+	}
+	if app, title := parseWinctx([]byte("garbage")); app != "" || title != "" {
+		t.Errorf("bad json should give empties, got %q/%q", app, title)
+	}
+}
+
+func TestHelperPathFallsBackToPATH(t *testing.T) {
+	dir := t.TempDir()
+	bin := filepath.Join(dir, "fakehelper")
+	if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	t.Setenv("PATH", dir)
+	if got := HelperPath("fakehelper"); got != bin {
+		t.Errorf("HelperPath=%q, want %q", got, bin)
+	}
+	if got := HelperPath("definitely-not-a-helper"); got != "" {
+		t.Errorf("missing helper should give empty, got %q", got)
+	}
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/autotrack -v`
+Expected: FAIL (build error) — `undefined: parseHIDIdle`
+
+- [ ] **Step 3: Implement the portable half**
+
+Create `internal/autotrack/sample.go`:
+
+```go
+package autotrack
+
+import (
+	"encoding/json"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strconv"
+	"strings"
+)
+
+// Sample is one OS observation handed to the segmentation loop.
+type Sample struct {
+	Idle   int  // HID idle seconds
+	Raw    *Ctx // foreground context; nil when no foreground app
+	Locked bool // screen locked (hard, immediate away signal)
+}
+
+// parseHIDIdle extracts HIDIdleTime (nanoseconds) from `ioreg -c IOHIDSystem`
+// output and returns whole seconds. 0 when absent or malformed.
+func parseHIDIdle(out string) int {
+	for _, line := range strings.Split(out, "\n") {
+		if !strings.Contains(line, "HIDIdleTime") {
+			continue
+		}
+		i := strings.LastIndex(line, "=")
+		if i < 0 {
+			continue
+		}
+		ns, err := strconv.ParseInt(strings.TrimSpace(line[i+1:]), 10, 64)
+		if err != nil {
+			continue
+		}
+		return int(ns / 1_000_000_000)
+	}
+	return 0
+}
+
+// parseLocked reports whether ioreg output shows the lock flag. The
+// CGSSessionScreenIsLocked key is present only while the screen is locked.
+func parseLocked(out string) bool {
+	return strings.Contains(strings.ReplaceAll(out, " ", ""), `"CGSSessionScreenIsLocked"=Yes`)
+}
+
+// parseWinctx decodes the winctx helper's {"app","title"} JSON. Empty on error.
+func parseWinctx(b []byte) (app, title string) {
+	var v struct {
+		App   string `json:"app"`
+		Title string `json:"title"`
+	}
+	if err := json.Unmarshal(b, &v); err != nil {
+		return "", ""
+	}
+	return v.App, v.Title
+}
+
+// HelperPath finds a bundled native helper (winctx, cal-events): first next
+// to the symlink-resolved tt binary (make install symlinks ~/.local/bin/tt →
+// <repo>/bin/tt, and the helpers build into <repo>/bin), then on $PATH.
+// "" when not found — callers degrade gracefully.
+func HelperPath(name string) string {
+	if exe, err := os.Executable(); err == nil {
+		if resolved, rerr := filepath.EvalSymlinks(exe); rerr == nil {
+			p := filepath.Join(filepath.Dir(resolved), name)
+			if st, serr := os.Stat(p); serr == nil && !st.IsDir() {
+				return p
+			}
+		}
+	}
+	if p, err := exec.LookPath(name); err == nil {
+		return p
+	}
+	return ""
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/autotrack -v`
+Expected: PASS
+
+- [ ] **Step 5: Implement the darwin half and the stub**
+
+Create `internal/autotrack/darwin.go`:
+
+```go
+//go:build darwin
+
+package autotrack
+
+import (
+	"context"
+	"os/exec"
+	"strings"
+	"time"
+)
+
+// osTimeout bounds every OS shell-out so a wedged osascript can never stall
+// the poll loop (parity with the Python daemon's timeout=4).
+const osTimeout = 4 * time.Second
+
+// browsers maps app names to the osascript that reads their active-tab URL.
+var browsers = map[string]string{
+	"Safari":         `tell application "Safari" to get URL of front document`,
+	"Google Chrome":  `tell application "Google Chrome" to get URL of active tab of front window`,
+	"Brave Browser":  `tell application "Brave Browser" to get URL of active tab of front window`,
+	"Microsoft Edge": `tell application "Microsoft Edge" to get URL of active tab of front window`,
+	"Arc":            `tell application "Arc" to get URL of active tab of front window`,
+	"Dia":            `tell application "Dia" to get URL of active tab of front window`,
+}
+
+// runCmd executes a command with the standard timeout, returning stdout
+// ("" on any failure — an OS hiccup must never panic the loop).
+func runCmd(name string, args ...string) string {
+	ctx, cancel := context.WithTimeout(context.Background(), osTimeout)
+	defer cancel()
+	out, err := exec.CommandContext(ctx, name, args...).Output()
+	if err != nil {
+		return ""
+	}
+	return string(out)
+}
+
+func osa(script string) string { return strings.TrimSpace(runCmd("osascript", "-e", script)) }
+
+func frontApp() string {
+	return osa(`tell application "System Events" to get name of first application process whose frontmost is true`)
+}
+
+func frontTitle() string {
+	return osa(`tell application "System Events" to tell (first application process whose frontmost is true) to get name of front window`)
+}
+
+func browserURL(app string) string {
+	script, ok := browsers[app]
+	if !ok {
+		return ""
+	}
+	return osa(script)
+}
+
+func idleSecs() int { return parseHIDIdle(runCmd("ioreg", "-c", "IOHIDSystem")) }
+
+func screenLocked() bool {
+	return parseLocked(runCmd("ioreg", "-n", "Root", "-d1", "-r", "-k", "CGSSessionScreenIsLocked"))
+}
+
+// winctxApp reads the frontmost {app, title} via the bundled AX helper
+// (richer titles than System Events). Empties when the helper is missing,
+// denied, or errors — the caller falls back to osascript.
+func winctxApp() (string, string) {
+	bin := HelperPath("winctx")
+	if bin == "" {
+		return "", ""
+	}
+	out := strings.TrimSpace(runCmd(bin))
+	if out == "" {
+		return "", ""
+	}
+	return parseWinctx([]byte(out))
+}
+
+// readContext samples the current foreground context; nil when no app.
+// Prefers the AX helper's app+title, falling back to System Events per field.
+func readContext() *Ctx {
+	app, title := winctxApp()
+	if app == "" {
+		app = frontApp()
+	}
+	if app == "" {
+		return nil
+	}
+	if title == "" {
+		title = frontTitle()
+	}
+	u := browserURL(app)
+	return &Ctx{App: app, Title: title, URL: u, Domain: Domain(u)}
+}
+
+// ReadSample gathers one full OS observation for the poll loop.
+func ReadSample() Sample {
+	return Sample{Idle: idleSecs(), Raw: readContext(), Locked: screenLocked()}
+}
+```
+
+Create `internal/autotrack/stub.go`:
+
+```go
+//go:build !darwin
+
+package autotrack
+
+// ReadSample on non-darwin platforms returns an empty observation so the
+// package builds and its pure tests run everywhere; the daemon is
+// darwin-only.
+func ReadSample() Sample { return Sample{} }
+```
+
+- [ ] **Step 6: Verify both build-tag halves compile and tests still pass**
+
+Run: `go build ./... && GOOS=linux go build ./internal/autotrack && go test ./internal/autotrack -v`
+Expected: both builds succeed; tests PASS
+
+- [ ] **Step 7: Commit**
+
+```bash
+jj commit -m "feat(autotrack): darwin OS sampling with !darwin stub and portable parsers"
+```
+
+---
+
+### Task 11: `internal/autotrack/run.go` — poll loop with signal flush
+
+**Files:**
+- Create: `internal/autotrack/run.go`
+- Test: `internal/autotrack/run_test.go`
+
+**Interfaces:**
+- Consumes: `Sample`, `Classify`, `Away`, `Step`, `Key`, `WriteSegment` (Tasks 2-4, 10); `config.Tracking` (Task 1).
+- Produces (used by Task 12): `func Run(dataDir string, tr config.Tracking, sample func() Sample, sigc <-chan os.Signal)` — loops until a signal arrives, then flushes the open segment and returns.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `internal/autotrack/run_test.go`:
+
+```go
+package autotrack
+
+import (
+	"os"
+	"strings"
+	"syscall"
+	"testing"
+	"time"
+
+	"ticktock/internal/config"
+)
+
+func TestRunFlushesOpenSegmentOnSignal(t *testing.T) {
+	dir := t.TempDir()
+	sigc := make(chan os.Signal, 1)
+	sample := func() Sample {
+		return Sample{Idle: 0, Raw: &Ctx{App: "TestApp", Title: "doc"}}
+	}
+	done := make(chan struct{})
+	go func() {
+		Run(dir, config.Tracking{PollSecs: 1, IdleGraceSecs: 900, MinSecs: 0}, sample, sigc)
+		close(done)
+	}()
+	time.Sleep(50 * time.Millisecond) // let the first poll open a segment
+	sigc <- syscall.SIGTERM
+	select {
+	case <-done:
+	case <-time.After(2 * time.Second):
+		t.Fatal("Run did not return after signal")
+	}
+	b, err := os.ReadFile(LogPath(dir, time.Now()))
+	if err != nil {
+		t.Fatalf("flush should have written today's log: %v", err)
+	}
+	line := strings.TrimSpace(string(b))
+	if !strings.Contains(line, `"app": "TestApp"`) || !strings.Contains(line, `"idle": false`) {
+		t.Errorf("flushed segment malformed: %s", line)
+	}
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/autotrack -run TestRunFlushes -v`
+Expected: FAIL (build error) — `undefined: Run`
+
+- [ ] **Step 3: Implement**
+
+Create `internal/autotrack/run.go`:
+
+```go
+package autotrack
+
+import (
+	"os"
+	"time"
+
+	"ticktock/internal/config"
+)
+
+// Run executes the poll loop: sample → Classify → Step → WriteSegment →
+// sleep, until a signal arrives on sigc; then it flushes the open segment and
+// returns (parity with the Python daemon's SIGTERM/SIGINT flush). sample is
+// injected (ReadSample in production) so the loop is testable.
+func Run(dataDir string, tr config.Tracking, sample func() Sample, sigc <-chan os.Signal) {
+	var cur *Segment
+	var curKey Key
+	poll := time.Duration(tr.PollSecs) * time.Second
+	for {
+		now := time.Now()
+		s := sample()
+		hard := s.Locked || Away(s.Raw)
+		ctx := Classify(s.Idle, s.Raw, tr.IdleGraceSecs, s.Locked)
+		var emitted *Segment
+		cur, curKey, emitted = Step(cur, curKey, now, s.Idle, ctx, tr.IdleGraceSecs, hard)
+		if emitted != nil {
+			_ = WriteSegment(dataDir, emitted, tr.MinSecs) // an IO hiccup must not kill the loop
+		}
+		select {
+		case <-sigc:
+			if cur != nil {
+				closed := *cur
+				closed.End = time.Now()
+				_ = WriteSegment(dataDir, &closed, tr.MinSecs)
+			}
+			return
+		case <-time.After(poll):
+		}
+	}
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/autotrack -v`
+Expected: PASS — including `TestRunFlushesOpenSegmentOnSignal`
+
+- [ ] **Step 5: Commit**
+
+```bash
+jj commit -m "feat(autotrack): Run poll loop with SIGTERM/SIGINT segment flush"
+```
+
+---
+
+### Task 12: `cmd/ticktock` — autotrack subcommand + timeline activity wiring
+
+**Files:**
+- Modify: `cmd/ticktock/main.go`
+- Create: `cmd/ticktock/main_test.go`
+
+**Interfaces:**
+- Consumes: `autotrack.Run`, `autotrack.ReadSample`, `autotrack.Classify`, `autotrack.HelperPath`, `activity.LoadCalendar`, `activity.CalendarFilter`, `config.Load` (`.Tracking`, `.Calendars`), `grid.ActivitySource`, `Model.WithActivity`.
+- Produces: `tt autotrack` (poll loop, what launchd invokes), `tt autotrack --once`, `tt autotrack --list-calendars`; `tt timeline` now shows the activity lane.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `cmd/ticktock/main_test.go`:
+
+```go
+package main
+
+import "testing"
+
+func TestRootHasAutotrackCommand(t *testing.T) {
+	root := newRootCmd()
+	cmd, _, err := root.Find([]string{"autotrack"})
+	if err != nil || cmd == nil || cmd.Name() != "autotrack" {
+		t.Fatalf("autotrack subcommand missing: %v", err)
+	}
+	if cmd.Flags().Lookup("once") == nil {
+		t.Error("autotrack should have --once")
+	}
+	if cmd.Flags().Lookup("list-calendars") == nil {
+		t.Error("autotrack should have --list-calendars")
+	}
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./cmd/ticktock -v`
+Expected: FAIL — `autotrack subcommand missing: ...`
+
+- [ ] **Step 3: Implement**
+
+In `cmd/ticktock/main.go`:
+
+Extend the imports:
+
+```go
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"os/signal"
+	"path/filepath"
+	"syscall"
+	"time"
+
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/spf13/cobra"
+
+	"ticktock/internal/activity"
+	"ticktock/internal/autotrack"
+	"ticktock/internal/config"
+	"ticktock/internal/history"
+	"ticktock/internal/store"
+	"ticktock/internal/tui/day"
+	"ticktock/internal/tui/form"
+	"ticktock/internal/tui/grid"
+	"ticktock/internal/tui/menu"
+)
+```
+
+Register the subcommand in `newRootCmd` (after `root.AddCommand(newMenuCmd())`):
+
+```go
+	root.AddCommand(newAutotrackCmd())
+```
+
+Replace `newTimelineCmd`'s `RunE` body's model construction line:
+
+```go
+				m := grid.New(store.New(store.ExecRunner{}), config.Load(), resolveDate(arg), loadSuggest())
+```
+
+with:
+
+```go
+				cfg := config.Load()
+				m := grid.New(store.New(store.ExecRunner{}), cfg, resolveDate(arg), loadSuggest()).
+					WithActivity(activitySource(cfg))
+```
+
+Append the new functions:
+
+```go
+// dataDir is the shared activity-log directory (unchanged from the Python
+// daemon): ~/.local/share/ticktock.
+func dataDir() (string, error) {
+	home, err := os.UserHomeDir()
+	if err != nil {
+		return "", err
+	}
+	return filepath.Join(home, ".local", "share", "ticktock"), nil
+}
+
+// activitySource wires the grid's ACTIVITY lane: the shared data dir, the
+// cal-events helper, and the calendars.show/hide filter from config.
+func activitySource(cfg config.Config) grid.ActivitySource {
+	dir, err := dataDir()
+	if err != nil {
+		dir = "" // lane degrades to the static gap lane
+	}
+	return grid.ActivitySource{
+		DataDir: dir,
+		CalBin:  autotrack.HelperPath("cal-events"),
+		Filter:  activity.CalendarFilter{Show: cfg.Calendars.Show, Hide: cfg.Calendars.Hide},
+	}
+}
+
+func newAutotrackCmd() *cobra.Command {
+	var once, listCals bool
+	cmd := &cobra.Command{
+		Use:   "autotrack",
+		Short: "Passive activity daemon (runs under launchd; writes only its staging log)",
+		Args:  cobra.NoArgs,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			if listCals {
+				return runListCalendars()
+			}
+			if once {
+				return runOnce()
+			}
+			dir, err := dataDir()
+			if err != nil {
+				return err
+			}
+			sigc := make(chan os.Signal, 1)
+			signal.Notify(sigc, syscall.SIGTERM, syscall.SIGINT)
+			autotrack.Run(dir, config.Load().Tracking, autotrack.ReadSample, sigc)
+			return nil
+		},
+	}
+	cmd.Flags().BoolVar(&once, "once", false, "sample once and print the classified context")
+	cmd.Flags().BoolVar(&listCals, "list-calendars", false, "print distinct calendar names cal-events reports for today")
+	return cmd
+}
+
+// runOnce samples and prints the classified context — parity with the Python
+// daemon's --once, for quick permission/sanity checks.
+func runOnce() error {
+	s := autotrack.ReadSample()
+	ctx := autotrack.Classify(s.Idle, s.Raw, config.Load().Tracking.IdleGraceSecs, s.Locked)
+	switch {
+	case ctx == nil:
+		fmt.Println("no foreground app")
+	case ctx.Idle:
+		fmt.Println("idle")
+	default:
+		enc := json.NewEncoder(os.Stdout)
+		enc.SetEscapeHTML(false)
+		enc.SetIndent("", "  ")
+		return enc.Encode(ctx)
+	}
+	return nil
+}
+
+// runListCalendars prints the distinct calendar names cal-events reports for
+// today, unfiltered, so the user can copy exact names into calendars.show /
+// calendars.hide in config.json.
+func runListCalendars() error {
+	bin := autotrack.HelperPath("cal-events")
+	if bin == "" {
+		return fmt.Errorf("cal-events helper not found — build it with `make helpers`")
+	}
+	events, err := activity.LoadCalendar(bin, time.Now().Format("2006-01-02"), activity.CalendarFilter{})
+	if err != nil {
+		return err
+	}
+	seen := map[string]bool{}
+	for _, e := range events {
+		if !seen[e.Calendar] {
+			seen[e.Calendar] = true
+			fmt.Println(e.Calendar)
+		}
+	}
+	if len(seen) == 0 {
+		fmt.Println("(no events today — try a busier day, or check Calendar permission for cal-events)")
+	}
+	return nil
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./... && go build ./...`
+Expected: PASS across all packages; clean build
+
+- [ ] **Step 5: Manual smoke check (darwin)**
+
+Run: `go build -o bin/tt ./cmd/ticktock && ./bin/tt autotrack --once`
+Expected: a JSON object like `{"app": "...", "title": "...", "url": "", "domain": "", "idle": false}` (or `idle` / `no foreground app`). First run may trigger a macOS Automation permission prompt for System Events — approve it.
+
+- [ ] **Step 6: Commit**
+
+```bash
+jj commit -m "feat(cmd): tt autotrack subcommand (--once, --list-calendars) + timeline activity wiring"
+```
+
+---
+
+## Infrastructure & cutover
+
+### Task 13: Vendored Swift helpers + Makefile targets
+
+**Files:**
+- Create: `native/winctx.swift` (vendored verbatim from `/Users/kortum/Developer/Home/ticktock-old/bin/winctx.swift`)
+- Create: `native/cal-events.swift` (vendored verbatim from `/Users/kortum/Developer/Home/ticktock-old/bin/cal-events.swift`)
+- Modify: `Makefile`
+
+**Interfaces:**
+- Consumes: nothing from Go code.
+- Produces: `bin/winctx`, `bin/cal-events` (found by `autotrack.HelperPath` because they sit next to `bin/tt`); `make helpers` target that skips gracefully without `swiftc`.
+
+- [ ] **Step 1: Vendor the sources**
+
+```bash
+mkdir -p native
+cp /Users/kortum/Developer/Home/ticktock-old/bin/winctx.swift native/winctx.swift
+cp /Users/kortum/Developer/Home/ticktock-old/bin/cal-events.swift native/cal-events.swift
+```
+
+Then verify the copies are byte-identical:
+
+Run: `diff native/winctx.swift /Users/kortum/Developer/Home/ticktock-old/bin/winctx.swift && diff native/cal-events.swift /Users/kortum/Developer/Home/ticktock-old/bin/cal-events.swift && echo identical`
+Expected: `identical`
+
+(For reference, `winctx.swift` prints `{"app":"<name>","title":"<focused window title>"}` via the AX API; `cal-events.swift` takes `<from YYYY-MM-DD> [to]` and prints a JSON array of `{title, start, end, calendar}` for non-all-day events via EventKit. Do not modify them — they are the working binaries' exact sources.)
+
+- [ ] **Step 2: Add the Makefile targets**
+
+Replace the entire `Makefile` with:
+
+```make
+.PHONY: build test install vet helpers
+build:
+	go build -o bin/tt ./cmd/ticktock
+test:
+	go test ./...
+vet:
+	go vet ./...
+# Native Swift helpers: winctx (AX window titles) and cal-events (EventKit).
+# Skips gracefully without swiftc — the daemon falls back to System Events
+# titles and the timeline simply shows no calendar lane.
+helpers:
+	@if command -v swiftc >/dev/null 2>&1; then \
+		mkdir -p bin; \
+		swiftc -O native/winctx.swift -o bin/winctx && echo "built bin/winctx"; \
+		swiftc -O native/cal-events.swift -o bin/cal-events && echo "built bin/cal-events"; \
+	else \
+		echo "swiftc not found — skipping native helpers (titles fall back to System Events; no calendar lane)"; \
+	fi
+install: build helpers
+	ln -sfn "$(PWD)/bin/tt" "$(HOME)/.local/bin/tt"
+```
+
+- [ ] **Step 3: Verify the build**
+
+Run: `make helpers`
+Expected: `built bin/winctx` and `built bin/cal-events` (this machine has swiftc; on a machine without it, the skip note prints and make exits 0)
+
+Run: `./bin/winctx`
+Expected: one JSON line, e.g. `{"app":"Terminal","title":"..."}` (title may be empty until Accessibility is granted to `bin/winctx` — System Settings → Privacy & Security → Accessibility)
+
+Run: `./bin/cal-events $(date +%F)`
+Expected: a JSON array (possibly `[]`); first run may prompt for Calendar access — grant it
+
+Run: `./bin/tt autotrack --list-calendars` (after `make build`)
+Expected: distinct calendar names, one per line (e.g. `Archer`, `Personal`, …)
+
+- [ ] **Step 4: Commit**
+
+```bash
+jj commit -m "build: vendor winctx/cal-events swift helpers with graceful-skip make target"
+```
+
+---
+
+### Task 14: launchd cutover — plist template, install script, integration verification
+
+**Files:**
+- Create: `deploy/autotrack.plist.template`
+- Create: `deploy/install-autotrack.sh`
+
+**Interfaces:**
+- Consumes: `bin/tt` (Task 12), the live launchd agent `com.humdrum.ticktock.autotrack` currently pointing at `/Users/kortum/Developer/Home/ticktock-old/daemon/autotrack.py`.
+- Produces: the agent repointed at `tt autotrack`; same label, same `StandardOut/ErrPath` under `~/.local/share/ticktock/`. Rollback = re-run `ticktock-old/daemon/install-daemon.sh`.
+
+- [ ] **Step 1: Create the plist template**
+
+Create `deploy/autotrack.plist.template`:
+
+```xml
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+    <key>Label</key>
+    <string>com.humdrum.ticktock.autotrack</string>
+    <key>ProgramArguments</key>
+    <array>
+        <string>@TT@</string>
+        <string>autotrack</string>
+    </array>
+    <key>RunAtLoad</key>
+    <true/>
+    <key>KeepAlive</key>
+    <true/>
+    <key>ProcessType</key>
+    <string>Background</string>
+    <key>StandardOutPath</key>
+    <string>@HOME@/.local/share/ticktock/autotrack.log</string>
+    <key>StandardErrorPath</key>
+    <string>@HOME@/.local/share/ticktock/autotrack.err</string>
+</dict>
+</plist>
+```
+
+- [ ] **Step 2: Create the install script**
+
+Create `deploy/install-autotrack.sh` and `chmod +x` it:
+
+```bash
+#!/usr/bin/env bash
+# Repoint the com.humdrum.ticktock.autotrack launchd agent at `tt autotrack`.
+#
+# Rollback: the Python daemon in ticktock-old is untouched — re-run
+#   ~/Developer/Home/ticktock-old/daemon/install-daemon.sh
+# (note: it installs under the old com.ticktock.autotrack label; bootout the
+# com.humdrum one first) or hand-edit the plist back to autotrack.py.
+set -euo pipefail
+REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+TT="$REPO/bin/tt"
+LABEL="com.humdrum.ticktock.autotrack"
+PLIST="${HOME}/Library/LaunchAgents/${LABEL}.plist"
+
+[ -x "$TT" ] || { echo "missing $TT — run: make build helpers" >&2; exit 1; }
+mkdir -p "${HOME}/.local/share/ticktock" "${HOME}/Library/LaunchAgents"
+
+sed -e "s#@TT@#${TT}#g" -e "s#@HOME@#${HOME}#g" \
+  "$REPO/deploy/autotrack.plist.template" > "$PLIST"
+
+launchctl bootout "gui/$(id -u)/${LABEL}" 2>/dev/null || true
+launchctl bootstrap "gui/$(id -u)" "$PLIST"
+echo "✓ ${LABEL} → ${TT} autotrack  (${PLIST})"
+echo "  verify: launchctl print gui/$(id -u)/${LABEL} | grep -E 'state|program'"
+echo "  logs:   tail -f ~/.local/share/ticktock/autotrack.err"
+```
+
+- [ ] **Step 3: Full pre-cutover verification**
+
+Run each and confirm:
+
+1. `make build helpers && make test` — Expected: helpers built, `ok` for every package.
+2. `./bin/tt autotrack --once` — Expected: JSON context (or `idle`).
+3. Short live run: `./bin/tt autotrack & sleep 45; kill -TERM %1; wait` — Expected: exits 0; then
+   `tail -2 ~/.local/share/ticktock/activity-$(date +%F).jsonl` shows a fresh line in the exact Python shape (`{"start": "...", "end": "...", "secs": NN, "app": "...", ...}`) whose `end` is within the last minute. (The Python daemon is still running under launchd at this point — interleaved lines from both writers are fine, the reader tolerates them.)
+4. `./bin/tt timeline` — Expected: the ACTIVITY column shows today's captured segments (labels + `│` continuations, `(idle)` dimmed) and calendar events (`▪ Title` in the blue accent); space-selection still draws the `▓` band over the lane; `n`/`p`/`t`/`r` reload the lane for other days.
+5. Optional: add `"calendars": {"show": ["Archer"]}` to `~/.config/ticktock/config.json` and re-open `tt timeline` — Expected: only Archer events in the lane. (Use `./bin/tt autotrack --list-calendars` for exact names.)
+
+- [ ] **Step 4: Cut launchd over**
+
+```bash
+./deploy/install-autotrack.sh
+launchctl print "gui/$(id -u)/com.humdrum.ticktock.autotrack" | grep -E "state|program"
+```
+
+Expected: `state = running` and `program` pointing at `.../ticktock-go/bin/tt`. Then confirm the Go daemon is writing:
+
+```bash
+sleep 30 && tail -1 ~/.local/share/ticktock/activity-$(date +%F).jsonl
+```
+
+Expected: a fresh segment line (end timestamp within the last minute).
+
+Note: the daemon now runs `bin/winctx` from launchd; if window titles come back empty, grant Accessibility to `bin/winctx` (System Settings → Privacy & Security → Accessibility). The daemon still runs without it (System Events fallback).
+
+**Rollback (any time):** repoint the plist back at the Python daemon —
+`launchctl bootout gui/$(id -u)/com.humdrum.ticktock.autotrack` then edit `~/Library/LaunchAgents/com.humdrum.ticktock.autotrack.plist`'s `ProgramArguments` back to `[/usr/bin/python3, /Users/kortum/Developer/Home/ticktock-old/daemon/autotrack.py]` and `launchctl bootstrap gui/$(id -u) <plist>`. The Python daemon and its data format are untouched.
+
+- [ ] **Step 5: Commit**
+
+```bash
+jj commit -m "deploy: launchd plist template + install script for tt autotrack cutover"
+```
+
+- [ ] **Step 6: Post-cutover (next day, manual)**
+
+After a normal day on the Go daemon: skim `tt timeline` for yesterday+today, spot-check `activity-*.jsonl` for sane segments (no zero-length spam, idle blocks where expected). Once satisfied, `ticktock-old` can be archived. Not a code step — record the outcome in the project doc.
+
+---
+
+## Verification summary
+
+| Spec requirement | Covered by |
+|---|---|
+| segment.go pure port (Ctx, Classify, CtxKey, Step) + table tests | Tasks 2, 3 |
+| Record byte-compatible `{start,end,secs,app,title,url,domain,idle}` | Task 4 (pinned against a real Python-written line) |
+| log.go WriteSegment (min-secs drop, daily file, mkdir) | Task 4 |
+| darwin.go OS IO + !darwin stub, timeouts, browser map, winctx fallback | Task 10 |
+| run.go poll loop + SIGTERM/SIGINT flush | Task 11 |
+| config Tracking block (defaults 5/900/15, tolerant) | Task 1 |
+| config calendars show/hide | Task 5 |
+| `tt autotrack`, `--once`, `--list-calendars` | Task 12 |
+| Makefile swiftc targets, graceful skip, vendored sources | Task 13 |
+| launchd template + cutover + rollback | Task 14 |
+| activity.Segment/Event, LoadDay tolerant parse | Task 6 |
+| LoadCalendar + CalendarFilter (show/hide, case-insensitive) | Task 7 |
+| paintActivity (labels, continuation, overlap precedence) | Task 8 |
+| grid Model activity field, rebuild/load/day-nav wiring, View render (accent, dimmed idle, ▓ band wins) | Task 9 |
+| integration/cutover, archive note | Task 14 |
docs/superpowers/specs/2026-07-08-tt-autotrack-daemon-and-activity-timeline-design.md +214 −0
@@ -0,0 +1,214 @@
+# tt autotrack daemon + activity/calendar timeline — design
+
+- **Date:** 2026-07-08
+- **Status:** Draft (awaiting user review)
+- **Repo:** `~/Developer/Home/ticktock-go` (`module ticktock`, binary `tt`)
+- **Supersedes:** the Python daemon in `ticktock-old/daemon/autotrack.py` and the
+  `tock-day` calendar merge. This is the parity port that lets `ticktock-old` be archived.
+
+## Goal (plain language)
+
+A background daemon quietly records what the user is doing — frontmost app, window
+title, active browser tab, and idle/away state — and appends it to a daily log. The
+`tt` timeline (grid) view then shows that captured activity in its ACTIVITY column,
+side by side with the day's calendar events, in a clean readable form. The user reads
+the timeline to see where their time actually went and to log it into tock.
+
+The user does not interact with the daemon directly; it runs under launchd. tock stays
+the source of truth — the daemon writes only to its own staging log, never to tock
+(suggest-then-confirm).
+
+## Background
+
+`ticktock-old` (Python) already does this: `daemon/autotrack.py` polls context and writes
+`~/.local/share/ticktock/activity-YYYY-MM-DD.jsonl`; `tock-day` merged that log with
+work-calendar events from a compiled Swift `cal-events` (EventKit) helper. tt has replaced
+the day/grid views in Go but has neither the daemon nor the activity/calendar reader. This
+design ports both, natively, into the single `tt` binary.
+
+The daemon and reader share the existing directories unchanged:
+- Config: `~/.config/ticktock/config.json`
+- Data: `~/.local/share/ticktock/activity-YYYY-MM-DD.jsonl`
+
+Because the paths are unchanged, cutover is seamless: the Go daemon writes the same stream
+the Python one did, and the reader sees one continuous history.
+
+## Non-goals (this port)
+
+- Writing suggestions back into tock automatically. The ACTIVITY column is display-only in
+  this port; turning a segment into a tock entry stays a later step.
+- Rule-teaching / activity-inspector / suggestion-mapping (the rest of M2c).
+- Non-macOS platforms. All OS integration is darwin-only behind build tags; the pure
+  segmentation and log/calendar parsing are portable and unit-tested on any platform.
+
+## Architecture
+
+Two subsystems, both inside the `tt` binary.
+
+```
+                          ┌──────────────────────────────┐
+  launchd ── tt autotrack │ internal/autotrack            │
+                          │  darwin IO ─▶ Classify ─▶ Step │──▶ activity-YYYY-MM-DD.jsonl
+                          └──────────────────────────────┘            │
+                                                                      ▼
+  cal-events (Swift/EventKit) ──▶ calendar JSON ────┐        ┌──────────────────┐
+                                                    ├──────▶ │ internal/activity │
+                          activity-YYYY-MM-DD.jsonl ─┘        │  LoadDay + merge  │
+                                                             └──────────────────┘
+                                                                      │
+                                                                      ▼
+                                                     internal/tui/grid ACTIVITY column
+```
+
+### A. Writer — `tt autotrack`
+
+New package `internal/autotrack`:
+
+- **`segment.go` (portable, pure).** Direct port of the Python core, clock injected so it
+  is deterministic and table-testable:
+  - `Ctx` — `{App, Title, URL, Domain string; Idle bool}`.
+  - `Segment` — `{Ctx; Start, End time.Time}`.
+  - `Classify(idle int, raw *Ctx, idleGrace int, locked bool) *Ctx` — locked or an
+    away-app (`loginwindow`, `ScreenSaverEngine`) ⇒ idle; `idle >= idleGrace` ⇒ idle;
+    otherwise the active context passes through (present-but-passive counts as active).
+  - `CtxKey(*Ctx)` — identity of "same activity": idle is its own key; active is
+    `(app, domain-or-title)`.
+  - `Step(cur *Segment, curKey key, now time.Time, idle int, ctx *Ctx, grace int, hard bool)`
+    `→ (cur, curKey, emitted *Segment)` — the segmentation state machine. Context change
+    closes the open segment and opens a new one; same context extends it. Idle transition
+    anchors the boundary: hard idle at `now-idle`; soft idle at `now-(idle-grace)` (grace
+    credited to the active app). Boundary clamped to `[cur.Start, now]` — never negative,
+    never future.
+  - `Record` encoding: `{start, end, secs, app, title, url, domain, idle}`, ISO-8601
+    seconds precision — byte-compatible with the Python output.
+- **`darwin.go` (build tag `darwin`).** OS sampling, same shell-outs the Python used:
+  - front app/title via `osascript` System Events;
+  - active browser URL via a per-browser `osascript` map (Safari/Chrome/Brave/Edge/Arc/Dia);
+  - HID idle seconds via `ioreg -c IOHIDSystem` (`HIDIdleTime`);
+  - screen-locked via `ioreg … -k CGSSessionScreenIsLocked`;
+  - richer window title via the bundled `winctx` Swift helper (falls back to System Events
+    when the helper is missing/denied).
+  - A `stub.go` (`!darwin`) returns empty context so the package builds and tests run
+    everywhere.
+- **`log.go` (portable).** `WriteSegment(dir, seg, minSecs)` — drops sub-`minSecs` segments,
+  else appends one JSON line to `activity-<seg.Start date>.jsonl`, creating the dir.
+- **`run.go`.** Poll loop: sample → `Classify` → `Step` → `WriteSegment(emitted)` → sleep.
+  SIGTERM/SIGINT flush the open segment before exit (parity with the Python daemon).
+
+`internal/config` gains a `Tracking` block read from the same file:
+
+```go
+type Tracking struct { PollSecs, IdleGraceSecs, MinSecs int } // defaults 5, 900, 15
+```
+
+read from `config.json`'s existing `tracking` object; missing/malformed keys fall back to
+defaults, matching the Python `load_tracking`.
+
+`cmd/ticktock` gains an `autotrack` subcommand:
+- `tt autotrack` — run the poll loop (what launchd invokes).
+- `tt autotrack --once` — sample once and print the classified context (parity with the
+  Python `--once`), for quick permission/sanity checks.
+- `tt autotrack --list-calendars` — print the distinct calendar names `cal-events` reports
+  for today, so the user can copy exact names into `calendars.show` / `calendars.hide`.
+
+### B. Reader — ACTIVITY column (activity + calendar)
+
+New package `internal/activity`:
+
+- **`Segment`** — `{Start, End time.Time; App, Title, URL, Domain string; Idle bool}`.
+- **`Event`** — `{Start, End time.Time; Title, Calendar string}` (a calendar event).
+- **`LoadDay(dataDir, date string) ([]Segment, error)`** — reads
+  `activity-<date>.jsonl`, tolerant parse (skip malformed lines, never fail the view on one
+  bad row). Missing file ⇒ empty slice, no error.
+- **`LoadCalendar(bin, date string, filter CalendarFilter) ([]Event, error)`** — runs the
+  `cal-events` helper for `date`, parses its JSON array, and keeps only events whose
+  `calendar` passes `filter`. Helper missing / access denied / bad output ⇒ empty slice,
+  no error (the timeline still renders without calendar data).
+
+**Calendar selection.** The user keeps several calendars for different purposes and must be
+able to silence the noisy ones. `cal-events` already returns every event tagged with its
+`calendar` name, so filtering is config-driven in the reader — no change to the helper.
+`internal/config` gains a `calendars` block:
+
+```jsonc
+"calendars": {
+  "show": ["Work", "Focus"]   // allowlist: only these calendars appear in the timeline
+}
+```
+
+Semantics: if `show` is non-empty, only listed calendars pass (case-insensitive exact match
+on the calendar name); if `show` is absent or empty, all calendars pass (current behaviour).
+An optional `"hide": [...]` blocklist is applied after `show` for the "everything except a
+couple" case. To discover the exact names, `tt autotrack --list-calendars` prints the
+distinct calendar names `cal-events` reports for today, so the user can copy them into config.
+`CalendarFilter` is built from this config and passed into `LoadCalendar`.
+
+Grid integration (mirrors the existing LOGGED painting):
+
+- **`paintActivity(g Grid, segs []Segment, events []Event) []Cell`** — one label on a
+  segment/event's first covered slot, continuation glyph on later covered slots, exactly as
+  `paintLogged` does for tock entries. Per-slot precedence when things overlap:
+  1. a calendar event's label at its start slot (the human-meaningful anchor),
+  2. else an active auto-tracked segment (`domain` or `title`),
+  3. else idle (`(idle)`, dimmed),
+  4. else the empty gap lane.
+  Label text: calendar → `▪ <title>`; activity → `<domain-or-title>`; idle → `(idle)`.
+- **`grid.Model`** gains an `activity []Cell` field alongside `logged`. `rebuild()` paints
+  it; the day loader (`load`, and the `n`/`p`/`t`/`r` day-nav + reload paths) fetches the
+  day's segments and calendar events alongside tock entries.
+- **`View`** renders the painted activity cell in the ACTIVITY column in place of today's
+  static `░` gap lane. The `▓` selection band still overrides while a range is being
+  selected; idle and continuation glyphs render dimmed; calendar events use a distinct
+  accent color so they read apart from auto-tracked activity.
+
+### Native helpers (Swift) build
+
+`winctx` (AX window context) and `cal-events` (EventKit) are compiled Swift binaries. Vendor
+the two `.swift` sources into the tt repo (e.g. `native/winctx.swift`, `native/cal-events.swift`)
+and add Makefile targets that build them to `bin/winctx` and `bin/cal-events` via `swiftc`.
+The build **skips gracefully** when `swiftc` is unavailable (prints a note, continues) — the
+daemon degrades to System Events titles and the reader simply shows no calendar events, so a
+machine without Xcode tools still builds and runs `tt`.
+
+### launchd cutover
+
+- `deploy/autotrack.plist.template` with `ProgramArguments = [<tt path>, autotrack]`, label
+  unchanged (`com.humdrum.ticktock.autotrack`), same `StandardOut/ErrPath` under
+  `~/.local/share/ticktock/`.
+- An install step renders the template with the installed `tt` path and repoints the running
+  agent (`launchctl bootout` + `bootstrap`) from the Python script to `tt autotrack`.
+- **Rollback:** the Python daemon in `ticktock-old` is untouched; if the Go daemon
+  misbehaves, repoint the plist back to `autotrack.py` and restart. Once the Go daemon is
+  verified over a normal day, `ticktock-old` can be archived.
+
+## Error handling / degradation
+
+- Every OS call has a timeout and returns empty on failure (never panics the loop) — matches
+  the Python behaviour.
+- Missing `winctx` → System Events titles. Missing/denied `cal-events` → no calendar lane.
+  Missing activity log → empty ACTIVITY column. None of these are errors to the user.
+- The reader skips malformed jsonl lines rather than failing the whole view.
+
+## Testing
+
+- **Segmentation:** Go table tests port the Python cases — idle-grace pass-through, soft vs
+  hard idle boundary anchoring, sub-`MIN_SECS` drop, context-change emit, boundary clamping.
+- **Log I/O:** round-trip a `Record` and re-read via `LoadDay`; assert byte-shape parity with
+  a sample line from a real `ticktock-old` activity file.
+- **Calendar parse + filter:** parse a captured `cal-events` JSON sample into `[]Event`;
+  assert `show` allowlist and `hide` blocklist keep/drop the right calendars (empty `show` ⇒
+  all pass).
+- **paintActivity:** table tests for label placement, continuation, and overlap precedence.
+- **Manual/integration:** `tt autotrack --once` prints current context; a short live run
+  appends valid jsonl; the grid renders today's activity + calendar; then cut launchd over
+  and confirm the Go daemon writes the same stream.
+
+## Cutover & rollback (summary)
+
+1. Build `tt` + `bin/{winctx,cal-events}`.
+2. Verify tests + `tt autotrack --once` + grid rendering on today's real data.
+3. Repoint launchd to `tt autotrack`; confirm it writes today's log.
+4. Run a normal day; confirm parity.
+5. Archive `ticktock-old`.
+
+Rollback at any point: repoint the plist back to `autotrack.py`.