Adds the two new metadata fields specified for Phase 1 of v0.6 plus
the validation and defaulting helpers around them.
internal/workspace/metadata.go:
- CurrentMetaSchemaVersion = 1 constant.
- WorkspaceSection struct {Mode string} with omitempty.
- SchemaVersion int and Workspace WorkspaceSection fields added to
TaskMeta at the top of the struct. Both are omitempty so legacy
task.yaml files (no schema_version, no workspace block) round-trip
without acquiring these keys when an unrelated field is updated.
- EffectiveSchemaVersion(meta) — returns 1 for stored-value-0 legacy
workspaces; non-zero stored values are returned verbatim.
- ValidateSchemaVersion(slug, meta) — rejects stored values higher
than CurrentMetaSchemaVersion with the spec-mandated upgrade
message. Accepts 0 (legacy missing).
- ValidateWorkspaceMode(slug, meta) — rejects modes other than ""
and "native". "adopted" is reserved for v0.7.
- ReadMeta now runs both validators after unmarshal. The validation
error includes the workspace slug (derived from task.yaml's slug
field, falling back to the directory basename when the file itself
is corrupt).
internal/workspace/create.go:
- workspace.Create stamps every new meta with
SchemaVersion: CurrentMetaSchemaVersion and Workspace.Mode: "native".
This is the ONLY write site for these fields in v0.6; resume,
archive, restore, and any other path that rewrites task.yaml MUST
NOT backfill them (the "no opportunistic schema writes" invariant).
internal/workspace/schema_test.go:
- 10 tests:
* new meta written by Create contains schema_version: 1 +
workspace.mode: native (both serialization and round-trip)
* legacy meta without these fields loads with stored value 0 / ""
and EffectiveSchemaVersion returns 1
* task.yaml with schema_version: 2 is rejected with upgrade message
* task.yaml with workspace.mode: adopted is rejected
* the no-opportunistic-writes invariant is pinned for both WriteMeta
and WriteMetaLocked: a legacy file rewritten with an updated
UpdatedAt does NOT acquire schema_version or workspace: keys
* ValidateSchemaVersion accepts {0, 1}; ValidateWorkspaceMode
accepts {"", "native"}
- 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.
Workspace directory names (YYYY-MM-DD_slug) and the YYYYMMDD-HHMMSS ID
field now use local time so users see their wall-clock date rather
than UTC — the prior behavior caused evening-EST creations to appear
under tomorrow's date for several hours every day. ctask info's
Created/Updated/Archived lines also convert to local for display.
Stored timestamps in task.yaml, session logs, the lease, the manifest,
and the session summary all continue to use UTC. Only user-facing
surfaces change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When CTASK_PROJECT_ROOT is unset, SearchRoots now appends
\$CTASK_ROOT/projects/ so that projects created under the default
category are discoverable from any shell without per-session env var
setup. Dedupe in scanAllRoots prevents double-counting workspaces that
are reachable both via the depth-2 scan under CTASK_ROOT and via the
new explicit search root. Adds a named regression test asserting no
duplicates appear in either ResolveQuery or ListWorkspaces results.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ctask new --project now creates an empty subdirectory named after the
final suffixed slug inside the workspace root, and sets meta.LaunchDir
to that slug. Task workspaces are unchanged. Seeds do not target the
subdirectory — the user populates it with their project code and their
own CLAUDE.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolves a relative launch_dir into the absolute directory the child
process should cd into. Returns an error for absolute paths and paths
that escape the workspace via .. traversal. Returns (wsDir, warning, nil)
on os.IsNotExist or target-is-a-file so the caller prints a warning and
falls back. Non-IsNotExist stat errors (permission, invalid name, I/O)
propagate as real errors rather than being masked as warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rewrites scanWorkspaces to handle both category layout
(root/<category>/<workspace>/task.yaml) and flat layout
(root/<workspace>/task.yaml used under CTASK_PROJECT_ROOT).
Adds scanAllRoots to walk multiple roots with absolute-path dedupe.
ResolveQuery, ListWorkspaces, and MostRecentActive now accept []string.
QueryResult gains a Root field so callers can render display paths and
session env vars relative to the originating root.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extracts the cross-type "most recently updated active workspace"
selector that previously lived inline in cmd/last and cmd/delete.
The helper takes only a root path and returns (nil, nil) when
nothing matches, which keeps the call sites trivial.
Eight focused tests cover:
- tasks-only fixture
- projects-only fixture
- project most-recent vs older task
- task most-recent vs older project
- legacy v0.2 workspace (no Type field) winning, treated as task
- newest workspace archived, older active project wins
- empty root returns (nil, nil)
- all archived returns (nil, nil)
A new createTestWorkspaceFull helper takes an explicit UpdatedAt
timestamp so the "which is newest" assertions don't depend on
wall-clock ordering.
cmd/last and cmd/delete are migrated onto the helper in the next
commit.
ListOpts now exposes a Type string field (TypeAny / TypeTask /
TypeProject). TypeAny is the new way to express "both tasks and
projects" in a single ListWorkspaces call -- which the next two
commits will use to consolidate cmd/last and cmd/delete onto a
single helper, and to make 'ctask list' default to showing both
types.
Invalid Type values now return an explicit error from
ListWorkspaces (defensive against typos in callers).
cmd/list, cmd/last, and cmd/delete are migrated to the new field.
External behavior is unchanged in this commit; the cleanup of
ctask list semantics happens in a follow-up commit so the diff
stays reviewable.
ListOpts gains a Projects bool that filters by EffectiveType.
Default behavior (Projects: false) now returns tasks only --
this is a deliberate semantic change that supports the new
'ctask list' (tasks) vs 'ctask list --projects' (projects)
spec.
The change silently regresses two cmd-level callers that scan
for "the most recently updated workspace": cmd/last.go (used by
'ctask last') and cmd/delete.go (used to print the "this was
your most recent workspace" note). Both are fixed by unioning a
tasks-scan with a projects-scan, so 'last' and 'delete' continue
to consider both types.
Test helper createTestWorkspaceTyped allows setting an explicit
type (or "" to simulate a v0.2 workspace with no type field).
EnsureGitignore writes a minimal .gitignore (.ctask/ +
logs/sessions.log) iff one does not already exist. This is the
file-system half of the v0.3 seed-wins rule for .gitignore: if
either the general or project seed copied a .gitignore into the
workspace, EnsureGitignore must be a no-op.
GitAvailable + RunGitInit wrap exec.LookPath("git") and `git init`
respectively, so the caller in cmd/new.go can decide whether to
print the informational note when git is missing.
Tests cover:
- missing -> created with the minimal body
- present -> preserved verbatim
- integration: general seed .gitignore preserved end-to-end
- integration: project seed .gitignore preserved end-to-end
- integration: no seed -> minimal body created
When SkipCategoryDir is true, Create places the workspace directly
under Root and does not append a Category subdirectory. This is the
mechanism that prevents doubled paths like
~/projects/projects/<slug> when the user sets CTASK_PROJECT_ROOT
without an explicit -c flag. The Category value is still recorded
on TaskMeta so list/info/filter still work.
CreateOpts gains IsProject, SeedDir, and ProjectSeedDir. The new
seeding flow is:
1. write built-in defaults (task or project CLAUDE.md + notes.md)
2. apply general seed if SeedDir != "" (overlay)
3. apply project seed if IsProject && ProjectSeedDir != "" (overlay)
4. write task.yaml (last, so seeds can never inject metadata)
The obsolete TestCreateDoesNotOverwriteSeedFiles test is removed:
its v0.2 invariant ("don't overwrite existing files") no longer
holds because v0.3 always lays defaults onto a fresh dir and seeds
are expected to overwrite.
TaskMeta now records type: task or type: project. EffectiveType
returns 'task' for missing/empty/unknown values and for nil meta,
so v0.2 workspaces continue to read as tasks without any
migration. The field is placed between Category and Mode in the
YAML output. Tests cover the round-trip and the legacy
no-type-field case.
Create function produces full workspace layout (task.yaml, CLAUDE.md, notes.md, context/, output/, logs/). Seed files only written if missing. Collision suffixing tested.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Slugify, AutoTitle, DirName, ResolveDir with -2, -3 suffixing. Full test coverage.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
TaskMeta struct matching task.yaml schema exactly. Tests for roundtrip, field presence, and archive state.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>