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** (`/.executor/journal.jsonl`, append-only; `pm executor stats` is the rollup, `/executor-retro` the reader) - a new run-time fact belongs in the run-state + journal, not in a log line. - **The web can start and stop runs too** (pm-cli-118-21): `pm serve`'s `POST /api/runs/{p}/{id}/{action}` goes through `internal/runctl`, which mirrors the board's launch/kill/claim rules (argv preview = the argv that runs, never `--sim`, `PM_LAUNCH_SOURCE=cockpit` -> journal `source`, a claim session `cockpit-` with a 3-min heartbeat for at most 1 h). A change to how the board launches or kills has a twin in `runctl` to keep in step. - **Tokens, not dollars, are what the subscription window meters** (0.55.0): per-sub `tokens` off the envelope's `usage`; workers run under `executor.worker_claude_config_dir` when set (a slim config dir without the user-scope skill catalogue / CLAUDE.md), and the worker prompt carries a call discipline (no prose between tools, batch independent calls) - see pm-cli-119. 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/docs/cli.md b/docs/cli.md index fe91087..df40c85 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,6 +1,6 @@ # CLI reference -Every command the `pm` binary exposes, with its flags and defaults, derived from `internal/cmd`. This is for people running pm from a shell or a script; an agent normally goes through the MCP server instead ([mcp.md](mcp.md)). `pm help ` and `pm completion ` come from cobra, and `--help` prints any command's flags. A bare `pm` opens the board. +Every command the `pm` binary exposes, with its flags and defaults, derived from `internal/cmd` (and `internal/executor` for `pm work`, `pm run-epic`, `pm finish` and `pm executor`). This is for people running pm from a shell or a script; an agent normally goes through the MCP server instead ([mcp.md](mcp.md)). `pm help ` and `pm completion ` come from cobra, and `--help` prints any command's flags. A bare `pm` opens the board. Argument conventions: `` is a project slug (a case-folded prefix works when it is unique), `` is a full task id such as `acme-12` unless the command says it searches. `[project]` in brackets means the project is optional and is detected from the current directory when omitted, by matching cwd against each project's `path`. diff --git a/docs/development.md b/docs/development.md index 677760b..02fcdb8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -30,7 +30,7 @@ go test ./internal/storage/ -run TestSlugify -v # one test - Go stdlib `testing`, table-driven, `t.Run("case", ...)` subtests, `t.TempDir()` for anything that touches disk. - Tests live in the same package as the code so they reach unexported functions. `setupTestStore(t)` in `internal/storage/store_test.go` builds a store with a project and tasks. - The write-rule invariants (links merge, Log appends, Spec rewrites, brief lifecycle) live in `internal/mcpserver/tools_test.go`; `ApplySpec` and `ExtractSpec` have unit tests in `internal/storage/spec_test.go`. -- Executor paths run through the fake `claude` binary (`fakeClaude` in `internal/cmd/work_exec_test.go`), covering the verified, blocked, crash and garbage-output paths of a worker. The envelope has two shapes, an object or a message array whose last element is the object. +- Executor paths run through the fake `claude` binary (`fakeClaude` in `internal/executor/work_exec_test.go`), covering the verified, blocked, crash and garbage-output paths of a worker. The envelope has two shapes, an object or a message array whose last element is the object. - MCP handlers are tested end to end through the SDK's in-memory transport (`startMCP` in `internal/mcpserver/e2e_test.go`) so the real registered closures run; the test body never re-simulates handler logic. - Lock and race invariants live in `internal/storage/lock_test.go` and the two-acquirer hammer in `internal/storage/worktree_test.go`. Run suspects under `-race`; `make test-race` runs the whole suite that way before anything touching locking is pushed. - HTTP routes are tested in `internal/server/server_test.go` with `httptest` over the same temp-store fixture, including the SSE feed and the remote-runs hook through a fake, never ssh. diff --git a/docs/executor.md b/docs/executor.md index 295fd74..2893bd1 100644 --- a/docs/executor.md +++ b/docs/executor.md @@ -94,11 +94,11 @@ Runs pm tasks/epics via **isolated headless `claude -p` workers** (implement → ### The three run kinds -- **`pm work [project] `** (`internal/cmd/work.go`, `work_prompt.go`, `work_git.go`) - the ATOM: one fresh `claude -p` in the project dir, inner loop on one task, returns `{status,summary,branch,commits,unresolved}`. Standalone -> draft PR; `--epic` -> commit only. `planWork` (assemble, no side effects) + `executeWork` (run + record); `--dry-run` prints prompt+argv without spending tokens. Flags: `--model` (opus, or `executor.model`), `--effort` (unset, or `executor.effort`), `--max-turns` (150), `--timeout` (120m), `--yolo`, `--allow-dirty`, `--additional` (+ `--slot N`, `--base`). (history: docs/design-log.md) -- **`pm run-epic [project] `** (`internal/cmd/run_epic.go` + `run_epic_sub.go`) - the MANAGER: side-effect-free `planEpic` (what `--dry-run` prints) + `executeEpic`; one `driveSubFlow` drives a sub in both modes, the mode differences live in the `subFlow` hook struct. Creates `epic/`, drives ready subs sequentially by `Order`, each on `feat/` off the integration branch, merges back `--no-ff` on verify-green -> `done_status`. Blocked/failed/conflict -> park on `waiting` + continue; reasons bubble to the parent's `## Manager Notes` (`recordSubFeedback`). Re-entrant (skips merged/done; a dead prior run's `doing` sub is RECOVERED - `crashRecoveredSubs`); ends with ONE draft epic->main PR; never merges to main, never closes the parent. (history: docs/design-log.md) -- **`pm finish [tracker]` - the acceptance is the THIRD run kind** (0.41.0, `internal/cmd/finish.go`): spawns a headless worker whose prompt INVOKES the `batch-finish-auto` skill - the procedure stays in `~/.claude/skills`, pm supplies process, harness and guard. `--yolo` by default (worker-guard still attached), user-scope MCP KEPT (`userMCP: true` - the skills drive pm through MCP), `--sim` only when a human is present - detached runs never touch the simulator. Claim first (`pm finish claim|release|status `): a TTL lock (10m, heartbeat-refreshed) stored NEXT TO THE RUN's run-state, host-stamped, never pid-based - the run may live on another machine. +- **`pm work [project] `** (`internal/executor/work.go`, `work_prompt.go`, `work_git.go`) - the ATOM: one fresh `claude -p` in the project dir, inner loop on one task, returns `{status,summary,branch,commits,unresolved}`. Standalone -> draft PR; `--epic` -> commit only. `planWork` (assemble, no side effects) + `executeWork` (run + record); `--dry-run` prints prompt+argv without spending tokens. Flags: `--model` (opus, or `executor.model`), `--effort` (unset, or `executor.effort`), `--max-turns` (150), `--timeout` (120m), `--yolo`, `--allow-dirty`, `--additional` (+ `--slot N`, `--base`). (history: docs/design-log.md) +- **`pm run-epic [project] `** (`internal/executor/run_epic.go` + `run_epic_sub.go`) - the MANAGER: side-effect-free `planEpic` (what `--dry-run` prints) + `executeEpic`; one `driveSubFlow` drives a sub in both modes, the mode differences live in the `subFlow` hook struct. Creates `epic/`, drives ready subs sequentially by `Order`, each on `feat/` off the integration branch, merges back `--no-ff` on verify-green -> `done_status`. Blocked/failed/conflict -> park on `waiting` + continue; reasons bubble to the parent's `## Manager Notes` (`recordSubFeedback`). Re-entrant (skips merged/done; a dead prior run's `doing` sub is RECOVERED - `crashRecoveredSubs`); ends with ONE draft epic->main PR; never merges to main, never closes the parent. (history: docs/design-log.md) +- **`pm finish [tracker]` - the acceptance is the THIRD run kind** (0.41.0, `internal/executor/finish.go`): spawns a headless worker whose prompt INVOKES the `batch-finish-auto` skill - the procedure stays in `~/.claude/skills`, pm supplies process, harness and guard. `--yolo` by default (worker-guard still attached), user-scope MCP KEPT (`userMCP: true` - the skills drive pm through MCP), `--sim` only when a human is present - detached runs never touch the simulator. Claim first (`pm finish claim|release|status `): a TTL lock (10m, heartbeat-refreshed) stored NEXT TO THE RUN's run-state, host-stamped, never pid-based - the run may live on another machine. - A re-claim from the same host + `--session` REFRESHES the live claim in place (`started` holds, TTL moves - the hand-driven acceptance's heartbeat, 0.49.0); empty session matches nothing, and the refresh is refused inside the last refresh-tick before expiry (RefreshFinishClaim's edge). Acceptance files are the siblings `.finish.json`/`.finish.log`/`.finish.md` (`storage.RunKindFinish` routes the paths - carry the constant verbatim). **A hand-driven acceptance must `pm finish record --result done|partial|blocked`** (0.51.0, `internal/cmd/finish_record.go`) - it writes the same `.finish.json` a headless run leaves, the only artifact the ACCEPTANCE column reads once the claim lapses; refuses a stranger's live claim (holder's `--session` passes) and a live headless run-state; `--report ` saves the report (empty file refused); NO journal lines on purpose (stats pair start/end by run_id). Board: `x` menu `a` on a tracker = acceptance in bg (`FinishRunLogPath`). (history: docs/design-log.md) + A re-claim from the same host + `--session` REFRESHES the live claim in place (`started` holds, TTL moves - the hand-driven acceptance's heartbeat, 0.49.0); empty session matches nothing, and the refresh is refused inside the last refresh-tick before expiry (RefreshFinishClaim's edge). Acceptance files are the siblings `.finish.json`/`.finish.log`/`.finish.md` (`storage.RunKindFinish` routes the paths - carry the constant verbatim). **A hand-driven acceptance must `pm finish record --result done|partial|blocked`** (0.51.0, `internal/executor/finish_record.go`) - it writes the same `.finish.json` a headless run leaves, the only artifact the ACCEPTANCE column reads once the claim lapses; refuses a stranger's live claim (holder's `--session` passes) and a live headless run-state; `--report ` saves the report (empty file refused); NO journal lines on purpose (stats pair start/end by run_id). Board: `x` menu `a` on a tracker = acceptance in bg (`FinishRunLogPath`). (history: docs/design-log.md) ### Chaining the acceptance, and where every run is listed - **Auto-chain** (`finish_mode: auto|off` on the TRACKER, validated like `mode`/`epic_mode`; `--then-finish[=false]` overrides per run): `executeEpic` spawns a detached `pm finish` LAST, after the summary and epic PR, in BOTH epic modes - best-effort (a failed spawn is a stderr line, never the run's error), same machine only, no chain after an account-wall abort, a held claim = quiet no-op. Independent mode checks the base back out first (`git worktree add` refuses a checked-out branch) - as a branch in the main checkout, DETACHED at the fork ref in a slot. Board: `&` in the epic launch menu appends `--then-finish`. (history: docs/design-log.md) @@ -142,7 +142,7 @@ Runs pm tasks/epics via **isolated headless `claude -p` workers** (implement → - **"The last fix was never reviewed" is a NUMBER, not prose**: a capped review loop cannot review its own last fix - the round that would is the round past the cap - and that state used to be readable only inside a worker's sentence, indistinguishable from "findings are still open" (both come back `blocked`). The guard records the repo HEAD on every spawn (`ReviewSpawn.Head`), `AggregateReviewSpawns` keeps the last ALLOWED top-level REVIEWER one (`LastReviewedHead`; a custom-type spawn - an explore helper, a project agent - never advances it, 0.49.1: it gets no packet and no model pin, so it says nothing about what a reviewer saw; the generic/custom line is `storage.GenericSubagentType`, shared with the guard's pin/packet/sync policies), and `countUnreviewedCommits` counts `LastReviewedHead..HEAD` right after the worker returns, before any merge or push -> `ReviewTelemetry.UnreviewedCommits`, carried by the run-state and the journal and reported by `pm executor stats`. No new sub result (the pm-cli-75 constraint); commits are the unit, so an UNCOMMITTED final fix does not register; every degraded path (no head, unreachable head, no git) leaves 0 rather than a guess. (history: docs/design-log.md) -- **A dead worker's effort is reconstructed, not zeroed** (`internal/cmd/work_effort.go`, `storage.WorkerEffort`): `turns`/`cost_usd` come off the result envelope, so a worker that dies mid-work journals 0/0 - one sub ran 3749 s, pushed its branch, and reported turns 0 / $0, and 6 of the 26 non-delivered subs in the 2026-08-11 window reported $0 the same way, making the retro's "$139 spent" a floor. On any death (no envelope, timeout included) `recoverWorkerEffort` re-reads the worker's own transcript - found via the session id pm MINTED - and records `JournalSub.Effort`/`SubRun.Effort`: main-loop turns plus output tokens, `source: transcript`. A SEPARATE field, never a fill-in of turns/cost: it is a different measurement (upper bound of `num_turns`; equal on the 9 of 14 checked subs whose transcript holds a single harness injection), and there is NO cost - a transcript carries tokens, not prices, and a hardcoded rate card would go stale silently. Tokens exclude subagent sidechains, so the figure is a floor; `pm executor stats` prints it as the reason the cost total is a FLOOR. Nil (never a zero-filled struct) when there is no transcript. (history: docs/design-log.md) +- **A dead worker's effort is reconstructed, not zeroed** (`internal/executor/work_effort.go`, `storage.WorkerEffort`): `turns`/`cost_usd` come off the result envelope, so a worker that dies mid-work journals 0/0 - one sub ran 3749 s, pushed its branch, and reported turns 0 / $0, and 6 of the 26 non-delivered subs in the 2026-08-11 window reported $0 the same way, making the retro's "$139 spent" a floor. On any death (no envelope, timeout included) `recoverWorkerEffort` re-reads the worker's own transcript - found via the session id pm MINTED - and records `JournalSub.Effort`/`SubRun.Effort`: main-loop turns plus output tokens, `source: transcript`. A SEPARATE field, never a fill-in of turns/cost: it is a different measurement (upper bound of `num_turns`; equal on the 9 of 14 checked subs whose transcript holds a single harness injection), and there is NO cost - a transcript carries tokens, not prices, and a hardcoded rate card would go stale silently. Tokens exclude subagent sidechains, so the figure is a floor; `pm executor stats` prints it as the reason the cost total is a FLOOR. Nil (never a zero-filled struct) when there is no transcript. (history: docs/design-log.md) - **Gate G2** (0.38.0): in an A/B replay of two real bugs sonnet argued both away, opus reported both - `review_model` stays opus; re-run the experiment when the harness or model generation changes. (history: docs/design-log.md) - **A document is not priced like code** (0.39.0): a doc-only diff (EVERY changed path has a prose extension - closed list, decided by extension never by path) gets **1 reviewer and 1 round** (`effectiveFixRounds` only ever LOWERS), with a check-the-claims-against-the-repo brief (`reviewDocPacketHeader`) instead of the adversarial-bug one; anything unclassifiable falls back to the code regime. (history: docs/design-log.md) - **Worker gate economics** (0.36.2): review sizing counts PRODUCTION files only (tests/lockfiles/snapshots/generated excluded from the count, never from the reviewed diff); scoped lint is ONE autofix pass, never check->fix->recheck; never split one self-contained change across commits for tidiness (hooks bill per commit). (history: docs/design-log.md) @@ -169,13 +169,13 @@ Runs pm tasks/epics via **isolated headless `claude -p` workers** (implement → ### Configuration and the worker's environment - **Context repos** (`executor.context_repos` in project.yaml, name->path): read-only reference repos listed in every worker prompt (`## Reference repos (READ-ONLY)`) so workers check cross-repo facts instead of guessing; prompt-level contract only - pm never touches the paths. (history: docs/design-log.md) -- **Profile** (`internal/storage/executor.go`): an `executor` block in `project.yaml` binds each phase (`implement/test/review/verify/pr`) to `skill:` | `cmd:` | empty (built-in generic) | `false` (skip). No block = all generics, `additional_worktree:false`. `pm executor init ` (`internal/cmd/executor_init.go`) auto-detects skills+stack and drafts it. +- **Profile** (`internal/storage/executor.go`): an `executor` block in `project.yaml` binds each phase (`implement/test/review/verify/pr`) to `skill:` | `cmd:` | empty (built-in generic) | `false` (skip). No block = all generics, `additional_worktree:false`. `pm executor init ` (`internal/executor/executor_init.go`) auto-detects skills+stack and drafts it. - **`pm executor init` drafts the handoff + playbook scaffold** (0.29.0): runtime-skill matching is on SEGMENTS, not substrings (a skill shipping `scripts/` wins within a tier); `handoff` is FILLED IF EMPTY, never refreshed; an existing playbook is never rewritten (O_EXCL); the scaffold leaves explicit `TODO:` slots that `doctor` warns about while they survive. (history: docs/design-log.md) - **`detectVerifyCmd`** (0.29.1): knows `validate` and hyphenation variants (`nodeVerifyParts` = spellings-per-step, first match wins, one per step); the detected command becomes `executor.baseline`. (history: docs/design-log.md) - **`skillNames` follows symlinks** (0.29.0): the test is "does `/SKILL.md` resolve" (os.Stat), never `IsDir()` on the ReadDir entry. (history: docs/design-log.md) - **Autonomy envelope** (hard rules, in `buildWorkerSystemPrompt`): stay within AC (no scope creep), never merge to main / force-push / hard-reset, commit on the given branch only. Permissions: `acceptEdits` + curated bash allowlist + disallow force-push/merge/hard-reset/hook-bypass (`--yolo` bypasses the allow/disallow lists) plus the `pm worker-guard` PreToolUse hook, which is attached in `--yolo` too (0.36.3). - **Worker = `claude -p`**, NO git worktree by default. Headless workers CANNOT use interactive-auth MCP (e.g. figma) → design-heavy tasks need a manual visual check. -- **Additional worktree mode** (`storage/worktree.go` + `cmd/work_worktree.go`): opt-in PER RUN via `--additional`; default = main checkout + clean-tree precondition. `executor.worktrees` = `{path, env}` slots (slot env overlays executor env, slot wins; the legacy `additional_worktree` pair synthesizes one; `Executor.ResolveWorktrees(projPath)` is the ONE resolution point). A run claims the FIRST FREE slot (`--slot N` pins; `pm work` per task, the epic manager ONCE per run). Flow (`prepareWorktree`): `EnsureWorktree` (create-or-REUSE; non-worktree path refused) -> seed untracked configs (`storage.CopyUntrackedFiles`; dep/build dirs excluded via `executor.seed_exclude`) -> `ExcludeFromGit` -> PID lock; `executor.prepare` runs ONCE PER RUN (15-min cap, failure aborts). `workDir` = the claimed slot, target of every git op; `RunState.RepoPath = workDir`; the journal records `work_dir`. Board: `#` toggle with per-slot occupancy. pm knows nothing about RN/Metro/sims. (history: docs/design-log.md) +- **Additional worktree mode** (`storage/worktree.go` + `executor/work_worktree.go`): opt-in PER RUN via `--additional`; default = main checkout + clean-tree precondition. `executor.worktrees` = `{path, env}` slots (slot env overlays executor env, slot wins; the legacy `additional_worktree` pair synthesizes one; `Executor.ResolveWorktrees(projPath)` is the ONE resolution point). A run claims the FIRST FREE slot (`--slot N` pins; `pm work` per task, the epic manager ONCE per run). Flow (`prepareWorktree`): `EnsureWorktree` (create-or-REUSE; non-worktree path refused) -> seed untracked configs (`storage.CopyUntrackedFiles`; dep/build dirs excluded via `executor.seed_exclude`) -> `ExcludeFromGit` -> PID lock; `executor.prepare` runs ONCE PER RUN (15-min cap, failure aborts). `workDir` = the claimed slot, target of every git op; `RunState.RepoPath = workDir`; the journal records `work_dir`. Board: `#` toggle with per-slot occupancy. pm knows nothing about RN/Metro/sims. (history: docs/design-log.md) - **Lock** (`.pm-executor.lock` at the worktree root, JSON `{pid,task_id,kind,started}`): `AcquireWorktreeLock` refuses with `*WorktreeBusyError` on a LIVE holder; a stale lock (dead pid) is taken over; same-pid re-acquire is idempotent. Held for the whole run, released by a deferred closure; `killRun` releases PID-matched on SIGTERM's behalf. `ReleaseWorktreeLock` never unlinks by path: it renames the lock aside, RE-VERIFIES what came aside (PID **and** `started`) and restores it via link+remove when it changed hands; `AcquireWorktreeLock`'s takeover and refresh paths are atomic through rename alone (steal = rename aside, refresh = write-tmp + rename over) and do NOT re-verify; every scratch/temp name any of them writes carries a per-process sequence number - the identical TOCTOU/uniqueness hardening `finish_claim.go` has, ported (pm-cli-115-4); a release that lost a race to a concurrent takeover returns a (currently discarded) error rather than deleting the winner's lock. Excluded from `git status`; the dirty-tree precondition is skipped in worktree mode. (history: docs/design-log.md) - **Env injection**: `executor.env` is an opaque `string->string` map appended verbatim to the worker's environment (`Executor.EnvSlice()` → `runWorker` extraEnv, wins over inherited - so a `CLAUDE_CONFIG_DIR` key would override the config-dir pinning; don't). pm does NOT interpret the keys: name them exactly what the consuming project's scripts read (e.g. `SIM_UDID`, not `ADDITIONAL_SIM_UDID`) - pm never starts Metro or targets a sim itself. - **Session slot registry** (0.24.0): the board's interactive worktree launches inject the FIRST FREE slot's merged env, so two sessions get DIFFERENT slots; free = no live session lock at `//.sessions/slot-N.lock` (the lock pid IS claude's after `exec`). Stale/corrupt locks read as free; all-busy degrades to slot 1's env without a claim; never injected for non-worktree launches. Session locks and `.pm-executor.lock` are SEPARATE resources. (history: docs/design-log.md) @@ -190,7 +190,7 @@ Runs pm tasks/epics via **isolated headless `claude -p` workers** (implement → - **Nothing is signalled until the pid is PROVEN the run's own** (0.37.1): `RunState.Kill` verifies identity via `ProcessAliveSinceStamp` against the run's `Started` and returns a typed `StaleRunError` instead of signalling a recycled pid; the refusal is loud and reconciling (state stamped, sub parked, journal line, NO escalation); the escalation re-reads and re-verifies. Test seam: `killSignal` var. (history: docs/design-log.md) - **Process groups + bounded Wait** (0.30.1, `internal/proc/proc.go`): `runWorker`/`captureBaseline` spawn via `proc.GroupCmd` (`Setpgid`; Cancel SIGTERMs the GROUP, SIGKILL after `proc.KillGrace`; `WaitDelay` 5s) so `--timeout` kills descendants and an inherited stdout pipe can't block Wait forever. `exec.ErrWaitDelay` = success-with-leftovers (a baseline captured that way is GREEN); the post-Wait SIGKILL never fires on a plain non-zero exit. (history: docs/design-log.md) - **Executor journal** (`internal/storage/executor_journal.go`): append-only JSONL at `/.executor/journal.jsonl` (RunState = LIVE state of one run; the journal = durable history): `start`, `end` (per-sub `result`/`note`/`branch`/`duration_s`/`session`/`turns`/`cost_usd`), `killed` (board kill, or the dying manager naming the signal), `crashed` (reconciled by the next run - see A dead manager says why); a `start` with no terminal line at all = a crash not reconciled yet. Run lines carry `additional`/`yolo`/`independent`/`work_dir`/`baseline` (as USED) and share a `run_id`. Appends best-effort; corrupt lines skipped. Consumed by `/executor-retro`. (history: docs/design-log.md) -- **A dead manager says why** (`internal/storage/executor_crash.go` + `internal/cmd/executor_crash.go`): a `start` with no terminal line used to be the WHOLE record of a crash (3 of 36 runs in the 2026-08-11 retro window, all on 2026-08-08, with not one byte of cause). +- **A dead manager says why** (`internal/storage/executor_crash.go` + `internal/executor/executor_crash.go`): a `start` with no terminal line used to be the WHOLE record of a crash (3 of 36 runs in the 2026-08-11 retro window, all on 2026-08-08, with not one byte of cause). Two halves, because deaths split in two: a CATCHABLE signal (Ctrl-C, a session harness SIGTERM, the board's kill) journals its own **`killed`** line naming the signal, from inside `forwardTerminalSignals` - the only moment left before the re-raise - armed by `armCrashJournal` right after the start line and disarmed by defer, so a run that reaches its end line never reports a kill too; an UNCATCHABLE death (SIGKILL, OOM, reboot) is reconciled by the NEXT run in that project (`storage.ReconcileCrashedRuns`, called before the new run WRITES ITS OWN RUN-STATE - the file is keyed by task id, so reversed ordering would destroy a same-task crash's forensics one step before reading them, 0.49.1), which appends **`crashed`** carrying what the dead run-state establishes (pid, start, last heartbeat -> a 30s window for the time of death, current sub, phase; the line also names "a catchable signal between workers" among the causes - the signal journaling rides the worker's forwarder, so a Ctrl-C between subs is never written down). diff --git a/docs/solo-and-batch.md b/docs/solo-and-batch.md index b3c8a8a..94698a4 100644 --- a/docs/solo-and-batch.md +++ b/docs/solo-and-batch.md @@ -12,7 +12,7 @@ the board still run all of it. Both paths hand the *procedure* to a skill that is **not in this repository**: solo runs the `/solo` skill, and `pm finish` tells its worker to invoke the -`batch-finish-auto` skill (`internal/cmd/finish_prompt.go`). pm supplies the +`batch-finish-auto` skill (`internal/executor/finish_prompt.go`). pm supplies the launch, the process harness, the files and the screens; the skills live in [mbalazy/claude-skills](https://github.com/mbalazy/claude-skills), linked into `~/.claude/skills` of the machine that runs them. This page is the manual for 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" 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)