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>
This commit is contained in:
2026-02-19 13:37:33 +01:00
parent a551f50cef
commit f57baee6bc
11 changed files with 1573 additions and 32 deletions
File diff suppressed because it is too large Load Diff
+470
View File
@@ -0,0 +1,470 @@
# Opal CLI: User Experience Improvements
**Status:** Draft — awaiting feedback
**Date:** 2026-02-18
---
## Problem Statement
Opal's CLI is functional and expressive, but several gaps in feedback, safety,
and discoverability make daily use rougher than it needs to be. These
improvements target the person who uses `opal` 10+ times a day — reducing
friction, preventing mistakes, and surfacing information at the right moment.
---
## Proposed Improvements
### IMP-1: Undo / Uncomplete
**Priority:** MUST
**Noted in:** `opal-web/BUGS.md` (missing uncomplete feat)
#### Problem
Accidentally completing or deleting the wrong task has no quick recovery path.
The only workaround is `opal edit <uuid>` and manually setting status back to
`pending`. This is slow, error-prone, and requires knowing the UUID.
#### User Stories
**US-1.1** As a user, I want to uncomplete a task so that I can recover from
accidental completions.
- **Given** task 3 was just completed
- **When** I run `opal 3 uncomplete` (or `opal undo`)
- **Then** task 3 returns to `pending` status, its `end` timestamp is cleared,
and it reappears in my default report
**US-1.2** As a user, I want a generic `undo` that reverts my last action so
that I don't need to know the exact reverse command.
- **Given** I just ran `opal 5 delete`
- **When** I run `opal undo`
- **Then** task 5 is restored to its previous status
#### Functional Requirements
1. FR-1.1: `opal undo` MUST revert the last mutating CLI action (done,
delete, modify, add, start, stop).
2. FR-1.2: `opal <id> uncomplete` MUST set a completed task back to pending
and clear the `end` timestamp.
3. FR-1.3: Undo history SHOULD persist across CLI invocations (stored in a
local undo log file or DB table).
4. FR-1.4: Undo SHOULD support at least the last 10 operations.
5. FR-1.5: `opal undo` MUST display what was reverted
(e.g., `Undone: task 3 "Buy milk" restored to pending`).
#### Design Decisions
- **Scope:** Local only — undo does NOT propagate across sync boundaries.
- **Undo `add`:** Deletes the created task entirely (hard delete, not soft).
- **Multi-level:** `opal undo` can be called repeatedly to walk back through
the last 10 operations (stack-based, LIFO).
---
### IMP-2: Better Feedback After `add`
**Priority:** MUST
#### Problem
`opal add Buy groceries due:tomorrow +errand` prints:
```
Created task 8f3a1b2c-4d5e-6f7a-8b9c-0d1e2f3a4b5c
```
The UUID is meaningless for subsequent commands. The user can't confirm their
modifiers were parsed correctly without running a separate `list` or `info`.
#### User Stories
**US-2.1** As a user, I want to see the display ID and parsed attributes after
adding a task so that I can confirm it was created correctly and reference it
immediately.
- **Given** I run `opal add Buy groceries due:tomorrow +errand`
- **When** the task is created
- **Then** I see output like:
```
Created task 3 — "Buy groceries"
Due: tomorrow (2026-02-19)
Tags: errand
Priority: default
```
#### Functional Requirements
1. FR-2.1: `add` MUST display the new task's display ID (not just UUID).
2. FR-2.2: `add` MUST echo back all parsed modifiers so the user can verify.
3. FR-2.3: For recurring tasks, `add` MUST show recurrence interval and the
first instance's due date.
4. FR-2.4: The display ID shown MUST be valid for immediate use
(e.g., `opal 3 done`).
---
### IMP-3: Show Matched Tasks in Confirmations
**Priority:** MUST
#### Problem
Destructive commands (`done`, `delete`, `modify`) prompt with only a count:
```
About to complete 3 tasks. Proceed? (y/N):
```
The user can't verify *which* 3 tasks will be affected without cancelling and
running a separate `list` command with the same filter.
#### User Stories
**US-3.1** As a user, I want to see the list of affected tasks before
confirming a bulk action so that I can verify I'm not making a mistake.
- **Given** I run `opal +errand done`
- **When** 3 tasks match
- **Then** I see:
```
About to complete 3 tasks:
1 Buy groceries due:tomorrow +errand
4 Return library books due:fri +errand
7 Pick up dry cleaning +errand
Proceed? (y/N):
```
#### Functional Requirements
1. FR-3.1: `done`, `delete`, and `modify` MUST list matched tasks (ID,
description, key attributes) before the confirmation prompt.
2. FR-3.2: If more than 10 tasks match, show the first 10 and note
"...and N more".
3. FR-3.3: Single-task operations (e.g., `opal 3 done`) SHOULD still show the
task description for verification but skip the y/N prompt.
---
### IMP-4: Fix `delete` Not Resolving Display IDs
**Priority:** MUST (bug fix)
#### Problem
`delete` calls `engine.GetTasks(filter)` directly without loading the working
set to resolve display IDs. This means `opal 3 delete` may not resolve ID 3
correctly, unlike `done` and `modify` which both load the working set.
#### Functional Requirements
1. FR-4.1: `delete` MUST load the working set and resolve display IDs the same
way `done` and `modify` do.
2. FR-4.2: All action commands (done, delete, modify, start, stop, info, edit)
MUST use the same ID resolution path.
---
### IMP-5: Handle Colons in Descriptions
**Priority:** MUST
#### Problem
`parseAddArgs` treats any argument containing `:` as a modifier. This silently
drops or misparses descriptions containing colons:
```
opal add "Meeting: discuss Q3 goals" # "Meeting:" parsed as modifier
opal add Fix bug in http://example.com # "http:" parsed as modifier
```
There's no escaping mechanism or error message — the description is silently
truncated.
#### User Stories
**US-5.1** As a user, I want to include colons in task descriptions so that I
can write natural language without worrying about parser conflicts.
- **Given** I run `opal add "Meeting: discuss Q3 goals"`
- **When** the task is created
- **Then** the description is `Meeting: discuss Q3 goals` with no modifiers
#### Functional Requirements
1. FR-5.1: Quoted strings MUST be treated as description text, not parsed for
modifiers.
2. FR-5.2: Only tokens matching a known modifier pattern (`key:value` where
`key` is a recognized attribute like `due`, `priority`, `project`, `recur`,
`status`, `wait`, `scheduled`, `until`) SHOULD be treated as modifiers.
3. FR-5.3: Unknown `key:value` patterns SHOULD be treated as description text,
not silently dropped.
4. FR-5.4: If a token is ambiguous, prefer treating it as description text.
#### Design Decisions
- **Allowlist approach:** Only recognized attribute keys (`due`, `priority`,
`project`, `recur`, `status`, `wait`, `scheduled`, `until`) are treated as
modifiers. All other `key:value` tokens are treated as description text.
---
### IMP-6: Consistent Error on No-Match
**Priority:** SHOULD
#### Problem
Action commands behave inconsistently when no tasks match a filter:
| Command | No-match behavior | Exit code |
|----------|-------------------------------------|-----------|
| `done` | Prints "No tasks matched." | 0 |
| `delete` | Prints "No tasks matched." | 0 |
| `modify` | Returns error "no tasks matched" | 1 |
| `start` | (unknown — needs verification) | ? |
| `stop` | (unknown — needs verification) | ? |
#### Functional Requirements
1. FR-6.1: All action commands MUST return exit code 1 when no tasks match an
explicit filter.
2. FR-6.2: All action commands MUST print to stderr (not stdout) when no tasks
match, to support scripting.
3. FR-6.3: The message SHOULD be consistent:
`Error: no tasks matched filter "<filter>"`.
---
### IMP-7: Recurring Task Feedback
**Priority:** SHOULD
#### Problem
Completing a recurring task instance gives no indication about recurrence:
```
$ opal 3 done
Completed 1 task(s).
```
The user doesn't know if a next instance was spawned, when it's due, or whether
the recurrence is still active.
#### User Stories
**US-7.1** As a user, I want to see recurrence information when completing a
recurring task so that I know the schedule is continuing.
- **Given** task 3 is a recurring weekly task
- **When** I run `opal 3 done`
- **Then** I see:
```
Completed task 3 — "Weekly review"
Next instance created — due: 2026-02-25 (in 7 days)
```
#### Functional Requirements
1. FR-7.1: Completing a recurring task instance MUST display whether a new
instance was created and its due date.
2. FR-7.2: If no new instance was created (e.g., recurrence was cleared), the
output MUST say so.
3. FR-7.3: `info` on a recurring instance SHOULD show the recurrence pattern
and parent template UUID.
---
### IMP-8: Shell Completions
**Priority:** SHOULD
#### Problem
No tab completion exists for commands, report names, project names, or tag
names. For a CLI with 14 commands, 13 report names, and user-defined projects
and tags, discoverability is poor.
#### Functional Requirements
1. FR-8.1: `opal completion bash|zsh|fish` MUST generate shell completion
scripts (cobra has built-in support for this).
2. FR-8.2: Completions SHOULD cover: commands, report names, `+tag` names,
`project:` values, `priority:` values, and `status:` values.
3. FR-8.3: Dynamic completions for tags and projects SHOULD query the database.
4. FR-8.4: Setup instructions SHOULD be printed after `opal completion <shell>`.
---
### IMP-9: Relative Dates in CLI Reports
**Priority:** SHOULD
#### Problem
CLI report tables likely show absolute dates (`2026-02-20`). When scanning a
task list, relative dates ("in 2d", "yesterday", "3w ago") are faster to parse
at a glance. The web UI already uses relative dates.
#### Functional Requirements
1. FR-9.1: Due dates in reports MUST be shown as relative when within 14 days
(e.g., "tomorrow", "in 3d", "2d ago").
2. FR-9.2: Dates beyond 14 days SHOULD fall back to short absolute format
(e.g., "Feb 28", "Mar 15").
3. FR-9.3: `info` SHOULD show both absolute and relative
(e.g., `Due: 2026-02-20 (in 2 days)`).
4. FR-9.4: Relative display COULD be togglable via config
(`date_display: relative|absolute`).
---
### IMP-10: Dry-Run / Preview for Action Commands
**Priority:** SHOULD
#### Problem
Before running `opal +errand done`, the user often runs `opal +errand list`
first to preview. This is a two-step workflow that could be one step.
#### Functional Requirements
1. FR-10.1: `done`, `delete`, `modify`, `start`, and `stop` SHOULD support a
`--dry-run` flag that lists matched tasks without acting.
2. FR-10.2: Dry-run output MUST match the same format as the confirmation
listing (IMP-3), followed by "Dry run — no changes made."
3. FR-10.3: Dry-run MUST exit with code 0 if tasks matched, 1 if none matched.
---
### IMP-11: Task Annotations
**Priority:** SHOULD
#### Problem
There's no way to attach notes to a task after creation. For long-running tasks
like "Debug auth issue" or "Research hosting options", users want to record
progress without cluttering the description.
#### User Stories
**US-11.1** As a user, I want to annotate tasks with timestamped notes so that
I can track progress and findings over time.
- **Given** task 3 exists
- **When** I run `opal 3 annotate "Traced to token expiry in middleware"`
- **Then** the annotation is saved with a timestamp
- **And** `opal 3 info` shows the annotation under the task details
**US-11.2** As a user, I want to link a task to a jade-depo note so that I can
associate detailed research or write-ups with a task.
- **Given** task 3 exists and a jade-depo note "debug-auth-issue.md" exists
- **When** I run `opal 3 annotate --note debug-auth-issue` (or similar)
- **Then** the task stores a reference to the jade-depo note
- **And** `opal 3 info` shows the linked note path
#### Functional Requirements
1. FR-11.1: `opal <id> annotate "<text>"` SHOULD add a timestamped note.
2. FR-11.2: Annotations MUST be visible in `info` and `edit`.
3. FR-11.3: Annotations MUST sync via the existing change log / sync system.
4. FR-11.4: `opal <id> denotate` COULD remove the most recent annotation.
5. FR-11.5: Annotations SHOULD support linking to jade-depo notes (exact
mechanism TBD — flag, URI scheme, or convention like `note:slug`).
#### Design Decisions
- **Storage:** JSON text column (`annotations`) on the `tasks` table. Each
annotation is a JSON object with `timestamp` and `text` fields. Stored as a
JSON array, e.g.:
```json
[
{"timestamp": 1708300000, "text": "Traced to token expiry in middleware"},
{"timestamp": 1708310000, "text": "note:debug-auth-issue"}
]
```
This keeps annotations co-located with the task, avoids schema complexity,
and syncs naturally via the existing change_log triggers.
#### Open Questions
- Should annotations be searchable via filters (e.g., `opal annotation:token list`)?
- Jade-depo integration: should `opal 3 annotate --note <title>` verify the
note exists in jade-depo, or just store the reference loosely? Loose coupling
is simpler but can lead to stale links.
---
### IMP-12: Task History
**Priority:** COULD
#### Problem
The `change_log` table records every mutation for sync, but there's no
user-facing way to view a task's history. Useful for understanding what changed,
when, and debugging unexpected state.
#### Functional Requirements
1. FR-12.1: `opal <id> log` COULD display the change history for a task.
2. FR-12.2: Output SHOULD show timestamp, change type, and what changed:
```
2026-02-18 09:15 created "Buy groceries" priority:D
2026-02-18 10:30 modified priority: D → H
2026-02-18 14:00 completed
```
3. FR-12.3: History MUST respect the existing change_log retention policy.
4. FR-12.4: History SHOULD be surfaced in `info` output (recent changes
section) and available in `edit` as read-only comment lines.
---
### IMP-13: Version Command
**Priority:** COULD
#### Functional Requirements
1. FR-13.1: `opal version` (or `opal --version`) MUST print the build version.
2. FR-13.2: Version SHOULD be set at build time via `ldflags`, reading from a
`VERSION` file in the repo root.
3. FR-13.3: Output SHOULD include version, commit hash, and build date.
---
## Out of Scope
- **GUI/TUI redesign** — this document covers CLI UX only.
- **New task attributes** (e.g., estimated effort, dependencies between tasks).
- **Multi-user features** — opal is a personal/household tool.
- **Plugin/hook system** — not needed at this stage.
- **Web UI changes** — covered separately in `opal-web/REQUIREMENTS.md`.
---
## Priority Summary
| ID | Improvement | Priority | Effort |
|--------|--------------------------------------|----------|----------|
| IMP-1 | Undo / uncomplete | MUST | Medium |
| IMP-2 | Better `add` feedback | MUST | Low |
| IMP-3 | Show matched tasks in confirmations | MUST | Low |
| IMP-4 | Fix `delete` display ID resolution | MUST | Low |
| IMP-5 | Handle colons in descriptions | MUST | Medium |
| IMP-6 | Consistent no-match error behavior | SHOULD | Low |
| IMP-7 | Recurring task feedback | SHOULD | Low |
| IMP-8 | Shell completions | SHOULD | Medium |
| IMP-9 | Relative dates in CLI reports | SHOULD | Low |
| IMP-10 | Dry-run flag for actions | SHOULD | Low |
| IMP-11 | Task annotations | SHOULD | Medium |
| IMP-12 | Task history | COULD | Medium |
| IMP-13 | Version command | COULD | Trivial |
+16 -10
View File
@@ -71,23 +71,29 @@ func addTask(args []string) error {
return nil
}
// parseAddArgs extracts description and modifiers from args
// Description = all non-filter/modifier words joined with spaces
// Filters/Modifiers = args with +, -, or containing :
// parseAddArgs extracts description and modifiers from args.
// Tags (+tag, -tag) are always modifiers. For key:value tokens, only
// recognized attribute keys (engine.ValidAttributeKeys) are treated as
// modifiers — everything else becomes part of the description.
func parseAddArgs(args []string) (string, []string, error) {
var descParts []string
var modifiers []string
for _, arg := range args {
isFilterOrModifier := strings.HasPrefix(arg, "+") ||
strings.HasPrefix(arg, "-") ||
strings.Contains(arg, ":")
if isFilterOrModifier {
if strings.HasPrefix(arg, "+") || strings.HasPrefix(arg, "-") {
modifiers = append(modifiers, arg)
} else {
descParts = append(descParts, arg)
continue
}
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 {
+21 -4
View File
@@ -26,14 +26,31 @@ func deleteTasks(args []string) error {
return err
}
tasks, err := engine.GetTasks(filter)
// Load working set to resolve display IDs (matches done/modify pattern)
ws, err := engine.LoadWorkingSet()
if err != nil {
return err
return fmt.Errorf("failed to load working set: %w", err)
}
var tasks []*engine.Task
if len(filter.IDs) > 0 {
for _, id := range filter.IDs {
task, err := ws.GetTaskByDisplayID(id)
if err != nil {
return err
}
tasks = append(tasks, task)
}
} else {
tasks, err = engine.GetTasks(filter)
if err != nil {
return err
}
}
if len(tasks) == 0 {
fmt.Println("No tasks matched.")
return nil
return fmt.Errorf("no tasks matched filter")
}
fmt.Printf("Delete %d task(s)? (y/N): ", len(tasks))
+1 -2
View File
@@ -63,8 +63,7 @@ func completeTasks(args []string) error {
}
if len(tasks) == 0 {
fmt.Println("No tasks matched.")
return nil
return fmt.Errorf("no tasks matched filter")
}
// Confirm if multiple tasks
+1 -1
View File
@@ -82,7 +82,7 @@ func modifyTasks(filterArgs, modifierArgs []string) error {
}
if len(tasks) == 0 {
return fmt.Errorf("no tasks matched")
return fmt.Errorf("no tasks matched filter")
}
// Confirm if multiple tasks or no filters specified
+4
View File
@@ -43,6 +43,10 @@ func startTasks(args []string) error {
}
}
if len(tasks) == 0 {
return fmt.Errorf("no tasks matched filter")
}
for _, task := range tasks {
task.StartTask()
fmt.Printf("Started task: %s\n", task.Description)
+4
View File
@@ -43,6 +43,10 @@ func stopTasks(args []string) error {
}
}
if len(tasks) == 0 {
return fmt.Errorf("no tasks matched filter")
}
for _, task := range tasks {
task.StopTask()
fmt.Printf("Stopped task: %s\n", task.Description)
+8 -7
View File
@@ -42,17 +42,18 @@ func ParseFilter(args []string) (*Filter, error) {
} else if strings.HasPrefix(arg, "-") && !strings.Contains(arg, ":") {
// Exclude tag (but not negative modifiers like priority:-)
f.ExcludeTags = append(f.ExcludeTags, strings.TrimPrefix(arg, "-"))
} else if strings.Contains(arg, ":") {
// Attribute filter
parts := strings.SplitN(arg, ":", 2)
key := parts[0]
value := parts[1]
} else if idx := strings.Index(arg, ":"); idx > 0 {
key := arg[:idx]
value := arg[idx+1:]
if key == "uuid" {
if FilterOnlyKeys[key] {
// Filter-only keys (e.g., uuid)
f.UUIDs = append(f.UUIDs, value)
} else {
} else if ValidAttributeKeys[key] {
// Known attribute filter
f.Attributes[key] = value
}
// Unrecognized key:value tokens are silently ignored
} else {
// Try parsing as numeric ID
id, err := strconv.Atoi(arg)
+30
View File
@@ -0,0 +1,30 @@
package engine
// ValidAttributeKeys are the recognized key:value attribute names.
// Used by parseAddArgs (cmd/add.go), ParseFilter, and ParseModifier
// to distinguish modifiers from description text.
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 ValidAttributeKeys that hold date values.
// Used by Modifier.Apply and Modifier.ApplyToNew for date parsing.
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 target, not something you set via modify).
var FilterOnlyKeys = map[string]bool{
"uuid": true,
}
+12 -8
View File
@@ -23,7 +23,9 @@ func NewModifier() *Modifier {
}
}
// ParseModifier parses command-line args into Modifier
// ParseModifier parses command-line args into Modifier.
// Only recognized attribute keys (ValidAttributeKeys) are accepted;
// unrecognized key:value tokens produce an error.
func ParseModifier(args []string) (*Modifier, error) {
m := NewModifier()
@@ -34,11 +36,13 @@ func ParseModifier(args []string) (*Modifier, error) {
} else if strings.HasPrefix(arg, "-") && !strings.Contains(arg, ":") {
// Remove tag
m.RemoveTags = append(m.RemoveTags, strings.TrimPrefix(arg, "-"))
} else if strings.Contains(arg, ":") {
// Attribute modification
parts := strings.SplitN(arg, ":", 2)
key := parts[0]
value := parts[1]
} else if idx := strings.Index(arg, ":"); idx > 0 {
key := arg[:idx]
value := arg[idx+1:]
if !ValidAttributeKeys[key] {
return nil, fmt.Errorf("unknown modifier: %q (known: due, priority, project, recur, status, wait, scheduled, until)", key)
}
if value == "" {
// Clear attribute (priority: with no value)
@@ -83,7 +87,7 @@ func (m *Modifier) Apply(task *Task) error {
resolvedDates["modified"] = task.Modified
// Apply attributes in the order they were specified (important for relative references)
dateKeys := map[string]bool{"due": true, "scheduled": true, "wait": true, "until": true}
dateKeys := DateKeys
for _, key := range m.AttributeOrder {
valuePtr := m.SetAttributes[key]
@@ -151,7 +155,7 @@ func (m *Modifier) ApplyToNew(task *Task) error {
}
// Apply attributes in the order they were specified (important for relative references)
dateKeys := map[string]bool{"due": true, "scheduled": true, "wait": true, "until": true}
dateKeys := DateKeys
for _, key := range m.AttributeOrder {
valuePtr := m.SetAttributes[key]