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.
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 subtasksTwo commands do the work:
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.
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.
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.
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.
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.
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.
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.
- 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 statsrolls 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.
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 rolluppm 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.
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)
-
pm work [project] <task>(internal/executor/work.go,work_prompt.go,work_git.go) - the ATOM: one freshclaude -pin 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-runprints prompt+argv without spending tokens. Flags:--model(opus, orexecutor.model),--effort(unset, orexecutor.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-freeplanEpic(what--dry-runprints) +executeEpic; onedriveSubFlowdrives a sub in both modes, the mode differences live in thesubFlowhook struct. Createsepic/<tracker>, drives ready subs sequentially byOrder, each onfeat/<slug>off the integration branch, merges back--no-ffon verify-green ->done_status. Blocked/failed/conflict -> park onwaiting+ continue; reasons bubble to the parent's## Manager Notes(recordSubFeedback). Re-entrant (skips merged/done; a dead prior run'sdoingsub 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 thebatch-finish-autoskill - the procedure stays in~/.claude/skills, pm supplies process, harness and guard.--yoloby default (worker-guard still attached), user-scope MCP KEPT (userMCP: true- the skills drive pm through MCP),--simonly 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 +
--sessionREFRESHES the live claim in place (startedholds, 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.RunKindFinishroutes the paths - carry the constant verbatim). A hand-driven acceptance mustpm finish record <tracker> --result done|partial|blocked(0.51.0,internal/executor/finish_record.go) - it writes the same.finish.jsona headless run leaves, the only artifact the ACCEPTANCE column reads once the claim lapses; refuses a stranger's live claim (holder's--sessionpasses) 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:xmenuaon a tracker = acceptance in bg (FinishRunLogPath). (history: docs/design-log.md)
- Auto-chain (
finish_mode: auto|offon the TRACKER, validated likemode/epic_mode;--then-finish[=false]overrides per run):executeEpicspawns a detachedpm finishLAST, 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 addrefuses 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 keyR): one table of every tracker's run AND acceptance across all projects, local + remote;--jsonis the board's contract (storage.LocalRunRowsis the ONE aggregation - the TUI never recounts). Remote runners live in the GLOBAL<pm-root>/config.yamlregistry (pm config show; missing file = no remotes, unparsable = loud error); remote rows fetch on demand, never per tick. (history: docs/design-log.md)
depends_ongate (unmetDepsinrun_epic.go): a sub runs only when every listed sub ismerged/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 onwaiting). Opt-in; settable via MCP; shown in--dry-run. (history: docs/design-log.md)mode: auto | manualgate (classifySubinrun_epic.go):manual= a PERMANENT human gate - skipped on EVERY run (no worker, status untouched, distinctmanualoutcome); the human does the work and moves the sub todone_statusthemselves; later auto subs gate on it via normaldepends_on. Only lowercaseauto/manualare 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):resolveWorkerModelinwork.gopicks--model(explicitly passed,cmd.Flags().Changed) > the sub's ownmodel:frontmatter >executor.model>opus, andresolveWorkerEffortpicks--effort>executor.effort> unset (no flag, Claude's own default) withstorage.ValidateEffort(closed listlow|medium|high|xhigh|max, exact spelling) failing the PLAN - so--dry-runreports the typo, not the spawn after prepare + baseline already ran.planWorkpins the resolved pair back intoplan.optswith the Set bits on, which is what keepsretarget/baseline/diff-base re-renders from resolving twice;planEpicresolves the run's pair (epicPlan.model/effort) and hands it to every sub as already-set, then applies the sub'smodel: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) --toolscuts the unused tool schemas out of every worker call (0.56.0,workerToolsinwork.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-schemastructured output still comes back under the list (the output tool is implicit).--toolsis 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--toolsand no--effort- its skills may reach for anything. (history: docs/design-log.md)executor.timeoutper project (storage.Duration, a duration STRING like90m): 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 inplanWork/planEpic(resolveWorkerTimeout) so--dry-runandpm executor showprint the number and its SOURCE (describeTimeout); an epic resolves it for the run and every sub inherits it.storage.Durationexists because a baretime.Durationmarshals to nanoseconds that yaml.v3's own decoder then REFUSES - one unrelatedwriteProjectwould make the project unreadable (the PhaseBinding/pm-cli-55 failure mode). A bare number is rejected, never guessed. (history: docs/design-log.md)
-
Hook bypass + lost results (0.36.3):
pm worker-guard(hidden command) rides on every worker as a PreToolUse hook via--settings- in--yolotoo - 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.parseWorkerOutputfalls 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 jsonhas 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 everyenvelopeParsergets the id pm MINTED (--session-id) aspinnedfallback - so a parse failure never loses the key the transcript recovery runs on, under the NEXT unknown shape too. Bothpm workandpm finishgo 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) judgestool_input.file_path; the bash patterns cover in-place sed,teeand redirects; the protected path is the whole of.git/(core.hooksPathlives 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/-amis blanked (never crossing;/&&/|), so prose about-npasses while a trailing--no-verify/-nAFTER the message stays blocked. (history: docs/design-log.md)
-
A dead worker says what killed it, and an account wall stops the run (0.37.0): without an envelope,
workerLastWordsreads the last assistant message from the worker transcript (tee'd stderr as fallback);accountWallReasonmatches account-level phrases -> sub resultaborted, the manager STOPS, the aborted sub returns to its ready status (neverwaiting), later subs are recordedskippedwith 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:reviewerCapfrom the production diff -<=2files/<=50lines -> 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:buildReviewPacketattaches 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:forceSyncSpawnpinsrun_in_background:falseon 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;updatedInputcarries the RAW tool input; degrades to allow-and-record.storage.ReviewTelemetryrecords what the WORKER asked for. Budget: 12 Read/Grep/Glob peragent_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)
-
runtime_toolsthat 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, aVAR=xprefix,sh <script>.Three layers: (1) a pattern may say
$SLOT(Bash($SLOT/.claude/skills/<skill>/scripts/<script>:*)), expanded bystorage.ExpandRuntimeToolsto the tree the worker runs in - the claimed slot, or the main checkout - on every render (planWorkfor the provisional dir,retargetfor the claimed one;workPlan.runtimeToolskeeps the raw list;--dry-runprints the expanded list); (2) an UPrigSectionnow takes the expanded patterns + the slot env and renders "Allowed runtime commands - exactly these prefixes" (runtimeToolsSection: each pattern as the prefix to type viastorage.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 doctorcheckRuntimeToolsWARNs 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$SLOTpatterns as ok, and leaves relative/bare prefixes alone.Data side (not in this repo): a React Native project's runtime_tools are one
$SLOTentry per script (a*inside a prefix is not documented to glob) plus the relative slip the worker actually typed, pm-cli's acceptrig-check.shin its absolute and~forms, both gainedBash(printenv:*)(notenv- 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 cireplacedweb/node_modulesunder it), every/node_modules/.vite/deps/*.jsanswered 504 "Outdated Optimize Dep", the page was blank, andweb-rig.shprobed onlyGET /and/api/projects(both 200).Two changes:
~/.claude/pm/pm-cli/.executor/web-rig.shnowstops Vite before the check (like it already did forpm serve; ~2 s), and~/.claude/skills/web-verify/scripts/dev-server.mjs checkgained--mount SEL(defaulth1;--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 --startinherits it. pm-cli declares journalweb-rigfor the next such event. Verified on slot 1:h1 mounted ("Today") in 1.2 s; a 200 page with an emptyh1is DEAD under the probe and OK under the old check. (history: docs/design-log.md)
-
Review has a FLOOR as well as a ceiling (pm-cli-130,
review_floor.go): averifiedresult 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 matcherStructuredOutput(judgeStructuredOutput) refuses the verdict one turn before the worker would end with it, naming the move (spawn onepm-reviewer, act on it, re-verify, return; never downgrade the verdict to dodge the check), anddemoteUnreviewedinexecuteWork- afterCollectReviewTelemetry, beforeapplyWorkerResultand the manager's merge/push - turns an envelope that still says verified intoblockedwithREVIEW-SKIPPED: worker returned verified with 0 reviewer spawns; pm requires at least one ...inunresolvedandreview.skipped: trueon 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: 0andverifiedinto 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),AggregateReviewSpawnskeeps 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 isstorage.GenericSubagentType, shared with the guard's pin/packet/sync policies), andcountUnreviewedCommitscountsLastReviewedHead..HEADright after the worker returns, before any merge or push ->ReviewTelemetry.UnreviewedCommits, carried by the run-state and the journal and reported bypm 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_usdcome 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)recoverWorkerEffortre-reads the worker's own transcript - found via the session id pm MINTED - and recordsJournalSub.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 ofnum_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 statsprints 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_modelstays 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 (
effectiveFixRoundsonly 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:inunresolvedinstead of implementing the disproven variant. (history: docs/design-log.md)
-
Independent (batch) epic mode (
driveSubIndependentinrun_epic_sub.go): for UNRELATED tasks each ending in its own PR.epic_mode: independenton the parent tracker (or--independent); NO epic branch - each sub's branch (resolveWorkBranch: taskbranch, elsefeat/<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(defaultpushed); a non-green sub STAYS ondoing. Workers run the BEST-EFFORT protocol:ASSUMPTION:/TODO:inunresolved; "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 bothcheckout <base>andbranch -f <base>in a worktree while ANOTHER worktree (the user's main checkout) holds that branch - which used to kill a slot batch infreshenBaseor withcheckout main for prepare: exit status 128, and left the slot sitting onmainso the main checkout could not switch back. So a slot run resolves a FORK REF once (slotForkRefinwork_git.go: fetch origin, thenorigin/<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 (gitFreshBranchaccepts any ref viagitRefExists), 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 BYrun-epic(pm work --additionalstill callsfreshenBasein 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--additionalnothing changed: the main checkout still runsfreshenBase+gitEnsureBranch.--dry-runnames the fork ref ("subs fork from origin/main (slot)"). Integration mode is untouched (itsepic/<tracker>branch lives only in the slot). -
Landing statuses + honest sub vocabulary (0.34.0): the worker envelope says
verified | blocked | failed(legacymergedfolded in bynormalizeWorkerStatus); the sub result in run-state/journal saysmerged(integration) vspushed(independent); the task status isexecutor.done_status(defaultmerged) /done_status_independent(defaultpushed; the built-in default degrades todone_statuswith a note whenpushedis missing fromstatuses:, an explicit value hits the hard gate). Resolved viaStore.GetLandingStatuses; the board cachesModel.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
runtimesits betweenverifyandprinstorage.ExecutorPhases, bound like any other(
phases.runtime: {skill: simulator-verify}on a React Native project; a web project bindsskill: 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" --startasexecutor.rigproves 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.mjsreads 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.shstanding up a hermeticpm serveover.executor/cockpit-fixtureplus Vite with a slot-only proxy override, becauseweb/vite.config.tshardcodes the user's:7070), but it is opt-in PER TASK -runtime: onin the task frontmatter (storage.ValidateRuntime:on|off|"", validated inwriteTask, tri-state over MCP likefinish_mode; a FIELD and not a tag because MCPtagsreplace wholesale and a re-tag would silently drop the opt-in).A runtime-off task's prompt says
runtime: skipand pays nothing else. Three project settings go with it, all inproject.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 likeprepare, right after it, in the claimed slot WITH the slot's env (prepare gets none), and ONLY whenruntimeSubsfinds 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 (rigTimeout10 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;rigSectionrenders the verdict into every runtime-ON worker's prompt (UP = drive as bound, DEAD = skip the phase and recordTODO: runtime verification not run - rig DEAD: <why>), the journal's start/end lines carryrig: up|dead(USE, likebaseline), both dry-runs print the command or "skipped - no sub has runtime: on",printEpicPlanmarks subsruntime=on.(2)
executor.runtime_tools---allowedToolspatterns (Bash(xcrun simctl:*),Bash(curl:*), the skill'sscripts/*) appended bybuildClaudeArgs(..., allowExtra)for a runtime-on worker ONLY (workPlan.allowExtra; survivesretargetand 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 intounresolvedasOBSERVED: <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 aTODO: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 showprintsrig/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, afalsebinding with a rig). Acceptance-side: the batch-finish skills must treatOBSERVED:as a claim to re-check with the same controls, not as done (skill change, outside this repo). (history: docs/design-log.md)
- Context repos (
executor.context_reposin 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): anexecutorblock inproject.yamlbinds each phase (implement/test/review/verify/pr) toskill:|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 initdrafts the handoff + playbook scaffold (0.29.0): runtime-skill matching is on SEGMENTS, not substrings (a skill shippingscripts/wins within a tier);handoffis FILLED IF EMPTY, never refreshed; an existing playbook is never rewritten (O_EXCL); the scaffold leaves explicitTODO:slots thatdoctorwarns about while they survive. (history: docs/design-log.md)detectVerifyCmd(0.29.1): knowsvalidateand hyphenation variants (nodeVerifyParts= spellings-per-step, first match wins, one per step); the detected command becomesexecutor.baseline. (history: docs/design-log.md)skillNamesfollows symlinks (0.29.0): the test is "does<name>/SKILL.mdresolve" (os.Stat), neverIsDir()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 (--yolobypasses the allow/disallow lists) plus thepm worker-guardPreToolUse hook, which is attached in--yolotoo (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 legacyadditional_worktreepair synthesizes one;Executor.ResolveWorktrees(projPath)is the ONE resolution point). A run claims the FIRST FREE slot (--slot Npins;pm workper 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 viaexecutor.seed_exclude) ->ExcludeFromGit-> PID lock;executor.prepareruns ONCE PER RUN (15-min cap, failure aborts).workDir= the claimed slot, target of every git op;RunState.RepoPath = workDir; the journal recordswork_dir. Board:#toggle with per-slot occupancy. pm knows nothing about RN/Metro/sims. (history: docs/design-log.md)- Lock (
.pm-executor.lockat the worktree root, JSON{pid,task_id,kind,started}):AcquireWorktreeLockrefuses with*WorktreeBusyErroron 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;killRunreleases PID-matched on SIGTERM's behalf.ReleaseWorktreeLocknever unlinks by path: it renames the lock aside, RE-VERIFIES what came aside (PID andstarted) 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, thestealExpiredClaimrule): 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 hardeningfinish_claim.gohas, 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 fromgit status; the dirty-tree precondition is skipped in worktree mode. (history: docs/design-log.md) - Env injection:
executor.envis an opaquestring->stringmap appended verbatim to the worker's environment (Executor.EnvSlice()→runWorkerextraEnv, wins over inherited - so aCLAUDE_CONFIG_DIRkey 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, notADDITIONAL_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 afterexec). 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.lockare SEPARATE resources. (history: docs/design-log.md) - Fresh branch per task on the reused worktree (
gitCleanWorktree+gitFreshBranchinwork_git.go):reset --hard+clean -fd(NO-x- ignored deps/configs survive), thencheckout -B <branch> <base>; worktree mode only (the non-worktree path keeps create-or-continuegitCheckoutBranch).pm run-epiccleans 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 forpm work,mainforrun-epic); in DEFAULT modebase_branchis NOT consulted. (history: docs/design-log.md)
- Lock (
- Worker env (
workerEnvinwork.go): stripsANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKENso the headlessclaude -pbills the subscription, not the metered API; appendsPM_HEADLESS=1so 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 = pm is the bus (
internal/storage/executor_run.go): each run writesRunStateJSON +.logunder<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 fromReadRunStatesby design. The TUI is the read side: run dashboard in the detail view,W= live agent-view tailing the worker transcript,refreshRunStatesrepolls per tick; board launches are detached (Setsid,▶badge).RunState.IsLive()=runningANDProcessAliveSinceStamp(PID, Started). (history: docs/design-log.md) -
Run-state heartbeat +
storage.RunWriter: a fixed 30s ticker (RunWriter.Heartbeat) re-stampsUpdatedfrom worker spawn to return, touching NOTHING else;RunWriter(one mutex, per-writer scratch files, nil = no-op) serializes every executor-process run-state access.CurrentSessionis 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.Killinrun_control.go): SIGTERM the manager's-pgid, SIGKILL ~2s later; stampsfailed, parks the in-flight sub onwaiting. The worker leads its OWN group - send something catchable;RunState.WorkerPGIDis 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.Killverifies identity viaProcessAliveSinceStampagainst the run'sStartedand returns a typedStaleRunErrorinstead 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:killSignalvar. (history: docs/design-log.md) -
Process groups + bounded Wait (0.30.1,
internal/proc/proc.go):runWorker/captureBaselinespawn viaproc.GroupCmd(Setpgid; Cancel SIGTERMs the GROUP, SIGKILL afterproc.KillGrace;WaitDelay5s) so--timeoutkills 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-subresult/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); astartwith no terminal line at all = a crash not reconciled yet. Run lines carryadditional/yolo/independent/work_dir/baseline(as USED) and share arun_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): astartwith 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
killedline naming the signal, from insideforwardTerminalSignals- the only moment left before the re-raise - armed byarmCrashJournalright 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 appendscrashedcarrying 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 finishincluded (0.49.1) - and reconcile before their state write;pm executor statsdeduplicates a board kill's twokilledlines (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 statsprints the reasons (renderDeaths, last 5) and counts a reconciled crash exactly once. (history: docs/design-log.md)
-
Handoff contract +
pm executor show/doctor(0.28.0;rig_skill0.47.0):executor.handoff=playbook(path) +runtime_skill(bare name) + optionalrig_skill(bare name - the COLD-START skill that stands the runtime up when it is down, vsruntime_skillwhich drives one that exists); derivable facts (the skill'sscripts/) are derived, never configured (Executor.ResolveHandoff(projPath, claudeConfigDir)).Resolution scopes differ ON PURPOSE (0.49.1):
, NOT in the repo",runtime_skillresolves 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;doctorWARNs "resolved in--strictpromotes it,showsays it) - a browser driver likeweb-verifyis 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_skillfalls back to the project's PINNED Claude config dir (claude_config_dir, default~/.claude- the dir the acceptance actually loads skills from,ResolvedHandoff.GlobalRootnames it) because a rig skill is machine knowledge, not repo knowledge.showprints the profile as RESOLVED, incl. worktree slots with merged env;pm context/pm_contextget ONE pointer line.doctor: ERROR = a binary filesystem fact (exit 1), WARN = a heuristic over prose (exit 0;--strictpromotes); an unsetrig_skillis silence (optional), a declared-but-unresolvable one is ERROR. (history: docs/design-log.md) -
Readiness contract (0.37.0):
checkBaselineWARNs whenexecutor.baselineis unset (the most expensive omission the journal records); the no-executor-block branch still runscheckLandingStatusesagainst built-in defaults; thedone_statusgate resolves inplanEpic(epicPlan.doneStatusGate), so--dry-runreaches 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 byrun_id. Careful reads: a leftoverstart= crash only when its pid is dead or >24h old;statussays 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)