From 4b5c1d784ebff3a48a5cc4aeb8d22de98f22419c Mon Sep 17 00:00:00 2001 From: Marcin Balazy
Date: Sat, 10 Oct 2026 17:55:20 +0200 Subject: [PATCH 1/4] refactor(executor): move the executor out of internal/cmd into its own package pm work, pm run-epic, pm finish, the worker guard, the review policy and the pm executor commands - 26 files and their 38 test files - move with git mv to internal/executor. The package clause changes, and the five command constructors root.go registers are exported (NewWorkCmd, NewRunEpicCmd, NewFinishCmd, NewExecutorCmd, NewWorkerGuardCmd); nothing else is exported and no logic changes. internal/executor imports only storage and the leaf helper packages, never internal/cmd. Tests: cmd keeps its own copies of the small store/stdout helpers it used from the executor's test files (helpers_test.go); the root registration check and the `pm context` case of the unreadable project.yaml test stay in cmd, where those commands live. --- internal/cmd/context_test.go | 32 +++++++ internal/cmd/helpers_test.go | 86 +++++++++++++++++++ internal/cmd/root.go | 11 +-- internal/cmd/root_test.go | 15 ++++ internal/{cmd => executor}/executor_crash.go | 2 +- .../{cmd => executor}/executor_crash_test.go | 2 +- internal/{cmd => executor}/executor_doctor.go | 2 +- .../{cmd => executor}/executor_doctor_test.go | 2 +- internal/{cmd => executor}/executor_init.go | 4 +- .../executor_init_playbook.go | 2 +- .../{cmd => executor}/executor_init_test.go | 2 +- internal/{cmd => executor}/executor_show.go | 2 +- .../{cmd => executor}/executor_show_test.go | 16 +--- internal/{cmd => executor}/executor_stats.go | 2 +- .../{cmd => executor}/executor_stats_test.go | 4 +- internal/{cmd => executor}/finish.go | 4 +- internal/{cmd => executor}/finish_prompt.go | 2 +- internal/{cmd => executor}/finish_record.go | 2 +- .../{cmd => executor}/finish_record_test.go | 2 +- internal/{cmd => executor}/finish_run_test.go | 2 +- internal/{cmd => executor}/finish_test.go | 16 +--- .../{cmd => executor}/finish_worktree_test.go | 2 +- internal/{cmd => executor}/review_cap.go | 2 +- internal/{cmd => executor}/review_cap_test.go | 2 +- internal/{cmd => executor}/review_floor.go | 2 +- .../{cmd => executor}/review_floor_test.go | 2 +- internal/{cmd => executor}/review_packet.go | 2 +- .../{cmd => executor}/review_packet_test.go | 2 +- internal/{cmd => executor}/reviewer_agent.go | 2 +- .../{cmd => executor}/reviewer_agent_test.go | 2 +- internal/{cmd => executor}/run_epic.go | 4 +- .../{cmd => executor}/run_epic_exec_test.go | 2 +- internal/{cmd => executor}/run_epic_finish.go | 2 +- .../{cmd => executor}/run_epic_finish_test.go | 2 +- .../{cmd => executor}/run_epic_phases_test.go | 2 +- .../{cmd => executor}/run_epic_plan_test.go | 2 +- internal/{cmd => executor}/run_epic_sub.go | 2 +- internal/{cmd => executor}/run_epic_test.go | 2 +- internal/{cmd => executor}/work.go | 4 +- .../{cmd => executor}/work_baseline_test.go | 2 +- internal/{cmd => executor}/work_effort.go | 2 +- .../{cmd => executor}/work_effort_test.go | 2 +- internal/{cmd => executor}/work_envelope.go | 2 +- .../{cmd => executor}/work_envelope_test.go | 2 +- internal/{cmd => executor}/work_exec_test.go | 2 +- internal/{cmd => executor}/work_failure.go | 2 +- .../{cmd => executor}/work_failure_test.go | 2 +- internal/{cmd => executor}/work_git.go | 2 +- .../{cmd => executor}/work_git_slot_test.go | 2 +- internal/{cmd => executor}/work_git_test.go | 2 +- .../{cmd => executor}/work_heartbeat_test.go | 2 +- internal/{cmd => executor}/work_model_test.go | 2 +- .../{cmd => executor}/work_phases_test.go | 2 +- internal/{cmd => executor}/work_proc_test.go | 2 +- internal/{cmd => executor}/work_prompt.go | 2 +- .../{cmd => executor}/work_resolve_test.go | 2 +- internal/{cmd => executor}/work_result.go | 2 +- .../{cmd => executor}/work_result_test.go | 2 +- internal/{cmd => executor}/work_rig.go | 2 +- internal/{cmd => executor}/work_rig_test.go | 2 +- .../{cmd => executor}/work_runtime_test.go | 2 +- internal/{cmd => executor}/work_test.go | 2 +- .../{cmd => executor}/work_timeout_test.go | 2 +- .../{cmd => executor}/work_unreviewed_test.go | 2 +- internal/{cmd => executor}/work_worktree.go | 2 +- .../{cmd => executor}/work_worktree_test.go | 2 +- internal/{cmd => executor}/worker_guard.go | 4 +- .../{cmd => executor}/worker_guard_test.go | 2 +- 68 files changed, 211 insertions(+), 101 deletions(-) create mode 100644 internal/cmd/helpers_test.go create mode 100644 internal/cmd/root_test.go rename internal/{cmd => executor}/executor_crash.go (99%) rename internal/{cmd => executor}/executor_crash_test.go (99%) rename internal/{cmd => executor}/executor_doctor.go (99%) rename internal/{cmd => executor}/executor_doctor_test.go (99%) rename internal/{cmd => executor}/executor_init.go (99%) rename internal/{cmd => executor}/executor_init_playbook.go (99%) rename internal/{cmd => executor}/executor_init_test.go (99%) rename internal/{cmd => executor}/executor_show.go (99%) rename internal/{cmd => executor}/executor_show_test.go (93%) rename internal/{cmd => executor}/executor_stats.go (99%) rename internal/{cmd => executor}/executor_stats_test.go (99%) rename internal/{cmd => executor}/finish.go (99%) rename internal/{cmd => executor}/finish_prompt.go (99%) rename internal/{cmd => executor}/finish_record.go (99%) rename internal/{cmd => executor}/finish_record_test.go (99%) rename internal/{cmd => executor}/finish_run_test.go (99%) rename internal/{cmd => executor}/finish_test.go (97%) rename internal/{cmd => executor}/finish_worktree_test.go (99%) rename internal/{cmd => executor}/review_cap.go (99%) rename internal/{cmd => executor}/review_cap_test.go (99%) rename internal/{cmd => executor}/review_floor.go (99%) rename internal/{cmd => executor}/review_floor_test.go (99%) rename internal/{cmd => executor}/review_packet.go (99%) rename internal/{cmd => executor}/review_packet_test.go (99%) rename internal/{cmd => executor}/reviewer_agent.go (99%) rename internal/{cmd => executor}/reviewer_agent_test.go (99%) rename internal/{cmd => executor}/run_epic.go (99%) rename internal/{cmd => executor}/run_epic_exec_test.go (99%) rename internal/{cmd => executor}/run_epic_finish.go (99%) rename internal/{cmd => executor}/run_epic_finish_test.go (99%) rename internal/{cmd => executor}/run_epic_phases_test.go (99%) rename internal/{cmd => executor}/run_epic_plan_test.go (99%) rename internal/{cmd => executor}/run_epic_sub.go (99%) rename internal/{cmd => executor}/run_epic_test.go (99%) rename internal/{cmd => executor}/work.go (99%) rename internal/{cmd => executor}/work_baseline_test.go (99%) rename internal/{cmd => executor}/work_effort.go (99%) rename internal/{cmd => executor}/work_effort_test.go (99%) rename internal/{cmd => executor}/work_envelope.go (99%) rename internal/{cmd => executor}/work_envelope_test.go (99%) rename internal/{cmd => executor}/work_exec_test.go (99%) rename internal/{cmd => executor}/work_failure.go (99%) rename internal/{cmd => executor}/work_failure_test.go (99%) rename internal/{cmd => executor}/work_git.go (99%) rename internal/{cmd => executor}/work_git_slot_test.go (99%) rename internal/{cmd => executor}/work_git_test.go (99%) rename internal/{cmd => executor}/work_heartbeat_test.go (99%) rename internal/{cmd => executor}/work_model_test.go (99%) rename internal/{cmd => executor}/work_phases_test.go (99%) rename internal/{cmd => executor}/work_proc_test.go (99%) rename internal/{cmd => executor}/work_prompt.go (99%) rename internal/{cmd => executor}/work_resolve_test.go (99%) rename internal/{cmd => executor}/work_result.go (99%) rename internal/{cmd => executor}/work_result_test.go (99%) rename internal/{cmd => executor}/work_rig.go (99%) rename internal/{cmd => executor}/work_rig_test.go (99%) rename internal/{cmd => executor}/work_runtime_test.go (99%) rename internal/{cmd => executor}/work_test.go (99%) rename internal/{cmd => executor}/work_timeout_test.go (99%) rename internal/{cmd => executor}/work_unreviewed_test.go (99%) rename internal/{cmd => executor}/work_worktree.go (99%) rename internal/{cmd => executor}/work_worktree_test.go (99%) rename internal/{cmd => executor}/worker_guard.go (99%) rename internal/{cmd => executor}/worker_guard_test.go (99%) diff --git a/internal/cmd/context_test.go b/internal/cmd/context_test.go index 148eec3..f9ed0cf 100644 --- a/internal/cmd/context_test.go +++ b/internal/cmd/context_test.go @@ -349,3 +349,35 @@ func TestContextCmdErrors(t *testing.T) { } }) } + +// `pm context` is a read-only diagnostic that never touches the project +// struct, so an unparseable project.yaml - exactly the situation it is run in - +// must not stop it printing its rollup. +func TestContextSurvivesUnreadableProjectYAML(t *testing.T) { + newStore := func(t *testing.T) (*storage.Store, string) { + t.Helper() + store := &storage.Store{Root: t.TempDir()} + if err := store.CreateProject("app", &storage.Project{Name: "App"}); err != nil { + t.Fatalf("create project: %v", err) + } + // Valid enough for ListProjects/ResolveProject to see the project, + // unparseable for ReadProject. + writeTestFile(t, store.ProjectYAML("app"), "name: App\nstatuses: [todo, doing\n\tbad: \"unclosed\n") + if _, err := store.GetProject("app"); err == nil { + t.Fatal("fixture is not corrupt: GetProject must fail for this test to mean anything") + } + return store, "app" + } + + t.Run("pm context prints its rollup", func(t *testing.T) { + store, slug := newStore(t) + addTask(t, store, slug, storage.TaskMeta{ID: "app-1", Title: "A doing task", Status: storage.StatusDoing}, "") + out, err := execContextCmd(t, store, slug) + if err != nil { + t.Fatalf("context must not fail on an unreadable project.yaml, got: %v", err) + } + if !strings.Contains(out, "A doing task") { + t.Errorf("expected the doing task in the rollup, got %q", out) + } + }) +} diff --git a/internal/cmd/helpers_test.go b/internal/cmd/helpers_test.go new file mode 100644 index 0000000..7827afb --- /dev/null +++ b/internal/cmd/helpers_test.go @@ -0,0 +1,86 @@ +package cmd + +import ( + "io" + "os" + "strings" + "sync" + "testing" + + "github.com/mbalazy/pm-cli/internal/storage" +) + +// These helpers have twins in internal/executor's tests: test files cannot be +// imported across packages, and both packages build stores and read stdout. + +// capturePipe points *target at a pipe for the duration of fn and returns what +// fn wrote. The pipe is drained by a concurrent reader: a fixture writing more +// than the OS pipe buffer (~64KB) would otherwise block fn forever and hang the +// whole test binary instead of failing an assertion. Restore + close go through +// a deferred sync.Once so a t.Fatal or panic inside fn cannot leave the stream +// pointing at a dead pipe for every later test in the package. +func capturePipe(t *testing.T, target **os.File, fn func()) string { + t.Helper() + old := *target + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + drained := make(chan string, 1) + go func() { + out, _ := io.ReadAll(r) + r.Close() + drained <- string(out) + }() + + var once sync.Once + restore := func() { + once.Do(func() { + *target = old + w.Close() + }) + } + *target = w + defer restore() + fn() + restore() + return <-drained +} + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + return capturePipe(t, &os.Stdout, fn) +} + +func tempStore(t *testing.T) (*storage.Store, string) { + t.Helper() + root := t.TempDir() + store := &storage.Store{Root: root} + if err := store.CreateProject("proj", &storage.Project{Name: "Proj", Path: t.TempDir()}); err != nil { + t.Fatalf("create project: %v", err) + } + return store, "proj" +} + +func addTask(t *testing.T, store *storage.Store, slug string, meta storage.TaskMeta, body string) *storage.Task { + t.Helper() + task := &storage.Task{Meta: meta, Body: body} + if err := store.AddTask(slug, task); err != nil { + t.Fatalf("add task %s: %v", meta.ID, err) + } + return task +} + +func mustContain(t *testing.T, haystack, needle string) { + t.Helper() + if !strings.Contains(haystack, needle) { + t.Errorf("expected output to contain %q\n---\n%s", needle, haystack) + } +} + +func writeTestFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 703c482..25605f2 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -13,6 +13,7 @@ package cmd import ( + "github.com/mbalazy/pm-cli/internal/executor" "github.com/mbalazy/pm-cli/internal/projectarg" "github.com/mbalazy/pm-cli/internal/storage" "github.com/mbalazy/pm-cli/internal/version" @@ -54,19 +55,19 @@ func NewRootCmd() *cobra.Command { newDoneCmd(store), newEditCmd(store), newBoardCmd(store), - newWorkCmd(store), - newRunEpicCmd(store), - newFinishCmd(store), + executor.NewWorkCmd(store), + executor.NewRunEpicCmd(store), + executor.NewFinishCmd(store), newRunsCmd(store), newServeCmd(store), newConfigCmd(store), - newExecutorCmd(store), + executor.NewExecutorCmd(store), newJournalCmd(store), newTimelineCmd(store), newMcpCmd(store), newMigrateIDsCmd(store), newSessionIDCmd(), - newWorkerGuardCmd(), + executor.NewWorkerGuardCmd(), newDocsCmd(), ) classifyUsage(root) diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go new file mode 100644 index 0000000..708bc64 --- /dev/null +++ b/internal/cmd/root_test.go @@ -0,0 +1,15 @@ +package cmd + +import "testing" + +// `pm finish` has to be reachable from the root command - a subcommand nobody +// registered is a subcommand nobody can run. +func TestFinishIsRegisteredOnRoot(t *testing.T) { + root := NewRootCmd() + for _, c := range root.Commands() { + if c.Name() == "finish" { + return + } + } + t.Fatal("`finish` is not registered on the root command") +} diff --git a/internal/cmd/executor_crash.go b/internal/executor/executor_crash.go similarity index 99% rename from internal/cmd/executor_crash.go rename to internal/executor/executor_crash.go index 3a2638a..5c9de72 100644 --- a/internal/cmd/executor_crash.go +++ b/internal/executor/executor_crash.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "fmt" diff --git a/internal/cmd/executor_crash_test.go b/internal/executor/executor_crash_test.go similarity index 99% rename from internal/cmd/executor_crash_test.go rename to internal/executor/executor_crash_test.go index a78b28e..1cb318d 100644 --- a/internal/cmd/executor_crash_test.go +++ b/internal/executor/executor_crash_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "bytes" diff --git a/internal/cmd/executor_doctor.go b/internal/executor/executor_doctor.go similarity index 99% rename from internal/cmd/executor_doctor.go rename to internal/executor/executor_doctor.go index 726cf3c..835edfb 100644 --- a/internal/cmd/executor_doctor.go +++ b/internal/executor/executor_doctor.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "errors" diff --git a/internal/cmd/executor_doctor_test.go b/internal/executor/executor_doctor_test.go similarity index 99% rename from internal/cmd/executor_doctor_test.go rename to internal/executor/executor_doctor_test.go index f82797e..9fe94b1 100644 --- a/internal/cmd/executor_doctor_test.go +++ b/internal/executor/executor_doctor_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "errors" diff --git a/internal/cmd/executor_init.go b/internal/executor/executor_init.go similarity index 99% rename from internal/cmd/executor_init.go rename to internal/executor/executor_init.go index 41994da..56f2e35 100644 --- a/internal/cmd/executor_init.go +++ b/internal/executor/executor_init.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "encoding/json" @@ -16,7 +16,7 @@ import ( "gopkg.in/yaml.v3" ) -func newExecutorCmd(store storage.TaskStore) *cobra.Command { +func NewExecutorCmd(store storage.TaskStore) *cobra.Command { cmd := &cobra.Command{ Use: "executor", Short: "Executor profile tooling", diff --git a/internal/cmd/executor_init_playbook.go b/internal/executor/executor_init_playbook.go similarity index 99% rename from internal/cmd/executor_init_playbook.go rename to internal/executor/executor_init_playbook.go index 79cf6e4..b0a5341 100644 --- a/internal/cmd/executor_init_playbook.go +++ b/internal/executor/executor_init_playbook.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "fmt" diff --git a/internal/cmd/executor_init_test.go b/internal/executor/executor_init_test.go similarity index 99% rename from internal/cmd/executor_init_test.go rename to internal/executor/executor_init_test.go index 3c0d0c2..7dc660b 100644 --- a/internal/cmd/executor_init_test.go +++ b/internal/executor/executor_init_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "os" diff --git a/internal/cmd/executor_show.go b/internal/executor/executor_show.go similarity index 99% rename from internal/cmd/executor_show.go rename to internal/executor/executor_show.go index 1ad04a0..1366b5b 100644 --- a/internal/cmd/executor_show.go +++ b/internal/executor/executor_show.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "fmt" diff --git a/internal/cmd/executor_show_test.go b/internal/executor/executor_show_test.go similarity index 93% rename from internal/cmd/executor_show_test.go rename to internal/executor/executor_show_test.go index a39b40d..90d50b3 100644 --- a/internal/cmd/executor_show_test.go +++ b/internal/executor/executor_show_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "path/filepath" @@ -165,7 +165,7 @@ func TestDescribeBinding(t *testing.T) { } // A slug-only command must not inherit projectarg.Load's fatal project load. -// `pm context` and `pm executor stats` are read-only diagnostics that never +// `pm context` (its case is in internal/cmd) and `pm executor stats` are read-only diagnostics that never // touch the project struct - before the resolver was shared they printed their // rollup regardless of what project.yaml held, and an unparseable one is // exactly the situation you run them in. @@ -203,18 +203,6 @@ func TestSlugOnlyCommandsSurviveUnreadableProjectYAML(t *testing.T) { } }) - t.Run("pm context prints its rollup", func(t *testing.T) { - store, slug := newStore(t) - addTask(t, store, slug, storage.TaskMeta{ID: "app-1", Title: "A doing task", Status: storage.StatusDoing}, "") - out, err := execContextCmd(t, store, slug) - if err != nil { - t.Fatalf("context must not fail on an unreadable project.yaml, got: %v", err) - } - if !strings.Contains(out, "A doing task") { - t.Errorf("expected the doing task in the rollup, got %q", out) - } - }) - t.Run("pm executor stats prints its note", func(t *testing.T) { store, slug := newStore(t) cmd := newExecutorStatsCmd(store) diff --git a/internal/cmd/executor_stats.go b/internal/executor/executor_stats.go similarity index 99% rename from internal/cmd/executor_stats.go rename to internal/executor/executor_stats.go index a80bd54..4e50c17 100644 --- a/internal/cmd/executor_stats.go +++ b/internal/executor/executor_stats.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "fmt" diff --git a/internal/cmd/executor_stats_test.go b/internal/executor/executor_stats_test.go similarity index 99% rename from internal/cmd/executor_stats_test.go rename to internal/executor/executor_stats_test.go index 3f2481e..080d0fc 100644 --- a/internal/cmd/executor_stats_test.go +++ b/internal/executor/executor_stats_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "bytes" @@ -1007,7 +1007,7 @@ func TestExecutorStatsCmdPrintsRollup(t *testing.T) { func TestExecutorStatsCmdRegistered(t *testing.T) { store, _ := tempStore(t) - for _, c := range newExecutorCmd(store).Commands() { + for _, c := range NewExecutorCmd(store).Commands() { if c.Name() == "stats" { return } diff --git a/internal/cmd/finish.go b/internal/executor/finish.go similarity index 99% rename from internal/cmd/finish.go rename to internal/executor/finish.go index 0e208e1..b70380a 100644 --- a/internal/cmd/finish.go +++ b/internal/executor/finish.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "encoding/json" @@ -33,7 +33,7 @@ import ( // procedure is the `batch-finish-auto` skill's, and a headless worker can // invoke it). -func newFinishCmd(store storage.TaskStore) *cobra.Command { +func NewFinishCmd(store storage.TaskStore) *cobra.Command { var ( opts finishOptions noSim bool diff --git a/internal/cmd/finish_prompt.go b/internal/executor/finish_prompt.go similarity index 99% rename from internal/cmd/finish_prompt.go rename to internal/executor/finish_prompt.go index 8ed38fb..b5403a2 100644 --- a/internal/cmd/finish_prompt.go +++ b/internal/executor/finish_prompt.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "fmt" diff --git a/internal/cmd/finish_record.go b/internal/executor/finish_record.go similarity index 99% rename from internal/cmd/finish_record.go rename to internal/executor/finish_record.go index 071533f..5d4ade0 100644 --- a/internal/cmd/finish_record.go +++ b/internal/executor/finish_record.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "fmt" diff --git a/internal/cmd/finish_record_test.go b/internal/executor/finish_record_test.go similarity index 99% rename from internal/cmd/finish_record_test.go rename to internal/executor/finish_record_test.go index 4469322..a04e750 100644 --- a/internal/cmd/finish_record_test.go +++ b/internal/executor/finish_record_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "os" diff --git a/internal/cmd/finish_run_test.go b/internal/executor/finish_run_test.go similarity index 99% rename from internal/cmd/finish_run_test.go rename to internal/executor/finish_run_test.go index 4f68a05..c766cd4 100644 --- a/internal/cmd/finish_run_test.go +++ b/internal/executor/finish_run_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "bytes" diff --git a/internal/cmd/finish_test.go b/internal/executor/finish_test.go similarity index 97% rename from internal/cmd/finish_test.go rename to internal/executor/finish_test.go index 30a9239..2430f25 100644 --- a/internal/cmd/finish_test.go +++ b/internal/executor/finish_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "encoding/json" @@ -16,7 +16,7 @@ import ( // subcommand built on its own would never see it). func runFinishCmd(t *testing.T, store storage.TaskStore, args ...string) (string, error) { t.Helper() - cmd := newFinishCmd(store) + cmd := NewFinishCmd(store) var out strings.Builder // Never a nil slice: cobra falls back to os.Args[1:] when args is nil, which // would feed the test binary's own flags to the command (the same trap @@ -335,18 +335,6 @@ func TestFinishBareCommandIsASkeleton(t *testing.T) { } } -// `pm finish` has to be reachable from the root command - a subcommand nobody -// registered is a subcommand nobody can run. -func TestFinishIsRegisteredOnRoot(t *testing.T) { - root := NewRootCmd() - for _, c := range root.Commands() { - if c.Name() == "finish" { - return - } - } - t.Fatal("`finish` is not registered on the root command") -} - // The tracker id becomes a file name, and release UNLINKS that file, so an id // carrying a path separator must be refused before either touches the disk. func TestFinishRejectsUnsafeTrackerID(t *testing.T) { diff --git a/internal/cmd/finish_worktree_test.go b/internal/executor/finish_worktree_test.go similarity index 99% rename from internal/cmd/finish_worktree_test.go rename to internal/executor/finish_worktree_test.go index e52d36c..3d57305 100644 --- a/internal/cmd/finish_worktree_test.go +++ b/internal/executor/finish_worktree_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "os" diff --git a/internal/cmd/review_cap.go b/internal/executor/review_cap.go similarity index 99% rename from internal/cmd/review_cap.go rename to internal/executor/review_cap.go index 4e3fc7d..42df2ee 100644 --- a/internal/cmd/review_cap.go +++ b/internal/executor/review_cap.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "os/exec" diff --git a/internal/cmd/review_cap_test.go b/internal/executor/review_cap_test.go similarity index 99% rename from internal/cmd/review_cap_test.go rename to internal/executor/review_cap_test.go index 98e819b..1814997 100644 --- a/internal/cmd/review_cap_test.go +++ b/internal/executor/review_cap_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "bytes" diff --git a/internal/cmd/review_floor.go b/internal/executor/review_floor.go similarity index 99% rename from internal/cmd/review_floor.go rename to internal/executor/review_floor.go index 33eaece..29a04bb 100644 --- a/internal/cmd/review_floor.go +++ b/internal/executor/review_floor.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "fmt" diff --git a/internal/cmd/review_floor_test.go b/internal/executor/review_floor_test.go similarity index 99% rename from internal/cmd/review_floor_test.go rename to internal/executor/review_floor_test.go index d44df6b..4a11409 100644 --- a/internal/cmd/review_floor_test.go +++ b/internal/executor/review_floor_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "bytes" diff --git a/internal/cmd/review_packet.go b/internal/executor/review_packet.go similarity index 99% rename from internal/cmd/review_packet.go rename to internal/executor/review_packet.go index ae64317..2a086dc 100644 --- a/internal/cmd/review_packet.go +++ b/internal/executor/review_packet.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "os" diff --git a/internal/cmd/review_packet_test.go b/internal/executor/review_packet_test.go similarity index 99% rename from internal/cmd/review_packet_test.go rename to internal/executor/review_packet_test.go index aacb1cd..8423d58 100644 --- a/internal/cmd/review_packet_test.go +++ b/internal/executor/review_packet_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "bytes" diff --git a/internal/cmd/reviewer_agent.go b/internal/executor/reviewer_agent.go similarity index 99% rename from internal/cmd/reviewer_agent.go rename to internal/executor/reviewer_agent.go index aff394d..a612a38 100644 --- a/internal/cmd/reviewer_agent.go +++ b/internal/executor/reviewer_agent.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "encoding/json" diff --git a/internal/cmd/reviewer_agent_test.go b/internal/executor/reviewer_agent_test.go similarity index 99% rename from internal/cmd/reviewer_agent_test.go rename to internal/executor/reviewer_agent_test.go index 06936ef..ed987af 100644 --- a/internal/cmd/reviewer_agent_test.go +++ b/internal/executor/reviewer_agent_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "bytes" diff --git a/internal/cmd/run_epic.go b/internal/executor/run_epic.go similarity index 99% rename from internal/cmd/run_epic.go rename to internal/executor/run_epic.go index 7b8123f..40efac4 100644 --- a/internal/cmd/run_epic.go +++ b/internal/executor/run_epic.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "context" @@ -58,7 +58,7 @@ type subOutcome struct { branch string // branch the sub's work landed on; empty for skips (no worker ran) } -func newRunEpicCmd(store storage.TaskStore) *cobra.Command { +func NewRunEpicCmd(store storage.TaskStore) *cobra.Command { var ( model string effort string diff --git a/internal/cmd/run_epic_exec_test.go b/internal/executor/run_epic_exec_test.go similarity index 99% rename from internal/cmd/run_epic_exec_test.go rename to internal/executor/run_epic_exec_test.go index f6fdade..083a3e1 100644 --- a/internal/cmd/run_epic_exec_test.go +++ b/internal/executor/run_epic_exec_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "fmt" diff --git a/internal/cmd/run_epic_finish.go b/internal/executor/run_epic_finish.go similarity index 99% rename from internal/cmd/run_epic_finish.go rename to internal/executor/run_epic_finish.go index e28fb56..fb6dffe 100644 --- a/internal/cmd/run_epic_finish.go +++ b/internal/executor/run_epic_finish.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "fmt" diff --git a/internal/cmd/run_epic_finish_test.go b/internal/executor/run_epic_finish_test.go similarity index 99% rename from internal/cmd/run_epic_finish_test.go rename to internal/executor/run_epic_finish_test.go index bbe3ac3..ef6c0cc 100644 --- a/internal/cmd/run_epic_finish_test.go +++ b/internal/executor/run_epic_finish_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "io" diff --git a/internal/cmd/run_epic_phases_test.go b/internal/executor/run_epic_phases_test.go similarity index 99% rename from internal/cmd/run_epic_phases_test.go rename to internal/executor/run_epic_phases_test.go index d66a82a..aa3b594 100644 --- a/internal/cmd/run_epic_phases_test.go +++ b/internal/executor/run_epic_phases_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "os" diff --git a/internal/cmd/run_epic_plan_test.go b/internal/executor/run_epic_plan_test.go similarity index 99% rename from internal/cmd/run_epic_plan_test.go rename to internal/executor/run_epic_plan_test.go index 2bdb96b..442b979 100644 --- a/internal/cmd/run_epic_plan_test.go +++ b/internal/executor/run_epic_plan_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "io" diff --git a/internal/cmd/run_epic_sub.go b/internal/executor/run_epic_sub.go similarity index 99% rename from internal/cmd/run_epic_sub.go rename to internal/executor/run_epic_sub.go index 397044f..64d281e 100644 --- a/internal/cmd/run_epic_sub.go +++ b/internal/executor/run_epic_sub.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "errors" diff --git a/internal/cmd/run_epic_test.go b/internal/executor/run_epic_test.go similarity index 99% rename from internal/cmd/run_epic_test.go rename to internal/executor/run_epic_test.go index 813647c..400455e 100644 --- a/internal/cmd/run_epic_test.go +++ b/internal/executor/run_epic_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "bytes" diff --git a/internal/cmd/work.go b/internal/executor/work.go similarity index 99% rename from internal/cmd/work.go rename to internal/executor/work.go index 6798c04..5b4b1a4 100644 --- a/internal/cmd/work.go +++ b/internal/executor/work.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "bytes" @@ -119,7 +119,7 @@ const workerResultSchema = `{ "required": ["status", "summary", "branch", "commits", "unresolved"] }` -func newWorkCmd(store storage.TaskStore) *cobra.Command { +func NewWorkCmd(store storage.TaskStore) *cobra.Command { var ( epic bool dryRun bool diff --git a/internal/cmd/work_baseline_test.go b/internal/executor/work_baseline_test.go similarity index 99% rename from internal/cmd/work_baseline_test.go rename to internal/executor/work_baseline_test.go index be01e90..0535521 100644 --- a/internal/cmd/work_baseline_test.go +++ b/internal/executor/work_baseline_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "os" diff --git a/internal/cmd/work_effort.go b/internal/executor/work_effort.go similarity index 99% rename from internal/cmd/work_effort.go rename to internal/executor/work_effort.go index c73c702..5bcff45 100644 --- a/internal/cmd/work_effort.go +++ b/internal/executor/work_effort.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "bufio" diff --git a/internal/cmd/work_effort_test.go b/internal/executor/work_effort_test.go similarity index 99% rename from internal/cmd/work_effort_test.go rename to internal/executor/work_effort_test.go index 1e912df..edbc05c 100644 --- a/internal/cmd/work_effort_test.go +++ b/internal/executor/work_effort_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "fmt" diff --git a/internal/cmd/work_envelope.go b/internal/executor/work_envelope.go similarity index 99% rename from internal/cmd/work_envelope.go rename to internal/executor/work_envelope.go index 27681e8..c3c2333 100644 --- a/internal/cmd/work_envelope.go +++ b/internal/executor/work_envelope.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "bytes" diff --git a/internal/cmd/work_envelope_test.go b/internal/executor/work_envelope_test.go similarity index 99% rename from internal/cmd/work_envelope_test.go rename to internal/executor/work_envelope_test.go index 01b2547..be62198 100644 --- a/internal/cmd/work_envelope_test.go +++ b/internal/executor/work_envelope_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "bytes" diff --git a/internal/cmd/work_exec_test.go b/internal/executor/work_exec_test.go similarity index 99% rename from internal/cmd/work_exec_test.go rename to internal/executor/work_exec_test.go index 9bd1849..9b8d409 100644 --- a/internal/cmd/work_exec_test.go +++ b/internal/executor/work_exec_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "fmt" diff --git a/internal/cmd/work_failure.go b/internal/executor/work_failure.go similarity index 99% rename from internal/cmd/work_failure.go rename to internal/executor/work_failure.go index 1962a6a..29f158b 100644 --- a/internal/cmd/work_failure.go +++ b/internal/executor/work_failure.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "bufio" diff --git a/internal/cmd/work_failure_test.go b/internal/executor/work_failure_test.go similarity index 99% rename from internal/cmd/work_failure_test.go rename to internal/executor/work_failure_test.go index 2dde0c3..3c775a6 100644 --- a/internal/cmd/work_failure_test.go +++ b/internal/executor/work_failure_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "errors" diff --git a/internal/cmd/work_git.go b/internal/executor/work_git.go similarity index 99% rename from internal/cmd/work_git.go rename to internal/executor/work_git.go index 7eedb7d..1d4727d 100644 --- a/internal/cmd/work_git.go +++ b/internal/executor/work_git.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "context" diff --git a/internal/cmd/work_git_slot_test.go b/internal/executor/work_git_slot_test.go similarity index 99% rename from internal/cmd/work_git_slot_test.go rename to internal/executor/work_git_slot_test.go index 2e67f6e..847fd7f 100644 --- a/internal/cmd/work_git_slot_test.go +++ b/internal/executor/work_git_slot_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "bytes" diff --git a/internal/cmd/work_git_test.go b/internal/executor/work_git_test.go similarity index 99% rename from internal/cmd/work_git_test.go rename to internal/executor/work_git_test.go index cafd761..0ca1a36 100644 --- a/internal/cmd/work_git_test.go +++ b/internal/executor/work_git_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "bytes" diff --git a/internal/cmd/work_heartbeat_test.go b/internal/executor/work_heartbeat_test.go similarity index 99% rename from internal/cmd/work_heartbeat_test.go rename to internal/executor/work_heartbeat_test.go index be9c962..19411cf 100644 --- a/internal/cmd/work_heartbeat_test.go +++ b/internal/executor/work_heartbeat_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "os" diff --git a/internal/cmd/work_model_test.go b/internal/executor/work_model_test.go similarity index 99% rename from internal/cmd/work_model_test.go rename to internal/executor/work_model_test.go index 539867f..2d682d6 100644 --- a/internal/cmd/work_model_test.go +++ b/internal/executor/work_model_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "strings" diff --git a/internal/cmd/work_phases_test.go b/internal/executor/work_phases_test.go similarity index 99% rename from internal/cmd/work_phases_test.go rename to internal/executor/work_phases_test.go index 4825d57..42b95c2 100644 --- a/internal/cmd/work_phases_test.go +++ b/internal/executor/work_phases_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "os" diff --git a/internal/cmd/work_proc_test.go b/internal/executor/work_proc_test.go similarity index 99% rename from internal/cmd/work_proc_test.go rename to internal/executor/work_proc_test.go index f2ba7ee..2703aa6 100644 --- a/internal/cmd/work_proc_test.go +++ b/internal/executor/work_proc_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "os" diff --git a/internal/cmd/work_prompt.go b/internal/executor/work_prompt.go similarity index 99% rename from internal/cmd/work_prompt.go rename to internal/executor/work_prompt.go index d7f67d9..666e361 100644 --- a/internal/cmd/work_prompt.go +++ b/internal/executor/work_prompt.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "fmt" diff --git a/internal/cmd/work_resolve_test.go b/internal/executor/work_resolve_test.go similarity index 99% rename from internal/cmd/work_resolve_test.go rename to internal/executor/work_resolve_test.go index 9d3d6b2..c6aad9f 100644 --- a/internal/cmd/work_resolve_test.go +++ b/internal/executor/work_resolve_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "errors" diff --git a/internal/cmd/work_result.go b/internal/executor/work_result.go similarity index 99% rename from internal/cmd/work_result.go rename to internal/executor/work_result.go index 6732098..7c52fc3 100644 --- a/internal/cmd/work_result.go +++ b/internal/executor/work_result.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "bufio" diff --git a/internal/cmd/work_result_test.go b/internal/executor/work_result_test.go similarity index 99% rename from internal/cmd/work_result_test.go rename to internal/executor/work_result_test.go index aad5fe6..10bb595 100644 --- a/internal/cmd/work_result_test.go +++ b/internal/executor/work_result_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "bytes" diff --git a/internal/cmd/work_rig.go b/internal/executor/work_rig.go similarity index 99% rename from internal/cmd/work_rig.go rename to internal/executor/work_rig.go index 1061987..97f5644 100644 --- a/internal/cmd/work_rig.go +++ b/internal/executor/work_rig.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "bytes" diff --git a/internal/cmd/work_rig_test.go b/internal/executor/work_rig_test.go similarity index 99% rename from internal/cmd/work_rig_test.go rename to internal/executor/work_rig_test.go index a1dfbd2..ba4a7fd 100644 --- a/internal/cmd/work_rig_test.go +++ b/internal/executor/work_rig_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "bytes" diff --git a/internal/cmd/work_runtime_test.go b/internal/executor/work_runtime_test.go similarity index 99% rename from internal/cmd/work_runtime_test.go rename to internal/executor/work_runtime_test.go index e704e20..7976476 100644 --- a/internal/cmd/work_runtime_test.go +++ b/internal/executor/work_runtime_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "os" diff --git a/internal/cmd/work_test.go b/internal/executor/work_test.go similarity index 99% rename from internal/cmd/work_test.go rename to internal/executor/work_test.go index e52db6b..037f66a 100644 --- a/internal/cmd/work_test.go +++ b/internal/executor/work_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "os" diff --git a/internal/cmd/work_timeout_test.go b/internal/executor/work_timeout_test.go similarity index 99% rename from internal/cmd/work_timeout_test.go rename to internal/executor/work_timeout_test.go index 236bd7d..548bada 100644 --- a/internal/cmd/work_timeout_test.go +++ b/internal/executor/work_timeout_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "strings" diff --git a/internal/cmd/work_unreviewed_test.go b/internal/executor/work_unreviewed_test.go similarity index 99% rename from internal/cmd/work_unreviewed_test.go rename to internal/executor/work_unreviewed_test.go index f0e6d1c..b90a631 100644 --- a/internal/cmd/work_unreviewed_test.go +++ b/internal/executor/work_unreviewed_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "os" diff --git a/internal/cmd/work_worktree.go b/internal/executor/work_worktree.go similarity index 99% rename from internal/cmd/work_worktree.go rename to internal/executor/work_worktree.go index 9a06882..b49c36b 100644 --- a/internal/cmd/work_worktree.go +++ b/internal/executor/work_worktree.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "errors" diff --git a/internal/cmd/work_worktree_test.go b/internal/executor/work_worktree_test.go similarity index 99% rename from internal/cmd/work_worktree_test.go rename to internal/executor/work_worktree_test.go index b1e475c..05fbf83 100644 --- a/internal/cmd/work_worktree_test.go +++ b/internal/executor/work_worktree_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "os" diff --git a/internal/cmd/worker_guard.go b/internal/executor/worker_guard.go similarity index 99% rename from internal/cmd/worker_guard.go rename to internal/executor/worker_guard.go index 727aef0..fc27362 100644 --- a/internal/cmd/worker_guard.go +++ b/internal/executor/worker_guard.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "encoding/json" @@ -512,7 +512,7 @@ func runWorkerGuard(in io.Reader, out, errOut io.Writer, opts guardOptions) int return 0 } -func newWorkerGuardCmd() *cobra.Command { +func NewWorkerGuardCmd() *cobra.Command { var opts guardOptions c := &cobra.Command{ Use: "worker-guard", diff --git a/internal/cmd/worker_guard_test.go b/internal/executor/worker_guard_test.go similarity index 99% rename from internal/cmd/worker_guard_test.go rename to internal/executor/worker_guard_test.go index 5cc5ef1..a91d5f3 100644 --- a/internal/cmd/worker_guard_test.go +++ b/internal/executor/worker_guard_test.go @@ -1,4 +1,4 @@ -package cmd +package executor import ( "bytes" From 420dd42d8d2b814c210cbf89b26e85d005ad4a56 Mon Sep 17 00:00:00 2001 From: Marcin Balazy
Date: Sat, 10 Oct 2026 17:57:28 +0200
Subject: [PATCH 2/4] docs: point CLAUDE.md and the docs at internal/executor
---
CLAUDE.md | 6 +++---
docs/cli.md | 2 +-
docs/development.md | 2 +-
docs/executor.md | 14 +++++++-------
docs/solo-and-batch.md | 2 +-
5 files changed, 13 insertions(+), 13 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 6fb24b0..8cd9198 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -60,7 +60,7 @@ Always add tests when implementing new features or fixing bugs. Conventions:
- Same package tests (access to unexported functions)
- `setupTestStore(t)` helper in `store_test.go` for tests needing a Store with project + tasks
- Invariant tests (links merge, Log append, Spec rewrite, brief lifecycle) live in `tools_test.go`; the `ApplySpec`/`ExtractSpec` unit tests live in `internal/storage/spec_test.go`
-- **Executor paths**: test through a FAKE `claude` binary (`fakeClaude` in `internal/cmd/work_exec_test.go` - a PATH-prepended script emitting a canned result envelope), never by spending tokens; covers verified/blocked/crash/garbage paths of `executeWork` (the envelope's green word was `merged` before 0.34.0 and is still accepted - `normalizeWorkerStatus`)
+- **Executor paths**: test through a FAKE `claude` binary (`fakeClaude` in `internal/executor/work_exec_test.go` - a PATH-prepended script emitting a canned result envelope), never by spending tokens; covers verified/blocked/crash/garbage paths of `executeWork` (the envelope's green word was `merged` before 0.34.0 and is still accepted - `normalizeWorkerStatus`)
- **MCP handlers**: test END-TO-END via the SDK in-memory transport (`startMCP` in `internal/mcpserver/e2e_test.go`) so the real registered closures run - do NOT re-simulate handler logic in the test body (the pre-0.23 tests did, which is why coverage sat at 12%)
- **Concurrency**: lock/race invariants live in `internal/storage/lock_test.go` + the `TestAcquireWorktreeLockAtomicRace` two-acquirer hammer in `worktree_test.go`; run suspects under `-race`
@@ -188,11 +188,11 @@ A **journal** is a per-project append-only record of how ONE declared, repeatedl
## Executor (`pm work` / `pm run-epic` / `pm finish`)
-Runs pm tasks/epics via isolated headless `claude -p` workers (implement -> test -> review -> verify). **The full internals live in `docs/executor.md`** (worktree slots, review policy, run-state/journal/heartbeat, kill + crash forensics, acceptance claims, baseline, independent mode, telemetry) - read it before touching `internal/cmd/work*.go`, `run_epic*.go`, `finish*.go`, `worker_guard.go` or `internal/storage/executor*.go`. The rules that bite regardless of what you are working on:
+Runs pm tasks/epics via isolated headless `claude -p` workers (implement -> test -> review -> verify). **The full internals live in `docs/executor.md`** (worktree slots, review policy, run-state/journal/heartbeat, kill + crash forensics, acceptance claims, baseline, independent mode, telemetry) - read it before touching `internal/executor/` (`work*.go`, `run_epic*.go`, `finish*.go`, `worker_guard.go`, `review_*.go`, `executor_*.go`) or `internal/storage/executor*.go`. `internal/cmd` only registers its commands (`root.go`), and the executor never imports `internal/cmd`. The rules that bite regardless of what you are working on:
- **Headless workers cannot write under `~/.claude`** (CC sensitive-path guard, a hard refusal with nobody to approve) - source lives in an ordinary checkout, data in `~/.claude/pm/`.
- **Review is decided by pm, not the worker** (0.38.0): reviewer count, model (`executor.review_model`, default opus), packet (the whole diff) and rounds (`executor.fix_rounds`) are enforced by the `pm worker-guard` PreToolUse hook, which also blocks hook bypasses and `.git/` writes - in `--yolo` too. Since pm-cli-130 there is a FLOOR as well: a `verified` on a non-empty production diff with zero reviewer spawns that ran is refused at `StructuredOutput` time and, if it still arrives, demoted to `blocked` + `REVIEW-SKIPPED:` by `demoteUnreviewed` (`review_floor.go`) before anything merges or pushes.
-- **Test executor paths through the fake `claude` binary** (`fakeClaude` in `internal/cmd/work_exec_test.go`), never by spending tokens; the envelope has two shapes (object, or a message array whose last element is the object - `decodeClaudeOutput`).
+- **Test executor paths through the fake `claude` binary** (`fakeClaude` in `internal/executor/work_exec_test.go`), never by spending tokens; the envelope has two shapes (object, or a message array whose last element is the object - `decodeClaudeOutput`).
- **Every run journals itself** (`
Date: Sat, 10 Oct 2026 18:07:40 +0200
Subject: [PATCH 3/4] docs: point the worktree-slot rule at
internal/executor/work_worktree.go
---
docs/executor.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/executor.md b/docs/executor.md
index 56ea753..2893bd1 100644
--- a/docs/executor.md
+++ b/docs/executor.md
@@ -175,7 +175,7 @@ Runs pm tasks/epics via **isolated headless `claude -p` workers** (implement →
- **`skillNames` follows symlinks** (0.29.0): the test is "does `
Date: Sat, 10 Oct 2026 18:09:18 +0200
Subject: [PATCH 4/4] refactor: name the executor's new home in the comments
that pointed at internal/cmd
---
cmd/pm/main.go | 6 +++---
internal/storage/review_telemetry.go | 2 +-
internal/storage/store.go | 2 +-
3 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/cmd/pm/main.go b/cmd/pm/main.go
index d56229e..dafa661 100644
--- a/cmd/pm/main.go
+++ b/cmd/pm/main.go
@@ -1,9 +1,9 @@
// Command pm is the pm binary: the CLI, the kanban TUI (`pm board`, or a bare
// `pm`), the MCP server Claude Code registers (`pm mcp`), the cockpit's HTTP
// server (`pm serve`) and the unattended executor (`pm work`, `pm run-epic`,
-// `pm finish`). Every subcommand lives in internal/cmd; this file builds the
-// root command and stamps the process start time the board measures its own
-// startup against.
+// `pm finish`). Every subcommand is registered in internal/cmd (the executor's
+// come from internal/executor); this file builds the root command and stamps
+// the process start time the board measures its own startup against.
package main
import (
diff --git a/internal/storage/review_telemetry.go b/internal/storage/review_telemetry.go
index e159155..d5db3db 100644
--- a/internal/storage/review_telemetry.go
+++ b/internal/storage/review_telemetry.go
@@ -165,7 +165,7 @@ type ReviewTelemetry struct {
// Reviewers is how many of Spawns pm treated as REVIEWERS: top-level,
// allowed, of a generic type (or none, which the guard turns into the
// reviewer type). It is the number the review FLOOR is judged against
- // (see cmd's review_floor.go): a custom-type helper is not a review, and
+ // (see internal/executor/review_floor.go): a custom-type helper is not a review, and
// a refused spawn never ran. Kept apart from Spawns rather than replacing
// it because Spawns is what every older line and stat already means.
Reviewers int `json:"reviewers,omitempty"`
diff --git a/internal/storage/store.go b/internal/storage/store.go
index 9033618..6bde550 100644
--- a/internal/storage/store.go
+++ b/internal/storage/store.go
@@ -63,7 +63,7 @@ func (s *Store) RootDir() string {
// warnLockDegrade reports a silent-degrade hazard: a caller that could not
// take the project lock and is about to write UNLOCKED anyway. Without this,
// a concurrent session's edit can get clobbered with no trace - see
-// applyWorkerResult (internal/cmd/work.go), the one site that already got
+// applyWorkerResult (internal/executor/work.go), the one site that already got
// this right, whose wording this mirrors.
func warnLockDegrade(op, slug string, lockErr error) {
fmt.Fprintf(os.Stderr, "pm: project lock unavailable for %q (%v) - %s without it\n", slug, lockErr, op)