| name | pm |
| description | Active PM orchestrator — manages issue pipeline, tracks coding threads, ranks the open backlog (OKR-aware) against a business goal, and suggests next work. Cold-starts from GitHub state, resumes from a /pm-handoff prompt, or runs continuously all day via `/pm day`. Triggers on "pm", "project manager", "orchestrate", "what should I work on", "rank issues", "work the backlog all day". |
| triggers | ["project manager","orchestrate","what should I work on","manage issues","rank issues","rank the backlog","priority list","full ranking","work the backlog all day"] |
| argument-hint | [day | --run] (optional — continuous mode: this thread becomes the repo's standing worker, looping rank → dispatch → merge → refill with between-turn liveness; see Step 2D) | [resume] (optional — 'resume' reads in-flight state from session files to continue a previous PM session) | [--no-clean | fast] (optional — skip the always-on inline /pm-clean cleanup for a ranking-only run) | [--window "until HH:MM"|"N hours"|"overnight"] (optional — fit the batch inside a planning window; see Step 0b) | [business goal] (optional — ranks the backlog by impact on that goal, e.g. 'increase scraping throughput') |
Active PM orchestrator. Manages which issues are being worked on across coding threads, tracks progress, and suggests next work.
Two entry modes, plus one posture that wraps either:
- Cold start (default): Scan GitHub state, suggest next 3-5 issues, enter orchestration loop.
- Resume: Read in-flight state from session files and continue where the previous PM left off.
- Day mode (
/pm day), on top of either: after the entry mode finishes, stay running — the thread becomes the repo's standing worker and keeps looping between turns instead of stopping at the end of the turn. Step 2D owns it.
Parse $ARGUMENTS in this order — day tokens first (so they never fall through to the business goal), then the cleanup flag, then the mode:
-
Day mode + its flags (strip each token as you read it):
day (its own whitespace-delimited word) or --run → DAY_MODE=true; otherwise false.
--tick → DAY_TICK=true. This is internal: only the Monitor armed in 2D.2 passes it. It implies DAY_MODE=true.
--day-generation <token> → TICK_GENERATION. Internal, always paired with --tick or --probe-wake.
--probe-wake → DAY_PROBE_WAKE=true. Internal: only the bounded probe Monitor armed in 2D.7 passes it, always with --day-generation. It implies DAY_MODE=true and routes straight to 2D.7's probe-fire handler — not to 2D.3's tick, which must not run while the board is parked.
--cadence Nm → DAY_CADENCE_MIN=N (default 5, range [1, 60]).
--max-pipeline-failures N → MAX_PIPELINE_FAILURES=N (default 3, range [1, 10]).
Validate both as unsigned integers with [[ "$v" =~ ^[0-9]+$ ]] before range-checking, and reject anything failing either test — naming the rejected input and falling back to the documented default. The pattern test is not belt-and-braces: both values are interpolated into 2D.2's --set JSON payload and into 2D.3's shell arithmetic, so 2.5 or abc does not merely produce a bad cadence, it writes a malformed day object into session state that every later read then fails on.
-
Cleanup escape hatch: if the remaining $ARGUMENTS contains the --no-clean flag or a bare fast token (its own whitespace-delimited word, e.g. /pm fast), set NO_CLEAN=true and strip that flag/token from the arguments before the checks below (so it is never read as a business goal); otherwise NO_CLEAN=false. NO_CLEAN=true suppresses the always-on Step 1C inline cleanup in modes — see Step 1C.
Day mode composes with everything above rather than replacing it: /pm day is a cold start that then keeps running, /pm day resume resumes and then keeps running, and /pm day increase scraping throughput carries that goal into every re-rank for the whole run.
A probe wake is not a tick either. When DAY_PROBE_WAKE=true, run Step 0, then go straight to 2D.7's probe-fire handler — skip 2D.3 entirely. A probe fire exists to re-read the runway while the board is parked; running a tick from it would dispatch work into the very wall the park was called to avoid.
A tick is not a fresh invocation. When DAY_TICK=true, run Step 0 (resolve tooling), then go straight to 2D.3's D0 gate — before Step 0a, so a tick from a superseded Monitor exits without printing anything, including 0a's "Active gh user" line. A gate that narrates is not silent. Once D0 passes, run Step 0a (D2's slot counting is author-scoped and needs $GH_USER), then continue through the rest of 2D.3. Skip Step 1 entirely — re-running the cold-start scan, Step 1C's cleanup gates, and Step 1D's triage every tick would re-ask, once per cadence all day, confirmations the user already answered.
Day mode's one-chip bound applies from the arming turn, not from the first tick. When DAY_MODE=true, Step 3.1's thread-prompt path offers at most one chip per turn — including the arming turn, where Step 1's own dispatch runs. Treat that turn as tick 0. Without this the mode's first turn would be the one that emits a wall of chips, which is precisely the failure it exists to end (2D.3 D2).
On an arming invocation, ownership is settled before any work. When DAY_MODE=true and DAY_TICK is not set, run Step 2D.1 immediately after Step 0a and before Step 1 — out of numeric order, deliberately. Step 1 claims issues, resolves cleanup gates, and dispatches pipelines; running it first and only then discovering that a /pr-monitor-and-manage fleet already owns this repo would leave claimed issues and live pipelines under two dispatching owners, which is the single failure the exclusion exists to prevent. Refusing costs a turn; refusing after dispatch costs a double merge. Steps 1 and 2 then run normally, and 2D.2 picks up from there.
Step 0: Resolve shared tooling
/pm is symlinked into every repo, but its helper scripts and reference docs are not — most repos carry no .claude/ directory. Resolve them; never invoke a bare .claude/scripts/… path. Full contract and the classified dependency inventory: .claude/reference/portable-skill-resolution.md (issue #1189).
resolve_script() {
local name="$1" candidate
for candidate in \
"$HOME/.claude/skills-worktree/.claude/scripts/$name" \
"$HOME/.claude/scripts/$name" \
".claude/scripts/$name"; do
if [[ -x "$candidate" ]]; then echo "$candidate"; return 0; fi
done
return 1
}
SESSION_STATE_SH=$(resolve_script session-state.sh || true)
PM_CONFIG_GET=$(resolve_script pm-config-get.sh || true)
ISSUE_CLAIM=$(resolve_script issue-claim.sh || true)
BACKLOG_HEALTH=$(resolve_script backlog-health.sh || true)
ACTIVE_WORK_CAP_SH=$(resolve_script active-work-cap.sh || true)
MAKESPAN_SH=$(resolve_script makespan.sh || true)
ESTIMATE_RESOLVE_SH=$(resolve_script estimate-resolve.sh || true)
WINDOW_PLAN_SH=$(resolve_script window-plan.sh || true)
USAGE_HORIZON_SH=$(resolve_script usage-horizon.sh || true)
Read reference docs through the same order — $HOME/.claude/skills-worktree/.claude/reference/<name> first, then $HOME/.claude/reference/, then .claude/reference/. That covers chip-launching.md, pm-output-templates.md, and session-state-schema.json.
When something does not resolve, say so in one line; never skip the contract silently.
chip-launching.md unreadable → required. Print ERROR: chip-launching.md not found (checked all three paths) — PM-context inline gate unavailable and stop before offering any chip. The gate is what keeps inline-first work inline; a /pm that cannot read it is precisely the run that spawns one thread per ready issue (#1189), so refusing to offer is the safe failure.
SESSION_STATE_SH empty → required for orchestration state. Print ERROR: session-state.sh not found (checked all three paths) — refill pause, slot tracking, and monitoring state unavailable. Rank and report, but do not start or refill pipelines: without persisted state a "stop" the user set earlier is invisible, and silently resuming refill against it is the worst available failure.
ISSUE_CLAIM empty → optional. Print DEGRADED: issue-claim.sh not found (checked all three paths) — claim checks skipped; issues may already be held by another thread and continue.
BACKLOG_HEALTH empty → optional. Print DEGRADED: backlog-health.sh not found (checked all three paths) — staleness block omitted and skip that block.
ACTIVE_WORK_CAP_SH empty → optional, but say so. Print DEGRADED: active-work-cap.sh not found (checked all three paths) — repo-wide cap unenforced, bounding chips on the per-thread ceiling only and cap the 3.1 chip batch at the 3–4 ceiling instead. A non-zero exit from a script that did resolve is not the same thing: it means a count source could not be read, so treat it as FREE = 0 and defer rather than offering as if the repo were idle (active-work-cap.md "Resolution order and failure behavior").
PM_CONFIG_GET empty → optional. Print DEGRADED: pm-config-get.sh not found (checked all three paths) — repo PM config unavailable, using defaults. An absent .claude/pm-config.md where the script resolved is a normal state that /pm bootstraps — say nothing there.
USAGE_HORIZON_SH empty → optional, degrades to unknown (day mode only). Print DEGRADED: usage-horizon.sh not found (checked all three paths) — runway verdict unavailable, day mode holds the conservative posture on the arming turn and treat every tick's verdict as (D2's horizon gate): in-flight work finishes, nothing new starts, and — an absent signal must not park a healthy board any more than it may green-light a dying one.
The pipeline ceiling, the autonomy grants, and the monitor-mode rules need no fallback: .claude/rules/*.md auto-loads at user scope in every project (portable-skill-resolution.md), so they are already in context wherever /pm runs.
Step 0a: Identify the current gh user
Before any mode-specific logic, detect the active GitHub user so downstream filtering can target "your work" vs. "all work".
GH_USER=$(gh api user --jq .login 2>/dev/null)
if [ -z "$GH_USER" ]; then
echo "WARNING: gh api user failed — falling back to unfiltered views"
else
echo "Active gh user: $GH_USER"
fi
Store $GH_USER for the rest of the session. Use it everywhere filtering matters:
- Your active PRs —
gh pr list --state open --search "author:$GH_USER"
- PRs awaiting your review —
gh pr list --state open --search "review-requested:$GH_USER"
- Your recent merged work —
gh pr list --state merged --search "author:$GH_USER" --limit 20
- Issues assigned to you —
gh issue list --state open --assignee "$GH_USER"
When the user asks "what should I work on?", prioritize in this order:
- Your own open PRs with unresolved review findings — highest priority (you own them and they're blocked on you)
- PRs where you're the requested reviewer — others are blocked on you
- Open issues assigned to you — committed work
- Unassigned issues you could claim — backlog pickup
If gh api user fails (no auth, network error), degrade gracefully: skip the user-scoped filters and fall back to the repo-wide views below. Note the fallback in the output so the user knows filtering is unavailable.
Step 0b: Window planning (when --window is set)
Runs immediately after Step 0a, only when WINDOW_STR is non-empty. Skipped on --tick turns (window persists from the arming turn).
# Parse the window string into machine values
WINDOW_MINUTES=0; EFFECTIVE_WINDOW_MIN=0; DEADLINE_EPOCH=0; STALL_MARGIN_MIN=0
if [[ -n "$WINDOW_STR" && -n "$WINDOW_PLAN_SH" ]]; then
WINDOW_PARSE_RC=0
WINDOW_LINE=$("$WINDOW_PLAN_SH" --window "$WINDOW_STR" 2>/dev/null) || WINDOW_PARSE_RC=$?
if [[ "$WINDOW_PARSE_RC" -ne 0 ]]; then
echo "DEGRADED: window-plan.sh failed (rc=$WINDOW_PARSE_RC) — ranking only, no dispatch (a window was requested but could not be parsed)"
WINDOW_STR=""
# Do NOT fall through to windowless dispatch: rank and report only (same as refill-paused path).
# Set PAUSED=true in the refill-check below so the dispatch gate holds.
WINDOW_PARSE_FAILED=true
fi
if [[ -n "$WINDOW_LINE" ]]; then
# Parse: window_minutes=N stall_margin_min=M effective_window_min=K deadline_epoch=E
WINDOW_MINUTES=$(printf '%s' "$WINDOW_LINE" | sed 's/.*window_minutes=\([0-9]*\).*/\1/')
STALL_MARGIN_MIN=$(printf '%s' "$WINDOW_LINE" | sed 's/.*stall_margin_min=\([0-9]*\).*/\1/')
EFFECTIVE_WINDOW_MIN=$(printf '%s' "$WINDOW_LINE" | sed 's/.*effective_window_min=\([0-9]*\).*/\1/')
DEADLINE_EPOCH=$(printf '%s' "$WINDOW_LINE" | sed 's/.*deadline_epoch=\([0-9]*\).*/\1/')
# Persist to session-state so the monitor loop and resume can read it
NOW_ISO=$(date -u +%Y-%m-%dT%H:%M:%SZ)
REPO_KEY=$("$SESSION_STATE_SH" --repo-key 2>/dev/null) || REPO_KEY=""
if [[ -n "$REPO_KEY" && -n "$SESSION_STATE_SH" ]]; then
SS_RC=0
"$SESSION_STATE_SH" --set \
".repos[\"$REPO_KEY\"].window={\"deadline_epoch\":${DEADLINE_EPOCH},\"window_minutes\":${WINDOW_MINUTES},\"effective_window_min\":${EFFECTIVE_WINDOW_MIN},\"set_at\":\"${NOW_ISO}\"}" \
2>/dev/null || SS_RC=$?
if [[ "$SS_RC" -ne 0 ]]; then
echo "DEGRADED: window state persistence failed (session-state.sh rc=$SS_RC) — ranking only, no dispatch (a window was requested but state could not be persisted)"
WINDOW_STR=""
WINDOW_PARSE_FAILED=true
fi
else
echo "DEGRADED: window state persistence failed (repo key unavailable) — ranking only, no dispatch (a window was requested but state could not be persisted)"
WINDOW_STR=""
WINDOW_PARSE_FAILED=true
fi
fi
fi
Report the window in the ranking header:
Window: until ~HH:MM ET (N h · effective M h after Z min stall margin)
Where HH:MM ET is $(TZ='America/New_York' date -j -f '%s' "$DEADLINE_EPOCH" +'%-I:%M %p ET' 2>/dev/null || TZ='America/New_York' date -d "@$DEADLINE_EPOCH" +'%-I:%M %p ET' 2>/dev/null).
STALL_MARGIN_MIN in pm-config.md. window-plan.sh reads pm-config.md's ## Budget section for a STALL_MARGIN_MIN: knob. Bootstrap the knob with a comment when bootstrapping pm-config.md (Step 1B.1):
## Budget
# STALL_MARGIN_MIN: 60 # minutes reserved for reviewer idle time in unattended runs (default 60 for windows > 6 h, 0 otherwise)
Step 1A: Resume mode
Read existing orchestration state to continue where a previous PM thread left off.
1A.1: Load pm-config.md
# Probe the config file via the shared parser. IMPORTANT: run the probe as a
# direct call first, not via `mapfile < <(...)`. With mapfile, `$?` captures
# mapfile's exit code (always 0 on success) — NOT the script's — so a probe
# like `mapfile ...; LIST_RC=$?` would silently never see the rc=2
# (config-missing) signal.
"$PM_CONFIG_GET" --list >/dev/null 2>&1
LIST_RC=$?
-
If LIST_RC == 2: tell the user to run /pm-handoff first to bootstrap the config, then stop.
-
Otherwise: enumerate sections and iterate for bodies:
mapfile -t SECTIONS < <("$PM_CONFIG_GET" --list 2>/dev/null)
for name in "${SECTIONS[@]}"; do
body="$("$PM_CONFIG_GET" --section "$name" 2>/dev/null)"
# store (name, body) — same loop as `/pm-handoff` Step 3
done
1A.2: Load in-flight state
# Session-wide orchestration state — SCOPED to the invoking repo (issue #687).
# --session-view projects the whole state file down to THIS repo's `.prs`,
# `.root_repo`, and the `.active_agents` that belong here; other repos never
# appear in the default view. Repo resolution reuses session-state.sh's
# precedence (--repo / $CLAUDE_SESSION_REPO / cwd origin, per #638). NEVER use
# `--get .` here — it dumps every repo's state and is the leak this scoping fixes.
SESSION_VIEW=$("$SESSION_STATE_SH" --session-view 2>/dev/null || echo "NO_SESSION_STATE")
echo "$SESSION_VIEW"
# Refill pause (issue #823) — read it EXPLICITLY. --session-view lifts only
# `.prs` and `.root_repo` out of the repo block and then deletes `.repos`, so a
# repo-scoped `refill` never appears in the projection above. Skipping this read
# is how a resumed thread silently resumes refilling after the user said stop.
REPO_KEY=$("$SESSION_STATE_SH" --repo-key 2>/dev/null)
REFILL_RC=0
REFILL_PAUSED=$("$SESSION_STATE_SH" --get ".repos[\"$REPO_KEY\"].refill.paused" 2>/dev/null) || REFILL_RC=$?
echo "REFILL_PAUSED=${REFILL_PAUSED:-null} REFILL_RC=$REFILL_RC"
# Day-mode posture (Step 2D) — read explicitly for exactly the same reason: it
# lives under the repo block, which --session-view deletes after lifting `.prs`
# and `.root_repo`, so it is invisible above. A resumed thread that skips this
# read cannot tell an armed day loop from a dead one, and reports neither.
# Keep the exit code: `|| echo null` here would render an unreadable state
# identical to "no day loop has ever run", and 1A.4 would then report nothing
# at all about a loop that may still be ticking.
DAY_RC=0
DAY_STATE=$("$SESSION_STATE_SH" --get ".repos[\"$REPO_KEY\"].day") || DAY_RC=$?
echo "DAY_STATE=${DAY_STATE:-null} DAY_RC=$DAY_RC"
# Per-PR handoff files — read ONLY the ones for PRs in this repo's scope. The
# handoff filename is still global (issue #655), so two repos at one PR number
# share a file; gating on the scoped PR set AND verifying each payload's repo
# bounds that leak on the read side without the rename #655 tracks.
if [ "$SESSION_VIEW" != "NO_SESSION_STATE" ]; then
SCOPED_PRS=$(jq -r '(.prs // {}) | keys[]' <<<"$SESSION_VIEW" 2>/dev/null)
CUR_REPO=$(jq -r '.repo // ""' <<<"$SESSION_VIEW" 2>/dev/null)
else
SCOPED_PRS=""
CUR_REPO=""
fi
found_handoffs=false
# Read-safe iteration: quoted, one key per line, numeric PR keys only (never
# word-split an unquoted list into the path below).
while IFS= read -r n; do
[ -n "$n" ] || continue
case "$n" in *[!0-9]*) continue ;; esac
# Resolve handoff path: scoped layout takes priority (issue #655); flat fallback for legacy files.
or=$([ -f "$HOME/.claude/session-state.json" ] && \
jq -r --arg n "$n" '(.repos // {}) | to_entries[] | select(.value.prs[$n].owner_repo?) | .value.prs[$n].owner_repo' \
"$HOME/.claude/session-state.json" 2>/dev/null | head -1 || true)
if [ -n "$or" ] && [ "$or" != "null" ]; then
f="$HOME/.claude/handoffs/${or}/pr-${n}-handoff.json"
[ -f "$f" ] || f="$HOME/.claude/handoffs/pr-${n}-handoff.json"
else
f="$HOME/.claude/handoffs/pr-${n}-handoff.json"
fi
[ -f "$f" ] || continue
# If the payload names a DIFFERENT repo than this one, it's the other repo's
# handoff colliding on this PR number (#655) — skip it. A null/absent
# owner_repo is unknown, not a mismatch, so fall back to the PR-number scope.
ho_repo=$(jq -r '.owner_repo // ""' "$f" 2>/dev/null)
if [ -n "$ho_repo" ] && [ -n "$CUR_REPO" ] && [ "$ho_repo" != "$CUR_REPO" ]; then
continue
fi
found_handoffs=true
echo "--- $f ---"
cat "$f"
done < <(printf '%s\n' "$SCOPED_PRS")
$found_handoffs || echo "NO_HANDOFF_FILES"
Invoking-repo scope (issue #687). Every default surface of /pm — the
assignments table, rankings, suggestions, and offered actions — stays in the
invoking repo's lane. The scoped read above is where that starts; the GitHub
views (gh pr/issue list) are already cwd-repo-scoped by gh. Cross-repo
reporting is opt-in: only when the user explicitly asks to see other repos'
work, read session-state.sh --session-view --all-repos (or --get .) — and
never offer or perform a write action (cleanup, merge, rebase, close)
against a PR/issue outside the invoking repo.
Interpret both together, exactly as 3.4's table does: REFILL_RC=0 with true means a human stopped refilling in an earlier turn — it stays paused, and 1A.4 says so in the recovered-state report. REFILL_RC=0 with false/null, or REFILL_RC=3 (no state file ever written), is the default — refill is on. Any other REFILL_RC is unreadable state, not permission: treat refill as paused and report it that way until the state file is readable again.
Parse any found state into an assignments table:
| PR | Issue | Phase | Reviewer | Last SHA | Notes |
|---|
1A.3: Verify against live GitHub
State files may be stale. Cross-reference with live data. When $GH_USER is set (Step 0a), also fetch the user-scoped views so resumed state can be annotated with "yours" vs. "others":
gh pr list --state open --json number,title,headRefName,author,updatedAt
gh pr list --state merged --limit 10 --json number,title,mergedAt
gh issue list --state open --json number,title,labels,assignees --limit 500
# User-scoped views (only if $GH_USER is set)
if [ -n "$GH_USER" ]; then
gh pr list --state open --search "author:$GH_USER" --json number,title,updatedAt
gh pr list --state open --search "review-requested:$GH_USER" --json number,title,author,updatedAt
gh issue list --state open --assignee "$GH_USER" --json number,title,labels
fi
Authorship guard (issue #733, safety.md). /pm ranks and suggests, but any PR work it dispatches (monitoring, /fixpr, /wrap, /subagent against an existing PR) is a write and is scoped to PRs you authored. The unscoped gh pr list above is for context only — annotate each PR "yours" vs "others" (as this step already does) and treat collaborator PRs as read-only (AC6): never dispatch a fix/merge/trigger against one. The per-PR helpers (merge-gate.sh, polling-state-gate.sh --ensure-session, pr-authorship.sh) enforce this as a fail-safe. Override only when the user names a specific PR in chat.
Truncation check: If the returned issue count equals 500, warn: "Showing 500 issues — repo may have more. Results may be incomplete."
- PRs that have merged since the handoff: mark as complete, remove from assignments.
- Issues that have been closed: remove from backlog.
- New PRs not in the state file: note them as untracked.
1A.4: Present recovered state
First, run Step 1C (Backlog & workspace cleanup, below) — the full inline /pm-clean flow, with its confirm gates resolved (acted on or declined) before anything below — it runs on every invocation, resume included (unless --no-clean / fast was passed, which prints only the ranking-only health line and skips the gates). Then show the user:
- Verified assignments table (corrected for merges/closures since handoff)
- Any issues that were in-progress but whose PRs are now missing or stale
- Remaining open issues not yet assigned
- The refill posture recovered in 1A.2 — say it out loud whenever it is not the default: "Refill is paused (you stopped it earlier) — say resume to restart it", or for a narrowed scope, name the scope. A pause the user can't see is one they can't lift, and silence would read as an idle board with no explanation.
- Any day-mode state recovered from
.repos[<key>].day — same reason, same one-line treatment. A live loop (fresh last_tick_at) says it is still ticking and at what cadence; a paused_at marker says the board froze and how to resume; refill_halted says a failure pattern is holding refill and names it. Read it explicitly — --session-view does not project it (2D.5). Apply 3.4's exit-code table to DAY_RC here too: 3 means no day loop has ever run in this repo and is reported as nothing; anything else non-zero means the state was unreadable, which is reported as such rather than as an absent loop, since the difference is whether a Monitor may still be ticking.
Then run Step 1D (Forgotten-PR triage, below) and print its ## Forgotten PRs block — always-on on the resume path too, rendered after the recovered-PR context above.
Proceed with current assignments by default — the recovered pipelines keep running, and this step starts nothing new on its own. Anything this thread does launch from here — a refill into free capacity (3.4), or a batch you re-prioritize into — follows the same inline-first default as the cold-start path: claim and dispatch the inline-eligible issues via Step 3.1 up to the 3–4 concurrent-pipeline ceiling, queue the remainder, and report the launches rather than proposing them. Prompts and chips only for a named /subagent Step 4 disqualifier or an explicit ask (3.1). The refill posture recovered in 1A.2 — reported in item 4 above — decides whether the automatic side of that happens: paused means no refill launches until a human resumes, and a non-null scope narrows every candidate before ranking picks one. As in 1B.5, the pause binds autonomous launches only — a live in-chat re-prioritize or a request naming issues is the human acting, so it proceeds, and it does not on its own lift the pause for future refills.
State: "Continuing with current assignments. Say 're-prioritize' to change strategy, or 'give me prompts instead' for prompt blocks rather than inline runs."
Then proceed to Step 2: Active Monitoring Setup (resume mode restores passive tracking — see Step 2).
Step 1B: Cold start (default)
No prior state — scan GitHub and suggest what to work on.
1B.1: Load or bootstrap pm-config.md
# Probe for the config file via the shared parser. rc=2 means the file is missing.
"$PM_CONFIG_GET" --list >/dev/null 2>&1
LIST_RC=$?
- If
LIST_RC == 2 (BOOTSTRAP): run the same bootstrap logic as /pm-handoff Step 2 (detect infrastructure, map architecture, generate pm-config.md). Then continue.
- Otherwise (CONFIG_EXISTS): parse sections via
--list + per-section --section <name> as in 1A.1.
Extract the ## OKRs section via "$PM_CONFIG_GET" --section OKRs. If rc=0 and the body does not start with "No OKRs set", set OKR_MODE=true.
1B.2: Fetch GitHub state
# Recent merged PRs — understand momentum and direction
gh pr list --state merged --limit 20 --json number,title,mergedAt,author,body
# Open issues — the backlog
gh issue list --state open --json number,title,labels,assignees,createdAt,updatedAt --limit 500
# Open PRs (ALL authors) — used ONLY to detect in-flight work for dedup: skip an
# issue that already has a PR, no matter whose. The author field distinguishes
# yours from collaborators'. Ceiling/slot COUNTS and merge/actionable OFFERS are
# built only from PRs you authored (author == $GH_USER / @me) — never this full
# set. A collaborator's backlog is at most FYI context, never a gate (issue #732).
gh pr list --state open --json number,title,headRefName,author,updatedAt,additions,deletions,body
# User-scoped views (only if $GH_USER is set from Step 0a)
if [ -n "$GH_USER" ]; then
# Your own open PRs — highest priority when asking "what's next"
gh pr list --state open --search "author:$GH_USER" --json number,title,updatedAt,headRefName
# PRs awaiting your review — others are blocked on you
gh pr list --state open --search "review-requested:$GH_USER" --json number,title,author,updatedAt
# Issues assigned to you — committed work
gh issue list --state open --assignee "$GH_USER" --json number,title,labels,updatedAt
fi
Truncation check: If the returned issue count equals 500, warn: "Showing 500 issues — repo may have more. Results may be incomplete."
1B.3: Read issue bodies for top candidates
Reading all issue bodies is expensive. Use a two-pass approach:
Pass 1 — Quick scan: From the issue list, identify the top ~20 candidates using fast signals:
- Labels containing
bug, critical, P0, P1, urgent, blocked
- Issues with no assignee (available for pickup)
- Issues not already covered by an open PR (cross-reference PR branch names and bodies for
#N references)
- Most recently updated (active discussion = likely important)
- Oldest unassigned (may be neglected but important)
Pass 2 — Deep read: For the top ~20 candidates, fetch full bodies:
# For each candidate issue number:
gh issue view $NUMBER --json body,title,labels,comments,assignees
Extract from each:
- Scope and intent (what the issue actually asks for, not just the title)
- Acceptance criteria — when present, these define "done"
- Dependency references, from the body and comments. Match these markers case-insensitively — the same way the closing-keyword rule below does, and for a concrete reason:
/issue-maker Step 8 and /subagent Step 5.1 both write increment links as - Depends on #N at the start of a list item, so a case-sensitive read would collect none of them and every increment chain would look parallelizable to /wave Step 5.1:
- Blocked direction:
blocked by #N, depends on #N, prerequisite for #N, after #N
- Unblocking direction:
unblocks #N, enables #N, required by #N, before #N
- In-flight signal: cross-reference against the open-PR list already fetched in 1B.2 (now includes
body) — a PR body containing a GitHub closing keyword (close/closes/closed, fix/fixes/fixed, resolve/resolves/resolved, case-insensitive) for this issue's number means a PR is already underway; match both local (#N) and cross-repo (owner/repo#N) reference forms. GitHub's closing keywords live in PR bodies, not in the issue's own text, so this signal is never collected from the issue body or comments — same source Section 3.3's progress detection reuses.
- Complexity signals: number of acceptance criteria, files mentioned, architectural scope
- Current assignee — who, if anyone, is already on it
1B.4: Score and rank issues
Sort candidates into four tiers — Critical, High, Medium, Low.
When the user stated a business goal, goal alignment is the primary signal and sets the tier directly:
- Critical — directly unblocks or achieves the goal; without it the goal cannot be met.
- High — significant enabler; materially accelerates progress toward the goal.
- Medium — supporting work; deferrable without derailing the goal.
- Low — tangential, or serves a different goal entirely.
With no stated goal (the default), the priority signals in (1) below set the tier instead. Either way, (2)-(6) then apply to every candidate.
Precedence: the initial tier always comes from goal-alignment (if a goal is stated) or priority-signals (1) otherwise. Leverage (2) and OKR (3) never set the tier on their own — they only modify it afterward, and only upward: leverage via an explicit tier-jump, OKR via the one-tier boost or tie-break rules in (3), never a downgrade. Momentum (4), cost-benefit (5), and exclusions (6) don't touch the tier at all — they refine ordering and the candidate pool within whatever tier (2)-(3) leave it in.
-
Priority signals:
- Labels:
P0/critical > P1/bug > P2/enhancement > unlabeled
- Age + activity: old unassigned issues with recent comments = neglected priority
-
Leverage (tier-jump): an issue inherits the urgency of what it unblocks — one that unblocks three Critical issues is itself Critical, even if its own alignment is Medium. Build a dependency map from the references collected in 1B.3:
- For each issue, record what blocks it and what it blocks.
- Follow chains: if #10 blocks #15 which blocks #20, the root (#10) gets the boost.
- Flag circular dependencies (A blocks B, B blocks A) — these need human resolution; surface them rather than ranking them.
-
OKR alignment (when OKR_MODE=true):
- Issues that directly advance an incomplete key result get a one-tier boost (unless already Critical)
- Issues aligned with an objective broadly get a tiebreaker advantage — ordering within the tier only; the tier label does not change
- Issues matching no OKR take no penalty — they rank on the other signals alone
- Record which OKR(s) each issue aligns with for the rationale (e.g. "Advances O1/KR2"); list at most 2, ordered by objective then key result
-
Recent momentum:
- What areas of the codebase have recent merged PRs? Issues in the same area benefit from warm context.
- What themes appear in recent merges? Issues continuing that theme are cheaper to pick up.
-
Cost-benefit (tie-break within a tier): at equal alignment, the smaller issue wins. Read effort from complexity:quick|light|medium|heavy labels when present, otherwise from scope signals in the body (count of acceptance criteria, files mentioned, architectural reach).
-
Exclusions:
- Skip issues that already have an open PR (per the in-flight signal cross-referenced in 1B.3 — a closing keyword in an open PR body means in flight)
- Skip issues assigned to someone else (unless stale > 14 days)
- Skip issues labeled
blocked, on-hold, wontfix, duplicate
Misaligned effort ("stop doing"): cross-reference the user's current work (their open PRs and assigned issues from 1B.2) against the tiers. If they are actively on Low/Medium work while Critical/High issues sit unassigned and within their scope, flag it — name the low-impact work and the higher-impact work to switch to. Only flag when the misalignment is clear and the alternative is materially better; when their current work is already Critical/High, say it is well-aligned instead.
1B.4b: Judgment check (ask only when the ranking turns on a judgment call)
Ranking is a recommendation, not arithmetic. Before presenting, check whether the top of the list depends on a call only the user can make. Any one of these triggers is enough:
- Near-tied top candidates — two or more issues share the top tier with no OKR or cost-benefit signal separating them.
- Competing OKR alignments — top candidates advance different objectives, and no stated business goal breaks the tie.
- Conflicting urgency signals — e.g. a
P0 label on a stale, quiet issue against an unlabeled issue with active discussion and a fresh dependency.
When a trigger fires, present only the tied candidates, one line of rationale each, and ask one focused question:
Two issues tie for the top:
- #42 — {title} — unblocks #50 and #53, advances O1/KR2
- #38 — {title} — labeled
P0, but quiet for three weeks
Which matters more right now — clearing the dependency chain, or the P0?
Incorporate the answer, finalize the ranking, and continue to 1B.5.
Negative rule — this does not fire on every run. No trigger, no question: when one candidate is clearly ahead, emit the ranking and proceed. A pause the user did not need is a failure of this step, not caution. Ask at most one question per ranking; if the answer is ambiguous, take the higher-leverage candidate, say so in one line, and move on.
1B.5: Present recommendations
First, run Step 1C (Backlog & workspace cleanup, below) — the full inline /pm-clean flow — ahead of everything else in this step, with its confirm gates resolved (acted on or declined) before any ranking output (unless --no-clean / fast was passed, which prints only the ranking-only health line and skips the gates). Then, when $GH_USER is set, lead the output with user-scoped sections before the general backlog ranking. These always take precedence over backlog pickup — they represent work already on the user's plate. Immediately after ## Your Open PRs, run Step 1D (Forgotten-PR triage, below) and print its ## Forgotten PRs block — like Step 1C it is always-on and informational, and it renders before ## Suggested Next Issues. If $GH_USER is unset so no ## Your Open PRs section renders, the block still appears — forgotten-pr-triage.sh defaults to @me — placed after whatever user-scoped sections did render (or on its own if none), still before ## Suggested Next Issues.
See .claude/reference/pm-output-templates.md §User-Scoped Sections for the block format (Your Open PRs, Forgotten PRs, PRs Awaiting Your Review, Issues Assigned to You).
Then output the top 3-5 backlog issues (unassigned / up for pickup) as a ranked list — see .claude/reference/pm-output-templates.md §Suggested Next Issues for the block format.
Full ranking (on request only). When the user asked to rank the backlog rather than "what's next" — "rank the backlog", "priority list", "full ranking" — replace the top 3-5 list with the tiered view. "Full" means every tier is covered, not that every issue is listed: name the issues that earn a decision in each tier and summarize the rest. Omit any tier with no issues. See .claude/reference/pm-output-templates.md §Full Ranking / Tiered View for the block format.
Summarize rather than enumerate once a tier stops informing a decision — most often the Low tier: "68 additional issues are Low-priority relative to this goal". The tier still appears with its heading; it just carries a count instead of 68 bullets.
Window-fit gate (when WINDOW_STR is set and Step 0b parsed it successfully). After ranking and before dispatch, trim the ranked batch to fit inside the remaining window using makespan.sh. This is a pure trim gate — it never reorders the batch.
Recompute the remaining window immediately before dispatch (time may have passed since the arming turn):
if [[ -n "$DEADLINE_EPOCH" && "$DEADLINE_EPOCH" -gt 0 ]]; then
NOW_EPOCH=$(date +%s 2>/dev/null) || NOW_EPOCH=0
RAW_REMAINING_MIN=$(( (DEADLINE_EPOCH - NOW_EPOCH) / 60 ))
# Subtract stall margin to preserve unattended idle headroom
REMAINING_MIN=$(( RAW_REMAINING_MIN - STALL_MARGIN_MIN ))
[[ "$REMAINING_MIN" -lt 0 ]] && REMAINING_MIN=0
# Cap EFFECTIVE_WINDOW_MIN to margin-adjusted remaining time (may be shorter than arming-turn value)
[[ "$REMAINING_MIN" -lt "$EFFECTIVE_WINDOW_MIN" ]] && EFFECTIVE_WINDOW_MIN="$REMAINING_MIN"
fi
- For each ranked candidate, call
estimate-resolve.sh <N> to get est_lo/est_hi; unestimated issues use the Standard fallback (45/90 min).
- Build the batch JSON and pipe to
makespan.sh. If makespan_hi <= EFFECTIVE_WINDOW_MIN (freshly recomputed above), the full batch fits — proceed to dispatch.
- If
makespan_hi > effective_window_min, drop the lowest-ranked candidate and recompute. Repeat until the remaining batch fits or only one issue remains. If that single remaining issue still exceeds the window, do not dispatch anything — emit a no-fit message instead: No batch fits in the remaining window ({EFFECTIVE_WINDOW_MIN} min). Suggest a longer window or a narrower selection. and list all exclusions.
- Each dropped issue is an exclusion — name it with the math:
#N (90 min plan) — excluded: batch would overshoot window by {delta} min
- Present the Window Plan block before
## Suggested Next Issues:
## Window Plan (until ~HH:MM ET · effective N h after M min stall margin)
Batch: #42, #38 — plan-bound makespan 3 h · finish ~4:30 PM ET ✓
Excluded (window):
- #61 (180 min plan) — excluded: adding it overshoots by 90 min
Also persist the final batch issue numbers to session-state so the Step 8 monitor loop can scope overrun alerts to the window batch:
BATCH_NUMS="[$(printf '"%s",' "${BATCH_ISSUES[@]}" | sed 's/,$//')]"
"$SESSION_STATE_SH" --set ".repos[\"$REPO_KEY\"].window.batch_issues=${BATCH_NUMS}" 2>/dev/null || true
When MAKESPAN_SH or ESTIMATE_RESOLVE_SH is unavailable: print DEGRADED: makespan unavailable — window fit skipped; dispatching full ranked batch.
Dispatch the top batch — the default, with no confirmation turn. The ranking is the selection. Take the top-ranked batch and hand its inline-eligible issues to Step 3.1, which claims each one and runs it through the /subagent A→B→C flow up to the 3–4 concurrent-pipeline ceiling, queueing the remainder. Do not ask "should I start these?" — free capacity is a trigger, not a question (CLAUDE.md "KEEP THE PIPELINE FULL"), and the launches are reported, never proposed, exactly as 3.4 reports a refill. Step 3.1 owns the mechanics — issue claims (/subagent 6.0), overlap chains (6.0b), the ceiling and the inline queue (Step 7) — and this step restates none of them. Prompts and chips are the exception, not the act: an issue produces one only when it carries a named /subagent Step 4 disqualifier (quoted in the offer) or the user explicitly asks for prompts — see 3.1.
Read the refill pause before dispatching anything — and before composing the ranking output above, the way Step 1C runs ahead of everything else in this step. Cold start is the default mode for a bare /pm, so this path runs in repos where a human already said "stop" and that stop was persisted. The resume path reads it in 1A.2 and this path reads it here — dispatching without the same read is how an inverted default silently relaunches against an explicit stop:
REPO_KEY=$("$SESSION_STATE_SH" --repo-key)
RC=0
SCOPE_RC=0
PAUSED=$("$SESSION_STATE_SH" --get ".repos[\"$REPO_KEY\"].refill.paused") || RC=$?
SCOPE=$("$SESSION_STATE_SH" --get ".repos[\"$REPO_KEY\"].refill.scope" 2>/dev/null) || SCOPE_RC=$?
# A requested window that could not be parsed or persisted blocks dispatch (Step 0b).
[[ "${WINDOW_PARSE_FAILED:-}" == "true" ]] && PAUSED=true
Interpret both reads with 3.4's table, unchanged: RC=0 + true → paused; RC=0 + false/null, or RC=3 (no state file ever written) → dispatch; any other RC → unreadable state is not permission, so treat it as paused and say the state was unreadable. SCOPE_RC gets the same treatment — a failed scope read yields an empty $SCOPE, which is indistinguishable from "no narrowing exists" and would dispatch the full backlog, so anything but 0 or 3 is paused-and-unreadable too. When paused, rank and report only — launch nothing, and say the pause out loud with how to lift it ("Refill is paused (you stopped it earlier) — say resume to restart it"). A non-null $SCOPE is a narrowing, not a stop: drop every candidate outside it before ranking decides anything, so the recommendations the user reads never contain work they excluded — filtering after the list renders surfaces exactly that work. Name the scope in the report. This gate binds the default dispatch only. A live in-chat request to start a specific issue is the human acting, not refill — it proceeds, and it does not on its own lift the pause for future refills. Step 0's degraded rules still win over this default: no SESSION_STATE_SH means rank and report without starting pipelines, and an unreadable chip-launching.md stops chip offers before they happen.
This read gates the batch; it does not replace the per-launch check. Ranking, Step 1C's confirm gates, and Step 1D's triage can span several turns, so the pause is re-read immediately before each launch by the gate that already owns that — /subagent Step 7's pre-launch check, the same one 3.4's refill relies on. A stop the user says while those steps are still running cancels the remaining launches, and a candidate outside a scope set in that window is skipped at dispatch time. Delegate to it; do not add a second pause mechanism here.
State, when the read cleared: "Starting the top {N} inline — #{a}, #{b}, #{c}; {M} queued. Say 'adjust' to change the selection, or 'give me prompts instead' for prompt blocks rather than inline runs." Then report each launch through 3.1 and the Active Work table (3.2). When it did not clear — paused, or unreadable and therefore read as paused — emit the ranking-only message instead: the ranking, the pause, and how to lift it. Never announce launches that did not happen.
Then proceed to Step 2: Active Monitoring Setup.
Step 1C: Backlog & workspace cleanup (always-on)
Runs on every /pm invocation — both the resume path (1A.4) and the cold-start path (1B.5) call this before printing any ranking/orchestration output, so cleanup is reviewed and acted on (or declined) before the ranking appears. This step never scores or ranks: it runs after state load/scoring, must complete before the ranking presentation, and must not alter 1B.3 candidate narrowing, 1B.4 scoring, or the 1B.4b judgment-check contract.
Unless NO_CLEAN=true (the --no-clean / fast escape hatch parsed in the preamble — see "Escape hatch" below), execute the complete .claude/skills/pm-clean/SKILL.md workflow inline — its Step 0 (argument parse) through Step 4 (confirm-and-act), using default thresholds (no [days] argument → 30-day issue inactivity and 30-day workspace age). This is the same "invoke the full SKILL.md workflow inline, no shortcuts" idiom /babysit-pr uses for /wrap and /fixpr: do not shortcut, and do not duplicate /pm-clean's logic here. /pm-clean stays the single source of the cleanup flow, so its gates, tables, and detection can never drift from a re-implementation in /pm.
Running the full flow means both of /pm-clean's scans and both of its independent confirm gates run inline:
- Issue-staleness scan —
backlog-staleness.sh (/pm-clean Step 1), presented then gated by its Step 4.1 close gate before any gh issue close.
- Workspace sweep —
stale-cleanup.sh --check (/pm-clean Step 2), then --apply only after its Step 4.2 delete gate.
The two scans are independent — one finding nothing never suppresses the other — and each keeps its own confirmation. /pm still NEVER auto-closes an issue or auto-deletes a worktree/branch: every closure and every deletion waits on the user's explicit confirmation inside /pm-clean's gates.
No double-scan. Because only /pm-clean's single pass runs, backlog-staleness.sh and stale-cleanup.sh each execute exactly once per /pm invocation. /pm no longer calls backlog-health.sh on this default path — the count-only "Backlog health" summary it used to print (issue #598) is now subsumed by /pm-clean's fuller, actionable report.
Clean repo → no friction. When both scans come back clean, /pm-clean emits its one-line status — Backlog is clean · No stale worktrees or branches. — with no confirm prompts, and /pm proceeds straight into ranking.
Once the cleanup is done (gates acted on or declined, or the clean-status line printed), return here and continue with the rest of the calling step (1A.4 or 1B.5), then Step 2.
Reconcile the cleanup's effect before ranking. If the inline cleanup closed any issues, remove those now-closed issues from the candidate set, the assignments table, and the ranking before the calling step (1A.4 / 1B.5) presents them — /pm must never suggest or list an issue the user just closed. This is a filter on the already-computed results, not a re-score, so the non-scoring contract above still holds (the dependency map and tiers are not recomputed). Workspace deletions do not affect the issue ranking and need no reconciliation.
Escape hatch: --no-clean / fast
When NO_CLEAN=true, skip the inline /pm-clean flow entirely — none of its cleanup scans or confirm gates run, so there is no friction. For a lightweight, non-interactive health signal, still print the count-only Backlog-health line from the shared aggregator (backlog-health.sh, which wraps the same detector without any interactive gate), then proceed directly to ranking:
"$BACKLOG_HEALTH" --json
This wraps the same backlog-staleness.sh detection (issue #598); see "$BACKLOG_HEALTH" --help for the full field reference. Render a compact bullet block — a heading plus short one-line stats, not a table:
## Backlog health (ranking-only run — cleanup skipped)
- **{total_open} open issues** — {opened_last_N_days} opened in the last 30 days, {older_than_N_days} older
- **{candidate_count} defer/close candidates** among the older issues — run `/pm-clean` (or `/pm` without `--no-clean`) to review and act on them
- **{actionable_backlog} actionable issues** — {closed_last_recent_days} closed in the past 7 days
- **Estimated time to clear:** {estimate.value} {estimate.unit}
When estimate_message is set instead of estimate (the 30-day closure rate is zero), replace the last line with:
- **Estimated time to clear:** cadence too low to estimate
If candidate_count is 0, drop the "defer/close candidates" line rather than showing a zero. This fallback stays purely informational — it never enumerates the flagged issues and never prompts for action; the full interactive cleanup is the default (no flag).
Step 1D: Forgotten-PR triage (always-on)
Runs on every /pm invocation, no flag required — both 1A.4 (resume) and 1B.5 (cold start) call it, rendering its block immediately after ## Your Open PRs and before ## Suggested Next Issues. Like Step 1C, it is informational-first: its block never alters 1B.3 narrowing, 1B.4 scoring, or the 1B.4b judgment check. Its scope is deliberately narrow — a one-shot startup triage of PRs you have likely forgotten, then a hand-off. It does not enter a monitoring loop; continuous PR-fleet monitoring remains /pr-monitor-and-manage's job.
Execute the complete .claude/skills/pm-forgotten-pr/SKILL.md workflow inline — detection, render, confirmation-gated close flow (with independent branch-delete gate), and confirmation-gated merge flow (sequenced via merge-sequence.sh, dispatched as phase-c-merger subagents, one-shot hand-off). This is the same "invoke the full SKILL.md workflow inline, no shortcuts" idiom Step 1C uses for /pm-clean. Pass $GH_USER and $FORGOTTEN_PR_DAYS from the current session; both have defaults in the skill itself.
Step 2: Active Monitoring Setup
After Step 1 presents assignments/suggestions, detect whether any active cloud threads exist and configure on-demand tracking. On the default (non-day) path /pm is a strictly on-demand orchestrator — it does not propose or arm any recurring poll (Monitor, CronCreate, /loop, or hand-rolled wake chains). PR fleet monitoring between messages is owned by /pr-monitor-and-manage.
Resume mode passes through this step too — restore passive tracking state; do not re-arm a poll.
For explicit user-initiated "poll every N" requests that are not PR-fleet-specific, persistent Monitor is the canonical primitive per .claude/rules/scheduling-reliability.md. /pm on this path never sets one up.
The one carve-out: DAY_MODE=true. Day mode arms exactly one persistent Monitor for the repo (Step 2D) and is mutually exclusive with /pr-monitor-and-manage — which is what keeps "exactly one owner dispatches against a given PR" true, the invariant the never-arm-a-poll rule was protecting in the first place. When DAY_MODE=true, run 2.1 for the ACTIVE_COUNT it produces, skip 2.2's redirect (day mode is the monitoring answer, so pointing at /pr-monitor-and-manage would name the one skill that must not run alongside it), record 2.3's passive fields as usual, and continue into 2D. Decision and rationale: .claude/reference/pm-monitoring-decision.md, .claude/reference/pm-day-mode.md.
2.1: Detect active threads
An active cloud thread is an open issue that is yours and in progress, established by ANY of the ownership triggers below — assignment to $GH_USER is not required when a trigger already proves the issue yours (it is only a weak fallback signal for otherwise-unowned issues):
- A feature branch referencing the issue exists on the remote and is attributable to you — it has a matching local worktree (yours) or an open PR you authored. A bare remote branch whose ownership can't be verified does not count: a
git branch -r name carries no author, so a collaborator's pushed branch would otherwise inflate your count.
- A local worktree exists for the issue (inherently yours — you created it)
- An open PR you authored (
author.login == $GH_USER / @me) has Closes #N / Fixes #N referencing the issue
Cross-reference the open-issue list (already fetched in Step 1) against open PRs and git branch -r / git worktree list. Count the result as ACTIVE_COUNT. ACTIVE_COUNT is your own active threads only — a collaborator's PR or bare remote branch does not make an issue one of yours, and /pr-monitor-and-manage (the ≥3 redirect target in 2.2) manages --author @me PRs, so the count feeding the redirect must match its scope (issue #732).
2.2: Fleet monitoring redirect (≥3 active threads)
When ACTIVE_COUNT ≥ 3, surface a one-line redirect (do not offer a scheduler from /pm):
"You have {N} active cloud threads. Run /pr-monitor-and-manage to auto-dispatch fixes and merges across the fleet with per-PR state tracking."
For 0–2 active threads, emit no polling offer — proceed with the assignments table and status only.
2.3: Passive tracking (default)
/pm tracks orchestration state on demand. When the user asks "status", "what's next?", or similar, Step 3 fetches live GitHub state and updates the assignments table. The user may explicitly say "passive" or "just track state" at any time — honor that.
Update ~/.claude/session-state.json to reflect passive tracking. Preserve unknown fields and record at least:
monitoring_active — true when /pm is tracking in-flight work (not a recurring poll)
monitoring_mode: passive for /pm-owned monitoring
- tracked
prs and active_agents where known
Do not create, modify, or clear polling_jobs[] on /pm's behalf. That field remains valid for other skills (/pr-monitor-and-manage, /babysit-pr, etc.); leave entries /pm did not create intact.
If orchestration state is stale after context turnover, recover using .claude/rules/monitor-mode.md "PM Monitoring Recovery".
2.4: Backwards compatibility
Any /pm-created CronCreate jobs from before this change died with their originating session (CronCreate is session-scoped; durable: true has no effect). New /pm sessions do not create replacement polls, and session-scheduling-reconcile.sh clears the dead records at session start (issue #827). Day mode does not change this: it arms a persistent Monitor, never a cron job.
After setup, proceed to Step 2D when DAY_MODE=true, otherwise straight to Step 3: Orchestration Loop.
Step 2D: Day mode — the standing worker (only when DAY_MODE=true)
Day mode turns this thread into the repo's standing worker for the day: rank → claim → dispatch inline → monitor and transition phases → merge → refill → repeat, surfacing roughly one line per event and stopping only on a real terminal condition. It introduces no new gate and relaxes none. Everything about what may run and how much — claims (/subagent 6.0), overlap chains (6.0b), the 3–4 pipeline ceiling, the repo-wide active_work_cap (#1191), the too-big partition and #1193 decomposition (3.1), and the refill pause (3.4) — binds exactly as it does on the on-demand path. Day mode's own contribution is narrow and is only this: between-turn persistence, and a contract for when to stop. Rationale, the mutual-exclusion argument, and the exit taxonomy: .claude/reference/pm-day-mode.md.
2D.1: Arm-time preconditions (non-tick invocation only)
Skip this whole sub-step when DAY_TICK=true — it is arm-time only.
REPO_KEY=$("$SESSION_STATE_SH" --repo-key)
NOW=$(date -u +%FT%TZ)
(a) Mutual exclusion with /pr-monitor-and-manage. Both dispatch /fixpr and /wrap against PRs; two owners on one PR races two merges. Read .pmm_active:
PMM_RC=0
PMM_ACTIVE=$("$SESSION_STATE_SH" --get '.pmm_active') || PMM_RC=$?
PMM_RC | Value | Arm? |
|---|
| 0 | true | No — a fleet monitor is live |
| 0 | false / null | Yes |
| 3 | — | Yes — no state file has ever been written |
| 4, 6, other | — | No — unreadable state is not permission |
When it refuses, say it in one line and stop: Day mode and /pr-monitor-and-manage are the same lane — run /pmm-stop first, then /pm day. A paused fleet (pmm_active false with .pmm.paused_at set) is not dispatching and does not block: arm, and say in the same heartbeat that the paused fleet stays paused and day mode now owns the PRs.
(b) Duplicate-day check, with a freshness window. A day loop whose session died leaves active: true behind forever, and a naive check would then refuse to ever arm again. Borrow /babysit-pr's A2 rule: active counts only while its last tick is fresh.
Read all three fields with their exit codes, and never || echo a default over a failure — a substituted value is indistinguishable from a real one, so a failed read would present itself as fact:
ACTIVE_RC=0; TICK_RC=0; EFF_RC=0
DAY_ACTIVE=$("$SESSION_STATE_SH" --get ".repos[\"$REPO_KEY\"].day.active") || ACTIVE_RC=$?
DAY_LAST_TICK=$("$SESSION_STATE_SH" --get ".repos[\"$REPO_KEY\"].day.last_tick_at") || TICK_RC=$?
DAY_EFF_MIN=$("$SESSION_STATE_SH" --get ".repos[\"$REPO_KEY\"].day.cadence_effective_minutes") || EFF_RC=$?
Apply 3.4's exit-code table to each: 0 is the value, 3 means no state file has ever been written (genuinely absent — arm), and anything else is unreadable state, so refuse to arm and say the read failed. DAY_EFF_MIN is the one that most repays this care: defaulting it to 5 on a failed read shrinks the freshness window from max(3 × 30, 15) = 90m to 15m for a loop running at a 30-minute widened cadence, so a live loop that ticked 20 minutes ago reads as dead and gets a second owner. A fabricated default does not merely lose information — it makes "we failed to look" indistinguishable from "nothing is there" (safety.md fail-closed posture).
With three readable values: freshness window is max(3 × cadence_effective_minutes, 15m). active == true and last_tick_at inside that window → a live loop is already running: refuse, one line, and name how to stop it (say "stop"). active == true with a stale — or unparseable, which is stale for this purpose — last_tick_at → the previous loop died with its session: reclaim it, say so in one line, and continue arming. Anything else → arm normally.
(b+) Session-restart during a usage-limit park. After resolving the active / freshness question, also read day.parked_until and day.limit_kind with their exit codes:
PARK_RC=0
PARKED_UNTIL=$("$SESSION_STATE_SH" --get ".repos[\"$REPO_KEY\"].day.parked_until") || PARK_RC=$?
LIMIT_KIND_RC=0
PARK_LIMIT_KIND=$("$SESSION_STATE_SH" --get ".repos[\"$REPO_KEY\"].day.limit_kind") || LIMIT_KIND_RC=$?
Apply 3.4's exit-code table to both: 0 is the stored value, 3 means no state file has ever been written, anything else is unreadable — retry once (exit 6 is a lock timeout and documented as retryable; handoff-files.md), then fail closed: report the read failure and stop before normal arming. Do not treat an unreadable parked_until as "no park pending" — a lock timeout or parse failure can hide an active park, allowing Steps 1 and 2 to dispatch while the account limit is still active. If PARKED_UNTIL is non-null, non-JSON-"null", and in the future (date -u -d "$PARKED_UNTIL" +%s 2>/dev/null || date -u -j -f '%Y-%m-%dT%H:%M:%SZ' "$PARKED_UNTIL" '+%s' is greater than $(date -u +%s) — -u on the BSD fallback too: without it macOS parses the Z timestamp as local time, so an ET machine reads every park as four hours longer than it is): the session restarted while a park was active. Only re-arm the limit-wake Monitor if PARK_LIMIT_KIND == "rolling_window" — weekly parks cannot be auto-woken because no in-session Monitor can outlast days. If limit_kind is readable and not "rolling_window" (or is readable and non-"rolling_window"), do not re-arm; print one line (Session restarted during weekly-cap park; manual resume required when window reopens) and stop. If limit_kind is unreadable (LIMIT_KIND_RC non-zero and non-3), fail closed: do not re-arm and print one line naming the read failure. For a confirmed rolling_window park: re-arm the limit-wake Monitor and say so in one line, then stop without running Steps 1 and 2. Which wake depends on how the park was recorded — read day.limit_cause and day.limit_probe_fires_remaining with the same exit-code table (0 value, 3 no state file, anything else unreadable → fail closed and re-arm nothing): a null/absent limit_probe_fires_remaining re-arms 2D.6's sleep-until-reset one-shot using the remaining time from now to PARKED_UNTIL (Session restarted during usage-window park; resuming automatically at {PARKED_UNTIL}); a limit_cause == "preemptive" park with a positive integer fires-remaining re-arms 2D.7's probe Monitor with that count, not a fresh bound (Session restarted during pre-emptive park; probing every {N}m, {F} checks left). Zero fires left means the bound was already spent — stay parked, manual resume, no re-arm. Either way the board is parked and will resume when the wake fires.
A spent bound outlives its own deadline — check it before the expiry shortcut. A pre-emptive park with an unknown reset sets parked_until to exactly cadence x fires ahead, which is the same instant the last probe fires. So the moment the bound is spent, parked_until is in the past — and an expiry-first test reads that as "the park resolved". It did not: PROBE=exhausted leaves the park in place, awaiting a manual /pause-resume, with the execution gate still closed. Arming normally there erases the manual-resume state and starts a loop whose every launch the gate then blocks — a live board that cannot dispatch and never says why. So when limit_cause == "preemptive" and limit_probe_fires_remaining reads 0, stay parked regardless of parked_until: re-arm nothing, print one line (Day loop parked (probe bound spent) — resume manually with /pause-resume), and stop recovery. Only with that case excluded does the expiry shortcut apply: if parked_until is in the past or null, the park resolved or never existed; continue arming normally.
(c) Settle the race before arming: publish, then re-read. (a) and (b) are read-then-write across separate session-state.sh calls, so each call is locked but the pair is not: /pm day and /pr-monitor-and-manage starting within the same moment can each read the other as clear and both arm. Close it without inventing a lease — write your own claim first, then re-read theirs:
-
Write only .repos[<key>].day.active=true — a bare ownership claim, nothing else:
"$SESSION_STATE_SH" --set ".repos[\"$REPO_KEY\"].day.active=true"
Not the full init object: that runs in 2D.2, after Steps 1 and 2, and it replaces the whole day object — writing it here would destroy any goal a previous run stored before 2D.2 gets the chance to read and carry it forward.
-
Re-read .pmm_active. Still clear → you own the repo; continue (Steps 1 and 2, then 2D.2 arms the Monitor).
-
Set → the fleet won the race: write .repos[<key>].day.active=false to release the claim, arm nothing, run no part of Step 1, and stand down with the same one-line message as (a).
Whoever writes second is guaranteed to see the other's claim, so this can never leave two owners. It can leave zero — both stand down if they interleave exactly — which is safe and re-runnable, and far cheaper than the lease protocol that would be needed to also guarantee a winner. /pr-monitor-and-manage Step 0-pre runs the mirror of this sequence.
2D.2: Arm
By the time this runs, 2D.1 has settled ownership and Steps 1 and 2 have already run in full and unchanged — the entry mode (1A resume or 1B cold start), Step 1C's cleanup gates, Step 1D's triage, and the ranking and first dispatch. Day mode does not skip the first turn's work; it keeps going after it. Initialize state and arm one Monitor:
Resolve the goal first — the init write below replaces the whole day object, so anything read after it reads what was just written, not what was there. /pm day resume with no goal text must carry the interrupted run's goal forward rather than wiping it:
PRIOR_GOAL_RC=0
PRIOR_GOAL=$("$SESSION_STATE_SH" --get ".repos[\"$REPO_KEY\"].day.goal") || PRIOR_GOAL_RC=$?
Read PRIOR_GOAL_RC with 3.4's table: 0 is the stored value (possibly JSON null), 3 means no state file has ever been written, and anything else is unreadable — retry once, since exit 6 is a lock timeout and documented as retryable (handoff-files.md), then report and stop if it still fails. Do not || echo null over it: that would make a failed read identical to "no goal was ever set", and a day loop that ranks against the wrong objective for six hours is precisely the error nobody is watching to catch.
Also read PRIOR_HITS before the init write so the thrash-guard counter can be carried forward across re-arms (2D.6). Use a lenient default on failure — an unreadable counter resets to 0 rather than blocking the arm:
PRIOR_HITS_RC=0
PRIOR_HITS=$("$SESSION_STATE_SH" --get ".repos[\"$REPO_KEY\"].day.consecutive_limit_hits") || PRIOR_HITS_RC=$?
[ "$PRIOR_HITS_RC" -eq 3 ] && PRIOR_HITS=0
# Unreadable or non-integer: start clean — each new limit hit will still increment from 0
[[ "$PRIOR_HITS" =~ ^[0-9]+$ ]] || PRIOR_HITS=0
Then take BUSINESS_GOAL when the user supplied one this invocation, otherwise PRIOR_GOAL, and build the whole day object in one jq call. Building it with jq --arg rather than string-interpolating it does two jobs at once: goal is the one field carrying the user's own words and must never reach a --set string directly (the same rule refill.scope follows in 3.4), and a single atomic write removes the second, separately-failing write that a follow-up --set would add.
DAY_GENERATION="$(date -u +%Y%m%dT%H%M%SZ)-$$-${RANDOM:-0}"
EFFECTIVE_GOAL="${BUSINESS_GOAL:-}"
[ -z "$EFFECTIVE_GOAL" ] && [ "$PRIOR_GOAL" != null ] && EFFECTIVE_GOAL=$(jq -r . <<<"$PRIOR_GOAL")
DAY_JSON=$(jq -cn \
--arg now "$NOW" --arg goal "$EFFECTIVE_GOAL" \
--argjson base "$DAY_CADENCE_MIN" --argjson maxfail "$MAX_PIPELINE_FAILURES" \
--argjson phits "$PRIOR_HITS" \
'{active:true, started_at:$now, last_tick_at:$now,
cadence_base_minutes:$base, cadence_effective_minutes:$base,
tick_count:0, digest:null, digest_streak:0,
failure_streak:0, max_pipeline_failures:$maxfail,
refill_halted:false, halt_reason:null, stop_requested:false,
monitor_task_id:null, monitor_generation:null, paused_at:null,
parked_until:null, limit_kind:null,
limit_resume_task_id:null, limit_resume_generation:null,
consecutive_limit_hits:$phits,
goal:(if $goal == "" then null else $goal end)}')
INIT_RC=0
"$SESSION_STATE_SH" --set ".repos[\"$REPO_KEY\"].day=$DAY_JSON" || INIT_RC=$?
The two --argjson arguments are why the preamble validates those flags as unsigned integers: --argjson base 2.5 or a non-numeric value makes jq fail here and the whole object never gets written.
This write must succeed before anything is armed. A non-zero INIT_RC means the ownership claim 2D.1(c) depends on was never published and there is no day object for the loop to tick against — so arm nothing, report that day mode did not start, and stop. Arming on an unwritten claim is the worst available order: the race protection is gone (the other side re-reads and sees nothing) and the Monitor would tick into state that does not exist.
Say which goal the run is using in the first heartbeat, and say when it was carried forward from a previous run rather than given this time — a resumed goal the user cannot see is one they cannot correct.
Then arm the Monitor with persistent: true and description PM day mode, sleep-first so the loop's own first tick is the one run inline below:
while sleep "$(( DAY_CADENCE_MIN * 60 ))"; do
printf '%s\n' "/pm day --tick --day-generation $DAY_GENERATION --cadence ${DAY_CADENCE_MIN}m"
done
Record the returned task ID immediately — an unrecorded Monitor cannot be stopped, so a day loop with no recorded ID is one nothing can turn off:
PUBLISH_RC=0
"$SESSION_STATE_SH" \
--set ".repos[\"$REPO_KEY\"].day.monitor_task_id=$MONITOR_TASK_ID" \
--set ".repos[\"$REPO_KEY\"].day.monitor_generation=\"$DAY_GENERATION\"" || PUBLISH_RC=$?
Check that write's exit code — do not assume it landed. The two failures need opposite handling and both end with day mode reported as not running:
- Arming failed (no task ID): roll the state back —
active=false, last_tick_at=null, both identity fields null.
- Arming succeeded but the publish failed (
PUBLISH_RC non-zero): a Monitor is now running that state does not know about, so TaskStop it using the ID you hold in hand right now, then roll back the same fields. That ID exists only in this shell; letting the turn end without using it strands a live Monitor re-invoking /pm day --tick on a repo with no day state, forever, with nothing left that can name it to TaskStop. If the TaskStop also fails, say so explicitly and name the task ID in the message so a human can stop it — a stranded loop the user cannot see is strictly worse than one they can.
Either way, tell the user day mode is not running: a thread that believes it armed and did not is the silent-watcher failure scheduling-reliability.md exists to prevent. The pipelines Step 1 already started keep running; they just have no between-turn loop.
Then run one tick immediately (2D.3) — the sleep fires first, so without this the board sits idle for a full cadence.
2D.3: The tick
Six sub-steps, in order. This is the entry point for DAY_TICK=true.
D0 — Tick gate. Three reads; any mismatch is a silent exit 0 with no output, because a stale Monitor's tick must not narrate:
TICK_GENERATION equals the recorded day.monitor_generation (a tick from a superseded Monitor is stale).
day.active == true.
day.stop_requested != true.
D1 — Reconcile and transition. Run items 1–3 of monitor-mode.md's per-cycle checklist verbatim: process completed subagents and parse exit reports, execute phase transitions per phase-protocols.md (including any stalled in session-state.json), and run the reviewer-escalation gate for every session PR still on reviewer == cr. That checklist owns each step's exact invocation; day mode adds nothing here — this is the same in-turn loop the on-demand path already runs.
Then update the failure streak from this tick's terminal outcomes, in completion order: merged resets failure_streak to 0; blocked increments it. Only terminal outcomes count — a pipeline still in Phase B is neither.
Evaluate the halt threshold here, not in D4. If the updated failure_streak >= max_pipeline_failures, persist day.refill_halted=true with halt_reason="failure_streak" and surface the pattern now, before D2 runs:
Refill halted — 3 consecutive pipelines blocked: #61 (CI failing on main), #55 (CI failing on main), #48 (CodeRabbit budget exhausted). Say "resume" to restart refilling.
Name each blocked pipeline's issue and its blocker, so the user can see at a glance whether this is one broken thing or three unrelated ones — that distinction is the entire value of halting on a streak rather than on a count.
The placement is the point: D2 reads refill_halted to decide whether to launch, so evaluating the threshold in D4 would let the very tick that crossed it refill first and halt afterwards, pushing one more pipeline into a board already known to be failing. A halt that takes effect one tick late is a halt that fired after the damage.
D2 — Refill. Run Step 3.4 unchanged — the pause read with its exit-code table, $SCOPE narrowing, queue before backlog, per-pick re-validation, overlap chains, FREE from active-work-cap.sh, and the reported-not-proposed launch lines. Day-mode conditions sit on top of it, none replacing any part:
The usage-horizon gate runs first, before any pick is dispatched (#1428). The harness prints the in-context remaining-token counter (<total_tokens>N tokens left</total_tokens>) into this turn's context and refreshes it after every tool result; read that number — never a count derived from the transcript or any local estimate — and hand it to usage-horizon.sh --observe, then branch on --check. This is the safety.md §"Anthropic Quota & Spend Authority" horizon carve-out: the figure is upstream-authoritative, and the script only compares it.
# HORIZON_REMAINING / HORIZON_LIMIT: the numbers the HARNESS printed this turn.
# Leave both empty when the counter is not in context — an absent reading is
# `unknown`, which is never `clear` and never a park trigger.
HORIZON_STATUS=unknown
HORIZON_OBSERVE_RC=0
if [ -n "${USAGE_HORIZON_SH:-}" ] && [ -n "${HORIZON_REMAINING:-}" ]; then
if [ -n "${HORIZON_LIMIT:-}" ]; then
"$USAGE_HORIZON_SH" --observe "$HORIZON_REMAINING" --limit "$HORIZON_LIMIT" \
>/dev/null 2>&1 || HORIZON_OBSERVE_RC=$?
else
"$USAGE_HORIZON_SH" --observe "$HORIZON_REMAINING" >/dev/null 2>&1 || HORIZON_OBSERVE_RC=$?
fi
fi
if [ -n "${USAGE_HORIZON_SH:-}" ] && [ "$HORIZON_OBSERVE_RC" -eq 0 ]; then
HORIZON_OUT=$("$USAGE_HORIZON_SH" --check 2>/dev/null) || true
_HS=$(printf '%s\n' "$HORIZON_OUT" | sed -n 's/^STATUS=//p' | head -1)
case "$_HS" in clear|approaching|critical) HORIZON_STATUS="$_HS" ;; *) HORIZON_STATUS=unknown ;; esac
# Keep the script's own REASON for the heartbeat. A run-long `unknown` otherwise
# looks identical whether the script is missing, its write is failing, the TTL
# expired, or a sibling session holds the slot — and only the last is benign.
HORIZON_REASON=$(printf '%s\n' "$HORIZON_OUT" | sed -n 's/^REASON=//p' | head -1)
fi
case "$HORIZON_STATUS" in
clear) HORIZON_REFILL_OK=true; HORIZON_PARK=false; HORIZON_IDLE_REASON="" ;;
approaching) HORIZON_REFILL_OK=false; HORIZON_PARK=false; HORIZON_IDLE_REASON="paused (horizon approaching)" ;;
critical) HORIZON_REFILL_OK=false; HORIZON_PARK=true; HORIZON_IDLE_REASON="paused (horizon critical)" ;;
*) HORIZON_REFILL_OK=false; HORIZON_PARK=false; HORIZON_IDLE_REASON="paused (horizon unknown)" ;;
esac
printf 'HORIZON_STATUS=%s\nHORIZON_REFILL_OK=%s\nHORIZON_PARK=%s\nHORIZON_IDLE_REASON=%s\nHORIZON_REASON=%s\n' \
"$HORIZON_STATUS" "$HORIZON_REFILL_OK" "$HORIZON_PARK" "$HORIZON_IDLE_REASON" "${HORIZON_REASON:-}"
--observe exits 0 on a successful record whatever the verdict — only --check maps verdicts to exit codes — so a non-zero HORIZON_OBSERVE_RC is a real write/usage/lock failure, not a bad verdict, and the gate skips --check entirely and holds unknown. That clamp is the one place this gate second-guesses the script, and it earns it: a failed observe means this turn's reading did not land, so any stored verdict is knowably older than what we just tried to record — and because the counter only falls during a session, a stale reading is optimistic, the one direction that matters. Skipping the read costs at most a tick of refill and can never park (unknown never parks). Never substitute a remembered number for a failed observe.
A tick with no counter in context still reads --check. The absent-reading case is deliberately not clamped: a reading recorded by an earlier tick of this same session is legitimate evidence, and the script's own TTL and session-ownership gates are what decide whether it is still good. Requiring a fresh reading every tick would force unknown on every turn where the counter did not surface, which stops refill on a healthy board — turning a wind-down feature into a board-stopper. Then:
| Verdict | Refill | Park | Chat |
|---|
clear | normal | no | nothing — the tick proceeds unchanged |
approaching | stop for this run — start no new chip and dispatch no new pipeline | no | one always-emit heartbeat line naming the runway (horizon approaching — ~N tokens left; starting nothing new) |
critical | stop | yes — run 2D.7 before this tick ends, then stop the tick | 2D.7's ≤2-line park surface |
unknown | stop | never | nothing — unknown is reported on the idle line only |
unknown is a posture, not an event: in-flight work finishes, nothing new starts, and no park is ever triggered by it alone. On a machine running several sessions the horizon slot is machine-wide and a displaced session reads unknown routinely (usage-horizon.sh --help §CONCURRENT SESSIONS), so treating it as a park trigger would park healthy boards for the wrong reason. The one thing it must never do is read as clear — hence the case default above, not a [ "$_HS" != critical ] test.
- Refill runs only when the horizon gate above sets
HORIZON_REFILL_OK=true. Report the horizon reason on the idle line (paused (horizon approaching) / paused (horizon critical) / paused (horizon unknown)). When HORIZON_REASON is non-empty, append it parenthetically on the unknown idle line only — paused (horizon unknown) — {HORIZON_REASON}. The IDLE_REASON digest input stays the bare form above, so the annotation never perturbs the stable-state hash.
- Refill runs only when
day.refill_halted is false. This is day mode's own automatic halt, set in D1 the moment the streak crosses the threshold so it binds on the same tick, and it is deliberately a separate field from .repos[<key>].refill: that one is contractually human-written-only, and a machine writing it would blur the very distinction that keeps issue text from being able to halt a pipeline. Read both; refill needs both clear. Report a halt as paused (pipeline failures) on the idle line.
- Refill runs only when
credit-budget.sh --check exits 0 (ok). Read it once per D2 tick (same moment as the refill.paused read, not in a separate phase). Apply 3.4's exit-code table: exit 1 (reached) → land near-done work and park (3.4's budget gate above); exit 2 (unknown) → conservative posture — finish in-flight, start nothing new, report paused (budget unknown). The budget check is re-read per pick exactly as refill.paused is.