Skip to content

/sdlc:orchestrate

Generated from solutions/ontological/skills/orchestrate/SKILL.md.

Run one reconcile-and-dispatch tick over the project’s task/PR/worktree state. Sync main, classify every open PR via /sdlc:pr-check, act on the verdicts (close out MERGED PRs, attempt resolution on NEEDS-RESPONSE / CONFLICTS / CI-FAILED), categorize in-flight tasks via sdlc task inflight against the per-project in-flight limits in sdlc.yaml, then dispatch up to max_implementations concurrent /sdlc:task-work runs against tasks in status: open/ready. Append one line per tick to .sdlc/orchestrator-log.md so the user can read what happened between check-ins. Wrap with /loop /sdlc:orchestrate for the hands-off Phase 1 entry point of epic E0001.

  • Bash
  • Read
  • Write
  • Agent
  • PushNotification
  • Skill

Usage:

  • /sdlc:orchestrate — perform one tick. No arguments.
  • /loop /sdlc:orchestrate — the long-interval reconciliation tick. The model uses ScheduleWakeup to fire the next tick on a slow wall-clock cadence (suggested 2–6 h; see Step 7). This is the normative safety net — not an optional extra — because it is the only surface that catches the conditions the event-gated wake cannot see (CI-FAILED / CONFLICTS transitions, lease expiry, the dead-stop notification). Keep it running even with the Monitor gate below.
  • Event-gated wake (Monitor) — for fast, token-cheap response to new work, pair the slow /loop with the deterministic sdlc orchestrate watch gate wrapped in a Monitor (see “Event-gated wake (Monitor)” below). The gate wakes a tick only when there is actionable work, so idle periods burn no orchestrator transcripts.

One tick is one reconcile-and-dispatch pass. It is bounded: every sub-agent has a tight return contract, every dispatched task-work either completes or hands back a single-line verdict, and the tick exits in time for the next one to fire. Long-running implementation work stays inside the sub-agents.

You are an orchestrator of execution. You DO NOT implement code changes yourself — you must always dispatch work to sub-agents, to preserve context space as long as possible.

The orchestrator parent NEVER invokes a sub-skill directly from its own body — Skill(/sdlc:...) calls from the parent are forbidden. For every per-PR pr-check, every MERGED→close-out, every ready-task task-work pickup, the parent launches an Agent(general-purpose) sub-agent with a prompt that runs the sub-skill and returns one short verdict line. The parent reads only the verdict. Keep the parent’s transcript per tick small (target: under 2k tokens excluding sub-agent return strings).

Return contract — one line only, parent truncates

Section titled “Return contract — one line only, parent truncates”

