Files
gems/docs/design/cli-ux-design.md
T
joakim f57baee6bc fix: IMP-4/5/6 — parser allowlist, delete ID resolution, consistent errors
IMP-5: Replace strings.Contains(arg, ":") heuristic with an allowlist
of recognized attribute keys (ValidAttributeKeys). Colons in task
descriptions (URLs, "Meeting: topic") are no longer misinterpreted as
modifiers. Canonical key sets live in engine/keys.go and are shared
across parseAddArgs, ParseFilter, and ParseModifier. ParseModifier now
errors on unknown keys.

IMP-4: delete command now loads the working set and resolves display IDs
via GetTaskByDisplayID, matching the pattern used by done/modify.

IMP-6: All action commands (done, delete, modify, start, stop) now
return an error on no-match (stderr, exit 1). Previously done/delete
printed to stdout and exited 0; start/stop had no check at all.

Also adds requirements and design docs for the CLI UX improvements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:37:33 +01:00

1007 lines
34 KiB
Markdown

# Opal CLI UX Improvements — Technical Design
**Status:** Draft — awaiting feedback
**Date:** 2026-02-19
**Requirements:** `docs/design/cli-ux-improvements.md`
---
## Architecture Overview
The improvements fall into four architectural themes:
1. **Undo system** — leverages the existing `change_log` for state recovery,
with a lightweight undo stack for tracking CLI operations (IMP-1)
2. **Output / feedback** — richer CLI output from existing commands (IMP-2, 3, 6, 7, 9)
3. **Parser / ID resolution fixes** — correctness in `cmd/` and `engine/` (IMP-4, 5)
4. **New commands & features** — annotations, history, completions, dry-run, version
(IMP-8, 10, 11, 12, 13)
```
┌──────────────────────────────────────────────────────┐
│ cmd/ layer │
│ │
│ add done delete modify start stop info edit │
│ undo uncomplete annotate denotate log version │
│ completion │
│ │
│ ┌────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ confirmutil│ │ feedback │ │ dry-run │ │
│ │ (IMP-3,6) │ │ (IMP-2,7) │ │ (IMP-10) │ │
│ └────────────┘ └──────────────┘ └──────────────┘ │
├──────────────────────────────────────────────────────┤
│ engine/ layer │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ │
│ │ undo.go │ │ annotate │ │ reldate │ │modifier │ │
│ │ (IMP-1) │ │ (IMP-11) │ │ (IMP-9) │ │(IMP-5) │ │
│ └──────────┘ └──────────┘ └──────────┘ └─────────┘ │
│ │
│ task.go ws.go filter.go display.go database.go │
├──────────────────────────────────────────────────────┤
│ SQLite (opal.db) │
│ │
│ tasks (+annotations col) tags working_set │
│ change_log undo_stack (new, lightweight) │
└──────────────────────────────────────────────────────┘
```
---
## Component Designs
All schema changes are made directly in the existing migration v1 in
`database.go`. No migration system needed — we are pre-production and have no
backwards compatibility constraints.
### 1. Undo System (IMP-1)
The undo system builds on top of the existing `change_log`. The change_log
already captures the full task state (via SQLite triggers) on every mutation.
Instead of duplicating that data, undo reads prior states from the change_log
and uses a lightweight stack to track which CLI operations are undoable.
#### How it works
The `change_log` stores the **post-mutation** state of a task on every INSERT
and UPDATE (via the `track_task_create` and `track_task_update` triggers).
This means:
- Entry at `id=N`: the task state **after** the Nth mutation
- Entry at `id=N-1` (same task_uuid): the task state **before** the Nth mutation
To undo mutation N, we restore the state from entry N-1. The sync client
already has `applyChangeDataToTask()` which parses the key:value `data` field
back into a `*Task` — we reuse this.
#### Data Model — `undo_stack` table
```sql
CREATE TABLE undo_stack (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at INTEGER NOT NULL,
op_type TEXT NOT NULL, -- 'add','done','delete','modify','start','stop'
task_uuid TEXT NOT NULL,
change_log_id INTEGER NOT NULL -- the change_log entry created by this mutation
);
```
This is deliberately lightweight — no snapshots, no duplicate data. The actual
task state lives in `change_log`.
The table is capped at 10 rows (oldest evicted on insert).
#### Engine Interface — `engine/undo.go`
```go
// RecordUndo records a CLI operation as undoable.
// Called AFTER the mutation (so the change_log entry exists).
// Finds the change_log entry created by this mutation and stores a reference.
func RecordUndo(opType string, taskUUID uuid.UUID) error
// PopUndo pops the most recent undo entry and reverts the task.
// Returns a description of what was undone for display.
func PopUndo() (description string, err error)
```
**`RecordUndo` behavior:**
1. `SELECT MAX(id) FROM change_log WHERE task_uuid = ?` — find the entry just
created by this mutation.
2. `INSERT INTO undo_stack (created_at, op_type, task_uuid, change_log_id) VALUES (...)`.
3. Evict: `DELETE FROM undo_stack WHERE id NOT IN (SELECT id FROM undo_stack ORDER BY id DESC LIMIT 10)`.
**`PopUndo` behavior (by op type):**
| Original op | Revert action |
|-------------|---------------|
| `add` | Hard delete the task (`DELETE FROM tasks WHERE uuid = ?`). For recurring tasks, also delete the template. |
| `done` | Find the change_log entry BEFORE `change_log_id` for the same `task_uuid`. Parse its `data` field into a Task. Restore that state (sets status back to pending, clears End). Save. |
| `delete` | Same as `done` — restore from prior change_log entry. |
| `modify` | Restore full state from prior change_log entry. Reconcile tags. |
| `start` | Restore from prior change_log entry (clears Start). |
| `stop` | Restore from prior change_log entry (restores Start). |
**For `add`:** The change_log entry has `change_type='create'`. There is no
prior entry. `PopUndo` detects `op_type='add'` and performs a hard delete
instead of restoring.
**Sync behavior:** The undo stack itself is local (not synced), but the
*revert* is a normal `task.Save()` which fires change_log triggers and syncs
like any other mutation. This means:
- Undo on device A → the reverted state syncs to device B
- Device B cannot "undo" device A's operations (the undo stack is per-device)
#### Uncomplete — dedicated path
`opal <id> uncomplete` does NOT use the undo stack. It directly:
1. Loads the task by display ID.
2. Asserts `status == StatusCompleted`.
3. Sets `status = StatusPending`, clears `End`.
4. Saves.
This is simpler and more predictable than routing through undo. Uncomplete is
an explicit, targeted action; undo is a generic "oops" button.
#### Cmd Layer — `cmd/undo.go`, `cmd/uncomplete.go`
```go
// cmd/undo.go
var undoCmd = &cobra.Command{
Use: "undo",
Short: "Undo the last action",
// ...
}
// cmd/uncomplete.go
var uncompleteCmd = &cobra.Command{
Use: "uncomplete [filter...]",
Short: "Restore a completed task to pending",
// ...
}
```
#### Integration Points
Every mutating command (`add`, `done`, `delete`, `modify`, `start`, `stop`)
must call `engine.RecordUndo(opType, taskUUID)` AFTER the mutation succeeds.
This is a one-line addition per command.
---
### 2. Action Command Output (IMP-2, 3, 6, 7)
These four improvements share a common pattern: richer output from existing
action commands. Rather than scattering formatting logic across each `cmd/*.go`
file, we introduce a shared output helper.
#### Engine Interface — `engine/feedback.go`
```go
// FormatTaskSummary returns a one-line summary for action feedback.
// Example: `3 "Buy groceries" due:tomorrow +errand`
func FormatTaskSummary(task *Task, ws *WorkingSet) string
// FormatTaskConfirmList returns the multi-task confirmation block.
// Shows up to 10 tasks, then "...and N more".
func FormatTaskConfirmList(tasks []*Task, ws *WorkingSet) string
// FormatAddFeedback returns the detailed post-add feedback block.
// Includes display ID, description, all parsed modifiers.
func FormatAddFeedback(task *Task, displayID int) string
// FormatCompletionFeedback returns completion feedback with recurrence info.
// If the completed task is a recurring instance, includes next instance details.
func FormatCompletionFeedback(task *Task, displayID int, nextInstance *Task) string
```
#### IMP-2: Better `add` feedback
**Changes to `cmd/add.go`:**
After `CreateTaskWithModifier`, the add command needs a display ID for the new
task. Load the working set and insert the new task into the `working_set`
table at `MAX(display_id) + 1`. This makes the ID immediately usable
(`opal <id> done`) until the next report render.
```go
// engine/ws.go — new function
func AppendTask(task *Task) (int, error)
```
`AppendTask` inserts a new row into `working_set` with
`display_id = MAX(display_id) + 1` and returns the assigned ID.
**Note on ID stability:** The working set is fully recalculated on every report
render — all display IDs shift based on the report's sort order. The appended
ID is therefore valid only until the next report runs. This is inherent to the
working set design and matches existing behavior (IDs from a previous `list`
can shift after running a different report). The ID is useful for the immediate
"add then act" workflow: `opal add ... && opal 7 done`.
Output format (non-recurring):
```
Created task 7 — "Buy groceries"
Due: tomorrow (2026-02-20)
Tags: errand
Priority: D
```
Output format (recurring):
```
Created recurring task 7 — "Weekly review"
Recurrence: 1w
Due: monday (2026-02-24)
Tags: meetings
```
#### IMP-3: Show matched tasks in confirmations
**Shared pattern for `done`, `delete`, `modify`:**
Replace the current confirmation prompt with `FormatTaskConfirmList`. The
existing confirmation flow in each command becomes:
```go
if len(tasks) > 1 {
fmt.Print(engine.FormatTaskConfirmList(tasks, ws))
fmt.Printf("Proceed? (y/N): ")
// ... existing confirm logic
} else if len(tasks) == 1 {
// Single task: show summary, no prompt
fmt.Printf("Completing task %s\n", engine.FormatTaskSummary(tasks[0], ws))
}
```
FR-3.3: Single-task operations show the description but skip the y/N prompt.
This is a behavior change for `done` and `delete` which currently always
prompt. `done` only prompts for len > 1 already. `delete` always prompts —
change it to skip for single-task operations matching by display ID.
#### IMP-6: Consistent no-match errors
All action commands adopt the same pattern:
```go
if len(tasks) == 0 {
fmt.Fprintf(os.Stderr, "No tasks matched filter.\n")
os.Exit(1)
}
```
Changes needed:
- `done`: change `fmt.Println` to `fmt.Fprintf(os.Stderr, ...)` + `os.Exit(1)`
(currently prints to stdout, exits 0)
- `delete`: same change (currently prints to stdout, exits 0)
- `modify`: already correct (returns error, which prints to stderr and exits 1)
- `start`, `stop`: verify and align
#### IMP-7: Recurring task feedback
**Changes to `engine/task.go` — `Complete()` return value:**
Currently `Complete()` returns `error`. To surface the spawned instance, change
the signature:
```go
// Complete marks a task as completed.
// Returns the next recurring instance if one was spawned, or nil.
func (t *Task) Complete() (*Task, error)
```
`SpawnNextInstance` already creates and saves the next instance — we just need
to return it. The call chain becomes:
```go
// engine/recurrence.go
func SpawnNextInstance(completedInstance *Task) (*Task, error)
// ^^^^^ return the new instance
// engine/task.go
func (t *Task) Complete() (*Task, error) {
// ... save completion ...
if t.ParentUUID != nil {
next, err := SpawnNextInstance(t)
return next, err
}
return nil, nil
}
```
**Changes to `cmd/done.go`:**
```go
next, err := task.Complete()
if err != nil { ... }
fmt.Print(engine.FormatCompletionFeedback(task, displayID, next))
// Output:
// Completed task 3 — "Weekly review"
// Next instance created — due: 2026-02-25 (in 7 days)
```
---
### 3. Parser & ID Resolution Fixes (IMP-4, IMP-5)
#### IMP-4: Fix `delete` display ID resolution
The fix is minimal. `cmd/delete.go` currently calls `engine.GetTasks(filter)`
directly. Change it to match the pattern in `done.go` and `modify.go`:
```go
func deleteTasks(args []string) error {
filter, err := engine.ParseFilter(args)
// ...
// ADD: Load working set (matches done/modify pattern)
ws, err := engine.LoadWorkingSet()
// ...
var tasks []*engine.Task
if len(filter.IDs) > 0 {
for _, id := range filter.IDs {
task, err := ws.GetTaskByDisplayID(id)
// ...
tasks = append(tasks, task)
}
} else {
tasks, err = engine.GetTasks(filter)
// ...
}
// ... rest unchanged
}
```
Note: `Filter.ToSQL()` already has a `working_set` subquery for IDs, so the
current code does technically resolve IDs. But it reads from the `working_set`
table without loading tasks into memory first, meaning the "task not found"
error messages are less helpful. More importantly, aligning the pattern makes
the confirmation display (IMP-3) consistent across commands.
#### IMP-5: Modifier key allowlist
**Canonical key set — `engine/keys.go`:**
Currently, the set of valid modifier keys is implicitly defined in three
places: `dateKeys` (defined twice in `modifier.go`), the `switch` cases for
non-date attributes (`priority`, `project`, `recur`), and the filter parser's
`key:value` handling. These should be consolidated into a single canonical set.
```go
// engine/keys.go — single source of truth for attribute keys
// ValidAttributeKeys are the recognized key:value attribute names.
// Used by parseAddArgs, ParseFilter, and ParseModifier.
var ValidAttributeKeys = map[string]bool{
"due": true, "priority": true, "project": true,
"recur": true, "status": true, "wait": true,
"scheduled": true, "until": true,
}
// DateKeys is the subset of attribute keys that hold date values.
// Extracted from the duplicate definitions in modifier.go Apply/ApplyToNew.
var DateKeys = map[string]bool{
"due": true, "scheduled": true, "wait": true, "until": true,
}
// FilterOnlyKeys are additional keys valid in filter context but not as
// modifiers (e.g., uuid is a filter, not something you set).
var FilterOnlyKeys = map[string]bool{
"uuid": true,
}
```
**Changes to `cmd/add.go` — `parseAddArgs()`:**
Replace the current `strings.Contains(arg, ":")` heuristic with an allowlist
check using `engine.ValidAttributeKeys`:
```go
func parseAddArgs(args []string) (string, []string, error) {
var descParts []string
var modifiers []string
for _, arg := range args {
if strings.HasPrefix(arg, "+") || strings.HasPrefix(arg, "-") {
modifiers = append(modifiers, arg)
continue
}
// Check for known modifier pattern: key:value where key is recognized
if idx := strings.Index(arg, ":"); idx > 0 {
key := arg[:idx]
if engine.ValidAttributeKeys[key] {
modifiers = append(modifiers, arg)
continue
}
}
descParts = append(descParts, arg)
}
if len(descParts) == 0 {
return "", nil, fmt.Errorf("description is required")
}
return strings.Join(descParts, " "), modifiers, nil
}
```
**Changes to `engine/filter.go` — `ParseFilter()`:**
Same allowlist plus filter-only keys:
```go
// In the arg loop, replace strings.Contains(arg, ":") with:
if idx := strings.Index(arg, ":"); idx > 0 {
key := arg[:idx]
if ValidAttributeKeys[key] || FilterOnlyKeys[key] {
// existing attribute/uuid handling
}
// else: silently ignore (not an ID, not a tag — treat as harmless)
}
```
**Changes to `engine/modifier.go`:**
Replace the duplicated `dateKeys` maps with the shared `DateKeys`, and use
`ValidAttributeKeys` for validation:
```go
func (m *Modifier) Apply(task *Task) error {
resolvedDates := make(map[string]time.Time)
// ... init existing dates ...
for _, key := range m.AttributeOrder {
valuePtr := m.SetAttributes[key]
if DateKeys[key] {
// ... existing date handling ...
continue
}
switch key {
case "priority":
// ...
case "project":
// ...
case "recur":
// ...
default:
return fmt.Errorf("unknown modifier key: %q", key)
}
}
// ...
}
```
---
### 4. Annotations (IMP-11)
#### Schema
Add the `annotations` column directly to the `tasks` table definition in
`database.go` migration v1, and update the change_log triggers to include it:
```sql
-- In the CREATE TABLE tasks statement, add after parent_uuid:
annotations TEXT DEFAULT NULL
-- In track_task_create and track_task_update triggers, add:
CASE WHEN NEW.annotations IS NOT NULL
THEN 'annotations: ' || NEW.annotations || CHAR(10) ELSE '' END ||
```
The column stores a JSON array of annotation objects. NULL = no annotations
(avoids storing `"[]"` on every task).
#### Data Model
```go
// engine/task.go — add to Task struct
type Annotation struct {
Timestamp int64 `json:"timestamp"`
Text string `json:"text"`
}
type Task struct {
// ... existing fields ...
Annotations []Annotation `json:"annotations,omitempty"`
}
```
#### Engine Interface — `engine/annotate.go`
```go
// Annotate appends a timestamped annotation to the task.
func (t *Task) Annotate(text string) error
// Denotate removes the most recent annotation from the task.
func (t *Task) Denotate() (*Annotation, error)
```
**`Annotate` behavior:**
1. Append `{timestamp: now, text: text}` to `t.Annotations`.
2. Serialize annotations to JSON.
3. `UPDATE tasks SET annotations = ?, modified = ? WHERE uuid = ?`.
**`Denotate` behavior:**
1. Pop last element from `t.Annotations`.
2. If empty, set annotations column to NULL.
3. Save.
#### SQL Persistence
The `annotations` column is read/written alongside all other task fields in
`GetTask()`, `GetTasks()`, and `Save()`:
- `Save()` INSERT/UPDATE: add `annotations` parameter (JSON string or NULL).
- `GetTask()` / `GetTasks()`: add `annotations` to SELECT, unmarshal JSON.
#### Sync
Annotations sync automatically — they're a column on the `tasks` table, so the
change_log triggers capture them. The sync client's `applyChangeDataToTask()`
needs a new case to parse the `annotations:` key from change log data.
#### Display
- `FormatTaskDetail` (info): Add annotations section after tags, showing each
annotation with timestamp.
- `edit` command: Show annotations as editable lines. Format:
```
# Annotations (editable — add/remove/modify lines)
annotation.1: 2026-02-18 09:15 | Traced to token expiry in middleware
annotation.2: 2026-02-18 14:30 | note:debug-auth-issue
```
On save, diff the annotations before/after to determine adds, removes, and
edits. Timestamps on new annotations are set to now; existing annotations
preserve their original timestamps unless the text is modified.
#### Jade-depo Linking
Deferred to a follow-up. The annotation text field supports any string, so
users can manually write `note:my-note-slug` as a convention. A future
`--note` flag can automate this.
#### Cmd Layer — `cmd/annotate.go`
```go
var annotateCmd = &cobra.Command{
Use: "annotate <text>",
Short: "Add an annotation to a task",
}
var denotateCmd = &cobra.Command{
Use: "denotate",
Short: "Remove the most recent annotation",
}
```
Both commands follow the standard ID resolution pattern (load working set,
resolve display ID).
`annotate` and `denotate` must be registered as known commands in
`root.go`'s `commandNames` slice, and the preprocessor must treat `annotate`
as a command with modifiers (the annotation text is the modifier arg).
---
### 5. Relative Dates (IMP-9)
#### Engine Interface — `engine/reldate.go`
```go
// FormatRelativeDate returns a human-readable relative date string.
// Within 14 days: "tomorrow", "in 3d", "2d ago", "today"
// Beyond 14 days: "Feb 28", "Mar 15"
func FormatRelativeDate(t time.Time) string
// FormatDateWithRelative returns "2026-02-20 (in 2 days)" style.
// Used in info/detail views.
func FormatDateWithRelative(t time.Time) string
```
#### Changes to `display.go`
Replace `formatDue()` to use `FormatRelativeDate`:
```go
func formatDue(due *time.Time) string {
if due == nil {
return ""
}
return FormatRelativeDate(*due)
}
```
The color coding (red for overdue, yellow for today) is preserved — applied on
top of the relative string.
`FormatTaskDetail` uses `FormatDateWithRelative` for the Due, Scheduled, Wait,
and Until fields.
---
### 6. Dry-Run (IMP-10)
#### Approach
A `--dry-run` flag on action commands. This is a Cobra persistent flag on the
root command (available to all subcommands).
```go
// cmd/root.go
var dryRunFlag bool
func init() {
rootCmd.PersistentFlags().BoolVar(&dryRunFlag, "dry-run", false,
"Show matched tasks without performing the action")
}
```
Each action command checks the flag after resolving tasks and before mutating:
```go
if dryRunFlag {
fmt.Print(engine.FormatTaskConfirmList(tasks, ws))
fmt.Println("Dry run — no changes made.")
return nil
}
```
This reuses the same `FormatTaskConfirmList` from IMP-3.
---
### 7. Task History (IMP-12)
Task history IS the `change_log`, viewed from the user's perspective. The
`change_log` table already records every mutation with timestamps, change
types, and full task state — there is no separate history storage.
`opal <id> log` reads directly from `change_log` and presents it in a
human-readable format. `opal <id> info` shows the last few entries as a
"Recent Changes" section.
The change_log's 30-day retention (configurable via `sync_config`) applies —
history older than the retention window is automatically cleaned up by the
existing `CleanupChangeLog()` function.
#### Engine Interface — `engine/history.go`
```go
type HistoryEntry struct {
ID int
Timestamp time.Time
ChangeType string // "create", "update", "delete"
Data string // raw key:value data from change_log
}
// GetTaskHistory returns change_log entries for a task UUID.
func GetTaskHistory(taskUUID uuid.UUID) ([]HistoryEntry, error)
// FormatTaskHistory returns a formatted history display.
// Parses the key:value data to show a diff-style view of what changed
// between consecutive entries.
func FormatTaskHistory(entries []HistoryEntry) string
```
**`FormatTaskHistory` diff logic:**
Rather than dumping the raw key:value data (which is the full state at each
point), compare consecutive entries and show only what changed:
```
2026-02-18 09:15 created "Buy groceries" priority:D +errand
2026-02-18 10:30 modified priority: D → H
2026-02-18 14:00 modified due: (none) → 2026-02-20
2026-02-18 16:00 completed
```
This requires parsing the `data` field of adjacent entries and diffing. The
`applyChangeDataToTask()` function in `sync/client.go` already knows how to
parse this format — we extract and reuse that parsing logic.
#### Cmd Layer — `cmd/log.go`
```go
var logCmd = &cobra.Command{
Use: "log [filter]",
Short: "Show change history for a task",
}
```
Resolves exactly one task (like `info` and `edit`), then calls
`GetTaskHistory(task.UUID)`.
#### Info Integration
`FormatTaskDetail` appends a "Recent Changes" section showing the last 5
history entries (if available).
---
### 8. Shell Completions & Help Text (IMP-8)
Cobra has built-in completion generation. The main work is registering dynamic
completions for tags and projects, and improving help text discoverability.
#### Help Text Improvements
The current `opal --help` lists all commands and reports in a flat list. We
should use Cobra's command grouping to separate them:
```
Commands:
add Add a new task
done Mark tasks as completed
modify Modify tasks
delete Delete tasks
start Start working on a task
stop Stop working on a task
edit Edit a task in $EDITOR
info Show task details
annotate Add an annotation to a task
denotate Remove the most recent annotation
undo Undo the last action
uncomplete Restore a completed task to pending
log Show change history for a task
Reports:
list Pending tasks (default)
next Next tasks to work on
active Currently active tasks
overdue Overdue tasks
...
Other:
completion Generate shell completions
version Print version information
server Manage the API server
sync Sync tasks
setup Interactive setup wizard
```
Cobra supports this via `AddGroup()` and setting `GroupID` on commands.
#### Cmd Layer — `cmd/completion.go`
```go
var completionCmd = &cobra.Command{
Use: "completion [bash|zsh|fish]",
Short: "Generate shell completions",
}
```
Uses `rootCmd.GenBashCompletion()`, `rootCmd.GenZshCompletion()`, etc.
#### Dynamic Completions
Register `ValidArgsFunction` on commands that accept tags/projects:
```go
addCmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
// Query DB for tag and project names
tags, _ := engine.GetAllTags()
projects, _ := engine.GetAllProjects()
// ... format as "+tag" and "project:name"
}
```
This requires the DB to be initialized during completion. Cobra's completion
mechanism calls `PersistentPreRun`, so `initializeApp()` runs and the DB is
available.
---
### 9. Version Command (IMP-13)
#### Build-time Variables
```go
// cmd/version.go
var (
Version = "dev"
Commit = "unknown"
BuildDate = "unknown"
)
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print version information",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("opal %s (%s) built %s\n", Version, Commit, BuildDate)
},
}
```
Set via `ldflags` in the build:
```sh
go build -ldflags "-X cmd.Version=$(cat VERSION) -X cmd.Commit=$(git rev-parse --short HEAD) -X cmd.BuildDate=$(date -u +%Y-%m-%d)"
```
Also register `rootCmd.Version = Version` so `opal --version` works via Cobra.
---
## Schema Changes
All changes go directly into the existing migration v1 in `database.go`. No
separate migration needed.
**New column on `tasks`:**
```sql
annotations TEXT DEFAULT NULL
```
**New table:**
```sql
CREATE TABLE undo_stack (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at INTEGER NOT NULL,
op_type TEXT NOT NULL,
task_uuid TEXT NOT NULL,
change_log_id INTEGER NOT NULL
);
```
**Updated triggers:** The `track_task_create` and `track_task_update` triggers
gain an additional line to capture annotations:
```sql
CASE WHEN NEW.annotations IS NOT NULL
THEN 'annotations: ' || NEW.annotations || CHAR(10) ELSE '' END ||
```
---
## File & Module Changes
### New Files
| File | Purpose | IMP |
|------|---------|-----|
| `engine/undo.go` | Undo stack + change_log-based revert logic | 1 |
| `engine/feedback.go` | Shared action output formatting | 2, 3, 7 |
| `engine/reldate.go` | Relative date formatting | 9 |
| `engine/annotate.go` | Annotation CRUD | 11 |
| `engine/history.go` | Task history (reads change_log) | 12 |
| `engine/keys.go` | Canonical `ValidAttributeKeys`, `DateKeys`, `FilterOnlyKeys` | 5 |
| `cmd/undo.go` | `opal undo` command | 1 |
| `cmd/uncomplete.go` | `opal <id> uncomplete` command | 1 |
| `cmd/annotate.go` | `opal <id> annotate` / `denotate` | 11 |
| `cmd/log.go` | `opal <id> log` command | 12 |
| `cmd/completion.go` | Shell completion generation | 8 |
| `cmd/version.go` | Version command | 13 |
### Modified Files
| File | Changes | IMP |
|------|---------|-----|
| `engine/database.go` | Add `annotations` column to tasks, add `undo_stack` table, update triggers | 1, 11 |
| `engine/task.go` | `Annotations` field on Task, `Complete()` returns `*Task`, annotations in Save/Get | 7, 11 |
| `engine/display.go` | Use `FormatRelativeDate` in `formatDue()`, add annotations to `FormatTaskDetail`, add recent history | 9, 11, 12 |
| `engine/modifier.go` | Use shared `DateKeys` / `ValidAttributeKeys`, error on unknown keys | 5 |
| `engine/filter.go` | Use shared `ValidAttributeKeys` / `FilterOnlyKeys` for colon parsing | 5 |
| `engine/recurrence.go` | `SpawnNextInstance` returns `(*Task, error)` | 7 |
| `engine/ws.go` | Add `AppendTask()` function | 2 |
| `cmd/root.go` | Register new commands in groups, add `--dry-run` flag, update `commandNames` | 1, 8, 10, 11, 12, 13 |
| `cmd/add.go` | Use `ValidAttributeKeys` in `parseAddArgs()`, call `RecordUndo`, use `FormatAddFeedback` | 1, 2, 5 |
| `cmd/done.go` | Show matched tasks, capture `Complete()` return, `FormatCompletionFeedback`, `RecordUndo`, stderr on no-match | 1, 3, 6, 7 |
| `cmd/delete.go` | Load working set, show matched tasks, `RecordUndo`, stderr on no-match | 1, 3, 4, 6 |
| `cmd/modify.go` | Show matched tasks, `RecordUndo` | 1, 3 |
| `cmd/start.go` | `RecordUndo`, stderr on no-match | 1, 6 |
| `cmd/stop.go` | `RecordUndo`, stderr on no-match | 1, 6 |
| `cmd/info.go` | Show annotations, show recent history | 11, 12 |
| `cmd/edit.go` | Show annotations as editable lines | 11 |
| `internal/sync/client.go` | Parse `annotations:` key in `applyChangeDataToTask()` | 11 |
### API Changes (for web parity)
The REST API should also surface annotations:
| Endpoint | Change |
|----------|--------|
| `GET /tasks`, `GET /tasks/{uuid}` | Include `annotations` in response |
| `PUT /tasks/{uuid}` | Accept `annotations` in request body |
| `POST /tasks/{uuid}/annotate` | New endpoint — add annotation |
| `POST /tasks/{uuid}/denotate` | New endpoint — remove last annotation |
| `POST /tasks/{uuid}/uncomplete` | New endpoint — restore to pending |
---
## Technical Decisions
### TD-1: Undo via change_log vs. separate snapshot storage
**Decision:** Use the existing `change_log` as the source of "before" states.
A lightweight `undo_stack` table stores only references (change_log IDs), not
duplicate snapshots.
**Alternatives:** Separate `undo_log` table with full task JSON snapshots.
**Rationale:** The change_log already captures the full task state on every
mutation via SQLite triggers. Duplicating that data in a separate table wastes
space and introduces a risk of the two copies diverging. The undo revert
creates a normal mutation that syncs via change_log triggers, making undo
visible across devices.
**Consequence:** Undo depth is limited by change_log retention (default 30
days). If retention cleanup removes the "before" entry, that undo entry becomes
unresolvable. The 10-entry undo cap means this is unlikely in practice — 10
undos within 30 days is well within retention.
### TD-2: `Complete()` signature change
**Decision:** Change `Complete()` to return `(*Task, error)`.
**Alternatives:** Add a separate `CompleteAndGetNext()` method.
**Rationale:** Every caller of `Complete()` benefits from knowing about the
spawned instance. Adding a parallel method creates confusion about which to
call. The return value is simply ignored where not needed.
**Consequence:** All callers of `Complete()` must update to handle the new
return type. There are 3 call sites: `cmd/done.go`, `handlers/tasks.go`, and
`sync/client.go`.
### TD-3: Allowlist vs. escape syntax for colons
**Decision:** Allowlist of known modifier keys (`ValidAttributeKeys`).
**Alternatives:** Escape syntax (e.g., `\:` or quoting), blocklist of known
non-modifier patterns.
**Rationale:** Allowlist is the simplest correct approach. Escape syntax adds
cognitive load. Blocklist is fragile (can't anticipate all URLs, time formats,
etc.). The set of modifier keys is small and stable.
**Consequence:** Adding a new task attribute in the future requires adding it
to `ValidAttributeKeys`. This is acceptable — new attributes are rare and
require engine changes anyway.
### TD-4: Annotations as JSON column vs. separate table
**Decision:** JSON column on `tasks` table.
**Alternatives:** Separate `annotations` table with FK to tasks.
**Rationale:** Benefits: syncs naturally via existing triggers, no joins
needed, annotations are always co-located with their task. Tradeoff: can't
query individual annotations with SQL (searching annotation text requires JSON
parsing). Acceptable for the expected usage pattern (< 10 annotations per
task).
### TD-5: No migration system — edit schema directly
**Decision:** All schema changes go directly into the existing migration v1 in
`database.go`.
**Alternatives:** Add a migration v2 with ALTER TABLE / DROP TRIGGER / etc.
**Rationale:** Not in production. No deployed databases need upgrading. Direct
schema editing is simpler and keeps the codebase clean. A migration system can
be added later when production deployment requires it.
---
## Implementation Order
Suggested order based on dependencies and risk:
| Phase | Items | Rationale |
|-------|-------|-----------|
| 1 | IMP-4, IMP-6, IMP-5 | Bug fixes first. Low risk. IMP-5 introduces `engine/keys.go` used everywhere. |
| 2 | IMP-13 | Trivial, establishes version for release tracking. |
| 3 | IMP-9, IMP-3, IMP-2, IMP-7 | Output improvements. IMP-3 provides `FormatTaskConfirmList` reused by IMP-10. IMP-7 requires `Complete()` signature change. |
| 4 | IMP-10 | Dry-run, depends on IMP-3's confirmation display. |
| 5 | IMP-1, IMP-11 | Undo + annotations. Both require schema changes (edit v1 together). |
| 6 | IMP-12 | Task history. No schema changes, reads existing change_log. |
| 7 | IMP-8 | Shell completions + help text improvements. Independent, can be done anytime. |
---
## Resolved Design Questions
1. **Edit command + annotations:** Annotations are **editable** in `opal edit`.
Parsed as `annotation.N: timestamp | text` lines. Diffed on save to detect
adds, removes, and edits.
2. **Undo across `add` for recurring tasks:** Yes — hard delete both the
template and the first instance.
3. **Working set append after add:** `AppendTask` assigns `MAX(display_id) + 1`.
This ID is ephemeral — the working set is fully recalculated on every report
render, and display IDs are determined by the report's sort order, not by
insertion order. The appended ID is valid for the immediate "add then act"
workflow and will shift on the next report. This is consistent with existing
behavior — all display IDs are ephemeral by design.