- 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.
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 = wsDirstores the absolute filesystem path (e.g.C:\Users\Warren\ai-workspaces\general\2026-04-24_promptvolleyon Windows). Written to YAML byWriteMeta/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 isQueryResult.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:
ignoredPathchecksstrings.HasPrefix(relPath, ".ctask/") || strings.HasPrefix(relPath, ".ctask\\")andrelPath == "logs/sessions.log" || relPath == "logs\\sessions.log". Manifest paths reach this function only afterfilepath.ToSlash(rel)atmanifest.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.PathSeparatorandfilepath.Separatorare used correctly where the platform separator is needed (e.g.internal/workspace/launchdir.go:48traversal check,internal/workspace/create.go:189display 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)thenstrings.ReplaceAll(rel, string(filepath.Separator), "/"). Used in error messages andcmd/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,defaultSeedDirat:138-149,expandPathat:152-162 - Current behavior:
ResolveRootdefaults tofilepath.Join(home, "ai-workspaces")(works on Linux:/home/<user>/ai-workspaces).ResolveSeedDir/ResolveProjectSeedDirswitch onruntime.GOOS:%APPDATA%\ctask\seed[-project]on Windows,~/.config/ctask/seed[-project]on Unix.searchRootKey/samePathlower-case on Windows for case-insensitive dedup, leave alone on Linux.expandPathhandles~/and converts to absolute viafilepath.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 ininternal/config/config.go(and tests). Commands always go viaconfig.Resolve*. - Linux/feature impact: Fine.
- Severity: fine
Finding: No registry, syscall, or PowerShell invocations from core logic
- Files: No
golang.org/x/sys/windowsimports, noimport "C", no exec'ing ofpowershell/cmdfrom 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.IDis timestamp-derived, never compared) - Current behavior:
QueryResult.Pathis always absolute (constructed viafilepath.Joinrooted at an absolute search root,query.go:122,142,147-150).TaskMeta.IDis created increate.go:70as a timestamp string and is never used for resolution. The user-facing identifier isTaskMeta.Slug. - Impact on planned features:
path <ws>is a one-liner againstQueryResult.Path.notes <ws>andrestore <ws>need only thePathandMetaalready present inQueryResult. 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.Slugis the bare slug after stripping date and any numeric collision suffix. Resolution matches against (1) full directory basename, (2) exactMeta.Slug, (3) case-insensitive substring ofMeta.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 viafilepath.*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 usingRelativePathfor/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)— hardcodedcmd/resume.go:50—resolveOne(roots, query, false)— hardcodedcmd/info.go:23,29—--all/-aflag (infoAll), default falsecmd/delete.go:30,36—--all/-aflag (deleteAll), default falsecmd/open.go:28,35—--all/-aflag (openAll), default falsecmd/last.go— usesworkspace.MostRecentActive(roots)(purpose-specific helper)cmd/new.go— does not resolve; creates fresh
- Central path:
- Current behavior: Every command that takes a query goes through
resolveOne, which goes throughResolveQuery. All callers passconfig.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=falsewhile others gate it behind--all. New commands (restore,notes,path) can plug straight in.restorewill needincludeArchived=true(or to set the resolver to true and then verifyMeta.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 viaWriteMetaLocked. No directory move, no lease/manifest cleanup, no cache invalidation. Active-session check at:35-49reads the lease and refuses (or prompts) if fresh. - Impact on planned features:
restoreis mechanically symmetric: clearStatus(or set to"active"), clearArchivedAt, bumpUpdatedAt,WriteMetaLocked. Should refuse ifStatus != "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(scanWorkspacesignores status) - Current behavior:
scanWorkspacesreads everytask.yamlregardless of status. Status filtering is purely aResolveQuery/ListWorkspacesdecision based on theIncludeArchivedflag. - 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 passincludeArchived=trueor expose an--allflag. - 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 istabwriter-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-workspaceshelper. 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.exeon Windows;$SHELL→bashon Unix.ExecAgent→exec.LookPath+exec.Command(path). No shell wrapping. PATHEXT resolution is automatic on Windows.ExecShellinjectsfunction prompt {...}via-NoExit -Commandfor PowerShell,PROMPT=...$P$Genv var for cmd.exe,PS1for bash; all branches guarded byruntime.GOOS.BuildEnvListis the sole env-export mechanism (os.Environ()++ provided map).isStdinTerminalusesos.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.gosearches for.shfirst on non-Windows,.ps1first on Windows. The.shscript uses bash idioms but is invoked explicitly viabash <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,\nonly). - Current behavior: No literal
\r\nanywhere 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=lffor 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:
buildoutputsctask.exe.installanduninstallinvokepowershell -NoProfile -ExecutionPolicy Bypass -File scripts/.... Runningjust installon Linux fails (nopowershell). - 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.shto 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.exeand*.exeare ignored. A binary built on Linux asctaskis 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.GOOSchecks 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 tocmd.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
WorkspacePathand compared back; they never touch the filesystem. - Severity: fine — and ironically further evidence that
WorkspacePathis 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) flipsStatus,ArchivedAt,UpdatedAtonly. No locks, sentinel files, or directory moves to undo.restoreis a clean inverse:resolveOne(roots, q, true)→ reject ifStatus != "archived"→ clearStatus+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 toincludeArchived=trueforinfospecifically (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 todeleteandopenbut those have stronger reasons to default to active-only. - Severity: should fix — small UX/policy change.
ctask notes <workspace>
notes.mdis unconditionally written byinternal/workspace/create.go:179-180. Always at<wsPath>/notes.md. Edge cases: file deleted by user (treat as error, likecatof missing file), empty file (print empty, exit 0).- Severity: fine
ctask path <workspace>
QueryResult.Pathis already absolute. Implementation:resolveOnethenfmt.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.godoes no hand-rolled flag parsing. Cobra shipsGenBashCompletionV2,GenPowerShellCompletionWithDesc,GenZshCompletionV2,GenFishCompletion— and Cobra also auto-injects acompletionsubcommand in modern versions (rootCmd.CompletionOptions). Adding the command itself is essentially free. - The quality of completion depends on
ValidArgsFunctionhooks on commands that accept a workspace query (resume,open,info,archive,delete,last, plus the newrestore,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/--jsonmode comes in. TheValidArgsFunctioncan callworkspace.ListWorkspacesdirectly (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 everytask.yamlregardless of status. Filtering is at theResolveQuerylayer, controlled byincludeArchived. Sonotes <archived>andpath <archived>will work as long as those commands passincludeArchived=true(or expose an--allflag).- 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 <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 ininternal/session/manifest.go:27,35. - Tighten the
--alldefault policy acrossarchive/resume/delete/open/info(Section 3 inconsistency) once the new commands have set the precedent. - Add a
.gitattributescoveringinternal/seed/to normalize EOL on the seed sources.
Summary — blocking and should-fix items, grouped by feature
Linux portability (cross-compile + run)
- should fix:
justfilehas no Linux targets —justfile:11,18,23 - should fix: No POSIX install/uninstall script —
scripts/install.ps1,scripts/uninstall.ps1 - should fix:
.gitignoredoes not cover the Linuxctaskbinary —.gitignore:1-2 - should fix (cross-OS sharing):
WorkspacePathpersisted 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
infoAlldefault —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.goresolveOne→internal/workspace/query.goResolveQuery) - should fix (cosmetic):
includeArchiveddefault is hardcoded inarchive/resume, flag-controlled ininfo/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.