From 7f2c43d599600a284dfbc43463cbfe0017b3393e Mon Sep 17 00:00:00 2001 From: typebasedio Date: Thu, 14 May 2026 19:47:24 -0400 Subject: [PATCH 1/7] feat(v0.5.4): SessionStatus display-only helper Add internal/session.SessionStatus(wsDir) that derives a display-only view of the workspace session lease. Pure read of .ctask/session.json with no tmux invocation, no PID liveness, no lock acquisition, and no mutation of lease state. States: none | active | stale. Mode defaults to "direct" when a pre-v0.5.3 lease lacks the field. Malformed leases surface as stale with a diagnostic so the present-but-broken case stays visible instead of being silently classified as none. Reused for ctask info and ctask list in subsequent commits. Lifecycle code continues to use the existing primitives (ReadLease, IsFresh, InspectLease). --- internal/session/status.go | 94 +++++++++++++++ internal/session/status_test.go | 204 ++++++++++++++++++++++++++++++++ 2 files changed, 298 insertions(+) create mode 100644 internal/session/status.go create mode 100644 internal/session/status_test.go diff --git a/internal/session/status.go b/internal/session/status.go new file mode 100644 index 0000000..689bdc3 --- /dev/null +++ b/internal/session/status.go @@ -0,0 +1,94 @@ +package session + +import ( + "encoding/json" + "errors" + "os" + "time" +) + +// SessionState is the display-only classification surfaced by SessionStatus. +type SessionState string + +const ( + // SessionStateNone: no lease file is present. + SessionStateNone SessionState = "none" + // SessionStateActive: lease file is present and fresh (heartbeat within StaleLeaseAfter). + SessionStateActive SessionState = "active" + // SessionStateStale: lease file is present but stale, or present-and-malformed. + SessionStateStale SessionState = "stale" +) + +// Status is the derived display-only view of a workspace's session lease. +// +// It is intentionally narrower than InspectLease/LeaseState: callers that +// need behavioral decisions (adoption, dispatch) must keep using the +// existing primitives. Status exists for `ctask info` and `ctask list` to +// surface session visibility without touching tmux, PID liveness, or any +// lock state. +type Status struct { + State SessionState + Mode string // "direct" | "persistent" | "" when malformed + PID int + Hostname string + Diagnostic string // human-readable note for the malformed case; empty otherwise +} + +// SessionStatus returns the display-only session summary for wsDir. +// +// It performs only one file read (.ctask/session.json) and never invokes +// tmux, checks PID liveness, modifies lease state, acquires locks, or +// otherwise mutates the workspace. PID liveness is intentionally deferred +// to v0.6's lazy-cleanup redesign, where it will have behavioral +// consequences; building it display-only here would mean building it +// twice. +// +// Display-only contract: do NOT call SessionStatus from lifecycle or +// adoption code. The "missing Mode defaults to direct" rule and the +// malformed-lease "stale" classification are display choices, not +// behavioral truths. Use ReadLease / IsFresh / InspectLease for +// lifecycle decisions. +func SessionStatus(wsDir string) Status { + return statusAt(wsDir, time.Now()) +} + +// statusAt is the test entry point with an injected clock. Production +// code goes through SessionStatus. +func statusAt(wsDir string, now time.Time) Status { + data, err := os.ReadFile(LeasePath(wsDir)) + if errors.Is(err, os.ErrNotExist) { + return Status{State: SessionStateNone} + } + if err != nil { + return Status{ + State: SessionStateStale, + Diagnostic: "lease exists but could not be read", + } + } + var l Lease + if jsonErr := json.Unmarshal(data, &l); jsonErr != nil { + return Status{ + State: SessionStateStale, + Diagnostic: "lease exists but could not be read", + } + } + + state := SessionStateStale + if IsFresh(&l, now, StaleLeaseAfter) { + state = SessionStateActive + } + + // Pre-v0.5.3 leases predate the mode field; treat them as direct so + // `info` and `list` render a meaningful value rather than blank. + mode := l.Mode + if mode == "" { + mode = "direct" + } + + return Status{ + State: state, + Mode: mode, + PID: l.PID, + Hostname: l.Hostname, + } +} diff --git a/internal/session/status_test.go b/internal/session/status_test.go new file mode 100644 index 0000000..d4d9270 --- /dev/null +++ b/internal/session/status_test.go @@ -0,0 +1,204 @@ +package session + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +// writeLeaseAt is provided by lease_inspect_test.go in this package. + +func TestSessionStatusNone(t *testing.T) { + ws := t.TempDir() // no .ctask/session.json + + got := statusAt(ws, time.Now()) + + if got.State != SessionStateNone { + t.Errorf("State: got %q, want %q", got.State, SessionStateNone) + } + if got.Mode != "" { + t.Errorf("Mode: got %q, want empty", got.Mode) + } + if got.PID != 0 { + t.Errorf("PID: got %d, want 0", got.PID) + } + if got.Hostname != "" { + t.Errorf("Hostname: got %q, want empty", got.Hostname) + } + if got.Diagnostic != "" { + t.Errorf("Diagnostic: got %q, want empty", got.Diagnostic) + } +} + +func TestSessionStatusFreshLocal(t *testing.T) { + ws := t.TempDir() + now := time.Now().UTC().Truncate(time.Second) + writeLeaseAt(t, ws, &Lease{ + SessionID: "fakehost-1234-20260514120000", + PID: 1234, + Hostname: "fakehost", + Username: "tester", + Agent: "claude", + Mode: "persistent", + StartedAt: now, + LastHeartbeatAt: now, + Terminal: "test", + }) + + got := statusAt(ws, now.Add(10*time.Second)) // well within StaleLeaseAfter + + if got.State != SessionStateActive { + t.Errorf("State: got %q, want %q", got.State, SessionStateActive) + } + if got.Mode != "persistent" { + t.Errorf("Mode: got %q, want persistent", got.Mode) + } + if got.PID != 1234 { + t.Errorf("PID: got %d, want 1234", got.PID) + } + if got.Hostname != "fakehost" { + t.Errorf("Hostname: got %q, want fakehost", got.Hostname) + } + if got.Diagnostic != "" { + t.Errorf("Diagnostic: got %q, want empty", got.Diagnostic) + } +} + +func TestSessionStatusFreshRemote(t *testing.T) { + // SessionStatus is display-only; "remote" is just whatever hostname is + // recorded in the lease. The helper does not compare to currentHostname() + // — that comparison happens in the cmd display layer (info). + ws := t.TempDir() + now := time.Now().UTC().Truncate(time.Second) + writeLeaseAt(t, ws, &Lease{ + PID: 4242, + Hostname: "some-other-host", + Mode: "direct", + StartedAt: now, + LastHeartbeatAt: now, + }) + + got := statusAt(ws, now.Add(10*time.Second)) + + if got.State != SessionStateActive { + t.Errorf("State: got %q, want %q", got.State, SessionStateActive) + } + if got.Hostname != "some-other-host" { + t.Errorf("Hostname: got %q, want some-other-host", got.Hostname) + } + if got.Mode != "direct" { + t.Errorf("Mode: got %q, want direct", got.Mode) + } + if got.PID != 4242 { + t.Errorf("PID: got %d, want 4242", got.PID) + } +} + +func TestSessionStatusStale(t *testing.T) { + ws := t.TempDir() + heartbeat := time.Now().UTC().Add(-5 * time.Minute) + writeLeaseAt(t, ws, &Lease{ + PID: 999, + Hostname: "oldhost", + Mode: "persistent", + StartedAt: heartbeat, + LastHeartbeatAt: heartbeat, + }) + + got := statusAt(ws, time.Now()) + + if got.State != SessionStateStale { + t.Errorf("State: got %q, want %q", got.State, SessionStateStale) + } + if got.Mode != "persistent" { + t.Errorf("Mode: got %q, want persistent", got.Mode) + } + if got.PID != 999 { + t.Errorf("PID: got %d, want 999", got.PID) + } + if got.Hostname != "oldhost" { + t.Errorf("Hostname: got %q, want oldhost", got.Hostname) + } + if got.Diagnostic != "" { + t.Errorf("Diagnostic: got %q, want empty", got.Diagnostic) + } +} + +func TestSessionStatusMalformed(t *testing.T) { + ws := t.TempDir() + if err := os.MkdirAll(filepath.Join(ws, ".ctask"), 0755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(LeasePath(ws), []byte("{not valid json"), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + got := statusAt(ws, time.Now()) + + if got.State != SessionStateStale { + t.Errorf("State: got %q, want %q", got.State, SessionStateStale) + } + if got.Mode != "" { + t.Errorf("Mode: got %q, want empty (malformed lease)", got.Mode) + } + if got.Diagnostic == "" { + t.Errorf("Diagnostic: want non-empty for malformed lease, got empty") + } + // PID/Hostname should be zero values when the lease can't be parsed — + // no partial info from a corrupt source. + if got.PID != 0 { + t.Errorf("PID: got %d, want 0 (malformed lease)", got.PID) + } + if got.Hostname != "" { + t.Errorf("Hostname: got %q, want empty (malformed lease)", got.Hostname) + } +} + +func TestSessionStatusMissingMode(t *testing.T) { + // Leases written by pre-v0.5.3 ctask have no `mode` field. SessionStatus + // must default to "direct" so the display layer does not show a blank + // session mode for older workspaces. + ws := t.TempDir() + if err := os.MkdirAll(filepath.Join(ws, ".ctask"), 0755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + now := time.Now().UTC().Truncate(time.Second) + // Write a lease JSON that omits the `mode` field entirely. + leaseJSON := []byte(`{ + "session_id": "fakehost-1234-20260514120000", + "pid": 1234, + "hostname": "fakehost", + "username": "tester", + "agent": "claude", + "started_at": "` + now.Format(time.RFC3339) + `", + "last_heartbeat_at": "` + now.Format(time.RFC3339) + `", + "terminal": "test" +}`) + if err := os.WriteFile(LeasePath(ws), leaseJSON, 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + got := statusAt(ws, now.Add(10*time.Second)) + + if got.State != SessionStateActive { + t.Errorf("State: got %q, want %q", got.State, SessionStateActive) + } + if got.Mode != "direct" { + t.Errorf("Mode: got %q, want direct (default for missing-mode lease)", got.Mode) + } + if got.PID != 1234 { + t.Errorf("PID: got %d, want 1234", got.PID) + } +} + +func TestSessionStatusUsesProductionEntrypoint(t *testing.T) { + // Sanity check that the production entry point (which uses time.Now) + // returns SessionStateNone when the lease is missing — guards against + // a refactor that loses the SessionStatus -> statusAt forwarding. + ws := t.TempDir() + got := SessionStatus(ws) + if got.State != SessionStateNone { + t.Errorf("SessionStatus on empty workspace: got %q, want %q", got.State, SessionStateNone) + } +} From e0e9cd764e19af1ebaf6d79dd11a81462691ca5d Mon Sep 17 00:00:00 2001 From: typebasedio Date: Thu, 14 May 2026 19:51:21 -0400 Subject: [PATCH 2/7] feat(v0.5.4): info Session block Add a Session block to ctask info output, surfacing the workspace session lease state derived from SessionStatus. Inserted between Path and any launch-dir fields so the new content is visually distinct from both blocks. Format: state on the header line, then indented Mode / Owner / Attach / Note rows aligned at column 14. The Owner line omits the hostname when it matches the local machine. The Attach hint surfaces only for active+persistent sessions and uses invocationName() so the suggested command reflects the user's actual invocation. Malformed leases render as stale with a single-line diagnostic and no Mode/Owner/Attach rows so we never display fields parsed from a broken file. Exposes session.CurrentHostname() so the cmd layer has a single source of truth for the local-vs-remote hostname check. --- cmd/info.go | 60 ++++++++++ cmd/info_session_test.go | 234 ++++++++++++++++++++++++++++++++++++++ internal/session/lease.go | 6 + 3 files changed, 300 insertions(+) create mode 100644 cmd/info_session_test.go diff --git a/cmd/info.go b/cmd/info.go index 27bb2d4..cc2579d 100644 --- a/cmd/info.go +++ b/cmd/info.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" "github.com/warrenronsiek/ctask/internal/config" + "github.com/warrenronsiek/ctask/internal/session" ) var infoCmd = &cobra.Command{ @@ -44,6 +45,8 @@ func runInfo(cmd *cobra.Command, args []string) error { fmt.Printf("Updated: %s\n", m.UpdatedAt.Local().Format("2006-01-02 15:04:05")) fmt.Printf("Path: %s\n", ws.Path) + printSessionBlock(ws.Path, m.Slug) + if m.LaunchDir != "" { // Per spec amendment: stat the expected path directly instead of // inferring existence from ResolveLaunch's fallback behavior. info @@ -81,3 +84,60 @@ func runInfo(cmd *cobra.Command, args []string) error { return nil } + +// printSessionBlock renders the v0.5.4 Session block for `ctask info`. +// +// Layout (values align at column 14 across the block): +// +// Session: +// Mode: (omitted when malformed) +// Owner: [host / ]pid N (Active; "Last owner:" when stale) +// Attach: attach (Active + persistent only) +// Note: (stale or malformed only) +// +// The hostname is omitted from the Owner/Last-owner line when it matches +// the local machine, matching the spec's "omit when local" rule. +// +// All command-form text uses invocationName() so the hint reflects how +// the user actually invoked the binary (./ctask vs ctask.exe vs ctask). +// SessionStatus itself stays neutral and never builds a command string. +func printSessionBlock(wsPath, slug string) { + s := session.SessionStatus(wsPath) + fmt.Println() + + switch s.State { + case session.SessionStateNone: + fmt.Println("Session: none") + return + case session.SessionStateStale: + // Malformed lease: SessionStatus reports state=stale, mode empty, + // diagnostic set. Render only the Note so we don't pretend to + // know the mode/owner when the file couldn't be parsed. + if s.Diagnostic != "" { + fmt.Println("Session: stale") + fmt.Printf(" Note: %s\n", s.Diagnostic) + return + } + } + + fmt.Printf("Session: %s\n", s.State) + fmt.Printf(" Mode: %s\n", s.Mode) + + ownerValue := fmt.Sprintf("pid %d", s.PID) + if s.Hostname != "" && s.Hostname != session.CurrentHostname() { + ownerValue = s.Hostname + " / " + ownerValue + } + if s.State == session.SessionStateActive { + fmt.Printf(" Owner: %s\n", ownerValue) + } else { + fmt.Printf(" Last owner: %s\n", ownerValue) + } + + if s.State == session.SessionStateActive && s.Mode == "persistent" { + fmt.Printf(" Attach: %s attach %s\n", invocationName(), slug) + } + + if s.State == session.SessionStateStale { + fmt.Println(" Note: lease expired; workspace may be available") + } +} diff --git a/cmd/info_session_test.go b/cmd/info_session_test.go new file mode 100644 index 0000000..ea487a7 --- /dev/null +++ b/cmd/info_session_test.go @@ -0,0 +1,234 @@ +package cmd + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/warrenronsiek/ctask/internal/session" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +// withInvocationNameInfo pins invocationName() to "ctask" for the duration +// of the test so attach-hint substring assertions are stable across hosts. +// Mirrors the helper in persistent_test.go but kept local to avoid +// cross-test coupling. +func withInvocationNameInfo(t *testing.T, name string) { + t.Helper() + prev := invocationNameOverride + invocationNameOverride = name + t.Cleanup(func() { invocationNameOverride = prev }) +} + +// makeInfoSessionWorkspace writes a workspace under root with the given +// slug and returns the workspace directory path. The workspace metadata +// is minimal — info only needs a parsable task.yaml. +func makeInfoSessionWorkspace(t *testing.T, root, slug string) string { + t.Helper() + dirName := "2026-05-14_" + slug + wsDir := filepath.Join(root, "general", dirName) + if err := os.MkdirAll(wsDir, 0755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + now := time.Now().UTC().Truncate(time.Second) + meta := &workspace.TaskMeta{ + ID: "t", Slug: slug, Title: slug, + CreatedAt: now, UpdatedAt: now, + Status: "active", Category: "general", Type: "task", + Mode: "local", Agent: "claude", + } + if err := workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta); err != nil { + t.Fatalf("WriteMeta: %v", err) + } + return wsDir +} + +func TestInfoNoSession(t *testing.T) { + root := t.TempDir() + makeInfoSessionWorkspace(t, root, "no-sess") + + out, err := runInfoCapture(t, root, "no-sess") + if err != nil { + t.Fatalf("runInfo: %v", err) + } + if !strings.Contains(out, "Session: none") { + t.Errorf("expected 'Session: none' in output:\n%s", out) + } + for _, mustNot := range []string{" Mode:", " Owner:", " Attach:", " Note:"} { + if strings.Contains(out, mustNot) { + t.Errorf("none-state info should not contain %q:\n%s", mustNot, out) + } + } +} + +func TestInfoShowsActivePersistentSessionWithAttachHint(t *testing.T) { + withInvocationNameInfo(t, "ctask") + root := t.TempDir() + wsDir := makeInfoSessionWorkspace(t, root, "active-persist") + + now := time.Now().UTC().Truncate(time.Second) + writeLeaseAtForCmdTest(t, wsDir, &session.Lease{ + PID: 12345, + Hostname: session.CurrentHostname(), + Mode: "persistent", + StartedAt: now, + LastHeartbeatAt: now, + }) + + out, err := runInfoCapture(t, root, "active-persist") + if err != nil { + t.Fatalf("runInfo: %v", err) + } + for _, want := range []string{ + "Session: active", + "Mode: persistent", + "Owner: pid 12345", // local host -> hostname omitted + "Attach: ctask attach active-persist", + } { + if !strings.Contains(out, want) { + t.Errorf("expected %q in output:\n%s", want, out) + } + } +} + +func TestInfoShowsActiveDirectSessionWithoutAttachHint(t *testing.T) { + withInvocationNameInfo(t, "ctask") + root := t.TempDir() + wsDir := makeInfoSessionWorkspace(t, root, "active-direct") + + now := time.Now().UTC().Truncate(time.Second) + writeLeaseAtForCmdTest(t, wsDir, &session.Lease{ + PID: 8888, + Hostname: session.CurrentHostname(), + Mode: "direct", + StartedAt: now, + LastHeartbeatAt: now, + }) + + out, err := runInfoCapture(t, root, "active-direct") + if err != nil { + t.Fatalf("runInfo: %v", err) + } + if !strings.Contains(out, "Session: active") { + t.Errorf("expected 'Session: active' in output:\n%s", out) + } + if !strings.Contains(out, "Mode: direct") { + t.Errorf("expected 'Mode: direct' in output:\n%s", out) + } + if strings.Contains(out, "Attach:") { + t.Errorf("direct active session must NOT show Attach hint:\n%s", out) + } +} + +func TestInfoShowsRemoteHostnameInOwnerLine(t *testing.T) { + withInvocationNameInfo(t, "ctask") + root := t.TempDir() + wsDir := makeInfoSessionWorkspace(t, root, "remote-active") + + other := "some-other-host-not-this-one" + if other == session.CurrentHostname() { + other = "different-" + other + } + now := time.Now().UTC().Truncate(time.Second) + writeLeaseAtForCmdTest(t, wsDir, &session.Lease{ + PID: 7777, + Hostname: other, + Mode: "persistent", + StartedAt: now, + LastHeartbeatAt: now, + }) + + out, err := runInfoCapture(t, root, "remote-active") + if err != nil { + t.Fatalf("runInfo: %v", err) + } + wantOwner := "Owner: " + other + " / pid 7777" + if !strings.Contains(out, wantOwner) { + t.Errorf("expected %q in output:\n%s", wantOwner, out) + } +} + +func TestInfoShowsStaleSessionWithLastOwner(t *testing.T) { + root := t.TempDir() + wsDir := makeInfoSessionWorkspace(t, root, "stale-sess") + + heartbeat := time.Now().UTC().Add(-10 * time.Minute) + writeLeaseAtForCmdTest(t, wsDir, &session.Lease{ + PID: 4242, + Hostname: session.CurrentHostname(), + Mode: "direct", + StartedAt: heartbeat, + LastHeartbeatAt: heartbeat, + }) + + out, err := runInfoCapture(t, root, "stale-sess") + if err != nil { + t.Fatalf("runInfo: %v", err) + } + for _, want := range []string{ + "Session: stale", + "Mode: direct", + "Last owner: pid 4242", + "Note: lease expired; workspace may be available", + } { + if !strings.Contains(out, want) { + t.Errorf("expected %q in output:\n%s", want, out) + } + } + if strings.Contains(out, "Attach:") { + t.Errorf("stale session must NOT show Attach hint:\n%s", out) + } +} + +func TestInfoShowsMalformedLeaseAsStaleWithDiagnostic(t *testing.T) { + root := t.TempDir() + wsDir := makeInfoSessionWorkspace(t, root, "broken-lease") + + if err := os.MkdirAll(filepath.Join(wsDir, ".ctask"), 0755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(session.LeasePath(wsDir), []byte("{not json"), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + out, err := runInfoCapture(t, root, "broken-lease") + if err != nil { + t.Fatalf("runInfo: %v", err) + } + if !strings.Contains(out, "Session: stale") { + t.Errorf("malformed-lease info should show 'Session: stale':\n%s", out) + } + if !strings.Contains(out, "Note: lease exists but could not be read") { + t.Errorf("malformed-lease info should surface the diagnostic:\n%s", out) + } + // Mode/Owner/Attach are deliberately suppressed for the malformed case + // — we don't have a parsed lease to read those values from. The + // indented " " prefix scopes the assertion to the session block so + // we don't false-positive on the workspace-metadata "Mode: local" + // header line. + for _, mustNot := range []string{" Mode:", " Owner:", " Last owner:", " Attach:"} { + if strings.Contains(out, mustNot) { + t.Errorf("malformed-lease info should not contain %q:\n%s", mustNot, out) + } + } +} + +// writeLeaseAtForCmdTest writes a Lease as JSON to wsDir's lease path. +// Local to the cmd package — internal/session has its own writeLeaseAt +// that we cannot reach across packages. +func writeLeaseAtForCmdTest(t *testing.T, wsDir string, l *session.Lease) { + t.Helper() + if err := os.MkdirAll(filepath.Join(wsDir, ".ctask"), 0755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + data, err := json.MarshalIndent(l, "", " ") + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if err := os.WriteFile(session.LeasePath(wsDir), data, 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } +} diff --git a/internal/session/lease.go b/internal/session/lease.go index 4da4e83..b12efa2 100644 --- a/internal/session/lease.go +++ b/internal/session/lease.go @@ -81,6 +81,12 @@ func currentHostname() string { return h } +// CurrentHostname is the exported form of currentHostname for callers in +// cmd/ that need to compare a lease's recorded hostname against the local +// machine (e.g., info's Owner-line "omit when local" rule). Keeps a +// single source of truth for the unknown-fallback semantics. +func CurrentHostname() string { return currentHostname() } + // currentTerminal is a best-effort terminal identifier based on common env vars. // Returns "unknown" if none are set. func currentTerminal() string { From 0c8076aba91fbddf224651fedae3314e3a967943 Mon Sep 17 00:00:00 2001 From: typebasedio Date: Thu, 14 May 2026 19:52:52 -0400 Subject: [PATCH 3/7] feat(v0.5.4): list SESSION column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a SESSION column to ctask list output, inserted to the right of STATUS per spec. Values: "direct", "persistent", "stale", or em dash for no session. Populated by SessionStatus, so each workspace adds at most one short lease-file read — negligible for typical workspace counts. Archived workspaces always render as the em dash regardless of any lease file present. The spec calls this a display simplification, not a lifecycle invariant: ctask info still surfaces the raw session state on archived workspaces because info is the diagnostic command. ctask list --names is unchanged: one basename per line, no header, no SESSION column. Verified by a regression test that asserts every emitted line is bare-basename whitespace-free and contains none of the SESSION tokens. --- cmd/list.go | 44 ++++++++- cmd/list_session_test.go | 193 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 cmd/list_session_test.go diff --git a/cmd/list.go b/cmd/list.go index cc348d3..e888ac2 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" "github.com/warrenronsiek/ctask/internal/config" + "github.com/warrenronsiek/ctask/internal/session" "github.com/warrenronsiek/ctask/internal/workspace" ) @@ -100,8 +101,9 @@ func runList(cmd *cobra.Command, args []string) error { date = dirName[:10] } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", ws.Meta.Status, + sessionColumn(ws.Path, ws.Meta.Status), workspace.EffectiveType(ws.Meta), ws.Meta.Mode, ws.Meta.Category, @@ -113,3 +115,43 @@ func runList(cmd *cobra.Command, args []string) error { return nil } + +// noSessionDisplay is the placeholder shown in the SESSION column when +// no session lease is present (or for archived workspaces, which are +// always rendered as no-session for display simplicity per v0.5.4 spec). +// Em dash matches the spec's example output. +const noSessionDisplay = "—" // — + +// sessionColumn returns the SESSION column value for ctask list. +// +// Archived workspaces always render as the em-dash placeholder — the +// spec calls this a display simplification, not a lifecycle invariant. +// Archive guards against active sessions, but a crash or manual file +// manipulation could theoretically leave a lease behind; ctask info +// will surface that diagnostic, ctask list will not. +// +// Active workspaces map SessionStatus to a single token: +// - state=none -> em dash +// - state=stale -> "stale" +// - state=active -> the lease's mode ("direct" or "persistent") +// +// SessionStatus reads only the lease file — no tmux invocation, no PID +// liveness — so this adds at most one short file read per workspace. +func sessionColumn(wsPath, wsStatus string) string { + if wsStatus == "archived" { + return noSessionDisplay + } + s := session.SessionStatus(wsPath) + switch s.State { + case session.SessionStateNone: + return noSessionDisplay + case session.SessionStateStale: + return "stale" + case session.SessionStateActive: + if s.Mode == "" { + return "active" + } + return s.Mode + } + return noSessionDisplay +} diff --git a/cmd/list_session_test.go b/cmd/list_session_test.go new file mode 100644 index 0000000..1ef27a3 --- /dev/null +++ b/cmd/list_session_test.go @@ -0,0 +1,193 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/warrenronsiek/ctask/internal/session" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +// makeListSessionWorkspace writes a workspace beneath root and returns +// the workspace directory. Mirrors makeInfoSessionWorkspace but allows +// the caller to control category, status, and slug for list-fixture +// scenarios. +func makeListSessionWorkspace(t *testing.T, root, category, dirName, slug, status string) string { + t.Helper() + wsDir := filepath.Join(root, category, dirName) + if err := os.MkdirAll(wsDir, 0755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + now := time.Now().UTC().Truncate(time.Second) + meta := &workspace.TaskMeta{ + ID: "t", Slug: slug, Title: slug, + CreatedAt: now, UpdatedAt: now, + Status: status, Category: category, Type: "task", + Mode: "local", Agent: "claude", + } + if status == "archived" { + meta.ArchivedAt = &now + } + if err := workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta); err != nil { + t.Fatalf("WriteMeta: %v", err) + } + return wsDir +} + +func TestListSessionColumnShowsModeAndStaleAndDash(t *testing.T) { + root := t.TempDir() + now := time.Now().UTC().Truncate(time.Second) + host := session.CurrentHostname() + + persistWS := makeListSessionWorkspace(t, root, "general", "2026-05-14_persist-ws", "persist-ws", "active") + directWS := makeListSessionWorkspace(t, root, "general", "2026-05-13_direct-ws", "direct-ws", "active") + staleWS := makeListSessionWorkspace(t, root, "general", "2026-05-12_stale-ws", "stale-ws", "active") + makeListSessionWorkspace(t, root, "general", "2026-05-11_idle-ws", "idle-ws", "active") + + writeLeaseAtForCmdTest(t, persistWS, &session.Lease{ + PID: 1, Hostname: host, Mode: "persistent", + StartedAt: now, LastHeartbeatAt: now, + }) + writeLeaseAtForCmdTest(t, directWS, &session.Lease{ + PID: 2, Hostname: host, Mode: "direct", + StartedAt: now, LastHeartbeatAt: now, + }) + heartbeat := now.Add(-10 * time.Minute) + writeLeaseAtForCmdTest(t, staleWS, &session.Lease{ + PID: 3, Hostname: host, Mode: "direct", + StartedAt: heartbeat, LastHeartbeatAt: heartbeat, + }) + + out, _, err := runListCapture(t, root, false, false, false) + if err != nil { + t.Fatalf("runList: %v", err) + } + + type expectation struct { + slug string + session string + } + for _, exp := range []expectation{ + {"persist-ws", "persistent"}, + {"direct-ws", "direct"}, + {"stale-ws", "stale"}, + {"idle-ws", noSessionDisplay}, + } { + line := findLineContaining(out, exp.slug) + if line == "" { + t.Errorf("output missing line for slug %q:\n%s", exp.slug, out) + continue + } + if !columnTokenPresent(line, exp.session) { + t.Errorf("slug %q line should contain session token %q, got line %q", exp.slug, exp.session, line) + } + } +} + +func TestListSessionColumnArchivedAlwaysDash(t *testing.T) { + // Archived workspaces show the em-dash placeholder in the SESSION + // column even if a lease file exists. ctask info still surfaces the + // raw lease state for diagnostic purposes; list keeps the simpler + // view. + root := t.TempDir() + now := time.Now().UTC().Truncate(time.Second) + + archivedWS := makeListSessionWorkspace(t, root, "general", "2026-05-10_archived-ws", "archived-ws", "archived") + writeLeaseAtForCmdTest(t, archivedWS, &session.Lease{ + PID: 99, Hostname: session.CurrentHostname(), Mode: "persistent", + StartedAt: now, LastHeartbeatAt: now, + }) + + out, _, err := runListCapture(t, root, true, false, false) + if err != nil { + t.Fatalf("runList --all: %v", err) + } + + line := findLineContaining(out, "archived-ws") + if line == "" { + t.Fatalf("output missing line for archived-ws:\n%s", out) + } + if !columnTokenPresent(line, noSessionDisplay) { + t.Errorf("archived-ws line should contain em-dash session token, got %q", line) + } + if columnTokenPresent(line, "persistent") { + t.Errorf("archived-ws line must NOT show 'persistent' even with a lease file present, got %q", line) + } +} + +func TestListNamesUnchangedHasNoSessionColumn(t *testing.T) { + // Spec invariant: ctask list --names is machine-readable, one + // basename per line, no session column. Adding the SESSION column + // to the formatted view must not leak into --names output. + root := t.TempDir() + now := time.Now().UTC().Truncate(time.Second) + host := session.CurrentHostname() + + persistWS := makeListSessionWorkspace(t, root, "general", "2026-05-14_persist-ws", "persist-ws", "active") + writeLeaseAtForCmdTest(t, persistWS, &session.Lease{ + PID: 1, Hostname: host, Mode: "persistent", + StartedAt: now, LastHeartbeatAt: now, + }) + makeListSessionWorkspace(t, root, "general", "2026-05-13_idle-ws", "idle-ws", "active") + + out, err := runListNamesCapture(t, root, false) + if err != nil { + t.Fatalf("runList --names: %v", err) + } + + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + for _, line := range lines { + if line == "" { + continue + } + // Each line must be a bare basename, no whitespace, no session token. + if strings.ContainsAny(line, " \t") { + t.Errorf("--names output line must not contain whitespace, got %q", line) + } + for _, tok := range []string{"persistent", "direct", "stale", noSessionDisplay} { + if strings.Contains(line, tok) { + t.Errorf("--names output line %q must not contain session token %q", line, tok) + } + } + } + + // And the basenames we expect must still be there. + for _, want := range []string{"2026-05-14_persist-ws", "2026-05-13_idle-ws"} { + found := false + for _, line := range lines { + if line == want { + found = true + break + } + } + if !found { + t.Errorf("--names output missing %q:\n%s", want, out) + } + } +} + +// findLineContaining returns the first line of out that contains substr, +// or "" if no such line exists. +func findLineContaining(out, substr string) string { + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, substr) { + return line + } + } + return "" +} + +// columnTokenPresent reports whether token appears in line as a +// whitespace-separated field. This avoids substring false positives like +// "stale" matching inside another token. +func columnTokenPresent(line, token string) bool { + for _, f := range strings.Fields(line) { + if f == token { + return true + } + } + return false +} From 0fb8de697b6e7c03a2dfdf2cddaa485be6063f95 Mon Sep 17 00:00:00 2001 From: typebasedio Date: Thu, 14 May 2026 19:56:05 -0400 Subject: [PATCH 4/7] polish(v0.5.4): invocation-name audit + targeted regression tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit walked every cmd/ and internal/ file that produces user-facing output. All command-form hints (text the user is meant to type back) were already routed through invocationName() in v0.5.3 — the audit is a verification pass, not a code rewrite. Additions: - Extract formatResumeRestoreHint and formatDirectModeTmuxHint as testable string-only helpers. Production paths are unchanged behaviorally; the helpers exist purely so the audit can pin down the format strings without simulating tmux or stderr capture. - Two new tests pinning the invocation name to a non-canonical value ("my-bin"). The pre-existing tests already protect these surfaces but pin the name to "ctask", so they cannot detect a regression that hard-codes "ctask" inside the format string. The new tests flush that out for the resume restore hint and the Layer-1 active-session attach hint. - Drop the duplicated withInvocationName helper accidentally added in the info-session tests; reuse the canonical helper from persistent_test.go. Product-identity references ("ctask persistent mode requires tmux", the SSH-remote `ssh -t ctask ` hint, doctor's "[ctask]" diagnostic prefix, root-command Use:/Long:) deliberately remain literal per spec §2. --- cmd/entry.go | 10 ++++++- cmd/info_session_test.go | 17 +++--------- cmd/invocation_audit_test.go | 51 ++++++++++++++++++++++++++++++++++++ cmd/resume.go | 20 +++++++++++--- 4 files changed, 81 insertions(+), 17 deletions(-) create mode 100644 cmd/invocation_audit_test.go diff --git a/cmd/entry.go b/cmd/entry.go index 11be00d..91927c7 100644 --- a/cmd/entry.go +++ b/cmd/entry.go @@ -153,9 +153,17 @@ func directModeTmuxHint(opts WorkspaceEntryOptions) string { if !shell.HasSession(tmuxPath, sessionName) { return "" } + return formatDirectModeTmuxHint(opts.WsMeta.Slug) +} + +// formatDirectModeTmuxHint builds the hint string itself, with no tmux +// or filesystem checks. Split out so unit tests can verify that the +// command-form line uses invocationName() without needing a live tmux +// session set up against a real workspace. +func formatDirectModeTmuxHint(slug string) string { return fmt.Sprintf( "Tip: a tmux session exists for this workspace.\nTo reattach instead of starting a second direct-mode session, run:\n %s attach %s", - invocationName(), opts.WsMeta.Slug) + invocationName(), slug) } func invokePersistentRun(opts WorkspaceEntryOptions, tmuxPath, sessionName string) error { diff --git a/cmd/info_session_test.go b/cmd/info_session_test.go index ea487a7..a3c38b9 100644 --- a/cmd/info_session_test.go +++ b/cmd/info_session_test.go @@ -12,16 +12,7 @@ import ( "github.com/warrenronsiek/ctask/internal/workspace" ) -// withInvocationNameInfo pins invocationName() to "ctask" for the duration -// of the test so attach-hint substring assertions are stable across hosts. -// Mirrors the helper in persistent_test.go but kept local to avoid -// cross-test coupling. -func withInvocationNameInfo(t *testing.T, name string) { - t.Helper() - prev := invocationNameOverride - invocationNameOverride = name - t.Cleanup(func() { invocationNameOverride = prev }) -} +// withInvocationName is provided by persistent_test.go in this package. // makeInfoSessionWorkspace writes a workspace under root with the given // slug and returns the workspace directory path. The workspace metadata @@ -65,7 +56,7 @@ func TestInfoNoSession(t *testing.T) { } func TestInfoShowsActivePersistentSessionWithAttachHint(t *testing.T) { - withInvocationNameInfo(t, "ctask") + withInvocationName(t, "ctask") root := t.TempDir() wsDir := makeInfoSessionWorkspace(t, root, "active-persist") @@ -95,7 +86,7 @@ func TestInfoShowsActivePersistentSessionWithAttachHint(t *testing.T) { } func TestInfoShowsActiveDirectSessionWithoutAttachHint(t *testing.T) { - withInvocationNameInfo(t, "ctask") + withInvocationName(t, "ctask") root := t.TempDir() wsDir := makeInfoSessionWorkspace(t, root, "active-direct") @@ -124,7 +115,7 @@ func TestInfoShowsActiveDirectSessionWithoutAttachHint(t *testing.T) { } func TestInfoShowsRemoteHostnameInOwnerLine(t *testing.T) { - withInvocationNameInfo(t, "ctask") + withInvocationName(t, "ctask") root := t.TempDir() wsDir := makeInfoSessionWorkspace(t, root, "remote-active") diff --git a/cmd/invocation_audit_test.go b/cmd/invocation_audit_test.go new file mode 100644 index 0000000..dad7016 --- /dev/null +++ b/cmd/invocation_audit_test.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "strings" + "testing" +) + +// v0.5.4 invocation-name audit: spec §2 codifies that command-form +// hints use invocationName() while product-identity references stay +// literal. The pre-existing tests already cover most paths individually +// (resume restore hint in resume_test.go, persistent bypass hints in +// persistent_test.go). These tests pin down the remaining piece — the +// Layer-1 active-session prompt's attach hint — and re-assert the +// resume restore-hint contract against an explicitly non-canonical +// invocation name so a regression that hard-codes "ctask" anywhere +// upstream of the hint format will fail loudly. + +func TestInvocationNameInActiveSessionPrompt(t *testing.T) { + // directModeTmuxHint composes the Layer-1 prompt's attach suggestion + // from formatDirectModeTmuxHint. The format-only helper is the right + // surface to test: it isolates the rendering decision from the tmux + // presence checks (which are environment-dependent) but exercises the + // exact string the user will see. + withInvocationName(t, "my-bin") + + got := formatDirectModeTmuxHint("demo-ws") + + if !strings.Contains(got, "my-bin attach demo-ws") { + t.Errorf("Layer-1 attach hint should use invocation name; got:\n%s", got) + } + if strings.Contains(got, "ctask attach demo-ws") { + t.Errorf("Layer-1 attach hint must NOT hard-code 'ctask attach' when invocation name differs; got:\n%s", got) + } +} + +func TestInvocationNameInRestoreHintNonCanonical(t *testing.T) { + // Complement to TestResumeArchivedShowsRestoreHint in resume_test.go, + // which pins invocationName to "ctask" — that protects against test + // binary noise but cannot detect a regression that hard-codes "ctask" + // in the format string. Pinning a non-default name flushes that out. + withInvocationName(t, "my-bin") + + got := formatResumeRestoreHint("my-archived-ws") + + if !strings.Contains(got, "my-bin restore my-archived-ws") { + t.Errorf("resume restore hint should use invocation name; got:\n%s", got) + } + if strings.Contains(got, "ctask restore") { + t.Errorf("resume restore hint must NOT hard-code 'ctask restore' when invocation name differs; got:\n%s", got) + } +} diff --git a/cmd/resume.go b/cmd/resume.go index b738dcd..53d9957 100644 --- a/cmd/resume.go +++ b/cmd/resume.go @@ -58,9 +58,7 @@ func doResume(query string, container, useShell, force bool, agentOverride strin ws := resolveOne(roots, query, true) if ws.Meta.Status == "archived" { - fmt.Fprintf(os.Stderr, - "[ctask] error: workspace %q is archived\n\nTo restore it:\n %s restore %s\n", - query, invocationName(), query) + fmt.Fprint(os.Stderr, formatResumeRestoreHint(query)) return fmt.Errorf("workspace archived") } @@ -88,3 +86,19 @@ func doResume(query string, container, useShell, force bool, agentOverride strin CommandName: "resume", }) } + +// formatResumeRestoreHint builds the multi-line stderr block printed +// when `ctask resume ` resolves to an archived workspace. +// Extracted so the v0.5.4 invocation-name audit can verify the +// command-form line uses invocationName() without depending on the +// surrounding fmt.Fprintf machinery. +// +// The "[ctask]" diagnostic prefix is intentionally a literal product +// reference (spec §2: product-identity references stay literal). The +// `restore ` line is the command-form portion and uses +// invocationName(). +func formatResumeRestoreHint(query string) string { + return fmt.Sprintf( + "[ctask] error: workspace %q is archived\n\nTo restore it:\n %s restore %s\n", + query, invocationName(), query) +} From 4fd0befee16a4515788d4b0c668b395623eec085 Mon Sep 17 00:00:00 2001 From: typebasedio Date: Thu, 14 May 2026 19:59:22 -0400 Subject: [PATCH 5/7] docs(v0.5.4): rewrite commands.md as a structured reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring docs/commands.md up to current state. The document was last substantively updated in v0.5; v0.5.2 (restore/notes/path/list --names/ completion + archived-inclusive lookup), v0.5.3 (attach + --direct + persistent session mode + doctor tmux check), and v0.5.4 (info Session block + list SESSION column) were all undocumented. Format follows the spec section §3: each command has Purpose, Usage, Scenarios, Examples, Flags, Notes, Related. Sections are kept short (roughly one screen each) — if a command needed more, the command is doing too much, not the docs. New non-command sections: workspace layout (task and project), a consolidated environment-variables table (previously scattered), explicit query-resolution rules including archive-inclusive lookup behavior, session modes (direct vs persistent), and a shell-completion how-to. Examples use the canonical "ctask" command name per spec §3 — docs describe the product, not the user's local binary path. --- docs/commands.md | 1096 +++++++++++++++++++++++++++------------------- 1 file changed, 642 insertions(+), 454 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index d9ec3d2..800df45 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1,576 +1,764 @@ -# Commands +# ctask Commands Reference -## ctask new +This document describes every ctask command, its flags, the workspace layout +ctask creates, the environment variables that influence its behavior, how +queries resolve to workspaces, the two session modes, and how to install +shell completion. -Create a new task or project workspace and launch the agent. +It is a practical reference, not a tutorial. Examples use the canonical +`ctask` command name; the binary may surface as `ctask.exe` or +`./ctask` in your shell, depending on how it was invoked. -``` -ctask new [title] [flags] -``` +## Table of contents -If title is omitted, generates `task-HHMMSS`. +- [Workspace layout](#workspace-layout) +- [Workspace creation and entry](#workspace-creation-and-entry) + - [`ctask new`](#ctask-new) + - [`ctask resume`](#ctask-resume) + - [`ctask last`](#ctask-last) + - [`ctask open`](#ctask-open) + - [`ctask attach`](#ctask-attach) +- [Inspection](#inspection) + - [`ctask info`](#ctask-info) + - [`ctask list`](#ctask-list) + - [`ctask notes`](#ctask-notes) + - [`ctask path`](#ctask-path) +- [Lifecycle](#lifecycle) + - [`ctask archive`](#ctask-archive) + - [`ctask restore`](#ctask-restore) + - [`ctask delete`](#ctask-delete) +- [Environment](#environment) + - [`ctask doctor`](#ctask-doctor) + - [`ctask completion`](#ctask-completion) +- [Environment variables](#environment-variables) +- [Query resolution](#query-resolution) +- [Session modes](#session-modes) +- [Shell completion](#shell-completion) +- [Exit codes](#exit-codes) + +--- + +## Workspace layout + +A task workspace: + + workspace-root/ + ├── task.yaml # workspace metadata (managed by ctask) + ├── CLAUDE.md # agent instructions (seeded by ctask, edited by agent) + ├── notes.md # working context (maintained by agent) + ├── logs/ + │ └── sessions.log # append-only session history + └── .ctask/ # ctask internals (do not edit) + ├── session.json # active session lease (v0.4+) + ├── write.lock # metadata write lock (v0.4+) + ├── manifest-start.json # session start snapshot (v0.2+) + └── last-session-summary.json # session summary (v0.4+) + +A project workspace adds a project subdirectory and (optionally) a git repo: + + workspace-root/ + ├── task.yaml # includes launch_dir: "" + ├── CLAUDE.md + ├── notes.md + ├── .git/ # single git repo at workspace root + ├── .gitignore # excludes .ctask/ and logs/sessions.log + ├── / # project subdirectory; agent launches here + │ └── ... # user's source code + ├── logs/ + │ └── sessions.log + └── .ctask/ + └── ... + +Files inside `.ctask/` are ctask state. Editing them by hand can confuse +session detection, archive guards, and the stale-workspace detector. + +--- + +## Workspace creation and entry + +### `ctask new` + +**Purpose:** Create a new task or project workspace and launch the agent. + +**Usage:** + + ctask new [title] [flags] + +If `title` is omitted, ctask generates `task-HHMMSS`. + +**Scenarios:** +- Starting a new piece of work from scratch. +- Standing up a longer-lived project workspace with `--project`. + +**Examples:** + + $ ctask new "fix auth bug" + [ctask] created general/2026-05-14_fix-auth-bug + + $ ctask new --project "billing service" + [ctask] created projects/2026-05-14_billing-service + + $ ctask new --no-launch "json cleanup" + [ctask] created general/2026-05-14_json-cleanup **Flags:** +- `--category`, `-c` — workspace category subdirectory. Default: `general` + for tasks, `projects` for projects. +- `--project` — create a project workspace. Uses `CTASK_PROJECT_ROOT` if + set, runs `git init`, seeds the project CLAUDE.md template. +- `--shell` — open an interactive shell instead of the agent. +- `--agent`, `-a` — override the agent command (default: `claude` or + `CTASK_AGENT`). +- `--no-launch` — create the workspace only; do not launch a session. +- `--direct` — bypass persistent session mode for this invocation. +- `--container` — deferred to a future release. -| Flag | Short | Default | Description | -|------|-------|---------|-------------| -| `--category` | `-c` | `general` (task) / `projects` (project) | Workspace category subdirectory | -| `--project` | | off | Create a long-lived project workspace (uses `CTASK_PROJECT_ROOT` if set, runs `git init`, project CLAUDE.md) | -| `--shell` | | off | Open interactive shell instead of agent | -| `--agent` | `-a` | `claude` | Command to exec as the agent | -| `--no-launch` | | off | Create workspace only, do not launch | -| `--container` | | off | Deferred to a future release | +**Notes:** +- `--project` runs `git init` if `git` is on PATH and seeds a minimal + `.gitignore` (only when no `.gitignore` was provided by a seed). +- For project workspaces, the agent launches inside the project + subdirectory (controlled by `launch_dir` in `task.yaml`). +- If the session ends without producing any file changes (e.g. the user + cancels at the agent prompt), the workspace is removed automatically — + the "provisional workspace" cleanup gate. Persistent mode disables + this gate. -**Examples:** - -```powershell -ctask new "fix auth bug" -ctask new -c scripts "backup helper" -ctask new --no-launch "json cleanup" -ctask new --shell "test env" -ctask new --agent aider "refactor api" -ctask new -ctask new --project "billing service" -ctask new --project -c backend "billing service" -``` - -When `--no-launch` is used, no session is started and no session log is written. - -### Project mode (`--project`) - -`--project` is a thin variation on the normal task workflow for longer-lived work. It changes: - -- `task.yaml` records `type: project` and `launch_dir: ` (v0.5+) -- Default category becomes `projects` -- Workspace root falls back to `CTASK_PROJECT_ROOT` if set; otherwise `CTASK_ROOT` -- Built-in CLAUDE.md is the project-oriented template (overridable via seed directories) -- Seed order: built-in defaults -> general seed (`CTASK_SEED_DIR`) -> project seed (`CTASK_SEED_PROJECT_DIR`) -- `git init` runs if `git` is on PATH; a minimal `.gitignore` (`.ctask/` + `logs/sessions.log`) is created **only if no `.gitignore` was already provided by a seed** -- If `git` is not available, ctask prints `[ctask] git not found; skipped repository initialization` and continues -- A project subdirectory named after the final suffixed slug is created inside the workspace. ctask does not seed any files inside it -- the user places their own CLAUDE.md, source code, and project structure there. - -**Workspace layout (v0.5):** - -``` -2026-04-22_litlink-v2/ -├── .ctask/ ctask state (lease, manifest, summary, lock) -├── .git/ single git repo at workspace root -├── .gitignore -├── CLAUDE.md ctask workspace rules (managed by ctask + seed) -├── notes.md -├── task.yaml includes launch_dir: "litlink-v2" -├── context/ reference material, imported specs -├── output/ ctask deliverables -├── logs/ session logs -└── litlink-v2/ project subdirectory (user's codebase) -``` - -When `ctask resume litlink-v2` runs, the agent is launched inside `litlink-v2/`. Both the workspace CLAUDE.md (root) and any project CLAUDE.md the user places inside `litlink-v2/` apply to the session -- Claude Code reads CLAUDE.md hierarchically. - -**Project root semantics:** - -- `CTASK_PROJECT_ROOT` not set: workspace goes under `$CTASK_ROOT/projects/_` (default category `projects` is appended) -- `CTASK_PROJECT_ROOT` set, no `-c`: workspace goes directly under `$CTASK_PROJECT_ROOT/_` (no `projects/` subdirectory is appended) -- `CTASK_PROJECT_ROOT` set, explicit `-c `: workspace goes under `$CTASK_PROJECT_ROOT//_` - -**Single git repo rule:** - -Project workspaces use a single git repository initialized at the workspace root. -Do not create nested git repositories inside the workspace -- including inside -the project subdirectory. If your project code lives in a subdirectory, it is -tracked by the root repo. - -**Changing the launch directory:** - -The `launch_dir` field in `task.yaml` controls which subdirectory the agent is launched into on `ctask resume`, `ctask last`, and `ctask open`. By default it is set to the project slug. To launch from the workspace root instead, edit `task.yaml` and set `launch_dir: ""`. To launch from a deeper path (e.g., `backend/api`), set `launch_dir: "backend/api"`. No CLI command is provided -- manual edit is the only supported way to change it. - -If `launch_dir` points to a directory that does not exist (deleted, renamed) or is not a directory, ctask prints a warning and falls back to the workspace root. Absolute paths and paths that escape the workspace via `..` are errors and abort the session. - -### Seed directories - -On `ctask new`, after writing the built-in defaults, ctask copies the contents of an optional user seed directory into the workspace. Files in the seed directory overwrite the built-in defaults; subdirectories are preserved recursively. `task.yaml` and `.ctask/` at the seed root are always skipped. - -| Variable | Default (Unix) | Default (Windows) | -|----------|---------------|-------------------| -| `CTASK_SEED_DIR` | `~/.config/ctask/seed/` | `%APPDATA%\ctask\seed\` | -| `CTASK_SEED_PROJECT_DIR` | `~/.config/ctask/seed-project/` | `%APPDATA%\ctask\seed-project\` | - -The general seed is applied to every workspace. The project seed is applied **only** when `--project` is set, on top of the general seed (project seed wins). Both directories are optional; missing directories are silently ignored. +**Related:** `ctask resume`, `ctask attach`, `ctask doctor`. --- -## ctask list +### `ctask resume` -List workspaces in reverse-chronological order. +**Purpose:** Reopen an existing workspace and launch the agent. -``` -ctask list [flags] -``` +**Usage:** -By default, `ctask list` shows **all active workspaces** -- both tasks and projects. Use `--task` or `--projects` to narrow by type, and `--all` to include archived workspaces. + ctask resume [flags] + +**Scenarios:** +- Picking up where you left off in a previous session. +- Re-entering a long-lived project workspace. + +**Examples:** + + $ ctask resume auth-bug + [ctask] local :: fix-auth-bug + [ctask] ~/ai-workspaces/general/2026-05-14_fix-auth-bug + [ctask] Last session: 2026-05-13 14:30-15:45 (warren-desktop, claude) **Flags:** +- `--shell` — open an interactive shell instead of the agent. +- `--agent`, `-a` — override the agent command for this invocation. +- `--force` — skip both the active-session and stale-workspace warnings. + Use only when the human has already decided to proceed. +- `--direct` — bypass persistent session mode for this invocation. +- `--container` — deferred. -| Flag | Short | Default | Description | -|------|-------|---------|-------------| -| `--all` | `-a` | off | Include archived workspaces | -| `--task` | | off | Show task workspaces only | -| `--projects` | | off | Show project workspaces only | -| `--category` | `-c` | all | Filter by category | -| `--limit` | `-n` | 20 | Maximum entries to show | +**Notes:** +- Resolves archived workspaces too, but refuses to launch them and + prints a `restore` hint instead. +- If multiple workspaces match the query, ctask prints all matches and + exits with an error. See [Query resolution](#query-resolution). +- Updates `updated_at` in `task.yaml` so `ctask last` recognises this + workspace as the most recent. -`--task` and `--projects` are mutually exclusive; passing both returns a usage error. - -**Examples:** - -```powershell -ctask list # active tasks AND projects -ctask list --all # everything (incl. archived) -ctask list --task # active tasks only -ctask list --task --all # all tasks (incl. archived) -ctask list --projects # active projects only -ctask list --projects --all # all projects (incl. archived) -ctask list -c scripts -n 5 -``` - -Output columns: status, type, mode, category, date, slug. - -Workspaces created before v0.3 (which have no `type` field in `task.yaml`) are treated as tasks. +**Related:** `ctask last`, `ctask attach`, `ctask restore`, `ctask info`. --- -## ctask resume +### `ctask last` -Reopen an existing workspace and launch the agent. +**Purpose:** Resume the most recently updated workspace, across both tasks +and projects. -``` -ctask resume [flags] -``` +**Usage:** -Resolves the workspace by query (exact directory name, exact slug, or case-insensitive substring). Archived workspaces are excluded by default. + ctask last [flags] + +**Scenarios:** +- Returning to whatever you were last working on without typing a name. + +**Examples:** + + $ ctask last + [ctask] local :: fix-auth-bug + [ctask] ... **Flags:** +- `--shell` — open an interactive shell instead of the agent. +- `--agent`, `-a` — override the agent command. +- `--force` — skip active-session and stale-workspace warnings. +- `--direct` — bypass persistent session mode for this invocation. -| Flag | Short | Default | Description | -|------|-------|---------|-------------| -| `--shell` | | off | Open shell instead of agent | -| `--agent` | `-a` | from task.yaml | Override agent command | -| `--force` | | off | Skip active-session and stale-workspace warnings | -| `--container` | | off | Deferred to a future release | +**Notes:** +- Equivalent to `ctask resume` on whichever active workspace has the + latest `updated_at`. Archived workspaces are excluded. +- Exits non-zero with `No active workspaces found.` when there are no + active workspaces. -**Examples:** - -```powershell -ctask resume auth-bug -ctask resume backup -ctask resume --shell auth-bug -ctask resume --agent aider auth-bug -ctask resume --force auth-bug -``` - -If multiple workspaces match, prints all matches and exits. If none match, prints an error. - -Session logging runs automatically: file changes during the session are recorded in `logs/sessions.log`. +**Related:** `ctask resume`. --- -## ctask open +### `ctask open` -Open a workspace directory in an interactive shell without launching the agent. +**Purpose:** Open a workspace directory in an interactive shell without +launching the agent. -``` -ctask open [flags] -``` +**Usage:** -Spawns a new subshell in the workspace directory. Does not modify the caller's shell session. + ctask open [flags] + +**Scenarios:** +- Browsing the workspace, running git commands, editing files manually. + +**Examples:** + + $ ctask open auth-bug + # subshell rooted at the workspace; exit returns to your prior shell. **Flags:** +- `--all`, `-a` — include archived workspaces in query resolution. +- `--force` — skip active-session and stale-workspace warnings. +- `--direct` — bypass persistent session mode for this invocation. -| Flag | Short | Default | Description | -|------|-------|---------|-------------| -| `--all` | `-a` | off | Include archived workspaces in query resolution | -| `--force` | | off | Skip active-session and stale-workspace warnings | +**Notes:** +- Spawns a subshell — the parent shell is unaffected. +- Same lease/manifest/summary lifecycle as `resume`, just running + `$SHELL` (or PowerShell on Windows) instead of the agent. -**Examples:** - -```powershell -ctask open auth-bug -``` +**Related:** `ctask resume`, `ctask path`. --- -## ctask info +### `ctask attach` -Display metadata and path for a workspace without entering it. +**Purpose:** Attach to a workspace via tmux. Always uses persistent +session mode regardless of `CTASK_SESSION_MODE`. -``` -ctask info [flags] -``` +**Usage:** + + ctask attach [flags] + +**Scenarios:** +- You have not enabled persistent mode globally but want tmux for one + workspace. +- A persistent tmux session already exists; you want to reattach + instead of starting a second direct-mode session. +- Scripts that need unambiguous "tmux always" behavior. + +**Examples:** + + $ ctask attach promptvolley-v3 + # owner-create OR passive reattach OR adopted reattach, + # depending on the workspace's current lease/tmux state. **Flags:** +- `--agent`, `-a` — override the agent command (used only on + owner-create, when ctask spawns the agent inside the new tmux session). +- `--force` — skip active-session and stale-workspace warnings. + Owner-create path only. -| Flag | Short | Default | Description | -|------|-------|---------|-------------| -| `--all` | `-a` | off | Include archived workspaces in query resolution | +**Notes:** +- Active-only resolution: archived workspaces are not candidates. +- See [Session modes](#session-modes) for the three entry paths + (owner-create, passive reattach, adopted reattach). +- Native Windows is not supported. Run ctask under WSL. -**Examples:** - -```powershell -ctask info auth-bug -ctask info backup -``` - -Shows: slug, title, category, status, mode, agent, created/updated timestamps, path, and directory contents. For v0.5 project workspaces, also shows `Launch dir`, `Launch path`, and `Dir exists`. +**Related:** `ctask resume --direct`, `ctask doctor` (tmux check). --- -## ctask archive +## Inspection -Mark a workspace as archived. The workspace stays in place but is hidden from default listings and query resolution. +### `ctask info` -``` -ctask archive -``` +**Purpose:** Display metadata, path, and current session state for a +workspace without entering it. + +**Usage:** + + ctask info + +**Scenarios:** +- Diagnosing whether a workspace is currently in use. +- Checking the launch directory for a project workspace. **Examples:** -```powershell -ctask archive auth-bug -``` + $ ctask info promptvolley-v3 + Task: promptvolley-v3 + Title: Prompt Volley v3 + Category: projects + Status: active + Mode: local + Agent: claude + Created: 2026-04-24 09:12:03 + Updated: 2026-05-14 18:05:21 + Path: /home/warren/ai-workspaces/projects/2026-04-24_promptvolley-v3 -To see archived workspaces, use `ctask list --all`. To resolve archived workspaces in other commands, use the `--all` flag where available. + Session: active + Mode: persistent + Owner: pid 48291 + Attach: ctask attach promptvolley-v3 + + Launch dir: promptvolley-v3/ + Launch path: /home/warren/.../2026-04-24_promptvolley-v3/promptvolley-v3 + Dir exists: yes + + Contents: + .ctask/ + .git/ + CLAUDE.md + ... + +**Flags:** (none) + +**Notes:** +- Resolves archived workspaces by default — the `Status:` line shows + whether the workspace is active or archived. No `--all` flag needed. +- The `Session:` block is derived from the lease file (no tmux invocation, + no PID liveness checks). Possible states: `none`, `active`, `stale`. + Hostname is omitted when it matches the local machine. +- `Attach:` hint appears only for active+persistent sessions. +- For an archived workspace with a stranded lease (rare; theoretically + possible after a crash), `info` will still surface the raw session + state because `info` is the diagnostic command. `list` collapses + archived workspaces to a dash for simplicity. + +**Related:** `ctask list`, `ctask path`, `ctask notes`. --- -## ctask last +### `ctask list` -Resume the most recently updated workspace, considering **both tasks and projects**. Equivalent to `ctask resume` on whichever active workspace has the latest `updated_at` timestamp. Archived workspaces are excluded. +**Purpose:** List workspaces in reverse-chronological order. -``` -ctask last [flags] -``` +**Usage:** + + ctask list [flags] + +**Scenarios:** +- Surveying active work. +- Finding the basename to feed into another command. +- Driving shell scripting via `--names`. + +**Examples:** + + $ ctask list + active persistent project local projects 2026-04-24 promptvolley-v3 + active direct task local general 2026-05-14 fix-auth-bug + active — task local general 2026-05-12 json-cleanup + + $ ctask list --names + 2026-04-24_promptvolley-v3 + 2026-05-14_fix-auth-bug + 2026-05-12_json-cleanup **Flags:** +- `--all`, `-a` — include archived workspaces. +- `--task` — show task workspaces only. +- `--projects` — show project workspaces only. Mutually exclusive with + `--task`. +- `--category`, `-c` — filter by category. +- `--limit`, `-n` — maximum entries to show. Default: 20. +- `--names` — emit one workspace directory basename per line, no header. + Used by shell completion and scripting. -| Flag | Short | Default | Description | -|------|-------|---------|-------------| -| `--shell` | | off | Open shell instead of agent | -| `--agent` | `-a` | from task.yaml | Override agent command | -| `--force` | | off | Skip active-session and stale-workspace warnings | +**Notes:** +- Output columns (default mode): status, session, type, mode, category, + date, slug. The `session` column shows `direct`, `persistent`, `stale`, + or em dash for no session. Archived workspaces always show em dash in + the session column (a display simplification — see `info` for the raw + state). +- `--names` output is intentionally minimal: bare basenames, no header, + empty stdout when nothing matches. Every emitted line is guaranteed + to resolve to exactly one workspace under the same archive policy. +- Workspaces created before v0.3 (no `type` field) are treated as tasks. + +**Related:** `ctask info`, `ctask path`, `ctask completion`. + +--- + +### `ctask notes` + +**Purpose:** Print a workspace's `notes.md` to stdout. + +**Usage:** + + ctask notes + +**Scenarios:** +- Quick read of working context without `cd`-ing into the workspace. +- Piping notes into pagers, search tools, or other agents. **Examples:** -```powershell -ctask last -ctask last --shell -``` + $ ctask notes auth-bug | less + $ ctask notes promptvolley-v3 | grep -i "decision" -If no active workspaces exist, prints an error and exits. +**Flags:** (none) + +**Notes:** +- Resolves archived workspaces too, mirroring `info`'s archive-inclusive + lookup. +- If `notes.md` is missing, prints `[ctask] no notes.md found in + workspace ""` to stderr and exits non-zero. + +**Related:** `ctask info`, `ctask path`. --- -## ctask doctor +### `ctask path` -Verify that ctask is correctly set up. Read-only -- never modifies anything. +**Purpose:** Print the absolute filesystem path of a workspace, with a +trailing newline. Designed for shell pipelines. -``` -ctask doctor -``` +**Usage:** -Checks: -1. Workspace root exists and is writable -2. Default agent command is found on PATH -3. Status-line helper script exists at the expected location -4. Claude Code `statusLine` is configured in `~/.claude/settings.json` -5. At least one workspace exists + ctask path -Exits 0 if all checks pass, 1 if any fail. Each failure includes a concrete fix instruction. +**Scenarios:** +- `cd "$(ctask path auth-bug)"` +- Feeding the path to other tools (rg, fd, editors). -`ctask doctor` also reports seed directory status: `[INFO]` if the `CTASK_SEED_DIR` / `CTASK_SEED_PROJECT_DIR` variable is unset (built-in defaults will be used), `[PASS]` if the variable is set and the path exists, `[FAIL]` if the variable is set but the path is missing. Only the configured-but-missing state counts as a failure. +**Examples:** -**Example output:** + $ ctask path auth-bug + /home/warren/ai-workspaces/general/2026-05-14_fix-auth-bug -``` - [PASS] Workspace root exists: C:\Users\Warren\ai-workspaces - [PASS] Default agent found: claude - [PASS] Status line helper found: C:\Users\Warren\AppData\Local\ctask\bin\ctask-statusline.sh - [PASS] Claude Code status line configured - [PASS] Workspaces found: 5 tasks (2 archived) - [INFO] General seed directory: not configured (using built-in defaults) - [INFO] Project seed directory: not configured (using built-in defaults) - [INFO] CTASK_PROJECT_ROOT: not set (projects discovered under C:\Users\Warren\ai-workspaces\projects) +**Flags:** (none) -5 checks passed, 0 failed -``` +**Notes:** +- Resolves archived workspaces too. +- Output uses native path separators (backslashes on Windows). + +**Related:** `ctask open`, `ctask info`. --- -## ctask delete +## Lifecycle -Permanently remove a workspace directory. +### `ctask archive` -``` -ctask delete [flags] -``` +**Purpose:** Mark a workspace as archived. The workspace stays on disk +but is hidden from default listings and most query resolution. + +**Usage:** + + ctask archive + +**Scenarios:** +- Putting a workspace away when the task is complete. +- Reducing default-listing noise. + +**Examples:** + + $ ctask archive auth-bug + [ctask] archived: general/2026-05-14_fix-auth-bug + +**Flags:** (none) + +**Notes:** +- Refuses to archive a workspace with an active session (fresh lease) + on non-TTY stdin. On a TTY, prompts for confirmation. +- After archiving, use `ctask list --all` to see archived workspaces and + `ctask restore` to bring one back. + +**Related:** `ctask restore`, `ctask list --all`, `ctask delete`. + +--- + +### `ctask restore` + +**Purpose:** Un-archive a workspace (set `status` back to `active`). + +**Usage:** + + ctask restore + +**Scenarios:** +- Resuming work on a previously archived task or project. + +**Examples:** + + $ ctask restore old-task + [ctask] restored: general/2026-04-22_old-task + [ctask] status: active + +**Flags:** (none) + +**Notes:** +- Errors if the target workspace is already active. +- Mirrors `archive`'s active-session lease guard: refuses on non-TTY, + prompts on TTY. +- Tab completion for `restore` surfaces archived workspaces only. + +**Related:** `ctask archive`, `ctask list --all`. + +--- + +### `ctask delete` + +**Purpose:** Permanently remove a workspace directory. + +**Usage:** + + ctask delete [flags] + +**Scenarios:** +- Reclaiming space from workspaces that are no longer needed. +- Removing accidentally created or trivial workspaces. + +**Examples:** + + $ ctask delete --force throwaway **Flags:** +- `--force`, `-f` — skip the confirmation prompt. +- `--all`, `-a` — include archived workspaces in query resolution. -| Flag | Short | Default | Description | -|------|-------|---------|-------------| -| `--force` | `-f` | off | Skip confirmation prompt | -| `--all` | `-a` | off | Include archived workspaces in query resolution | +**Notes:** +- Confirmation is required by default. `--force` skips it but does NOT + override the active-session guard. +- Refuses to delete the workspace currently exported via + `CTASK_WORKSPACE` (catches same-session attempts) and refuses any + workspace with `.ctask/manifest-start.json` present (catches + cross-terminal attempts). Exit the session first. +- Prints a note before confirmation when deleting the most recently + updated workspace. + +**Related:** `ctask archive` (the safer alternative for completed work). + +--- + +## Environment + +### `ctask doctor` + +**Purpose:** Verify ctask is correctly set up. Read-only. + +**Usage:** + + ctask doctor + +**Scenarios:** +- First-run sanity check after install. +- Diagnosing a misconfigured agent, missing seed dir, or tmux problems. **Examples:** -```powershell -ctask delete old-task -ctask delete --force old-task -ctask delete --all --force archived-task -``` + $ ctask doctor + [PASS] Workspace root exists: /home/warren/ai-workspaces + [PASS] Default agent found: claude + [PASS] Status line helper found: /home/warren/.local/bin/ctask-statusline.sh + [PASS] Claude Code status line configured + [PASS] Workspaces found: 7 tasks (2 archived) + [INFO] General seed directory: not configured (using built-in defaults) + [INFO] Project seed directory: not configured (using built-in defaults) + [INFO] CTASK_PROJECT_ROOT: not set (projects discovered under /home/warren/ai-workspaces/projects) + [INFO] Session mode: persistent + [INFO] tmux found: tmux 3.4 (/usr/bin/tmux) -**Safety:** -- Confirmation is required by default. `--force` skips it. -- If the workspace has an active session (running in another terminal), deletion is refused even with `--force`. Exit the session first. -- If the workspace is the most recently updated one, a note is printed before confirmation. + 10 checks passed, 0 failed + +**Flags:** (none) + +**Notes:** +- Exits 0 on all-pass, 1 if any check fails. +- Each `[FAIL]` row includes a concrete fix line. +- Persistent-mode hosts also see two tmux rows; direct-mode hosts see a + single `[INFO] Session mode: direct (tmux not required)` row. + +**Related:** [`ctask completion`](#ctask-completion), [Session modes](#session-modes). --- -## Query Resolution +### `ctask completion` -Commands that take a `` argument (`resume`, `open`, `info`, `archive`, `delete`) resolve workspaces in this order: +**Purpose:** Emit shell-completion scripts for bash, zsh, fish, or +PowerShell. -1. Exact directory name match (e.g. `2026-04-06_auth-bug`) -2. Exact slug match (e.g. `auth-bug`) -3. Case-insensitive substring match (e.g. `auth`) +**Usage:** -If multiple workspaces match, all matches are printed and the command exits. If none match, an error is printed. + ctask completion {bash | zsh | fish | powershell} -Archived workspaces are excluded from matching by default. Use `--all` where supported to include them. +**Scenarios:** +- Setting up tab completion in your shell so workspace names complete + automatically. -By default, queries search `$CTASK_ROOT` (including its `projects/` subdirectory) plus `$CTASK_PROJECT_ROOT` when that variable is set. Projects created without `CTASK_PROJECT_ROOT` are therefore discoverable from any shell without additional configuration. Custom `CTASK_PROJECT_ROOT` paths must be set (recommended: at user scope) in any shell where you want those projects findable. +**Examples:** + + # bash, current shell only + $ source <(ctask completion bash) + + # zsh, persistent + $ ctask completion zsh > "${fpath[1]}/_ctask" + + # PowerShell, persistent + PS> ctask completion powershell | Out-String | Invoke-Expression + +**Flags:** (none — the shell is a positional argument.) + +**Notes:** +- See [Shell completion](#shell-completion) for installation patterns + per shell. +- Workspace name completion is provided via Cobra `ValidArgsFunction` + hooks: `resume`/`open`/`attach` complete on active workspaces, + `restore` completes on archived workspaces, `info`/`notes`/`path` + complete on any workspace. + +**Related:** [`ctask list --names`](#ctask-list). --- -## Environment Variables +## Environment variables + +ctask reads these to configure behavior: + +| Variable | Default | Description | +|----------|---------|-------------| +| `CTASK_ROOT` | `~/ai-workspaces` (Unix) / `%USERPROFILE%\ai-workspaces` (Windows) | Workspace root directory. | +| `CTASK_PROJECT_ROOT` | (none) | Workspace root for project workspaces. When set, projects go directly under this path with no `projects/` segment unless `-c` is also passed. | +| `CTASK_AGENT` | `claude` | Default agent command. | +| `CTASK_SEED_DIR` | `~/.config/ctask/seed/` (Unix) / `%APPDATA%\ctask\seed\` (Windows) | General user seed directory copied into every new workspace. | +| `CTASK_SEED_PROJECT_DIR` | `~/.config/ctask/seed-project/` (Unix) / `%APPDATA%\ctask\seed-project\` (Windows) | Project seed directory copied only for `--project` workspaces (overlay on top of the general seed). | +| `CTASK_SESSION_MODE` | `direct` | `direct` or `persistent`. See [Session modes](#session-modes). | ctask exports these into every child session: | Variable | Description | |----------|-------------| -| `CTASK_TASK` | Task slug | -| `CTASK_MODE` | Execution mode (`local`) | -| `CTASK_ROOT` | Resolved workspace root path | -| `CTASK_WORKSPACE` | Full workspace path | -| `CTASK_CATEGORY` | Category name | -| `CTASK_TYPE` | `task` or `project` | -| `CTASK_LAUNCH_DIR` | Project subdirectory (v0.5); empty for tasks and pre-v0.5 projects | - -Configure ctask behavior with: - -| Variable | Default | Description | -|----------|---------|-------------| -| `CTASK_ROOT` | `%USERPROFILE%\ai-workspaces` (Windows) / `~/ai-workspaces` (Unix) | Workspace root directory | -| `CTASK_AGENT` | `claude` | Default agent command | -| `CTASK_PROJECT_ROOT` | (none) | Workspace root for projects. When set, project workspaces are created directly under this path (no doubled `projects/` segment unless `-c` is passed). | -| `CTASK_SEED_DIR` | `%APPDATA%\ctask\seed\` (Windows) / `~/.config/ctask/seed/` (Unix) | General user seed directory copied into every new workspace. | -| `CTASK_SEED_PROJECT_DIR` | `%APPDATA%\ctask\seed-project\` (Windows) / `~/.config/ctask/seed-project/` (Unix) | Project seed directory copied only for `--project` workspaces (overlay on top of the general seed). | +| `CTASK_TASK` | Workspace slug. | +| `CTASK_MODE` | Execution mode (currently always `local`). | +| `CTASK_ROOT` | Resolved workspace root path. | +| `CTASK_WORKSPACE` | Full workspace path. | +| `CTASK_CATEGORY` | Category name. | +| `CTASK_TYPE` | `task` or `project`. | +| `CTASK_LAUNCH_DIR` | Project subdirectory; empty for tasks and pre-v0.5 projects. | --- -## Concurrency and safety +## Query resolution -ctask v0.4 protects workspaces from conflicts when multiple sessions (or manual file edits) touch the same workspace. +Commands that take a `` argument resolve workspaces in this order: -### Session lease +1. Exact directory basename match (e.g. `2026-05-14_fix-auth-bug`). +2. Exact slug match (e.g. `fix-auth-bug`). +3. Case-insensitive substring match against the slug (e.g. `auth`). -Each active `ctask resume`, `open`, `last`, or `new` writes a lease file at `/.ctask/session.json` identifying the ctask process, hostname, user, agent, mode, and a heartbeat timestamp. A background goroutine updates the heartbeat every 30 seconds. +If multiple workspaces match, ctask prints all matches and exits with +an error. If none match, ctask prints `No workspace matches "".` +and exits with an error. -On session start, if a fresh lease already exists (heartbeat within 60 seconds), ctask warns: +**Archive-inclusive lookup:** Some commands resolve archived workspaces +by default — `info`, `notes`, `path`, `restore`, and `resume`. Of those, +`resume` refuses to launch the archived workspace and prints a +`restore` hint. The other archive-inclusive commands operate on the +archived workspace as you would expect. -``` -[ctask] This workspace has an active session: - Session: - Host: - Agent: - Started: ( ago) - Last seen: ago +`open`, `delete`, and a few others gate archive inclusion behind +`--all`. `archive` itself is active-only by definition. - Opening a second session may cause conflicts. - Continue anyway? [y/N] -``` - -If the user answers `y`, the second session proceeds **without writing its own lease** (see "Known limitation: coexisting sessions" below). If the lease is older than 60 seconds (crash, lost connection), ctask cleans it up silently and proceeds. - -### Metadata write lock - -All ctask-owned file writes (`task.yaml`, `logs/sessions.log`, `.ctask/session.json`, `.ctask/manifest-start.json`, `.ctask/last-session-summary.json`) are serialized through `/.ctask/write.lock`. The lock is held for the duration of one write only. If the lock cannot be acquired within 2 seconds, the write is skipped with a warning rather than blocking. - -### Stale-workspace detection - -On session start, ctask compares the current workspace state against the end-state recorded by the previous session's summary. If anything changed outside a ctask session (another machine, manual edits), ctask warns: - -``` -[ctask] Workspace modified since last session ended: - - Last session: (, ) - - Modified since then: - notes.md (modified) - output/report.md (new file) - - These changes were not made during a ctask session. - Review before continuing? [Y/n] -``` - -Press Enter (or `y`) to proceed. Press `n` to exit without launching. - -This check is skipped silently for workspaces that have never completed a v0.4 session (no `last-session-summary.json`). - -### Session handoff summary - -At end of session, ctask writes `/.ctask/last-session-summary.json` containing: - -- `session_id`, `hostname`, `agent`, `mode` -- `started_at`, `ended_at`, `duration_seconds` -- `files_added`, `files_modified`, `files_deleted` -- `notes_updated` -- `end_manifest` (snapshot of workspace file list at session end -- used by the stale-workspace detector) - -The next session prints a short orientation banner from this file: - -``` -[ctask] local :: api-cleanup -[ctask] ~/ai-workspaces/general/2026-04-21_api-cleanup -[ctask] Last session: 2026-04-21 14:30-15:45 (warren-desktop, claude) -[ctask] Changed: notes.md, output/plan.md -``` - -### `--force` - -`--force` on `resume`, `open`, and `last` suppresses both the active-session warning and the stale-workspace warning. It does **not** disable the metadata write lock or the session summary -- those are always active. - -Use `--force` only for automation where the human has already decided to proceed. - -### Known limitation: coexisting sessions - -When the user confirms "Continue anyway?" on an active-session warning (or passes `--force`), the second session runs **without writing its own lease**. This keeps the lease model simple (one lease file per workspace), but has two consequences: - -1. A third session attempt will only see the original lease. The second (coexisting) session is invisible to lease-based detection. -2. If the original session exits and removes its lease before the coexisting session finishes, the coexisting session is unprotected for its remaining lifetime. - -The metadata write lock still serializes all ctask-owned file writes regardless of session count, so no state corruption can occur. If you need stronger guarantees, exit the existing session before starting another one. +**Search roots:** Queries always search `$CTASK_ROOT` plus +`$CTASK_PROJECT_ROOT` when set. When `CTASK_PROJECT_ROOT` is unset, +`$CTASK_ROOT/projects/` is searched as well, so projects created with +the default category are discoverable from any shell without +additional configuration. --- -## Exit Codes +## Session modes + +ctask supports two session modes, selected by `CTASK_SESSION_MODE`: + +- **`direct`** (default) — the agent or shell is the foreground process + of the ctask invocation. When the terminal closes, the agent exits. + This is the v0.4 behavior. +- **`persistent`** — workspace entry runs inside a deterministic + per-workspace tmux session named `ctask---`. + Multiple terminals (local + SSH) can attach to the same session, and + the agent survives terminal disconnection. + +Persistent mode requires: + +- tmux 3.0+ on PATH. +- An interactive terminal (over SSH, use `ssh -t`). +- Not running inside an existing tmux session (the `$TMUX` env var + must be unset). +- A Unix-like host or WSL — native Windows is not supported. + +When you run a persistent-mode entry command (`new`, `resume`, `last`, +`open`, or `attach`), ctask picks one of three paths: + +1. **Owner-create** — no tmux session exists for this workspace. ctask + launches the agent inside a new tmux session and starts a lease and + heartbeat. Equivalent to direct-mode entry plus a tmux wrapper. +2. **Passive reattach** — a tmux session exists and a fresh local lease + is heartbeating. ctask attaches the user's terminal to the existing + session and exits when the user detaches. No lease writes, no + manifest, no finalize — the original ctask owner is still managing + the workspace. +3. **Adopted reattach** — a tmux session exists but the lease is + missing, stale, or from another host (the original owner died). + ctask transfers ownership to itself, captures a fresh start + manifest, starts heartbeating, attaches the terminal, and runs + finalize when the session ends. + +`new`, `resume`, `last`, and `open` accept `--direct` to bypass +persistent mode for one invocation. When a persistent tmux session +exists for the workspace, ctask prompts the user to confirm. `--direct` +is a no-op under direct mode (allows scripts to use it defensively). + +`ctask attach` always uses persistent mode regardless of +`CTASK_SESSION_MODE`. + +`ctask doctor` validates session-mode prerequisites and reports tmux +availability. + +--- + +## Shell completion + +`ctask completion ` writes a Cobra-generated completion script +to stdout. Install it once per shell: + +**bash** (system-wide): + + ctask completion bash > /etc/bash_completion.d/ctask + +**bash** (per-user, current session): + + source <(ctask completion bash) + +**zsh** (assuming `compinit` and an `fpath` directory): + + ctask completion zsh > "${fpath[1]}/_ctask" + +**fish:** + + ctask completion fish > ~/.config/fish/completions/ctask.fish + +**PowerShell** (current session): + + ctask completion powershell | Out-String | Invoke-Expression + +**PowerShell** (persistent — append to your profile): + + ctask completion powershell >> $PROFILE + +Once installed, tab completion works on workspace-name arguments. The +completion source is `ctask list --names`, filtered per command: +`resume`/`open`/`attach` show active workspaces only, `restore` shows +archived workspaces only, and `info`/`notes`/`path` show all. + +--- + +## Exit codes | Code | Meaning | |------|---------| -| 0 | Success | -| 1 | General error (multiple matches, not found, invalid args, doctor failure) | -| 2 | Missing required argument | -| 127 | Agent command not found | - ---- - -## Persistent Session Mode (tmux) - -ctask v0.5.3+ supports an opt-in persistent session mode where workspace entry -runs inside a deterministic per-workspace tmux session. Multiple terminals -(local + SSH) attach to the same session, the agent and shell state survive -terminal disconnection, and the v0.4 lifecycle protections continue to apply -with one ctask process owning the workspace lifecycle while terminal -connections come and go. - -### Enabling persistent mode - -```bash -export CTASK_SESSION_MODE=persistent # in ~/.bashrc or equivalent -``` - -When unset or set to `direct`, ctask behaves as in v0.5.2 (no behavior change). - -Persistent mode requires: -- tmux 3.0+ on PATH (install via `apt install tmux`, `brew install tmux`, `pacman -S tmux`, or `dnf install tmux`) -- An interactive terminal (over SSH, use `ssh -t`) -- Not running inside an existing tmux session -- A Unix-like host or WSL — native Windows is not supported (use WSL) - -### Three entry paths - -When you run a persistent-mode entry command (`new`, `resume`, `last`, `open`, or `attach`), ctask picks one of three paths: - -1. **Owner-create** — no tmux session exists for this workspace yet. The command behaves like the direct path but launches the agent inside a new tmux session named `ctask---`. -2. **Passive reattach** — a tmux session exists and a fresh local lease is heartbeating. The command attaches the user's terminal to the existing session and exits when the user detaches. No lease writes, no manifest, no finalize — the original ctask owner is still managing the workspace. -3. **Adopted reattach** — a tmux session exists but the lease is missing, stale, or from another host (the original owner died). The command transfers ownership to itself, captures a fresh start manifest, starts heartbeating, attaches the terminal, and runs finalize when the session ends. - -### `ctask attach ` - -`ctask attach` always uses tmux regardless of `CTASK_SESSION_MODE`. Useful when you have not enabled persistent mode globally but want tmux for one workspace, or when shell scripts need unambiguous behavior. - -```bash -ctask attach promptvolley-v3 -``` - -The same three paths apply. - -### `--direct` bypass flag - -`new`, `resume`, `last`, and `open` accept `--direct` to bypass persistent mode for one invocation. When a persistent tmux session exists for the workspace, ctask prompts: - -``` -A persistent tmux session exists for this workspace: - ctask-projects-promptvolley-v3-a8f3c2 - -Opening a direct-mode shell may create conflicting workspace activity. -The recommended path is: - ctask attach promptvolley-v3 - -Continue with --direct anyway? [y/N] -``` - -`--direct` is a no-op under direct mode (allows scripts to use it defensively). - -### Doctor - -`ctask doctor` reports: - -``` -[INFO] Session mode: persistent -[INFO] tmux found: tmux 3.4 (/usr/bin/tmux) -``` - -or, on misconfiguration: - -``` -[INFO] Session mode: persistent -[FAIL] tmux not found on PATH - Fix: install tmux 3.0+ (apt/brew/pacman/dnf), or unset CTASK_SESSION_MODE -``` - -### Workflow examples - -**Local development** - -```bash -export CTASK_SESSION_MODE=persistent - -ctask new --project promptvolley-v3 -# -> workspace created, tmux session ctask-projects-promptvolley-v3-a8f3c2 started, attached. - -# Detach with Ctrl-B d. Terminal returns; tmux session keeps running. - -ctask resume promptvolley-v3 -# -> passive reattach. Same Claude Code session, scrollback intact. -``` - -**Remote access via SSH** - -```bash -ssh -t warren-desktop # -t is required -ctask resume promptvolley-v3 # -> passive reattach (concurrent with desktop) -``` - -### Native Windows note - -Persistent mode is not supported on native Windows (PowerShell). Run ctask under WSL and install tmux there. +| 0 | Success. | +| 1 | General error (multiple matches, not found, invalid args, doctor failure, refusal). | +| 2 | Missing required argument (Cobra default). | +| 127 | Agent command not found. | From ae9bfafb1fac37b35dbebab63f92d16f68040ac6 Mon Sep 17 00:00:00 2001 From: typebasedio Date: Thu, 14 May 2026 20:01:19 -0400 Subject: [PATCH 6/7] polish(v0.5.4): suppress Cobra duplicate Error on archived resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ctask resume printed both the helpful [ctask] diagnostic + restore hint AND a redundant trailing "Error: workspace archived" line from Cobra's default error rendering. Cosmetic but unprofessional. Add an errArchivedWorkspace sentinel and have runResume flip SilenceErrors only when the inner error is that sentinel. All other resume errors (lookup failure, metadata write failure, etc.) continue to flow through Cobra's default rendering unchanged — we only silence the case where we have already printed an equivalent diagnostic ourselves. Verified end-to-end through Cobra Execute (not just runResume direct invocation) so the SilenceErrors-flip-from-RunE timing is exercised. --- cmd/resume.go | 18 ++++- cmd/resume_archived_polish_test.go | 117 +++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 cmd/resume_archived_polish_test.go diff --git a/cmd/resume.go b/cmd/resume.go index 53d9957..c1943b3 100644 --- a/cmd/resume.go +++ b/cmd/resume.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "os" "path/filepath" @@ -38,8 +39,21 @@ func init() { rootCmd.AddCommand(resumeCmd) } +// errArchivedWorkspace is the sentinel doResume returns when the user +// asks to resume an archived workspace. doResume already prints the +// full [ctask] diagnostic + restore hint to stderr, so the cmd-layer +// wrapper flips SilenceErrors to suppress Cobra's redundant trailing +// "Error: workspace archived" line. All other errors (lookup failure, +// metadata write failure, etc.) flow through Cobra's default +// rendering unchanged. +var errArchivedWorkspace = errors.New("workspace archived") + func runResume(cmd *cobra.Command, args []string) error { - return doResume(args[0], resumeContainer, resumeShell, resumeForce, resumeAgent, resumeDirect) + err := doResume(args[0], resumeContainer, resumeShell, resumeForce, resumeAgent, resumeDirect) + if errors.Is(err, errArchivedWorkspace) { + cmd.SilenceErrors = true + } + return err } // doResume is the shared resume logic used by both `resume` and `last`. @@ -59,7 +73,7 @@ func doResume(query string, container, useShell, force bool, agentOverride strin if ws.Meta.Status == "archived" { fmt.Fprint(os.Stderr, formatResumeRestoreHint(query)) - return fmt.Errorf("workspace archived") + return errArchivedWorkspace } // updated_at bump (existing v0.4 behavior). diff --git a/cmd/resume_archived_polish_test.go b/cmd/resume_archived_polish_test.go new file mode 100644 index 0000000..253bfb3 --- /dev/null +++ b/cmd/resume_archived_polish_test.go @@ -0,0 +1,117 @@ +package cmd + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +// TestResumeArchivedHintNoDuplicateError exercises resume through Cobra +// (not just runResume directly) so the SilenceErrors path is observed +// end-to-end. After v0.5.4 the [ctask] diagnostic block must print on +// its own — Cobra's default trailing "Error: workspace archived" line +// is suppressed via the conditional SilenceErrors set inside runResume. +// +// This guards against: +// - Reverting to a generic fmt.Errorf("workspace archived") that no +// longer matches the sentinel. +// - Removing the SilenceErrors flip. +// - A future runResume refactor that returns the error before the +// conditional check runs. +func TestResumeArchivedHintNoDuplicateError(t *testing.T) { + withInvocationName(t, "ctask") + + root := t.TempDir() + wsDir := filepath.Join(root, "general", "2026-04-22_archived-poll") + if err := os.MkdirAll(wsDir, 0755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + now := time.Now().UTC().Truncate(time.Second) + archived := now.Add(-time.Hour) + meta := &workspace.TaskMeta{ + ID: "t", Slug: "archived-poll", Title: "archived-poll", + CreatedAt: now, UpdatedAt: archived, + ArchivedAt: &archived, + Status: "archived", + Category: "general", + Type: "task", + Mode: "local", + Agent: "claude", + } + if err := workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta); err != nil { + t.Fatalf("WriteMeta: %v", err) + } + + prevRoot := os.Getenv("CTASK_ROOT") + os.Setenv("CTASK_ROOT", root) + t.Cleanup(func() { + if prevRoot == "" { + os.Unsetenv("CTASK_ROOT") + } else { + os.Setenv("CTASK_ROOT", prevRoot) + } + }) + + // resumeCmd is a package global; restore SilenceErrors after the test + // so other tests against the same command see the original setting. + prevSilence := resumeCmd.SilenceErrors + t.Cleanup(func() { resumeCmd.SilenceErrors = prevSilence }) + + // Drive a fresh Cobra parent so we don't invoke unrelated commands + // during the test. AddCommand removes from the previous parent first. + parent := &cobra.Command{Use: "ctask-test"} + parent.AddCommand(resumeCmd) + t.Cleanup(func() { rootCmd.AddCommand(resumeCmd) }) + + parent.SetArgs([]string{"resume", "archived-poll"}) + + // Cobra writes the trailing "Error: ..." line to its configured + // error output, NOT to os.Stderr by default. Capture both: the + // [ctask] diagnostic goes to os.Stderr (via fmt.Fprint), while the + // would-be Cobra trailing line would go to parent's err output. + var cobraErrBuf bytes.Buffer + parent.SetErr(&cobraErrBuf) + parent.SetOut(&bytes.Buffer{}) + + stderrR, stderrW, _ := os.Pipe() + prevStderr := os.Stderr + os.Stderr = stderrW + defer func() { os.Stderr = prevStderr }() + + execErr := parent.Execute() + + stderrW.Close() + var directStderr bytes.Buffer + directStderr.ReadFrom(stderrR) + + if execErr == nil { + t.Fatal("expected error from resuming archived workspace") + } + if !errors.Is(execErr, errArchivedWorkspace) { + t.Errorf("Execute should return errArchivedWorkspace, got %v", execErr) + } + + // The [ctask] diagnostic must be present (printed to os.Stderr by + // runResume's stderr write). + gotStderr := directStderr.String() + if !strings.Contains(gotStderr, "[ctask] error: workspace") { + t.Errorf("[ctask] diagnostic missing from stderr:\n%s", gotStderr) + } + if !strings.Contains(gotStderr, "ctask restore archived-poll") { + t.Errorf("restore hint missing from stderr:\n%s", gotStderr) + } + + // The Cobra-default "Error: workspace archived" line must NOT appear. + // It would normally land on parent's error writer. + cobraOut := cobraErrBuf.String() + if strings.Contains(cobraOut, "Error:") { + t.Errorf("Cobra default Error: line was not suppressed:\n%s", cobraOut) + } +} From 7704cd93fc9738bd6fd91e4fc1b68e5b9ed61907 Mon Sep 17 00:00:00 2001 From: typebasedio Date: Thu, 14 May 2026 20:01:57 -0400 Subject: [PATCH 7/7] release(v0.5.4): bump version to 0.5.4 --- cmd/root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/root.go b/cmd/root.go index fcf5d8a..6fec2a1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" ) -var version = "0.5.3" +var version = "0.5.4" var rootCmd = &cobra.Command{ Use: "ctask",