Files
gems/docs/design/cli-ux-design.md
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

34 KiB

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

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

// 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

// 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

// 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.

// 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:

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:

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.goComplete() return value:

Currently Complete() returns error. To surface the spawned instance, change the signature:

// 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:

// 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:

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:

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.

// 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.goparseAddArgs():

Replace the current strings.Contains(arg, ":") heuristic with an allowlist check using engine.ValidAttributeKeys:

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.goParseFilter():

Same allowlist plus filter-only keys:

// 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:

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:

-- 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

// 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

// 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

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

// 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:

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).

// 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:

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

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

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

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:

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

// 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:

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:

annotations TEXT DEFAULT NULL

New table:

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:

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.