Every sub-agent the orchestrator dispatches MUST return exactly one line — the verdict line documented in the relevant sub-skill’s output contract (e.g. MERGED, TASK-WORK-DONE pr=#42 ..., TASK-WORK-BLOCKED reason="..."). All slug-namespaced; never use the bare prefixes (DONE pr=, READY:, BLOCKED reason=) — they collide with parent terminal verdicts (cost two task-work runs 2026-05-28). The dispatch prompts in Steps 2 and 4 below state this explicitly with the preamble:

Return EXACTLY ONE LINE in the documented verdict shape.
Anything after the first newline will be truncated by the parent
and discarded.

The parent enforces the contract by truncating the sub-agent’s return string to the first non-blank line before parsing:

# Parent-side truncation guard. Apply to every sub-agent return
# before pattern-matching on the verdict.
verdict = next(
(line.strip() for line in return_text.splitlines() if line.strip()),
"",
)
# Everything after `verdict` is dropped. The full return text is
# NEVER pasted into the digest or the parent transcript.

If the truncated first line doesn’t match any documented verdict marker for the sub-skill, log the raw first line as ERROR reason="garbled return" in the digest (per Step 2 / Step 4 failure modes) and move on. Never echo the dropped tail back to the user.

The orchestrator does not cap concurrent sub-agents wholesale — Step 2 dispatches one /sdlc:pr-check per open PR with no limit, and close-out / conflict-resolution sub-agents fire freely. The in-flight limits below apply only to Step 4’s new-implementation dispatch (new /sdlc:task-work sub-agents), and they are categorized:

  • max_implementations (default 5) — hard cap on the implementing category (status in-progress, no open PR). Reaching this limit blocks Step 4 from starting any new task-work this tick.
  • max_awaiting_review (default 20) — informational ceiling on the awaiting-review category (status in-progress AND an open PR exists). Reaching this limit annotates the digest but does NOT block new implementations from starting.

Both limits are configurable per project under <project-root>/sdlc.yaml’s orchestrator: block. See ${CLAUDE_PLUGIN_ROOT}conventions/sdlc-yaml.md for the shape. Step 3 computes the categories via sdlc task inflight; Step 4 consumes the count.

References:

  • ${CLAUDE_PLUGIN_ROOT}/skills/pr-check/SKILL.md — per-PR classifier the orchestrator calls.
  • ${CLAUDE_PLUGIN_ROOT}/skills/task-close-out/SKILL.md — close-out skill the orchestrator calls on MERGED.
  • ${CLAUDE_PLUGIN_ROOT}/skills/task-work/SKILL.md — task-work skill the orchestrator dispatches on ready tasks.
  • sdlc task inflight — categorizing in-flight counter Step 3 shells out to.
  • sdlc orchestrate log-tick — digest writer Step 5 shells out to.
  • sdlc orchestrate watch — the deterministic pending-work gate the event-gated Monitor wake polls (see “Event-gated wake (Monitor)”).
  • ${CLAUDE_PLUGIN_ROOT}skills/orchestrate/watch_loop.sh — the never-exiting Monitor wrapper around sdlc orchestrate watch.
  • ${CLAUDE_PLUGIN_ROOT}/conventions/sdlc-yaml.md — per-project config shape (orchestrator: and pr_check: blocks).
  • ${CLAUDE_PLUGIN_ROOT}/conventions/commit-messages.md — not used (this skill never commits), kept for cross-reference.

Two complementary wake surfaces drive this skill; run BOTH:

  1. The fixed-cadence /loop reconciliation tick (slow, normative) — /loop /sdlc:orchestrate on the Step 7 long interval (2–6 h). This is the safety net that catches everything the gate cannot see.
  2. The event-gated wake (fast, token-cheap) — a Monitor wrapping the deterministic sdlc orchestrate watch op, which decides in code whether there is actionable work and wakes a tick only when there is.

The gate is the orchestrate watch op (joins the orchestrate noun beside log-tick). It evaluates three pending-work predicates — pr-gone, pr-attention, task-ready — over observable state, and emits at most ONE stdout line per poll:

PENDING reasons=<comma-list> prs=<sorted numbers> tasks=<sorted basenames>

emitted IFF ≥1 predicate holds (nothing otherwise). It exits 0 in every state (pending, empty, and upstream-gh-failure — a failure prints nothing to stdout, diagnostics to stderr). The design is level-triggered: it reports work pending now, and pending-ness clears when the substrate state a tick mutates reflects handling (a cursor advances, a task leaves open/ready, close-out removes branch+worktree). So a crashed tick’s wake re-fires while the work remains, and the first poll needs no special case.

Wrap it in a never-exiting Monitor so each emitted line is one wake event:

Monitor(
command: "${CLAUDE_PLUGIN_ROOT}skills/orchestrate/watch_loop.sh <main-repo>",
persistent: true,
)

watch_loop.sh is the homed wrapper: it polls sdlc orchestrate watch on an interval (default 60 s, floor 30 s), emits ≤1 line per poll, and NEVER exits (so the persistent: true Monitor keeps watching). stdout presence is the only per-poll signal — the wrapper carries no set -e and never propagates a poll’s exit code, so a transient gh/network blip degrades to “no event this tick” instead of killing the loop. An idle period produces zero events and therefore zero orchestrator transcripts — that is the whole point.

On a wake, scope Step 2 to the prs= identities (see Step 2). The gate hands the tick the exact PR numbers and task basenames that have pending work, so a woken tick re-checks only those PRs instead of fanning out across all open PRs — the dominant token cost once idle ticks are gone.

What the gate does NOT detect (the reconciliation tick’s job). The three predicates have no footprint for: CI-FAILED and CONFLICTS transitions, lease expiry, and the dead-stop notification (Step 6). These are caught ONLY by the fixed-cadence /loop tick — which is why that tick is retained as the normative long-interval reconciliation safety net, not removed. A pr-red predicate (CI via ETag’d check-runs) is a possible follow-up, out of scope here.

Bring local main up to date with origin before doing anything else.

git fetch origin
git -C <main-repo> pull --rebase --autostash

If the rebase reports a conflict, abort the tick: append a digest line that names the conflicting paths and exit. Do not attempt auto-resolution. The next tick will retry naturally.

Scope this step to the woken work when the wake came from the gate. If this tick was woken by the event-gated Monitor (above) and the PENDING line carried a prs=<numbers> list, reconcile ONLY those PR numbers — they are exactly the PRs with pending pr-attention / pr-gone work. This is what keeps a woken tick token-cheap: it skips the fan-out across every open PR. When the tick was instead woken by the fixed-cadence /loop reconciliation cadence (the safety net), or the wake carried no prs= list, enumerate the full set with gh pr list --state open --json number,headRefName --limit 50 — the slow tick is where CI-FAILED / CONFLICTS transitions (invisible to the gate) get caught, so it must see every PR.

For each PR in the resolved set (the prs= subset on a gate wake, else every open PR from the list above), launch one sub-agent. Send all the Agent calls in a single message so they run concurrently — per-PR verdicts are independent and parallelism here is the whole point of delegating to sub-agents.

Each sub-agent’s prompt (concise; do NOT inline the procedure — the sub-agent reads pr-check’s SKILL.md itself):

Return EXACTLY ONE LINE in the documented verdict shape.
Anything after the first newline will be truncated by the parent
and discarded.
Run /sdlc:pr-check <N> and return the exact verdict line on its own
line. No commentary. If the skill emits anything other than a
documented marker (CLEAN, NEEDS-RESPONSE, CONFLICTS, CI-FAILED,
MERGED, CLOSED, ERROR), surface that first line verbatim so the
parent can log the anomaly.

After the sub-agent returns, apply the parent-side truncation guard from the “Return contract” preamble before parsing the verdict.

When all sub-agents return, dispatch by verdict. Every follow-up sub-agent launched here MUST receive the “Return contract” preamble (“Return EXACTLY ONE LINE …”) at the top of its prompt, and the parent MUST apply the truncation guard to the return string before parsing — the same rule that gates pr-check returns gates close-out and resolution returns.

  • MERGED or CLOSED → launch a follow-up sub-agent running /sdlc:task-close-out <derived-basename> where the basename is parsed from the PR’s headRefName (strip the task/ prefix — see ${CLAUDE_PLUGIN_ROOT}conventions/branch-naming.md). Read back the close-out marker (TASK-CLOSE-OUT-DONE pr=#N ... / PARTIAL ... / ALREADY-CLOSED / etc.) and record it in the digest.

  • NEEDS-RESPONSE → dispatch the lease-aware /sdlc:pr-respond <pr-number> skill via the Agent tool:

    Agent prompt: /sdlc:pr-respond <pr-number>

    The dispatched sub-agent looks up its lease via the PR footer (planted by /sdlc:task-work at PR open) plus branch derivation inside the worktree — NO lease-state via env vars, NO argv lease state passed by orchestrate. pr-respond handles the re-acquire, response, push, and transition; orchestrate’s job is purely to dispatch and read back the terminal marker.

    Read back the marker (PR-RESPONSE-COMPLETE: pr=<n> task=<id> on success, or one of the structured-failure stderr markers LEASE-FOOTER-MISSING, WORKTREE-MISSING, LEASE-FENCING-MISMATCH, LEASE-CONFLICT, LEASE-MISSING) and record it in the digest. If pr-respond can’t resolve in one pass, record the unresolved state and move on. Do NOT block the tick on a stuck PR.

    For NEEDS-RESPONSE specifically: pr-respond’s response sub-agent posts reply comments via ${CLAUDE_PLUGIN_ROOT}skills/pr-check/post_self_comment.sh <pr-number> <body>, never raw gh pr comment — a raw post skips the cursor’s self_posted_at filter, so the next tick re-fires NEEDS-RESPONSE on the orchestrator’s own comment and wastes a dispatch. See ${CLAUDE_PLUGIN_ROOT}skills/pr-check/SKILL.md’s “Self-posted comment filter” subsection for the wrapper’s contract.

  • CONFLICTS / CI-FAILED → launch a resolution sub-agent in the task’s worktree at .sdlc/worktrees/<basename> with a prompt describing the verdict’s reason and asking the sub-agent to attempt the fix (rebase, fix the failing check). If the sub-agent can’t resolve in one pass, record the unresolved state in the digest and move on. Do NOT block the tick on a stuck PR. These verdicts do NOT go through /sdlc:pr-respond — they are not review-comment work, so they skip the comment-reply path and need no lease re-acquire dance; the dispatched resolution sub-agent reads its lease via discover_lease() per the standard branch-derivation contract.

  • CLEAN → no action; record the CLEAN verdict in the digest.

  • ERROR → record the error reason in the digest; do not retry within the tick. The next tick will re-fetch state and try again.

Shell out once per tick to the categorizing counter:

${CLAUDE_PLUGIN_ROOT}cli/sdlc task inflight --project-root <main-repo> --output json

It emits one JSON object carrying the per-tick in-flight limits, a per-category count, and per-task details. See sdlc task inflight --help. The categories are:

  • implementing — status in-progress, no open PR. Counts toward max_implementations.
  • awaiting-review — status in-progress, open PR exists. Counts toward max_awaiting_review. Never blocks Step 4 dispatch.
  • stale — status closed/... but worktree still present. Surfaces as a digest warning. Does NOT count toward either limit.
  • other — anything else (planning/*, open/ready with worktree, in-progress/blocked, missing task file). Informational; does NOT count toward either limit.

Capture the JSON for Step 4 (implementing count and the configured max_implementations gate the dispatch) and Step 5 (the digest records inflight={...}, caps-reached=<list>, and stale=<slugs>).

The op resolves the limits from the project’s <main-repo>/sdlc.yaml orchestrator: block, falling back to the defaults max_implementations: 5 and max_awaiting_review: 20. See ${CLAUDE_PLUGIN_ROOT}conventions/sdlc-yaml.md § orchestrator:.

If the script exits non-zero (rare — missing .sdlc/worktrees/ is not an error; the script returns empty counts), record the failure in the digest and dispatch nothing this tick.

Before any dispatch decisions run, fetch the control-plane ref and confirm the orchestrator’s sdlc_version is compatible with the authority’s.

Shell out to the lease library via ${CLAUDE_PLUGIN_ROOT}cli/sdlc lease inspect refs/sdlc/control-plane --json:

${CLAUDE_PLUGIN_ROOT}cli/sdlc lease inspect refs/sdlc/control-plane --json

Exit-code semantics (per the lease CLI’s documented exit-code reference — solutions/ontological/cli/lease_cli/README.md § “Exit-code reference”, also surfaced by sdlc lease --help):

  • 0 → ref exists, payload printed as a JSON object. Parse the sdlc_version field, compare against the orchestrator’s own SDLC_VERSION constant (from solutions/ontological/lib/lease/version.ts). The predicate is same-major-implies-compatible (per the ADR’s “Control Plane Compatibility” section). On mismatch: abort the tick with CONTROL-PLANE-INCOMPATIBLE expected=<local-major> actual=<remote-major> on stderr, write the same marker to the digest, and dispatch nothing this tick.
  • 5 → REF-NOT-FOUND. The control-plane ref does not exist on the authority: the cutover migration has not been run yet, or the ref was deleted. Abort the tick with CONTROL-PLANE-MISSING on stderr, write the same marker to the digest, and dispatch nothing.
  • Other → unexpected library failure (network, auth, malformed payload). Abort the tick with LEASE-LIBRARY-ERROR exit=<N> on stderr, mirror to the digest, dispatch nothing.

Do not act on a control-plane-gate failure beyond logging. Don’t PushNotification for a single failed gate; the next tick re-fetches.

Capture the parsed sdlc_version (string, e.g. "1.0.0") for the Step 5 digest field cp=<version>; on missing-ref or incompatible the digest field is cp=missing or cp=incompatible respectively.

Identify dispatchable tasks by shelling out to the deterministic pickup verb — the pickup-order algorithm lives in exactly one place, owned by ${CLAUDE_PLUGIN_ROOT}cli/sdlc task next (see docs/planning/decisions/D-Q2WR-task-pickup-order.md for the algorithm narrative and the priority / dependency-lift / dispatchability-filter semantics):

${CLAUDE_PLUGIN_ROOT}cli/sdlc task next --status open/ready \
--exclude-autonomy human-only

Dispatchable basenames print on stdout, one per line. Three predicates drop the rest, each naming its skip on stderr:

PredicateRulestderr marker
LeafA task with children is a structural rollup, skipped whatever its kind ([[D-VSLI-distributed-work-runner-architecture]]).skipped-parent:
Kindkind: implementation only; absent kind: counts as implementation. planning / research leaves stay human-driven until their runners exist.skipped-kind:
DispatchabilityEvery depends_on target must be satisfied, so nothing dispatches against a deliverable that does not exist yet.skipped-blocked:

Collect the skipped-blocked basenames for the Step 5 digest’s skipped-blocked= field. The other two skips need no digest field — a rollup or a non-implementation leaf is never a dispatch candidate.

Process the stdout output one basename per line. Exclude any task that already appears in Step 3’s tasks list (it already has a worktree and feat branch — it is in flight).

Dispatch autonomy: autonomous/pr tasks the same way as any other — their self-ready (the /sdlc:task-auto-define pass inside /sdlc:task-work’s Step 5a ensure-ready gate) happens inside the sub-agent; the orchestrator reads the same terminal verdict. The work still stops at an open PR (TASK-WORK-DONE pr=#<N>); nothing in this loop ever auto-merges. Tasks at supervised / human-only / absent autonomy park at a gap.

Read counts.implementing and limits.max_implementations from the Step 3 JSON. If implementing >= max_implementations, do not dispatch this tick — record tasks-dispatched=none-cap-reached in the digest and skip to Step 5.

For each candidate that survives the cap check, attempt to win the task lease via the lease CLI before dispatching the sub-agent. The CLI does the CAS-CREATE on refs/sdlc/tasks/<task-id> with owner=current_host_id() (same-host inherit on CAS-FAILED); branch on its exit code:

${CLAUDE_PLUGIN_ROOT}cli/sdlc lease task acquire <task-id>

Use task acquire, the same verb the sub-agent’s discover_lease() / acquire_lease() walks at startup — both write the same current_host_id() as owner, so the sub-agent inherits the orchestrator’s pre-claim. Do NOT use task claim: it writes a random UUID into owner and guarantees a cross-owner mismatch and a spurious LEASE-CONFLICT from the sub-agent.

Exit-code branching:

  • 0ACQUIRED task=<id> lease_id=<uuid> phase=claimed on stdout. The orchestrator dispatches the Agent sub-agent for /sdlc:task-work <slug> (next subsection). Record the per-task digest annotation claim=won.
  • 4LEASE-CONFLICT ref=refs/sdlc/tasks/<id> owner=<other-host-id> on stderr. Another host already holds the lease (a different orchestrator, or an operator running task-work directly). Skip this candidate, record claim=lost, and move to the next candidate WITHOUT aborting the tick.
  • Other non-zero → unexpected library failure (network, auth, dependency resolution error, namespace conflict). Abort the tick with LEASE-LIBRARY-ERROR exit=<N> on stderr, mirror to the digest, dispatch nothing further this tick.

Process candidates in the order sdlc task next emitted them (canonical pickup-order is owned by that verb — see docs/planning/decisions/D-Q2WR-task-pickup-order.md) and stop after max_implementations - implementing successful claims. A claim=lost does NOT count against the cap. A claim=skipped annotation appears for candidates the loop never reached because the cap was already filled by earlier successful claims.

Otherwise dispatch up to max_implementations - implementing sub-agents in a single message (parallel). The Agent invocation passes NO lease-state via env vars and NO argv lease state — only the slug. The dispatched sub-agent discovers its lease via discover_lease() on startup (from the lease library); orchestrate’s responsibility ends at the successful CLI claim. The branch name (task/<slug>) and the cache file written by the CLI’s CAS-CREATE are the only handoff. See ${CLAUDE_PLUGIN_ROOT}conventions/lease-aware-skills.md for the discovery contract.

Each sub-agent’s prompt:

Return EXACTLY ONE LINE in the documented verdict shape.
Anything after the first newline will be truncated by the parent
and discarded.
These verdicts are FINAL outputs from /sdlc:task-work, not from
the sub-skills it invokes. Markers from sub-skills are slug-namespaced
specifically so they cannot be confused with task-work's terminal
verdict. They are INTERMEDIATE signals; you must continue past them
through Steps 5b through 10 unless explicitly halting per the
task-work spec. Examples of sub-skill intermediate markers (never
valid as task-work's final verdict):
- From /sdlc:task-ensure-ready: ENSURE-READY-OK:, ENSURE-READY-NEEDS-DEFINITION:,
ENSURE-READY-AMBIGUOUS:, ENSURE-READY-NO-TASK-FOUND
- From /sdlc:task-define: TASK-DEFINE-DEFINED:, TASK-DEFINE-NO-CHANGES:,
TASK-DEFINE-ALREADY-READY:
- From /sdlc:task-auto-define (transitive via ensure-ready's autonomy gate
on autonomous/pr tasks): TASK-AUTO-DEFINE-DEFINED:,
TASK-AUTO-DEFINE-NO-CHANGES:, TASK-AUTO-DEFINE-INSUFFICIENT:
- From /sdlc:spawn-task-pr (transitive via Step 8 post-mortem):
SPAWN-TASK-PR-DONE, SPAWN-TASK-PR-EXISTING, SPAWN-TASK-PR-REHEARSED
Run /sdlc:task-work <slug> end-to-end. Return one verdict line:
- TASK-WORK-DONE pr=#<N> — PR opened and ready for review.
- TASK-WORK-BLOCKED reason="<why>" — could not proceed; <blocked>
section written and committed on the feat branch.
- TASK-WORK-NEEDS-DEFINITION slug=<basename> — ensure-ready flagged
a gap; spec needs human attention before pickup.
- ERROR reason="<why>" — task-work itself failed before reaching
a verdict.

After the sub-agent returns, validate the verdict against the task-work allowlist before dispatching on it. Three classes of verdict matter:

  1. Strict task-work verdicts — the four canonical terminal markers task-work’s spec promises. These are unambiguous.
  2. Recoverable escape markers — sub-skill terminal markers the sub-agent emitted when it bailed early. These have a known mapping back to a task-work outcome and are auto-recovered.
  3. Anything else — ANOMALY.
TASK_WORK_VERDICT_RE = re.compile(
r"^(TASK-WORK-DONE pr=#\d+|"
r"TASK-WORK-BLOCKED reason=\".+\"|"
r"TASK-WORK-NEEDS-DEFINITION slug=\S+|"
r"ERROR reason=\".+\")"
)
# Recoverable escape markers — sub-skill terminal markers that an
# escaping task-work sub-agent may emit as its final line. Each
# class has a documented re-interpretation back to a task-work
# outcome (see ESCAPE_RECOVERY_MAP below).
ESCAPE_MARKER_RE = re.compile(
r"^(ENSURE-READY-OK: \S+|"
r"ENSURE-READY-NEEDS-DEFINITION: \S+|"
r"ENSURE-READY-AMBIGUOUS:|"
r"ENSURE-READY-NO-TASK-FOUND|"
r"TASK-DEFINE-DEFINED: \S+|"
r"TASK-DEFINE-NO-CHANGES: \S+|"
r"TASK-DEFINE-ALREADY-READY: \S+)"
)
# Mapping: escape marker prefix -> (recovered_outcome, action)
# - "needs-definition": treat as TASK-WORK-NEEDS-DEFINITION (the
# task-state on main is already correct because the sub-skill
# committed the downshift before stopping).
# - "stamped-but-stopped": readiness verified but Steps 5b–10 never
# ran. The task is still actionable; record for re-dispatch on
# a later tick (do NOT re-dispatch in the same tick — risk of
# infinite loop with a deterministically-escaping sub-agent).
# - "error-no-task-found": treat as ERROR.
ESCAPE_RECOVERY_MAP = {
"ENSURE-READY-OK:": ("stamped-but-stopped", None),
"ENSURE-READY-NEEDS-DEFINITION:": ("needs-definition", None),
"ENSURE-READY-AMBIGUOUS:": ("error-ambiguous-task", None),
"ENSURE-READY-NO-TASK-FOUND": ("error-no-task-found", None),
"TASK-DEFINE-DEFINED:": ("stamped-but-stopped", None),
"TASK-DEFINE-NO-CHANGES:": ("stamped-but-stopped", None),
"TASK-DEFINE-ALREADY-READY:": ("stamped-but-stopped", None),
}
# Phase 1: try the strict path first. The documented contract is
# "verdict is the FIRST non-blank line"; if that matches the
# task-work allowlist, this is the well-behaved case.
first_line = next(
(line.strip() for line in return_text.splitlines() if line.strip()),
"",
)
if TASK_WORK_VERDICT_RE.match(first_line):
verdict = first_line
# well-behaved path; continue with normal dispatch
else:
# Phase 2: scan every line and look for the LAST line matching
# either the strict allowlist OR the escape-marker allowlist.
# Prefer strict matches (a buried valid verdict is the
# "verdict-last" leak pattern from the lenient parse). Fall
# through to escape-marker recovery if only a sub-skill marker
# appears.
lines = [l.strip() for l in return_text.splitlines() if l.strip()]
strict_match = next(
(l for l in reversed(lines) if TASK_WORK_VERDICT_RE.match(l)),
None,
)
if strict_match is not None:
verdict = strict_match
digest_record_lenient_recovery(slug, raw_first_line=first_line)
else:
escape_match = next(
(l for l in reversed(lines) if ESCAPE_MARKER_RE.match(l)),
None,
)
if escape_match is not None:
prefix = next(
p for p in ESCAPE_RECOVERY_MAP
if escape_match.startswith(p)
)
recovered_outcome, _action = ESCAPE_RECOVERY_MAP[prefix]
digest_record_escape_recovery(
slug,
marker=escape_match,
recovered_outcome=recovered_outcome,
)
# Do not re-dispatch in this tick. The outcome is
# logged; a future tick will re-pick the task if
# status permits (recovered_outcome="stamped-but-
# stopped" tasks stay open/ready on main, so they
# surface as candidates again next tick — orchestrator
# may want to skip them on the immediate next tick to
# avoid tight loops; see "Re-dispatch back-off" below).
verdict = None # signal: do not parse further
else:
digest_record_anomaly(slug, raw_verdict=first_line)
verdict = None
continue

digest_record_anomaly writes an ANOMALY entry into the tick digest (see Step 5) of the form ANOMALY: task-work slug=<slug> raw="<truncated verdict>". The dispatch loop moves on without crediting the run as success/ blocked/needs-definition. The half-started worktree is left for the human to inspect.

digest_record_lenient_recovery writes a LENIENT-RECOVERY entry of the form LENIENT-RECOVERY: task-work slug=<slug> first_line="<truncated>". The dispatch credits the recovered verdict as the run’s outcome.

digest_record_escape_recovery writes an ESCAPE-RECOVERY entry of the form ESCAPE-RECOVERY: task-work slug=<slug> marker="<line>" recovered_outcome=<outcome>. This entry surfaces that the sub-agent escaped via a known sub-skill marker but the orchestrator recovered the semantic outcome. The outcomes:

  • stamped-but-stopped — readiness was verified (commit on main) but Steps 5b–10 never ran. Task remains open/ready; a future tick may re-dispatch. The orchestrator SHOULD apply a one-tick back-off before re-picking the same slug (see “Re-dispatch back-off”) to avoid tight loops with a deterministically- escaping sub-agent.
  • needs-definition — readiness gate flagged a real gap; the sub-skill committed the downshift before stopping. Task is now planning/needs-definition on main; no further action this tick.
  • error-ambiguous-task / error-no-task-found — pre-flight failure; task wasn’t actionable. No state change required.

Track a per-slug “last escape tick” annotation. When the orchestrator considers a candidate for dispatch in Step 4, if the candidate’s last escape was the previous tick, SKIP it for this tick and add claim=skipped-back-off to the digest. This keeps a deterministically-escaping sub-agent from consuming a cap slot on every tick. Background: [[T-NP7H-task-work-sub-agent-verdict-contract-escape-recurrence]].

Append one line per tick to .sdlc/orchestrator-log.md by shelling out to the digest writer, which owns the field grammar:

${CLAUDE_PLUGIN_ROOT}cli/sdlc orchestrate log-tick \
--sync <ok|conflict> \
--cp <version|missing|incompatible> \
--prs <verdict-counts> \
--implementing <I> --awaiting-review <A> --stale <S> \
--caps-reached <limit> ... \
--stale-slugs <slug> ... \
--tasks-dispatched <slug:claim=won|lost|skipped> ... \
--skipped-blocked <slug> ... \
--blocked <slug> ... \
--events '<kind>|<slug>|<text>|<outcome>' ...

The op owns .sdlc/orchestrator-log.md (gitignored under .sdlc/*) and computes tick=<N> itself — the skill passes no tick flag. The pinned summary line the op emits:

<ISO 8601 UTC timestamp> tick=<N> sync=<ok|conflict> cp=<version|missing|incompatible> prs=<verdict-counts> inflight={implementing=I,awaiting-review=A,stale=S} caps-reached=<list|none> stale=<slugs|none> tasks-dispatched=<slugs-with-claim-annotations> skipped-blocked=<slugs|none> blocked=<slugs>

Pass each flag the value Steps 3a/4 already computed:

  • tick=<N> is the number of lines already in the file plus one; the op reads the file and computes it (the skill passes no tick flag).
  • cp=<version|missing|incompatible> is the result of Step 3a’s control-plane gate. On the happy path this is the authority’s sdlc_version (e.g. cp=1.0.0). cp=missing signals the control-plane ref does not exist on the authority (the cutover migration has not run); cp=incompatible signals a major-version mismatch between local and remote. Both of the latter two values imply this tick dispatched nothing — tasks-dispatched=none-cp-gate in those cases.
  • prs=<verdict-counts> is a comma-separated summary like MERGED=1,CLEAN=2,NEEDS-RESPONSE=1 — one count per verdict that actually appeared this tick.
  • inflight={implementing=I,awaiting-review=A,stale=S} is the per-category in-flight count from Step 3’s JSON.
  • caps-reached=<list> is a comma-separated list of any limits that were at or above their cap this tick (subset of max_implementations, max_awaiting_review), or none. max_implementations here means Step 4 dispatched zero new tasks because the cap was hit. max_awaiting_review is the warning-only ceiling described in the in-flight limits preamble.
  • stale=<slugs> is the comma-separated list of basenames whose task file is closed/... but the worktree still exists. These do not count against either limit; the field is a teardown reminder for the human (or a follow-up tick).
  • tasks-dispatched=<slugs> is the comma-separated list of task slugs dispatched. Each slug carries a claim= annotation (<slug>:claim=won|lost|skipped) from Step 4’s lease-claim gate: won means the CLI acquire succeeded and the sub-agent was dispatched, lost means LEASE-CONFLICT (a different host won the race), skipped means the cap filled before the loop reached this candidate. If Step 4 dispatched nothing the value is none with one of the reasons: none-cap-reached (Step 3 cap), none-empty (no ready candidates), none-cp-gate (Step 3a aborted), none-counter-failed (Step 3 script error), none-lease-library-error (Step 3a or Step 4 library failure).
  • skipped-blocked=<slugs> is the comma-separated list of candidates sdlc task next filtered out this tick because a depends_on target was not yet satisfied (read from the verb’s skipped-blocked: … stderr lines). none when nothing was filtered. Informational — these tasks become dispatchable on a later tick once their blockers reach a satisfied state; the field surfaces which chains are waiting and on what.
  • blocked=<slugs> is the comma-separated list of tasks the tick noticed are in status: in-progress/blocked or planning/needs-definition — informational only, not acted on (see Step 6).

If Step 4’s verdict validation rejects any sub-agent return OR recovers a verdict (via lenient last-match OR escape-marker recovery), pass one --events '<kind>|<slug>|<text>|<outcome>' record per event so the op prepends one line per event BEFORE the tick summary line. The <kind> is anomaly, lenient-recovery, or escape-recovery; <outcome> is used only by escape-recovery. The op renders each into the pinned shapes:

<ISO 8601 UTC timestamp> ANOMALY: task-work slug=<slug> raw="<truncated verdict>"
<ISO 8601 UTC timestamp> LENIENT-RECOVERY: task-work slug=<slug> first_line="<truncated>"
<ISO 8601 UTC timestamp> ESCAPE-RECOVERY: task-work slug=<slug> marker="<line>" recovered_outcome=<outcome>

ANOMALY fires when NO line in the sub-agent’s return matches the strict or escape allowlists (all parses failed); the raw first non-blank line is captured so the human can diagnose the failure mode. Feed it anomaly|<slug>|<truncated first line>|.

LENIENT-RECOVERY fires when the first non-blank line failed the strict allowlist but a later line matched it — the verdict still parses (the dispatch credits the recovered outcome), but the entry surfaces that the sub-agent leaked prose before the verdict marker. Feed it lenient-recovery|<slug>|<truncated first line>|.

ESCAPE-RECOVERY fires when no strict task-work verdict appeared but a known sub-skill escape marker did. The orchestrator re-interprets the marker to a recovered_outcome per ESCAPE_RECOVERY_MAP and continues without crediting the run as a task-work outcome (e.g. stamped-but-stopped means the task remains open/ready on main and is eligible for re-dispatch on a later tick after one-tick back-off). Feed it escape-recovery|<slug>|<marker line>|<recovered_outcome>.

The op echoes every line it writes (event lines plus the tick summary) on stdout, so the parent transcript carries the tick summary without re-reading the log file.

PushNotification fires only when state can’t progress without a human. The threshold:

  • gh pr list --state open is empty, AND
  • Zero pickable status: open/ready tasks remain (excluding human-only), AND
  • At least one task is in planning/needs-definition or in-progress/blocked.

Under those conditions, notify. Otherwise stay quiet.

Routine blockers do NOT notify:

  • A single in-progress/blocked task while other ready tasks remain.
  • A single failed sub-agent (CI-FAILED, NEEDS-RESPONSE that didn’t resolve in one pass).
  • A CLEAN PR sitting waiting for review.

The digest log is the surface for routine signals; PushNotification is reserved for the dead-stop case.

Print the summary line the op echoed in Step 5 on stdout one more time (the user-facing summary), and exit. When wrapped in /loop, the model schedules the next tick via ScheduleWakeup.

The /loop tick is the long-interval reconciliation cadence — the normative safety net that catches the conditions the event-gated wake cannot see (CI-FAILED / CONFLICTS transitions, lease expiry, the dead-stop notification). Its job is periodic full reconciliation, not fast response, so schedule it on a slow wall-clock cadence:

  • Default reconciliation interval → 2–6 h (suggested). The event-gated Monitor (see “Event-gated wake (Monitor)”) handles the fast path, so the /loop cadence does not need to be tight — a long interval keeps idle cost near zero while still guaranteeing the blind-spot conditions get swept.
  • When work is actively in flight (open PRs mid-review, sub-agents running) you MAY shorten toward the low end of that range so a reconciliation pass lands sooner; idle, stay at the high end.

This replaces the earlier fast fixed cadence: fast response now comes from the deterministic orchestrate watch gate, not from a tight ScheduleWakeup. The exact ScheduleWakeup semantics live in the /loop skill; this skill just returns.

If Step 1’s pull-rebase conflicts, abort the tick without dispatch. Append sync=conflict and the conflicting paths to the digest. PushNotification is appropriate here only if conflicts persist across two consecutive ticks — for a single tick, the next one will retry.

If a sub-agent in Step 2 or Step 4 returns garbled output (no recognisable verdict), record the raw return in the digest and move on. Do not let one malformed return crash the tick.

If Step 3’s counter script fails (non-zero exit), treat in-flight counts as unknown for this tick: skip Step 4 dispatch entirely, record tasks-dispatched=none-counter-failed and inflight=unknown in the digest, and continue to Step 5 / Step 6 normally. The next tick will re-run the counter.

If orchestrate log-tick exits non-zero (read-only filesystem, etc.), surface the error on stdout but otherwise complete the tick — the in-memory state is the source of truth for the just-completed cycle.

  • The parent body NEVER invokes a sub-skill directly; every per-PR pr-check, every close-out, every task-work pickup is dispatched via the Agent tool.
  • The in-flight limits (max_implementations, max_awaiting_review) are per-project config in <project-root>/sdlc.yaml’s orchestrator: block, not literals in this body. The counter that produces the categories the limits gate against is sdlc task inflight.
  • .sdlc/orchestrator-log.md is gitignored under .sdlc/* and is the only file this skill touches; Step 5’s sdlc orchestrate log-tick op owns the write.
  • Sub-agents are launched concurrently via parallel Agent calls in a single message wherever the work is independent (per-PR pr-check, per-task task-work dispatch).
  • Committing model-generated messages. See ${CLAUDE_PLUGIN_ROOT}conventions/commit-messages.md — not used by this skill (it never commits), but sub-agents it dispatches will, and they inherit the convention.