▍ humdrum codex / dots v1.0.0

docs: spec blockSize amendment + v1 implementation plan (11 tasks)

0e81826e8a1a7b970964665f843f0dea85ab2a89
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-07-29 15:42

parent 667fea5d

2 files changed

docs/superpowers/plans/2026-07-29-dots-cli-v1.md +1952 −0
@@ -0,0 +1,1952 @@
+# dots-cli v1 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:** A Go CLI (`dots`) for reflex-speed Dots logging — `log`, `clear`, `note`, `today`, `types`, `auth` — plus a small server PR adding CLI-token auth.
+
+**Architecture:** Thin online-only client: every command is 1–2 HTTP calls. Reads ride the existing `GET /api/widget/data` payload (day, blocks, planBlocks, activityTypes). Writes reuse `PUT /api/days/[date]/blocks` and `POST /api/days/[date]/notes`, which gain Bearer-token auth via a new `cli_tokens` table cloned from the existing `widget_tokens` machinery. All slot/match/diff logic is pure functions in `internal/day`.
+
+**Tech Stack:** Go 1.23+, cobra, lipgloss, zalando/go-keyring, golang.org/x/term. Server side: Next.js 15 App Router, Supabase, Clerk (existing Dots stack).
+
+## Global Constraints
+
+- Spec: `docs/superpowers/specs/2026-07-29-dots-cli-design.md` — binding.
+- **Both repos are jj-colocated. Commit with `jj commit -m "..."`, never `git commit`. Never use interactive jj flags.**
+- Server repo: `/Users/kortum/Developer/Humdrum-One/Dots` (github `humdrum-tiv/dots`). CLI repo: `/Users/kortum/Developer/Humdrum-One/dots-cli`.
+- Server repo has NO test runner. Server tasks verify with `npx tsc --noEmit` (and `npm run build` where stated). Do not add a test framework.
+- CLI repo: table-driven Go tests; `go test ./...` must pass at the end of every task.
+- **No silent overwrite:** any write that changes or removes an existing logged block prints a diff table and requires `y` confirmation or `--yes`. Non-TTY without `--yes` = abort, exit 1.
+- `blockIndex = minutesSinceMidnight / day.blockSize`; blockSize ∈ {15, 30, 60}, read from the day payload. Never hardcode 30.
+- Server URL: `https://dots.humdrum.one`, overridable via `DOTS_URL` env var.
+- Go module path: `github.com/humdrum-tiv/dots-cli`.
+- Errors: one line to stderr, exit code 1. No retries.
+
+---
+
+## Server tasks (repo: `/Users/kortum/Developer/Humdrum-One/Dots`)
+
+Work on a bookmark: from repo root run `jj new main` then `jj bookmark create cli-tokens -r @`.
+
+### Task 1: `cli_tokens` table + db lib
+
+**Files:**
+- Create: `supabase/migrations/20260729000000_cli_tokens.sql`
+- Create: `src/lib/db/cliTokens.ts`
+- Modify: `src/types/index.ts` (append near `WidgetTokenRow`, ~line 532)
+
+**Interfaces:**
+- Produces: `getCliToken(userId): Promise<CliToken | null>`, `generateCliToken(userId): Promise<CliToken>`, `revokeCliToken(userId): Promise<void>`, `getUserIdByCliToken(token): Promise<string | null>` — exact mirrors of the widget-token functions in `src/lib/db/widgetTokens.ts`.
+
+- [ ] **Step 1: Write the migration**
+
+```sql
+-- CLI tokens: allow the dots CLI to authenticate without Clerk sessions.
+-- Mirror of widget_tokens; separate table so revoking one doesn't kill the other.
+
+CREATE TABLE cli_tokens (
+  id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
+  user_id text NOT NULL,
+  token text NOT NULL UNIQUE,
+  created_at timestamptz DEFAULT now(),
+  UNIQUE(user_id)
+);
+ALTER TABLE cli_tokens ENABLE ROW LEVEL SECURITY;
+CREATE POLICY deny_all ON cli_tokens FOR ALL USING (false);
+CREATE INDEX idx_cli_tokens_token ON cli_tokens(token);
+```
+
+- [ ] **Step 2: Add row/domain types**
+
+In `src/types/index.ts`, directly after the `WidgetToken` interface (search for `export interface WidgetTokenRow`), append:
+
+```ts
+export interface CliTokenRow {
+  id: string;
+  user_id: string;
+  token: string;
+  created_at: string;
+}
+
+export interface CliToken {
+  id: string;
+  userId: string;
+  token: string;
+  createdAt: string;
+}
+```
+
+(Match the existing `WidgetTokenRow`/`WidgetToken` field shapes exactly — open the file and copy their structure if they differ from the above.)
+
+- [ ] **Step 3: Write `src/lib/db/cliTokens.ts`**
+
+Copy `src/lib/db/widgetTokens.ts` wholesale, then rename: `widget_tokens` → `cli_tokens`, `WidgetToken` → `CliToken`, `WidgetTokenRow` → `CliTokenRow`, `getWidgetToken` → `getCliToken`, `generateWidgetToken` → `generateCliToken`, `revokeWidgetToken` → `revokeCliToken`, `getUserIdByWidgetToken` → `getUserIdByCliToken`. Same `randomBytes(32).toString("hex")` token, same delete-then-insert replace behavior.
+
+- [ ] **Step 4: Typecheck**
+
+Run: `npx tsc --noEmit` (repo root). Expected: clean.
+
+- [ ] **Step 5: Commit**
+
+```bash
+jj commit -m "feat: cli_tokens table + db lib (mirror of widget tokens)"
+```
+
+**Note for the final task:** the migration must be applied to Supabase before live verification (`supabase db push` or dashboard SQL editor — the user does this; flag it, don't run it).
+
+### Task 2: shared token-auth helper, applied to the three routes
+
+**Files:**
+- Create: `src/lib/apiAuth.ts`
+- Modify: `src/app/api/widget/data/route.ts` (top ~25 lines)
+- Modify: `src/app/api/days/[date]/blocks/route.ts` (PUT only)
+- Modify: `src/app/api/days/[date]/notes/route.ts` (POST only)
+
+**Interfaces:**
+- Consumes: `getUserIdByCliToken` (Task 1), `getUserIdByWidgetToken` (existing).
+- Produces: `resolveUserId(request: Request): Promise<string | null>` — Clerk session first, else CLI bearer token. `resolveSubscribedUserId(request: Request): Promise<string | null>` — same, but also requires `publicMetadata.subscribed === true` (via `currentUser()` on the Clerk path, `clerkClient().users.getUser(userId)` on the token path).
+
+- [ ] **Step 1: Write `src/lib/apiAuth.ts`**
+
+```ts
+import { auth, clerkClient, currentUser } from "@clerk/nextjs/server";
+import { getUserIdByCliToken } from "@/lib/db/cliTokens";
+
+function bearerToken(request: Request): string | null {
+  const h = request.headers.get("authorization");
+  return h?.startsWith("Bearer ") ? h.slice(7) : null;
+}
+
+/** Clerk session first, else CLI bearer token. Null if neither. */
+export async function resolveUserId(request: Request): Promise<string | null> {
+  const { userId } = await auth();
+  if (userId) return userId;
+  const token = bearerToken(request);
+  if (!token) return null;
+  return getUserIdByCliToken(token);
+}
+
+/** Like resolveUserId, but also requires an active subscription. */
+export async function resolveSubscribedUserId(request: Request): Promise<string | null> {
+  const { userId } = await auth();
+  if (userId) {
+    const user = await currentUser();
+    return user?.publicMetadata?.subscribed === true ? userId : null;
+  }
+  const token = bearerToken(request);
+  if (!token) return null;
+  const tokenUserId = await getUserIdByCliToken(token);
+  if (!tokenUserId) return null;
+  const client = await clerkClient();
+  const user = await client.users.getUser(tokenUserId);
+  return user.publicMetadata?.subscribed === true ? tokenUserId : null;
+}
+```
+
+(Check the repo's `@clerk/nextjs` version for whether `clerkClient` is called as a function — `^6.0.0` uses `await clerkClient()`. If tsc complains, adjust to the version's calling convention.)
+
+- [ ] **Step 2: Widen `GET /api/widget/data` to accept CLI tokens**
+
+In `src/app/api/widget/data/route.ts`, replace the token-resolution block at the top of `GET` (the lines that read the `authorization` header and call `getUserIdByWidgetToken`) with:
+
+```ts
+const authHeader = request.headers.get("authorization");
+const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
+
+if (!token) {
+  return NextResponse.json({ error: "Missing Authorization header" }, { status: 401 });
+}
+
+const userId = (await getUserIdByWidgetToken(token)) ?? (await getUserIdByCliToken(token));
+if (!userId) {
+  return NextResponse.json({ error: "Invalid token" }, { status: 401 });
+}
+```
+
+Add `import { getUserIdByCliToken } from "@/lib/db/cliTokens";` to the imports. Everything below stays untouched.
+
+- [ ] **Step 3: Token-auth on blocks PUT**
+
+In `src/app/api/days/[date]/blocks/route.ts`, in `PUT` only, replace:
+
+```ts
+const { userId } = await auth();
+if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+```
+
+with:
+
+```ts
+const userId = await resolveUserId(request);
+if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+```
+
+Add `import { resolveUserId } from "@/lib/apiAuth";`. Leave `GET` on plain Clerk `auth()`. If `auth` is now unused, drop the import.
+
+- [ ] **Step 4: Token-auth on notes POST**
+
+In `src/app/api/days/[date]/notes/route.ts`, in `POST` only, replace `const userId = await getSubscribedUserId();` with:
+
+```ts
+const userId = await resolveSubscribedUserId(request);
+```
+
+Add `import { resolveSubscribedUserId } from "@/lib/apiAuth";`. Leave `GET` on `getSubscribedUserId()`.
+
+- [ ] **Step 5: Typecheck + commit**
+
+Run: `npx tsc --noEmit`. Expected: clean.
+
+```bash
+jj commit -m "feat: accept CLI bearer tokens on widget/data, blocks PUT, notes POST"
+```
+
+### Task 3: `/api/cli/token` route + `/cli` page, push + PR
+
+**Files:**
+- Create: `src/app/api/cli/token/route.ts`
+- Create: `src/app/cli/page.tsx`
+- Create: `src/components/settings/CliTokenSettings.tsx`
+
+**Interfaces:**
+- Consumes: Task 1 lib functions.
+- Produces: `GET/POST/DELETE /api/cli/token` (Clerk-session-only, JSON `{ token }`), page at `/cli`.
+
+- [ ] **Step 1: Write `src/app/api/cli/token/route.ts`**
+
+Copy `src/app/api/widget/token/route.ts` wholesale; swap the three imports to `@/lib/db/cliTokens` (`getCliToken`, `generateCliToken`, `revokeCliToken`) and update the `console.error` labels to `/api/cli/token`. Clerk-session auth stays as-is — tokens are only ever minted from a browser session.
+
+- [ ] **Step 2: Write the settings component + page**
+
+First read `src/components/settings/ScriptableWidgetSettings.tsx` to see how the existing widget-token UI fetches/generates/revokes and its styling. Build `CliTokenSettings.tsx` the same way against `/api/cli/token`: show current token (fetched on mount from `GET`), a Generate button (`POST`, replaces), a Copy button (`navigator.clipboard.writeText`), a Revoke button (`DELETE`), and a one-line hint: `Paste this token into \`dots auth\` in your terminal.` Reuse the same class names / UI primitives that file uses so it matches the app.
+
+Then `src/app/cli/page.tsx`: follow the pattern of an existing signed-in page (look at how `src/app/page.tsx` guards auth) and render `<CliTokenSettings />` with a page title "CLI access". If Clerk middleware protects pages by default, no extra guard code is needed — confirm against `src/middleware.ts`.
+
+- [ ] **Step 3: Typecheck + build**
+
+Run: `npx tsc --noEmit` then `npm run build`. Expected: both clean.
+
+- [ ] **Step 4: Commit, push, PR**
+
+```bash
+jj commit -m "feat: /cli token page + /api/cli/token route"
+jj git push -b cli-tokens --allow-new
+gh pr create --repo humdrum-tiv/dots --head cli-tokens --title "CLI token auth (dots-cli v1)" --body "cli_tokens table (mirror of widget_tokens), resolveUserId/resolveSubscribedUserId helper, CLI bearer accepted on widget/data GET + blocks PUT + notes POST, /cli token management page. Server half of dots-cli v1 (spec in dots-cli repo).
+
+🤖 Generated with [Claude Code](https://claude.com/claude-code)"
+```
+
+Expected: PR opens. **Flag to the user:** merge + `supabase db push` (migration) must happen before Task 10's live verification.
+
+---
+
+## CLI tasks (repo: `/Users/kortum/Developer/Humdrum-One/dots-cli`)
+
+### Task 4: scaffold Go module + cobra root
+
+**Files:**
+- Create: `go.mod`, `cmd/dots/main.go`, `internal/config/config.go`, `.gitignore`
+
+**Interfaces:**
+- Produces: `config.BaseURL() string` (env `DOTS_URL` else `https://dots.humdrum.one`), cobra root command `dots` that later tasks hang subcommands on via `root.AddCommand`. `main.go` exposes `func newRootCmd() *cobra.Command` (in package main) so tests can exercise wiring.
+
+- [ ] **Step 1: Init module + deps**
+
+```bash
+go mod init github.com/humdrum-tiv/dots-cli
+go get github.com/spf13/cobra@latest github.com/charmbracelet/lipgloss@latest github.com/zalando/go-keyring@latest golang.org/x/term@latest
+```
+
+- [ ] **Step 2: Write `internal/config/config.go`**
+
+```go
+package config
+
+import "os"
+
+const defaultBaseURL = "https://dots.humdrum.one"
+
+// BaseURL returns the Dots server root, without trailing slash.
+func BaseURL() string {
+	if v := os.Getenv("DOTS_URL"); v != "" {
+		return v
+	}
+	return defaultBaseURL
+}
+```
+
+- [ ] **Step 3: Write `cmd/dots/main.go`**
+
+```go
+package main
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/spf13/cobra"
+)
+
+func newRootCmd() *cobra.Command {
+	root := &cobra.Command{
+		Use:           "dots",
+		Short:         "Log your day from the terminal",
+		SilenceUsage:  true,
+		SilenceErrors: true,
+	}
+	return root
+}
+
+func main() {
+	if err := newRootCmd().Execute(); err != nil {
+		fmt.Fprintln(os.Stderr, err)
+		os.Exit(1)
+	}
+}
+```
+
+- [ ] **Step 4: `.gitignore`**
+
+```
+dots
+dist/
+```
+
+- [ ] **Step 5: Verify + commit**
+
+Run: `go build ./... && go run ./cmd/dots --help`. Expected: help text prints.
+
+```bash
+jj commit -m "feat: scaffold go module, config, cobra root"
+```
+
+### Task 5: slot math (`internal/day`)
+
+**Files:**
+- Create: `internal/day/slots.go`, `internal/day/slots_test.go`
+
+**Interfaces:**
+- Produces (package `day`):
+  - `ParseTarget(arg string, blockSize int, now time.Time) ([]int, error)` — arg may be `""` (current slot), `"H:MM"`/`"HH:MM"`, or `"start-end"`; returns canonical block indices for the day's blockSize.
+  - `IsTimeSyntax(arg string) bool` — whether a positional arg should be treated as time/range rather than activity.
+  - `SlotLabel(index, blockSize int) string` — `"14:30"` style label for output.
+
+- [ ] **Step 1: Write failing tests**
+
+`internal/day/slots_test.go`:
+
+```go
+package day
+
+import (
+	"reflect"
+	"testing"
+	"time"
+)
+
+func at(h, m int) time.Time {
+	return time.Date(2026, 7, 29, h, m, 0, 0, time.UTC)
+}
+
+func TestParseTarget(t *testing.T) {
+	cases := []struct {
+		name      string
+		arg       string
+		blockSize int
+		now       time.Time
+		want      []int
+		wantErr   bool
+	}{
+		{"empty is current slot", "", 30, at(14, 47), []int{29}, false},
+		{"single time", "14:30", 30, at(0, 0), []int{29}, false},
+		{"snap down", "14:47", 30, at(0, 0), []int{29}, false},
+		{"single digit hour", "9:00", 30, at(0, 0), []int{18}, false},
+		{"leading zero", "09:00", 30, at(0, 0), []int{18}, false},
+		{"range end exclusive", "13:00-15:00", 30, at(0, 0), []int{26, 27, 28, 29}, false},
+		{"range at 15min blocks", "13:00-14:00", 15, at(0, 0), []int{52, 53, 54, 55}, false},
+		{"range at 60min blocks", "13:30-15:00", 60, at(0, 0), []int{13, 14}, false},
+		{"midnight slot", "0:00", 30, at(0, 0), []int{0}, false},
+		{"last slot", "23:30", 30, at(0, 0), []int{47}, false},
+		{"end before start", "15:00-13:00", 30, at(0, 0), nil, true},
+		{"end equals start", "13:00-13:00", 30, at(0, 0), nil, true},
+		{"hour out of range", "24:00", 30, at(0, 0), nil, true},
+		{"minute out of range", "12:60", 30, at(0, 0), nil, true},
+		{"garbage", "archer", 30, at(0, 0), nil, true},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			got, err := ParseTarget(c.arg, c.blockSize, c.now)
+			if c.wantErr != (err != nil) {
+				t.Fatalf("err = %v, wantErr %v", err, c.wantErr)
+			}
+			if !c.wantErr && !reflect.DeepEqual(got, c.want) {
+				t.Fatalf("got %v, want %v", got, c.want)
+			}
+		})
+	}
+}
+
+func TestIsTimeSyntax(t *testing.T) {
+	yes := []string{"14:30", "9:00", "09:00", "13:00-15:00"}
+	no := []string{"archer", "", "14", "14:3", "a-b", "14:30ish"}
+	for _, s := range yes {
+		if !IsTimeSyntax(s) {
+			t.Errorf("IsTimeSyntax(%q) = false, want true", s)
+		}
+	}
+	for _, s := range no {
+		if IsTimeSyntax(s) {
+			t.Errorf("IsTimeSyntax(%q) = true, want false", s)
+		}
+	}
+}
+
+func TestSlotLabel(t *testing.T) {
+	if got := SlotLabel(29, 30); got != "14:30" {
+		t.Fatalf("got %q", got)
+	}
+	if got := SlotLabel(0, 30); got != "0:00" {
+		t.Fatalf("got %q", got)
+	}
+	if got := SlotLabel(55, 15); got != "13:45" {
+		t.Fatalf("got %q", got)
+	}
+}
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `go test ./internal/day/ -v`. Expected: FAIL (undefined functions).
+
+- [ ] **Step 3: Implement `internal/day/slots.go`**
+
+```go
+package day
+
+import (
+	"fmt"
+	"regexp"
+	"time"
+)
+
+var timeRe = regexp.MustCompile(`^(\d{1,2}):(\d{2})$`)
+var rangeRe = regexp.MustCompile(`^(\d{1,2}:\d{2})-(\d{1,2}:\d{2})$`)
+
+// IsTimeSyntax reports whether arg looks like a time or time range,
+// deciding whether a positional arg is a target or an activity name.
+func IsTimeSyntax(arg string) bool {
+	return timeRe.MatchString(arg) || rangeRe.MatchString(arg)
+}
+
+func parseMinutes(s string) (int, error) {
+	m := timeRe.FindStringSubmatch(s)
+	if m == nil {
+		return 0, fmt.Errorf("invalid time %q (want H:MM)", s)
+	}
+	var h, min int
+	fmt.Sscanf(m[1], "%d", &h)
+	fmt.Sscanf(m[2], "%d", &min)
+	if h > 23 || min > 59 {
+		return 0, fmt.Errorf("invalid time %q", s)
+	}
+	return h*60 + min, nil
+}
+
+// ParseTarget resolves a time argument to canonical block indices.
+// arg "" means the slot containing now. Ranges are end-exclusive.
+func ParseTarget(arg string, blockSize int, now time.Time) ([]int, error) {
+	if arg == "" {
+		return []int{(now.Hour()*60 + now.Minute()) / blockSize}, nil
+	}
+	if m := rangeRe.FindStringSubmatch(arg); m != nil {
+		start, err := parseMinutes(m[1])
+		if err != nil {
+			return nil, err
+		}
+		end, err := parseMinutes(m[2])
+		if err != nil {
+			return nil, err
+		}
+		if end <= start {
+			return nil, fmt.Errorf("range end %s is not after start %s", m[2], m[1])
+		}
+		first := start / blockSize
+		last := (end - 1) / blockSize
+		out := make([]int, 0, last-first+1)
+		for i := first; i <= last; i++ {
+			out = append(out, i)
+		}
+		return out, nil
+	}
+	mins, err := parseMinutes(arg)
+	if err != nil {
+		return nil, err
+	}
+	return []int{mins / blockSize}, nil
+}
+
+// SlotLabel renders a block index as its start time, e.g. "14:30".
+func SlotLabel(index, blockSize int) string {
+	mins := index * blockSize
+	return fmt.Sprintf("%d:%02d", mins/60, mins%60)
+}
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `go test ./internal/day/ -v`. Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+jj commit -m "feat: slot math — target parsing, time syntax detection, labels"
+```
+
+### Task 6: activity fuzzy matching (`internal/day`)
+
+**Files:**
+- Create: `internal/day/match.go`, `internal/day/match_test.go`
+
+**Interfaces:**
+- Consumes: nothing from other tasks (pure).
+- Produces: `type ActivityType struct { ID, Name, Color string }` (package `day`; the API client in Task 8 returns this type) and `MatchActivity(query string, types []ActivityType) (matches []ActivityType)` — case-insensitive; exact match wins outright (len 1), else all prefix matches, else all substring matches, else empty. Caller decides what to do with 0/1/many.
+
+- [ ] **Step 1: Write failing tests**
+
+`internal/day/match_test.go`:
+
+```go
+package day
+
+import "testing"
+
+var types = []ActivityType{
+	{ID: "1", Name: "ARCHER", Color: "#c04000"},
+	{ID: "2", Name: "Archery practice", Color: "#00c040"},
+	{ID: "3", Name: "Foofaraw", Color: "#4000c0"},
+	{ID: "4", Name: "Reading", Color: "#404040"},
+}
+
+func names(ms []ActivityType) []string {
+	out := make([]string, len(ms))
+	for i, m := range ms {
+		out[i] = m.Name
+	}
+	return out
+}
+
+func TestMatchActivity(t *testing.T) {
+	cases := []struct {
+		query string
+		want  []string
+	}{
+		{"archer", []string{"ARCHER"}},          // exact beats prefix
+		{"arch", []string{"ARCHER", "Archery practice"}}, // prefix, both
+		{"foo", []string{"Foofaraw"}},
+		{"ading", []string{"Reading"}},          // substring fallback
+		{"xyz", []string{}},
+		{"READING", []string{"Reading"}},
+	}
+	for _, c := range cases {
+		got := names(MatchActivity(c.query, types))
+		if len(got) != len(c.want) {
+			t.Fatalf("%q: got %v, want %v", c.query, got, c.want)
+		}
+		for i := range got {
+			if got[i] != c.want[i] {
+				t.Fatalf("%q: got %v, want %v", c.query, got, c.want)
+			}
+		}
+	}
+}
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `go test ./internal/day/ -run TestMatchActivity -v`. Expected: FAIL.
+
+- [ ] **Step 3: Implement `internal/day/match.go`**
+
+```go
+package day
+
+import "strings"
+
+// ActivityType is the CLI-side view of a Dots activity type.
+type ActivityType struct {
+	ID    string
+	Name  string
+	Color string
+}
+
+// MatchActivity finds candidate types for a query: exact (case-insensitive)
+// wins outright; otherwise all prefix matches; otherwise all substring matches.
+func MatchActivity(query string, types []ActivityType) []ActivityType {
+	q := strings.ToLower(query)
+	var prefix, substr []ActivityType
+	for _, t := range types {
+		n := strings.ToLower(t.Name)
+		if n == q {
+			return []ActivityType{t}
+		}
+		if strings.HasPrefix(n, q) {
+			prefix = append(prefix, t)
+		} else if strings.Contains(n, q) {
+			substr = append(substr, t)
+		}
+	}
+	if len(prefix) > 0 {
+		return prefix
+	}
+	if substr == nil {
+		return []ActivityType{}
+	}
+	return substr
+}
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `go test ./internal/day/ -v`. Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+jj commit -m "feat: activity fuzzy matching (exact > prefix > substring)"
+```
+
+### Task 7: plan-fill + overwrite diff (`internal/day`)
+
+**Files:**
+- Create: `internal/day/resolve.go`, `internal/day/resolve_test.go`
+
+**Interfaces:**
+- Consumes: `SlotLabel` (Task 5).
+- Produces (package `day`):
+  - `type Change struct { Index int; FromID, ToID *string }` — nil = empty/cleared.
+  - `ResolveLog(targets []int, activityID *string, blocks, planBlocks map[int]string) (assignments map[int]*string, overwrites []Change, err error)` — activityID nil ⇒ plan-fill; error `ErrNoPlan` variant listing slots with no plan block (all-or-nothing).
+  - `ResolveClear(targets []int, blocks map[int]string) (assignments map[int]*string, cleared []Change)` — only already-logged slots are cleared (assignments contains nil for each logged target; empty targets are no-ops).
+- Maps use only logged/planned entries: index present ⇒ non-empty activityTypeId. The API client (Task 8) delivers this shape.
+
+- [ ] **Step 1: Write failing tests**
+
+`internal/day/resolve_test.go`:
+
+```go
+package day
+
+import (
+	"strings"
+	"testing"
+)
+
+func sp(s string) *string { return &s }
+
+func TestResolveLogExplicitActivity(t *testing.T) {
+	blocks := map[int]string{28: "old"}
+	asg, over, err := ResolveLog([]int{28, 29}, sp("new"), blocks, nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(asg) != 2 || *asg[28] != "new" || *asg[29] != "new" {
+		t.Fatalf("assignments %v", asg)
+	}
+	if len(over) != 1 || over[0].Index != 28 || *over[0].FromID != "old" || *over[0].ToID != "new" {
+		t.Fatalf("overwrites %v", over)
+	}
+}
+
+func TestResolveLogSameActivityNoOverwrite(t *testing.T) {
+	blocks := map[int]string{28: "same"}
+	_, over, err := ResolveLog([]int{28}, sp("same"), blocks, nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(over) != 0 {
+		t.Fatalf("re-logging same activity should not count as overwrite: %v", over)
+	}
+}
+
+func TestResolveLogPlanFill(t *testing.T) {
+	plan := map[int]string{29: "planned-a", 30: "planned-b"}
+	asg, over, err := ResolveLog([]int{29, 30}, nil, map[int]string{}, plan)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if *asg[29] != "planned-a" || *asg[30] != "planned-b" {
+		t.Fatalf("assignments %v", asg)
+	}
+	if len(over) != 0 {
+		t.Fatalf("overwrites %v", over)
+	}
+}
+
+func TestResolveLogPlanFillMissingPlanAllOrNothing(t *testing.T) {
+	plan := map[int]string{29: "planned-a"}
+	_, _, err := ResolveLog([]int{29, 30, 31}, nil, map[int]string{}, plan)
+	if err == nil {
+		t.Fatal("want error for slots without plan")
+	}
+	msg := err.Error()
+	if !strings.Contains(msg, "15:00") || !strings.Contains(msg, "15:30") {
+		t.Fatalf("error should list offending slots (blockSize 30): %q", msg)
+	}
+}
+
+func TestResolveClear(t *testing.T) {
+	blocks := map[int]string{28: "a"}
+	asg, cleared := ResolveClear([]int{28, 29}, blocks)
+	if len(asg) != 1 || asg[28] != nil {
+		t.Fatalf("assignments %v", asg)
+	}
+	if len(cleared) != 1 || cleared[0].Index != 28 || *cleared[0].FromID != "a" || cleared[0].ToID != nil {
+		t.Fatalf("cleared %v", cleared)
+	}
+}
+```
+
+Note: the missing-plan error must render slot labels, which needs blockSize — give `ResolveLog` a fifth parameter `blockSize int` and update the test calls above to pass `30`. (Signature: `ResolveLog(targets []int, activityID *string, blocks, planBlocks map[int]string, blockSize int)`.)
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `go test ./internal/day/ -run TestResolve -v`. Expected: FAIL.
+
+- [ ] **Step 3: Implement `internal/day/resolve.go`**
+
+```go
+package day
+
+import (
+	"fmt"
+	"strings"
+)
+
+// Change describes one slot transition for the confirmation table.
+type Change struct {
+	Index  int
+	FromID *string // nil = was empty
+	ToID   *string // nil = cleared
+}
+
+// ResolveLog computes the write set for a log command. activityID nil means
+// plan-fill: every target slot takes its plan block's activity, and any slot
+// without one aborts the whole command (all-or-nothing).
+func ResolveLog(targets []int, activityID *string, blocks, planBlocks map[int]string, blockSize int) (map[int]*string, []Change, error) {
+	assignments := make(map[int]*string, len(targets))
+	var overwrites []Change
+
+	if activityID == nil {
+		var missing []string
+		for _, i := range targets {
+			if _, ok := planBlocks[i]; !ok {
+				missing = append(missing, SlotLabel(i, blockSize))
+			}
+		}
+		if len(missing) > 0 {
+			return nil, nil, fmt.Errorf("no planned activity at %s — nothing painted", strings.Join(missing, ", "))
+		}
+	}
+
+	for _, i := range targets {
+		to := activityID
+		if to == nil {
+			v := planBlocks[i]
+			to = &v
+		}
+		assignments[i] = to
+		if from, ok := blocks[i]; ok && from != *to {
+			f := from
+			overwrites = append(overwrites, Change{Index: i, FromID: &f, ToID: to})
+		}
+	}
+	return assignments, overwrites, nil
+}
+
+// ResolveClear computes the write set for a clear command. Only slots that
+// are actually logged are touched; clearing an empty slot is a no-op.
+func ResolveClear(targets []int, blocks map[int]string) (map[int]*string, []Change) {
+	assignments := make(map[int]*string)
+	var cleared []Change
+	for _, i := range targets {
+		if from, ok := blocks[i]; ok {
+			f := from
+			assignments[i] = nil
+			cleared = append(cleared, Change{Index: i, FromID: &f, ToID: nil})
+		}
+	}
+	return assignments, cleared
+}
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `go test ./internal/day/ -v`. Expected: PASS (all of slots, match, resolve).
+
+- [ ] **Step 5: Commit**
+
+```bash
+jj commit -m "feat: plan-fill resolution and overwrite/clear diff computation"
+```
+
+### Task 8: API client (`internal/api`)
+
+**Files:**
+- Create: `internal/api/client.go`, `internal/api/client_test.go`
+
+**Interfaces:**
+- Consumes: `day.ActivityType` (Task 6).
+- Produces (package `api`):
+  - `type Client struct { BaseURL, Token string; HTTP *http.Client }` and `New(baseURL, token string) *Client`.
+  - `type DayData struct { BlockSize int; Timezone string; Types []day.ActivityType; Blocks, PlanBlocks map[int]string }`
+  - `(*Client) GetDay(date, tz string) (*DayData, error)` — `GET /api/widget/data?date=...&tz=...`.
+  - `(*Client) WriteBlocks(date string, assignments map[int]*string) error` — `PUT /api/days/{date}/blocks`, JSON body `{"29":"typeid","30":null}`.
+  - `(*Client) AddNote(date, content string) error` — `POST /api/days/{date}/notes`, body `{"content":"..."}`.
+  - `ErrUnauthorized` sentinel (401) — commands map it to "not linked — run `dots auth`". 403 on notes maps to its own message ("subscription required").
+
+- [ ] **Step 1: Write failing tests**
+
+`internal/api/client_test.go` — httptest server with a fixture mirroring the real widget/data shape:
+
+```go
+package api
+
+import (
+	"encoding/json"
+	"errors"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+)
+
+const widgetFixture = `{
+  "day": {"blockSize": 30, "timeFormat": "24h", "timezone": "America/Los_Angeles"},
+  "activityTypes": [
+    {"id": "t1", "name": "ARCHER", "color": "#c04000", "isArchived": false, "displayOrder": 0},
+    {"id": "t2", "name": "Foofaraw", "color": "#4000c0", "isArchived": false, "displayOrder": 1}
+  ],
+  "blocks": {"28": "t1"},
+  "planBlocks": {"29": "t2"},
+  "events": [],
+  "extraEvents": {}
+}`
+
+func newTestServer(t *testing.T, handler http.HandlerFunc) (*httptest.Server, *Client) {
+	t.Helper()
+	srv := httptest.NewServer(handler)
+	t.Cleanup(srv.Close)
+	return srv, New(srv.URL, "tok123")
+}
+
+func TestGetDay(t *testing.T) {
+	_, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path != "/api/widget/data" {
+			t.Errorf("path %s", r.URL.Path)
+		}
+		if r.Header.Get("Authorization") != "Bearer tok123" {
+			t.Errorf("auth header %q", r.Header.Get("Authorization"))
+		}
+		if r.URL.Query().Get("date") != "2026-07-29" || r.URL.Query().Get("tz") == "" {
+			t.Errorf("query %s", r.URL.RawQuery)
+		}
+		w.Write([]byte(widgetFixture))
+	})
+	d, err := c.GetDay("2026-07-29", "America/Los_Angeles")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if d.BlockSize != 30 || len(d.Types) != 2 || d.Blocks[28] != "t1" || d.PlanBlocks[29] != "t2" {
+		t.Fatalf("parsed %+v", d)
+	}
+}
+
+func TestGetDayUnauthorized(t *testing.T) {
+	_, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusUnauthorized)
+	})
+	_, err := c.GetDay("2026-07-29", "UTC")
+	if !errors.Is(err, ErrUnauthorized) {
+		t.Fatalf("want ErrUnauthorized, got %v", err)
+	}
+}
+
+func TestWriteBlocks(t *testing.T) {
+	var got map[string]*string
+	_, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
+		if r.Method != http.MethodPut || r.URL.Path != "/api/days/2026-07-29/blocks" {
+			t.Errorf("%s %s", r.Method, r.URL.Path)
+		}
+		json.NewDecoder(r.Body).Decode(&got)
+		w.Write([]byte(`{}`))
+	})
+	tid := "t2"
+	if err := c.WriteBlocks("2026-07-29", map[int]*string{29: &tid, 28: nil}); err != nil {
+		t.Fatal(err)
+	}
+	if *got["29"] != "t2" || got["28"] != nil {
+		t.Fatalf("body %v", got)
+	}
+}
+
+func TestAddNote(t *testing.T) {
+	var got map[string]any
+	_, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
+		if r.Method != http.MethodPost || r.URL.Path != "/api/days/2026-07-29/notes" {
+			t.Errorf("%s %s", r.Method, r.URL.Path)
+		}
+		json.NewDecoder(r.Body).Decode(&got)
+		w.WriteHeader(http.StatusCreated)
+		w.Write([]byte(`{}`))
+	})
+	if err := c.AddNote("2026-07-29", "hello"); err != nil {
+		t.Fatal(err)
+	}
+	if got["content"] != "hello" {
+		t.Fatalf("body %v", got)
+	}
+}
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `go test ./internal/api/ -v`. Expected: FAIL.
+
+- [ ] **Step 3: Implement `internal/api/client.go`**
+
+```go
+package api
+
+import (
+	"bytes"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strconv"
+	"time"
+
+	"github.com/humdrum-tiv/dots-cli/internal/day"
+)
+
+// ErrUnauthorized means the stored token was rejected (or absent).
+var ErrUnauthorized = errors.New("unauthorized")
+
+// ErrForbidden means the server refused for subscription reasons (403).
+var ErrForbidden = errors.New("forbidden")
+
+type Client struct {
+	BaseURL string
+	Token   string
+	HTTP    *http.Client
+}
+
+func New(baseURL, token string) *Client {
+	return &Client{BaseURL: baseURL, Token: token, HTTP: &http.Client{Timeout: 15 * time.Second}}
+}
+
+// DayData is the CLI's view of GET /api/widget/data.
+type DayData struct {
+	BlockSize  int
+	Timezone   string
+	Types      []day.ActivityType
+	Blocks     map[int]string // only logged slots
+	PlanBlocks map[int]string // only planned slots
+}
+
+type widgetPayload struct {
+	Day struct {
+		BlockSize int    `json:"blockSize"`
+		Timezone  string `json:"timezone"`
+	} `json:"day"`
+	ActivityTypes []struct {
+		ID         string `json:"id"`
+		Name       string `json:"name"`
+		Color      string `json:"color"`
+		IsArchived bool   `json:"isArchived"`
+	} `json:"activityTypes"`
+	Blocks     map[string]*string `json:"blocks"`
+	PlanBlocks map[string]*string `json:"planBlocks"`
+}
+
+func (c *Client) do(method, path string, query url.Values, body any) (*http.Response, error) {
+	u := c.BaseURL + path
+	if query != nil {
+		u += "?" + query.Encode()
+	}
+	var buf *bytes.Buffer = &bytes.Buffer{}
+	if body != nil {
+		if err := json.NewEncoder(buf).Encode(body); err != nil {
+			return nil, err
+		}
+	}
+	req, err := http.NewRequest(method, u, buf)
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Authorization", "Bearer "+c.Token)
+	if body != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+	resp, err := c.HTTP.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("can't reach %s: %w", c.BaseURL, err)
+	}
+	switch {
+	case resp.StatusCode == http.StatusUnauthorized:
+		resp.Body.Close()
+		return nil, ErrUnauthorized
+	case resp.StatusCode == http.StatusForbidden:
+		resp.Body.Close()
+		return nil, ErrForbidden
+	case resp.StatusCode >= 400:
+		resp.Body.Close()
+		return nil, fmt.Errorf("server error: %s", resp.Status)
+	}
+	return resp, nil
+}
+
+func intKeyMap(in map[string]*string) map[int]string {
+	out := make(map[int]string)
+	for k, v := range in {
+		if v == nil {
+			continue
+		}
+		i, err := strconv.Atoi(k)
+		if err != nil {
+			continue
+		}
+		out[i] = *v
+	}
+	return out
+}
+
+func (c *Client) GetDay(date, tz string) (*DayData, error) {
+	q := url.Values{"date": {date}, "tz": {tz}}
+	resp, err := c.do(http.MethodGet, "/api/widget/data", q, nil)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+	var p widgetPayload
+	if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
+		return nil, fmt.Errorf("bad response: %w", err)
+	}
+	d := &DayData{
+		BlockSize:  p.Day.BlockSize,
+		Timezone:   p.Day.Timezone,
+		Blocks:     intKeyMap(p.Blocks),
+		PlanBlocks: intKeyMap(p.PlanBlocks),
+	}
+	for _, t := range p.ActivityTypes {
+		if t.IsArchived {
+			continue
+		}
+		d.Types = append(d.Types, day.ActivityType{ID: t.ID, Name: t.Name, Color: t.Color})
+	}
+	return d, nil
+}
+
+func (c *Client) WriteBlocks(date string, assignments map[int]*string) error {
+	body := make(map[string]*string, len(assignments))
+	for k, v := range assignments {
+		body[strconv.Itoa(k)] = v
+	}
+	resp, err := c.do(http.MethodPut, "/api/days/"+date+"/blocks", nil, body)
+	if err != nil {
+		return err
+	}
+	resp.Body.Close()
+	return nil
+}
+
+func (c *Client) AddNote(date, content string) error {
+	resp, err := c.do(http.MethodPost, "/api/days/"+date+"/notes", nil, map[string]string{"content": content})
+	if err != nil {
+		return err
+	}
+	resp.Body.Close()
+	return nil
+}
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `go test ./... `. Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+jj commit -m "feat: API client — GetDay, WriteBlocks, AddNote over widget/data + day routes"
+```
+
+### Task 9: token storage + `dots auth` (`internal/auth`)
+
+**Files:**
+- Create: `internal/auth/store.go`, `internal/auth/store_test.go`, `internal/cli/auth.go`
+- Modify: `cmd/dots/main.go` (register command)
+
+**Interfaces:**
+- Consumes: `api.New(...).GetDay(...)` (Task 8) for verification, `config.BaseURL()` (Task 4).
+- Produces (package `auth`): `SaveToken(token string) error`, `LoadToken() (string, error)` — keychain service `"dots-cli"`, account `"token"`; on keyring error, fallback file `~/.config/dots/token` (0600). `ErrNoToken` when nothing stored.
+- Produces (package `cli`): `NewAuthCmd() *cobra.Command`, plus helpers used by every later command: `RequireClient() (*api.Client, error)` (loads token, returns "not linked — run `dots auth`" error if missing) and `Today(tz *time.Location) string` date helper. Also `NewRootWiring` note: `cmd/dots/main.go` gains `root.AddCommand(cli.NewAuthCmd())`.
+
+- [ ] **Step 1: Write failing tests (file fallback only — keychain is not unit-testable)**
+
+`internal/auth/store_test.go`:
+
+```go
+package auth
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+func TestFileFallbackRoundTrip(t *testing.T) {
+	dir := t.TempDir()
+	t.Setenv("DOTS_TOKEN_DIR", dir) // test hook: overrides ~/.config/dots
+
+	if err := saveTokenFile("abc123"); err != nil {
+		t.Fatal(err)
+	}
+	got, err := loadTokenFile()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if got != "abc123" {
+		t.Fatalf("got %q", got)
+	}
+	info, _ := os.Stat(filepath.Join(dir, "token"))
+	if info.Mode().Perm() != 0o600 {
+		t.Fatalf("perm %v", info.Mode().Perm())
+	}
+}
+
+func TestLoadTokenFileMissing(t *testing.T) {
+	t.Setenv("DOTS_TOKEN_DIR", t.TempDir())
+	if _, err := loadTokenFile(); err == nil {
+		t.Fatal("want error for missing token file")
+	}
+}
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `go test ./internal/auth/ -v`. Expected: FAIL.
+
+- [ ] **Step 3: Implement `internal/auth/store.go`**
+
+```go
+package auth
+
+import (
+	"errors"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/zalando/go-keyring"
+)
+
+const service = "dots-cli"
+const account = "token"
+
+// ErrNoToken means no token is stored anywhere.
+var ErrNoToken = errors.New("no token stored")
+
+func tokenDir() string {
+	if d := os.Getenv("DOTS_TOKEN_DIR"); d != "" {
+		return d
+	}
+	home, _ := os.UserHomeDir()
+	return filepath.Join(home, ".config", "dots")
+}
+
+func saveTokenFile(token string) error {
+	dir := tokenDir()
+	if err := os.MkdirAll(dir, 0o700); err != nil {
+		return err
+	}
+	return os.WriteFile(filepath.Join(dir, "token"), []byte(token+"\n"), 0o600)
+}
+
+func loadTokenFile() (string, error) {
+	b, err := os.ReadFile(filepath.Join(tokenDir(), "token"))
+	if err != nil {
+		return "", ErrNoToken
+	}
+	return strings.TrimSpace(string(b)), nil
+}
+
+// SaveToken prefers the OS keychain, falling back to a 0600 file.
+func SaveToken(token string) error {
+	if err := keyring.Set(service, account, token); err == nil {
+		return nil
+	}
+	return saveTokenFile(token)
+}
+
+// LoadToken checks the keychain first, then the fallback file.
+func LoadToken() (string, error) {
+	if t, err := keyring.Get(service, account); err == nil && t != "" {
+		return t, nil
+	}
+	return loadTokenFile()
+}
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `go test ./internal/auth/ -v`. Expected: PASS.
+
+- [ ] **Step 5: Implement `internal/cli/auth.go` + shared helpers**
+
+```go
+package cli
+
+import (
+	"bufio"
+	"fmt"
+	"os"
+	"os/exec"
+	"runtime"
+	"strings"
+	"time"
+
+	"github.com/spf13/cobra"
+
+	"github.com/humdrum-tiv/dots-cli/internal/api"
+	"github.com/humdrum-tiv/dots-cli/internal/auth"
+	"github.com/humdrum-tiv/dots-cli/internal/config"
+)
+
+// RequireClient loads the stored token and returns a ready API client.
+func RequireClient() (*api.Client, error) {
+	token, err := auth.LoadToken()
+	if err != nil {
+		return nil, fmt.Errorf("not linked — run `dots auth`")
+	}
+	return api.New(config.BaseURL(), token), nil
+}
+
+// Today returns the current date (YYYY-MM-DD) in the device timezone.
+func Today() string {
+	return time.Now().Format("2006-01-02")
+}
+
+// TZ returns the device IANA timezone name.
+func TZ() string {
+	zone, _ := time.Now().Zone()
+	if loc := time.Local.String(); loc != "Local" {
+		return loc
+	}
+	return zone
+}
+
+func openBrowser(url string) {
+	if runtime.GOOS == "darwin" {
+		exec.Command("open", url).Start()
+	} else {
+		exec.Command("xdg-open", url).Start()
+	}
+}
+
+func NewAuthCmd() *cobra.Command {
+	return &cobra.Command{
+		Use:   "auth",
+		Short: "Link this machine to your Dots account",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			pageURL := config.BaseURL() + "/cli"
+			fmt.Printf("Opening %s — generate a token there, then paste it here.\n", pageURL)
+			openBrowser(pageURL)
+			fmt.Print("Token: ")
+			reader := bufio.NewReader(os.Stdin)
+			line, err := reader.ReadString('\n')
+			if err != nil {
+				return fmt.Errorf("no token entered")
+			}
+			token := strings.TrimSpace(line)
+			if token == "" {
+				return fmt.Errorf("no token entered")
+			}
+			client := api.New(config.BaseURL(), token)
+			if _, err := client.GetDay(Today(), TZ()); err != nil {
+				return fmt.Errorf("token check failed: %w", err)
+			}
+			if err := auth.SaveToken(token); err != nil {
+				return fmt.Errorf("could not store token: %w", err)
+			}
+			fmt.Println("Linked. Try `dots today`.")
+			return nil
+		},
+	}
+}
+```
+
+In `cmd/dots/main.go`, inside `newRootCmd`, add:
+
+```go
+root.AddCommand(cli.NewAuthCmd())
+```
+
+with import `"github.com/humdrum-tiv/dots-cli/internal/cli"`.
+
+- [ ] **Step 6: Build + full tests + commit**
+
+Run: `go build ./... && go test ./...`. Expected: clean, PASS.
+
+```bash
+jj commit -m "feat: token storage (keychain + file fallback) and dots auth command"
+```
+
+### Task 10: `log`, `clear`, `note` commands with confirmation gate
+
+**Files:**
+- Create: `internal/cli/confirm.go`, `internal/cli/confirm_test.go`, `internal/cli/log.go`, `internal/cli/note.go`
+- Modify: `cmd/dots/main.go` (register commands)
+
+**Interfaces:**
+- Consumes: everything from Tasks 5–9.
+- Produces: `NewLogCmd()`, `NewClearCmd()`, `NewNoteCmd()` (package `cli`); `FormatChanges(changes []day.Change, typesByID map[string]string, blockSize int) string` and `Confirm(prompt string, yes bool, in io.Reader, out io.Writer, isTTY bool) (bool, error)` — pure, tested.
+
+- [ ] **Step 1: Write failing tests for the confirmation pieces**
+
+`internal/cli/confirm_test.go`:
+
+```go
+package cli
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/humdrum-tiv/dots-cli/internal/day"
+)
+
+func sp(s string) *string { return &s }
+
+func TestFormatChanges(t *testing.T) {
+	names := map[string]string{"t1": "archer", "t2": "foofaraw"}
+	changes := []day.Change{
+		{Index: 29, FromID: sp("t1"), ToID: sp("t2")},
+		{Index: 30, FromID: sp("t1"), ToID: nil},
+	}
+	out := FormatChanges(changes, names, 30)
+	if !strings.Contains(out, "14:30") || !strings.Contains(out, "archer → foofaraw") {
+		t.Fatalf("out %q", out)
+	}
+	if !strings.Contains(out, "15:00") || !strings.Contains(out, "archer → (cleared)") {
+		t.Fatalf("out %q", out)
+	}
+}
+
+func TestConfirm(t *testing.T) {
+	// --yes bypasses everything
+	ok, err := Confirm("overwrite?", true, strings.NewReader(""), &strings.Builder{}, false)
+	if err != nil || !ok {
+		t.Fatalf("yes flag: ok=%v err=%v", ok, err)
+	}
+	// non-TTY without --yes = hard error
+	if _, err := Confirm("overwrite?", false, strings.NewReader("y\n"), &strings.Builder{}, false); err == nil {
+		t.Fatal("non-TTY without --yes must error")
+	}
+	// TTY: y accepts, anything else declines
+	ok, _ = Confirm("overwrite?", false, strings.NewReader("y\n"), &strings.Builder{}, true)
+	if !ok {
+		t.Fatal("y should accept")
+	}
+	ok, _ = Confirm("overwrite?", false, strings.NewReader("n\n"), &strings.Builder{}, true)
+	if ok {
+		t.Fatal("n should decline")
+	}
+	ok, _ = Confirm("overwrite?", false, strings.NewReader("\n"), &strings.Builder{}, true)
+	if ok {
+		t.Fatal("empty should decline (default N)")
+	}
+}
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `go test ./internal/cli/ -v`. Expected: FAIL.
+
+- [ ] **Step 3: Implement `internal/cli/confirm.go`**
+
+```go
+package cli
+
+import (
+	"bufio"
+	"fmt"
+	"io"
+	"strings"
+
+	"github.com/humdrum-tiv/dots-cli/internal/day"
+)
+
+// FormatChanges renders the pre-write diff table shown before confirmation.
+func FormatChanges(changes []day.Change, typeNames map[string]string, blockSize int) string {
+	name := func(id *string) string {
+		if id == nil {
+			return "(cleared)"
+		}
+		if n, ok := typeNames[*id]; ok {
+			return n
+		}
+		return *id
+	}
+	var b strings.Builder
+	for _, c := range changes {
+		from := "(empty)"
+		if c.FromID != nil {
+			from = name(c.FromID)
+		}
+		fmt.Fprintf(&b, "  %-6s %s → %s\n", day.SlotLabel(c.Index, blockSize), from, name(c.ToID))
+	}
+	return b.String()
+}
+
+// Confirm enforces the no-silent-overwrite rule: --yes bypasses, a real TTY
+// prompts y/N (default N), and non-TTY without --yes refuses outright.
+func Confirm(prompt string, yes bool, in io.Reader, out io.Writer, isTTY bool) (bool, error) {
+	if yes {
+		return true, nil
+	}
+	if !isTTY {
+		return false, fmt.Errorf("would change existing blocks — re-run with --yes (non-interactive)")
+	}
+	fmt.Fprintf(out, "%s [y/N] ", prompt)
+	line, err := bufio.NewReader(in).ReadString('\n')
+	if err != nil {
+		return false, nil
+	}
+	return strings.TrimSpace(strings.ToLower(line)) == "y", nil
+}
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `go test ./internal/cli/ -v`. Expected: PASS.
+
+- [ ] **Step 5: Implement `internal/cli/log.go` (log + clear)**
+
+```go
+package cli
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/spf13/cobra"
+	"golang.org/x/term"
+
+	"github.com/humdrum-tiv/dots-cli/internal/day"
+)
+
+func stdinIsTTY() bool { return term.IsTerminal(int(os.Stdin.Fd())) }
+
+func typeNames(types []day.ActivityType) map[string]string {
+	m := make(map[string]string, len(types))
+	for _, t := range types {
+		m[t.ID] = t.Name
+	}
+	return m
+}
+
+// pickActivity resolves a query to exactly one type or errors.
+func pickActivity(query string, types []day.ActivityType) (*day.ActivityType, error) {
+	matches := day.MatchActivity(query, types)
+	switch len(matches) {
+	case 1:
+		return &matches[0], nil
+	case 0:
+		names := make([]string, len(types))
+		for i, t := range types {
+			names[i] = t.Name
+		}
+		return nil, fmt.Errorf("no activity matches %q — have: %v", query, names)
+	default:
+		if !stdinIsTTY() {
+			return nil, fmt.Errorf("%q is ambiguous: %v", query, namesOf(matches))
+		}
+		fmt.Println("Which one?")
+		for i, m := range matches {
+			fmt.Printf("  %d) %s\n", i+1, m.Name)
+		}
+		fmt.Print("> ")
+		var n int
+		if _, err := fmt.Scanln(&n); err != nil || n < 1 || n > len(matches) {
+			return nil, fmt.Errorf("no selection")
+		}
+		return &matches[n-1], nil
+	}
+}
+
+func namesOf(ts []day.ActivityType) []string {
+	out := make([]string, len(ts))
+	for i, t := range ts {
+		out[i] = t.Name
+	}
+	return out
+}
+
+func NewLogCmd() *cobra.Command {
+	var date string
+	var yes bool
+	cmd := &cobra.Command{
+		Use:   "log [time|range] [activity]",
+		Short: "Paint logged block(s); no activity = fill from plan",
+		Args:  cobra.MaximumNArgs(2),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			client, err := RequireClient()
+			if err != nil {
+				return err
+			}
+			// Positional parsing: first arg is a time only if it looks like one.
+			target, activityQuery := "", ""
+			switch len(args) {
+			case 1:
+				if day.IsTimeSyntax(args[0]) {
+					target = args[0]
+				} else {
+					activityQuery = args[0]
+				}
+			case 2:
+				if !day.IsTimeSyntax(args[0]) {
+					return fmt.Errorf("first argument %q is not a time — usage: dots log [time|range] [activity]", args[0])
+				}
+				target, activityQuery = args[0], args[1]
+			}
+
+			if date == "" {
+				date = Today()
+			}
+			d, err := client.GetDay(date, TZ())
+			if err != nil {
+				return err
+			}
+			targets, err := day.ParseTarget(target, d.BlockSize, timeNow())
+			if err != nil {
+				return err
+			}
+
+			var activityID *string
+			if activityQuery != "" {
+				t, err := pickActivity(activityQuery, d.Types)
+				if err != nil {
+					return err
+				}
+				activityID = &t.ID
+			}
+
+			assignments, overwrites, err := day.ResolveLog(targets, activityID, d.Blocks, d.PlanBlocks, d.BlockSize)
+			if err != nil {
+				return err
+			}
+			if len(overwrites) > 0 {
+				fmt.Print(FormatChanges(overwrites, typeNames(d.Types), d.BlockSize))
+				ok, err := Confirm("Overwrite?", yes, os.Stdin, os.Stdout, stdinIsTTY())
+				if err != nil {
+					return err
+				}
+				if !ok {
+					fmt.Println("Aborted, nothing written.")
+					return nil
+				}
+			}
+			if err := client.WriteBlocks(date, assignments); err != nil {
+				return err
+			}
+			fmt.Printf("Logged %d block(s).\n", len(assignments))
+			return nil
+		},
+	}
+	cmd.Flags().StringVar(&date, "date", "", "day to write (YYYY-MM-DD, default today)")
+	cmd.Flags().BoolVarP(&yes, "yes", "y", false, "skip confirmation prompts")
+	return cmd
+}
+
+func NewClearCmd() *cobra.Command {
+	var date string
+	var yes bool
+	cmd := &cobra.Command{
+		Use:   "clear <time|range>",
+		Short: "Clear logged block(s)",
+		Args:  cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			client, err := RequireClient()
+			if err != nil {
+				return err
+			}
+			if !day.IsTimeSyntax(args[0]) {
+				return fmt.Errorf("%q is not a time or range", args[0])
+			}
+			if date == "" {
+				date = Today()
+			}
+			d, err := client.GetDay(date, TZ())
+			if err != nil {
+				return err
+			}
+			targets, err := day.ParseTarget(args[0], d.BlockSize, timeNow())
+			if err != nil {
+				return err
+			}
+			assignments, cleared := day.ResolveClear(targets, d.Blocks)
+			if len(cleared) == 0 {
+				fmt.Println("Nothing logged there.")
+				return nil
+			}
+			fmt.Print(FormatChanges(cleared, typeNames(d.Types), d.BlockSize))
+			ok, err := Confirm("Clear?", yes, os.Stdin, os.Stdout, stdinIsTTY())
+			if err != nil {
+				return err
+			}
+			if !ok {
+				fmt.Println("Aborted, nothing cleared.")
+				return nil
+			}
+			if err := client.WriteBlocks(date, assignments); err != nil {
+				return err
+			}
+			fmt.Printf("Cleared %d block(s).\n", len(cleared))
+			return nil
+		},
+	}
+	cmd.Flags().StringVar(&date, "date", "", "day to write (YYYY-MM-DD, default today)")
+	cmd.Flags().BoolVarP(&yes, "yes", "y", false, "skip confirmation prompts")
+	return cmd
+}
+```
+
+Add to the same file a seam for tests and DST-safe "now":
+
+```go
+import "time"
+
+// timeNow is a seam so command tests can pin the clock.
+var timeNow = func() time.Time { return time.Now() }
+```
+
+(Fold the `time` import into the existing import block.)
+
+- [ ] **Step 6: Implement `internal/cli/note.go`**
+
+```go
+package cli
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/spf13/cobra"
+)
+
+func NewNoteCmd() *cobra.Command {
+	var date string
+	cmd := &cobra.Command{
+		Use:   "note <text>",
+		Short: "Append a note to the day",
+		Args:  cobra.MinimumNArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			client, err := RequireClient()
+			if err != nil {
+				return err
+			}
+			if date == "" {
+				date = Today()
+			}
+			content := strings.TrimSpace(strings.Join(args, " "))
+			if content == "" {
+				return fmt.Errorf("empty note")
+			}
+			if err := client.AddNote(date, content); err != nil {
+				return err
+			}
+			fmt.Println("Noted.")
+			return nil
+		},
+	}
+	cmd.Flags().StringVar(&date, "date", "", "day to write (YYYY-MM-DD, default today)")
+	return cmd
+}
+```
+
+- [ ] **Step 7: Register in `cmd/dots/main.go`**
+
+```go
+root.AddCommand(cli.NewAuthCmd(), cli.NewLogCmd(), cli.NewClearCmd(), cli.NewNoteCmd())
+```
+
+- [ ] **Step 8: Build + tests + commit**
+
+Run: `go build ./... && go test ./...`. Expected: clean, PASS.
+
+```bash
+jj commit -m "feat: log/clear/note commands with overwrite confirmation gate"
+```
+
+### Task 11: `today` + `types` commands, live E2E, wrap-up
+
+**Files:**
+- Create: `internal/render/grid.go`, `internal/render/grid_test.go`, `internal/cli/today.go`, `internal/cli/types.go`
+- Modify: `cmd/dots/main.go`, `README.md` (create)
+
+**Interfaces:**
+- Consumes: `api.DayData` (Task 8), `day.SlotLabel` (Task 5).
+- Produces: `render.Grid(d *api.DayData, currentIndex int) string` — lipgloss truecolor grid + legend; `currentIndex` −1 for non-today. `NewTodayCmd()`, `NewTypesCmd()`.
+
+- [ ] **Step 1: Write failing render test (structure, not colors)**
+
+`internal/render/grid_test.go`:
+
+```go
+package render
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/humdrum-tiv/dots-cli/internal/api"
+	"github.com/humdrum-tiv/dots-cli/internal/day"
+)
+
+func TestGridStructure(t *testing.T) {
+	d := &api.DayData{
+		BlockSize: 30,
+		Types: []day.ActivityType{
+			{ID: "t1", Name: "archer", Color: "#c04000"},
+		},
+		Blocks:     map[int]string{28: "t1"},
+		PlanBlocks: map[int]string{29: "t1"},
+	}
+	out := Grid(d, 29)
+	// 48 slots at blockSize 30 → 6 rows of 8 (4h per row), each labeled with its start hour.
+	for _, label := range []string{"0:00", "4:00", "8:00", "12:00", "16:00", "20:00"} {
+		if !strings.Contains(out, label) {
+			t.Fatalf("missing row label %s in:\n%s", label, out)
+		}
+	}
+	if !strings.Contains(out, "archer") {
+		t.Fatalf("missing legend entry:\n%s", out)
+	}
+}
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `go test ./internal/render/ -v`. Expected: FAIL.
+
+- [ ] **Step 3: Implement `internal/render/grid.go`**
+
+```go
+package render
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/charmbracelet/lipgloss"
+
+	"github.com/humdrum-tiv/dots-cli/internal/api"
+	"github.com/humdrum-tiv/dots-cli/internal/day"
+)
+
+// Grid renders the day as rows of colored dots with a legend.
+// Logged = ● in type color; planned-only = ○ in type color; empty = dim ·.
+// currentIndex (−1 to disable) gets an underline marker.
+func Grid(d *api.DayData, currentIndex int) string {
+	blocksPerDay := 1440 / d.BlockSize
+	perRow := blocksPerDay / 6 // 6 rows → 4h per row at any blockSize
+
+	colorOf := make(map[string]string, len(d.Types))
+	for _, t := range d.Types {
+		colorOf[t.ID] = t.Color
+	}
+	dim := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
+
+	var b strings.Builder
+	for row := 0; row < 6; row++ {
+		start := row * perRow
+		b.WriteString(fmt.Sprintf("%-6s", day.SlotLabel(start, d.BlockSize)))
+		for i := start; i < start+perRow; i++ {
+			var cell string
+			if id, ok := d.Blocks[i]; ok {
+				cell = lipgloss.NewStyle().Foreground(lipgloss.Color(colorOf[id])).Render("●")
+			} else if id, ok := d.PlanBlocks[i]; ok {
+				cell = lipgloss.NewStyle().Foreground(lipgloss.Color(colorOf[id])).Render("○")
+			} else {
+				cell = dim.Render("·")
+			}
+			if i == currentIndex {
+				cell = lipgloss.NewStyle().Underline(true).Render(cell)
+			}
+			b.WriteString(cell + " ")
+		}
+		b.WriteString("\n")
+	}
+
+	b.WriteString("\n")
+	for _, t := range d.Types {
+		sw := lipgloss.NewStyle().Foreground(lipgloss.Color(t.Color)).Render("●")
+		b.WriteString(fmt.Sprintf("%s %s   ", sw, t.Name))
+	}
+	b.WriteString("\n")
+	return b.String()
+}
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `go test ./internal/render/ -v`. Expected: PASS. (lipgloss emits no escapes when output isn't a TTY profile, so the structural assertions hold either way.)
+
+- [ ] **Step 5: Implement `internal/cli/today.go` and `internal/cli/types.go`**
+
+`today.go`:
+
+```go
+package cli
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+
+	"github.com/spf13/cobra"
+	"golang.org/x/term"
+
+	"github.com/humdrum-tiv/dots-cli/internal/render"
+)
+
+func NewTodayCmd() *cobra.Command {
+	var date string
+	var asJSON bool
+	cmd := &cobra.Command{
+		Use:   "today",
+		Short: "Show the day grid",
+		Args:  cobra.NoArgs,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			client, err := RequireClient()
+			if err != nil {
+				return err
+			}
+			isToday := date == ""
+			if isToday {
+				date = Today()
+			}
+			d, err := client.GetDay(date, TZ())
+			if err != nil {
+				return err
+			}
+			if asJSON || !term.IsTerminal(int(os.Stdout.Fd())) {
+				return json.NewEncoder(os.Stdout).Encode(map[string]any{
+					"date": date, "blockSize": d.BlockSize,
+					"blocks": d.Blocks, "planBlocks": d.PlanBlocks, "types": d.Types,
+				})
+			}
+			current := -1
+			if isToday {
+				now := timeNow()
+				current = (now.Hour()*60 + now.Minute()) / d.BlockSize
+			}
+			fmt.Printf("%s\n\n", date)
+			fmt.Print(render.Grid(d, current))
+			return nil
+		},
+	}
+	cmd.Flags().StringVar(&date, "date", "", "day to show (YYYY-MM-DD, default today)")
+	cmd.Flags().BoolVar(&asJSON, "json", false, "raw JSON output")
+	return cmd
+}
+```
+
+`types.go`:
+
+```go
+package cli
+
+import (
+	"fmt"
+
+	"github.com/charmbracelet/lipgloss"
+	"github.com/spf13/cobra"
+)
+
+func NewTypesCmd() *cobra.Command {
+	return &cobra.Command{
+		Use:   "types",
+		Short: "List active activity types",
+		Args:  cobra.NoArgs,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			client, err := RequireClient()
+			if err != nil {
+				return err
+			}
+			d, err := client.GetDay(Today(), TZ())
+			if err != nil {
+				return err
+			}
+			for _, t := range d.Types {
+				sw := lipgloss.NewStyle().Foreground(lipgloss.Color(t.Color)).Render("●")
+				fmt.Printf("%s %s\n", sw, t.Name)
+			}
+			return nil
+		},
+	}
+}
+```
+
+Register both in `cmd/dots/main.go`:
+
+```go
+root.AddCommand(cli.NewAuthCmd(), cli.NewLogCmd(), cli.NewClearCmd(), cli.NewNoteCmd(), cli.NewTodayCmd(), cli.NewTypesCmd())
+```
+
+- [ ] **Step 6: Full build + tests**
+
+Run: `go build ./... && go test ./...`. Expected: clean, PASS.
+
+- [ ] **Step 7: README**
+
+Write `README.md`: one-paragraph description, install (`go install github.com/humdrum-tiv/dots-cli/cmd/dots@latest` or `go build -o ~/bin/dots ./cmd/dots`), the command table from the spec, the auth flow (browser → `/cli` → paste), `DOTS_URL` note. Point at the spec for details.
+
+- [ ] **Step 8: Commit**
+
+```bash
+jj commit -m "feat: today grid + types commands, README"
+```
+
+- [ ] **Step 9: Live E2E (requires server PR merged + migration applied)**
+
+Preconditions (user-owned, flag if missing): Dots PR from Task 3 merged & deployed, `cli_tokens` migration applied in Supabase.
+
+```bash
+go build -o /tmp/dots ./cmd/dots
+/tmp/dots auth                      # browser flow, paste token
+/tmp/dots types                     # lists real types
+/tmp/dots today                     # renders grid
+/tmp/dots log 3:00 <some-type>      # paint an empty overnight slot
+/tmp/dots today                     # verify dot appears
+/tmp/dots log 3:00 <other-type>     # must show diff table + prompt; answer n → verify unchanged
+/tmp/dots clear 3:00 -y             # cleanup without prompt
+/tmp/dots note "cli e2e test"      # then delete the note in the web UI
+```
+
+Verify each step's output; then check the web app shows/loses the 3:00 dot accordingly. Report results; do not mark this step complete on partial success.
+
+- [ ] **Step 10: Repo hygiene**
+
+```bash
+backlog init "dots-cli" --agent-instructions claude --integration-mode cli --task-prefix TASK --zero-padded-ids 3
+backlog config set remoteOperations false
+```
+
+Then edit `backlog/config.yml`: `default_status: "🟦 Backlog"`, `statuses: ["🟦 Backlog", "🟢 In progress", "🚧 Paused", "🏁 Done"]`. Create GitHub repo + push: `gh repo create humdrum-tiv/dots-cli --private --source . --push` (jj: `jj git push --allow-new` after adding the remote if needed). Commit any backlog files: `jj commit -m "chore: backlog init"`.
+
+---
+
+## Self-review notes
+
+- Spec coverage: auth (T3/T9), log+plan-fill+overwrite rule (T5/T7/T10), clear (T7/T10), note (T10), today+json (T11), types (T11), server changes (T1–T3), keychain (T9), errors/exit codes (client `do()` + cobra SilenceUsage), testing strategy (per task), release (T11 README + repo hygiene). Out-of-scope list respected — no retries, no TUI, no type creation.
+- Types consistent across tasks: `day.ActivityType`, `day.Change`, `api.DayData`, `ResolveLog(..., blockSize int)` five-param form is the binding signature.
+- Known judgment calls an implementer may hit: exact Clerk `clerkClient()` call shape (T2 notes it), lipgloss version API drift (if `lipgloss.Color` differs, adapt — structure tests don't assert escapes), widget/data `planBlocks` may be absent for non-subscribers (`.catch(() => ({}))` server-side — treat missing map as empty).
docs/superpowers/specs/2026-07-29-dots-cli-design.md +6 −2
@@ -31,12 +31,16 @@ (dev/testing; defaults to `https://dots.humdrum.one`).
 
 ### Time syntax
 
-Dots blocks are 30-minute slots. Accepted forms:
+Dots blocks are `blockSize`-minute slots, where `blockSize` is a per-day setting of
+15, 30, or 60 (`blockIndex = minutesSinceMidnight / blockSize`). The CLI reads
+`day.blockSize` from the day payload and does all slot math against it. Accepted forms
+(examples at blockSize 30):
 
 - *(omitted)* — the slot containing the current device time
 - `14:30` (also `9:00`, `09:00`) — the single slot containing that time; times snap
   down to the slot boundary (`14:47` → `14:30`)
-- `13:00-15:00` — range, end-exclusive: slots 13:00, 13:30, 14:00, 14:30
+- `13:00-15:00` — range, end-exclusive: slots 13:00, 13:30, 14:00, 14:30. Ranges do
+  not cross midnight.
 
 Argument parsing: the first positional arg is treated as a time/range only when it
 matches the time syntax (`H:MM`, `HH:MM`, or a `-`-joined pair); otherwise it is the