diff --git a/.gitignore b/.gitignore index a8c1259..351594d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ +ctask ctask.exe +ctask-* *.exe +dist/ diff --git a/audit-report.md b/audit-report.md new file mode 100644 index 0000000..afa8e13 --- /dev/null +++ b/audit-report.md @@ -0,0 +1,332 @@ +# ctask Portability & Feature Readiness Audit + +**Audit date:** 2026-05-07 +**Scope:** Read-only audit of the ctask codebase against (a) running on Linux without behavioural drift from Windows and (b) the planned features `restore`, `info` on archived workspaces, `notes `, `path `, shell completion, and a centralized resolver. + +**Headline:** The codebase is already in good shape for Linux. There are **no true blockers** to running ctask on Linux today — a `GOOS=linux go build` will produce a working binary, and the test suite is portable. The work falls into three buckets: distribution surface (justfile / install scripts / `.gitignore` are Windows-only), one piece of latent metadata (`WorkspacePath` stores absolute paths but nothing reads them), and a handful of small additions needed before completion can be wired up cleanly (`list --names` and `ValidArgsFunction` hooks). + +--- + +## Section 1 — Path handling (portability) + +### Finding: `WorkspacePath` persisted as absolute path in `task.yaml` +- **Files:** `internal/workspace/create.go:138`, `internal/workspace/metadata.go:29` +- **Current behavior:** `meta.WorkspacePath = wsDir` stores the absolute filesystem path (e.g. `C:\Users\Warren\ai-workspaces\general\2026-04-24_promptvolley` on Windows). Written to YAML by `WriteMeta`/`WriteMetaLocked`. Verified by grep: **the field is never read in production code** — only in test struct initializers (`internal/workspace/metadata_test.go`, `cmd/{archive,delete,list}_test.go`, `internal/workspace/query_test.go`). The runtime path used everywhere is `QueryResult.Path`, derived freshly from the filesystem walk. +- **Linux/feature impact:** No functional break today. But: (a) the field is misleading — task.yaml claims `workspace_path: C:\...` even when read on Linux; (b) any future code that decides to consume the field will instantly break cross-OS workspace sharing (Windows host ↔ WSL/Docker reading the same dir tree); (c) blocks any plan where ctask metadata travels between hosts. +- **Severity:** **should fix** — either drop the field entirely (it is dead) or convert to relative-to-root before persisting. + +### Finding: Defensive dual-separator checks in manifest are dead code +- **Files:** `internal/session/manifest.go:27`, `internal/session/manifest.go:35` +- **Current behavior:** `ignoredPath` checks `strings.HasPrefix(relPath, ".ctask/") || strings.HasPrefix(relPath, ".ctask\\")` and `relPath == "logs/sessions.log" || relPath == "logs\\sessions.log"`. Manifest paths reach this function only after `filepath.ToSlash(rel)` at `manifest.go:65`, so the `\\`-form branches can never fire. +- **Linux/feature impact:** Fine functionally on Linux; just confusing/redundant. +- **Severity:** fine + +### Finding: All path construction goes through `filepath` package +- **Files:** Verified across `internal/config/config.go`, `internal/workspace/{create.go, query.go, list.go, metadata.go, launchdir.go, slug.go}`, `internal/session/*.go`, `cmd/*.go`. No string concatenation with `\\` or `/` separators in path-building positions; `path.Join` (the URL/forward-slash variant) is not used; `os.PathSeparator` and `filepath.Separator` are used correctly where the platform separator is needed (e.g. `internal/workspace/launchdir.go:48` traversal check, `internal/workspace/create.go:189` display normalization). +- **Linux/feature impact:** Fine. +- **Severity:** fine + +### Finding: `RelativePath` normalizes to forward slashes for display +- **Files:** `internal/workspace/create.go:184-190` +- **Current behavior:** `filepath.Rel(root, wsPath)` then `strings.ReplaceAll(rel, string(filepath.Separator), "/")`. Used in error messages and `cmd/archive.go:61`, `cmd/helpers.go:28`. +- **Linux/feature impact:** Fine — display strings will be identical across platforms. +- **Severity:** fine + +### Finding: Config layer cleanly handles `~`, env-var defaults, Windows case-insensitive dedup +- **Files:** `internal/config/config.go:12-105`, `defaultSeedDir` at `:138-149`, `expandPath` at `:152-162` +- **Current behavior:** `ResolveRoot` defaults to `filepath.Join(home, "ai-workspaces")` (works on Linux: `/home//ai-workspaces`). `ResolveSeedDir`/`ResolveProjectSeedDir` switch on `runtime.GOOS`: `%APPDATA%\ctask\seed[-project]` on Windows, `~/.config/ctask/seed[-project]` on Unix. `searchRootKey`/`samePath` lower-case on Windows for case-insensitive dedup, leave alone on Linux. `expandPath` handles `~/` and converts to absolute via `filepath.Abs`. +- **Linux/feature impact:** Fine. +- **Severity:** fine + +### Finding: Env var resolution is centralized +- **Files:** Grep confirms `os.Getenv("CTASK_ROOT" | "CTASK_PROJECT_ROOT" | "CTASK_SEED_DIR" | "CTASK_SEED_PROJECT_DIR")` appears only in `internal/config/config.go` (and tests). Commands always go via `config.Resolve*`. +- **Linux/feature impact:** Fine. +- **Severity:** fine + +### Finding: No registry, syscall, or PowerShell invocations from core logic +- **Files:** No `golang.org/x/sys/windows` imports, no `import "C"`, no exec'ing of `powershell`/`cmd` from non-shell layers. +- **Linux/feature impact:** Fine. +- **Severity:** fine + +**Section 1 summary:** 1 should-fix (`WorkspacePath`), 1 cosmetic dead-code note. Path-construction discipline is solid. + +--- + +## Section 2 — Workspace identity model + +### Finding: Canonical identity is the absolute filesystem `Path`; no separate ID type +- **Files:** `internal/workspace/query.go:14-18` (`QueryResult{Path, Root, Meta}`), `internal/workspace/metadata.go:19-32` (`TaskMeta.ID` is timestamp-derived, never compared) +- **Current behavior:** `QueryResult.Path` is always absolute (constructed via `filepath.Join` rooted at an absolute search root, `query.go:122,142,147-150`). `TaskMeta.ID` is created in `create.go:70` as a timestamp string and is never used for resolution. The user-facing identifier is `TaskMeta.Slug`. +- **Impact on planned features:** `path ` is a one-liner against `QueryResult.Path`. `notes ` and `restore ` need only the `Path` and `Meta` already present in `QueryResult`. No identity-model refactor is needed. +- **Severity:** fine + +### Finding: Slug ≠ directory basename — slug is the post-date suffix +- **Files:** `internal/workspace/slug.go`, `internal/workspace/create.go:83-85,129`, `internal/workspace/query.go:46-50,57-65` +- **Current behavior:** Directory basename has the form `YYYY-MM-DD_[-]`. `TaskMeta.Slug` is the bare slug after stripping date and any numeric collision suffix. Resolution matches against (1) full directory basename, (2) exact `Meta.Slug`, (3) case-insensitive substring of `Meta.Slug`. +- **Impact on planned features:** Completion candidates should be slugs (or full dated basenames) — both forms are accepted by the resolver. Confirmed the relationship is consistent across the codebase. +- **Severity:** fine + +### Finding: Display paths use `/` regardless of OS; filesystem operations use native separator +- **Files:** `internal/workspace/create.go:189` (display normalization), filesystem paths via `filepath.*` everywhere else +- **Impact on planned features:** Output of `path ` should keep native separators (it is a real FS path). List/error output should keep using `RelativePath` for `/` display. +- **Severity:** fine + +**Section 2 summary:** Identity model is clean and consistent. No work required. + +--- + +## Section 3 — Workspace resolution: per-command consistency + +### Finding: Resolution is already centralized; minor drift in `includeArchived` defaults +- **Files:** + - Central path: `cmd/helpers.go:13-35` (`resolveOne`), `internal/workspace/query.go:23-66` (`ResolveQuery`) + - `cmd/archive.go:29` — `resolveOne(roots, args[0], false)` — hardcoded + - `cmd/resume.go:50` — `resolveOne(roots, query, false)` — hardcoded + - `cmd/info.go:23,29` — `--all/-a` flag (`infoAll`), default false + - `cmd/delete.go:30,36` — `--all/-a` flag (`deleteAll`), default false + - `cmd/open.go:28,35` — `--all/-a` flag (`openAll`), default false + - `cmd/last.go` — uses `workspace.MostRecentActive(roots)` (purpose-specific helper) + - `cmd/new.go` — does not resolve; creates fresh +- **Current behavior:** Every command that takes a query goes through `resolveOne`, which goes through `ResolveQuery`. All callers pass `config.SearchRoots()`. 0-match exits 1; multi-match prints candidates and exits 1. No hand-rolled scans elsewhere. +- **Impact on planned features:** No "centralized resolver" rewrite is needed — it already exists. The only inconsistency is that some commands hardcode `includeArchived=false` while others gate it behind `--all`. New commands (`restore`, `notes`, `path`) can plug straight in. `restore` will need `includeArchived=true` (or to set the resolver to true and then verify `Meta.Status == "archived"`). +- **Severity:** **should fix** — flag inconsistency, not a structural problem. Decide a uniform policy (e.g., commands that act on metadata get `--all`; commands that need an active session don't). + +**Section 3 summary:** Resolver is already shared. The "centralized resolver" item on the planned-features list is essentially done. + +--- + +## Section 4 — Archive implementation + +### Finding: Archive is a metadata-only flip; trivially reversible +- **Files:** `cmd/archive.go:27-65` +- **Current behavior:** Sets `Meta.Status = "archived"`, `Meta.ArchivedAt = &now`, `Meta.UpdatedAt = now`. Writes via `WriteMetaLocked`. **No directory move, no lease/manifest cleanup, no cache invalidation.** Active-session check at `:35-49` reads the lease and refuses (or prompts) if fresh. +- **Impact on planned features:** `restore` is mechanically symmetric: clear `Status` (or set to `"active"`), clear `ArchivedAt`, bump `UpdatedAt`, `WriteMetaLocked`. Should refuse if `Status != "archived"` (idempotency / user-error guard). No directory move needed. +- **Severity:** fine + +### Finding: Archived workspaces stay in place; filtering is at the resolver layer +- **Files:** `internal/workspace/query.go:31-33` (filter), `internal/workspace/list.go:46-47` (filter), `internal/workspace/query.go:99-155` (`scanWorkspaces` ignores status) +- **Current behavior:** `scanWorkspaces` reads every `task.yaml` regardless of status. Status filtering is purely a `ResolveQuery`/`ListWorkspaces` decision based on the `IncludeArchived` flag. +- **Impact on planned features:** Cross-workspace reads on archived workspaces (`notes my-old-ws`, `path my-old-ws`) will work as long as the new commands pass `includeArchived=true` or expose an `--all` flag. +- **Severity:** fine + +**Section 4 summary:** Clean. `restore` is a one-screen feature. + +--- + +## Section 5 — List command and filtering + +### Finding: `ctask list` has no machine-readable mode +- **Files:** `cmd/list.go:14-101` +- **Current behavior:** Default lists active tasks + projects. Flags: `--all/-a` (include archived), `--task`, `--projects`, `--category/-c`, `--limit/-n`. Output is `tabwriter`-aligned six-column human format: `status, type, mode, category, date, slug`. + ``` + active task local general 2026-04-05 task-active + active project local projects 2026-04-02 proj-active + ``` +- **Impact on planned features:** **Blocks clean shell-completion.** A completion script needs a stable, parseable list of slugs. Either add `--names` (one slug per line) or `--json`, or add a hidden `__complete-workspaces` helper. Parsing the tabwriter output is fragile (tab spacing, future column changes). +- **Severity:** **should fix** — directly blocks the completion feature being clean. + +**Section 5 summary:** Functionality is fine; lack of a `--names` (or equivalent) is the gap. + +--- + +## Section 6 — Agent launch & shell assumptions + +### Finding: Agent and shell launch are os-aware and use `os/exec` directly +- **Files:** `internal/shell/launch.go` (entire), `cmd/archive.go:89-95` (`isStdinTerminal`) +- **Current behavior:** + - `DefaultShell()` → `powershell.exe` → `cmd.exe` on Windows; `$SHELL` → `bash` on Unix. + - `ExecAgent` → `exec.LookPath` + `exec.Command(path)`. No shell wrapping. PATHEXT resolution is automatic on Windows. + - `ExecShell` injects `function prompt {...}` via `-NoExit -Command` for PowerShell, `PROMPT=...$P$G` env var for cmd.exe, `PS1` for bash; all branches guarded by `runtime.GOOS`. + - `BuildEnvList` is the sole env-export mechanism (`os.Environ()` ++ provided map). + - `isStdinTerminal` uses `os.ModeCharDevice` (cross-platform). + - No registry, no console-mode manipulation, no `term.MakeRaw`/ANSI handling. +- **Impact on planned features:** Fine. Linux launches will work. +- **Severity:** fine + +### Finding: Heartbeat / lease / manifest writers are pure in-process Go +- **Files:** `internal/session/heartbeat.go`, `internal/session/run.go` +- **Current behavior:** Goroutine heartbeat rewrites lease via `os.WriteFile`. No subprocess helpers. +- **Severity:** fine + +### Finding: Statusline scripts ship to both platforms; install is Windows-only (see Section 8) +- **Files:** `scripts/ctask-statusline.sh`, `scripts/ctask-statusline.ps1`, `scripts/install.ps1:67-80`, `cmd/doctor.go:78-92` +- **Current behavior:** Both helper scripts are copied on Windows install. `doctor.go` searches for `.sh` first on non-Windows, `.ps1` first on Windows. The `.sh` script uses bash idioms but is invoked explicitly via `bash ` so should be fine. +- **Severity:** fine (the install-script-only-Windows issue is logged in Section 8). + +**Section 6 summary:** Fully portable. No work required for Linux launch. + +--- + +## Section 7 — File content & line endings + +### Finding: All ctask-generated files use `\n` +- **Files:** `internal/workspace/metadata.go:50` (`yaml.Marshal`), `internal/session/manifest.go:87` (`json.MarshalIndent`), `internal/session/log.go:36-121` (manual `\n`), `internal/seed/templates.go` (backtick raw strings, `\n` only). +- **Current behavior:** No literal `\r\n` anywhere in `*.go`. Standard Go marshalers emit LF. +- **Severity:** fine + +### Finding: Seed copy is byte-for-byte (preserves source line endings) +- **Files:** `internal/seed/copy.go:73-92` +- **Current behavior:** `io.Copy(out, in)` — no normalization. Seed files committed with CRLF on a Windows machine will be copied as CRLF on Linux too. +- **Linux impact:** Generally fine for Markdown / config files. Becomes a problem only if the seed contains a shell script the agent later executes; recommend a `.gitattributes` `* text=auto eol=lf` for the seed dirs if/when this matters. +- **Severity:** fine (pre-emptive note; not blocking) + +**Section 7 summary:** Clean. + +--- + +## Section 8 — Build system + +### Finding: `justfile` has no Linux build/install/uninstall targets +- **Files:** `justfile:6-23` +- **Current behavior:** `build` outputs `ctask.exe`. `install` and `uninstall` invoke `powershell -NoProfile -ExecutionPolicy Bypass -File scripts/...`. Running `just install` on Linux fails (no `powershell`). +- **Linux impact:** Linux developers can still run `GOOS=linux go build -o ctask .` directly, but there is no parallel install path. +- **Severity:** **should fix** — required for a clean Linux developer/install experience. + +### Finding: No POSIX install/uninstall script +- **Files:** `scripts/install.ps1`, `scripts/uninstall.ps1` — PowerShell only. +- **Linux impact:** No `install.sh` to put the binary on `$PATH`, drop in the statusline helper, etc. +- **Severity:** **should fix** — required for distribution parity. + +### Finding: `.gitignore` only ignores `*.exe`, not the Linux `ctask` binary +- **Files:** `.gitignore:1-2` +- **Current behavior:** `ctask.exe` and `*.exe` are ignored. A binary built on Linux as `ctask` is not. +- **Linux impact:** Easy to accidentally commit a built Linux binary. +- **Severity:** **should fix** — trivial. + +### Finding: No build tags, no cgo, no `//go:embed` of OS-specific assets +- **Files:** Project-wide grep — clean. +- **Linux impact:** Cross-compile is `GOOS=linux GOARCH=amd64 go build .` with nothing else needed. +- **Severity:** fine + +### Finding: `runtime.GOOS` use is contained and correctly branched +- **Files:** `internal/config/config.go:101,112,139`, `internal/workspace/query.go:158`, `internal/shell/launch.go` (multiple), `cmd/doctor.go:78,146`. All have an explicit Unix branch. +- **Severity:** fine + +**Section 8 summary:** 3 should-fix items, all in distribution surface (justfile, install scripts, .gitignore). No code-level blockers. + +--- + +## Section 9 — Test coverage + +### Finding: 29 `_test.go` files, broad coverage, all spot-checks portable +- **Files:** Tests live in `cmd/` (5), `internal/config/` (2), `internal/workspace/` (8), `internal/session/` (10), `internal/shell/` (1), `internal/seed/` (2), `internal/lockfile/` (1). +- **Current behavior:** All path construction uses `t.TempDir()` + `filepath.Join`. `runtime.GOOS` checks guard Windows-specific assertions (`internal/config/config_test.go:57`, `internal/shell/launch_test.go:11-14`). `t.Skip("tilde expansion not typical on Windows")` patterns are present. No CRLF/LF assertions. No tests shell out to `cmd.exe`/`powershell.exe`. +- **Linux impact:** Spot-checked `internal/shell/launch_test.go`, `internal/workspace/query_test.go`, `internal/config/config_test.go`. All pass on Linux without modification. +- **Severity:** fine + +### Finding: A few tests use Unix-style fixture string literals — but only as comparison values, not as path-construction targets +- **Files:** `internal/workspace/metadata_test.go:81,126,157,237` (e.g. `WorkspacePath: "/home/warren/..."`) +- **Current behavior:** These strings are stored in `WorkspacePath` and compared back; they never touch the filesystem. +- **Severity:** fine — and ironically further evidence that `WorkspacePath` is never resolved as a real path. + +**Section 9 summary:** Tests are portable. No work required. + +--- + +## Section 10 — Feature-specific gotchas + +### `ctask restore` +- Archive (`cmd/archive.go:51-54`) flips `Status`, `ArchivedAt`, `UpdatedAt` only. No locks, sentinel files, or directory moves to undo. `restore` is a clean inverse: `resolveOne(roots, q, true)` → reject if `Status != "archived"` → clear `Status`+`ArchivedAt` → `WriteMetaLocked`. +- **Severity:** fine + +### `ctask info` on archived workspaces directly +- Today: `cmd/info.go:23,29` — gated on `--all/-a`. The user wants direct lookup to also work without `-a`. Easiest path: change the default to `includeArchived=true` for `info` specifically (it's a read-only display command, no harm in always finding archived workspaces). No callers depend on the current default-off behaviour. Same logic could optionally extend to `delete` and `open` but those have stronger reasons to default to active-only. +- **Severity:** **should fix** — small UX/policy change. + +### `ctask notes ` +- `notes.md` is unconditionally written by `internal/workspace/create.go:179-180`. Always at `/notes.md`. Edge cases: file deleted by user (treat as error, like `cat` of missing file), empty file (print empty, exit 0). +- **Severity:** fine + +### `ctask path ` +- `QueryResult.Path` is already absolute. Implementation: `resolveOne` then `fmt.Println(ws.Path)`. No new function needed. +- **Severity:** fine + +### Shell completion (`ctask completion {bash,powershell}`) +- The CLI is plain Cobra v1.10.2 (`cmd/root.go`), `main.go` does no hand-rolled flag parsing. Cobra ships `GenBashCompletionV2`, `GenPowerShellCompletionWithDesc`, `GenZshCompletionV2`, `GenFishCompletion` — and Cobra also auto-injects a `completion` subcommand in modern versions (`rootCmd.CompletionOptions`). **Adding the command itself is essentially free.** +- The *quality* of completion depends on `ValidArgsFunction` hooks on commands that accept a workspace query (`resume`, `open`, `info`, `archive`, `delete`, `last`, plus the new `restore`, `notes`, `path`). Each hook would call the resolver to enumerate slug candidates. Trivial wrapper. +- **Real dependency:** dynamic completion needs a way to enumerate workspaces — that's where Section 5's missing `--names`/`--json` mode comes in. The `ValidArgsFunction` can call `workspace.ListWorkspaces` directly (no shell-out), so this is solvable in-process without a new flag, but a flag is also useful for external tooling and bash-side hand-written completion. +- **Severity:** fine for the command itself; completion *content* depends on the should-fix in Section 5. + +### Cross-workspace reads on archived workspaces +- `scanWorkspaces` (`query.go:99-155`) reads every `task.yaml` regardless of status. Filtering is at the `ResolveQuery` layer, controlled by `includeArchived`. So `notes ` and `path ` will work as long as those commands pass `includeArchived=true` (or expose an `--all` flag). +- **Severity:** fine + +--- + +## Section 11 — Recommended implementation order + +The codebase needs no Linux-portability bugfix to *run* on Linux. The work is best ordered by what unblocks the most other items downstream. + +### Step 1 — Linux distribution surface +**What:** Add a `build-linux` / `build-all` target to `justfile`, add a `scripts/install.sh` and `scripts/uninstall.sh` mirroring the PowerShell scripts (probably installing to `~/.local/bin` with a check-and-warn for `$PATH`), add `ctask` and `ctask-*` to `.gitignore`. +**Why first:** Required for *Linux portability* end-to-end. None of the other planned work blocks on this, but a developer can't smoke-test the Linux build cleanly without it. Resolves Section 8 should-fixes. +**Required for:** Linux portability (distribution). + +### Step 2 — Cross-compile and smoke test on Linux/WSL +**What:** Build the Linux binary from Step 1, run the test suite (`GOOS=linux go test ./...` from Windows is not a substitute — actually run on Linux), exercise `new`, `list`, `info`, `archive`, `resume` against a fresh `~/ai-workspaces` root. +**Why now:** Validates the audit's central claim ("the code is portable") with evidence rather than inference. Also surfaces any environmental issues before more code is added. +**Required for:** Linux portability (validation). + +### Step 3 — Decide the fate of `WorkspacePath` +**What:** Either delete the field (cleanest — it has no readers and the persisted value is misleading on cross-OS shares) or convert it to a path relative to the resolved root before persisting (preserves the schema). Update `internal/workspace/create.go:138`, the YAML schema, and the test fixtures. +**Why before features ship:** Once `notes`/`path`/`restore` ship, users will start sharing workspaces between Windows and WSL/Docker. A latent-but-visible Windows path in `task.yaml` will be the first thing they ask about. Cheaper to fix while no consumer exists than after. +**Required for:** Cross-OS workspace sharing hygiene; resolves Section 1's only should-fix. + +### Step 4 — Add `ctask list --names` (and/or `--json`) +**What:** Add a flag (or hidden helper subcommand `__complete-workspaces`) that emits one slug per line, ignoring filters/limit, suitable for completion sources. +**Why before completion:** Either the in-process `ValidArgsFunction` hooks (Step 6) or external bash completion scripts will want a stable enumeration source. Cheaper to do once than to retrofit per-shell. +**Required for:** Shell completion (clean implementation). + +### Step 5 — Implement `ctask restore`, `ctask notes `, `ctask path ` +**What:** Three small commands. Each: `resolveOne(roots, args[0], true)` → operate on `QueryResult`. `restore` adds an active-session check (mirror archive's lease guard). +**Why grouped:** All three follow the same template and exercise the same code paths. Doing them together amortizes the design conversation about "how should the new commands handle archived workspaces" — settle the policy in one PR. +**Required for:** restore + notes + path features. + +### Step 6 — Decide and apply policy: `info` default for archived +**What:** Either flip `infoAll` default to `true` (so `ctask info ` works without `-a`) or keep the flag and document. Confirm no test expects the current default-off behaviour. +**Why after Step 5:** Better to set the policy once with the new commands in mind. If `notes`/`path` default to including archived, `info` should match for consistency. +**Required for:** "info on archived workspaces directly" feature. + +### Step 7 — Add `ctask completion ` and `ValidArgsFunction` hooks +**What:** Add the `completion` subcommand (Cobra one-liner — or simply enable Cobra's built-in `CompletionOptions`). Add `ValidArgsFunction` to every command taking a workspace query: `resume`, `open`, `info`, `archive`, `delete`, `notes`, `path`, `restore`. Each hook calls a shared helper that enumerates slugs. +**Why last:** Completion exposes everything else. If commands or list output change later, the completion has to track them. Doing it after the new commands settle avoids churn. +**Required for:** Shell completion feature. + +### Optional follow-ups (not on the planned-feature list, but cheap wins) +- Drop the dead `\\` branches in `internal/session/manifest.go:27,35`. +- Tighten the `--all` default policy across `archive`/`resume`/`delete`/`open`/`info` (Section 3 inconsistency) once the new commands have set the precedent. +- Add a `.gitattributes` covering `internal/seed/` to normalize EOL on the seed sources. + +--- + +## Summary — blocking and should-fix items, grouped by feature + +### Linux portability (cross-compile + run) +- **should fix:** `justfile` has no Linux targets — `justfile:11,18,23` +- **should fix:** No POSIX install/uninstall script — `scripts/install.ps1`, `scripts/uninstall.ps1` +- **should fix:** `.gitignore` does not cover the Linux `ctask` binary — `.gitignore:1-2` +- **should fix (cross-OS sharing):** `WorkspacePath` persisted as absolute path — `internal/workspace/create.go:138`, `internal/workspace/metadata.go:29` + +### `ctask restore` +- *(no blocking or should-fix items — clean addition)* + +### `ctask info` on archived workspaces directly +- **should fix:** Decide policy on `infoAll` default — `cmd/info.go:23` + +### `ctask notes ` +- *(no blocking or should-fix items — clean addition)* + +### `ctask path ` +- *(no blocking or should-fix items — clean addition)* + +### Shell completion (`ctask completion {bash,powershell}`) +- **should fix:** No machine-readable list output (`--names`/`--json`) — `cmd/list.go:81-98` + +### Centralized workspace resolver +- *(already centralized via `cmd/helpers.go` `resolveOne` → `internal/workspace/query.go` `ResolveQuery`)* +- **should fix (cosmetic):** `includeArchived` default is hardcoded in `archive`/`resume`, flag-controlled in `info`/`delete`/`open` — `cmd/archive.go:29`, `cmd/resume.go:50`, `cmd/info.go:23`, `cmd/delete.go:30`, `cmd/open.go:28` + +### No-impact / housekeeping +- Dead defensive code in `internal/session/manifest.go:27,35` (fine; can be cleaned). + +**Total: 0 blocking, 6 should-fix items.** None of them prevent ctask from running on Linux today; all of them either smooth the distribution surface, prevent latent cross-OS bugs, or unblock the cleanest implementation of the planned features. diff --git a/cmd/archive_test.go b/cmd/archive_test.go index c997b96..b99acb2 100644 --- a/cmd/archive_test.go +++ b/cmd/archive_test.go @@ -32,7 +32,6 @@ func makeArchiveWs(t *testing.T, root, category, dirName string) string { Type: "task", Mode: "local", Agent: "claude", - WorkspacePath: dir, } workspace.WriteMeta(filepath.Join(dir, "task.yaml"), meta) return dir diff --git a/cmd/delete_test.go b/cmd/delete_test.go index f21acec..0ac92eb 100644 --- a/cmd/delete_test.go +++ b/cmd/delete_test.go @@ -30,7 +30,6 @@ func createTestWs(t *testing.T, root, category, dirName, status string) string { Category: category, Mode: "local", Agent: "claude", - WorkspacePath: dir, } workspace.WriteMeta(filepath.Join(dir, "task.yaml"), meta) diff --git a/cmd/list_test.go b/cmd/list_test.go index 8f427e3..43ed02a 100644 --- a/cmd/list_test.go +++ b/cmd/list_test.go @@ -34,7 +34,6 @@ func listTestEnv(t *testing.T) string { Type: taskType, Mode: "local", Agent: "claude", - WorkspacePath: dir, } workspace.WriteMeta(filepath.Join(dir, "task.yaml"), meta) } diff --git a/internal/workspace/create.go b/internal/workspace/create.go index 3b74433..b450b2e 100644 --- a/internal/workspace/create.go +++ b/internal/workspace/create.go @@ -135,7 +135,6 @@ func Create(opts CreateOpts) (*CreateResult, error) { Type: taskType, Mode: opts.Mode, Agent: opts.Agent, - WorkspacePath: wsDir, ArchivedAt: nil, } if opts.IsProject { diff --git a/internal/workspace/metadata.go b/internal/workspace/metadata.go index 9fc1437..6da8d6c 100644 --- a/internal/workspace/metadata.go +++ b/internal/workspace/metadata.go @@ -26,7 +26,6 @@ type TaskMeta struct { Type string `yaml:"type"` Mode string `yaml:"mode"` Agent string `yaml:"agent"` - WorkspacePath string `yaml:"workspace_path"` ArchivedAt *time.Time `yaml:"archived_at"` LaunchDir string `yaml:"launch_dir,omitempty"` } diff --git a/internal/workspace/metadata_test.go b/internal/workspace/metadata_test.go index 0ef1580..b4d26f3 100644 --- a/internal/workspace/metadata_test.go +++ b/internal/workspace/metadata_test.go @@ -18,7 +18,6 @@ func TestWriteMetaLockedHappyPath(t *testing.T) { CreatedAt: now, UpdatedAt: now, Status: "active", Category: "general", Type: "task", Mode: "local", Agent: "claude", - WorkspacePath: dir, } if err := WriteMetaLocked(metaPath, meta); err != nil { @@ -78,7 +77,6 @@ func TestWriteAndReadMeta(t *testing.T) { Category: "general", Mode: "local", Agent: "claude", - WorkspacePath: "/home/warren/ai-workspaces/general/20260405_arch-notes", ArchivedAt: nil, } @@ -123,7 +121,6 @@ func TestMetaYAMLFieldsPresent(t *testing.T) { Category: "general", Mode: "local", Agent: "claude", - WorkspacePath: "/tmp/test", } WriteMeta(path, meta) @@ -131,7 +128,7 @@ func TestMetaYAMLFieldsPresent(t *testing.T) { data, _ := os.ReadFile(path) content := string(data) - for _, field := range []string{"id:", "slug:", "title:", "created_at:", "updated_at:", "status:", "category:", "type:", "mode:", "agent:", "workspace_path:", "archived_at:"} { + for _, field := range []string{"id:", "slug:", "title:", "created_at:", "updated_at:", "status:", "category:", "type:", "mode:", "agent:", "archived_at:"} { if !strings.Contains(content, field) { t.Errorf("missing field %s in YAML output", field) } @@ -154,7 +151,6 @@ func TestMetaTypeRoundTrip(t *testing.T) { Type: "project", Mode: "local", Agent: "claude", - WorkspacePath: "/tmp/billing", } if err := WriteMeta(path, meta); err != nil { t.Fatalf("WriteMeta: %v", err) @@ -234,7 +230,6 @@ func TestMetaArchive(t *testing.T) { Category: "general", Mode: "local", Agent: "claude", - WorkspacePath: "/tmp/test", } WriteMeta(path, meta) diff --git a/internal/workspace/query_test.go b/internal/workspace/query_test.go index c292968..ddc4a24 100644 --- a/internal/workspace/query_test.go +++ b/internal/workspace/query_test.go @@ -40,7 +40,6 @@ func createTestWorkspaceFull(t *testing.T, root, category, dirName, status, task Type: taskType, Mode: "local", Agent: "claude", - WorkspacePath: dir, } WriteMeta(filepath.Join(dir, "task.yaml"), meta) } @@ -186,7 +185,6 @@ func createFlatProjectWorkspaceFull(t *testing.T, root, dirName string, updatedA Type: "project", Mode: "local", Agent: "claude", - WorkspacePath: dir, } WriteMeta(filepath.Join(dir, "task.yaml"), meta) } diff --git a/justfile b/justfile index fa98d70..a394cd2 100644 --- a/justfile +++ b/justfile @@ -10,6 +10,19 @@ default: build: go build -o ctask.exe . +# Cross-compile a Linux amd64 binary into dist/ +build-linux: + mkdir -p dist + GOOS=linux GOARCH=amd64 go build -o dist/ctask-linux-amd64 ./ + +# Cross-compile a Windows amd64 binary into dist/ +build-windows: + mkdir -p dist + GOOS=windows GOARCH=amd64 go build -o dist/ctask-windows-amd64.exe ./ + +# Build both Windows and Linux artifacts into dist/ +build-all: build-windows build-linux + # Run the full test suite test: go test ./... -v -count=1 diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..7237124 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,95 @@ +#!/bin/sh +# Install ctask to ~/.local/bin on Linux (or pass an alternative directory as $1). +# Builds ctask from the local repo, copies the binary and bash status-line helper +# to the install directory, and warns if the directory is not on $PATH. +# Does not modify workspace data. Does not modify shell config. + +set -eu + +INSTALL_DIR="${1:-$HOME/.local/bin}" + +# Find repo root (script lives in scripts/) +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) + +if [ ! -f "$REPO_ROOT/go.mod" ]; then + printf 'Error: cannot find go.mod at %s. Run this script from the ctask repo.\n' "$REPO_ROOT" >&2 + exit 1 +fi + +printf 'ctask install\n' +printf ' Source: %s\n' "$REPO_ROOT" +printf ' Target: %s\n' "$INSTALL_DIR" +printf '\n' + +# Step 1: find go +if ! command -v go >/dev/null 2>&1; then + printf 'Error: Go not found on PATH. Install Go 1.26+ and ensure it is on PATH.\n' >&2 + exit 1 +fi + +# Step 2: build (host platform; on WSL/Linux this produces a Linux binary named "ctask") +printf 'Building ctask... ' +if ! ( cd "$REPO_ROOT" && go build -o ctask ./ ); then + printf 'FAILED\n' + exit 1 +fi +printf 'OK\n' + +# Step 3: ensure install dir exists +if [ ! -d "$INSTALL_DIR" ]; then + mkdir -p "$INSTALL_DIR" + printf ' Created %s\n' "$INSTALL_DIR" +fi + +# Step 4: copy binary and statusline helper +install_file() { + src=$1 + dst=$2 + if [ -f "$src" ]; then + cp -f "$src" "$dst" + chmod 0755 "$dst" + printf ' Installed %s\n' "$(basename "$dst")" + else + printf ' Warning: source not found: %s\n' "$src" >&2 + fi +} + +install_file "$REPO_ROOT/ctask" "$INSTALL_DIR/ctask" +install_file "$REPO_ROOT/scripts/ctask-statusline.sh" "$INSTALL_DIR/ctask-statusline.sh" + +# Step 5: PATH check (do not modify shell config; warn with the right snippet) +case ":${PATH:-}:" in + *":$INSTALL_DIR:"*) + printf '\n PATH: already contains %s\n' "$INSTALL_DIR" + ;; + *) + printf '\n PATH: %s is not on $PATH\n' "$INSTALL_DIR" + shell_name=$(basename "${SHELL:-/bin/sh}") + case "$shell_name" in + zsh) + printf ' Add this to ~/.zshrc:\n export PATH="%s:$PATH"\n' "$INSTALL_DIR" + ;; + bash) + printf ' Add this to ~/.bashrc (or ~/.bash_profile on macOS):\n export PATH="%s:$PATH"\n' "$INSTALL_DIR" + ;; + *) + printf ' Add the following to your shell config:\n export PATH="%s:$PATH"\n' "$INSTALL_DIR" + ;; + esac + ;; +esac + +# Step 6: verify +if [ -x "$INSTALL_DIR/ctask" ]; then + ver=$("$INSTALL_DIR/ctask" --version 2>/dev/null || echo unknown) + printf '\nInstalled: %s\n' "$ver" +else + printf 'Warning: binary not found at %s/ctask\n' "$INSTALL_DIR" >&2 + exit 1 +fi + +# Step 7: clean up build artifact in repo root +rm -f "$REPO_ROOT/ctask" + +printf "\nRun 'ctask doctor' in a new terminal to verify setup.\n" diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100644 index 0000000..083dbb1 --- /dev/null +++ b/scripts/uninstall.sh @@ -0,0 +1,40 @@ +#!/bin/sh +# Uninstall ctask from ~/.local/bin (or pass an alternative directory as $1). +# Removes the binary and ctask-statusline.sh helper from the install directory. +# Does NOT remove CTASK_ROOT, workspaces, or task data. + +set -eu + +INSTALL_DIR="${1:-$HOME/.local/bin}" + +printf 'ctask uninstall\n' +printf ' Install dir: %s\n\n' "$INSTALL_DIR" + +if [ ! -d "$INSTALL_DIR" ]; then + printf ' Nothing to uninstall. Directory does not exist.\n' + exit 0 +fi + +# Only remove ctask-owned files +removed=0 +for name in ctask ctask-statusline.sh; do + target="$INSTALL_DIR/$name" + if [ -f "$target" ]; then + rm -f "$target" + printf ' Removed %s\n' "$name" + removed=$((removed + 1)) + fi +done + +if [ "$removed" = "0" ]; then + printf ' No ctask files found in install directory.\n' +fi + +printf '\n PATH: not modified.\n' +printf ' If you added %s to $PATH manually, remove the entry from your shell config.\n' "$INSTALL_DIR" + +printf '\nWorkspace data is untouched:\n' +root="${CTASK_ROOT:-$HOME/ai-workspaces}" +printf ' %s\n' "$root" + +printf '\nDone.\n' diff --git a/v0.5.1-spec.md b/v0.5.1-spec.md new file mode 100644 index 0000000..d1e9b23 --- /dev/null +++ b/v0.5.1-spec.md @@ -0,0 +1,202 @@ +# ctask v0.5.1 Spec — Linux Portability Baseline + +## Theme + +v0.1 = create and resume workspaces +v0.2 = understand and continue workspaces later +v0.3 = personalize workspaces and support longer-lived project work +v0.4 = protect workspaces from concurrent session conflicts +v0.5 = separate workspace management from project code +v0.5.1 = ctask runs correctly on Linux (WSL and Docker) + +## Problem + +ctask was developed on Windows and has only been built and tested there. The developer now works across Windows, WSL, and Docker containers, and needs ctask available in all three environments. A portability audit (see `audit-report.md`) confirmed that the Go code is already portable — path handling uses `filepath.*` consistently, config defaults are OS-aware, and shell launching branches by `runtime.GOOS`. There are no blocking issues. + +What's missing is the distribution surface: build targets, install scripts, and binary artifact coverage. There is also one piece of dead metadata (`WorkspacePath`) that persists absolute Windows paths into task.yaml, creating misleading data that would confuse cross-OS workspace inspection. + +This spec covers the minimal work to produce a working, installable Linux binary and clean up the one metadata issue before the next feature round builds on top of it. + +## Scope + +Three deliverables: + +1. Linux build targets and binary artifacts +2. POSIX install and uninstall scripts +3. Remove dead `WorkspacePath` metadata + +Plus a manual validation step: smoke test on WSL or a Linux container. + +--- + +## 1. Linux Build Targets + +### Current state + +`justfile` has Windows-only build targets. `.gitignore` covers `ctask.exe` but not the extension-less Linux binary. + +**Reference:** `audit-report.md` Section 8 — justfile lines 11, 18, 23; .gitignore lines 1–2. + +### Changes + +Add build targets to `justfile`, outputting to a `dist/` directory to keep the project root clean: + +``` +build-linux: + mkdir -p dist + GOOS=linux GOARCH=amd64 go build -o dist/ctask-linux-amd64 ./ + +build-windows: + mkdir -p dist + GOOS=windows GOARCH=amd64 go build -o dist/ctask-windows-amd64.exe ./ + +build-all: build-windows build-linux +``` + +Update `.gitignore`: + +``` +ctask +ctask.exe +ctask-* +dist/ +``` + +### Rules + +- `build-linux` produces a single `ctask` binary for linux/amd64 +- `build-all` produces both Windows and Linux binaries with distinguishable names +- No cgo. Pure Go build. If any dependency introduces cgo, set `CGO_ENABLED=0` explicitly +- No ARM64 Linux target yet. Add when a real need exists (e.g., ARM-based containers or CI runners) +- No macOS target. Deferred per roadmap (post-v1.0) +- No GitHub Actions release pipeline yet. That belongs in a later round when ctask is ready for distribution beyond personal use + +--- + +## 2. Install and Uninstall Scripts + +### Current state + +`scripts/install.ps1` and `scripts/uninstall.ps1` exist for Windows. No equivalent for Linux. + +### New files + +**`scripts/install.sh`** + +Behavior: + +- Default install location: `~/.local/bin/ctask` +- Check that `~/.local/bin` exists; create it if not +- Copy the binary and set executable permission +- Install `scripts/ctask-statusline.sh` to the same location as the binary (so `ctask doctor` can find it). Mark it executable. Do not install the PowerShell statusline helper on Linux +- Check whether `~/.local/bin` is in `$PATH`; if not, print a warning with the appropriate shell config line to add (detect bash vs zsh by checking `$SHELL`) +- Accept an optional argument to override install location: `./install.sh /usr/local/bin` +- If a ctask binary already exists at the target, overwrite it (upgrade path) + +**`scripts/uninstall.sh`** + +Behavior: + +- Default location: `~/.local/bin/ctask` +- Accept optional argument to override +- Remove the binary and the `ctask-statusline.sh` helper from the same directory +- Do not remove `CTASK_ROOT` or any workspace data — print a note that workspaces are preserved + +### Rules + +- Scripts should be POSIX sh, not bash-specific, for maximum container compatibility +- Scripts mirror the install/uninstall pattern of the existing PowerShell scripts — keep the UX parallel +- No package manager integration (apt, brew, etc.). Not in scope +- For Docker containers, the expected install pattern is a `COPY` or `RUN curl` in the Dockerfile, not running install.sh. The script is primarily for WSL and interactive Linux environments + +--- + +## 3. Remove `WorkspacePath` Metadata + +### Current state + +`internal/workspace/create.go:138` sets `meta.WorkspacePath = wsDir`, persisting the absolute filesystem path (e.g., `C:\Users\Warren\ai-workspaces\general\2026-04-24_foo`) into task.yaml as `workspace_path`. + +The audit confirmed this field is never read by production code. It appears only in test struct initializers. The runtime workspace path is always derived fresh from the filesystem walk via `QueryResult.Path`. + +**Reference:** `audit-report.md` Section 1 — `internal/workspace/create.go:138`, `internal/workspace/metadata.go:29`. + +### Changes + +- Remove `WorkspacePath` from the `TaskMeta` struct in `internal/workspace/metadata.go` +- Remove the assignment in `internal/workspace/create.go:138` +- Update all test fixtures and struct initializers that reference the field +- Existing task.yaml files that contain `workspace_path` should be left alone — Go's YAML unmarshaling will silently ignore unknown fields. No migration needed +- Do not add a replacement field. If a persistent workspace identifier is needed later, add a proper `workspace_id` with correct semantics (relative path, forward-slash normalized) at that time + +### Rules + +- This is a removal, not a replacement. The field is dead code with misleading output +- Newly created workspaces will no longer have `workspace_path` in their task.yaml +- Old workspaces with the field continue to work — the field is simply ignored on read +- No schema migration command needed + +--- + +## 4. Validation + +After implementing the above, perform a manual smoke test on WSL or a Linux container. + +### Smoke test checklist + +Run these commands on Linux and verify correct behavior: + +``` +ctask new "linux-test" +ctask list +ctask info linux-test +ctask resume linux-test (agent launch — verify shell and env vars) +ctask archive linux-test +ctask list (should not show archived workspace) +ctask list --all (should show archived workspace, if flag exists) +ctask info linux-test -a (should show archived workspace info) +ctask delete linux-test (clean up) +ctask doctor (verify all checks pass on Linux) +``` + +Verify: + +- Default `CTASK_ROOT` resolves to `$HOME/ai-workspaces` +- Workspace directories are created with correct structure +- task.yaml does not contain `workspace_path` +- Session logs, notes.md, CLAUDE.md are created with LF line endings +- Agent launch works (or fails gracefully if the agent binary isn't installed in the Linux environment) +- `ctask doctor` reports correct OS, root path, and PATH status + +### Test suite + +Run `go test ./...` on Linux. The audit states the test suite should pass without modification after the `WorkspacePath` removal and fixture updates. Confirm this. + +--- + +## Backward Compatibility + +- Existing Windows workspaces are unaffected +- Old task.yaml files with `workspace_path` continue to load correctly (field ignored) +- No changes to any existing command behavior +- No changes to workspace layout, session logging, or agent launching + +## Non-Goals for v0.5.1 + +- Shell completion (v0.5.2 or later) +- New commands (restore, notes, path — covered in the next spec) +- GitHub Actions or release automation +- Package manager integration +- macOS or ARM64 targets +- Config file changes (v0.6 scope per roadmap) + +## Build Order + +1. Add Linux build targets to justfile (with `dist/` output) and update .gitignore +2. Add `scripts/install.sh` and `scripts/uninstall.sh` (including statusline helper install) +3. Remove `WorkspacePath` from `TaskMeta`, `create.go`, and test fixtures +4. Run test suite on Windows (confirm nothing broke) +5. Cross-compile Linux binary +6. Run test suite on Linux/WSL +7. Install via `install.sh` on Linux/WSL, verify `ctask doctor` passes (including statusline helper check) +8. Execute smoke test checklist on Linux/WSL