lazy-batch

star 0

Autonomous orchestrator for the AlgoBooth (or any queue.json-driven) feature pipeline. Loops on lazy-state.py, spawns one Opus subagent per cycle, and drives the full tail (/spec → /plan-feature → /execute-plan → /mcp-test → __mark_complete__). A halt for any reason other than max-cycles presents an AskUserQuestion resolution path and resumes — only max-cycles, all-features-complete, environment-exhaustion, and missing-queue remain clean stops. Terminal action is __mark_complete__, gated by the MCP-coverage audit + completion-integrity gate. (The /retro step is unwired — 2026-06.)

jacobrocks1212 By jacobrocks1212 schedule Updated 6/16/2026

name: lazy-batch description: Autonomous orchestrator for the AlgoBooth (or any queue.json-driven) feature pipeline. Loops on lazy-state.py, spawns one Opus subagent per cycle, and drives the full tail (/spec → /plan-feature → /execute-plan → /mcp-test → mark_complete). A halt for any reason other than max-cycles presents an AskUserQuestion resolution path and resumes — only max-cycles, all-features-complete, environment-exhaustion, and missing-queue remain clean stops. Terminal action is mark_complete, gated by the MCP-coverage audit + completion-integrity gate. (The /retro step is unwired — 2026-06.) argument-hint: <max-cycles, e.g. 10> [--allow-research-skip] [--adhoc "" — enqueue an ad-hoc task at the top of the queue] [--park] [--per-feature-cycle-cap ] [--strict-research-halt] plan-mode: never model: opus allowed-tools: ["Bash", "Read", "Agent", "Write", "Edit", "AskUserQuestion"]

Lazy Batch — Autonomous Pipeline Orchestrator

Drives the per-feature autonomous tail (/plan-feature (= /spec-phases + /write-plan in one cycle) → /execute-plan/mcp-test → mark-complete) by looping on ~/.claude/scripts/lazy-state.py. Each cycle spawns an Opus subagent that invokes the named sub-skill; the orchestrator (this skill, running in the main session) never touches source code, never invokes a skill directly, and never parses sentinel files manually.

Step ordering note: the /retro step has been UNWIRED (operator decision, 2026-06). Once all phases are complete the pipeline routes directly to /mcp-test (Step 9 MCP gate); lazy-state.py never emits retro-feature. /mcp-test only runs on workstation (cloud defers). Behavior inside the loop is otherwise unchanged — the orchestrator dispatches whatever lazy-state.py returns. (The /retro-feature skill remains in the catalog; git history is the restore path.)

This is the workstation orchestrator. The cloud variant is /lazy-batch-cloud (under repos/algobooth/.claude/skills/lazy-batch-cloud/); the two are coupled per CLAUDE.md.


