Skip to content

Latest commit

 

History

History
204 lines (142 loc) · 50.6 KB

executor.md

File metadata and controls

204 lines (142 loc) · 50.6 KB

Executor internals (pm work / pm run-epic / pm finish)

The executor is one of the two ways pm works while nobody watches. It runs one task, or a whole epic's subtasks, through headless claude -p workers. The other mode is a long-lived solo session, launched from the cockpit (the manual for it, and the batch recipe, is solo-and-batch.md; the design record is cockpit.md, "Solo launch"), and since 2026-09-09 solo is the current path: the cockpit hides executor runs unless cockpit.show_executor is turned on (solo is the everyday unattended path, the executor the less-used one). Everything below still runs from the CLI and the board, and the executor stays (decided 2026-10-12). Running it is the manual: commands, flags, modes. Overview and the sections after it are how it works inside, in the words each rule was written in when it was decided.

Running it

The executor runs pm tasks autonomously through isolated headless claude -p workers, so a multi-subtask epic executes without blowing one session's context.

First run:

pm executor init my-app       # once per project: detect skills/stack, draft the profile
pm work my-app-12 --dry-run   # inspect the full worker prompt + argv, zero tokens
pm work my-app-12             # run one task end-to-end → draft PR
pm run-epic my-app-10         # drive a whole tracker's subtasks

Two commands do the work:

pm work [project] <task> - the atom

