f57baee6bc
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>
1007 lines
34 KiB
Markdown
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.
|