Files
ctask/audit-report.md
T
typebasedio 7a7b2490c2 feat(v0.5.1): Linux portability baseline
- justfile: add build-linux, build-windows, build-all (output to dist/)
- .gitignore: cover ctask, ctask-*, dist/
- scripts/install.sh + scripts/uninstall.sh: POSIX equivalents of .ps1
- remove WorkspacePath metadata field (no production readers; legacy
  task.yaml files continue to parse silently)

Linux smoke-test on WSL/container pending.
See audit-report.md and v0.5.1-spec.md.
2026-05-07 18:22:41 -04:00

26 KiB

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 <ws>, path <ws>, 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/<user>/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 <ws> is a one-liner against QueryResult.Path. notes <ws> and restore <ws> 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_<slug>[-<N>]. 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 <ws> 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:29resolveOne(roots, args[0], false) — hardcoded
    • cmd/resume.go:50resolveOne(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.execmd.exe on Windows; $SHELLbash on Unix.
    • ExecAgentexec.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 <path> 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+ArchivedAtWriteMetaLocked.
  • 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 <workspace>

  • notes.md is unconditionally written by internal/workspace/create.go:179-180. Always at <wsPath>/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 <workspace>

  • 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 <archived> and path <archived> will work as long as those commands pass includeArchived=true (or expose an --all flag).
  • Severity: fine

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 <ws>, ctask path <ws>

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 <archived> 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 <shell> 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 <ws>

  • (no blocking or should-fix items — clean addition)

ctask path <ws>

  • (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 resolveOneinternal/workspace/query.go ResolveQuery)
  • should fix (cosmetic): includeArchived default is hardcoded in archive/resume, flag-controlled in info/delete/opencmd/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.