Spawns one fresh worker in the project directory (so the project's own CLAUDE.md, skills and MCP servers load by cwd). The worker runs the inner loop - implement → test → adversarial review → fix → verify - and returns a schema-validated JSON contract: {status, summary, branch, commits, unresolved}. Standalone runs end with a draft PR; --epic is the form the manager calls it in (commit on the current branch, no per-sub PR); --dry-run prints the full prompt and argv without spending a token. Every flag of every executor command is tabled in cli.md.

pm run-epic [project] <tracker> - the manager

Drives a tracker's subtasks sequentially (by order) in one of two modes:

Integration mode (default) - for one coherent feature. Creates epic/<tracker>, branches each sub off it, merges back --no-ff on verify-green, parks blocked/failed/conflicted subs on waiting and keeps going. Ends with ONE draft epic→main PR. Re-entrant: a re-run skips finished subs. Never merges to main, never closes the parent.

Independent (batch) mode (epic_mode: independent on the tracker) - for a batch of unrelated tickets. Each sub gets its own branch off the base, the worker runs best-effort (never abandons; records ASSUMPTION: / TODO: / SPEC-CONFLICT: entries instead), and every branch carrying commits is pushed - even on failure, since partial work on origin beats work lost to the next branch wipe. Nothing merges; a human finishes each task on its own branch.

Per-sub gates

  • depends_on: [ids] - unmet deps skip the sub (no worker, no wasted tokens); a re-run picks it up once deps merge.
  • mode: manual - a permanent human gate: the manager never touches the sub; you do it by hand and move it to done yourself.
  • model: sonnet - per-sub model override, so trivial subs run cheap while investigation subs stay on the strong model.

pm finish [tracker] - the acceptance as a run

The third run kind: a headless worker that invokes the global batch-finish-auto skill (from mbalazy/claude-skills, linked into the config dir by its install.sh) to accept a finished batch - walk the sub branches, verify, push fixes, write a report. Yolo by default (the guard hook still rides along), --sim only for attended runs - anything detached keeps its hands off shared runtime. Every acceptance starts by CLAIMING the run (pm finish claim|release|status <tracker>): a TTL lock stored beside the run's own state, so two acceptance sessions can never take the same run - even across machines. Its state, log and report live in <tracker>.finish.json / .finish.log / .finish.md, never overwriting the run's files.

Set finish_mode: auto on a tracker (or pass --then-finish) and pm run-epic chains the acceptance itself when the run completes - detached, best-effort, same machine only. pm runs (and the board's R view) shows every tracker's run and acceptance across all projects, including remote runners registered in the global ~/.claude/pm/config.yaml (pm config show), with the count of unresolved visual claims per acceptance - the morning TODO list.

Verification baseline - the "new failures only" verdict

If executor.baseline is set (typically the project's full verification command), it is captured once per run on the branch the work forks from and injected into every worker prompt. Green baseline: any failure the worker sees is new. Red baseline: the listed failures are pre-existing, out of scope, and must not demote the verdict - the worker records them once as PRE-EXISTING: and moves on. This kills the two classic failure modes of agents in imperfect repos: blaming inherited breakage on themselves, and burning turns re-diagnosing it in every sub.

Worktree slots (--additional)

Opt-in per run. The project defines a pool of worktree slots in project.yaml (executor.worktrees, each with a path and env overlay - e.g. its own Metro port and simulator UDID). A run claims the first free slot with an atomic, PID-checked lock (claim = link(2) of a fully written file; stale locks of dead processes are taken over), reuses the worktree across runs (installed deps survive), gives each task a fresh branch off a wiped tree, and leaves your main checkout completely alone. executor.prepare (e.g. yarn install --frozen-lockfile) runs once per run in the claimed slot. Interactive board launches share the same slot pool through a separate session-lock registry, so a manual worktree session and a headless run never fight over runtime resources.

Safety envelope

Workers run under acceptEdits with a curated bash allowlist and explicit disallows (force-push, merge to main, hard reset); --yolo bypasses. Hard rules in the system prompt: stay strictly within the AC (scope creep is a failure, not a bonus), commit early and often, never switch branches. The executor itself never merges to main and never closes the parent tracker - those calls stay human.

Worker environment: ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN are stripped so headless workers bill the Claude subscription, not the metered API; PM_HEADLESS=1 lets project hooks skip interactive-only boot work; CLAUDE_CONFIG_DIR is pinned when the project uses a non-default Claude account.

Observability - pm is the bus

  • Run state (<project>/.executor/<task>.json): live JSON the TUI polls - PID, current sub/phase/session, per-sub outcomes. A 30s heartbeat re-stamps it while a worker is in flight, so a 40-minute sub is distinguishable from a hung manager. (The stamp proves "the manager is alive inside a worker", not "the worker is progressing" - documented honestly at the source.)
  • Journal (<project>/.executor/journal.jsonl): append-only history of every run - outcomes, durations, turns, cost, flags, run ids (pids get recycled; run ids do not). pm executor stats rolls it up: runs by kind, the six-outcome sub histogram (worker-backed vs gate decisions counted separately), duration/turns/cost totals.
  • All observability writes are best-effort by contract: a failed write costs a stale dashboard, never a wrong run.

Executor configuration and the handoff contract

The executor block in project.yaml binds each phase to a skill, a command, the built-in generic, or skip; plus baseline, prepare, base branch, worktree slots, env, read-only context_repos (cross-repo facts a worker may read but never touch), and the handoff: a pointer to the project's evidence playbook and the skill that drives its runtime (simulator/browser).

pm executor init <project>    # auto-detects skills + stack, drafts the block,
                              # scaffolds the evidence playbook (TODO slots for prose)
pm executor show [project]    # the profile as RESOLVED: bindings, slots + env, handoff
pm executor doctor [project]  # completeness check: ERRORs are binary filesystem facts,
                              # WARNs are heuristics over prose; --strict promotes warns
pm executor stats [project]   # journal rollup

pm executor init is re-runnable and merge-aware: detected bindings refresh, hand-set fields survive (comments and unknown keys in project.yaml are preserved through a YAML node-tree merge), and hand-written playbook prose is never regenerated.

Overview

Moved out of CLAUDE.md on 2026-09-02 (retro pm-cli-119-4): this section was 29 KB of the 51 KB file, and every headless worker re-read it on each of its ~150 API calls while working on the TUI, the MCP server or the service layer. It is the same content, maintained here from now on - CLAUDE.md keeps the handful of rules that bite anyone touching the repo and points here for the rest. Decision history stays in docs/design-log.md.

Runs pm tasks/epics via isolated headless claude -p workers (implement → test → review → verify), so a multi-subtask epic executes without blowing one session's context. (history: docs/design-log.md)

The three run kinds

  • pm work [project] <task> (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] <tracker> (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/<tracker>, drives ready subs sequentially by Order, each on feat/<slug> 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 <tracker>): 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 <tracker>.finish.json/.finish.log/.finish.md (storage.RunKindFinish routes the paths - carry the constant verbatim). A hand-driven acceptance must pm finish record <tracker> --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 <md> 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)
  • pm runs + board Runs view (internal/storage/runs.go, internal/cmd/runs.go; board key R): one table of every tracker's run AND acceptance across all projects, local + remote; --json is the board's contract (storage.LocalRunRows is the ONE aggregation - the TUI never recounts). Remote runners live in the GLOBAL <pm-root>/config.yaml registry (pm config show; missing file = no remotes, unparsable = loud error); remote rows fetch on demand, never per tick. (history: docs/design-log.md)

Per-sub gates, model and limits

  • depends_on gate (unmetDeps in run_epic.go): a sub runs only when every listed sub is merged/done; an unmet dep SKIPS the sub (no worker, stays on its ready status - a re-run picks it up), unlike a genuine failure (parked on waiting). Opt-in; settable via MCP; shown in --dry-run. (history: docs/design-log.md)
  • mode: auto | manual gate (classifySub in run_epic.go): manual = a PERMANENT human gate - skipped on EVERY run (no worker, status untouched, distinct manual outcome); the human does the work and moves the sub to done_status themselves; later auto subs gate on it via normal depends_on. Only lowercase auto/manual are valid (storage.ValidateMode); shown as [MANUAL] in --dry-run. (history: docs/design-log.md)
  • Worker model + effort: one ladder, resolved once per plan (0.56.0; per-sub model: since 0.21.1): resolveWorkerModel in work.go picks --model (explicitly passed, cmd.Flags().Changed) > the sub's own model: frontmatter > executor.model > opus, and resolveWorkerEffort picks --effort > executor.effort > unset (no flag, Claude's own default) with storage.ValidateEffort (closed list low|medium|high|xhigh|max, exact spelling) failing the PLAN - so --dry-run reports the typo, not the spawn after prepare + baseline already ran. planWork pins the resolved pair back into plan.opts with the Set bits on, which is what keeps retarget/baseline/diff-base re-renders from resolving twice; planEpic resolves the run's pair (epicPlan.model/effort) and hands it to every sub as already-set, then applies the sub's model: on top. Both dry-runs print the value and its SOURCE. Reviewers are independent of all of it (executor.review_model) - that is the point: retro 2026-09-05 measured the main loop at ~76% of a sub's cost, so a project puts its workers on sonnet and keeps the opus reviewers as the safety net. (history: docs/design-log.md)
  • --tools cuts the unused tool schemas out of every worker call (0.56.0, workerTools in work.go): a worker is built with --tools Bash,Edit,Write,Read,Grep,Glob,Agent,Task,TodoWrite,Skill - what the inner loop uses and nothing else. Retro 2026-09-05: the schemas of WebSearch/WebFetch/NotebookEdit/the plan-mode pair rode in every one of ~156 calls per sub at ~11k tokens, against zero uses in 30 subs. Probed on claude 2.1.261: unknown names are ignored (hence both spellings of the subagent tool), and --json-schema structured output still comes back under the list (the output tool is implicit). --tools is a different thing from --allowedTools: absent from --tools = the tool does not exist; absent from the allowlist = it prompts (headless: refused). The acceptance (pm finish) passes NO --tools and no --effort - its skills may reach for anything. (history: docs/design-log.md)
  • executor.timeout per project (storage.Duration, a duration STRING like 90m): the per-worker wall-clock ceiling belongs to the project profile, not to whoever remembers the flag. Precedence --timeout (cmd.Flags().Changed) > executor.timeout > defaultWorkerTimeout (120m), resolved ONCE in planWork/planEpic (resolveWorkerTimeout) so --dry-run and pm executor show print the number and its SOURCE (describeTimeout); an epic resolves it for the run and every sub inherits it. storage.Duration exists because a bare time.Duration marshals to nanoseconds that yaml.v3's own decoder then REFUSES - one unrelated writeProject would make the project unreadable (the PhaseBinding/pm-cli-55 failure mode). A bare number is rejected, never guessed. (history: docs/design-log.md)

The worker guard

  • Hook bypass + lost results (0.36.3): pm worker-guard (hidden command) rides on every worker as a PreToolUse hook via --settings - in --yolo too - blocking hook-bypass forms (--no-verify/-n, core.hooksPath, HUSKY=0, rm-ing hooks) with an exit-2 message naming the legitimate move; it FAILS OPEN on an unreadable payload. parseWorkerOutput falls back to the worker transcript when the envelope has no structured result - OR does not decode at all (0.54.2, pm-cli-117).

    The CLI's --output-format json has two shapes: one result object (older CLIs, every test fake) and, since Claude Code 2.1.237, a message ARRAY whose LAST element is that envelope; decodeClaudeOutput (work_envelope.go) accepts both by first byte and pulls the session id out of whatever the bytes hold (envelope field, any array message, a textual "session_id" scan), and every envelopeParser gets the id pm MINTED (--session-id) as pinned fallback - so a parse failure never loses the key the transcript recovery runs on, under the NEXT unknown shape too. Both pm work and pm finish go through it. The verify phase's bound command IS the project's full validation for the worker - never swapped for the one named in the repo's docs. (history: docs/design-log.md)

  • Liveness = pid + process START TIME (0.37.1, internal/storage/process.go): ProcessAliveSince(Stamp) - a process started BEFORE the lock's stamp is the writer, one started after is a recycled pid. EPERM counts as ALIVE; missing info degrades to bare liveness, never to "dead"; 5s grace. (history: docs/design-log.md)

  • .git/ is not writable by ANY tool (0.37.1): a SECOND PreToolUse matcher (Write|Edit|MultiEdit|NotebookEdit) judges tool_input.file_path; the bash patterns cover in-place sed, tee and redirects; the protected path is the whole of .git/ (core.hooksPath lives in .git/config); reads stay allowed. (history: docs/design-log.md)

  • Commit-message prose is stripped before the guard matches (0.37.1, stripMessagePayload): the payload of --message/-m/-am is blanked (never crossing ;/&&/|), so prose about -n passes while a trailing --no-verify/-n AFTER the message stays blocked. (history: docs/design-log.md)

When a worker dies, and who decides the review

  • A dead worker says what killed it, and an account wall stops the run (0.37.0): without an envelope, workerLastWords reads the last assistant message from the worker transcript (tee'd stderr as fallback); accountWallReason matches account-level phrases -> sub result aborted, the manager STOPS, the aborted sub returns to its ready status (never waiting), later subs are recorded skipped with a note; an unrecognised wall behaves as an ordinary failure. (history: docs/design-log.md)

  • Review policy lives in the harness, not the prompt (0.38.0, review_cap.go/review_packet.go): pm's PreToolUse hook decides and REFUSES a non-compliant reviewer spawn. Count: reviewerCap from the production diff - <=2 files/<=50 lines -> 1, <=10/<=400 -> 2, else 3, per round (tests/lockfiles/generated excluded from the COUNT only). Model: executor.review_model (default opus), independent of the run's --model. Packet: buildReviewPacket attaches the whole diff (>48kB -> --stat + whole files); reviewers have NO Bash. Rounds: executor.fix_rounds (default 2); a further round only after a valid finding. Sync: forceSyncSpawn pins run_in_background:false on generic-type spawns - the Agent tool is async BY DEFAULT and an async reviewer is never collected (pm-cli-105).

    Matcher Agent|Task; nested spawns refused; updatedInput carries the RAW tool input; degrades to allow-and-record. storage.ReviewTelemetry records what the WORKER asked for. Budget: 12 Read/Grep/Glob per agent_id (reviewerToolBudget; 25 until 0.55.0 - by then every reviewer hit it, see pm-cli-119-3), then refused ("finalize your report"; spawns/StructuredOutput never blocked); refusals = tool_denied. (history: docs/design-log.md)

What a runtime pass may type, and what proves the app is up

  • runtime_tools that match what a worker types (pm-cli-130): a permission pattern is a LITERAL prefix of the typed command, and the 2026-09-06 e2e test had 0 of 3 runtime workers run a skill script - denied on a main-checkout absolute path vs the slot's relative path, ~, export, a VAR=x prefix, sh <script>.

    Three layers: (1) a pattern may say $SLOT (Bash($SLOT/.claude/skills/<skill>/scripts/<script>:*)), expanded by storage.ExpandRuntimeTools to the tree the worker runs in - the claimed slot, or the main checkout - on every render (planWork for the provisional dir, retarget for the claimed one; workPlan.runtimeTools keeps the raw list; --dry-run prints the expanded list); (2) an UP rigSection now takes the expanded patterns + the slot env and renders "Allowed runtime commands - exactly these prefixes" (runtimeToolsSection: each pattern as the prefix to type via storage.RuntimeToolCommand, the slot's variable NAMES with "never export / never prefix, printenv NAME", the chained-command rule) - a DEAD rig lists nothing; (3) pm executor doctor checkRuntimeTools WARNs on a pattern naming the MAIN checkout while slots exist (the exact defect) and on a path that does not exist on this machine, reports $SLOT patterns as ok, and leaves relative/bare prefixes alone.

    Data side (not in this repo): a React Native project's runtime_tools are one $SLOT entry per script (a * inside a prefix is not documented to glob) plus the relative slip the worker actually typed, pm-cli's accept rig-check.sh in its absolute and ~ forms, both gained Bash(printenv:*) (not env - it lists secrets), and both skills' SKILL.md say that inside a run the slot env is already set. (history: docs/design-log.md)

  • The web rig proves the app MOUNTS, and restarts Vite every rig (pm-cli-130; skill + data dir, no binary change): e2e test C1 (2026-09-06) got a false RIG UP - run B's Vite was kept across run C's prepare (npm ci replaced web/node_modules under it), every /node_modules/.vite/deps/*.js answered 504 "Outdated Optimize Dep", the page was blank, and web-rig.sh probed only GET / and /api/projects (both 200).

    Two changes: ~/.claude/pm/pm-cli/.executor/web-rig.sh now stops Vite before the check (like it already did for pm serve; ~2 s), and ~/.claude/skills/web-verify/scripts/dev-server.mjs check gained --mount SEL (default h1; --mount '' = the HTTP-only check, used for the API port): after the HTTP probe it opens the route in the skill's own Playwright Chromium and requires the selector to render non-empty text - RIG DEAD: served but the app did not mount (<sel> empty|never appeared ...) with the page errors named. rig-check.sh --start inherits it. pm-cli declares journal web-rig for the next such event. Verified on slot 1: h1 mounted ("Today") in 1.2 s; a 200 page with an empty h1 is DEAD under the probe and OK under the old check. (history: docs/design-log.md)

What review is worth, and what it is allowed to cost

  • Review has a FLOOR as well as a ceiling (pm-cli-130, review_floor.go): a verified result with a NON-EMPTY production diff (the reviewer cap's own sizing - tests/lockfiles/generated excluded, prose included) and zero top-level reviewer spawns that actually ran (storage.ReviewTelemetry.Reviewers: generic/typeless, not denied, not nested - a custom-type helper is not a review) is refused TWICE: the guard's fifth PreToolUse matcher StructuredOutput (judgeStructuredOutput) refuses the verdict one turn before the worker would end with it, naming the move (spawn one pm-reviewer, act on it, re-verify, return; never downgrade the verdict to dodge the check), and demoteUnreviewed in executeWork - after CollectReviewTelemetry, before applyWorkerResult and the manager's merge/push - turns an envelope that still says verified into blocked with REVIEW-SKIPPED: worker returned verified with 0 reviewer spawns; pm requires at least one ... in unresolved and review.skipped: true on the run-state/journal (pm executor stats: "review skipped: N sub(s) ...").

    Both halves fail open: no telemetry (hook never attached), no diff base, no git, or an EMPTY production diff pass - a no-op sub has nothing to review. Why: run pm-cli-129 (2026-09-06) landed a sub with review.spawns: 0 and verified into the epic branch, and nothing said a word. (history: docs/design-log.md)

  • "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/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)

  • Premise-verification rules (0.22.1): a review finding that contradicts the PREMISE of a spec decision is never closed with a defensive guard - verify the premise against the authoritative source, then fix the root or escalate. In independent mode a premise checkable in this repo or a READ-ONLY reference repo is NOT an assumption: grep it, and record refuting evidence as SPEC-CONFLICT: in unresolved instead of implementing the disproven variant. (history: docs/design-log.md)

Epic modes, baseline and the runtime phase

  • Independent (batch) epic mode (driveSubIndependent in run_epic_sub.go): for UNRELATED tasks each ending in its own PR. epic_mode: independent on the parent tracker (or --independent); NO epic branch - each sub's branch (resolveWorkBranch: task branch, else feat/<slug>) forks off the base (--base > executor.base_branch > main); the manager pushes any branch carrying commits (even on failed outcomes), merges NOTHING, opens no epic PR. Verify-green -> done_status_independent (default pushed); a non-green sub STAYS on doing. Workers run the BEST-EFFORT protocol: ASSUMPTION:/TODO: in unresolved; "verified" = zero NEW verify failures; "blocked" only on zero progress.(history: docs/design-log.md)

  • Independent + --additional: in a slot the base is a REF, never a branch to check out (pm-cli-131/132-1): git refuses both checkout <base> and branch -f <base> in a worktree while ANOTHER worktree (the user's main checkout) holds that branch - which used to kill a slot batch in freshenBase or with checkout main for prepare: exit status 128, and left the slot sitting on main so the main checkout could not switch back. So a slot run resolves a FORK REF once (slotForkRef in work_git.go: fetch origin, then origin/<base> when it exists, else the local base; no fast-forward, and local commits origin lacks are a warning, not the main checkout's divergence error), forks every sub off it (gitFreshBranch accepts any ref via gitRefExists), and checks it out DETACHED (gitCheckoutDetached) for prepare, for the baseline and before the chained acceptance. The local base branch is never checked out and never moved BY run-epic (pm work --additional still calls freshenBase in the slot - work.go's call site is untouched by pm-cli-132-1 and hits the same refusal when the local base is behind origin). Without --additional nothing changed: the main checkout still runs freshenBase + gitEnsureBranch. --dry-run names the fork ref ("subs fork from origin/main (slot)"). Integration mode is untouched (its epic/<tracker> branch lives only in the slot).

  • Landing statuses + honest sub vocabulary (0.34.0): the worker envelope says verified | blocked | failed (legacy merged folded in by normalizeWorkerStatus); the sub result in run-state/journal says merged (integration) vs pushed (independent); the task status is executor.done_status (default merged) / done_status_independent (default pushed; the built-in default degrades to done_status with a note when pushed is missing from statuses:, an explicit value hits the hard gate). Resolved via Store.GetLandingStatuses; the board caches Model.landing. (history: docs/design-log.md)

  • Verification baseline + new-failures-only verdict (0.25.0): executor.baseline (optional shell command) is captured ONCE per run on the fork ref and injected into every worker prompt - green = "any failure you see is NEW", red carries the tail-truncated output (8k) as the pre-existing set; capture failure/timeout degrades to no-baseline, never aborts. Verify-green = zero NEW failures; PRE-EXISTING: never demotes the verdict; "failed" = failures the worker's changes introduced. (history: docs/design-log.md)

  • Runtime phase + rig hook - the simulator in a batch run (pm-cli-122; retro 2026-09-05 section 3): a sixth phase runtime sits between verify and pr in storage.ExecutorPhases, bound like any other

    (phases.runtime: {skill: simulator-verify} on a React Native project; a web project binds skill: web-verify - the GLOBAL browser driver in ~/.claude/skills/web-verify (from mbalazy/claude-skills), pm-cli-123: rig-check.sh --repo "$PWD" --port "$WEB_PORT" --start as executor.rig proves the slot's dev server serves the slot by the LISTENING pid's cwd and warm-starts it detached when nothing listens, never touching another tree's server; web-ui.mjs reads aria snapshots/screenshots/console; an ordinary web app = one dev-server port per slot, the cockpit itself = two ports per slot with .executor/web-rig.sh standing up a hermetic pm serve over .executor/cockpit-fixture plus Vite with a slot-only proxy override, because web/vite.config.ts hardcodes the user's :7070), but it is opt-in PER TASK - runtime: on in the task frontmatter (storage.ValidateRuntime: on|off|"", validated in writeTask, tri-state over MCP like finish_mode; a FIELD and not a tag because MCP tags replace wholesale and a re-tag would silently drop the opt-in).

    A runtime-off task's prompt says runtime: skip and pays nothing else. Three project settings go with it, all in project.yaml: (1) executor.rig - a shell command that PROVES the slot's runtime runs the claimed worktree (rig-check.sh --repo <slot> --udid $SIM_UDID --port $ADDITIONAL_METRO_PORT), run ONCE per run like prepare, right after it, in the claimed slot WITH the slot's env (prepare gets none), and ONLY when runtimeSubs finds at least one runtime-on sub that is not landed/manual (pm work: only when the task itself is on; both only under --additional - the main checkout's simulator is the user's).

    runRig (work_rig.go) never errors: exit 0 = RIG UP, non-zero / timeout (rigTimeout 10 min, the cap that forbids a 15-40 min cold build inside a run - a slot is built by hand once) / cannot-start = RIG DEAD (<reason>) with the output tail; rigSection renders the verdict into every runtime-ON worker's prompt (UP = drive as bound, DEAD = skip the phase and record TODO: runtime verification not run - rig DEAD: <why>), the journal's start/end lines carry rig: up|dead (USE, like baseline), both dry-runs print the command or "skipped - no sub has runtime: on", printEpicPlan marks subs runtime=on.

    (2) executor.runtime_tools - --allowedTools patterns (Bash(xcrun simctl:*), Bash(curl:*), the skill's scripts/*) appended by buildClaudeArgs(..., allowExtra) for a runtime-on worker ONLY (workPlan.allowExtra; survives retarget and the baseline/rig re-renders); a runtime-off worker keeps the envelope byte-identical; ignored under --yolo.

    (3) The evidence contract in buildWorkerSystemPrompt: inner-loop step 6 runs AFTER a green verify, a reading can neither upgrade nor demote the verify verdict (the gate is verify), the worker never boots/builds/relaunches/re-points the runtime, budget ~10-20 turns; every reading goes into unresolved as OBSERVED: <screen> shows <seen> - expected: <AC> - positive control: <must be visible and was> - negative control: <must not be visible and was not> - evidence: <path> - BOTH controls required (the sim-rig journal's 15 false readings are what a reading without controls looks like), a HYPOTHESIS for the acceptance to confirm, never "verified on the simulator"; a reading not taken is a TODO: line. The generic runtime driver proves the tree first (a marker in the entry file seen in the runtime's log) and stops on a dead rig.

    pm executor show prints rig/runtime_tools; doctor (checkRuntimePhase) is silent when none of the three is set and WARNs on each half-configured combination (rig without a binding, a binding without rig or tools, a false binding with a rig). Acceptance-side: the batch-finish skills must treat OBSERVED: as a claim to re-check with the same controls, not as done (skill change, outside this repo). (history: docs/design-log.md)

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 <project> (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 <name>/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 + 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 renames the stale/corrupt lock aside and RE-VERIFIES it too (stealWorktreeLock, the stealExpiredClaim rule): a live holder that won the same takeover is linked back and the acquire reports busy, an unreadable file (EACCES, EIO) is refused before anything is moved, one that turns unreadable between the read and the rename is linked back with an error, only a dead or unparseable lock is deleted; the refresh path is atomic through rename alone (write-tmp + rename over); 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 <pm-root>/<slug>/.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)
    • Fresh branch per task on the reused worktree (gitCleanWorktree + gitFreshBranch in work_git.go): reset --hard + clean -fd (NO -x - ignored deps/configs survive), then checkout -B <branch> <base>; worktree mode only (the non-worktree path keeps create-or-continue gitCheckoutBranch). pm run-epic cleans once at epic start; each sub's branch is fresh off the integration tip. (history: docs/design-log.md)
    • Base branch precedence (resolveWorktreeBase, only under --additional): --base > executor.base_branch > fallback (the main checkout's current branch for pm work, main for run-epic); in DEFAULT mode base_branch is NOT consulted. (history: docs/design-log.md)
  • Worker env (workerEnv in work.go): strips ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN so the headless claude -p bills the subscription, not the metered API; appends PM_HEADLESS=1 so project SessionStart hooks can skip boot work a headless worker can't use; interactive board launches never carry the marker. (history: docs/design-log.md)

Observability, killing a run, process hygiene

  • Observability = pm is the bus (internal/storage/executor_run.go): each run writes RunState JSON + .log under <project>/.executor/<taskID>.json (PID, RepoPath, CurrentSub/Session, Phase, per-sub []SubRun - every manager result word is also a run-state status); an acceptance writes the sibling <taskID>.finish.json/.finish.log, excluded from ReadRunStates by design. The TUI is the read side: run dashboard in the detail view, W = live agent-view tailing the worker transcript, refreshRunStates repolls per tick; board launches are detached (Setsid, ▶ badge). RunState.IsLive() = running AND ProcessAliveSinceStamp(PID, Started). (history: docs/design-log.md)

  • Run-state heartbeat + storage.RunWriter: a fixed 30s ticker (RunWriter.Heartbeat) re-stamps Updated from worker spawn to return, touching NOTHING else; RunWriter (one mutex, per-writer scratch files, nil = no-op) serializes every executor-process run-state access. CurrentSession is pinned at spawn and cleared with the heartbeat stop - that pairing is load-bearing. A fresh stamp proves the MANAGER is alive, not that the worker progresses. (history: docs/design-log.md)

  • Kill a live run (K -> confirm; killRun + RunState.Kill in run_control.go): SIGTERM the manager's -pgid, SIGKILL ~2s later; stamps failed, parks the in-flight sub on waiting. The worker leads its OWN group - send something catchable; RunState.WorkerPGID is signalled IN ADDITION, worker group first, after a re-read. (history: docs/design-log.md)

  • 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 <project>/.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/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).

    All three run kinds arm the catchable half - pm finish included (0.49.1) - and reconcile before their state write; pm executor stats deduplicates a board kill's two killed lines (same run id) in the deaths report. Idempotent by construction (the crashed line is itself terminal); skips a live pid and an id-less run-state (a recycled pid could close the wrong start). pm executor stats prints the reasons (renderDeaths, last 5) and counts a reconciled crash exactly once. (history: docs/design-log.md)

The handoff contract and the readiness check

  • Handoff contract + pm executor show/doctor (0.28.0; rig_skill 0.47.0): executor.handoff = playbook (path) + runtime_skill (bare name) + optional rig_skill (bare name - the COLD-START skill that stands the runtime up when it is down, vs runtime_skill which drives one that exists); derivable facts (the skill's scripts/) are derived, never configured (Executor.ResolveHandoff(projPath, claudeConfigDir)).

    Resolution scopes differ ON PURPOSE (0.49.1): runtime_skill resolves in the REPO first (.claude/skills, then .claude/commands) and, since pm-cli-123, falls back to the project's pinned Claude config dir FLAGGED (ResolvedHandoff.RuntimeSkillGlobal; doctor WARNs "resolved in

    , NOT in the repo", --strict promotes it, show says it) - a browser driver like web-verify is machine knowledge exactly like a rig skill, but a repo-missing skill must stay visible before a machine the repo is synced to pays for it at acceptance time; rig_skill falls back to the project's PINNED Claude config dir (claude_config_dir, default ~/.claude - the dir the acceptance actually loads skills from, ResolvedHandoff.GlobalRoot names it) because a rig skill is machine knowledge, not repo knowledge. show prints the profile as RESOLVED, incl. worktree slots with merged env; pm context/pm_context get ONE pointer line. doctor: ERROR = a binary filesystem fact (exit 1), WARN = a heuristic over prose (exit 0; --strict promotes); an unset rig_skill is silence (optional), a declared-but-unresolvable one is ERROR. (history: docs/design-log.md)

  • Readiness contract (0.37.0): checkBaseline WARNs when executor.baseline is unset (the most expensive omission the journal records); the no-executor-block branch still runs checkLandingStatuses against built-in defaults; the done_status gate resolves in planEpic (epicPlan.doneStatusGate), so --dry-run reaches the same verdict as the real run - whole plan first, then the blocker, exit non-zero. (history: docs/design-log.md)

  • pm executor stats [project] (0.25.3): read-only journal rollup - runs per kind, sub-outcome histogram (fixed order, zeros included), duration/turns/cost totals + averages; pairs lines by run_id. Careful reads: a leftover start = crash only when its pid is dead or >24h old; status says how the MANAGER exited (the real verdict is the histogram); gate skips re-record on re-runs and are excluded from averages. (history: docs/design-log.md)