Imported from zeveck/zskills (
skills/run-plan/SKILL.md). Install upstream withnpx skills add zeveck/zskills --skill run-plan. Copyright stays with the author.
/run-plan <plan-file> [phase|finish] [auto] [every SCHEDULE] [now] | stop | next — Plan Phase Executor
Orchestrates plan-driven development. Reads a plan document, identifies the next incomplete phase, dispatches implementation in a worktree, verifies with a separate agent, updates progress tracking, writes a persistent report, and optionally auto-lands to main. Can self-schedule for recurring runs to work through multi-phase plans autonomously.
Ultrathink throughout. Use careful, thorough reasoning at every step.
Arguments
/run-plan <plan-file> [phase] [auto] [pr|direct] [every SCHEDULE] [now]
/run-plan stop | next
- plan-file (required) — path to plan, e.g.
plans/FEATURE_PLAN.md - phase (optional) — specific phase, e.g.
4a. If omitted, auto-detect next incomplete phase - finish (optional) — run ALL remaining phases sequentially until the
plan is complete.
finishis approval to START — do not ask for confirmation before the first phase (the user already said "finish"). Withoutauto: pauses BETWEEN phases to show results and ask "continue to next phase?" Withauto(or thefinish autocomposite alias): each phase runs as its own cron-fired top-level turn (~5 min between phases via one-shot crons scheduled by Phase 5c). The first phase runs immediately; each subsequent phase is scheduled after the prior phase lands. Preserves fresh context per phase — no late-phase fatigue. Each phase still gets full verification, testing, and all safety rails. If any phase fails verification or hits a conflict, stops there.finishandeveryare mutually exclusive.finish autoschedules its own ~5-min one-shot crons internally.every Nschedules a recurring cron at user-set cadence. Combining them would produce two overlapping cron schedules. Use one or the other. - auto (optional) — run autonomously: skip scope-confirmation /
approval prompts and human-review pauses (between-phase pause, drift
findings, staleness check, verifier-fail review) so the skill can run
without an attended user, AND pass
--autoto per-phase/land-prdispatches (auto-merge the resulting PR). Thefinish autocomposite is an alias that hoistsauto(backward-compat per D2-RP). - every SCHEDULE (optional) — self-schedule recurring runs via cron:
- Accepts intervals:
4h,2h,30m,12h - Accepts time-of-day:
day at 9am,day at 14:00,weekday at 9am - Without
now: schedules only, does NOT run immediately - With
now: schedules AND runs immediately - Implies
auto— scheduling only makes sense for autonomous runs - Cron prompt omits phase number so each invocation auto-detects the next incomplete phase
- Each run re-registers the cron (self-perpetuating)
- Cron is session-scoped — dies when the session dies
- Accepts intervals:
- now (optional) — run immediately. When combined with
every, runs immediately AND schedules. Withoutevery,nowis the default behavior. - status — show plan progress: all phases, their status, what's next, and what's blocked. Read-only — no agents dispatched, no approval gate.
- stop — cancel any existing
/run-plancron and exit. Takes precedence over all other arguments. - next — check when the next scheduled run will fire. Takes precedence
over all other arguments except
stop.
Detection: scan $ARGUMENTS for:
stop(case-insensitive) — cancel cron and exit (highest precedence)next(case-insensitive) — check schedule and exitstatus(case-insensitive) — show plan progress and exitfinish(case-insensitive) — run all remaining phases sequentiallynow(case-insensitive) — run immediatelyauto(case-insensitive) — autonomous modeeveryfollowed by a schedule expression — scheduling modepr(case-insensitive) — PR landing modedirect(case-insensitive) — direct landing mode- Neither
prnordirect— read config default (execution.landing), orcherry-pickif no config
Landing mode resolution:
- Explicit argument wins:
prordirectin $ARGUMENTS - Config default: read
.claude/zskills-config.jsonexecution.landingfield - Fallback:
cherry-pick
# Detect landing mode
# Resolve repo root before any config read. $PROJECT_ROOT is never exported
# into a SKILL.md fence (it is only set inside post-run-invariants.sh, a
# separate process), so anchor it on $CLAUDE_PROJECT_DIR -- the established
# peer pattern used elsewhere in this skill (#791).
PROJECT_ROOT="${PROJECT_ROOT:-$CLAUDE_PROJECT_DIR}"
LANDING_MODE="cherry-pick" # default
if [[ "$ARGUMENTS" =~ (^|[[:space:]])[pP][rR]($|[[:space:]]) ]]; then
LANDING_MODE="pr"
elif [[ "$ARGUMENTS" =~ (^|[[:space:]])[dD][iI][rR][eE][cC][tT]($|[[:space:]]) ]]; then
LANDING_MODE="direct"
else
# Read config default
CONFIG_FILE="$PROJECT_ROOT/.claude/zskills-config.json"
if [ -f "$CONFIG_FILE" ]; then
CONFIG_CONTENT=$(cat "$CONFIG_FILE")
if [[ "$CONFIG_CONTENT" =~ \"landing\"[[:space:]]*:[[:space:]]*\"([^\"]*)\" ]]; then
CFG_LANDING="${BASH_REMATCH[1]}"
if [ -n "$CFG_LANDING" ]; then
LANDING_MODE="$CFG_LANDING"
fi
fi
fi
fi
Finish-mode resolution. $FINISH_MODE is set deterministically from
args (NOT orchestrator-tracked) so downstream conditionals (cherry-pick
worktree gating, PR title construction, etc.) see a single canonical
value. Values are exactly "finish", "finish-auto", or empty —
nothing else is valid. Any downstream check that reads $FINISH_MODE
must match against these three states.
# Detect finish mode (canonical values: "finish", "finish-auto", "")
FINISH_MODE=""
if [[ "$ARGUMENTS" =~ (^|[[:space:]])[fF][iI][nN][iI][sS][hH]($|[[:space:]]) ]]; then
if [[ "$ARGUMENTS" =~ (^|[[:space:]])[aA][uU][tT][oO]($|[[:space:]]) ]]; then
FINISH_MODE="finish-auto"
else
FINISH_MODE="finish"
fi
fi
# AUTO_FLAG: set by standalone `auto` (regardless of finish), OR by the
# `finish auto` composite (backward-compatible alias per D2-RP).
AUTO_FLAG=0
if [[ "$ARGUMENTS" =~ (^|[[:space:]])[aA][uU][tT][oO]($|[[:space:]]) ]]; then
AUTO_FLAG=1
fi
# Backward-compatible composite: `finish auto` implies AUTO_FLAG.
# The post-detection hoist preserves the load-bearing user workflow
# `/run-plan plan.md finish auto every 4h`.
if [ "$FINISH_MODE" = "finish-auto" ]; then
AUTO_FLAG=1
fi
# AUTO_ARG: the suffix string to thread into every child-skill dispatch
# that recognizes positional `auto` (e.g. /refine-plan, /draft-plan,
# /draft-tests per #581 / PR #642). Empty when AUTO_FLAG=0 so children
# stay in non-auto behavior. Closure-incomplete #648 sites used this.
AUTO_ARG=""
if [ "$AUTO_FLAG" = "1" ]; then
AUTO_ARG=" auto"
fi
Validation:
# direct + main_protected -> error
PROJECT_ROOT="${PROJECT_ROOT:-$CLAUDE_PROJECT_DIR}"
if [[ "$LANDING_MODE" == "direct" ]]; then
CONFIG_FILE="$PROJECT_ROOT/.claude/zskills-config.json"
if [ -f "$CONFIG_FILE" ]; then
CONFIG_CONTENT=$(cat "$CONFIG_FILE")
if [[ "$CONFIG_CONTENT" =~ \"main_protected\"[[:space:]]*:[[:space:]]*true ]]; then
echo "ERROR: direct mode is incompatible with main_protected: true. Use pr mode or change config."
exit 1
fi
fi
fi
Reading branch_prefix from config:
# Read branch prefix from config (default: feat/)
PROJECT_ROOT="${PROJECT_ROOT:-$CLAUDE_PROJECT_DIR}"
BRANCH_PREFIX="feat/"
if [ -f "$PROJECT_ROOT/.claude/zskills-config.json" ]; then
CONFIG_CONTENT=$(cat "$PROJECT_ROOT/.claude/zskills-config.json")
# ([^\"]*) allows empty string match -- empty prefix means no prefix
if [[ "$CONFIG_CONTENT" =~ \"branch_prefix\"[[:space:]]*:[[:space:]]*\"([^\"]*)\" ]]; then
BRANCH_PREFIX="${BASH_REMATCH[1]}"
fi
fi
Resolving FULL_TEST_CMD from config:
The orchestrator resolves the test command ONCE here and passes it
verbatim to every impl/verifier/fix-agent dispatch prompt. Three-case
decision tree (same contract as /verify-changes):
PROJECT_ROOT="${PROJECT_ROOT:-$CLAUDE_PROJECT_DIR}"
FULL_TEST_CMD=""
if [ -f "$PROJECT_ROOT/.claude/zskills-config.json" ]; then
CONFIG_CONTENT=$(cat "$PROJECT_ROOT/.claude/zskills-config.json")
if [[ "$CONFIG_CONTENT" =~ \"full_cmd\"[[:space:]]*:[[:space:]]*\"([^\"]*)\" ]]; then
FULL_TEST_CMD="${BASH_REMATCH[1]}"
fi
fi
if [ -n "$FULL_TEST_CMD" ]; then
# Case 1: config set — use it.
TEST_MODE="config"
else
# Check for test infra (same list as preflight hook-placeholder gate):
# package.json with a "test" script, vitest/jest/pytest configs,
# Makefile, tests/*.sh, tests/*.py, tests/*.js.
TEST_INFRA_DETECTED=0
[ -f "$PROJECT_ROOT/package.json" ] && grep -q '"test"[[:space:]]*:' "$PROJECT_ROOT/package.json" && TEST_INFRA_DETECTED=1
ls "$PROJECT_ROOT"/vitest.config.* "$PROJECT_ROOT"/jest.config.* "$PROJECT_ROOT"/pytest.ini \
"$PROJECT_ROOT"/.mocharc.* "$PROJECT_ROOT"/Makefile 2>/dev/null | grep -q . && TEST_INFRA_DETECTED=1
ls "$PROJECT_ROOT/tests"/*.sh "$PROJECT_ROOT/tests"/*.py "$PROJECT_ROOT/tests"/*.js 2>/dev/null | grep -q . && TEST_INFRA_DETECTED=1
if [ "$TEST_INFRA_DETECTED" -eq 1 ]; then
# Case 2: tests exist but no command — misconfigured. Refuse.
echo "ERROR: /run-plan: test infra detected but testing.full_cmd is empty." >&2
echo " Run /update-zskills to configure, or edit .claude/zskills-config.json." >&2
exit 1
else
# Case 3: no test infra, no command — docs-only/greenfield. Skip test gate.
TEST_MODE="skipped"
echo "/run-plan: no test infra detected; skipping test gate — will be noted in report."
fi
fi
Never hardcode npm run test:all, npm start, or .test-results.txt.
Every subsequent reference uses $FULL_TEST_CMD, $DEV_SERVER_CMD, and
$TEST_OUTPUT_FILE. Agent dispatch prompts must include the RESOLVED
literal value of each var (substituted from the helper's output BEFORE
emission), or the explicit "Tests: skipped — no test infra" when
TEST_MODE=skipped. Markdown blockquotes (e.g., the worktree-test recipe
at lines 898-930) do NOT undergo parameter expansion at emission time —
YOU, the orchestrator-model, must perform the substitution before typing
the blockquote into the subagent's prompt.
To resolve all three vars in one step (sibling resolution to the
$FULL_TEST_CMD decision tree above), source the helper:
if [ -f "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-resolve-config.sh" ]; then
export CLAUDE_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT}"
. "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-resolve-config.sh"
else
. "$CLAUDE_PROJECT_DIR/.claude/skills/update-zskills/scripts/zskills-resolve-config.sh"
fi
# Informational fallbacks for the recipe (non-critical-path):
[ -z "$DEV_SERVER_CMD" ] && DEV_SERVER_CMD=npm
[ -z "$TEST_OUTPUT_FILE" ] && TEST_OUTPUT_FILE=.test-results.txt
Note: if dev_server.cmd is unset, the recipe instructs npm as a
sensible default; configure dev_server.cmd for non-npm projects.
The testing.output_file filename suffix is informational (not
load-bearing for project semantics) — fallback to .test-results.txt
is safe.
Strip pr/direct from arguments before passing to downstream processing
(same pattern as stripping auto, finish, etc.).
Examples:
/run-plan plans/FEATURE_PLAN.md— interactive, next phase/run-plan plans/FEATURE_PLAN.md 4b— interactive, specific phase/run-plan plans/FEATURE_PLAN.md finish— interactive, all remaining phases (pauses between each)/run-plan plans/FEATURE_PLAN.md finish auto— autonomous, all remaining phases (chunked, one phase per cron turn)/run-plan plans/FEATURE_PLAN.md auto every 4h— schedule every 4h/run-plan plans/FEATURE_PLAN.md auto every 4h now— schedule + run now/run-plan plans/FEATURE_PLAN.md finish auto pr— autonomous, all phases, PR landing/run-plan plans/FEATURE_PLAN.md direct— direct mode, work on main/run-plan plans/FEATURE_PLAN.md status— show plan progress/run-plan now— trigger the active cron early/run-plan stop— cancel scheduled runs/run-plan next— check when the next phase will run
Subcommands: status, now, next, stop
Select the subcommand based on $ARGUMENTS, then read the
corresponding file in full and follow its procedure end-to-end.
Do not proceed until you have read the file. Each subcommand exits
before Phase 1.
| Subcommand | File |
|---|---|
status |
subcommands/stop-next-status.md |
now (standalone) |
subcommands/stop-next-status.md |
next |
subcommands/stop-next-status.md |
stop |
subcommands/stop-next-status.md |
Phase 0 — Schedule (if every is present)
If $ARGUMENTS contains every <schedule>:
-
Parse the schedule — convert to a cron expression. The LLM interprets natural scheduling expressions.
For interval-based schedules (
4h,2h,30m): use the CURRENT minute as the offset so the first fire is a full interval from now, not aligned to midnight. Check the current minute withdate +%M:4hat minute 9 →9 */4 * * *(fires at :09 past every 4th hour)2hat minute 15 →15 */2 * * *30m→*/30 * * * *(no offset needed for sub-hour)1hat minute 9 →9 * * * *
For time-of-day schedules (
day at 9am,weekday at 2pm): offset round minutes by a few to avoid API busy marks:day at 9am→3 9 * * *day at 14:00→3 14 * * *weekday at 9am→3 9 * * 1-5
-
Deduplicate — use
CronList+CronDeleteto remove any whose prompt starts withRun /run-plan. -
Construct the cron prompt. Strip the phase number (so each invocation auto-detects the next incomplete phase). Always include
nowin the cron prompt so each cron fire runs immediately AND re-registers itself. Note: thisnowis for the CRON's invocation, not the current invocation.Assemble the prompt from
$AUTO_FLAGstate, not from literal$ARGUMENTSsubstrings. Cron-firedevery-mode runs MUST advance between phases without human interaction, so includeautounconditionally for neweverycrons (auto = autonomous = skip between-phase pause + auto-merge):CRON_TOKENS="" # `auto` is mandatory for `every`-mode crons (between-phase advance # must be non-interactive). Default-on regardless of $AUTO_FLAG. CRON_TOKENS="$CRON_TOKENS auto" CRON_PROMPT="Run /run-plan $PLAN_FILE$CRON_TOKENS every $SCHEDULE now"Default new-cron shape:
Run /run-plan <plan-file> auto every <schedule> now. Note: the phase number is intentionally omitted so the cron auto-advances. -
Create the cron — use
CronCreate:cron: the cron expression from step 1recurring: trueprompt: the constructed command from step 3
-
Confirm with wall-clock time. Always show times in the configured timezone — use
TZ="${TIMEZONE:-UTC}" datefor conversion, not the system timezone (which may differ):If
nowis present:Run-plan scheduled every 4h. Running now. Next phase run after this one: ~8:15 PM ET (cron ID XXXX).
If
nowis NOT present:Run-plan scheduled every 4h. First run: ~4:15 PM ET (cron ID XXXX). Use
/run-plan nextto check,/run-plan stopto cancel. -
If
nowis present: proceed to Phase 1 (run immediately). Ifnowis NOT present: Exit. The cron fires later.
End-of-phase scheduling note: when a phase finishes and a cron is active, always include the estimated next run time with timezone in the completion message. Example:
Phase complete. Next phase run in ~3h 45m (~11:30 PM ET, cron XXXX).
If every is NOT present, skip this phase and proceed to Phase 1
(bare invocation always runs immediately).
Phase 1 — Parse Plan & Extract Verbatim Phase Text
The key differentiator. Plans have varied formats, so the agent uses LLM comprehension rather than rigid parsing.
Read authority (PLAN_FILE_FOR_READ) — compute before ANY plan read
Before any read of the plan file (frontmatter, tracker, phase text), compute the authoritative source for this invocation.
Why this matters. In PR mode, per-phase tracker updates (🟡 In Progress
→ ✅ Done, status: complete) commit on the feature branch, per the
PR-mode bookkeeping rule. Main's copy of the plan file is stale across
cron-fired finish auto pr re-entries until the squash merge lands at plan
completion. A naive cat plans/<plan>.md from the orchestrator's CWD (main)
would silently show ⬚/⬚ on turn 2 even when Phase 1 is already done on
the feature branch — causing the re-entry check to re-execute already-done
phases. Cherry-pick and direct modes commit bookkeeping on main directly, so
main is authoritative there.
# Compute BEFORE Step 0. LANDING_MODE is already resolved from args/config
# in the argument-detection section at the top of this skill.
PLAN_SLUG=$(basename "$PLAN_FILE" .md | tr '[:upper:]' '[:lower:]' | tr '_' '-')
# Pre-worktree bootstrap: anchor on $CLAUDE_PROJECT_DIR (orchestrator session
# root) — WORKTREE_PATH is not yet in scope at the read-authority block.
MAIN_ROOT="$CLAUDE_PROJECT_DIR"
PROJECT_NAME=$(basename "$MAIN_ROOT")
PR_WORKTREE_PATH="/tmp/${PROJECT_NAME}-pr-${PLAN_SLUG}"
if [ "$LANDING_MODE" = "pr" ] && [ -d "$PR_WORKTREE_PATH" ]; then
PLAN_FILE_FOR_READ="$PR_WORKTREE_PATH/$PLAN_FILE"
echo "PR-mode re-entry: reading plan from feature-branch worktree at $PR_WORKTREE_PATH"
else
PLAN_FILE_FOR_READ="$MAIN_ROOT/$PLAN_FILE"
fi
Every subsequent plan read MUST use $PLAN_FILE_FOR_READ, including
Step 0's re-entry check, all of Parse Plan's steps, and the Status command
(which duplicates this computation for its own read-only early-exit path).
The plain $PLAN_FILE (relative to CWD) would silently point at main's
stale copy in PR-mode chunked re-entries.
Writes are unaffected. Phase 4 tracker updates and Phase 5b frontmatter updates continue to follow the PR-mode bookkeeping rule (commit on feature branch in PR mode; commit on main in cherry-pick/direct mode). Only the read path needed the explicit branch.
Preflight checks
Before parsing, check for stale state from a previous failed run:
-
Idempotent re-entry check (chunked finish auto only). If running with
finish auto, this turn may have been triggered by a cron from a previous turn. Re-emit the pipeline ID first (cron-fired turns are fresh sessions):TRACKING_ID=$(basename "$PLAN_FILE" .md | tr '[:upper:]_' '[:lower:]-') echo "ZSKILLS_PIPELINE_ID=run-plan.$TRACKING_ID"Sentinel-recovery prelude (#110). Before evaluating the four cases below, check for a
cron-recovery-needed.<phase>marker left by a prior turn whose CronCreate failed after a successful CronDelete (high-severity race documented in WI 1.3 step 4d). The marker means the recurring*/1cron may not exist; this turn must try to re-establish it before doing anything else. The counter is held — this is recovery, not normal flow.if [ -f "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-resolve-config.sh" ]; then export CLAUDE_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT}" . "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-resolve-config.sh" else . "$CLAUDE_PROJECT_DIR/.claude/skills/update-zskills/scripts/zskills-resolve-config.sh" fi # MAIN_ROOT is already in scope from the "Read authority" block earlier # in this section (line ~393); reuse it here. PIPELINE_ID="${ZSKILLS_PIPELINE_ID:-run-plan.$TRACKING_ID}" PIPELINE_ID=$(bash "$ZSKILLS_SKILLS_ROOT/create-worktree/scripts/sanitize-pipeline-id.sh" "$PIPELINE_ID") if compgen -G "$MAIN_ROOT/.zskills/tracking/$PIPELINE_ID/cron-recovery-needed.*" >/dev/null 2>&1; then # CronList → check if a "Run /run-plan <plan-file> finish auto" cron # already exists (a stale fire from before the failed Delete may have # raced ahead of us). The literal `finish auto` composite alias is # preserved indefinitely (D2-RP load-bearing user workflow): on # /run-plan re-entry, the parser sets FINISH_MODE=finish-auto and # hoists AUTO_FLAG=1, so the cron-fired turn runs autonomously and # lands with --auto. # If exists AND cadence ∈ {*/1, */10, */30, */60} (sane backoff cadence # set, A1 fix): rm cron-recovery-needed.* (already recovered at a # sane cadence; trust it). # If exists BUT cadence ∉ {*/1, */10, */30, */60} (A1 fix: third-party # cron at e.g. */15, or scheduler corruption returning bad cadence): # emit WARN cron-recovery-bad-cadence with the observed cadence # string, force CronDelete on ALL matching crons, then fall through # to the "missing" branch below to CronCreate at */1. # If missing: CronCreate cron="*/1 * * * *" recurring=true # prompt="Run /run-plan <plan-file> finish auto" # then verify via CronList again AND verify the new cron's # cadence is exactly "*/1 * * * *" (not just "exists"). # On success: rm cron-recovery-needed.*. On failure: leave # marker, emit WARN cron-recovery-failed and continue to # case dispatch (Case 3's own inline retry may succeed, or # Case 4 will happy-path past the missing cron). fiThe cadence-sanity check (A1 fix) protects against three failure modes: (i) a third-party tool created a recurring cron with the same prompt at a different cadence (e.g.,
*/15); (ii) Case 3's 3-retry exhaustion left a partial-success cron at the wrong target cadence; (iii) the previous turn's CronCreate raced with another top-level invocation. Without this check, the "exists, rm marker" branch would silently accept a wrong cadence and the pipeline would run at the wrong fire rate indefinitely.Then read the plan frontmatter (
statusfield) and the plan tracker (phase statuses) from$PLAN_FILE_FOR_READ(computed in the "Read authority" section above — NOT from main's copy of the plan). Four cases:-
Frontmatter
status: complete: plan truly done. Terminal cron cleanup (Design 2a): callCronListandCronDeleteon any job whose prompt matchesRun /run-plan <plan-file> finish autofor THIS plan file. In Design 2a chunking, the recurring*/1cron will otherwise keep firing forever — Case 1 is the only routine termination path. Also rm any leftover recovery sentinel and per-phase defer counters so the next pipeline starts clean (R6 fix, #110):if [ -f "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-resolve-config.sh" ]; then export CLAUDE_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT}" . "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-resolve-config.sh" else . "$CLAUDE_PROJECT_DIR/.claude/skills/update-zskills/scripts/zskills-resolve-config.sh" fi PIPELINE_ID="${ZSKILLS_PIPELINE_ID:-run-plan.$TRACKING_ID}" PIPELINE_ID=$(bash "$ZSKILLS_SKILLS_ROOT/create-worktree/scripts/sanitize-pipeline-id.sh" "$PIPELINE_ID") rm -f "$MAIN_ROOT/.zskills/tracking/$PIPELINE_ID/cron-recovery-needed."* rm -f "$MAIN_ROOT/.zskills/tracking/$PIPELINE_ID/in-progress-defers."*Then exit with "Plan complete (already). Cron deleted." No more work, no more fires.
-
All phases Done + frontmatter NOT complete: Phase 5b needs to run (it owns the final-verify gate logic via its new first sub-step). Skip Phase 1 sub-steps 1–9 and Phases 2–5; route directly to Phase 5b. Phase 5b's gate handles the verify-pending vs verify-fulfilled vs no-marker cases — single source of truth, no duplicated logic in Step 0.
-
Next-target phase already In Progress (per tracker): apply the adaptive backoff decision rule (#110). The pipeline cron stays at its current cadence on most fires and steps down to a slower cadence only at boundary fires
C+1 ∈ {1, 10, 16, 26}. The counterCis per-phase scoped.The DECISION is factored into a pure script —
defer-backoff-decide.sh— so the (bug-prone) arithmetic/branching is unit-tested in isolation (tests/test-runplan-defer-backoff.sh) and can never drift silently from the prose. The Cron* tool calls stay HERE in prose; the script only decides WHICH of them to make.-
Read
Cfrom$MAIN_ROOT/.zskills/tracking/$PIPELINE_ID/in-progress-defers.<phase>(default0if missing). Read current cadenceRviaCronListsubstring match onRun /run-plan <plan-file> finish auto(if multi-match, pick the first for cadence read — the delete-all directive collapses all). Note the CronList match countN(0when no cron matches). Then ask the decision script which directives to apply:DIRECTIVES=$(bash "$ZSKILLS_SKILLS_ROOT/run-plan/scripts/defer-backoff-decide.sh" \ --counter "$C" \ --cadence "$R" \ --cronlist-match "$N" \ --create-result ok \ --case 3 \ --recovery-marker no \ --phase "$PHASE")(
--create-resultisokon entry; flip tomissing/missing-after-3-retriesand re-ask only if the verify in directiveREPLACE_CRONbelow comes back short — see step 3d.) -
The script computes target cadence
TfromC+1(<10→*/1,10..15→*/10,16..25→*/30,≥26→*/60) and emits one directive per line. Interpret them top-to-bottom:-
WARN no-cron-match— no cron matched (N == 0); do NOT increment counter, output the defer message, exit. -
DELETE_ALL_MATCHING_CRONS— CronList → enumerate ALL prompts containingRun /run-plan <plan-file> finish auto, then CronDelete each ID (T != R, so the cadence is changing). -
SLEEP_BETWEEN_RETRIES—sleep 2before the CronCreate retry (N1 fix: inter-attempt spacing protects against rate-limit-class CronCreate failures, which would otherwise burn all 3 retries inside the same rate-limit window). -
REPLACE_CRON T— CronCreate ONE cron withcron: T,recurring: true,prompt: "Run /run-plan <plan-file> finish auto". -
WRITE_COUNTER N— writeN(=C+1) to the counter file$MAIN_ROOT/.zskills/tracking/$PIPELINE_ID/in-progress-defers.<phase>. -
PROCEED defer-message-printed— output the defer message (boundary fireC+1 ∈ {1, 10, 16, 26}):Phase X already in progress, deferring. Backoff cadence now T.
-
PROCEED defer-message-silent— advance silently (intermediate fire, so users see meaningful step-down events but not minute-by-minute noise).
-
-
Verify + retry on
REPLACE_CRON. After a CronCreate, verify via CronList again; if no match found OR cadence !=T, re-ask the script with--create-result missing(which emitsSLEEP_BETWEEN_RETRIESthenREPLACE_CRON T) and retry steps c–d up to 2 more times (3 total CronCreate attempts; worst-case wall-time on the failing path ≈ 4-6s of sleep + 6 LLM tool calls). On the 3rd exhausted attempt, re-ask with--create-result missing-after-3-retries; the script then emits the high-severity-race directives (noWRITE_COUNTER):-
WARN cron-replace-failed (3 retries exhausted)— emit to stdout. -
WRITE_RECOVERY_MARKER <phase>— write thecron-recovery-needed.<phase>sentinel. -
EMIT_USER_WARN cron-stalled— output a prominent user-visible WARN to the turn's final message:⚠ /run-plan finish auto: failed to update cron after 3 attempts. Pipeline is stalled until you re-invoke /run-plan finish auto.
If the next invocation also fails: run
/run-plan stopto clear all crons, then file an issue at github.com/zeveck/zskills/issues/new with the contents of .zskills/tracking//cron-recovery-needed. and yourCronListoutput.(N2 fix: explicit escalation path —
/run-plan stop+ manualgh issuefiling — so users have a complete action ladder rather than a re-invoke-or-give-up choice.)Do NOT increment counter. Exit.
-
When
T == Rthe script emits noDELETE_ALL_MATCHING_CRONS/REPLACE_CRON(no cron action) — onlyWRITE_COUNTER+PROCEED. -
-
Otherwise: proceed with normal preflight (steps 1–9) then Phase 2. Before proceeding, clear all per-phase defer counters and any stale recovery sentinel from a prior phase (#110):
if [ -f "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-resolve-config.sh" ]; then export CLAUDE_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT}" . "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-resolve-config.sh" else . "$CLAUDE_PROJECT_DIR/.claude/skills/update-zskills/scripts/zskills-resolve-config.sh" fi PIPELINE_ID="${ZSKILLS_PIPELINE_ID:-run-plan.$TRACKING_ID}" PIPELINE_ID=$(bash "$ZSKILLS_SKILLS_ROOT/create-worktree/scripts/sanitize-pipeline-id.sh" "$PIPELINE_ID") rm -f "$MAIN_ROOT/.zskills/tracking/$PIPELINE_ID/in-progress-defers."* rm -f "$MAIN_ROOT/.zskills/tracking/$PIPELINE_ID/cron-recovery-needed."*(Harmless on first phase — rm of missing files is a no-op.)
Stale crons are harmless — duplicate fires exit cleanly via this check. Re-entry routes to Phase 5b which owns verify-pending state and self-rescheduling.
-
Plan-claim acquire (W2a.1, A11 / AC2a.7). Acquire a single-host
atomic claim on this plan BEFORE any mode-detection branch (so the claim
is reachable from worktree / PR / cherry-pick / direct / delegate paths).
On race-lost (exit 10) this fence terminates cleanly; the orchestrator
reports "declined" and exits the turn — a second pipeline already owns
the plan and our session must not double-fire /run-plan for it.
claim-plan.sh is ownership-aware (self-re-entry returns 0), so a chunked
finish auto re-fire re-acquiring its OWN plan claim with the stable
run-plan.$TRACKING_ID pipeline_id succeeds — exit 10 here therefore means
ONLY a truly-foreign pipeline holds the plan, which is exactly when
"declined" is the correct verdict (D8 / #803).
Self-re-entry contract (twins: claim-plan.sh / claim-issue.sh)
Both twin primitives share one ownership-aware self-re-entry decision,
implemented by the helper skills/create-worktree/scripts/claim-self-reentry.sh
(invoked as a bash subprocess on each twin's already-exists branch — NEVER
sourced, since its exit codes would terminate a sourcing caller). The
exit-code contract is: acquire — 0 = fresh-OR-self (the existing claim
is the caller's own pipeline_id, or there was no claim and we just took it →
proceed), 10 = foreign-OR-absent/malformed (another pipeline holds it, or
claim.json is missing/truncated → never steal), 11 = filesystem
infrastructure failure, 2 = usage error; release — 12 = ownership
mismatch (the claim is held by a different pipeline_id; release refuses).
There is NO TTL — a claim is released ONLY by an explicit release call, so
a re-entering self always sees its own claim and proceeds (0), and a
foreign holder is honored until that holder explicitly releases.
if [ -f "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-resolve-config.sh" ]; then
export CLAUDE_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT}"
. "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-resolve-config.sh"
else
. "$CLAUDE_PROJECT_DIR/.claude/skills/update-zskills/scripts/zskills-resolve-config.sh"
fi
PIPELINE_ID="${ZSKILLS_PIPELINE_ID:-run-plan.$TRACKING_ID}"
PIPELINE_ID=$(bash "$ZSKILLS_SKILLS_ROOT/create-worktree/scripts/sanitize-pipeline-id.sh" "$PIPELINE_ID")
# Resolve dispatch_mode for the claim record (issue #874 — sibling to
# #858). The chip's lock condition reads plan.claim.dispatch_mode, which
# outlives the /work-on-plans wrapper that spawned this /run-plan. Map
# the canonical $FINISH_MODE ("finish"|"finish-auto"|"") to the
# claim-side enum (phase|finish|inherit).
DISPATCH_MODE_FOR_CLAIM="phase"
if [ "$FINISH_MODE" = "finish" ] || [ "$FINISH_MODE" = "finish-auto" ]; then
DISPATCH_MODE_FOR_CLAIM="finish"
fi
set +e
bash "$ZSKILLS_SKILLS_ROOT/run-plan/scripts/claim-plan.sh" \
acquire "$PLAN_SLUG" --pipeline-id "$PIPELINE_ID" \
--dispatch-mode "$DISPATCH_MODE_FOR_CLAIM"
rc=$?
set -e
case "$rc" in
0) : ;;
10) echo "Plan $PLAN_SLUG is in-flight by another pipeline; this invocation declined." >&2; exit 0 ;;
11) echo "claim-plan.sh acquire infrastructure failure"; exit 1 ;; # Failure Protocol
2) echo "claim-plan.sh acquire usage error"; exit 2 ;;
*) echo "claim-plan.sh acquire unexpected rc=$rc"; exit 1 ;;
esac
Same-plan in-flight guard (issue #883)
Cron re-fires can land while the previous turn of THIS SAME plan is
still mid-flight (chunked finish auto re-fires /run-plan $PLAN_FILE
every interval, and CronCreate's "fires only while idle" is turn-level
idle, not task-level). claim-plan.sh does NOT close this: its self-
re-entry returns rc=0 (proceed) for any same-pipeline re-acquire (#825),
so two same-session same-plan turns both pass the claim gate. The
shared check-inflight-batch.sh helper, called with the per-work-
identity --pipeline-id filter, distinguishes "same plan re-fire mid-
flight" (skip) from "different plan in-flight" (proceed):
- Two
/run-planinvocations for DIFFERENT plans → each writes its owninflight/run-plan/run-plan.<plan-a>.json/<plan-b>.jsonsentinel; the--pipeline-idfilter ensures cross-plan fires don't collide. - Two cron fires of
/run-plan $PLAN_Awhile phase-N is still running → the second fire sees the same-pipeline sentinel and exits clean, leaving the in-flight turn to finish on its own.
The sentinel is cleared at all /run-plan terminal exit points (Phase
5b's already-complete no-op release + Phase 6's terminal-merge release;
both in modes/execute-phase.md). Stale escape lives in the helper
(2h max-age) — a crashed turn's sentinel is reclaimed automatically.
if [ -f "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-resolve-config.sh" ]; then
export CLAUDE_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT}"
. "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-resolve-config.sh"
else
. "$CLAUDE_PROJECT_DIR/.claude/skills/update-zskills/scripts/zskills-resolve-config.sh"
fi
INFLIGHT_HELPER="$ZSKILLS_SKILLS_ROOT/create-worktree/scripts/check-inflight-batch.sh"
if [ -x "$INFLIGHT_HELPER" ]; then
if bash "$INFLIGHT_HELPER" check run-plan --pipeline-id "$PIPELINE_ID" > /tmp/.run-plan-inflight.$$ 2>/dev/null; then
INFLIGHT_LINE=$(cat /tmp/.run-plan-inflight.$$)
rm -f /tmp/.run-plan-inflight.$$
INFLIGHT_AGE=$(printf '%s' "$INFLIGHT_LINE" | awk -F'\t' '{print $3}')
echo "run-plan $PIPELINE_ID in flight (sentinel age ${INFLIGHT_AGE:-?}s); skipping redundant cron re-fire" >&2
exit 0
fi
rm -f /tmp/.run-plan-inflight.$$
# Fresh fire — write our sentinel so subsequent same-session same-plan
# re-fires see it and skip. Cleared in modes/execute-phase.md at the
# already-complete no-op (Phase 5b) and the terminal-merge (Phase 6).
bash "$INFLIGHT_HELPER" write run-plan --pipeline-id "$PIPELINE_ID" || \
echo "run-plan: WARN — could not write in-flight sentinel (continuing)" >&2
fi
Issue-claim acquire (W3.1/W3.2/W3.5, #803 execution-window protection).
If the plan's frontmatter carries one or more issue: N fields, claim each
linked issue-<N> for the plan's FULL lifetime — held across idle gaps
between chunked fires (filesystem + no-TTL + self-re-entry make this
automatic) and released ONLY at the plan's terminal release points
(execute-phase.md terminal merge, the already-complete no-op, and the
operator-stop sweep — NEVER per-phase). The issue claim uses run-plan's OWN
$PIPELINE_ID (same as the plan claim) and --sprint-id "$PIPELINE_ID".
This arm is DELIBERATELY different from the plan-claim decline arm above: issue-foreign-at-start is WARN-and-PROCEED, never abort. #739 removed auto-expiry, so a possibly-stale issue claim must not block a deliberately-run plan — the plan owns the plan; the issue contention is a softer operator signal. On rc=10 we log loudly and CONTINUE, and we NEVER release the legitimately-won plan claim (no plan-claim leak).
Parse the bare-integer issue number(s) from $PLAN_FILE_FOR_READ's
frontmatter (the PR-mode-worktree-aware read authority — NOT bare
$PLAN_FILE). Strip # and surrounding quotes to a bare positive integer
(claim-issue.sh validate_issue_number rejects non-numeric input with
exit 2), aligning with the Close-linked-issue parser's tolerance for
issue: 42 and issue: "#42".
if [ -f "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-resolve-config.sh" ]; then
export CLAUDE_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT}"
. "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-resolve-config.sh"
else
. "$CLAUDE_PROJECT_DIR/.claude/skills/update-zskills/scripts/zskills-resolve-config.sh"
fi
# Parse issue:N field(s) from the plan frontmatter (the leading `---`
# delimited YAML block only), normalized to bare positive integers.
# Tolerates `issue: 42`, `issue: "#42"`, `issue: '#42'`. Multiple
# `issue:` lines → multiple claims.
ISSUE_NUMS=()
while IFS= read -r raw; do
# strip surrounding quotes and a leading '#', keep only digits
n=$(printf '%s' "$raw" | tr -d '"'\''#[:space:]')
case "$n" in
''|*[!0-9]*) continue ;; # skip non-numeric (defensive)
esac
[ "$n" -gt 0 ] 2>/dev/null && ISSUE_NUMS+=("$n")
done < <(awk '
NR==1 && $0 != "---" { exit } # no frontmatter → nothing to do
NR==1 { infm=1; next }
infm && $0 == "---" { exit } # end of frontmatter block
infm && /^[[:space:]]*issue:[[:space:]]*/ {
sub(/^[[:space:]]*issue:[[:space:]]*/, "", $0); print $0
}
' "$PLAN_FILE_FOR_READ" 2>/dev/null)
for N in "${ISSUE_NUMS[@]}"; do
set +e
bash "$ZSKILLS_SKILLS_ROOT/fix-issues/scripts/claim-issue.sh" \
acquire "$N" --pipeline-id "$PIPELINE_ID" --sprint-id "$PIPELINE_ID"
irc=$?
set -e
case "$irc" in
0) : ;; # acquired-fresh OR self-re-entry — proceed, issue claim held
10) echo "issue #$N is claimed by another pipeline; proceeding with plan execution, issue claim NOT held" >&2 ;; # WARN-and-PROCEED (D9) — do NOT abort, do NOT release the plan claim
11) echo "claim-issue.sh acquire infrastructure failure for issue #$N"; exit 1 ;; # Failure Protocol
2) echo "claim-issue.sh acquire usage error for issue #$N (un-stripped #N / empty PIPELINE_ID — internal bug)"; exit 2 ;;
*) echo "claim-issue.sh acquire unexpected rc=$irc for issue #$N"; exit 1 ;;
esac
done
-
In-progress git operation?
ls .git/CHERRY_PICK_HEAD .git/MERGE_HEAD .git/REBASE_HEAD 2>/dev/null git status --porcelain | grep '^UU\|^AA\|^DD'If either command produces output, STOP. Invoke the Failure Protocol.
-
Stash stack?
git stash listIf there is a stash with message containing "pre-cherry-pick", a previous run's stash was never restored. STOP. Invoke the Failure Protocol — the user needs to
git stash poporgit stash dropbefore a new phase can start safely. -
Leftover plan worktrees?
git worktree listIf worktrees from a previous run exist (paths containing
plan-), warn the user. Do not remove them — note their presence and continue. -
Unconfigured test-command config?
if [ -f "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-resolve-config.sh" ]; then export CLAUDE_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT}" . "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-resolve-config.sh" else . "$CLAUDE_PROJECT_DIR/.claude/skills/update-zskills/scripts/zskills-resolve-config.sh" fi # Lane-agnostic: $FULL_TEST_CMD is read from .claude/zskills-config.json # at the project root, the SAME source the hook itself reads at runtime.This gate is an early-exit mirror of the hook's own commit-block: when
testing.full_cmdis unset/empty in.claude/zskills-config.jsonAND test infrastructure exists, the hook will block the eventualgit commitwith "BLOCKED: project has test infrastructure ... but testing.full_cmd is empty" (Case C athooks/block-unsafe-project.sh.template:714-729) — so catching it at preflight just prevents wasted work. Its job is to notice when a project has test infrastructure but the config doesn't yet name a test command.Why the config read, not a hook grep. The hook resolves
UNIT_TEST_CMD/FULL_TEST_CMDat runtime from.claude/zskills-config.json(hooks/block-unsafe-project.sh.template:579-606) — there are no{{...}}placeholders left in the hook body to scan for. Both install lanes share that one config file, so the resolver is lane-agnostic by construction: the legacy lane reads.claude/zskills-config.jsondirectly; the plugin lane's hook lives under${CLAUDE_PLUGIN_ROOT}but still reads the SAME.claude/zskills-config.jsonin the checked-out tree (the resolver above mirrors that contract).Three cases, report each explicitly so the reasoning is legible:
FULL_TEST_CMDempty AND test infra exists — where test infra means any of:package.jsonwith a"test"script,vitest.config.*,jest.config.*,pytest.ini,.mocharc.*,Makefile, ortests/*.sh|*.py|*.js(this list must matchhooks/block-unsafe-project.sh.template:611-630exactly, otherwise preflight under-reports and the hook still blocks at commit time): STOP. Test-command config has not been set — run/update-zskillsfirst, or (if this plan's purpose is to configure it) have the plan land those changes in an early phase before real enforcement matters. Report: "hook-config gate tripped: testing.full_cmd unset AND test infra detected — stopping."FULL_TEST_CMDempty, no test infra: gate silent, proceed. This is either a fresh/bootstrap project or one with no tests by design. If the plan establishes tests, it should also fill the testing config (testing.unit_cmd,testing.full_cmd, and optionallyui.file_patternsin.claude/zskills-config.json) in the same phase, so subsequent runs have real enforcement. Report: "gate silent: testing.full_cmd unset but no test infra yet — bootstrap or tests-by-design; if this plan adds tests, also fill testing.full_cmd."FULL_TEST_CMDnon-empty: config is set, nothing to do. Report: "testing.full_cmd configured; gate n/a."
If the resolver itself fails to read
.claude/zskills-config.json(missing or malformed),FULL_TEST_CMDends up empty and the gate falls through the same three cases above, which is the correct behavior — a missing config in a project with test infra is exactly the misconfiguration the hook's Case-C block exists to catch. -
Clean up landed worktrees from previous phases
if [ -f "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-resolve-config.sh" ]; then export CLAUDE_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT}" . "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-resolve-config.sh" else . "$CLAUDE_PROJECT_DIR/.claude/skills/update-zskills/scripts/zskills-resolve-config.sh" fi for wt_line in $(git worktree list --porcelain | grep '^worktree ' | sed 's/^worktree //'); do if [ -f "$wt_line/.landed" ] && grep -q 'status: landed' "$wt_line/.landed"; then echo "Cleaning up landed worktree: $wt_line" bash "$ZSKILLS_SKILLS_ROOT/commit/scripts/land-phase.sh" "$wt_line" fi doneThis catches stragglers from crashed agents, container restarts, or any remaining edge cases. Defense in depth —
.claude/skills/commit/scripts/land-phase.shis the primary fix (called after each phase landing), the preflight is the safety net.
Plan-cited preflights — open-PR file-path conflict gate
When a plan touches a path prefix (e.g. skills/update-zskills/) across
multiple phases AND runs in PR mode, every phase's preflight needs to
self-filter the pipeline's OWN PR. Inlining gh pr list --state open --limit 100 --json number,title,files | grep -F '<prefix>' in each phase trips the
gate from Phase 2 onward, because the pipeline's own feature branch becomes
the only matching PR (issue #177).
Plans MUST cite the helper instead of inlining the gh+grep pattern:
if [ -f "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-resolve-config.sh" ]; then
export CLAUDE_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT}"
. "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-resolve-config.sh"
else
. "$CLAUDE_PROJECT_DIR/.claude/skills/update-zskills/scripts/zskills-resolve-config.sh"
fi
# In a plan phase that needs an open-PR conflict gate. The orchestrator
# already tracks $RUN_PLAN_PR_NUMBER (the pipeline's own PR) — pass it via
# --exclude-pr so the gate self-filters.
if ! bash "$ZSKILLS_SKILLS_ROOT/run-plan/scripts/pr-preflight.sh" \
--path-prefix "skills/update-zskills/" \
--exclude-pr "${RUN_PLAN_PR_NUMBER:-}"; then
echo "FAIL: open PR(s) touch the path; coordinate before continuing." >&2
exit 1
fi
The helper:
- Takes
--path-prefix <prefix>(required) and--exclude-pr <num>(optional). - Emits matching PR numbers on stdout, one per line; empty when clean.
- Exit 0 when clean, 1 when at least one matching PR remains, 2 on arg or gh error.
--exclude-prmay be empty — the script then performs no exclusion, so it is safe to call BEFORE the orchestrator knows the pipeline's PR number (e.g. Phase 1 of a plan that has not yet been pushed).
Source: skills/run-plan/scripts/pr-preflight.sh. Pure bash; no jq.
Parse plan
-
Read the plan file in full from
$PLAN_FILE_FOR_READ(see "Read authority" section above — in PR mode with an existing feature-branch worktree, this resolves to the worktree's copy; otherwise main's copy). Also read any companion progress document if referenced (e.g.,FEATURE_PROGRESS_AND_NEXT_STEPS.md) — same path rule applies. -
Extract phases and status — handle four formats:
- Progress tracker table (FEATURE_PLAN style): rows with
✅ Done,⬚(not started),🟡(in progress), etc. - Numbered phase sections (
## Phase 4a — Title): look for completion markers in the section body or companion doc - Checklist (
- [x]/- [ ]): checked = done, unchecked = not done - Narrative: infer status from codebase evidence (files exist, tests pass, etc.)
- Progress tracker table (FEATURE_PLAN style): rows with
-
Determine target phase:
- If phase arg given: use it. If already complete, warn (or skip in auto)
- If no phase arg: first incomplete phase
- If ALL phases complete:
- If frontmatter
status: complete: report "Plan complete" → stop. Ifevery, delete the cron viaCronList+CronDelete. - If frontmatter NOT complete: route to Phase 5b directly (Phase 5b's gate handles final-verify deferral; if final-verify is satisfied or not required, Phase 5b completes the plan).
- If frontmatter
- If multiple phases share the same number (e.g., 4a, 4b, 4c), treat each sub-phase as a separate phase
Recoverable-STOP sentinel hygiene (Issue #923). Steps 4–6 below each
have a recoverable STOP that deliberately leaves the every/finish auto
cron alive to retry on a later fire (dependency-not-met, phase-in-progress
conflict, staleness). Before taking any of those STOP-and-exit branches,
clear the #883 in-flight sentinel — otherwise the retry fire (same
(session_id, pipeline_id)) is treated as a redundant re-fire and SKIPPED
until the 2h staleness reclaims it, defeating the intended retry. Re-derive
$PIPELINE_ID / $INFLIGHT_HELPER at fence-top exactly as the terminal-clear
sites in modes/execute-phase.md do (config-source first):
if [ -f "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-resolve-config.sh" ]; then
export CLAUDE_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT}"
. "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-resolve-config.sh"
else
. "$CLAUDE_PROJECT_DIR/.claude/skills/update-zskills/scripts/zskills-resolve-config.sh"
fi
PIPELINE_ID="${ZSKILLS_PIPELINE_ID:-run-plan.$TRACKING_ID}"
PIPELINE_ID=$(bash "$ZSKILLS_SKILLS_ROOT/create-worktree/scripts/sanitize-pipeline-id.sh" "$PIPELINE_ID")
INFLIGHT_HELPER="$ZSKILLS_SKILLS_ROOT/create-worktree/scripts/check-inflight-batch.sh"
if [ -x "$INFLIGHT_HELPER" ]; then
bash "$INFLIGHT_HELPER" clear run-plan --pipeline-id "$PIPELINE_ID" || true
fi
This is distinct from Step 0 Case 3's adaptive-backoff defer — that path is a genuine "phase actively in progress, keep deferring" yield and MUST keep the sentinel set; do NOT add the clear there.
-
Check dependencies — if a prerequisite phase isn't Done, STOP (clear the sentinel via the fence above first). Report which dependency is missing. If
every, the cron retries later. -
Check for conflicts — if the target phase is "In Progress" (🟡 or equivalent), another agent may be working on it. STOP (clear the sentinel via the fence above first). Do not compete.
-
Check for staleness. Two independent checks. If a check forces a STOP-and-exit that leaves the cron alive to retry, clear the sentinel via the fence above first.
a. Textual staleness. if the plan's Dependencies section contains language like "drafted before," "may need refresh," or "APIs and data structures referenced here are based on [another plan's] design, not actual code," the plan may be stale:
- Without
auto: tell the user "this plan was drafted before its dependency was implemented. Want me to refresh it with/refine-plan?" - With
auto: dispatch/refine-plan <plan-file>$AUTO_ARGon the plan file to update it (the trailing$AUTO_ARGpropagates auto so /refine-plan's own AUTO_FLAG fires and it dispatches /land-pr per #648). After the refresh, re-read the plan and continue. - Skip this check if the plan file was modified more recently than the dependency's completion (it may already be up to date).
b. Arithmetic staleness (pre-dispatch). For the target phase's
### Acceptance Criteriasection, extract numeric targets and verify against current source.Procedure:
- Read the target phase's
### Acceptance Criteriabullets. - For each bullet, attempt to match a numeric claim via the
token-compatible grammar (Phase 1
<stated>forms: N-M, ≤N, ≥N, ~N, exactly N). Unmatched bullets skip. - For each matched claim, locate the corresponding extraction
rule (if any) in the target phase's
### Design & Constraintssection. Supported rules:- Literal arithmetic expression: "N - M + K" → evaluate via
.claude/skills/run-plan/scripts/plan-drift-correct.sh --eval "N - M + K"(the script implements parse-only integer arithmetic; no shell eval, no injection surface). - "extract lines N..M" or "lines N-M" → value is M - N + 1.
- "SKILL.md X lines down from Y" → value is Y - X or X (case- by-case; script uses a small fixed set of patterns).
- No derivable rule → skip bullet, emit info line: "pre-dispatch arithmetic check: skipped (no derivable rule)".
- Literal arithmetic expression: "N - M + K" → evaluate via
- Compute drift between stated target and derived value.
Use the same
--driftcommand as Phase 3.5. - Collect findings per bullet.
Decision:
- Without
auto: present findings:
Ask user: "(1) proceed (Phase 3.5 will post-correct small drift), (2) pause forPre-dispatch arithmetic drift: Phase <N>: <bullet-text> plan says: <stated> arithmetic says: <derived> drift: <pct>%/refine-plan, (3) override (suppress this check for this phase)?" - With
auto: if any bullet has drift >20%, dispatch/refine-plan <plan-file>$AUTO_ARG(plan-level issue, not per-band; the trailing$AUTO_ARGpropagates auto so /refine-plan dispatches /land-pr per #648); after refresh, re-read and continue. If all drifts are ≤20%, log findings to the phase report and proceed — Phase 3.5 will auto-correct post-hoc within the ≤20% band.
- Without
-
Save the VERBATIM phase text — copy the entire section from the plan file exactly as written. Every sentence, every bullet, every formula, every constraint. This text will be passed to agents in Phase 2 and Phase 3.
Do NOT summarize, paraphrase, or reinterpret. The plan is the spec.
Lesson from
/fix-issues#387: summarized descriptions caused agents to implement the wrong thing. "Reset button" was interpreted as "clear canvas" instead of "reset mappings to defaults" because only the title was read. The same will happen with plan phases if the orchestrator summarizes "implement translational mechanical domain" without the formulas, state equations, and design constraints. -
Create tracking fulfillment marker. Determine the tracking ID: use the ID passed by the parent skill if this is a delegated invocation, or derive from the plan file slug if standalone (e.g.,
FEATURE_PLAN.md→feature-plan). Then create the fulfillment file in the MAIN repo:if [ -f "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-resolve-config.sh" ]; then export CLAUDE_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT}" . "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-resolve-config.sh" else . "$CLAUDE_PROJECT_DIR/.claude/skills/update-zskills/scripts/zskills-resolve-config.sh" fi # Re-derive BRANCH_PREFIX and PLAN_SLUG at fence-top (R-5-1): # both were set in earlier disjoint fences (BRANCH_PREFIX@178-185, # PLAN_SLUG@382) and do NOT survive cross-fence per the # "Resolve config-derived vars at fence-top" convention # (modes/execute-phase.md:425). # Needed below for the requires.land-pr.<id> marker's branch: field. BRANCH_PREFIX="feat/" if [ -f "$CLAUDE_PROJECT_DIR/.claude/zskills-config.json" ]; then CONFIG_CONTENT=$(cat "$CLAUDE_PROJECT_DIR/.claude/zskills-config.json") # ([^"]*) allows empty string match -- empty prefix means no prefix if [[ "$CONFIG_CONTENT" =~ \"branch_prefix\"[[:space:]]*:[[:space:]]*\"([^\"]*)\" ]]; then BRANCH_PREFIX="${BASH_REMATCH[1]}" fi fi PLAN_SLUG=$(basename "$PLAN_FILE" .md | tr '[:upper:]' '[:lower:]' | tr '_' '-') # PR mode: bookkeeping commits inside the worktree on the feature branch # (PR-mode bookkeeping rule). Pre-worktree fences (Phase 1 step 8 runs # before Phase 2 worktree creation) anchor on $PR_WORKTREE_PATH if PR-mode # resume detected the worktree above; otherwise on $CLAUDE_PROJECT_DIR. BOOKKEEPING_ROOT="$CLAUDE_PROJECT_DIR" if [ "$LANDING_MODE" = "pr" ] && [ -n "$PR_WORKTREE_PATH" ] && [ -d "$PR_WORKTREE_PATH" ]; then BOOKKEEPING_ROOT="$PR_WORKTREE_PATH" fi ZSKILLS_PATHS_ROOT="$BOOKKEEPING_ROOT" \ if [ -f "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-paths.sh" ]; then export CLAUDE_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT}" . "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-paths.sh" else source "$BOOKKEEPING_ROOT/.claude/skills/update-zskills/scripts/zskills-paths.sh" fi MAIN_ROOT="$BOOKKEEPING_ROOT" PIPELINE_ID="${ZSKILLS_PIPELINE_ID:-run-plan.$TRACKING_ID}" PIPELINE_ID=$(bash "$ZSKILLS_SKILLS_ROOT/create-worktree/scripts/sanitize-pipeline-id.sh" "$PIPELINE_ID") [ -n "$PIPELINE_ID" ] || { echo "tracking: empty PIPELINE_ID — refusing flat write" >&2; exit 1; } mkdir -p "$MAIN_ROOT/.zskills/tracking/$PIPELINE_ID" printf 'skill: run-plan\nid: %s\nplan: %s\nphase: %s\nstatus: started\ndate: %s\n' \ "$TRACKING_ID" "$PLAN_FILE" "$PHASE" "$(TZ="${TIMEZONE:-UTC}" date -Iseconds)" \ > "$MAIN_ROOT/.zskills/tracking/$PIPELINE_ID/fulfilled.run-plan.$TRACKING_ID" # Lock down verification requirement IMMEDIATELY (was Phase 2, # now skill entry — ensures hook blocks landing even if Phase # 2/3 are skipped via error path). Delegation: verify-changes is # a child of run-plan in this pipeline, so its `requires.*` marker # lives in run-plan's OWN subdir (parent reconciles fulfillment). printf 'skill: verify-changes\nparent: run-plan\nid: %s\ndate: %s\n' \ "$TRACKING_ID" "$(TZ="${TIMEZONE:-UTC}" date -Iseconds)" \ > "$MAIN_ROOT/.zskills/tracking/$PIPELINE_ID/requires.verify-changes.$TRACKING_ID" # PR-mode only: lock down landing requirement at skill entry. Closes the # Phase-6-skip hole observed in PR #211 (chunked finish-auto exited after # Phase 5b's frontmatter flip without dispatching /land-pr in Phase 6 — # PR sat open and unmerged). The parent-writes-requires convention follows # the existing requires.verify-changes write above and the 4 other # writers documented in plans/REQUIRES_LAND_PR.md. The matching # fulfilled.land-pr.<id> is written by /land-pr when row 6 of its # .landed status-mapping table holds (MERGE_REQUESTED=true AND # PR_STATE=MERGED AND CI_STATUS in {pass, none, skipped}). Idempotent: # chunked finish-auto invokes /run-plan once per phase, overwrite is fine # (same content each invocation). if [ "$LANDING_MODE" = "pr" ]; then BRANCH_NAME_FOR_MARKER="${BRANCH_PREFIX}${PLAN_SLUG}" printf 'skill: land-pr\nparent: run-plan\nid: %s\nbranch: %s\ndate: %s\n' \ "$TRACKING_ID" "$BRANCH_NAME_FOR_MARKER" "$(TZ="${TIMEZONE:-UTC}" date -Iseconds)" \ > "$MAIN_ROOT/.zskills/tracking/$PIPELINE_ID/requires.land-pr.$TRACKING_ID" fi # Phase tracking (UX): update dashboard chip's current_phase. `|| true` — # phase-tracking is UX-not-correctness; failure must not abort the pipeline. bash "$ZSKILLS_SKILLS_ROOT/run-plan/scripts/claim-plan.sh" \ set-phase "$PLAN_SLUG" --require-pipeline "$PIPELINE_ID" --current-phase "Phase $PHASE" || true -
Classify UI impact from the plan text. Scan the phase description for UI indicators: mentions of editor, toolbar, canvas, panel, dialog, CSS, button, menu, viewport, renderer, dark mode, layout, or any reference to UI/editor/styles directories in the project. Flag the phase as UI-touching if any are found.
In
finishmode, classify ALL phases upfront and report:Running 5 phases. Phases 3 and 5 touch UI
*Truncated - read the full file at https://github.com/zeveck/zskills/blob/a80bf53b6f4b7e20a7bda4017d1651fdda58031c/skills/run-plan/SKILL.m