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>
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:
- Undo system — leverages the existing
change_logfor state recovery, with a lightweight undo stack for tracking CLI operations (IMP-1) - Output / feedback — richer CLI output from existing commands (IMP-2, 3, 6, 7, 9)
- Parser / ID resolution fixes — correctness in
cmd/andengine/(IMP-4, 5) - 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:
SELECT MAX(id) FROM change_log WHERE task_uuid = ?— find the entry just created by this mutation.INSERT INTO undo_stack (created_at, op_type, task_uuid, change_log_id) VALUES (...).- 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:
- Loads the task by display ID.
- Asserts
status == StatusCompleted. - Sets
status = StatusPending, clearsEnd. - 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: changefmt.Printlntofmt.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:
// 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.go — parseAddArgs():
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.go — ParseFilter():
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:
- Append
{timestamp: now, text: text}tot.Annotations. - Serialize annotations to JSON.
UPDATE tasks SET annotations = ?, modified = ? WHERE uuid = ?.
Denotate behavior:
- Pop last element from
t.Annotations. - If empty, set annotations column to NULL.
- Save.
SQL Persistence
The annotations column is read/written alongside all other task fields in
GetTask(), GetTasks(), and Save():
Save()INSERT/UPDATE: addannotationsparameter (JSON string or NULL).GetTask()/GetTasks(): addannotationsto 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.editcommand: Show annotations as editable lines. Format: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.# 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
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
-
Edit command + annotations: Annotations are editable in
opal edit. Parsed asannotation.N: timestamp | textlines. Diffed on save to detect adds, removes, and edits. -
Undo across
addfor recurring tasks: Yes — hard delete both the template and the first instance. -
Working set append after add:
AppendTaskassignsMAX(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.