| name | run-plan |
| disable-model-invocation | false |
| argument-hint | <plan-file> [phase|finish|status] [auto] [every SCHEDULE] [now] | stop | next |
| description | Execute the next phase of a plan: parse status, dispatch implementation in a worktree, verify via a separate agent, update progress, write the plan report (`$ZSKILLS_REPORTS_DIR/plan-{slug}.md`), and optionally auto-land to main. Self-schedules via cron; use `next` to check, `stop` to cancel. |
| metadata | {"version":"2026.06.03+7d0000"} |
/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.
finish is approval to START — do not ask for
confirmation before the first phase (the user already said "finish").
Without auto: pauses BETWEEN phases to show results and ask
"continue to next phase?" With auto (or the finish auto composite
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.
finish and every are mutually exclusive. finish auto schedules
its own ~5-min one-shot crons internally. every N schedules 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
--auto to per-phase /land-pr
dispatches (auto-merge the resulting PR). The finish auto composite
is an alias that hoists auto (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
- now (optional) — run immediately. When combined with
every, runs
immediately AND schedules. Without every, now is 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-plan cron 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 exit
status (case-insensitive) — show plan progress and exit
finish (case-insensitive) — run all remaining phases sequentially
now (case-insensitive) — run immediately
auto (case-insensitive) — autonomous mode
every followed by a schedule expression — scheduling mode
pr (case-insensitive) — PR landing mode
direct (case-insensitive) — direct landing mode
- Neither
pr nor direct — read config default (execution.landing),
or cherry-pick if no config
Landing mode resolution:
- Explicit argument wins:
pr or direct in $ARGUMENTS
- Config default: read
.claude/zskills-config.json execution.landing field
- Fallback:
cherry-pick
PROJECT_ROOT="${PROJECT_ROOT:-$CLAUDE_PROJECT_DIR}"
LANDING_MODE="cherry-pick"
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
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.
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=0
if [[ "$ARGUMENTS" =~ (^|[[:space:]])[aA][uU][tT][oO]($|[[:space:]]) ]]; then
AUTO_FLAG=1
fi
if [ "$FINISH_MODE" = "finish-auto" ]; then
AUTO_FLAG=1
fi
AUTO_ARG=""
if [ "$AUTO_FLAG" = "1" ]; then
AUTO_ARG=" auto"
fi
Validation:
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:
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")
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
TEST_MODE="config"
else
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
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
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
[ -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.
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 with date +%M:
4h at minute 9 → 9 */4 * * * (fires at :09 past every 4th hour)
2h at minute 15 → 15 */2 * * *
30m → */30 * * * * (no offset needed for sub-hour)
1h at 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 + CronDelete to remove any whose
prompt starts with Run /run-plan.
-
Construct the cron prompt. Strip the phase number (so each invocation
auto-detects the next incomplete phase). Always include now in the cron
prompt so each cron fire runs immediately AND re-registers itself. Note:
this now is for the CRON's invocation, not the current invocation.
Assemble the prompt from $AUTO_FLAG state, not from literal
$ARGUMENTS substrings. Cron-fired every-mode runs MUST advance
between phases without human interaction, so include auto
unconditionally for new every crons (auto = autonomous = skip
between-phase pause + auto-merge):
CRON_TOKENS=""
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 1
recurring: true
prompt: the constructed command from step 3
-
Confirm with wall-clock time. Always show times in the configured
timezone — use TZ="${TIMEZONE:-UTC}" date for conversion, not the
system timezone (which may differ):
If now is present:
Run-plan scheduled every 4h. Running now.
Next phase run after this one: ~8:15 PM ET (cron ID XXXX).
If now is NOT present:
Run-plan scheduled every 4h.
First run: ~4:15 PM ET (cron ID XXXX).
Use /run-plan next to check, /run-plan stop to cancel.
-
If now is present: proceed to Phase 1 (run immediately).
If now is 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.
PLAN_SLUG=$(basename "$PLAN_FILE" .md | tr '[:upper:]' '[:lower:]' | tr '_' '-')
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 */1
cron 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
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
fi
The 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 (status field) 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): call CronList and CronDelete on
any job whose prompt matches Run /run-plan <plan-file> finish auto
for THIS plan file. In Design 2a chunking, the recurring */1
cron 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
counter C is 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 C from
$MAIN_ROOT/.zskills/tracking/$PIPELINE_ID/in-progress-defers.<phase>
(default 0 if missing). Read current cadence R via CronList
substring match on Run /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 count N
(0 when 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-result is ok on entry; flip to missing /
missing-after-3-retries and re-ask only if the verify in
directive REPLACE_CRON below comes back short — see step 3d.)
-
The script computes target cadence T from C+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
containing Run /run-plan <plan-file> finish auto, then
CronDelete each ID (T != R, so the cadence is changing).
-
SLEEP_BETWEEN_RETRIES — sleep 2 before 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 with cron: T,
recurring: true,
prompt: "Run /run-plan <plan-file> finish auto".
-
WRITE_COUNTER N — write N (= 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 fire C+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 emits
SLEEP_BETWEEN_RETRIES then REPLACE_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 (no WRITE_COUNTER):
-
WARN cron-replace-failed (3 retries exhausted) — emit to stdout.
-
WRITE_RECOVERY_MARKER <phase> — write the
cron-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 stop to
clear all crons, then file an issue at
github.com/zeveck/zskills-dev/issues/new with the contents of
.zskills/tracking//cron-recovery-needed.
and your CronList output.
(N2 fix: explicit escalation path — /run-plan stop + manual
gh issue filing — so users have a complete action ladder
rather than a re-invoke-or-give-up choice.)
Do NOT increment counter. Exit.
When T == R the script emits no DELETE_ALL_MATCHING_CRONS /
REPLACE_CRON (no cron action) — only WRITE_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")
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 ;;
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-plan invocations for DIFFERENT plans → each writes its own
inflight/run-plan/run-plan.<plan-a>.json / <plan-b>.json sentinel;
the --pipeline-id filter ensures cross-plan fires don't collide.
- Two cron fires of
/run-plan $PLAN_A while 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.$$
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
ISSUE_NUMS=()
while IFS= read -r raw; do
n=$(printf '%s' "$raw" | tr -d '"'\''#[:space:]')
case "$n" in
''|*[!0-9]*) continue ;;
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) : ;;
10) echo "issue #$N is claimed by another pipeline; proceeding with plan execution, issue claim NOT held" >&2 ;;
11) echo "claim-issue.sh acquire infrastructure failure for issue #$N"; exit 1 ;;
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 list
If 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 pop or git stash drop before a new phase
can start safely.
-
Leftover plan worktrees?
git worktree list
If 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
This gate is an early-exit mirror of the hook's own commit-block:
when testing.full_cmd is unset/empty in .claude/zskills-config.json
AND test infrastructure exists, the hook will block the eventual git commit with "BLOCKED: project has test infrastructure ... but
testing.full_cmd is empty" (Case C at
hooks/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_CMD at 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.json
directly; the plugin lane's hook lives under ${CLAUDE_PLUGIN_ROOT}
but still reads the SAME .claude/zskills-config.json in the
checked-out tree (the resolver above mirrors that contract).
Three cases, report each explicitly so the reasoning is legible:
FULL_TEST_CMD empty AND test infra exists — where test infra
means any of: package.json with a "test" script, vitest.config.*,
jest.config.*, pytest.ini, .mocharc.*, Makefile, or
tests/*.sh|*.py|*.js (this list must match
hooks/block-unsafe-project.sh.template:611-630 exactly, otherwise
preflight under-reports and the hook still blocks at commit time):
STOP. Test-command config has not been set — run
/update-zskills first, 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_CMD empty, 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 optionally
ui.file_patterns in .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_CMD non-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_CMD
ends 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
done
This catches stragglers from crashed agents, container restarts, or any
remaining edge cases. Defense in depth — .claude/skills/commit/scripts/land-phase.sh is 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
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-pr may 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.)
-
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.
If every, delete the cron via CronList + 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 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_ARG on the plan
file to update it (the trailing $AUTO_ARG propagates 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 Criteria section, extract numeric
targets and verify against current source.
Procedure:
- Read the target phase's
### Acceptance Criteria bullets.
- 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 & Constraints section. 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)".
- Compute drift between stated target and derived value.
Use the same
--drift command as Phase 3.5.
- Collect findings per bullet.
Decision:
-
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
BRANCH_PREFIX="feat/"
if [ -f "$CLAUDE_PROJECT_DIR/.claude/zskills-config.json" ]; then
CONFIG_CONTENT=$(cat "$CLAUDE_PROJECT_DIR/.claude/zskills-config.json")
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 '_' '-')
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"
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"
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
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 finish mode, classify ALL phases upfront and report:
Running 5 phases. Phases 3 and 5 touch UI — landing will wait for
your sign-off at the end.
This tells the user immediately whether the run will be fully automatic
or will need their review before landing. No surprises at Phase 6.
-
Present the phase plan:
- Without
auto: display the phase summary (name, status,
dependencies, work items, UI classification) and wait for user
approval
- With
auto: proceed immediately
finish mode: overall verification after all phases
In finish mode, after ALL phases complete their per-phase implement →
verify loops, run a final overall verification before writing the
report and landing:
-
Dispatch an overall verification agent. In worktree mode, run
/verify-changes worktree on the full worktree diff. In delegate mode
(or mixed), run /verify-changes on main against the commits from all
phases combined. This catches cross-phase integration issues: regressions
from later phases breaking earlier work, conflicting imports, duplicated code.
-
If ANY phase was classified as UI-touching (step 7), dispatch a
dedicated manual testing agent that exercises ALL UI changes together
via playwright-cli. This agent:
- Tests the combined UI state (not each change in isolation)
- Takes comprehensive screenshots showing everything working together
- Prepares the sign-off report so the user can review efficiently
- Uses
/manual-testing recipes for selectors and setup
The goal: instead of "3 items need sign-off, go check yourself," the
user gets "3 items need sign-off, here are screenshots of all of them
working together."
-
Proceed to Phase 5 (write report) with the combined verification results.
Phases 2-6 — Execute Phase
After Phase 1 parsing completes, read
modes/execute-phase.md in full and follow its
procedure end-to-end. Do not proceed until you have read the file.
It covers:
- Phase 2 — Implement (worktree/delegate/direct/PR mode dispatch)
- Phase 3 — Verify (separate agent, failure protocol, drift signals)
- Phase 3.5 — Plan-text drift auto-correction
- Phase 4 — Update Progress Tracking
- Phase 5 — Write Report
- Phase 5b — Plan Completion
- Phase 5c — Chunked finish auto transition
- Phase 6 — Land (dispatches to modes/cherry-pick.md, modes/pr.md, modes/direct.md, or modes/delegate.md)
Failure Protocol
Read references/failure-protocol.md
for crash handling, cron cleanup, working-tree restoration, failure-report
template, and user-facing failure messaging. The failed-run report
template is in the same file.
Key Rules
- "Noted as gap" is a FAILURE, not a pass. If the implementer skips
a work item and the verifier writes "gaps noted" or "not a blocker" —
that is a verification failure. Dispatch a fix agent for the missing
items. Do not advance to Phase 4. Do not write "gaps noted" in reports.
Past failure: Block Expansion Phase 1 skipped example model + runtime
entry; verifier accepted both skips instead of invoking Failure Protocol.
- Never weaken tests — fix the code, not the test. Do not loosen
tolerances, skip assertions, or remove test cases.
- Honest status reporting — if the user asks "are you stuck?", answer
with DATA: (1) current phase and when it started, (2) agent duration and
tool call count, (3) errors or retries. Do not say "everything is fine"
if an agent has been running >30 minutes or retried 2+ times.
- Plan-text drift signals. Implementation and verification
agents MUST emit a
PLAN-TEXT-DRIFT: token (format above) for
each numeric acceptance criterion that doesn't match reality.
Phase 3.5 parses these to decide whether to auto-correct the
plan. Token format forbids : and = inside <field>.
Edge Cases
- No progress tracker: LLM reads plan sections + checks codebase for
evidence of completion (files exist, tests pass, git log mentions the phase)
- Phase fails verification: auto mode tries one fix cycle (dispatch fix
agent + re-verify), then stops after 2 total cycles
- All phases complete: report "Plan complete", delete cron if scheduled
- Dependency not met: stop cleanly, report which dependency. If
every,
the cron retries on next invocation (the dependency may be completed by then)
- Phase "In Progress": another agent may be working — stop, don't compete.
Report the conflict.
- Existing worktree for phase: previous incomplete run — ask user
(interactive) or try to resume from the existing worktree (auto)
- Implementation produces no commits: the agent worked but committed
nothing. Report in
$ZSKILLS_REPORTS_DIR/plan-{slug}.md as "No commits produced — investigate
worktree." Do not attempt to cherry-pick nothing. In auto mode, invoke
the Failure Protocol (this is an unrecoverable state for cron)
- Plan file not found: stop immediately, report the error
- Phase arg doesn't match any phase: stop, list available phases