From 8e351c86502cea78f1f0b3aa19cde7ca702bacab Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 8 Apr 2026 09:58:02 +0300 Subject: Rename task CLI from ask to do - Move cmd/ask to cmd/do; mage BuildDo builds binary named do - Update askcli help text, errors, Fish completion (complete -c do, __do_*) - Task alias cache path: XDG cache hexai/do/task-aliases-v2.json - Refresh README and docs; go install path cmd/do@latest - Remove accidentally tracked cmd/ask build artifact; ignore cmd/do/do and cmd/do/ask Made-with: Cursor --- .gitignore | 4 +- Magefile.go | 12 +- README.md | 6 +- cmd/ask/ask | Bin 4322459 -> 0 bytes cmd/ask/main.go | 22 - cmd/ask/main_test.go | 51 -- cmd/do/main.go | 22 + cmd/do/main_test.go | 51 ++ docs/buildandinstall.md | 6 +- docs/fish-completion.md | 22 +- docs/plan-ask-uuid-wrapper.md | 108 +-- docs/usage.md | 84 +- integrationtests/ask_scope_test.go | 262 ------ integrationtests/ask_test.go | 1283 ------------------------------ integrationtests/do_scope_test.go | 262 ++++++ integrationtests/do_test.go | 1283 ++++++++++++++++++++++++++++++ internal/askcli/command_add.go | 8 +- internal/askcli/command_delete.go | 2 +- internal/askcli/command_dep.go | 8 +- internal/askcli/command_fish.go | 4 +- internal/askcli/command_info_add_test.go | 2 +- internal/askcli/command_write.go | 16 +- internal/askcli/completion.go | 92 +-- internal/askcli/completion_test.go | 42 +- internal/askcli/dispatch.go | 50 +- internal/askcli/dispatch_test.go | 16 +- internal/askcli/formatter.go | 2 +- internal/askcli/task_alias_cache.go | 2 +- internal/askcli/taskexec.go | 6 +- internal/askcli/taskexec_test.go | 22 +- internal/taskproxy/run_test.go | 12 +- 31 files changed, 1882 insertions(+), 1880 deletions(-) delete mode 100755 cmd/ask/ask delete mode 100644 cmd/ask/main.go delete mode 100644 cmd/ask/main_test.go create mode 100644 cmd/do/main.go create mode 100644 cmd/do/main_test.go delete mode 100644 integrationtests/ask_scope_test.go delete mode 100644 integrationtests/ask_test.go create mode 100644 integrationtests/do_scope_test.go create mode 100644 integrationtests/do_test.go diff --git a/.gitignore b/.gitignore index 90b1ee1..49978b8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ -/ask +/do +/cmd/do/do +/cmd/do/ask /hexai /hexai-lsp-server /hexai-mcp-server diff --git a/Magefile.go b/Magefile.go index 65748fd..fb19689 100644 --- a/Magefile.go +++ b/Magefile.go @@ -25,15 +25,15 @@ var ( // Build builds binaries. func Build() error { - mg.Deps(BuildAsk, BuildHexaiLSP, BuildHexaiCLI, BuildHexaiTmuxAction, BuildHexaiTmuxEdit, BuildHexaiMCPServer) + mg.Deps(BuildDo, BuildHexaiLSP, BuildHexaiCLI, BuildHexaiTmuxAction, BuildHexaiTmuxEdit, BuildHexaiMCPServer) printCoverage() return nil } -// BuildAsk builds the Taskwarrior proxy wrapper. -func BuildAsk() error { +// BuildDo builds the Taskwarrior proxy wrapper. +func BuildDo() error { printCoverage() - return sh.RunV("go", "build", "-o", "ask", "./cmd/ask") + return sh.RunV("go", "build", "-o", "do", "./cmd/do") } // BuildHexaiLSP builds the LSP server binary. @@ -70,7 +70,7 @@ func BuildHexaiMCPServer() error { func Dev() error { printCoverage() mg.Deps(Test, Vet, Lint) - if err := sh.RunV("go", "build", "-race", "-o", "ask", "./cmd/ask"); err != nil { + if err := sh.RunV("go", "build", "-race", "-o", "do", "./cmd/do"); err != nil { return err } if err := sh.RunV("go", "build", "-race", "-o", "hexai-lsp-server", "./cmd/hexai-lsp-server"); err != nil { @@ -120,7 +120,7 @@ func Install() error { return err } for _, name := range []string{ - "ask", + "do", "hexai-lsp-server", "hexai", "hexai-tmux-action", diff --git a/README.md b/README.md index 510705f..9e46ed5 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,12 @@ It has got improved capabilities for Go code understanding (for example, create * Stand-alone command line tool for LLM interaction - Includes `--tps-simulation` to preview how fast a model would feel by streaming placeholder text or piped stdin at a chosen token-per-second rate * Task management CLI for agent-managed project work - - Entrypoint: `ask` + - Entrypoint: `do` - Auto-scopes to `project: +agent` (derived from git repo root) - Never exposes numeric task IDs — uses UUIDs only - Machine-friendly output: UUID-only tables, suppressed decorative text - - Subcommands: `ask add`, `ask list`, `ask info`, `ask annotate`, `ask start`, `ask stop`, `ask done`, `ask priority`, `ask tag`, `ask dep`, `ask urgency`, `ask modify`, `ask denotate`, `ask delete`, `ask fish`, `ask help` - - Fish completion generator: `ask fish` + - Subcommands: `do add`, `do list`, `do info`, `do annotate`, `do start`, `do stop`, `do done`, `do priority`, `do tag`, `do dep`, `do urgency`, `do modify`, `do denotate`, `do delete`, `do fish`, `do help` + - Fish completion generator: `do fish` * Parallel completions and CLI responses from multiple providers/models for side-by-side comparison * **MCP server for prompt/runbook management** (`hexai-mcp-server`) - **⚠️ DEPRECATED/EXPERIMENTAL** - Create, update, delete, and retrieve prompts via MCP protocol diff --git a/cmd/ask/ask b/cmd/ask/ask deleted file mode 100755 index cffc885..0000000 Binary files a/cmd/ask/ask and /dev/null differ diff --git a/cmd/ask/main.go b/cmd/ask/main.go deleted file mode 100644 index afab992..0000000 --- a/cmd/ask/main.go +++ /dev/null @@ -1,22 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os" - - "codeberg.org/snonux/hexai/internal/askcli" -) - -func main() { - d := askcli.NewDispatcher(nil) - code, err := d.Dispatch(context.Background(), os.Args[1:], os.Stdin, os.Stdout, os.Stderr) - if err != nil { - // Print the internal error so callers get a useful diagnostic message. - fmt.Fprintln(os.Stderr, err) - os.Exit(code) - } - if code != 0 { - os.Exit(code) - } -} diff --git a/cmd/ask/main_test.go b/cmd/ask/main_test.go deleted file mode 100644 index db6b436..0000000 --- a/cmd/ask/main_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package main - -import ( - "bytes" - "context" - "io" - "testing" - - "codeberg.org/snonux/hexai/internal/askcli" -) - -func TestMain_WiresDispatcher(t *testing.T) { - var gotArgs []string - d := askcli.NewDispatcher(&spyRunner{ - runFn: func(_ context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { - gotArgs = append([]string(nil), args...) - io.WriteString(stdout, `[{"uuid":"test-uuid","description":"Test","status":"pending","priority":"H","tags":["cli"],"urgency":15.0,"depends":[]}]`) - return 0, nil - }, - }) - code, err := d.Dispatch(context.Background(), []string{"list", "limit:1"}, nil, &bytes.Buffer{}, &bytes.Buffer{}) - if err != nil { - t.Fatalf("dispatch returned error: %v", err) - } - if code != 0 { - t.Fatalf("exitCode = %d, want 0", code) - } - if len(gotArgs) < 1 || gotArgs[len(gotArgs)-1] != "export" { - t.Fatalf("args = %v, want [..., export]", gotArgs) - } -} - -func TestMain_ExitsNonZero(t *testing.T) { - d := askcli.NewDispatcher(&spyRunner{ - runFn: func(context.Context, []string, io.Reader, io.Writer, io.Writer) (int, error) { - return 1, nil - }, - }) - code, _ := d.Dispatch(context.Background(), []string{"list"}, nil, &bytes.Buffer{}, &bytes.Buffer{}) - if code == 0 { - t.Fatalf("exitCode = 0, want non-zero") - } -} - -type spyRunner struct { - runFn func(context.Context, []string, io.Reader, io.Writer, io.Writer) (int, error) -} - -func (s *spyRunner) Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { - return s.runFn(ctx, args, stdin, stdout, stderr) -} diff --git a/cmd/do/main.go b/cmd/do/main.go new file mode 100644 index 0000000..afab992 --- /dev/null +++ b/cmd/do/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "context" + "fmt" + "os" + + "codeberg.org/snonux/hexai/internal/askcli" +) + +func main() { + d := askcli.NewDispatcher(nil) + code, err := d.Dispatch(context.Background(), os.Args[1:], os.Stdin, os.Stdout, os.Stderr) + if err != nil { + // Print the internal error so callers get a useful diagnostic message. + fmt.Fprintln(os.Stderr, err) + os.Exit(code) + } + if code != 0 { + os.Exit(code) + } +} diff --git a/cmd/do/main_test.go b/cmd/do/main_test.go new file mode 100644 index 0000000..db6b436 --- /dev/null +++ b/cmd/do/main_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "bytes" + "context" + "io" + "testing" + + "codeberg.org/snonux/hexai/internal/askcli" +) + +func TestMain_WiresDispatcher(t *testing.T) { + var gotArgs []string + d := askcli.NewDispatcher(&spyRunner{ + runFn: func(_ context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { + gotArgs = append([]string(nil), args...) + io.WriteString(stdout, `[{"uuid":"test-uuid","description":"Test","status":"pending","priority":"H","tags":["cli"],"urgency":15.0,"depends":[]}]`) + return 0, nil + }, + }) + code, err := d.Dispatch(context.Background(), []string{"list", "limit:1"}, nil, &bytes.Buffer{}, &bytes.Buffer{}) + if err != nil { + t.Fatalf("dispatch returned error: %v", err) + } + if code != 0 { + t.Fatalf("exitCode = %d, want 0", code) + } + if len(gotArgs) < 1 || gotArgs[len(gotArgs)-1] != "export" { + t.Fatalf("args = %v, want [..., export]", gotArgs) + } +} + +func TestMain_ExitsNonZero(t *testing.T) { + d := askcli.NewDispatcher(&spyRunner{ + runFn: func(context.Context, []string, io.Reader, io.Writer, io.Writer) (int, error) { + return 1, nil + }, + }) + code, _ := d.Dispatch(context.Background(), []string{"list"}, nil, &bytes.Buffer{}, &bytes.Buffer{}) + if code == 0 { + t.Fatalf("exitCode = 0, want non-zero") + } +} + +type spyRunner struct { + runFn func(context.Context, []string, io.Reader, io.Writer, io.Writer) (int, error) +} + +func (s *spyRunner) Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { + return s.runFn(ctx, args, stdin, stdout, stderr) +} diff --git a/docs/buildandinstall.md b/docs/buildandinstall.md index a707faa..abca741 100644 --- a/docs/buildandinstall.md +++ b/docs/buildandinstall.md @@ -3,7 +3,7 @@ Hexai uses Mage for developer tasks. Install Mage, then run targets like build, dev, test, and install. - Install Mage: `go install github.com/magefile/mage@latest` -- Build binaries: `mage build` (produces `ask`, `hexai`, `hexai-lsp-server`, `hexai-tmux-action`, and `hexai-tmux-edit`) +- Build binaries: `mage build` (produces `do`, `hexai`, `hexai-lsp-server`, `hexai-tmux-action`, and `hexai-tmux-edit`) - Dev build (+ tests, vet, lint): `mage dev` - Run tests: `mage test` - Run tests with coverage: `go test ./... -cover` @@ -11,7 +11,7 @@ Hexai uses Mage for developer tasks. Install Mage, then run targets like build, - In restricted sandboxes/CI (no sockets), skip network-based tests: - `HEXAI_TEST_SKIP_NET=1 go test ./... -cover` - Install binaries to `GOPATH/bin`: `mage install` -- Load Fish completions in the current shell: `~/go/bin/ask fish | source` +- Load Fish completions in the current shell: `~/go/bin/do fish | source` Note: `mage lint` uses `golangci-lint`. Install via `mage devinstall` if needed. @@ -19,7 +19,7 @@ Note: `mage lint` uses `golangci-lint`. Install via `mage devinstall` if needed. Either use the Mage method as mentioned above, or install directly with: -- Taskwarrior proxy: `go install codeberg.org/snonux/hexai/cmd/ask@latest` +- Taskwarrior proxy: `go install codeberg.org/snonux/hexai/cmd/do@latest` - CLI: `go install codeberg.org/snonux/hexai/cmd/hexai@latest` - LSP: `go install codeberg.org/snonux/hexai/cmd/hexai-lsp-server@latest` - Action runner: `go install codeberg.org/snonux/hexai/cmd/hexai-tmux-action@latest` diff --git a/docs/fish-completion.md b/docs/fish-completion.md index d707b77..f55e4cf 100644 --- a/docs/fish-completion.md +++ b/docs/fish-completion.md @@ -1,34 +1,34 @@ # Fish Completion -The `ask` task-management CLI embeds its Fish completion script in the binary and prints it with `ask fish`. +The `do` task-management CLI embeds its Fish completion script in the binary and prints it with `do fish`. -It completes the top-level `ask` subcommands and the nested `ask dep` operations. -It also completes task selectors for UUID-taking commands by reading pending tasks through `ask complete-uuids`, which uses the local alias cache for stable short IDs. +It completes the top-level `do` subcommands and the nested `do dep` operations. +It also completes task selectors for UUID-taking commands by reading pending tasks through `do complete-uuids`, which uses the local alias cache for stable short IDs. Fish suggests each task's alias ID first and also keeps the raw UUID available as a fallback selector. -Selector suggestions stop once a command has consumed its selector argument, and `ask dep add` / `ask dep rm` suggest selectors for both task positions. -When typing `ask add depends:...`, Fish also completes the comma-separated dependency selector list inside the `depends:` modifier. +Selector suggestions stop once a command has consumed its selector argument, and `do dep add` / `do dep rm` suggest selectors for both task positions. +When typing `do add depends:...`, Fish also completes the comma-separated dependency selector list inside the `depends:` modifier. The script preserves the global `--json` flag. Load it into the current Fish session: ```sh -ask fish | source +do fish | source ``` If you installed with `mage install` and `~/go/bin` is not on your `PATH` yet, use: ```sh -~/go/bin/ask fish | source +~/go/bin/do fish | source ``` To enable it automatically for new Fish sessions, add this to your Fish config or a file in `~/.config/fish/conf.d/`: ```fish -set -l ask_bin ~/go/bin/ask +set -l do_bin ~/go/bin/do -if test -x $ask_bin - $ask_bin fish | source +if test -x $do_bin + $do_bin fish | source end ``` -No external `ask.fish` file is required. +No external `do.fish` file is required. diff --git a/docs/plan-ask-uuid-wrapper.md b/docs/plan-ask-uuid-wrapper.md index dcaf44c..31d719b 100644 --- a/docs/plan-ask-uuid-wrapper.md +++ b/docs/plan-ask-uuid-wrapper.md @@ -1,8 +1,8 @@ -# Plan: `ask` as UUID-only Taskwarrior Wrapper +# Plan: `do` as UUID-only Taskwarrior Wrapper ## Goal -Rewrite the `ask` command from a thin pass-through proxy into a **subcommand-based CLI** that wraps Taskwarrior. The wrapper never exposes numeric task IDs to the caller — only UUIDs. Output is minimal and machine-friendly for coding agents. +Rewrite the `do` command from a thin pass-through proxy into a **subcommand-based CLI** that wraps Taskwarrior. The wrapper never exposes numeric task IDs to the caller — only UUIDs. Output is minimal and machine-friendly for coding agents. The existing `project: +agent` auto-injection is preserved. @@ -10,39 +10,39 @@ The existing `project: +agent` auto-injection is preserved. | Subcommand | Example | Taskwarrior equivalent | |---|---|---| -| `ask add "Implement X"` | create task | `task project:P +agent add "Implement X"` | -| `ask add priority:H +cli "Fix bug"` | create with priority & tag | same + `priority:H +cli` | -| `ask list` | list pending tasks | `task project:P +agent status:pending export` → reformat | -| `ask info ` | show one task | `task uuid: export` → filtered fields | -| `ask annotate "note"` | add annotation | `task uuid: annotate "note"` | -| `ask start ` | start work | `task uuid: start` | -| `ask stop ` | stop work | `task uuid: stop` | -| `ask done ` | mark complete | `task uuid: done` | -| `ask priority H` | set priority | `task uuid: modify priority:H` | -| `ask tag +foo` | add tag | `task uuid: modify +foo` | -| `ask tag -foo` | remove tag | `task uuid: modify -foo` | -| `ask dep add ` | add dependency | `task uuid: modify depends:` | -| `ask dep rm ` | remove dependency | `task uuid: modify depends:-` | -| `ask dep list ` | show dependencies | `task uuid: export` → `depends` field | -| `ask urgency` | list by urgency | `task project:P +agent export` → sort by urgency | -| `ask modify ` | general modify | `task uuid: modify ` (priority, tags, depends, /old/new/) | -| `ask denotate "text"` | remove annotation | `task uuid: denotate "text"` | -| `ask delete ` | delete task | `task uuid: delete` | -| `ask export` | raw JSON dump | `task project:P +agent export` → pass through | +| `do add "Implement X"` | create task | `task project:P +agent add "Implement X"` | +| `do add priority:H +cli "Fix bug"` | create with priority & tag | same + `priority:H +cli` | +| `do list` | list pending tasks | `task project:P +agent status:pending export` → reformat | +| `do info ` | show one task | `task uuid: export` → filtered fields | +| `do annotate "note"` | add annotation | `task uuid: annotate "note"` | +| `do start ` | start work | `task uuid: start` | +| `do stop ` | stop work | `task uuid: stop` | +| `do done ` | mark complete | `task uuid: done` | +| `do priority H` | set priority | `task uuid: modify priority:H` | +| `do tag +foo` | add tag | `task uuid: modify +foo` | +| `do tag -foo` | remove tag | `task uuid: modify -foo` | +| `do dep add ` | add dependency | `task uuid: modify depends:` | +| `do dep rm ` | remove dependency | `task uuid: modify depends:-` | +| `do dep list ` | show dependencies | `task uuid: export` → `depends` field | +| `do urgency` | list by urgency | `task project:P +agent export` → sort by urgency | +| `do modify ` | general modify | `task uuid: modify ` (priority, tags, depends, /old/new/) | +| `do denotate "text"` | remove annotation | `task uuid: denotate "text"` | +| `do delete ` | delete task | `task uuid: delete` | +| `do export` | raw JSON dump | `task project:P +agent export` → pass through | ### List filters, sort, and limit -`ask list` accepts optional filters, sort, and limit arguments: +`do list` accepts optional filters, sort, and limit arguments: | Example | Taskwarrior equivalent | |---|---| -| `ask list` | `task project:P +agent status:pending export` (default sort: priority-, urgency-) | -| `ask list +READY` | `task project:P +agent +READY export` | -| `ask list +BLOCKED` | `task project:P +agent +BLOCKED export` | -| `ask list +frontend` | `task project:P +agent +frontend export` | -| `ask list started` | `task project:P +agent start.any: export` | -| `ask list limit:3` | show only first 3 results | -| `ask list +READY limit:1` | next ready task | +| `do list` | `task project:P +agent status:pending export` (default sort: priority-, urgency-) | +| `do list +READY` | `task project:P +agent +READY export` | +| `do list +BLOCKED` | `task project:P +agent +BLOCKED export` | +| `do list +frontend` | `task project:P +agent +frontend export` | +| `do list started` | `task project:P +agent start.any: export` | +| `do list limit:3` | show only first 3 results | +| `do list +READY limit:1` | next ready task | ## Data Retrieval: `task export` @@ -81,7 +81,7 @@ If an argument looks like a bare numeric ID where a UUID is expected, reject wit ## Package Layout ``` -cmd/ask/main.go — parse subcommand, dispatch to askcli +cmd/do/main.go — parse subcommand, dispatch to askcli internal/askcli/ — NEW package ├── dispatch.go — subcommand router (switch args[0]) ├── taskexec.go — wraps Taskwarrior execution (binary lookup, repo detection, run) @@ -108,45 +108,45 @@ Each `command_*.go` file gets a corresponding `command_*_test.go`. ## Changes to Existing Code -- **`cmd/ask/main.go`** — stops calling `taskproxy.Runner.Run` directly; delegates to `askcli.Dispatch()`. +- **`cmd/do/main.go`** — stops calling `taskproxy.Runner.Run` directly; delegates to `askcli.Dispatch()`. - **`internal/taskproxy/`** — reused by `askcli/taskexec.go` for binary lookup (`findTaskBinary`) and repo root detection (`detectRepoRoot`). The `Runner.Run` pass-through method becomes unused and can be removed. ## Task Breakdown 1. Scaffold `internal/askcli/` — dispatch, taskexec, taskexport, formatter -2. Implement `ask add` (UUID extraction from Taskwarrior stdout) -3. Implement `ask list` (export → UUID-only table) -4. Implement `ask info ` (export → filtered fields) -5. Implement `ask annotate "note"` -6. Implement `ask start ` / `ask stop ` -7. Implement `ask done ` -8. Implement `ask priority

` -9. Implement `ask tag +/-tag` -10. Implement `ask dep add/rm/list` -11. Implement `ask urgency` -12. Implement `ask modify ` (general-purpose modify) -13. Implement `ask denotate "text"` (remove annotation) -14. Implement `ask delete ` -15. Implement `ask export` (raw JSON) -16. Add filter/sort/limit support to `ask list` (+READY, +BLOCKED, +tag, started, limit:N) -17. Wire `cmd/ask/main.go` to `askcli.Dispatch`, remove old pass-through +2. Implement `do add` (UUID extraction from Taskwarrior stdout) +3. Implement `do list` (export → UUID-only table) +4. Implement `do info ` (export → filtered fields) +5. Implement `do annotate "note"` +6. Implement `do start ` / `do stop ` +7. Implement `do done ` +8. Implement `do priority

` +9. Implement `do tag +/-tag` +10. Implement `do dep add/rm/list` +11. Implement `do urgency` +12. Implement `do modify ` (general-purpose modify) +13. Implement `do denotate "text"` (remove annotation) +14. Implement `do delete ` +15. Implement `do export` (raw JSON) +16. Add filter/sort/limit support to `do list` (+READY, +BLOCKED, +tag, started, limit:N) +17. Wire `cmd/do/main.go` to `askcli.Dispatch`, remove old pass-through 18. Update docs and README -19. Create `agent-task-management` skill (replacement for `taskwarrior-task-management`) — uses only `ask` subcommands, no Taskwarrior references -20. Update Pi coding agent: rename `taskwarrior-plan-mode` extension → `agent-plan-mode`, rewrite to use `ask` subcommands only -21. Audit `agent-task-management` skill and `agent-plan-mode` extension: ensure zero Taskwarrior leakage — agents must see `ask` as the native task system, not a wrapper +19. Create `agent-task-management` skill (replacement for `taskwarrior-task-management`) — uses only `do` subcommands, no Taskwarrior references +20. Update Pi coding agent: rename `taskwarrior-plan-mode` extension → `agent-plan-mode`, rewrite to use `do` subcommands only +21. Audit `agent-task-management` skill and `agent-plan-mode` extension: ensure zero Taskwarrior leakage — agents must see `do` as the native task system, not a wrapper ## Skill & Extension Migration -After the `ask` CLI is complete and documented, three follow-up tasks abstract away the Taskwarrior implementation detail: +After the `do` CLI is complete and documented, three follow-up tasks abstract away the Taskwarrior implementation detail: ### 19. `agent-task-management` skill -Create a new skill at `~/.agents/skills/agent-task-management/` by copying the structure from `taskwarrior-task-management` (SKILL.md + references/00-context.md through 5-review-overview-tasks.md). Rewrite all content to use `ask` subcommands (`ask add`, `ask list`, `ask info`, `ask start`, `ask stop`, `ask done`, `ask annotate`, `ask denotate`, `ask modify`, `ask priority`, `ask tag`, `ask dep`, `ask urgency`, `ask delete`, `ask export`). Remove all mentions of Taskwarrior, raw `task` command, numeric IDs, and `_uuid` lookups. +Create a new skill at `~/.agents/skills/agent-task-management/` by copying the structure from `taskwarrior-task-management` (SKILL.md + references/00-context.md through 5-review-overview-tasks.md). Rewrite all content to use `do` subcommands (`do add`, `do list`, `do info`, `do start`, `do stop`, `do done`, `do annotate`, `do denotate`, `do modify`, `do priority`, `do tag`, `do dep`, `do urgency`, `do delete`, `do export`). Remove all mentions of Taskwarrior, raw `task` command, numeric IDs, and `_uuid` lookups. ### 20. `agent-plan-mode` Pi extension -Rename `~/git/hyperstack/pi/agent/extensions/taskwarrior-plan-mode/` → `agent-plan-mode/`. Update `index.ts`, `utils.ts`, `README.md` to use new `ask` subcommands instead of raw pass-through calls. Rename `TaskwarriorTask` type, update state keys, command names, descriptions, and UI messages. +Rename `~/git/hyperstack/pi/agent/extensions/taskwarrior-plan-mode/` → `agent-plan-mode/`. Update `index.ts`, `utils.ts`, `README.md` to use new `do` subcommands instead of raw pass-through calls. Rename `TaskwarriorTask` type, update state keys, command names, descriptions, and UI messages. ### 21. Taskwarrior leakage audit -Grep all files in the new skill and extension for `taskwarrior` (any casing), `task warrior`, raw `task ` command patterns, numeric task ID usage, `_uuid` lookups, and any hint that `ask` wraps another tool. **Agents must believe `ask` IS the task management system — not a proxy.** This prevents agents from attempting raw Taskwarrior commands or mentioning Taskwarrior in annotations/descriptions. +Grep all files in the new skill and extension for `taskwarrior` (any casing), `task warrior`, raw `task ` command patterns, numeric task ID usage, `_uuid` lookups, and any hint that `do` wraps another tool. **Agents must believe `do` IS the task management system — not a proxy.** This prevents agents from attempting raw Taskwarrior commands or mentioning Taskwarrior in annotations/descriptions. diff --git a/docs/usage.md b/docs/usage.md index 0786a00..cc78300 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -125,77 +125,77 @@ cat SOMEFILE.txt | hexai --tps-simulation 20 ## Task management -`ask` is a task management CLI for the current git project. By default it auto-scopes to `project: +agent` so operations are confined to agent-managed project tasks. +`do` is a task management CLI for the current git project. By default it auto-scopes to `project: +agent` so operations are confined to agent-managed project tasks. -Use `ask na ` or `ask no-agent ` to run the same subcommands against project tasks without the `+agent` tag. Those prefixes keep the project scope but replace the default tag filter with `-agent`. +Use `do na ` or `do no-agent ` to run the same subcommands against project tasks without the `+agent` tag. Those prefixes keep the project scope but replace the default tag filter with `-agent`. -`ask` never exposes Taskwarrior numeric task IDs. Human-facing output uses stable local alias IDs where practical, while `ask info` shows both the alias ID and the UUID. Commands that accept a task selector support either the alias ID or the UUID. +`do` never exposes Taskwarrior numeric task IDs. Human-facing output uses stable local alias IDs where practical, while `do info` shows both the alias ID and the UUID. Commands that accept a task selector support either the alias ID or the UUID. -`ask` must be run inside a git repository so the project name can be derived from the repo root. +`do` must be run inside a git repository so the project name can be derived from the repo root. ### Subcommands | Subcommand | Description | |---|---| -| `ask add "description"` | Create a new task and print `created task ` | -| `ask add depends:, "description"` | Create task with inline dependencies | -| `ask add priority:H "description"` | Create task with priority | -| `ask add +tag "description"` | Create task with tag | -| `ask na add "description"` | Create a project task without the `+agent` tag | -| `ask list` | List pending tasks only (alias-ID table) | -| `ask na list` | List pending project tasks without the `+agent` tag | -| `ask all` | List all tasks including completed/deleted | -| `ask list +READY` | List only ready tasks | -| `ask list +BLOCKED` | List blocked tasks | -| `ask list +tag` | Filter by tag | -| `ask list started` | List started tasks | -| `ask list limit:N` | Limit results | -| `ask list sort:priority-,urgency-` | Sort by priority then urgency | -| `ask info [id\|uuid]` | Show task details, or the current started task if no selector is provided | -| `ask annotate "note"` | Add annotation | -| `ask start ` | Start working on a task | -| `ask stop ` | Stop work on a task | -| `ask done ` | Mark task complete | -| `ask priority H\|M\|L` | Set priority | -| `ask tag +tag` | Add tag | -| `ask tag -tag` | Remove tag | -| `ask dep add ` | Add dependency | -| `ask dep rm ` | Remove dependency | -| `ask dep list ` | List dependencies | -| `ask urgency` | List tasks by urgency | -| `ask modify ` | General-purpose modify | -| `ask denotate "text"` | Remove annotation | -| `ask delete ` | Delete a task | +| `do add "description"` | Create a new task and print `created task ` | +| `do add depends:, "description"` | Create task with inline dependencies | +| `do add priority:H "description"` | Create task with priority | +| `do add +tag "description"` | Create task with tag | +| `do na add "description"` | Create a project task without the `+agent` tag | +| `do list` | List pending tasks only (alias-ID table) | +| `do na list` | List pending project tasks without the `+agent` tag | +| `do all` | List all tasks including completed/deleted | +| `do list +READY` | List only ready tasks | +| `do list +BLOCKED` | List blocked tasks | +| `do list +tag` | Filter by tag | +| `do list started` | List started tasks | +| `do list limit:N` | Limit results | +| `do list sort:priority-,urgency-` | Sort by priority then urgency | +| `do info [id\|uuid]` | Show task details, or the current started task if no selector is provided | +| `do annotate "note"` | Add annotation | +| `do start ` | Start working on a task | +| `do stop ` | Stop work on a task | +| `do done ` | Mark task complete | +| `do priority H\|M\|L` | Set priority | +| `do tag +tag` | Add tag | +| `do tag -tag` | Remove tag | +| `do dep add ` | Add dependency | +| `do dep rm ` | Remove dependency | +| `do dep list ` | List dependencies | +| `do urgency` | List tasks by urgency | +| `do modify ` | General-purpose modify | +| `do denotate "text"` | Remove annotation | +| `do delete ` | Delete a task | ### Examples ```sh # Create a task -ask add priority:H "Implement new feature" +do add priority:H "Implement new feature" # Create a non-agent task -ask na add "Follow up manually" +do na add "Follow up manually" # Create a task with dependencies -ask add +cli depends:0,1 "Implement dependent feature" +do add +cli depends:0,1 "Implement dependent feature" # List tasks -ask list +READY limit:5 +do list +READY limit:5 # List non-agent tasks -ask no-agent list +do no-agent list # Show alias and UUID for a task -ask info 0 +do info 0 # Show a non-agent task -ask na info 0 +do na info 0 # Start working -ask start 0 +do start 0 # Done -ask done 0 +do done 0 ``` ## Hexai Action (TUI) diff --git a/integrationtests/ask_scope_test.go b/integrationtests/ask_scope_test.go deleted file mode 100644 index a328881..0000000 --- a/integrationtests/ask_scope_test.go +++ /dev/null @@ -1,262 +0,0 @@ -//go:build integration - -package integrationtests - -import ( - "context" - "encoding/json" - "fmt" - "strings" - "testing" - "time" - - "codeberg.org/snonux/hexai/internal/askcli" -) - -func scopedAskArgs(scopePrefix string, args ...string) []string { - if strings.TrimSpace(scopePrefix) == "" { - return append([]string(nil), args...) - } - scoped := []string{scopePrefix} - return append(scoped, args...) -} - -func createTaskInScope(ctx context.Context, scopePrefix, desc string) (taskInfo, error) { - stdout, stderr, code := runAsk(ctx, scopedAskArgs(scopePrefix, "add", "+integrationtest", desc)) - if code != 0 { - return taskInfo{}, fmt.Errorf("create task failed (code %d): stdout=%s stderr=%s", code, stdout.String(), stderr.String()) - } - - id := extractTaskIDFromAddOutput(stdout.String()) - if id == "" { - return taskInfo{}, fmt.Errorf("could not extract task ID from ask add output: %s", stdout.String()) - } - - info, ok := getTaskInfoInScope(ctx, scopePrefix, id) - if !ok { - return taskInfo{}, fmt.Errorf("could not resolve task ID %q after ask %s add", id, scopePrefix) - } - if info.UUID == "" { - return taskInfo{}, fmt.Errorf("ask %s info %q did not return a UUID", scopePrefix, id) - } - return info, nil -} - -func getTaskInfoInScope(ctx context.Context, scopePrefix, selector string) (taskInfo, bool) { - stdout, _, code := runAsk(ctx, scopedAskArgs(scopePrefix, "info", selector)) - if code != 0 { - return taskInfo{}, false - } - return parseTaskInfoText(stdout.String(), selector), true -} - -func exportTaskByUUID(ctx context.Context, uuid string) (askcli.TaskExport, error) { - stdout, stderr, code := runTask(ctx, []string{"uuid:" + uuid, "export"}) - if code != 0 { - return askcli.TaskExport{}, fmt.Errorf("task export failed (code %d): stdout=%s stderr=%s", code, stdout.String(), stderr.String()) - } - - var tasks []askcli.TaskExport - if err := json.Unmarshal(stdout.Bytes(), &tasks); err != nil { - return askcli.TaskExport{}, fmt.Errorf("parse task export: %w", err) - } - if len(tasks) != 1 { - return askcli.TaskExport{}, fmt.Errorf("expected 1 task, got %d", len(tasks)) - } - return tasks[0], nil -} - -func hasTag(tags []string, want string) bool { - for _, tag := range tags { - if tag == want { - return true - } - } - return false -} - -// hasSelectorLine reports whether any line in output starts with want followed -// by a tab or end-of-line. This handles the "selector\tdescription" format -// emitted by complete-uuids for fish shell autocompletion. -func hasSelectorLine(output, want string) bool { - for _, line := range strings.Split(strings.TrimSpace(output), "\n") { - line = strings.TrimSpace(line) - // Accept exact match (no description) or tab-prefixed description. - if line == want || strings.HasPrefix(line, want+"\t") { - return true - } - } - return false -} - -func TestNoAgentAddOmitsAgentTag(t *testing.T) { - for _, prefix := range []string{"na", "no-agent"} { - t.Run(prefix, func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) - defer cancel() - t.Setenv("XDG_CACHE_HOME", t.TempDir()) - - desc := fmt.Sprintf("integration test non-agent add %s %d", prefix, time.Now().UnixNano()) - info, err := createTaskInScope(ctx, prefix, desc) - if err != nil { - t.Fatalf("failed to create no-agent task: %v", err) - } - defer deleteTask(ctx, info.UUID) - - task, err := exportTaskByUUID(ctx, info.UUID) - if err != nil { - t.Fatalf("failed to export task: %v", err) - } - if task.Description != desc { - t.Fatalf("description = %q, want %q", task.Description, desc) - } - if hasTag(task.Tags, "agent") { - t.Fatalf("tags = %v, task should not have agent tag", task.Tags) - } - if !hasTag(task.Tags, "integrationtest") { - t.Fatalf("tags = %v, task should keep explicit integrationtest tag", task.Tags) - } - if info.ID == "" { - t.Fatal("expected alias ID for no-agent task") - } - }) - } -} - -func TestNoAgentListSeparatesScopedTasks(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) - defer cancel() - t.Setenv("XDG_CACHE_HOME", t.TempDir()) - - agentDesc := fmt.Sprintf("integration test scoped agent list %d", time.Now().UnixNano()) - noAgentDesc := fmt.Sprintf("integration test scoped no-agent list %d", time.Now().UnixNano()) - - agentUUID, err := createTask(ctx, agentDesc) - if err != nil { - t.Fatalf("failed to create agent task: %v", err) - } - defer deleteTask(ctx, agentUUID) - - noAgentInfo, err := createTaskInScope(ctx, "na", noAgentDesc) - if err != nil { - t.Fatalf("failed to create no-agent task: %v", err) - } - defer deleteTask(ctx, noAgentInfo.UUID) - - stdout, stderr, code := runAsk(ctx, []string{"list"}) - if code != 0 { - t.Fatalf("ask list failed with code %d: stdout=%s stderr=%s", code, stdout.String(), stderr.String()) - } - if !strings.Contains(stdout.String(), agentDesc) { - t.Fatalf("ask list should contain agent task %q: %s", agentDesc, stdout.String()) - } - if strings.Contains(stdout.String(), noAgentDesc) { - t.Fatalf("ask list should not contain no-agent task %q: %s", noAgentDesc, stdout.String()) - } - - for _, prefix := range []string{"na", "no-agent"} { - t.Run(prefix, func(t *testing.T) { - scopedStdout, scopedStderr, scopedCode := runAsk(ctx, []string{prefix, "list"}) - if scopedCode != 0 { - t.Fatalf("ask %s list failed with code %d: stdout=%s stderr=%s", prefix, scopedCode, scopedStdout.String(), scopedStderr.String()) - } - if !strings.Contains(scopedStdout.String(), noAgentDesc) { - t.Fatalf("ask %s list should contain no-agent task %q: %s", prefix, noAgentDesc, scopedStdout.String()) - } - if strings.Contains(scopedStdout.String(), agentDesc) { - t.Fatalf("ask %s list should not contain agent task %q: %s", prefix, agentDesc, scopedStdout.String()) - } - }) - } -} - -func TestNoAgentSelectorCommandsUseScopedTasks(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) - defer cancel() - t.Setenv("XDG_CACHE_HOME", t.TempDir()) - - desc := fmt.Sprintf("integration test scoped info %d", time.Now().UnixNano()) - info, err := createTaskInScope(ctx, "na", desc) - if err != nil { - t.Fatalf("failed to create no-agent task: %v", err) - } - defer deleteTask(ctx, info.UUID) - - _, stderr, code := runAsk(ctx, []string{"info", info.ID}) - if code == 0 { - t.Fatalf("ask info %s unexpectedly succeeded outside no-agent scope", info.ID) - } - if !strings.Contains(stderr.String(), "current scope") { - t.Fatalf("stderr = %q, want current-scope guidance", stderr.String()) - } - - for _, prefix := range []string{"na", "no-agent"} { - t.Run(prefix, func(t *testing.T) { - stdout, scopedStderr, scopedCode := runAsk(ctx, []string{prefix, "info", info.ID}) - if scopedCode != 0 { - t.Fatalf("ask %s info failed with code %d: stdout=%s stderr=%s", prefix, scopedCode, stdout.String(), scopedStderr.String()) - } - if !strings.Contains(stdout.String(), "UUID: "+info.UUID) { - t.Fatalf("ask %s info output missing UUID %q: %s", prefix, info.UUID, stdout.String()) - } - }) - } - - stdout, stderr, code := runAsk(ctx, []string{"na", "done", info.ID}) - if code != 0 { - t.Fatalf("ask na done failed with code %d: stdout=%s stderr=%s", code, stdout.String(), stderr.String()) - } - - task, err := exportTaskByUUID(ctx, info.UUID) - if err != nil { - t.Fatalf("failed to export completed no-agent task: %v", err) - } - if task.Status != "completed" { - t.Fatalf("status = %q, want completed", task.Status) - } -} - -func TestNoAgentCompleteUUIDsUsesScopedTasks(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) - defer cancel() - t.Setenv("XDG_CACHE_HOME", t.TempDir()) - - agentUUID, err := createTask(ctx, fmt.Sprintf("integration test complete uuids agent %d", time.Now().UnixNano())) - if err != nil { - t.Fatalf("failed to create agent task: %v", err) - } - defer deleteTask(ctx, agentUUID) - agentAlias := mustTaskAlias(t, ctx, agentUUID) - - noAgentInfo, err := createTaskInScope(ctx, "na", fmt.Sprintf("integration test complete uuids no-agent %d", time.Now().UnixNano())) - if err != nil { - t.Fatalf("failed to create no-agent task: %v", err) - } - defer deleteTask(ctx, noAgentInfo.UUID) - - defaultStdout, defaultStderr, defaultCode := runAsk(ctx, []string{"complete-uuids"}) - if defaultCode != 0 { - t.Fatalf("ask complete-uuids failed with code %d: stdout=%s stderr=%s", defaultCode, defaultStdout.String(), defaultStderr.String()) - } - if !hasSelectorLine(defaultStdout.String(), agentAlias) || !hasSelectorLine(defaultStdout.String(), agentUUID) { - t.Fatalf("default complete-uuids should contain agent selectors: %s", defaultStdout.String()) - } - if hasSelectorLine(defaultStdout.String(), noAgentInfo.ID) || hasSelectorLine(defaultStdout.String(), noAgentInfo.UUID) { - t.Fatalf("default complete-uuids should not contain no-agent selectors: %s", defaultStdout.String()) - } - - for _, prefix := range []string{"na", "no-agent"} { - t.Run(prefix, func(t *testing.T) { - stdout, stderr, code := runAsk(ctx, []string{prefix, "complete-uuids"}) - if code != 0 { - t.Fatalf("ask %s complete-uuids failed with code %d: stdout=%s stderr=%s", prefix, code, stdout.String(), stderr.String()) - } - if !hasSelectorLine(stdout.String(), noAgentInfo.ID) || !hasSelectorLine(stdout.String(), noAgentInfo.UUID) { - t.Fatalf("ask %s complete-uuids should contain no-agent selectors: %s", prefix, stdout.String()) - } - if hasSelectorLine(stdout.String(), agentAlias) || hasSelectorLine(stdout.String(), agentUUID) { - t.Fatalf("ask %s complete-uuids should not contain agent selectors: %s", prefix, stdout.String()) - } - }) - } -} diff --git a/integrationtests/ask_test.go b/integrationtests/ask_test.go deleted file mode 100644 index 0ebdb01..0000000 --- a/integrationtests/ask_test.go +++ /dev/null @@ -1,1283 +0,0 @@ -//go:build integration - -package integrationtests - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "regexp" - "slices" - "strings" - "testing" - "time" - - "codeberg.org/snonux/hexai/internal/askcli" -) - -// repoRoot is set in TestMain before any test runs. -var repoRoot string - -func findRepoRoot() string { - dir, err := os.Getwd() - if err != nil { - return "" - } - for { - if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { - return dir - } - if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil { - return dir - } - parent := filepath.Dir(dir) - if parent == dir { - break - } - dir = parent - } - return "" -} - -func askBinaryPath() string { - return filepath.Join(repoRoot, "cmd", "ask", "ask") -} - -func runAsk(ctx context.Context, args []string) (stdout, stderr bytes.Buffer, exitCode int) { - cmd := exec.CommandContext(ctx, askBinaryPath(), args...) - cmd.Dir = repoRoot - cmd.Stdout = &stdout - cmd.Stderr = &stderr - err := cmd.Run() - if err == nil { - return - } - var ee *exec.ExitError - if !errors.As(err, &ee) { - return bytes.Buffer{}, stderr, -1 - } - return stdout, stderr, ee.ExitCode() -} - -// runAskWithStdin runs ask with the given stdin. Only use this for commands -// that actually forward stdin to taskwarrior (currently only: delete). -func runAskWithStdin(ctx context.Context, args []string, stdin string) (stdout, stderr bytes.Buffer, exitCode int) { - cmd := exec.CommandContext(ctx, askBinaryPath(), args...) - cmd.Dir = repoRoot - cmd.Stdin = strings.NewReader(stdin) - cmd.Stdout = &stdout - cmd.Stderr = &stderr - err := cmd.Run() - if err == nil { - return - } - var ee *exec.ExitError - if !errors.As(err, &ee) { - return bytes.Buffer{}, stderr, -1 - } - return stdout, stderr, ee.ExitCode() -} - -func runTask(ctx context.Context, args []string) (stdout, stderr bytes.Buffer, exitCode int) { - cmd := exec.CommandContext(ctx, "task", args...) - cmd.Dir = repoRoot - cmd.Stdout = &stdout - cmd.Stderr = &stderr - err := cmd.Run() - if err == nil { - return - } - var ee *exec.ExitError - if !errors.As(err, &ee) { - return bytes.Buffer{}, stderr, -1 - } - return stdout, stderr, ee.ExitCode() -} - -func runTaskWithStdin(ctx context.Context, args []string, stdin string) (stdout, stderr bytes.Buffer, exitCode int) { - cmd := exec.CommandContext(ctx, "task", args...) - cmd.Dir = repoRoot - cmd.Stdin = strings.NewReader(stdin) - cmd.Stdout = &stdout - cmd.Stderr = &stderr - err := cmd.Run() - if err == nil { - return - } - var ee *exec.ExitError - if !errors.As(err, &ee) { - return bytes.Buffer{}, stderr, -1 - } - return stdout, stderr, ee.ExitCode() -} - -// createTask creates a new task via ask add and returns its UUID. -// ask add prints a human-facing created-task message, so we resolve the created UUID via ask info. -func createTask(ctx context.Context, desc string) (string, error) { - stdout, stderr, code := runAsk(ctx, []string{"add", "+integrationtest", desc}) - if code != 0 { - return "", fmt.Errorf("create task failed (code %d): stdout=%s stderr=%s", code, stdout.String(), stderr.String()) - } - id := extractTaskIDFromAddOutput(stdout.String()) - if id == "" { - return "", fmt.Errorf("could not extract task ID from ask add output: %s", stdout.String()) - } - info, ok := getTaskInfoFast(ctx, id) - if !ok { - return "", fmt.Errorf("could not resolve task ID %q after ask add", id) - } - if info.UUID == "" { - return "", fmt.Errorf("ask info %q did not return a UUID", id) - } - return info.UUID, nil -} - -func extractTaskIDFromAddOutput(output string) string { - for _, line := range strings.Split(strings.TrimSpace(output), "\n") { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, "created task ") { - return strings.TrimSpace(strings.TrimPrefix(line, "created task ")) - } - } - return strings.TrimSpace(output) -} - -// deleteTask removes the task identified by uuid from Taskwarrior. It always -// uses a fresh background context with a short timeout so that deferred cleanup -// calls succeed even when the calling test's context has already been cancelled -// (e.g. after a timeout). The ctx parameter is accepted for backwards -// compatibility but intentionally ignored. -func deleteTask(_ context.Context, uuid string) { - cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - runTaskWithStdin(cleanupCtx, []string{"uuid:" + uuid, "delete"}, "yes\n") -} - -func listTasksWithTag(ctx context.Context, tag string) []askcli.TaskExport { - stdout, _, _ := runTask(ctx, []string{"export", "project:hexai", "+agent"}) - var tasks []askcli.TaskExport - if err := json.Unmarshal(stdout.Bytes(), &tasks); err != nil { - return nil - } - var filtered []askcli.TaskExport - for _, t := range tasks { - if t.Status == "deleted" || t.Status == "completed" { - continue - } - for _, t2 := range t.Tags { - if t2 == tag { - filtered = append(filtered, t) - break - } - } - } - return filtered -} - -type taskInfo struct { - ID string - UUID string - Description string - Status string - Started string - StartTime string - Priority string - Depends []string - Tags []string -} - -var ( - idFieldRx = regexp.MustCompile(`ID:\s+(.+)`) - uuidFieldRx = regexp.MustCompile(`UUID:\s+(.+)`) - descFieldRx = regexp.MustCompile(`Description:\s+(.+)`) - statusFieldRx = regexp.MustCompile(`Status:\s+(.+)`) - startedFieldRx = regexp.MustCompile(`Started:\s+(.+)`) - startTimeFieldRx = regexp.MustCompile(`Start time:\s+(.+)`) - priorityFieldRx = regexp.MustCompile(`Priority:\s+(.+)`) - dependsFieldRx = regexp.MustCompile(`Depends:\s+(.+)`) - tagsFieldRx = regexp.MustCompile(`Tags:\s+(.+)`) - uuidFormatRx = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) -) - -func parseTaskInfoText(output string, uuid string) taskInfo { - ti := taskInfo{UUID: uuid} - if m := idFieldRx.FindStringSubmatch(output); len(m) > 1 { - ti.ID = strings.TrimSpace(m[1]) - } - if m := uuidFieldRx.FindStringSubmatch(output); len(m) > 1 { - ti.UUID = strings.TrimSpace(m[1]) - } - if m := descFieldRx.FindStringSubmatch(output); len(m) > 1 { - ti.Description = strings.TrimSpace(m[1]) - } - if m := statusFieldRx.FindStringSubmatch(output); len(m) > 1 { - ti.Status = strings.TrimSpace(m[1]) - } - if m := startedFieldRx.FindStringSubmatch(output); len(m) > 1 { - ti.Started = strings.TrimSpace(m[1]) - } - if m := startTimeFieldRx.FindStringSubmatch(output); len(m) > 1 { - ti.StartTime = strings.TrimSpace(m[1]) - } - if m := priorityFieldRx.FindStringSubmatch(output); len(m) > 1 { - ti.Priority = strings.TrimSpace(m[1]) - } - if m := dependsFieldRx.FindStringSubmatch(output); len(m) > 1 { - depStr := strings.TrimSpace(m[1]) - if depStr != "" { - ti.Depends = strings.Split(depStr, ", ") - } - } - if m := tagsFieldRx.FindStringSubmatch(output); len(m) > 1 { - tagStr := strings.TrimSpace(m[1]) - ti.Tags = strings.Split(tagStr, ", ") - } - return ti -} - -func getTaskInfoFast(ctx context.Context, uuid string) (taskInfo, bool) { - stdout, _, code := runAsk(ctx, []string{"info", uuid}) - if code != 0 { - return taskInfo{}, false - } - return parseTaskInfoText(stdout.String(), uuid), true -} - -// getTaskInfoRaw returns the raw text output of ask info for a given UUID. -func getTaskInfoRaw(ctx context.Context, uuid string) (string, bool) { - stdout, _, code := runAsk(ctx, []string{"info", uuid}) - if code != 0 { - return "", false - } - return stdout.String(), true -} - -func mustTaskAlias(t *testing.T, ctx context.Context, uuid string) string { - t.Helper() - - ti, ok := getTaskInfoFast(ctx, uuid) - if !ok { - t.Fatalf("failed to get task info for %s", uuid) - } - if ti.ID == "" { - t.Fatalf("task info for %s did not include an alias ID", uuid) - } - return ti.ID -} - -func aliasCachePath(t *testing.T, cacheRoot string) string { - t.Helper() - return filepath.Join(cacheRoot, "hexai", "ask", "task-aliases-v2.json") -} - -// cleanupOrphanedIntegrationTasks deletes any tasks with the +integrationtest -// tag that were left behind by previous test runs (e.g. when a test timed out -// before its deferred deleteTask could complete, or when the process was -// killed). Running this at the start of TestMain keeps the Taskwarrior -// database clean and prevents orphaned tasks from polluting subsequent runs. -// -// A bulk deletion approach is used to handle large numbers of orphaned tasks -// efficiently: taskwarrior's "all" confirmation answer deletes all matching -// tasks in a single invocation rather than one call per task. -func cleanupOrphanedIntegrationTasks() { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // "all" as stdin answers taskwarrior's per-task confirmation prompts with - // "delete all matching tasks", so the entire set is removed in one shot. - runTaskWithStdin(ctx, []string{ - "rc.verbose=nothing", - "project:hexai", - "+integrationtest", - "status:pending", - "delete", - }, "all\n") -} - -func TestMain(m *testing.M) { - repoRoot = findRepoRoot() - if repoRoot == "" { - fmt.Fprintln(os.Stderr, "integration tests: cannot find repo root (go.mod or .git)") - os.Exit(1) - } - // Always rebuild the binary so tests reflect the current source. - askBin := askBinaryPath() - cmd := exec.Command("go", "build", "-o", askBin, "./cmd/ask/") - cmd.Dir = repoRoot - if out, err := cmd.CombinedOutput(); err != nil { - fmt.Fprintf(os.Stderr, "failed to build ask binary: %v\n%s\n", err, out) - os.Exit(1) - } - // Remove any tasks left over from previous integration test runs to avoid - // state pollution across runs. - cleanupOrphanedIntegrationTasks() - os.Exit(m.Run()) -} - -func TestAdd(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - uuid, err := createTask(ctx, "integration test task for add") - if err != nil { - t.Fatalf("failed to create task: %v", err) - } - defer deleteTask(ctx, uuid) - - tasks := listTasksWithTag(ctx, "integrationtest") - found := false - for _, task := range tasks { - if task.UUID == uuid { - found = true - break - } - } - if !found { - t.Errorf("task %s not found in export", uuid) - } -} - -// TestAddReturnsAlias verifies that ask add outputs the human-facing alias ID in its creation message. -func TestAddReturnsAlias(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - stdout, _, code := runAsk(ctx, []string{"add", "+integrationtest", "uuid format check"}) - if code != 0 { - t.Fatalf("ask add failed with code %d", code) - } - rawOutput := strings.TrimSpace(stdout.String()) - id := extractTaskIDFromAddOutput(rawOutput) - info, ok := getTaskInfoFast(ctx, id) - if !ok { - t.Fatalf("ask info %q failed after add", id) - } - defer deleteTask(ctx, info.UUID) - - if id == "" { - t.Fatal("ask add returned an empty task ID") - } - if rawOutput != "created task "+id { - t.Fatalf("ask add output = %q, want %q", rawOutput, "created task "+id) - } - if uuidFormatRx.MatchString(id) { - t.Fatalf("ask add output %q leaked a UUID, want alias ID", id) - } - if info.ID != id { - t.Fatalf("ask info ID = %q, want %q", info.ID, id) - } - if !uuidFormatRx.MatchString(info.UUID) { - t.Fatalf("ask info UUID = %q, want valid UUID", info.UUID) - } -} - -func TestAddWithDependsModifier(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) - defer cancel() - t.Setenv("XDG_CACHE_HOME", t.TempDir()) - - dep1UUID, err := createTask(ctx, "integration test add depends target one") - if err != nil { - t.Fatalf("failed to create first dependency task: %v", err) - } - defer deleteTask(ctx, dep1UUID) - - dep2UUID, err := createTask(ctx, "integration test add depends target two") - if err != nil { - t.Fatalf("failed to create second dependency task: %v", err) - } - defer deleteTask(ctx, dep2UUID) - - dep1Alias := mustTaskAlias(t, ctx, dep1UUID) - dep2Alias := mustTaskAlias(t, ctx, dep2UUID) - - stdout, stderr, code := runAsk(ctx, []string{ - "add", - "+integrationtest", - "depends:" + dep1Alias + "," + dep2Alias, - "integration", - "test", - "task", - "with", - "inline", - "depends", - }) - if code != 0 { - t.Fatalf("ask add with depends modifier failed with code %d: stdout=%s stderr=%s", code, stdout.String(), stderr.String()) - } - - id := extractTaskIDFromAddOutput(stdout.String()) - info, ok := getTaskInfoFast(ctx, id) - if !ok { - t.Fatalf("ask info %q failed after add", id) - } - defer deleteTask(ctx, info.UUID) - - raw, ok := getTaskInfoRaw(ctx, info.UUID) - if !ok { - t.Fatalf("raw info for created task %s failed", info.UUID) - } - if !strings.Contains(raw, dep1Alias+" ("+dep1UUID+")") || !strings.Contains(raw, dep2Alias+" ("+dep2UUID+")") { - t.Fatalf("created task info missing formatted dependencies: %s", raw) - } -} - -func TestList(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - cacheRoot := t.TempDir() - t.Setenv("XDG_CACHE_HOME", cacheRoot) - - uuid, err := createTask(ctx, "integration test task for list") - if err != nil { - t.Fatalf("failed to create task: %v", err) - } - defer deleteTask(ctx, uuid) - - stdout, _, code := runAsk(ctx, []string{"list"}) - if code != 0 { - t.Fatalf("list failed with code %d: %s", code, stdout.String()) - } - alias := mustTaskAlias(t, ctx, uuid) - if !strings.Contains(stdout.String(), alias) { - t.Errorf("list output does not contain expected alias %q", alias) - } - if strings.Contains(stdout.String(), uuid) { - t.Errorf("list output should not contain raw UUID %s", uuid) - } - if !strings.Contains(stdout.String(), "integration test task for list") { - t.Errorf("list output does not contain expected task description") - } -} - -func TestAll(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - uuid, err := createTask(ctx, "integration test task for all") - if err != nil { - t.Fatalf("failed to create task: %v", err) - } - defer deleteTask(ctx, uuid) - - stdout, _, code := runAsk(ctx, []string{"all"}) - if code != 0 { - t.Fatalf("all failed with code %d: %s", code, stdout.String()) - } - if !strings.Contains(stdout.String(), "integration test task for all") { - t.Errorf("all output does not contain expected task description") - } -} - -func TestReady(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - uuid, err := createTask(ctx, "integration test task for ready") - if err != nil { - t.Fatalf("failed to create task: %v", err) - } - defer deleteTask(ctx, uuid) - - stdout, _, code := runAsk(ctx, []string{"ready"}) - if code != 0 { - t.Fatalf("ready failed with code %d: %s", code, stdout.String()) - } - if !strings.Contains(stdout.String(), "integration test task for ready") { - t.Errorf("ready output does not contain expected task description") - } -} - -func TestInfo(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - t.Setenv("XDG_CACHE_HOME", t.TempDir()) - - uuid, err := createTask(ctx, "integration test task for info") - if err != nil { - t.Fatalf("failed to create task: %v", err) - } - defer deleteTask(ctx, uuid) - - ti, ok := getTaskInfoFast(ctx, uuid) - if !ok { - t.Fatalf("info failed or returned no output") - } - if ti.UUID != uuid { - t.Errorf("info uuid mismatch: got %s, want %s", ti.UUID, uuid) - } - if ti.ID == "" { - t.Errorf("info output missing alias ID") - } - if !strings.Contains(ti.Description, "integration test task for info") { - t.Errorf("info description mismatch: %s", ti.Description) - } - - aliasOutput, ok := getTaskInfoRaw(ctx, ti.ID) - if !ok { - t.Fatalf("info by alias failed") - } - if !strings.Contains(aliasOutput, "ID: "+ti.ID) { - t.Errorf("info by alias output missing alias line: %s", aliasOutput) - } - if !strings.Contains(aliasOutput, "UUID: "+uuid) { - t.Errorf("info by alias output missing uuid line: %s", aliasOutput) - } -} - -func TestInfoShowsAllDependencies(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) - defer cancel() - t.Setenv("XDG_CACHE_HOME", t.TempDir()) - - dependency1, err := createTask(ctx, "integration test info dependency one") - if err != nil { - t.Fatalf("failed to create first dependency task: %v", err) - } - defer deleteTask(ctx, dependency1) - - dependency2, err := createTask(ctx, "integration test info dependency two") - if err != nil { - t.Fatalf("failed to create second dependency task: %v", err) - } - defer deleteTask(ctx, dependency2) - - dependent, err := createTask(ctx, "integration test task for info dependencies") - if err != nil { - t.Fatalf("failed to create dependent task: %v", err) - } - defer deleteTask(ctx, dependent) - - if stdout, stderr, code := runAsk(ctx, []string{"dep", "add", dependent, dependency2}); code != 0 { - t.Fatalf("dep add for second dependency failed with code %d: stdout=%s stderr=%s", code, stdout.String(), stderr.String()) - } - if stdout, stderr, code := runAsk(ctx, []string{"dep", "add", dependent, dependency1}); code != 0 { - t.Fatalf("dep add for first dependency failed with code %d: stdout=%s stderr=%s", code, stdout.String(), stderr.String()) - } - - ti, ok := getTaskInfoFast(ctx, dependent) - if !ok { - t.Fatalf("info failed for task with dependencies") - } - if len(ti.Depends) != 2 { - t.Fatalf("info dependencies count = %d, want 2: %+v", len(ti.Depends), ti.Depends) - } - - alias1 := mustTaskAlias(t, ctx, dependency1) - alias2 := mustTaskAlias(t, ctx, dependency2) - wantDepends := []string{ - alias1 + " (" + dependency1 + ")", - alias2 + " (" + dependency2 + ")", - } - slices.Sort(wantDepends) - if !slices.Equal(ti.Depends, wantDepends) { - t.Fatalf("info dependencies = %+v, want %+v", ti.Depends, wantDepends) - } -} - -func TestAnnotate(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - uuid, err := createTask(ctx, "integration test task for annotate") - if err != nil { - t.Fatalf("failed to create task: %v", err) - } - defer deleteTask(ctx, uuid) - - note := "this is a test annotation" - stdout, _, code := runAsk(ctx, []string{"annotate", uuid, note}) - if code != 0 { - t.Fatalf("annotate failed with code %d: %s", code, stdout.String()) - } - - raw, ok := getTaskInfoRaw(ctx, uuid) - if !ok { - t.Fatalf("could not get task info after annotate") - } - if !strings.Contains(raw, note) { - t.Errorf("annotation text %q not found in task info output:\n%s", note, raw) - } -} - -func TestStart(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - uuid, err := createTask(ctx, "integration test task for start") - if err != nil { - t.Fatalf("failed to create task: %v", err) - } - defer deleteTask(ctx, uuid) - - stdout, _, code := runAsk(ctx, []string{"start", uuid}) - if code != 0 { - t.Fatalf("start failed with code %d: %s", code, stdout.String()) - } - - ti, ok := getTaskInfoFast(ctx, uuid) - if !ok { - t.Fatalf("could not get task info after start") - } - if ti.Started != "yes" { - t.Errorf("task started state = %q, want yes", ti.Started) - } - if ti.StartTime == "" { - t.Errorf("task start time is empty after start") - } -} - -func TestStop(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - uuid, err := createTask(ctx, "integration test task for stop") - if err != nil { - t.Fatalf("failed to create task: %v", err) - } - defer deleteTask(ctx, uuid) - - runAsk(ctx, []string{"start", uuid}) - - stdout, _, code := runAsk(ctx, []string{"stop", uuid}) - if code != 0 { - t.Fatalf("stop failed with code %d: %s", code, stdout.String()) - } - - ti, ok := getTaskInfoFast(ctx, uuid) - if !ok { - t.Fatalf("could not get task info after stop") - } - if ti.Started != "no" { - t.Errorf("task started state = %q, want no", ti.Started) - } - if ti.StartTime != "" { - t.Errorf("task start time should be empty after stop: %s", ti.StartTime) - } -} - -func TestDone(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - uuid, err := createTask(ctx, "integration test task for done") - if err != nil { - t.Fatalf("failed to create task: %v", err) - } - - stdout, _, code := runAsk(ctx, []string{"done", uuid}) - if code != 0 { - t.Fatalf("done failed with code %d: %s", code, stdout.String()) - } - - ti, ok := getTaskInfoFast(ctx, uuid) - if !ok { - t.Fatalf("could not get task info after done") - } - if strings.ToLower(ti.Status) != "completed" { - t.Errorf("task status = %s, want completed", ti.Status) - } - - deleteTask(ctx, uuid) -} - -func TestPriority(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - uuid, err := createTask(ctx, "integration test task for priority") - if err != nil { - t.Fatalf("failed to create task: %v", err) - } - defer deleteTask(ctx, uuid) - - stdout, _, code := runAsk(ctx, []string{"priority", uuid, "H"}) - if code != 0 { - t.Fatalf("priority failed with code %d: %s", code, stdout.String()) - } - - ti, ok := getTaskInfoFast(ctx, uuid) - if !ok { - t.Fatalf("could not get task info after priority") - } - if ti.Priority != "H" { - t.Errorf("task priority = %s, want H", ti.Priority) - } -} - -func TestTag(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - uuid, err := createTask(ctx, "integration test task for tag") - if err != nil { - t.Fatalf("failed to create task: %v", err) - } - defer deleteTask(ctx, uuid) - - stdout, _, code := runAsk(ctx, []string{"tag", uuid, "+cli"}) - if code != 0 { - t.Fatalf("tag add failed with code %d: %s", code, stdout.String()) - } - - ti, ok := getTaskInfoFast(ctx, uuid) - if !ok { - t.Fatalf("could not get task info after tag") - } - found := false - for _, tg := range ti.Tags { - if tg == "cli" { - found = true - break - } - } - if !found { - t.Errorf("tag cli not found on task: %+v", ti.Tags) - } - - runAsk(ctx, []string{"tag", uuid, "-cli"}) - - ti2, _ := getTaskInfoFast(ctx, uuid) - for _, tg := range ti2.Tags { - if tg == "cli" { - t.Errorf("tag cli should have be