HARD CONSTRAINTS (non-negotiable)

  1. The orchestrator MAY use Write/Edit ONLY on sentinel files (BLOCKED.md, DEFERRED_NON_CLOUD.md, VALIDATED.md, COMPLETED.md, NEEDS_RESEARCH.md, NEEDS_INPUT.md, RETRO_DONE.md, SKIP_MCP_TEST.md, MCP_TEST_RESULTS.md) inside docs/features/, AND on ROADMAP.md / per-feature SPEC.md / PHASES.md status lines when performing the __mark_complete__ action (which is a documentation-level update by definition, not a source-code edit). NEEDS_INPUT.md may additionally be appended to (not overwritten) with a ## Resolution section by Step 1g (decision-resume mode) after AskUserQuestion returns — or by the Step 1g D7 scope resolution (resolved_by: completeness-policy, no question); the orchestrator then dispatches a Sonnet subagent to propagate the choice into SPEC.md / PHASES.md and neutralize the sentinel. BLOCKED.md may likewise be appended to (not overwritten) with a ## Resolution section by Step 1h (blocked-resolution mode) after AskUserQuestion returns — or by the Step 1h D7 sequencing-only auto-resolution (no question); the orchestrator then enacts the chosen resolution path and neutralizes the sentinel by rename (lazy-state.py keys the halt on the BLOCKED.md filename). A Defer (queue reorder) resolution is now a deterministic out-of-cycle lazy-state.py --reorder-queue --id <id> --to tail Bash call the orchestrator runs directly (the script calls lazy_core.reorder_queue + _atomic_write; gated by refuse_if_cycle_active), NOT a dispatched Opus apply-resolution subagent — and the orchestrator still NEVER hand-edits queue.json (it calls the script). Resolution paths that DO require source/SPEC/PHASES edits (e.g. /add-phase) still dispatch an Opus subagent. All other Write/Edit operations — source code, test files, plan files, PHASES.md — require subagent dispatch (the Step 1g apply-resolution subagent is the dispatch that authorizes the SPEC/PHASES edits flowing from a decision).

  2. The orchestrator MUST NOT invoke any /skill directly via the Skill tool. Every sub-skill invocation goes through a spawned Agent subagent. This keeps the orchestrator's context lean across many cycles. Pseudo-skills (__*__) are NOT real skills and are handled inline per Step 1c.5 — they are sentinel-file edits + commits, not skill dispatches.

  3. The orchestrator MUST NOT manually parse SPEC.md, PHASES.md, or plan files. State inference is exclusively via lazy-state.py. Sentinel files MAY be read by the orchestrator to confirm a write or to drive a pseudo-skill action.

  4. One cycle = one subagent dispatch FOR REAL WORK SKILLS. Do not chain multiple sub-skills inside a single cycle; the state machine drives that progression across cycles. Pseudo-skill cycles (sentinel writes) are not subagent dispatches at all — they are inline orchestrator actions that count as one cycle each.

  5. Interactive prompts are scoped to the resolution modes — decision-resume (Step 1g), blocked-resolution (Step 1h), and operator-directed halt-resolution (Step 1i) — ONLY for the orchestrator itself. The guiding rule: a halt for ANY reason other than max-cycles (and the genuine all-done success / environment-exhaustion / no-queue stops listed in Step 1i) presents the operator an AskUserQuestion resolution path and continues the loop, rather than dead-ending — except that scope-class decisions and sequencing-only blockers are auto-resolved per ~/.claude/skills/_components/completeness-policy.md (D7), not asked: the standing policy reduces questions, never adds them, and the resolution modes ask only for what remains product-class. Outside Step 1g / 1h / 1i, the orchestrator MUST NOT call AskUserQuestion — with four additional permitted uses: (i) the one-time echo-back confirmation when a mid-run operator message implies a budget change, standing resolution mode, or early stop (Step 0 standing-directive protocol); (ii) the budget-and-queue guard question when the run would otherwise end with budget and queue both remaining; (iii) the Step 0.45 --enqueue-adhoc task-details prompt when --adhoc is supplied with no text and the task cannot be unambiguously inferred from the conversation; and (iv) the Step 5 in-session resume multi-feature disambiguation question when research arrives for an ambiguous feature ("which feature does this research belong to?"). Uses (i) and (ii) are orchestrator-level confirmations of operator intent; uses (iii) and (iv) are bounded single-question disambiguation prompts at well-defined pre-loop and resume boundaries. None are resolution-mode decisions about feature/bug content. Inside Step 1g, the orchestrator MUST AskUserQuestion against a well-formed NEEDS_INPUT.md (rich body per ~/.claude/skills/_components/sentinel-frontmatter.md), append a ## Resolution section, dispatch the apply-resolution subagent, and then continue the loop — Step 1g no longer halts the orchestrator. Inside Step 1h, the orchestrator MUST AskUserQuestion for the resolution path against a BLOCKED.md (re-printing its body first), record the choice, dispatch the apply-resolution subagent to enact it, and continue the loopblocked no longer halts the orchestrator either (except the operator-chosen "Halt for manual fix" path). (The legacy halt-on-needs-input behavior is gone; the user retains decision-making autonomy via AskUserQuestion, the apply step is mechanical propagation.) This constraint scopes the orchestrator, not subagents it dispatches. A /spec subagent dispatched at state-machine Step 4.5 (stub-spec detected) is allowed and expected to call AskUserQuestion during Phase 1 brainstorming — that's the legitimate design-conversation channel for a SPEC whose baseline doesn't exist yet. The orchestrator dispatches /spec exactly the same way it dispatches /execute-plan (one Agent call per cycle); whatever the dispatched skill does internally is its own contract. See "Stub specs vs structured-research-pending specs" below for the disambiguation rule.

  6. The orchestrator MUST print a Zero-Context Operator Briefing AND re-print the load-bearing context to chat BEFORE calling AskUserQuestion. The operator may have been away for hours and retains NO session context (and may be reading on mobile, where AskUserQuestion truncates). In Step 1g the briefing (step 2a of the decision-resume component) catches them up from zero — what's being worked, why we halted, every option with pros/cons and fit against the original requirements, and a recommendation — followed by the verbatim ## Decision Context re-print (step 2b); the AskUserQuestion option set MUST exactly match the options presented in the briefing (same labels, 1:1 — no option may appear in the UI that wasn't explained in chat first). Never call AskUserQuestion against a malformed NEEDS_INPUT.md (one missing the ## Decision Context H2 with H3 subsections matching decisions: 1:1) — surface the malformation as a quality issue and halt instead (see Step 1g.1). In Step 1h the load-bearing context is the BLOCKED.md body verbatim (no mandated rich-body schema — a thin body is NOT a malformation halt; re-print whatever is there and note in chat if it is sparse); in Step 1i it is the obstacle context the shared halt-resolution.md mandates. The same zero-context briefing discipline (catch the away operator up from zero before asking) applies to Step 1h/1i.

  7. NEVER actively wait for filesystem events. The orchestrator MUST NOT use Monitor, sleep, wait, polling loops, or any other mechanism to block while research is uploaded. Research arrives on the user's own timeline — they may be away from their device for hours or days. When queue-blocked-on-research or needs-research fires, the orchestrator halts cleanly (Step 1f / Step 4). The resume signal is chat-driven, not filesystem-driven: if the user's next message in the same conversation supplies research (file attachment, pasted text, or absolute path), the in-session resume protocol (Step 5) fires immediately; otherwise the user's next /lazy-batch invocation is the resume signal. Responding to a chat message is NOT polling — it is a single-turn event, not an active wait.

  8. TWO session-global monotonic counters replace the single cycle counter. Both are initialized once in Step 0 and NEITHER is ever reset on feature transitions.

    • forward_cycles — counts pipeline-advancing work. Ceiling: max_cycles. Incremented by: (a) real-skill dispatch cycles (Step 1e step 5) and (b) pipeline-advancing pseudo-skills at Step 1c.5 (__mark_complete__, __mark_fixed__, __write_deferred_non_cloud__ (cloud variant only — workstation lazy-state.py never emits this), __write_validated_from_results__, __write_validated_from_skip__, __grant_skip_no_mcp_surface__, __flip_plan_complete_cloud_saturated__). Capped at Step 1c (if forward_cycles >= max_cycles → the existing max-cycles halt).
    • meta_cycles — counts resolution / recovery / audit / cleanup work. NO ceiling — uncapped by design (operator decision 2026-06-14). Incremented by: Step 1g (decision-resume), Step 1h (blocked-resolution), Step 1i (operator-directed halt-resolution), LOOP-DETECTED / Step 1e.4a recovery dispatches, the input-audit cycle at Step 1d.5, and the stale-plan flip pseudo-skill __flip_plan_complete_stale__. The meta loop is NOT bounded by a meta cap; the run's only hard stop is the forward_cycles >= max_cycles cap at Step 1c. meta_cycles is still tracked and displayed (as a bare count), but there is NO if meta_cycles >= … halt anywhere — Step 1g/1h/1i have no meta-cap check.
    • Input-audit (Step 1d.5): audits are NOT counted as separate cycles (they share the real-skill cycle's slot in cycle_log and do NOT increment either counter). This keeps audit costs outside the budget.
    • Running total for cycle_log index: use forward_cycles + meta_cycles as the monotonic N in cycle-log entries and per-cycle headings (i.e., the N-th action in this invocation regardless of type). prev_cycle_signature is a tuple of ids, unaffected.
    • Cycle N's per-cycle heading always refers to the N-th action in this invocation, regardless of which feature it operated on. A feature transition is NOT a fresh batch; the orchestrator runs ONE log across every feature it touches.
  9. Dispatch ONLY against the feature lazy-state.py returned THIS cycle; never fabricate a feature. The orchestrator dispatches a cycle subagent against exactly the feature_id + spec_path from the current cycle's lazy-state.py output, verbatim. It MUST NOT invent, infer, or hand-edit a feature_id/slug that the state script did not emit. The state script (Step 2) already skips any queue entry whose spec_dir does not resolve on disk (emitting a dangling queue entry diagnostic) — so a real feature ALWAYS has an on-disk spec_path before dispatch. The cycle subagent prompt MUST forbid the subagent from CREATING a feature's SPEC.md/RESEARCH.md/queue.json/ROADMAP.md entries from a bare slug: the only sanctioned dir-creating paths are the --enqueue-adhoc bootstrap (Step 0.45) and a /spec dispatch against an already-seeded directory. If a cycle's feature_id does not correspond to an on-disk spec_path, that is a bug to surface (halt + report) — NEVER a cue to manufacture the feature. (This guards the observed failure where a hallucinated slug caused a subagent to fabricate an entire feature.)

  10. HARD CONSTRAINT — stop-authorization: the orchestrator MUST NOT end a run except on max-cycles or a genuine script-emitted terminal it JUST received from the state probe. The ONLY legitimate no-AskUserQuestion stops are: (a) forward_cycles >= max_cycles (Step 1c), and (b) a terminal_reason in {all-features-complete, all-bugs-fixed, max-cycles, cloud-queue-exhausted, device-queue-exhausted, host-capability-saturated, queue-missing, blocked-halt-for-manual, needs-research, queue-blocked-on-research} returned by the state script in the CURRENT cycle's probe. Any DESIRE to stop for ANY other reason — context pressure, orchestrator-context load, reliability friction, "I think I should checkpoint", or ≥2 guard denials in an attended run — is NOT license to end the run. The orchestrator MUST first route through the budget-and-queue-guard AskUserQuestion. A checkpoint stop MAY then proceed only after the operator confirms and only by calling lazy-state.py --run-end --reason checkpoint --operator-authorized. The script now mechanically enforces this: an attended --run-end --reason checkpoint without --operator-authorized is REFUSED (exit 1, marker kept) — an orchestrator that unilaterally decides to checkpoint will be denied and must continue or ask. Motivating incident (2026-06-14 / lazy-validation-readiness Phase 7): during an attended /lazy-batch 50 run the orchestrator permanently stopped at 5/50 cycles via --run-end --reason checkpoint without presenting an AskUserQuestion — the ≥2-denial prose trigger was read as license to stop unilaterally in an attended run; it is not. When passing --run-end on a genuine terminal, INCLUDE --terminal-reason <reason> (from the sanctioned set above) for stop-authorization validation; omitting it is back-compat but deprecated.

Cycle-subagent execution model (recursive dispatch is NOT available — inline edits required). The cycle subagent dispatched at Step 1d does not have the Agent tool: recursive sub-subagent dispatch is not supported from inside a dispatched subagent, even on workstation. (This was confirmed empirically — an /execute-plan cycle subagent that tried to dispatch Sonnet test/impl agents found the tool unavailable and could only halt.) This forces a load-bearing override of any dispatched skill's sub-subagent contract: skills that nominally fan out to sub-subagents (e.g. /execute-plan → Sonnet test-agent + impl-agent, /retro → research subagents A–G) MUST be performed INLINE inside the cycle subagent itself using Edit/Write/Read directly. This override applies only at the cycle-subagent level — the orchestrator still dispatches exactly one Agent per cycle, and the override never expands the orchestrator's Write/Edit scope (HARD CONSTRAINT 1 still holds; the orchestrator edits only sentinels). This is the same execution model as /lazy-batch-cloud; the two orchestrators are coupled per CLAUDE.md. (Unlike cloud, workstation retains the Tauri runtime, MCP HTTP server, audio device, and Windows tooling — only the recursive-dispatch limit and its inline-edit override are shared.)

Known limitation — TDD agent-separation is traded away. Collapsing /execute-plan's test-agent→impl-agent split into ONE inline cycle subagent means the structural test-first guarantee (a separate agent writes failing tests before a separate agent implements — the R-EP-2/R-EP-3 separation) is GONE: it cannot be enforced from sub-subagent dispatch evidence when there is no dispatch. This is an intentional tradeoff given the no-recursive-dispatch reality, not a defect. Compensating controls: (1) per-batch quality gates (R-EP-6) still run and must pass 100%; (2) the /retro pass audits the landed work; (3) the MCP-validation pass (which writes VALIDATED.md) gates final completion. The inline cycle subagent SHOULD still write tests-before-impl within each batch — read the test expectations, write the failing tests, confirm they fail for the right reason, THEN implement — even though the ordering can't be structurally verified. /lazy-batch-retro's cloud branch already grades R-EP-2/R-EP-3 as n/a (cloud-override); the same grading applies to inline workstation cycles.

OUTPUT CONTRACT — orchestrator voice (read at run start)

ALL orchestrator chat output MUST follow ~/.claude/skills/_components/orchestrator-voice.md — the turn-template contract (T1 run banner, T2 dispatch / T3 return / T4 inline-gate cycle blocks, T5 park line, T6 rich zones, T7 final report; mechanics silent; rules cited only on deviation; probe JSON never restated in prose). ZERO-TEXT RULE: Claude Code's general "say what you're about to do before tool calls / give brief updates" guidance is OVERRIDDEN for this run — the UI already prints every tool call; between tool calls emit NOTHING unless it is byte-shaped as a template (sanctioned output starts with ## , ### Cycle , a template field line, //, or a T6/T7 body — anything else, don't type it). No transition sentences, no "reading X", no "preflight passed", no "composing the dispatch". Read it at run start, and RE-READ it after any compaction boundary (alongside lazy-dispatch-template.md — see Step 1d's compaction discipline); the contract survives summarization by re-read, not by memory. Where an older passage in this skill prescribes a different chat-output shape, the contract's Precedence clause wins; the verbatim re-print / Zero-Context Operator Briefing requirements (HARD CONSTRAINT 6, decision-resume.md, blocked-resolution.md, parked-flush.md, halt-resolution.md) are sanctioned T6 rich zones and are never overridden. Graded by /lazy-batch-retro's R-V-* rules.

STANDING POLICY — completeness-first (D7). Read ~/.claude/skills/_components/completeness-policy.md at run start, and RE-READ it after any compaction boundary (it is on the Step 1d compaction re-read list). It is pre-authorized: decisions whose options differ only in effort / sizing / sequencing / completeness (class: scope) are auto-resolved to the MOST COMPLETE option in BOTH modes — logged (⚖ policy: line, resolved_by: completeness-policy, run-end D7 digest in the T7 report), never asked. It governs the cycle and input-audit subagent prompts (source suppression), Step 1g (scope-class sentinel resolution runs first), Step 1h (sequencing-only blockers auto-resolve; spin-offs pre-authorized, notify + log), and the Gate-1 coverage outcome at Step 1c.5 (author coverage / test-exempt, never ask). D7 only REMOVES questions — product-class decisions still ask exactly as before. Graded by /lazy-batch-retro's R-D7-* rules.

$ARGUMENTS is tokenized on whitespace. Recognized tokens:

  • Positive integermax_cycles. If absent, default to 10. If a non-numeric / < 1 integer is supplied, refuse with:

    /lazy-batch requires a positive integer max-cycles. Usage: /lazy-batch <N> [--allow-research-skip] [--adhoc "<task>"] [--park]. Default: 10.

    Ambiguous max-cycles (Deliverable D — clarify, never silently coerce): if the token is present but non-integer in a way that suggests a quantity the user had in mind — e.g. "infinity", "lots", "max", "all", "unlimited" — do NOT silently translate it to a hard-coded default. Instead, ask ONE clarifying AskUserQuestion before proceeding:

    You passed '{token}' for max-cycles — how many cycles should I run? (e.g. 10 / 30 / 100)

  • --allow-research-skip (optional flag) → sets allow_research_skip = true. Default false. When set, the orchestrator restores the legacy "batch the research backlog" behavior: lazy-state.py is called with --skip-needs-research, Step 4 drops a NEEDS_RESEARCH.md sentinel for each research-pending feature without halting, and the loop halts on queue-blocked-on-research once every remaining feature is research-pending. This flag is for sessions where you have manually verified the remaining queue is independent — i.e., starting work on a downstream feature is safe even though an upstream feature is awaiting research. Use case is rare. The DEFAULT (flag absent) is to halt strictly on the FIRST needs-research so an ordered queue with dependencies cannot leak work onto unsafe downstream features.

  • --adhoc (optional flag) → sets adhoc_task to the remainder of $ARGUMENTS after the --adhoc token (everything following it, verbatim). If --adhoc is the last token with no trailing text, adhoc_task is empty and the task is inferred from the conversation (see Step 0.45). When adhoc_task is set (flag present), the orchestrator runs Step 0.45 (Ad-hoc Enqueue) before the main loop so the referenced work is enqueued at the top of the queue. Off by default (flag absent → no ad-hoc enqueue). Because --adhoc consumes the rest of the string, place <N> and --allow-research-skip BEFORE it.

  • --park (optional flag) → sets park_mode = true. Default false. Enables "park-and-continue" mode. This flag is opt-in and off by default. Without it, the orchestrator's behavior is byte-for-byte the existing one — a NEEDS_INPUT.md halts the loop into the existing Step 1g resolution-and-wait. The --park flag may appear in any position relative to the cycle-count arg (e.g. /lazy-batch --park 30 and /lazy-batch 30 --park are equivalent). The full park/flush/auto-accept semantics (what happens when park mode is active) are defined in Steps 1g, 1h, and 1i of this skill — this token purely enables the mode.

  • --per-feature-cycle-cap <N> (optional flag) → arms the per-feature budget guard with a fixed ceiling N; OFF by default (the guard never arms without this flag — the whole-run max-cycles is the sole default budget). Pass --per-feature-cycle-cap <N> to every lazy-state.py probe invocation in Step 1a to opt-in. The orchestrator itself does NOT compute the ceiling — that is lazy-state.py's job; this flag merely threads the override in. When the budget guard trips, the budget_guard probe field surfaces the ceiling in the PushNotification (see §1c.6 budget-guard notification).

  • --strict-research-halt (optional flag) → pass --strict-research-halt to every lazy-state.py probe invocation in Step 1a, restoring the legacy halt-on-first-gated-head behavior (disabling the default-on dependency-aware skip-ahead). Default (flag absent): the new dependency-aware skip-ahead is ON — when the queue head is research-gated or BLOCKED, lazy-state.py automatically advances to the next independent, independent: true-marked queue item (if one exists) instead of halting immediately. Pass --strict-research-halt only when you want the pre-feature strict behavior (halt as soon as the queue head is gated, regardless of downstream items). The gated head is always surfaced (notification + end-of-run flush in the batch report) regardless of whether skip-ahead advanced past it.

Unknown tokens are an error:

/lazy-batch: unrecognized argument {token}. Usage: /lazy-batch <N> [--allow-research-skip] [--adhoc "<task>"] [--park] [--per-feature-cycle-cap <N>] [--strict-research-halt].

Standing-directive echo-back protocol (Deliverable C): mid-run operator messages that imply a change to the orchestrator's operating mode MUST be acknowledged with a single AskUserQuestion echo-back BEFORE the mode takes effect. A "standing directive" is any message that implies one of:

  • (a) Budget change — the operator wants to extend or reduce max_cycles (e.g. "run 20 more cycles", "stop after this feature").
  • (b) Standing resolution mode — the operator wants a recurring resolution policy applied automatically until some condition (e.g. "auto-resolve all blockers as add-phase-and-fix until feature X completes").
  • (c) Early stop — the operator wants to terminate the current run sooner than max_cycles (e.g. "stop after this cycle", "pause after the next commit").

Echo-back format (one AskUserQuestion, phrased in active terms):

{Interpretation of the directive in active terms, e.g. "Extend to N cycles and auto-resolve blockers as add-phase-and-fix until X completes — confirm?"} — Yes / No (adjust: ...)

Only enter the new mode after the operator confirms. If they say No or provide a correction, re-parse and echo again.

Budget-and-queue guard: the orchestrator MUST NOT end a run with both budget remaining (forward_cycles < max_cycles) AND active queue items remaining (features that are neither complete, deferred, nor blocked on research) without first asking the operator (one AskUserQuestion) whether to continue into a new run or stop now. This prevents silent early exits where the orchestrator halts mid-queue without the operator realising. The AskUserQuestion path is the attended default. After the operator confirms a stop, the orchestrator passes --operator-authorized to lazy-state.py --run-end --reason checkpoint; without that confirmation flag the script REFUSES the checkpoint (exit 1, marker kept) — see HARD CONSTRAINT 10.

Unattended-checkpoint arm (sanctioned early stop — UNATTENDED runs only). In an unattended run (a scheduled / cron / overnight run that passed --run-start --unattended — see Step 0.55; interactive /lazy-batch invocations are attended and this arm does NOT apply), an early stop is sanctioned ONLY as a CHECKPOINT, and ONLY when a reliability trigger holds: ≥2 guard denials this run, OR an explicit operator pause message. The reliability trigger sanctions an unattended checkpoint because no operator can answer the AskUserQuestion in a scheduled run. In an attended run — even if ≥2 guard denials occurred — the orchestrator MUST route through the budget-and-queue-guard AskUserQuestion (above) and may checkpoint only after the operator confirms. The script enforces this: an attended --run-end --reason checkpoint without --operator-authorized is REFUSED (exit 1, marker kept), so an orchestrator that "decides to checkpoint" without asking will be denied and must continue or ask. A checkpoint (unattended OR operator-authorized) requires ALL THREE of: (1) python3 ~/.claude/scripts/lazy-state.py --run-end --reason checkpoint --next-route "<the probed next route>" [--operator-authorized] (writes lazy-run-checkpoint.json so the next --run-start resumes from it; --operator-authorized is included when and only when the budget-and-queue-guard AskUserQuestion confirmed the stop); (2) a PushNotification carrying the next route + the trigger reason; (3) the T7 final report naming the trigger. An early stop WITHOUT the checkpoint --run-end (or without a holding trigger, or without operator authorization for an attended run) remains a contract violation — the unattended arm narrows, never widens, the silent-exit ban. Resume side: --run-start echoes resumed_from_checkpoint (and deletes the checkpoint file) when it consumes one; surface it on the T1 run-start block as one line (see Step 0.55 / orchestrator-voice.md T1).

Initialize counters and per-session state:

  • forward_cycles = 0 — initialized once per /lazy-batch invocation; monotonic across feature transitions (HARD CONSTRAINT 8 — never reset when lazy-state.py returns a new feature_id). Counts pipeline-advancing work; ceiling is max_cycles.
  • meta_cycles = 0 — initialized once per /lazy-batch invocation; monotonic across feature transitions (HARD CONSTRAINT 8 — never reset on feature transitions). Counts resolution/recovery/cleanup work; uncapped — no ceiling, no cap enforcement (operator decision 2026-06-14). Only forward_cycles is capped (at max_cycles).
  • max_cycles = <parsed>
  • allow_research_skip = <parsed> — see Step 4 + Step 1f for the behavior switch.
  • cycle_log = [] — each entry: {forward_cycles + meta_cycles, feature, action, subagent_summary} (the running total is the monotonic N-th action in this invocation).
  • research_pending = set() — feature_ids whose RESEARCH.md is missing and a NEEDS_RESEARCH.md sentinel was dropped this session. Only used when allow_research_skip == true. In the default (strict-halt) path this set never accumulates because Step 4 halts on the first feature; it stays empty.
  • skip_needs_research = false — flips to true after the first needs-research cycle only when allow_research_skip == true. In the default path this stays false for the entire session because Step 4 halts before the loop continues.
  • prev_cycle_signature = None — tuple (feature_id, sub_skill, sub_skill_args, current_step) from the most recent cycle (pseudo-skill or real-skill). Drives the Step 1d loop-guard hint. None until at least one cycle has dispatched. sub_skill_args is part of the tuple deliberately: a multi-part /execute-plan sequence (part-1 → part-2 → part-3) returns the same (feature_id, sub_skill, current_step) on every part but a different sub_skill_args (the plan-part path), which is real forward progress, not a loop. Omitting sub_skill_args made the loop-guard false-trigger on every multi-part plan. Including it lets the guard fire only on a genuine no-progress repeat (identical part re-returned).
  • adhoc_task = <parsed> — the ad-hoc task text from --adhoc (empty string if the flag was present with no text; unset/None if the flag was absent). See Step 0.45.
  • park_mode = <parsed>true if --park was present, false otherwise. When false, all halt behavior is byte-for-byte the existing one.

Step 0.0: Environment Preflight (FIRST — before the start banner and before remote sync)

Read and follow ~/.claude/skills/_components/lazy-preflight.md as the very first action of this invocation — before the start banner, before Step 0.4 remote sync, before the first state probe. Run its read-only check block (skills symlink resolves, ~/.claude/scripts/lazy-state.py exists, python3 runs, node resolvable — prepending /c/nvm4w/nodejs if needed). If any check fails, print the component's setup recipe and STOP — zero cycles consumed (do not print the banner, do not call the state script, do not enter the loop). On success, node is on PATH for the whole session (no per-call export PATH), and you continue to the banner / Step 0.4 as normal.

The entire run-start sequence is SILENT (zero-text rule): the preflight, the contract/policy reads, Step 0.4 remote sync, and the queue read for the banner are executed back-to-back with NO text between the tool calls — no "I'll start by…", no "preflight passed", no "let me read…", no "sync clean". The FIRST text this invocation emits is the T1 banner (preflight failure and sync divergence are the T6 exceptions).


Print the start banner — T1 per ~/.claude/skills/_components/orchestrator-voice.md (≤4 lines; nothing else before the first cycle block):

## /lazy-batch — run start
mode   workstation · park {on|off} · research {strict|batched}
budget fwd {max_cycles} · meta no cap
queue  {N} feature(s) · first: {first queue entry id}

The queue line is best-effort (one Bash read of docs/features/queue.json for the entry count — a banner fact, not state inference; state inference remains exclusively lazy-state.py per HARD CONSTRAINT 3); omit the line if the queue file can't be read cheaply. The repo root and flag parsing are mechanics — not announced.


Step 0.4: Resume-time remote sync (HARD REQUIREMENT)

Runs once, immediately after Step 0 (arg parsing) and BEFORE Step 0.5 / the Step 1a first state probe. This is a single-turn git reconciliation, NOT polling — it does not violate HARD CONSTRAINT 7 (no active waiting). It does NOT touch the orchestrator's Write/Edit sentinel-only scope (HARD CONSTRAINT 1) — these are Bash git operations, not file edits.

Rationale: a /lazy-batch session can be interrupted (machine sleep, crash, terminal close) and resumed later, or the work branch's remote may have advanced from another machine (or a cloud /lazy-batch-cloud run on the same branch). If the orchestrator runs lazy-state.py against a local tree behind the remote tip, it infers state from stale local files (plans, sentinels, SPEC) and may re-do or corrupt already-pushed work. Reconcile local to the remote tip BEFORE any local-state inference. (This guardrail is mirrored from /lazy-batch-cloud Step 0.4, where the same failure mode is acute because of container reclaim.)

Algorithm:

  1. Determine the work branch:

    branch=$(git rev-parse --abbrev-ref HEAD)
    
  2. Fetch the remote tip (retry up to 4× with exponential backoff 2s/4s/8s/16s on network error — bounded retry, not an active wait):

    git fetch origin "$branch"
    

    If the branch does not exist on origin yet (brand-new work branch never pushed), there is nothing to reconcile: skip the rest of Step 0.4 and continue to Step 0.5.

  3. Fast-forward local to the remote tip:

    git merge --ff-only "origin/$branch"
    
  4. If the fast-forward FAILS because local has DIVERGED from origin (non-fast-forwardable — local has commits origin lacks AND origin has commits local lacks), do NOT clobber. Do NOT git reset --hard, do NOT force anything. Surface the divergence to chat and halt for human resolution:

    🛑 /lazy-batch — work branch diverged from origin
    
    Local `{branch}` and origin/{branch} have both moved independently
    (non-fast-forwardable). This may indicate concurrent edits from another
    machine or a force-push. Refusing to auto-reconcile to avoid losing work.
    
    Resolve manually (inspect `git log --oneline --graph {branch} origin/{branch}`),
    then re-invoke /lazy-batch.
    

    PushNotification with the same one-line summary, then STOP. Do NOT run lazy-state.py.

  5. On a clean fast-forward (or when local was already up to date / the branch was unpushed), continue to Step 0.5 silently — a successful sync is mechanics per the orchestrator-voice contract (silence means the machinery worked). Only the step-4 divergence halt is announced (a T6 error — recipe printed in full).


Step 0.45: Ad-hoc Enqueue (only when --adhoc was supplied)

Runs once, after Step 0.4 (remote sync) and BEFORE Step 0.5 / the first state probe. Skipped entirely when the --adhoc flag was absent. It runs AFTER the remote ff-sync deliberately: enqueuing mutates queue.json in the working tree, so it must happen on the reconciled remote tip, not a stale local snapshot that the Step 0.4 fast-forward would then conflict with.

!cat ~/.claude/skills/_components/adhoc-enqueue.md

After the enqueue returns, continue to Step 0.5. The first cycle's state probe will return the ad-hoc feature first and route it to /spec; its end-of-cycle commit+push carries the bootstrap files (queue.json, ROADMAP.md, the spec dir + ADHOC_BRIEF.md) to origin.


Step 0.5: Pre-loop staged-research ingest check

Before entering the main loop, check whether the user staged Gemini research uploads between sessions. This is the "resume after halt" entry point — a previous /lazy-batch invocation may have halted in Step 1f (research-wait), the user uploaded research in the meantime, and this invocation should pick it up automatically.

Algorithm:

  1. Probe for staged .txt files:

    find docs/gemini-sprint/results -maxdepth 1 -name '*.txt' -type f 2>/dev/null | head -1
    

    If empty → no staged research, skip to Step 1.

  2. If staged .txt files exist, dispatch /ingest-research as cycle 1 (counts against max_cycles):

    Agent({
      description: "lazy-batch pre-loop ingest-research dispatch",
      subagent_type: "general-purpose",
      model: "sonnet",
      prompt: <the prompt below>
    })
    

    Subagent prompt:

    You are advancing one cycle of the autonomous feature pipeline. The
    orchestrator detected staged Gemini research at session start —
    .txt file(s) are present in docs/gemini-sprint/results/.
    
    Working directory: {cwd}
    
    Action for this cycle:
      Invoke the /ingest-research skill with no arguments. It will scan
      docs/gemini-sprint/results/ for every .txt, correlate each to a feature
      via the prompt symlinks under docs/gemini-sprint/prompts/, write
      per-feature RESEARCH.md + RESEARCH_SUMMARY.md, drop the > Draft
      (pre-Gemini) trailer in SPEC.md, clear queue.json "stub": true, move
      consumed .txt files to _consumed/, and commit per feature.
    
    Operating mode: batch (--batch is implicit for /ingest-research — see its
    SKILL.md hard constraints).
    
    After the skill returns:
      1. Report the final summary block /ingest-research printed.
      2. List any ambiguous correlations (NEEDS_INPUT.md sentinels written) —
         the next orchestrator cycle will halt at decision-halt mode (Step 1g).
      3. Report which feature_ids now have RESEARCH.md on disk.
    
    You may NOT spawn further subagents. You MAY use Edit/Write under docs/
    per /ingest-research's hard constraints.
    
  3. After dispatch:

    • Append to cycle_log: {forward_cycles + meta_cycles + 1, "—", "/ingest-research (pre-loop)", "<subagent summary>"}.
    • Increment forward_cycles to 1 (ingesting research is pipeline-advancing work).
    • Enter the main loop (Step 1).

Direct RESEARCH.md drops into canonical feature directories don't require ingestion — lazy-state.py sees them at Step 5 and routes to /spec Phase 3 naturally. Step 0.5 is specifically for the staged .txt upload path.

If the user provided a one-off file path via /ingest-research <path> (run BEFORE /lazy-batch), that invocation handled the ingest in its own session — by the time /lazy-batch runs, RESEARCH.md already exists in the canonical location, and Step 0.5 is a no-op for that feature.


Step 0.52: Validation-readiness pre-screen (advisory — F5 / lazy-validation-readiness)

Purpose: Before front-loading DEFERRED_NON_CLOUD features, emit a per-feature verdict table showing whether each candidate's MCP test scenarios assert tools that are already registered in the repo. This surfaces "the scenario asserts a tool that doesn't exist yet" early — at curation time — rather than three cycles later at the Step-9 mcp-test boundary.

Run:

python3 ~/.claude/scripts/validation_readiness.py --repo-root {cwd}

The script lives at ~/.claude/scripts/validation_readiness.py (symlinked from user/scripts/validation_readiness.py in the claude-config repo).

Output format (example):

validation_readiness — DEFERRED_NON_CLOUD pre-screen verdict
======================================================================
  FEATURE                                   VERDICT                 MISSING TOOLS
  ----------------------------------------  ----------------------  ------------------------------
  sidecar-watchdog                          ready
  d8-session-format                         needs-work              evaluate_code
  polyphonic-stems                          needs-work              get_diagnostic_counters
  ...
advisory: operator may still front-load a needs-work feature.

This step is ADVISORY — not a hard gate. The operator may still choose to front-load a needs-work feature deliberately (e.g., the plan for this session IS to implement the missing surface first). However, the verdict is logged so that if the run later hits a deep blocker at Step 9, the blocker is traceable to an ignored pre-screen warning rather than appearing as a surprise. Features with no DEFERRED_NON_CLOUD.md are silently skipped (not front-load candidates); features with DEFERRED_NON_CLOUD.md but no mcp-tests/ scenarios are shown as ready (no scenarios).

If the script is absent (first run after the claude-config symlink was set up, or a machine where the ~/.claude/scripts/ symlink hasn't been refreshed), skip this step silently and continue to Step 0.55 — this is a zero-text-rule advisory, never a blocker.


Step 0.55: Write the run marker (IMMEDIATELY before the T1 banner / loop entry)

After Step 0.5 (pre-loop ingest check) completes — and before printing the T1 banner or entering the Step 1 loop — assert the orchestrator identity signal, then write the run marker:

# C3 self-immunity signal (cycle-subagent-runs-orchestrator-work, Phase 1): the
# orchestrator asserts its identity by EXPORTING LAZY_ORCHESTRATOR=1 into the
# session env it runs every lazy-state.py lifecycle/routing call from. This is
# the positive, marker-independent carrier `refuse_if_cycle_active` /
# `refuse_cycle_marker_mutation_if_subagent` key on (lazy_core.py priority 1) —
# it makes the orchestrator STRUCTURALLY IMMUNE to a stale/live cycle marker
# (its own --cycle-end clears the marker while the marker is still present), and
# the ABSENCE of the var is what marks a cycle subagent (a subagent's Bash
# subprocess never inherits this export). Carry it on EVERY lifecycle/routing
# call below (--run-start/--run-end/--cycle-begin/--cycle-end/--apply-pseudo/
# --enqueue-adhoc/--emit-dispatch); export once for the session so it persists.
export LAZY_ORCHESTRATOR=1

python3 ~/.claude/scripts/lazy-state.py \
  --run-start --max-cycles {max_cycles} \
  --repo-root {cwd}

Attendedness: interactive /lazy-batch invocations (the operator is present in the session) call --run-start WITHOUT --unattended — the marker records attended: true (the default). Only a scheduled/cron driver (a cloud task, an overnight automation) passes --unattended, recording attended: false. The attended field governs whether --run-end --reason checkpoint requires --operator-authorized (see HARD CONSTRAINT 10 and the budget-and-queue guard above). Legacy markers lacking the field are treated as attended — the stricter gate is the safe default.

What this does. The marker (~/.claude/state/lazy-run-marker.json) is the single on/off switch for the inject + validate-deny hooks. While the marker is present:

  • The inject hook (lazy-route-inject.sh) fires on every UserPromptSubmit turn, runs the full probe form, and injects the route (LAZY-ROUTE (hook-injected, turn N): …) into the model's context via additionalContext — the orchestrator does NOT need to remember to probe; the probe arrives with the turn.
  • The validate-deny guard (lazy-dispatch-guard.sh) checks every Agent dispatch against the prompt registry; an unregistered prompt is denied with a corrective recipe.

Interactive sessions (no marker) are completely untouched — both hooks exit instantly on the test -f fast path. The marker is script-owned: --run-start writes it; --run-end deletes it. The orchestrator never hand-writes the marker file.

Session state pinned in the marker: pipeline=feature, cloud=false, repo_root, max_cycles, session_id (bound on first hook firing), nonce_seed. Counters (forward_cycles, meta_cycles) are persisted in the marker from this point forward — the inject hook reads them without needing CLI flags.

Resume from a checkpoint. If a prior run ended via the unattended-checkpoint arm (Step 0 budget-and-queue guard), --run-start consumes lazy-run-checkpoint.json and echoes its content as resumed_from_checkpoint in the run-start output (then deletes the file — single-use). When the run-start output carries resumed_from_checkpoint, surface it on the T1 banner as one extra line — resume <next_route> (checkpoint <date>) (orchestrator-voice.md T1) — so the operator sees the run picked up where the checkpoint left off.

--run-end is MANDATORY on every terminal/halt path — see §1c.6 for the enumeration. A missed deletion is self-healing (24h staleness + session-id mismatch cleanup) but is a protocol violation the retro grades.

If --run-start fails (script error), surface a T6 and STOP before printing the banner — a run with no marker is a run with no enforcement, which is still safe for the pipeline (it degrades to pre-Phase-5 behavior) but should not silently proceed without the operator knowing enforcement is off.


Step 1: Cycle Loop

Unified driver — merged-view dispatch (single driver, two state scripts; unified-pipeline-orchestrator Phase 2). /lazy-batch is the SHARED driver for BOTH the feature and bug pipelines. Each cycle it probes the merged work-list head with python3 ~/.claude/scripts/lazy-state.py --next-merged --repo-root {cwd} (Phase-1 surface — read-only ORDERING ONLY; it never re-infers per-item state) to learn the next actionable item's {item_id, type, repo_root}, then type-dispatches the rest of the cycle to the matching state script:

  • type == "feature" → drive this cycle with lazy-state.py exactly as Steps 1a–1e describe; the type-correct terminal action is __mark_complete__ (writes COMPLETED.md).
  • type == "bug" → drive this cycle with bug-state.py (same JSON contract, docs/bugs/, --bug-id scoping); the type-correct terminal action is __mark_fixed__ (writes FIXED.md).

The merged view normalizes the two queues' divergent ordering fields (feature tier int / bug severity P0..Low) onto one effective-priority scale and breaks ties bug-before-feature — but that ordering lives ENTIRELY in lazy_core.merged_priority (Phase 1), NOT in this prose: the driver only CONSUMES the merged head, it never re-implements ordering. Both state machines and all gates run UNCHANGED — this skill carries NO new state-machine logic; the merged probe is the only addition.

No-regression (single-type runs are unchanged). When only ONE queue is populated, the merged head is simply that queue's head, so the cycle sequence is byte-for-byte identical to the pre-unification per-type batch: a features-only queue runs exactly as /lazy-batch always did (drive lazy-state.py, terminal __mark_complete__); a bugs-only queue runs exactly as the standalone /lazy-bug-batch (drive bug-state.py, terminal __mark_fixed__). The merged probe is additive — lazy-state.py --next-merged over a single populated queue returns that queue's head and nothing else. The parity audit (lazy_parity_audit.py --merged-view) + a single-type fixture assert this no-regression guarantee.

Steps 1a–1e below are written against lazy-state.py (the feature path, the common case). For a type == "bug" cycle, substitute bug-state.py for lazy-state.py and __mark_fixed__ for __mark_complete__ everywhere in the cycle body; the dispatch SHAPE is otherwise identical (this is the same coupling /lazy-bug-batch already documents). See the State Machine Summary at the bottom of this skill for the per-type dispatch table.

Repeat:

1a. Run lazy-state.py

LAZY-ROUTE banner check (FIRST — before deciding to run the probe). The inject hook (lazy-route-inject.sh) fires on every UserPromptSubmit turn while the run marker is present. When the hook fires, it runs the full probe form itself and injects the result into the turn context as a single additionalContext string with the following structure:

LAZY-ROUTE (hook-injected, turn N): {"feature_id": "...", "sub_skill": "...", "cycle_prompt": "...", "cycle_model": "opus", ...} nonce=<hex-value>

On a post-compaction re-entry (the hook event is PostCompact or SessionStart with source=="compact"), the string also carries a POST-COMPACTION RE-ENTRY: paragraph after the nonce line. If the inject hook itself errored (fail-open breadcrumb), a HOOK_ERROR: <error text> suffix appears at the end of the string. The probe JSON is compact (no indentation) and contains all the same fields as a manual lazy-state.py --repeat-count --emit-prompt --probe invocation. If the current turn carries a LAZY-ROUTE (hook-injected, turn N): banner, consume it directly — extract feature_id, sub_skill, cycle_prompt, cycle_model, and all other probe fields from the injected JSON. Do NOT run another lazy-state.py probe on this turn. Re-probing when a banner is already present advances the persisted counters TWICE for one logical cycle — a protocol violation the retro grades. The counter in cycle_header is POST-advance (1-based current-cycle semantics — the script already incremented before injecting). If no LAZY-ROUTE banner is present (hook was inactive, or the turn is the first turn after a compaction boundary before the hook re-armed), run the probe as below.

python3 ~/.claude/scripts/lazy-state.py [--skip-needs-research]

Pass --skip-needs-research only when allow_research_skip == true AND skip_needs_research == true. The double-gate matters: in the default (strict-halt) path, skip_needs_research never flips to true because Step 4 halts the loop on the first needs-research, so the script is always called without the flag and returns terminal_reason: needs-research for the first research-pending feature in queue order. Only the --allow-research-skip path arms the legacy batching behavior.

Probe enrichment (optional — folds repeat-count, git guards, and cycle header into one payload). The orchestrator MAY call the probe with additional flags to fold repeat_count, git_guards, and cycle_header into the JSON in a single invocation:

python3 ~/.claude/scripts/lazy-state.py --repeat-count --emit-prompt --probe \
  --max-cycles {max_cycles} \
  [--skip-needs-research]

The --forward-cycles and --meta-cycles flags are NO LONGER passed on probe invocations. The marker persists the counters (forward_cycles, meta_cycles) from the moment --run-start wrote it; the inject hook and probe read them directly from the marker without needing CLI flags. The flags remain in the CLI for backward compatibility but MUST NOT be passed by the orchestrator — passing them would override the marker's persisted state with stale in-memory values.

--repeat-count enriches the output with a repeat_count field (how many consecutive cycles returned the same (feature_id, sub_skill, sub_skill_args, current_step) tuple) for mechanical loop detection. It ALSO emits a step_repeat_count field (how many consecutive cycles reached the same (feature_id, current_step) STEP — sub_skill/sub_skill_args-blind, and with NO head-advance reset). Probe hygiene: --repeat-count ADVANCES both persisted streaks, so it is reserved for the SINGLE dispatch-bound probe per cycle (the one whose result you actually dispatch on). Any diagnostic / inspection probe — re-checking state out of band, sanity-reading the routing — MUST use --repeat-count-peek instead (it reads the would-be streaks WITHOUT advancing the persisted state). The dispatch-tuple repeat_count is HEAD-aware: the same tuple plus new commits since the last probe RESETS it to 1 (a re-validation after landed commits is forward progress, not a loop). step_repeat_count is the oscillation tripwire (T6): when it is >= 3, STOP — do NOT keep dispatching the emitted action mechanically. Surface the warning ⚠ step '<current_step>' reached <step_repeat_count> times without advancing — inspect routing before dispatching, then inspect WHY the state machine keeps returning to that step before continuing. Unlike repeat_count, the step counter does NOT reset when HEAD advances — it is built to catch "productive-looking" oscillation where each cycle commits a file (HEAD moves → the dispatch streak resets every iteration) yet routing never leaves the step (the live d8 write-plan loop on a gate-owned PHASES row, 2026-06-11). A high step_repeat_count with a low repeat_count is exactly that signature. Never redirect probe or diagnostic output into the repo tree. Capture probe output IN-BAND — the probe already prints its JSON to stdout, so consume that stdout directly (result=$(python3 user/scripts/lazy-state.py … ) or pipe it straight into the consumer); do NOT round-trip through a temp file. A temp-file round-trip is the source of the Windows-portability crash: a hardcoded/idiomatic POSIX /tmp/probe.json (the fallback when $TMPDIR is unset in Git-Bash) is read back by Windows-native Python, which has no /tmp, and the read-back dies with FileNotFoundError. If a temp file is GENUINELY unavoidable, NEVER use a bare /tmp/... path — require a path PRODUCED AND READ BY THE SAME INTERPRETER (f="$(mktemp)" consumed by the same shell, or %TEMP% resolved by the same Windows Python that reads it back), never a path that crosses Git-Bash/Windows-Python conventions. --probe (combined with the three counter flags) folds git_guards (clean-tree + origin-parity) and a pre-formatted cycle_header string into the response. --emit-prompt (composed with --repeat-count) folds the fully-assembled cycle dispatch prompt into the JSON: cycle_prompt (the complete, token-bound prompt — the loop block already appended when repeat_count >= 2, the mcp-test runtime variant already selected from the spec's PHASES.md **MCP runtime:** line) and cycle_model ("opus", or "sonnet" when the loop block was appended). Both are null on pseudo-skill (__*) and terminal/idle probes; on an assembly failure for a real skill cycle_prompt_refused carries the reason instead. These flags are purely additive — the base JSON fields are unchanged. --emit-prompt SHOULD be passed on EVERY probe — it is null on pseudo-skill/terminal probes, so it is always safe to request, and folding prompt assembly into the same probe call is what makes Step 1d a pure consume-and-dispatch.

Step 1a — probe ONCE per cycle (F2 double-probe debounce). Run exactly ONE advancing, dispatch-bound --repeat-count --emit-prompt probe per cycle — the one whose cycle_prompt you actually dispatch — and use --repeat-count-peek for EVERY inspection / sanity / out-of-band probe so that only the single dispatch-bound probe advances the streaks. Probing a route twice with no dispatch between (an inspection probe, then the dispatch-bound probe) is a re-read, not a re-attempt, and historically inflated step_repeat_count into false LOOP DETECTED blocks. update_repeat_counts now defends this in depth: when a run marker is present it debounces a re-read via the registry consume-count delta (an unchanged consumed-emission count between two identical step probes ⇒ no dispatch landed ⇒ step_repeat_count is HELD, not incremented), so a genuine same-step oscillation (a real dispatch — hence a consume — between repeats) still trips while a benign double-probe no longer does. This note is the behavioral complement: even with the script debounce, keep to one advancing probe + peek for inspection.

Investigation triggers + the inline-diagnosis budget (see ~/.claude/skills/_components/investigation-dispatch.md — the dispatch template lives THERE, reference it, never inline-copy; the orchestrator emits the dispatch via python3 ~/.claude/scripts/lazy-state.py --emit-dispatch investigation --context item_name=… --context spec_path=… --context symptom=… --context trigger=… --context inherited_hypotheses=… --context item_id=… --context cwd=… and dispatches dispatch_prompt VERBATIM). Root-cause diagnosis is dispatched work, not orchestrator work. Three triggers: (1) the probe JSON carries validation_escalation: true and no current INVESTIGATION.md exists in {spec_path} → the blocked-resolution path dispatches /investigate BEFORE any corrective phase (Step 1h carries the wiring); (2) failed fix — a fix cycle landed but the post-fix live/validation check shows the symptom unchanged → the next dispatch for that issue is /investigate, NOT another fix cycle (a headless-green fix built to an unverified orchestrator hypothesis once burned ~266k tokens and seeded the next bug); (3) inline-diagnosis budget — if you have spent more than ~8 of your own diagnostic tool calls (source reads, log greps, live probes) on one issue, STOP probing and dispatch /investigate; quick checks stay inline, sustained diagnosis does not (measured cost of unbounded inline diagnosis: ~60% of orchestrator activity + a mid-diagnosis compaction in the 2026-06-11 live run). No-narrative-as-fact: your dispatch prompts cite INVESTIGATION.md (artifact path + ledger rows) or say "cause unknown — investigation pending"; unproven hunches go to the investigation labeled unproven, never to a fix cycle as "strong hypothesis" headers.

Park-mode probe flag (--park only). When park_mode == true (the --park invocation flag), append BOTH --park-needs-input AND --park-blocked to EVERY lazy-state.py probe invocation in this step (base or enriched form alike). With these flags, the script skips features carrying an unresolved NEEDS_INPUT.md (instead of halting on needs-input) OR a feature-local BLOCKED.md (instead of halting on blocked), and reports them in a parked[] array on the JSON output — each entry tagged sentinel_kind (needs-input | blocked) — the input to the Step 1g park path, the Step 1g-flush, and the §1c.6 park notifications. When every remaining feature is parked, the script returns the distinct queue-exhausted-all-parked terminal (handled in Step 1b). When park_mode == false, call the script plain (NEITHER flag) — existing behavior, byte-for-byte; the parked[] key never appears, and a feature-local BLOCKED.md still halts on blocked (Step 1h).

If the script exits non-zero, run python3 ~/.claude/scripts/lazy-state.py --run-end (idempotent — safe even if the marker is absent), surface the error, push a PushNotification, print the final batch report (see Step 2), and STOP.

Parse the JSON output. Extract: feature_id, feature_name, spec_path, current_step, sub_skill, sub_skill_args, terminal_reason, notify_message, diagnostics.

1b. Handle terminal states

If terminal_reason is set:

  • blocked: see Step 1h (blocked-resolution mode). Not a terminal halt anymore — and most blockers no longer ask. Step 1h FIRST applies the research-blocked carve-out (component step 1a-research): a blocked feature carrying a co-located live NEEDS_RESEARCH.md + RESEARCH_PROMPT.md with RESEARCH.md absent is a research gap, NOT a product fork — route it to Step 4 (Research Halt) and surface the research prompt for the operator to paste into Gemini; do NOT run the blocked-resolution AskUserQuestion and do NOT add-a-phase (a research gap is filled by Gemini research, not a corrective phase). Otherwise Step 1h classifies the blocker per completeness-policy.md §3: a sequencing-only blocker (every resolution path converges on the same product behavior) is auto-resolved — add-phase + fix now, or /spec-bug / ad-hoc spin-off + dependency-gate + requeue-to-tail — logged + push-notified, no question. Only a genuine product fork takes the operator path: re-print the BLOCKED.md body verbatim, run AskUserQuestion for the resolution path (add a phase / defer to queue tail / halt-for-manual / custom), record the choice, dispatch the Opus apply-resolution subagent to enact it (neutralizing BLOCKED.md via rename), and return to Step 1a. The loop continues; do NOT print the final batch report — UNLESS the operator chooses "Halt for manual fix", which keeps BLOCKED.md untouched and STOPs (the legacy behavior, now one option among several). Park-mode exception (park_mode == true): this terminal is NOT reached for a feature-local block — the --park-blocked probe flag (Step 1a) parks the blocked feature into parked[] and advances the queue, so Step 1h does NOT fire for it; the block is deferred to the Step 1g-flush (which re-prints the BLOCKED.md body and runs the SAME resolution affordance at run-end). Per SPEC D5, this includes escalation/mcp-validation per-feature blocks (validation_escalation, blocker_kind: mcp-validation) — park mode defers everything parkable. Only the global/environment terminals below (queue-missing, device-queue-exhausted, etc.) still halt mid-run.
  • needs-input: see Step 1g (decision-resume mode). Not a terminal state for the orchestrator anymore. Step 1g first auto-resolves any scope-class decisions per completeness-policy.md (D7 — step 1b of the component, both modes, never asked); for the remaining product-class decisions it re-prints the rich ## Decision Context, runs AskUserQuestion, appends ## Resolution, dispatches the Sonnet apply-resolution subagent (which edits SPEC.md / PHASES.md and neutralizes the sentinel), and returns to Step 1a. The loop continues; do NOT print the final batch report.
  • needs-research: see Step 4 (research halt). Behavior depends on allow_research_skip:
    • Default (allow_research_skip == false): Step 4 writes NEEDS_RESEARCH.md, prints the inline-prompt halt announcement, PushNotifications, prints the final batch report, and STOPs. The orchestrator does NOT advance past the research-pending feature — this is critical for ordered queues where downstream features depend on upstream work.
    • Opt-in (allow_research_skip == true): legacy batching behavior — Step 4 writes NEEDS_RESEARCH.md, adds feature_id to research_pending, DOES NOT increment either counter, flips skip_needs_research = true, and returns to Step 1a so the next state-script call passes --skip-needs-research and either advances to a ready feature or returns queue-blocked-on-research.
  • queue-blocked-on-research: see Step 1f (research-wait mode). Only reachable when allow_research_skip == true — in the default path Step 4 halts before this terminal can fire.
  • needs-spec-input: see Step 1i (operator-directed halt-resolution) — the orchestrator re-prints what the dir contains and AskUserQuestions the path (provide spec direction → seed the baseline / defer & continue queue / halt). It no longer bare-STOPs "cannot start from nothing".
  • queue-missing: Run --run-end, then PushNotification with notify_message, print final batch report, STOP. (There is no queue to continue — the operator must create queue.json first; NOT routed to Step 1i per the halt-resolution component's exclusion list.)
  • completion-unverified: a feature's SPEC/ROADMAP claims Complete but no COMPLETED.md receipt exists — it was flipped OUTSIDE the validation gate (a cycle subagent or hand edit bypassing /mcp-test + the coverage audit). See Step 1i (operator-directed halt-resolution): re-print the gap and AskUserQuestion the path — reopen & re-validate (**Status:** In-progress → let the pipeline re-run MCP validation) / grandfather the receipt (lazy-state.py --backfill-receipts, only if genuinely validated before the gate) / defer & continue / halt. Do NOT auto-flip, auto-reopen, or auto-backfill — that judgment is the operator's, now surfaced as a choice rather than a bare halt. (This is the terminal that makes failure mode 1 self-announcing instead of silent.)
  • stale_upstream: an upstream feature/work-item this feature was materialized from changed since materialize. See Step 1i (operator-directed halt-resolution): re-print the gap and AskUserQuestion the path (re-materialize/absorb → re-run materialize or /realign-spec / reject the change / defer & continue / halt). lazy-state.py emits this (Step 2.9); do NOT auto-resolve.
  • all-features-complete: a SINGLE-TYPE queue-exhausted terminal — the FEATURE side is done, but the unified driver must NOT stop the whole run if the BUG side still has actionable work. Apply the option-(b) unified-driver fallthrough (see the box below) FIRST: probe the OTHER type (bug-state.py); only if it too is exhausted do you stop. When BOTH types are exhausted: Run --run-end, then PushNotification "ALL FEATURES COMPLETE — roadmap finished after {forward_cycles} forward + {meta_cycles} meta /lazy-batch cycle(s).", print final batch report, STOP.
  • queue-exhausted-all-parked (--park mode only): the queue advanced past every workable feature and every remaining feature is parked (blocked and/or needs-input). This is an HONEST distinct terminal — NOT all-features-complete (the roadmap is not finished). FIRST fire the Step 1g-flush (triggers (b)/(c)) so every parked item — needs-input AND blocked (sentinel_kind) — is surfaced and resolved at run-end; THEN run python3 ~/.claude/scripts/lazy-state.py --run-end, PushNotification "Queue exhausted — {parked_count} feature(s) parked (blocked/needs-input); surfaced at flush.", print final batch report, STOP. Do NOT report success.
  • queue-exhausted-budget-deferred: All remaining queue items were deferred/evicted to the queue tail by the budget guard (no independent successor exists to skip-ahead to). This is NOT all-features-complete — the roadmap is not finished; features were over-budget, not done. Fire the budget-guard trip PushNotification (§1c.6 point 5) for the triggering feature, then run python3 ~/.claude/scripts/lazy-state.py --run-end, then PushNotification "lazy-batch halted — queue exhausted by budget guard; {N} feature(s) deferred to queue tail. Re-invoke /lazy-batch to continue.", print final batch report, STOP. On the next /lazy-batch invocation the deferred features reappear at the queue tail with fresh cycle counts.
  • cloud-queue-exhausted: Unreachable for /lazy-batch (workstation variant); treat as all-features-complete defensively — run python3 ~/.claude/scripts/lazy-state.py --run-end first, then PushNotification, print final batch report, STOP.
  • all-bugs-fixed: the SINGLE-TYPE bug-side queue-exhausted terminal (reachable for the unified driver when the merged head is type == bug). Symmetric to all-features-complete: apply the option-(b) unified-driver fallthrough (box below) FIRST — probe the OTHER type (lazy-state.py); only when BOTH types are exhausted: Run --run-end, then PushNotification "ALL BUGS FIXED — bug queue cleared after {forward_cycles} forward + {meta_cycles} meta /lazy-batch cycle(s).", print final batch report, STOP.
  • device-queue-exhausted: Reachable only on a no-real-device workstation (WSL2/CI, where the audio backend is the HeadlessPumpDriver). Every remaining feature carries DEFERRED_REQUIRES_DEVICE.md (real-device-only MCP assertions that cannot be certified here). Run --run-end, then PushNotification with notify_message, print final batch report, STOP. The honest resume is a real-device host: tell the user to set ALGOBOOTH_REAL_AUDIO_DEVICE=1 (or run on native hardware) and re-run /lazy-batch — there the same features RE-OPEN (Step 9 dispatches /mcp-test scoped to the deferred scenario IDs as ordinary cycles) and complete. This is the device-axis mirror of cloud-queue-exhausted. Note: the re-open dispatch itself needs no special handling — on a real-device host the state script emits sub_skill: mcp-test for the deferred scenarios, which runs as a normal cycle.
  • host-capability-saturated: The host-capability-axis generalization of device-queue-exhausted (host-capability-declaration-for-gated-features). Every remaining feature declares a requires_host: capability (a binary toolchain, GPU, etc.) that is absent on THIS host — each carries DEFERRED_REQUIRES_HOST.md (the missing capability ids) and no VALIDATED.md. Run --run-end, then PushNotification with notify_message (the script names each feature + its missing capability id(s): host-capability miss — <feature-id> requires <cap-id> (absent on this host); deferred to capability-host), print final batch report, STOP. The honest resume is a capability-bearing host: run /lazy-batch on a machine where the probe reports the capability present — there the same features RE-OPEN (an empty missing set is the no-special-case re-open; Step 9 dispatches /mcp-test as an ordinary cycle) and complete. The deferred features were NOT dropped or waived — they remain in the queue, re-openable. The host_deferred_features probe key surfaces each deferred feature_id for the end-of-run flush. The re-open dispatch needs no special handling — on a capability-host the state script simply does not skip the feature.

Option-(b) unified-driver fallthrough (single-type queue-exhausted terminal; item 3, lazy-batch-unified-driver-parity-and-accounting Phase 2). lazy-state.py --next-merged is PURE ORDERING — it returns the merged head WITHOUT re-inferring per-item state, so a resolved (already-Complete / already-Fixed) item can sit at the merged head and a single-type queue-exhausted terminal can fire while the OTHER type still has an actionable item (the on-disk-bug masking that motivated this fix). DO NOT stop the whole run on a single-type terminal. When this cycle's terminal is all-features-complete (the head was type == feature) OR all-bugs-fixed (the head was type == bug), the driver FALLS THROUGH to probe the OTHER type before declaring the run done:

  1. The terminal was all-features-complete → probe the BUG side: python3 ~/.claude/scripts/bug-state.py --repo-root {cwd} (which loads docs/bugs/queue.json AND on-disk bugs via load_bug_queue / _find_open_bug_dirs). If it returns an actionable bug (NOT all-bugs-fixed / queue-missing), DO NOT stop — start the next cycle on that bug (drive bug-state.py, terminal __mark_fixed__) and continue the loop.
  2. The terminal was all-bugs-fixed → probe the FEATURE side: python3 ~/.claude/scripts/lazy-state.py --repo-root {cwd}. If it returns an actionable feature (NOT all-features-complete / queue-missing), DO NOT stop — start the next cycle on that feature and continue the loop.
  3. Only when the OTHER type ALSO returns its own queue-exhausted terminal (or queue-missing) is the whole run genuinely terminal — THEN run --run-end + PushNotification + final report + STOP, per the matching bullet above.

This keeps --next-merged / merged_worklist PURE (no per-item state inference leaks into them — the documented "ordering-only" contract in user/scripts/CLAUDE.md); the fallthrough lives ENTIRELY in this driver loop. Features stay strictly queue.json-driven — the fallthrough adds NO on-disk feature fallback (out of scope per SPEC Verified Symptom 4); only the bug side honors on-disk pickup, exactly as /lazy-bug-batch does.

1c. Check the max-cycles cap

If forward_cycles >= max_cycles:

python3 ~/.claude/scripts/lazy-state.py --run-end
PushNotification({ message: "lazy-batch hit max-cycles ({max_cycles}). Restart from a fresh session to continue." })

Print final batch report, STOP. Do NOT try to renew the cap automatically — the cap exists to bound runaway costs.

1c.6. PushNotification policy (park / halt / flush / run-end)

The orchestrator fires PushNotification at exactly four canonical event points so the operator receives a phone notification whenever the run changes state. PushNotification is always called by the orchestrator — state scripts never call it.

  1. park (--park mode only) — fired once per newly-parked item when park_mode == true and the probe returns a non-empty parked[] array (the script's queue-walk park skip; parked[] arrives on ordinary Step 1a probes and lists ALL currently-parked items, not just new ones). Dedup rule: maintain an in-session set of already-notified parked ids; on each probe, fire only for ids in parked[] that are NOT yet in the set, then add them. Never re-fire for an id already in the set. (After a compaction boundary the set may be lost — one duplicate notification per item after a compact is acceptable; re-seed the set from the current parked[] on the first post-compact probe without firing.) Wording branches on the entry's sentinel_kind: for a needs-input park, the message carries the running parked-count: "parked {feature_name} — {N} decision(s) parked so far this run", and the T5 chat line is ⏸ parked {feature_name} — {N} decision(s) · notified ({parked_count} parked this run). For a blocked park (sentinel_kind == "blocked", decision_count == 0), it reads as a parked BLOCK with the blocker's phase: message "parked {feature_name} — BLOCKED ({phase}); deferred to flush ({parked_count} parked this run)", T5 chat line ⏸ parked {feature_name} — BLOCKED ({phase}) · notified ({parked_count} parked this run) (read {phase} from the parked entry / the BLOCKED.md frontmatter). Both branches are governed by the SAME dedup set (fire once per newly-parked id; never re-fire; re-seed silently after a compaction boundary).

  2. halt (both modes) — fired on every terminal/halt: NEEDS_INPUT halt, BLOCKED halt-for-manual, needs-research strict halt, queue-blocked-on-research, queue-missing, all-features-complete, queue-exhausted-all-parked (--park mode — after the flush), queue-exhausted-budget-deferred (budget-guard — all items deferred to queue tail), max-cycles, device-queue-exhausted, host-capability-saturated, script-error, and any future obstacle terminal. Most of these already carry per-terminal PushNotification calls above — this point names the policy explicitly so no terminal can be added without a notification.

    --run-end is MANDATORY before EVERY terminal/halt PushNotification. On every path listed above, call python3 ~/.claude/scripts/lazy-state.py --run-end BEFORE the PushNotification fires. --run-end deletes the run marker AND the prompt registry (all run-scoped enforcement state). A missed deletion is self-healing (24h staleness + session-id mismatch cleanup) but is a protocol violation the retro grades. The call is idempotent — if the marker is already absent (e.g. --run-start failed earlier), --run-end exits cleanly.

    Dev-runtime teardown is MANDATORY on run-end (ISSUE 4 — d8-effect-chains run, 2026-06-14). The orchestrator OWNS the dev runtime it pre-booted in Step 1d.0 (npm run dev:restart), so it MUST tear it down when the run ends — otherwise the runtime (Vite 1420 + MCP 3333 + sidecar + Tauri binary) leaks across runs (a stray dev process was left running after the d8 run). On EVERY terminal/halt path, AFTER --run-end and BEFORE the PushNotification, run the full kill in the orchestrator session:

    npm run dev:kill   # workstation only; no-op-safe if nothing is running
    

    dev:kill (scripts/kill-dev.js) is the only reliable full teardown — it kills Vite, the MCP server, named-pipe-surviving sidecar processes, and orphaned Tauri binaries. Run it UNCONDITIONALLY on workstation runs (it is safe even if the runtime was never booted — e.g. an all-not-required queue). It is N/A for /lazy-batch-cloud (no desktop runtime is ever booted). The mcp-test cycle subagent does NOT kill the orchestrator-owned runtime mid-run (it may be reused next cycle); teardown is the orchestrator's responsibility at run boundary — see mcp-test SKILL.md Step 7.

    --terminal-reason <reason> (SHOULD — deprecated to omit). When ending a run on a genuine terminal (not an operator-authorized checkpoint), pass --run-end --reason terminal --terminal-reason <reason> where <reason> is one of the sanctioned set: all-features-complete, all-bugs-fixed, max-cycles, cloud-queue-exhausted, device-queue-exhausted, host-capability-saturated, queue-missing, blocked-halt-for-manual, needs-research, queue-blocked-on-research. The script validates <reason> against lazy_core.SANCTIONED_STOP_TERMINAL — an unsanctioned reason requires --operator-authorized or the call is refused (exit 1, marker kept). Omitting --terminal-reason is back-compat (the script infers terminal if --reason is absent) but is deprecated; include it for stop-authorization validation and retro auditability. (Phase 7 / lazy-validation-readiness.)

  3. flush (--park mode only) — fired when parked decisions are collected and sent to the operator via the batched AskUserQuestion (the WU-4 flush protocol). The notification signals that the operator's input is being requested. Message: "lazy-batch flush — {N} parked decision(s) ready for your input".

  4. run-end (both modes) — fired when the run terminates and the final batch report is printed. This point largely coincides with the terminal halts above; stating it as a named point ensures every run termination path fires a notification, even if a new exit path is added that does not fit one of the named terminal reasons.

  5. budget-guard trip (both modes, when budget guard fires mid-cycle) — fired ONCE per feature that the budget guard defers/evicts (the budget_guard probe field is non-null in the cycle's probe output). The orchestrator reads the budget_guard field from the probe JSON and fires:

    PushNotification({ message: "feature-budget-guard tripped — {budget_guard.feature_id} deferred to queue tail after {budget_guard.count_at_trip} cycles (computed ceiling {budget_guard.computed_ceiling}); advancing to {budget_guard.next_id}" })
    

    This is distinct from a terminal notification — the run CONTINUES (the guard defers the over-budget feature and advances to the next independent item, if one exists). A trip notification fires in-cycle, not at halt. If the budget guard trips AND the resulting terminal is queue-exhausted-budget-deferred (all remaining items are budget-deferred with no independent successor), the trip notification fires first, then the halt notification fires (point 2 above).

Per-cycle LAZY_QUEUE.md regen (mobile-queue-control, claude-config + AlgoBooth). At each per-cycle commit point (the pseudo-skill commit below, and the recovery-cycle commit at Step 1d that stages residue), BEFORE the git add -A that stages the cycle's changes, run python user/scripts/lazy-queue-doc.py --repo-root <repo_root> so the regenerated root-level LAZY_QUEUE.md (the GitHub-mobile-readable queue status doc) is staged by the existing git add -A and rides the cycle's commit on main. The generator is a PURE read over on-disk lazy state via pipeline_visualizer.probe.probe_state — it embeds NO wall-clock, so an unchanged-state regen is byte-identical and adds nothing to the commit (no spurious diff). It is orchestrator-invoked only — NEVER called from the lazy-state.py / bug-state.py compute path (this keeps the "pure read, never writes during a probe" / one-writer discipline; no state-machine change). For AlgoBooth, the equivalent invocation lands in that repo's /lazy-batch(-cloud) cycle commit (a one-line --repo-root-addressable add — the generator itself needs no AlgoBooth-side change; cross-repo wiring is documented for the operator, not authored from this repo's tree).

1c.5. Inline pseudo-skill handling (NO subagent dispatch)

If sub_skill starts with __ (double-underscore), it is a pseudo-skill — a small sentinel-file write + commit, NOT a real skill that performs implementation work. Perform the action inline (orchestrator session) instead of dispatching a subagent. This is the spirit-preserving relaxation of HARD CONSTRAINT 1: sentinel files are documentation, and dispatching an Opus subagent to write a 10-line YAML block + run git commit wastes a full subagent's worth of context.

Follow ~/.claude/skills/lazy/SKILL.md Step 3's protocol for each pseudo-skill exactly (the wrapper and orchestrator do the same thing here):

  • __grant_skip_no_mcp_surface__ — emitted at Step 9 (workstation only) when the feature's PHASES declares **MCP runtime:** not-required AND the repo has no app surface (no src-tauri/, no package.json). Run python3 ~/.claude/scripts/lazy-state.py --apply-pseudo __grant_skip_no_mcp_surface__ <spec_path> (the script is the single author of the SKIP_MCP_TEST.md write — granted_by: pipeline-structural, re-verified by skip_waiver_refusal; idempotent; refuses if the repo has an app surface or PHASES is not not-required), then commit + push per policy. This is the structural short-circuit that avoids dispatching a wasted /mcp-test Opus cycle whose only job would be to confirm there is nothing to test. The next probe routes to __write_validated_from_skip__. Pipeline-advancing → forward_cycles.
  • __write_validated_from_skip__ — run python3 ~/.claude/scripts/lazy-state.py --apply-pseudo __write_validated_from_skip__ <spec_path> (the script is the single author of the VALIDATED.md write — it reads SKIP_MCP_TEST.md, writes VALIDATED.md, and is idempotent), then commit + push per policy.
  • __write_validated_from_results__ — run python3 ~/.claude/scripts/lazy-state.py --apply-pseudo __write_validated_from_results__ <spec_path> (the script reads MCP_TEST_RESULTS.md, writes VALIDATED.md with the extracted scenarios, and is idempotent), then commit + push per policy. The script is the SINGLE author of VALIDATED.md — hand-writing it is the one remaining integrity side-door and is banned (the 2026-06-11 run's endgame bypass). The apply is gated: it refuses (refused:<reason>, zero writes, exit 1) on missing/wrong-kind MCP_TEST_RESULTS.md, on resultall-passing, on pass_count != total_count, and on a validated_commit that doesn't match HEAD (stale results). On a refusal, do NOT retry blindly and do NOT hand-write the sentinel — the refusal names expected vs found; the honest route is a fresh /mcp-test cycle that produces genuinely passing, fresh results (the refusal on stale/partial results IS the pipeline working).
  • __mark_complete__gated by TWO inline docs-only gates, in order, BEFORE the flip runs. Gate 1 — MCP-coverage audit via the deterministic --gate-coverage subcommand (unified-pipeline-orchestrator Phase 5): run python3 ~/.claude/scripts/lazy-state.py --gate-coverage <spec_path> — it reads SPEC.md's ## Locked Decisions / ## Resolved by Research / numbered key-decisions surface and greps each <spec_path>/mcp-tests/*.md (RESOLVING symlink/64-byte-pointer targets — the Windows blindspot) for each decision's id + keywords, returning JSON {ok, decisions, uncovered:[id], scenario_count} (exit 1 iff uncovered[] is non-empty). The algorithm spec + the D7 routing live in ~/.claude/skills/_components/mcp-coverage-audit.md. If uncovered[] is non-empty, follow the component's D7 outcome (completeness-policy.md §4 — Gate 1 never asks, no NEEDS_INPUT.md): documented-MCP-untestable decisions get an inline SPEC test-exempt note (a docs-level __mark_complete__ edit — HARD CONSTRAINT 1 holds); the rest route to a corrective coverage cycle — dispatch a cycle subagent to author the mcp-tests/ scenario(s) and run them (meta cycle), emit the ⚖ policy: line(s) + D7-digest entries, then on Gate-1 halt: append {forward_cycles + meta_cycles + 1, feature_name, "__mark_complete__ (gate 1 halted)", "{N} uncovered → corrective coverage cycle"} to cycle_log, increment forward_cycles (gate-halted mark-complete is still a forward-advancing attempt), and return to Step 1a — the next mark-complete attempt re-audits clean. Gate 2 — completion-integrity gate per the shared ~/.claude/skills/_components/completion-integrity-gate.md component (runs ONLY after gate 1 returns clean): verify phase-coherence (zero non-verification unchecked deliverables in PHASES.md) and that a validation sentinel (VALIDATED.md, or SKIP_MCP_TEST.md; workstation does NOT accept a bare DEFERRED_NON_CLOUD.md) exists. (RETRO_DONE.md is NO LONGER required here — retro is unwired, 2026-06.) If a precondition fails, the orchestrator writes <spec_path>/NEEDS_INPUT.md (written_by: completion-integrity-gate) describing the gap and commits it; append {forward_cycles + meta_cycles + 1, feature_name, "__mark_complete__ (gate 2 halted)", "<reason> → NEEDS_INPUT.md"} to cycle_log, increment forward_cycles, return to Step 1a — the next state-script call returns terminal_reason: needs-input and Step 1g handles it before the next mark-complete attempt (Gate 2's integrity gaps are NOT scope decisions; they keep the needs-input path). Only when BOTH gates pass does the orchestrator proceed: run python3 ~/.claude/scripts/lazy-state.py --apply-pseudo __mark_complete__ <spec_path> — the script is the single author of COMPLETED.md (kind: completed, provenance: gated, folding the validation evidence from VALIDATED.md/MCP_TEST_RESULTS.md into the receipt body — the durable proof lazy-state.py Step 2 keys on), the SPEC.md/PHASES.md **Status:** Complete flip, the deletion of the consumed VALIDATED.md/RETRO_DONE.md/DEFERRED_NON_CLOUD.md sentinels (COMPLETED.md/SKIP_MCP_TEST.md/MCP_TEST_RESULTS.md are kept), the docs/features/queue.json trim (now by RESOLVED spec_dir, killing the -followups queue.no-completed class), AND the docs/features/ROADMAP.md strikethrough (moved INTO --apply-pseudo as of unified-pipeline-orchestrator Phase 5 — the orchestrator no longer hand-strikes the ROADMAP row; the subcommand returns roadmap_struck/queue_trimmed). Mechanical third gate: --apply-pseudo itself ALSO enforces per-phase coherence before writing — it auto-flips all-ticked phases to Complete and REFUSES (refused:<reason>, zero writes) if any phase retains an unchecked box (verification rows included) or a non-Complete/Superseded Status. On ok: false + this refusal, do NOT retry blindly — route a corrective coherence cycle. Emit the dispatch via the script (registry-registered, guard allows it):
python3 ~/.claude/scripts/lazy-state.py \
  --emit-dispatch coherence-recovery \
  --context item_name="{feature_name}" \
  --context spec_path="{spec_path}" \
  --context gate_output="<the --apply-pseudo refusal reason string>" \
  --context item_id="{feature_id}" \
  --context cwd="{cwd}"

Dispatch dispatch_prompt VERBATIM using dispatch_model. The @requires keys for --emit-dispatch coherence-recovery are: item_name, spec_path, gate_output, item_id, cwd. The subagent reconciles PHASES.md honestly (tick-with-evidence or re-scope, never blind-tick) then returns to Step 1a. Exactly as a Gate-1 halt routes. The ROADMAP strikethrough is NO LONGER an orchestrator step — --apply-pseudo __mark_complete__ strikes the docs/features/ROADMAP.md row itself (unified-pipeline-orchestrator Phase 5; returns roadmap_struck). Then commit + push per project policy. See the component files for the full gate algorithms. Both gates are docs-only (read SPEC.md / PHASES.md / mcp-tests/*.md / sentinels, no Tauri / no MCP server) — they run identically in workstation and cloud.

  • __flip_plan_complete_cloud_saturated__ — emitted only by lazy-state.py --cloud at Step 7a when an In-progress plan's only unchecked WUs (scoped to the plan's phases: field) are documented in <spec_path>/DEFERRED_NON_CLOUD.md as workstation-only. sub_skill_args is the absolute plan-file path. Run python3 ~/.claude/scripts/lazy-state.py --apply-pseudo __flip_plan_complete_cloud_saturated__ <spec_path> --plan <plan_file_path> (the script edits only the status: line in the plan frontmatter → Complete, is idempotent, and does NOT touch SPEC.md, ROADMAP.md, or any sentinel). Derive the plan part number from the plan's phases: field for the commit message (e.g. phases: [6] → part 6; fall back to the plan filename's leading part-N / phase-N token). Commit per project policy with message chore(<feature_id>): mark plan part N Complete (cloud-saturated), then push. This is a forward cycle — increment forward_cycles.

  • __flip_plan_complete_stale__ — emitted by lazy-state.py at Step 7a (in both cloud and workstation mode) when EVERY work-unit a Ready/In-progress plan references is already [x] — the plan is stale/already-applied but the frontmatter status: was never flipped. sub_skill_args is the absolute plan-file path. Action (stays inline — --apply-pseudo does NOT implement stale): read the plan's YAML frontmatter, edit ONLY the status: line in place (Ready or In-progressComplete) — leave every other field and the markdown body untouched. Derive the plan part number from the plan's phases: field; fall back to the plan filename's leading part-N / phase-N token if phases: is missing. Stage the plan file and commit per project policy with message chore(<feature_id>): mark plan part N Complete (stale — already applied). Do NOT touch SPEC.md, ROADMAP.md, or any other sentinel. Distinction from __flip_plan_complete_cloud_saturated__: stale fires in BOTH cloud and workstation (it is not cloud-only) and means every WU was already [x] — not deferred to workstation, genuinely done. Without this flip the Step 7a: execute plan probe would return an In-progress plan with all WUs done, the orchestrator would dispatch /execute-plan against it, the subagent would find no work, make no commit, and the next cycle would return the same state — a no-op loop. This is a meta cycle — increment meta_cycles (flipping a stale plan is cleanup, not forward implementation work).

  • __mark_fixed__ (the type == bug terminal — unified driver; item 2, lazy-batch-unified-driver-parity-and-accounting Phase 3) — gated by the SAME TWO inline docs-only gates as __mark_complete__, in order, BEFORE the archive runs. This block is the bug-pipeline twin of __mark_complete__ above and mirrors /lazy-bug-batch Step 1c.5's __mark_fixed__ block VERBATIM — drive it with bug-state.py (NOT lazy-state.py) for a type == bug cycle.

    Gate 1 — MCP-coverage audit per ~/.claude/skills/_components/mcp-coverage-audit.md (or the deterministic python3 ~/.claude/scripts/bug-state.py --gate-coverage <spec_path> equivalent). Run with {spec_path} and {bug_id}. If uncovered:N, follow its D7 outcome (completeness-policy.md §4 — Gate 1 never asks, no NEEDS_INPUT.md): documented-MCP-untestable decisions get an inline SPEC test-exempt note; the rest route to a corrective coverage cycle (dispatch a cycle subagent to author the mcp-tests/ scenario(s) + run them — meta cycle), with ⚖ policy: line(s) + D7-digest entries. Do NOT run the archive steps. Append to cycle_log {forward_cycles + meta_cycles + 1, bug_name, "__mark_fixed__ (gate 1 halted)", "{N} uncovered → corrective coverage cycle"}, increment forward_cycles (gate-halted mark-fixed is still a forward-advancing attempt), return to Step 1a — the next mark-fixed attempt re-audits clean.

    Gate 2 — completion-integrity gate per ~/.claude/skills/_components/completion-integrity-gate.md (runs ONLY after gate 1 returns clean). Adapted for bugs: kind: fixed, filename: FIXED.md. If a precondition fails, write {spec_path}/NEEDS_INPUT.md (written_by: completion-integrity-gate), commit it, and return refused:<reason> — same halt-cycle-and-surface-via-Step-1g pattern as gate 1 (Gate 2's integrity gaps keep the needs-input path).

    Only when BOTH gates pass: run python3 ~/.claude/scripts/bug-state.py --apply-pseudo __mark_fixed__ {spec_path} — the script is the single author of the FIXED.md receipt (kind: fixed, provenance: gated, folding validation evidence from VALIDATED.md / MCP_TEST_RESULTS.md into the receipt body), the SPEC.md/PHASES.md **Status:** Fixed flip, and the deletion of the consumed VALIDATED.md / RETRO_DONE.md (if stale) / DEFERRED_NON_CLOUD.md sentinels (FIXED.md / SKIP_MCP_TEST.md / MCP_TEST_RESULTS.md are kept). Mechanical third gate inside --apply-pseudo __mark_fixed__: the script auto-flips all-ticked phases to Complete and REFUSES (refused:<reason>, zero writes) if any phase retains an unchecked box (verification rows included) or a non-Complete/Superseded Status. On ok: false + this refusal, do NOT retry blindly — route a corrective coherence cycle via python3 ~/.claude/scripts/bug-state.py --emit-dispatch coherence-recovery --context item_name="{bug_name}" --context spec_path="{spec_path}" --context gate_output="<the --apply-pseudo refusal reason string>" --context item_id="{bug_id}" --context cwd="{cwd}" and dispatch dispatch_prompt VERBATIM. The orchestrator NEVER hand-writes the receipt, the status flip, or the sentinel deletions.

    After the script returns, the orchestrator runs ONE more script call — the archive mechanics are also script-owned per ~/.claude/skills/_components/mark-fixed-archive.md: python3 ~/.claude/scripts/bug-state.py --repo-root {repo_root} --archive-fixed {spec_path} (SPEC evidence header lines, staged-deletion-coherent git mv to docs/bugs/_archive/ with Windows-lock retry, tracked-only inbound-reference repoint, docs/bugs/queue.json trim, atomic commit — then push the commit it created). The orchestrator performs ZERO hand edits for the archive; on ok: false it writes {spec_path}/BLOCKED.md (blocker_kind: archive-failure) quoting the script's refused diagnostic verbatim (sentinel-scope — within HARD CONSTRAINT 1). The call is idempotent and resume-safe — a PARTIAL STATE diagnostic means re-run, never hand-unwind. Pipeline-advancing → forward_cycles.

After the inline action:

  1. Append to cycle_log: {forward_cycles + meta_cycles, feature_name, sub_skill, "inline: <one-line summary>"} (use the UPDATED total after the increment in step 5 below, i.e. the N-th total action completed this invocation).
  2. Push backstop (guardrail C — mirrored from /lazy-batch-cloud). The inline pseudo-skill committed a sentinel / plan-frontmatter change locally; push it now — git push origin $(git rev-parse --abbrev-ref HEAD) (retry up to 4× with exponential backoff 2s/4s/8s/16s on network error; WORK BRANCH only, never main, never force). This backstops inline cycles the orchestrator owns directly — a git push of an already-committed change, NOT a Write/Edit, so HARD CONSTRAINT 1 still holds. "Up to date" is a fine result (a prior cycle's push already carried it).
  3. Emit the T4 inline pseudo-skill block (Step 3 / orchestrator-voice.md): the canonical step heading (### {Step name} — {work summary} [x/y]), an act line ({sub_skill} → {feature_id}), a gates line when gates ran (__mark_complete__), a done line (inline outcome), and a next line. Nothing else. A gate REFUSAL switches to T6-refusal (rich) — the refusal evidence and the NEEDS_INPUT routing deserve full detail.
  4. Update prev_cycle_signature = (feature_id, sub_skill, sub_skill_args, current_step) (same uniform post-cycle update as Step 1e — keeps loop-guard accurate across mixed pseudo-skill / real-skill cycles).
  5. Increment the appropriate counter: forward_cycles for pipeline-advancing pseudo-skills (__mark_complete__, __mark_fixed__, __write_deferred_non_cloud__ (cloud variant only — workstation lazy-state.py never emits this), __write_validated_from_results__, __write_validated_from_skip__, __grant_skip_no_mcp_surface__, __flip_plan_complete_cloud_saturated__); meta_cycles for cleanup pseudo-skills (__flip_plan_complete_stale__). Return to Step 1a — DO NOT fall through to Step 1d.

This saves one Opus dispatch per pseudo-skill action. On a typical feature lifecycle (workstation: 1 × __write_validated_* + 1 × __mark_complete__ = 2 dispatches reclaimed; cloud: 1 × __write_deferred_non_cloud__ minimum) the savings compound across a multi-feature queue pass.

1d. Compose and dispatch the cycle subagent (REAL SKILLS ONLY)

Compaction discipline — re-read the dispatch template AND the output contract first. Before composing this dispatch — and ALWAYS as the first action after any compaction boundary — re-read ~/.claude/skills/_components/lazy-dispatch-template.md, ~/.claude/skills/_components/orchestrator-voice.md (the chat-output contract — its turn templates survive summarization by re-read, not by memory; the re-reads themselves are silent mechanics), AND ~/.claude/skills/_components/completeness-policy.md (the D7 standing policy — its auto-resolve rules likewise survive compaction by re-read, not memory). The dispatch template is the on-disk canonical dispatch skeleton (subagent_type, the REQUIRED model: field, prompt envelope) and carries the Read-before-Edit rule: compaction resets read-state, so re-Read any file (PHASES.md, plans, SKILLs, components) before you Edit/Write it. The prompt contents are NOT reconstructed by hand — they arrive pre-bound from the probe's cycle_prompt (the --emit-prompt field); the template governs only the dispatch ENVELOPE (which fields, which model). 41% of post-compaction spawns in the 2026-06-10 audit dropped the model: field — re-reading this template before each dispatch, and copying cycle_model into it rather than reconstructing prompts from memory, is what prevents that.

Post-compaction re-entry protocol (HARD — the first post-compaction action is NEVER a dispatch). Compaction is the measured protocol cliff (2026-06-11 run: after the compaction boundary the counters never recovered, probes stopped entirely, and prompts went hand-authored for the rest of the run). On the first turn after any compaction boundary, BEFORE any Agent call: (1) re-read Step 1a of this SKILL plus the three components named above; (2) the session counters (forward_cycles, meta_cycles) are persisted in the run marker — the post-compaction probe reads them from the marker directly, so no manual reconstruction is needed. As a cross-check, verify the surviving T1/T2/T4 context broadly agrees with the marker's counters; if there is a discrepancy, trust the marker (it is the script-owned source of truth) and record any notable divergence in a single T6 line; (3) run the FULL Step 1a probe form (--repeat-count --emit-prompt --probe --max-cycles …) — note: --forward-cycles/--meta-cycles are NOT passed (the marker owns the counters); proceed only from its output. Dispatching from a pre-compaction probe held in memory, or from a hand-reconstructed prompt, is a contract violation.

Governing-file reload discipline (self-edit mode — C8). When the Step 1a probe reports self_edit_mode: true, this /lazy-batch run is editing the very harness it executes from, so a cycle that commits to the orchestrator's own in-context governing prose makes the copy you hold stale. After EVERY cycle, intersect the cycle's commit (git diff --name-only, or read the probe's governing_files_touched list — the script computes the same intersection for you) with the governing-file set — the files the orchestrator holds in-context and does NOT get for free from a fresh subprocess / disk-read:

  • user/skills/lazy-batch/SKILL.md (+ the user/skills/lazy-bug-batch/SKILL.md and repos/algobooth/.claude/skills/lazy-batch-cloud/SKILL.md twins for those orchestrators)
  • user/skills/_components/orchestrator-voice.md, user/skills/_components/completeness-policy.md, user/skills/_components/lazy-dispatch-template.md

For ANY hit, re-Read that file via its ~/.claude/... path BEFORE composing the next dispatch. This is the SAME re-read as the compaction discipline above — triggered by a self-edit commit instead of a compaction boundary — and the governing-file set MUST stay in lockstep with that compaction re-read list (if the compaction list grows, this set grows with it). The re-read is a silent mechanic (no chat narration).

Auto-refresh boundary (documented no-ops — MUST NOT be reloaded; they were never stale). These surfaces are ALREADY live on the next probe/dispatch and are EXCLUDED from the governing-file set by construction — never re-read them as part of the reload check: lazy_core.py / lazy-state.py / bug-state.py (a fresh python3 subprocess runs every probe); lazy-batch-prompts/cycle-base-prompt.md + its addenda + loop-block.md (re-read by emit_cycle_prompt from disk every probe); hook .sh bodies (bash ~/.claude/hooks/X.sh reads the file each invocation); and downstream skill prose (each dispatched subagent loads its skill fresh).

New-hook-registration restart surfacing (T6). If a cycle's commit added or REMOVED a hook ENTRY in settings.json (a new PreToolUse/PostToolUse wiring object — NOT merely an edit to an already-wired script body, which is an auto-refresh no-op above), surface a single T6 line: ⚠ settings.json hook wiring changed — restart the session to (de)register; the running session still uses the old wiring. Do NOT claim the change is live — hook registration is read at session start, so only a session restart re-registers it. Distinguish an ENTRY add/remove (restart-required) from a script-body edit (already live) explicitly.

Never hand-append to cycle_prompt. Repo-specific instructions (an audio-INVARIANTS gate, a project HARD requirement) live in <repo>/.claude/skill-config/cycle-prompt-addenda.md (same @section grammar as the base template) — the SCRIPT reads that file and appends the matching sections to cycle_prompt, token-bound and residue-checked. A live orchestrator hand-spliced the AlgoBooth audio gate onto the emitted prompt on 2026-06-11; that path is now closed — if a repo gate is missing, add a section to the addenda file, do not edit the dispatch.

Long-build ownership (harness-tracked). Any build or test that may exceed a single subagent turn is orchestrator-owned: start it with Bash run_in_background: true from this (the orchestrator) session and track it via the harness — NEVER background it from inside a dispatched cycle subagent, whose process tree is torn down when its turn ends (a tauri build backgrounded that way once silently vanished). Before committing to a 20–40 min packaged tauri build, run cargo check --release first to catch compile errors in minutes. Full rule: .claude/skill-config/long-build-ownership.md. This is Bash-only process ownership — it does not expand the orchestrator's sentinel-only Write/Edit scope (HARD CONSTRAINT 1 holds).

If Step 1c.5 did not handle this cycle (i.e. sub_skill is a real skill name, not __*__), build a minimal subagent prompt. The prompt instructs the subagent to invoke ONE skill in batch mode, commit, and report — nothing else.

1d.0. Pre-boot the dev runtime for /mcp-test cycles (WORKSTATION ONLY — runs BEFORE prompt composition)

Applies ONLY when sub_skill == "mcp-test". Skip this sub-step entirely for every other sub_skill. (This sub-step does not exist in /lazy-batch-cloud — cloud's Step 9 returns __write_deferred_non_cloud__, never mcp-test, so the cloud orchestrator never reaches it.)

Why this exists (the failure it fixes). The cycle subagent has NO Agent tool (HARD CONSTRAINT block above) and runs /mcp-test INLINE. The mcp-test SKILL.md Step 2 boots npm run tauri:dev as a background task, then Step 4 waits for readiness. Empirically, an inline cycle subagent that started a background build and then ENDED ITS TURN waiting on it produced a premature, resultless return: the background build process did NOT survive the subagent's turn boundary, and the orchestrator (SendMessage unavailable in this workstation environment) could not resume the dead subagent. Net: a validation cycle that wrote no result and no sentinel, burning the whole cycle. The structural fix is for the orchestrator's own session — which is long-lived and persists across subagent turns — to OWN the dev-runtime background process, so the runtime is already up and MCP-ready when the mcp-test subagent connects to it.

Procedure (orchestrator session, all Bash — NOT file edits):

  1. Plan-declared structural untestability — skip the boot entirely (routing only, NOT a waiver). Check the feature's PHASES.md for an **MCP runtime:** header line (authored by /spec-phases at decomposition time):

    grep -m1 '^\*\*MCP runtime:\*\*' "{spec_path}/PHASES.md"
    
    • Line says not-requiredskip steps 1–3 entirely (no probe, no dev:restart, no readiness block — the ~3–7.5 min boot is pure waste when the deliverable has no MCP surface). The mcp-test prompt VARIANT (runtime-up vs no-runtime, with {untestability_reason} bound) is chosen by the SCRIPT — emit_cycle_prompt reads the same PHASES.md **MCP runtime:** line and selects the matching section, so the orchestrator does NOT swap any prompt block by hand here; the script-assembled cycle_prompt already carries the correct variant. The skip AUTHORITY stays with the mcp-test cycle (it verifies the plan's claim against docs/features/mcp-testing/SPEC.md and writes the granted_by: mcp-test + spec_class sentinel only if it concurs) — the plan field is routing, never a grant.
    • Line absent or required → proceed with steps 1–3 as written.

    NEEDS_RUNTIME recovery: if a no-runtime mcp-test cycle returns the single line NEEDS_RUNTIME (it found an MCP-testable surface the plan missed), run steps 1–3 NOW, then emit the re-dispatch via the script (registry-registered, guard allows it):

python3 ~/.claude/scripts/lazy-state.py \
  --emit-dispatch needs-runtime-redispatch \
  --context item_name="{feature_name}" \
  --context spec_path="{spec_path}" \
  --context original_cycle_prompt_note="mcp-test cycle found MCP-testable surface; plan declared not-required" \
  --context item_id="{feature_id}" \
  --context cwd="{cwd}"

Dispatch dispatch_prompt VERBATIM using dispatch_model. The @requires keys for --emit-dispatch needs-runtime-redispatch are: item_name, spec_path, original_cycle_prompt_note, item_id, cwd. The failed attempt + re-dispatch together consume ONE forward cycle (increment once, after the re-dispatched cycle returns); tag the re-dispatch disp line (opus, recovery). A disagreement costs one extra dispatch round-trip — never correctness.

  1. Ensure the runtime is up, current, AND owned — ONE --ensure-runtime subcommand call consuming the M4 verdict (long-build-and-runtime-ownership Phase 5, atop unified-pipeline-orchestrator Phase 5). The probe → stale-binary check → dev:restart (bg) → curl-until-200 → MCP-tool assertion dance is the lazy-state.py --ensure-runtime subcommand (lazy_core.ensure_runtime), now the M4 liveness/recovery state machine. Run it ONCE instead of any hand-composed rebuild→health-poll→inspect until-loop — there is NO hand-rolled poll loop in the cycle prompt:

    python3 ~/.claude/scripts/lazy-state.py --ensure-runtime --repo-root "{cwd}"
    # → M4 verdict JSON:
    #   {state: READY|STALE|HIJACKED|DEAD|BLOCKED,
    #    ownership_verified: bool, health_code: int, mcp_tools_present: bool,
    #    terminal_blocker: str|null,
    #    status: ready|booted|stale-rebuilt}   # legacy field, retained (superset)
    

    Route on the FULL verdict's state (not a field-extracted subset — see the "route from the FULL probe JSON" rule below). The subcommand already performed Identity (ownership) → Staleness → Health classification + bounded recovery (STALE/DEAD auto-recover via restart() in an exponential-backoff loop capped at 5 attempts); the orchestrator only ROUTES on the returned state:

    • state: READY — runtime is up, current, healthy, AND owned (.runtime.lock.json verified against the live kernel start_time + this run's session). Proceed to dispatch ONLY when state == READY AND health_code == 200 AND mcp_tools_present (a conjunction — read all three from the FULL probe JSON, never state alone). A state: READY paired with a non-200 health_code (or mcp_tools_present: false) is treated as NOT-ready and is NOT dispatched against: the orchestrator OWNS the --ensure-runtime boot/recovery FIRST (the same orchestrator-owned cold-compile/boot path it already runs at the top of Step 1d.0) before any mcp-test dispatch — never dispatch a subagent against a runtime the verdict itself reports as non-serving. This health_code/mcp_tools_present cross-check is defense-in-depth atop the Phase 1 producer fix (ensure-runtime-legacy-mode-optimistic-ready-verdict): after that fix the producer never emits READY with a non-200 health_code (the legacy branch derives its verdict from the actual re-probe code, exactly as the M4 path does), so this guard should never fire in practice — it exists to catch a future producer regression and the residual mcp_tools_present: false case the state: BLOCKED bullet already names. It introduces NO new blocker_kind and NO new state: a miss reuses the existing orchestrator-owned --ensure-runtime boot path (and, if recovery exhausts, the existing mcp-runtime-unready BLOCKED.md), exactly as the DEAD/HIJACKED/BLOCKED bullets below already do.
      • state: READY with ownership_verified: false is ALSO a "proceed to dispatch" outcome — the soft owned-unverified READY (ensure-runtime-false-hijacked-on-owned-serving-runtime). This is a self-booted, serving runtime whose ownership diverges ONLY on the session component: the live PID is the SAME process this run booted (kernel start_time matches the recorded lock start_time) and it is provably serving THIS app's MCP tools (health_code == 200 + mcp_tools_present), but the lock's controller_session_id and the threaded live_session_id come from different sources and do not match (a Bash-driven single-session orchestrator). The proceed-conjunction above ALREADY admits it — it reads state/health_code/mcp_tools_present, NEVER ownership_verified — so an ownership_verified: false READY proceeds with no special-casing. Say so explicitly: do NOT hand-verify the runtime, do NOT dev:kill + cold-boot, and do NOT write BLOCKED.md — the producer no longer false-reports HIJACKED for the owned-serving case (the relaxation lives in _ensure_runtime_m4's classifier; see user/scripts/CLAUDE.md --ensure-runtime). The genuine-foreign case (no matching PID, or a divergent live PID/start_time) still classifies HIJACKED (next bullet) and stays terminal.
    • state: STALE that recovered to READY — the native binary was stale (a newer src-tauri/crates commit exists since boot, via the stale_binary predicate); the subcommand forced a dev:restart so the mcp-test cycle sees the current tool registry, then re-verified READY. (Legacy status: stale-rebuilt; spec reference: F7 in docs/specs/lazy-validation-readiness/SPEC.md.) Proceed to dispatch.
    • state: DEAD that recovered to READY — runtime was down; the subcommand started/restarted it (background dev:restart, orchestrator-owned) and re-verified health=200 + ownership. (Legacy status: booted.) Proceed to dispatch.
    • state: HIJACKED — a GENUINELY FOREIGN process holds the port: the recorded lock PID is either dead (→ DEAD recovery) or held by a DIFFERENT live process whose start_time diverges from the recorded lock start_time (real PID reuse). This is now the ONLY case the producer emits HIJACKED for — a self-booted serving runtime that diverges only on the session component classifies the soft owned-unverified READY above, not HIJACKED (ensure-runtime-false-hijacked-on-owned-serving-runtime). The subcommand DID NOT and WILL NOT SIGKILL it (LD3 safety/stability rule — never kill an unowned process). Surface a BLOCKED.md (blocker_kind: mcp-runtime-unready) with the verdict's terminal_blocker text VERBATIM as the body, and do NOT dispatch a subagent against it.
    • state: BLOCKED — bounded recovery exhausted (restart() retried up to 5× with backoff without restoring a healthy, owned runtime), OR an unrecoverable readiness failure (mcp_tools_present: false after recovery / health never reached 200). Surface a BLOCKED.md (blocker_kind: mcp-runtime-unready) with the verdict's terminal_blocker text VERBATIM as the body, and do NOT dispatch a subagent against a dead runtime — a subagent cannot recover a runtime the orchestrator failed to boot.

    Cold-compile patient-wait (ensure-runtime-recovery-starves-cold-compile). A cold/first boot or a new-crate STALE no longer starves: the subcommand now distinguishes a compiling runtime (Vite :1420 up, backend :3333 not yet serving — a long cold Rust compile in progress) from a genuinely dead one (both ports down) via a two-port readiness check. A compiling runtime is PATIENTLY WAITED on (owned, cold-compile-sized ~7.5-min budget, NEVER kill-restarted) and reaches state: READY without the old 5-starved-kill-restarts → false-BLOCKED symptom. The ≤5×backoff bounded recovery is now reserved strictly for a genuine crash (dead). A compiling wait that genuinely never serves within the budget still surfaces state: BLOCKED — but its terminal_blocker carries DISTINCT cold-compile-timeout text (the runtime was compiling and waited, not crash-recovery-exhausted), still mapped to the SAME blocker_kind: mcp-runtime-unready downstream (no new blocker_kind, no routing change — the orchestrator surfaces the verdict text VERBATIM exactly as above). Default-off / repo-agnostic: a repo with no :1420 frontend behaves byte-identically to before (every non-serving runtime classifies dead). No new routing branch here — compiling→READY proceeds to dispatch like any other recovered state; the only operator-visible change is a clearer BLOCKED message on a genuine cold-compile timeout.

    Pre-Vite cold-boot patient-wait (ensure-runtime-starves-pre-vite-sidecar-build). The cold-compile patient-wait above covered only the Vite-up window (its only "still booting" signal was Vite :1420 being up). A cold/first boot ALSO spends its first ~1–2 min in the PRE-VITE BeforeDevCommand/sidecar:build phase, where BOTH :1420 and :3333 are down while the spawned boot process is still alive — that window was previously misclassified dead and kill-restarted into a false BLOCKED. The subcommand now consults a SECOND "still booting" signal — boot-process liveness — so a both-ports-down-but-live-boot runtime classifies as booting (not killed): it is PATIENTLY WAITED through the pre-Vite window on the same ~7.5-min cold-compile budget and reaches state: READY without starvation, extending the Vite-up patient-wait above. The genuine-crash path is unchanged: a both-ports-down runtime with NO live boot (never booted / truly crashed and not restarting) still enters the bounded ≤5×backoff recovery. Default-off / repo-agnostic: a repo whose config does not opt into the boot-liveness signal behaves byte-identically to before (a both-ports-down runtime classifies dead). No new routing branch and no new blocker_kindbooting→READY proceeds to dispatch like any other recovered state.

    state ∈ {HIJACKED, BLOCKED} (and any residual unrecoverable mcp_tools_present: false) is the ONLY blocking path; state ∈ {READY, STALE, DEAD} having reached READY means the subcommand already recovered — proceed. The AlgoBooth specifics (TCP 3333 health endpoint, npm run dev:restart, the src-tauri/crates native globs, the asserted MCP tool, the .runtime.lock.json lock filename, the port) are parameterized in lazy_core's default config dict — not hand-typed here.

    Sidecar-pipe readiness dimension (env-transient-counts-against-validation-retry-budget Phase 1, Leg A — repo-agnostic default OFF). The dev HTTP server boots INDEPENDENTLY of the MCP sidecar named pipe, so health_code: 200 does NOT prove the sidecar is connected. A zombie node process holding the :3333 pipe after a dev:restart leaves the runtime HTTP-healthy but MCP-functionally DEAD — a self-inflicted ENV transient, not a code failure. When a repo sets assert_sidecar_connected: true in its --ensure-runtime config override (AlgoBooth opts in; the default is OFF so non-AlgoBooth repos are unaffected), the M4 Health phase additionally asserts get_sidecar_status.is_connected: true: a pipe-dead-but-HTTP-200 runtime routes through bounded recovery (a dev:restart that reaps the stale pipe) and, on persistent disconnect, to state: BLOCKED → the same blocker_kind: mcp-runtime-unready (escalation-immune) terminal as above — NOT a dispatch against a pipe-dead runtime, and NEVER an mcp-validation charge against the feature's validation-retry budget. (The cycle prompt's runtime-up variant carries the matching mid-cycle NEEDS_RUNTIME escape for the case the gate is OFF or the pipe dies after the gate passed.) The subcommand owns the orchestrator-owned background process exactly as the old hand-run steps did (the runtime survives the upcoming subagent's turn boundary), and the M4 lock makes that ownership verifiable across the cycle boundary (Persistent Service contract).

    Guard-takeover — the orchestrator owns long builds (long-build-and-runtime-ownership Phase 3/4). The long-build-ownership-guard.sh PreToolUse(Bash) hook (registered in user/settings.json) fail-OPEN denies any subagent attempt to run an exact long-build signature (tauri build / cargo build --release / npm run build), bubbling the literal takeover token LONG-BUILD-OWNERSHIP-TAKEOVER in its deny reason. When that token surfaces from a cycle subagent's denied build, the orchestrator session (long-lived, persists across subagent turns) takes over the spawn instead of the subagent: run the build under the Transient Build contract — lazy_core.run_transient_build(cmd, cwd=...) (spawns detached via spawn_detached so it survives a subagent tear, then synchronously AWAITS conclusion, capturing exit_code/stdout for telemetry) — and on its return call lazy_core.promote_artifact_atomically(staging_dir, final_dir, exit_code=result["exit_code"]) so the artifact is os.replace'd into production ONLY on exit_code == 0 (a torn/failed build leaves production untouched). The Transient Build contract is DISTINCT from the Persistent Service --ensure-runtime above (LD5: one spawn primitive, two contracts) — it does NOT write .runtime.lock.json and does NOT leave the process for a future cycle. This is Bash/lazy_core-driven process ownership, NOT a file edit — HARD CONSTRAINT 1 holds (same rationale as the runtime boot).

  2. **No prompt amendment by ha

Content truncated for page performance. Open the source repository for the full SKILL.md file.

Install via CLI
npx skills add https://github.com/jacobrocks1212/claude-config --skill lazy-batch
Repository Details
star Stars 0
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator
jacobrocks1212
jacobrocks1212 Explore all skills →