Pair Workflow Cycle
ASecurityIn-session coordinator for pair's delivery cycle: drives ONE card through implement → verify (a fresh card has no up-front contract) and, for each round of review findings, prepare → validate → green → verify, one stage per subagent, from an interactive Claude Code, Codex or pi (with pi-subagents) session — no dependency on Claude Code's Workflow tool. Enters on a fresh card ($card) or straight into fix & review on an existing PR ($pr). Holds zero cycle rules: every transition, budget and fre...
- 2 stars
- 0 votes
- 0 copies
- 0 views
- Added November 7, 2026
Works with
Security analysis
100/100Pro scans all 16 files and shows the line behind each finding
npx -y skills add learnn-com/designn --skill pair-workflow-cycle --agent claude-codeAre you the author of Pair Workflow Cycle?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/learnn-com-pair-workflow-cycle)---
name: pair-workflow-cycle
description: "In-session coordinator for pair's delivery cycle: drives ONE card through implement → verify (a fresh card has no up-front contract) and, for each round of review findings, prepare → validate → green → verify, one stage per subagent, from an interactive Claude Code, Codex or pi (with pi-subagents) session — no dependency on Claude Code's Workflow tool. Enters on a fresh card ($card) or straight into fix & review on an existing PR ($pr). Holds zero cycle rules: every transition, budget and freshness decision comes from cycle-state.mjs resolve, every argument packet and worktree from cycle-dispatch.mjs. Binds its harness by PROBING for a subagent primitive, never by product name, and HALTs realization-unavailable with the pair-cli fallback when none is present. Never decides merge — the `merge` stage's script does."
version: 0.2.0
author: Foomakers
---
# /pair-workflow-cycle — One Card, One Stage at a Time, From Inside a Session
The delivery cycle is a state machine that already exists; this skill is one way to turn its crank. It asks the durable state what is due, dispatches exactly that stage to a subagent, forgets everything but the answer, and asks again. It classifies nothing, judges nothing and selects nothing — every decision it acts on was already taken by `cycle-state.mjs`, and every packet it hands out was rendered by `cycle-dispatch.mjs`.
Two entries, one cycle: a refined card with no PR runs the whole thing; a PR that already exists enters at its first verification and runs fix & review. You never merge, and you never review.
## Arguments
| Argument | Required | Description |
| ----------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `$card` | One of | Card (issue) number. The fresh-card entry: `resolve` yields `implement / initial / a0` — no up-front contract (ADR-024 amendment 2026-09-23); a run directory already on the sealed `a0` path continues it. Give `$card` or `$pr`, never both and never neither. |
| `$pr` | One of | PR number — the fix & review entry: `resolve` yields `verify / first / r0` and NO preparation runs before it. The card is read from the PR's linked issue. |
| `$rounds` | No | How many remediation rounds THIS invocation may spend. Default: the policy's `maxFixRounds`. It only ever narrows: a `$rounds` above `maxFixRounds` is **clamped** to the policy value and the clamp is reported — it can never widen the ceiling, because the ceiling is the cycle's, not the invocation's. |
| `$runId` | No | Run directory to drive: `.pair/working/runs/$runId/<card>/`. Default `story-<card>` — the batch engine's own convention, so a cycle started by `pair-implement-batch` resumes here and back. |
| `$notes` | No | Scope directive from the card; threaded into every stage packet, overriding the issue body where they conflict. |
| `$profile` | No | The workflow profile to use (US-488): a NAME, looked up in `pair.config.json`'s `workflowProfiles.files` / `.inline`. Cascade, resolved once per run: `$workflowConfig` > `$profile` > `workflowProfiles.default` > the KB default (schema-default engine and model, default effort, `fresh` context). Unresolvable ⇒ HALT `profile-unresolved`, never a silent fallback. A value starting with `{` is the **legacy** inline object `{ "effort": … }` (one of `low \| medium \| high \| xhigh \| max`, applied to every stage's dispatch instruction exactly as before — enforced for Codex, a best-effort prompt request only for Claude); absent ⇒ today's behavior, unchanged. |
| `$until` | No | The target (US-521, ADR-027): `ready` (stop at the prepare→implement boundary), `pr` (default: the review-approved PR) or `merged` (enter the merge stage — the only value under which the merge gate is evaluated). Precedence: argument > argument preset > explicit adoption key (`## Autonomy`, then translated legacy sections) > adoption preset > KB default — every effective value is printed with its source. |
| `$prepare` | No | The prepare gate (US-523, ADR-028), `<hold-always\|hold-never\|hold-when>[; has: <labels>][; lacks: <labels>]`: who prepares a Draft / Ready-without-breakdown `$card` before the cycle starts. `hold-always` (default) keeps R3.11 — a human prepares; `hold-never` proceeds alone recording assumptions; `hold-when` proceeds alone unless a boundary escalates (`needs-review`, the card stays Draft). Same grammar and precedence as `$merge`; see the Prepare phase in Step 1. |
| `$merge` | No | The merge gate, same grammar. `hold-always` (default) parks `awaiting-human`; `hold-never`/`hold-when` enter the merge stage under `until: merged` unless an escalation fires. #490's signal checks stay mandatory under every mode. |
| `$workflowConfig` | No | Path of an external profile file, used verbatim; wins over `$profile` and `pair.config.json`. Malformed ⇒ HALT `profile-invalid`. |
Everything else a stage receives — `$run $story $branch $worktree $base $stacked $entry $policy $inputs $workflowVersion` and the phase-specific arguments — is **rendered by the script**, never composed here in prose.
## Algorithm
### Step 0: Bind the realization (mandatory, before any dispatch)
**Check.** Read the table as data and probe your OWN toolset for its dispatch primitive:
```bash
node "$SKILL_DIR/scripts/cycle-dispatch.mjs" realizations --tools '<JSON array of the tool names you actually have>'
```
**Skip.** Never. A realization bound in a previous turn is re-probed: the toolset is a property of this session, not of the task.
**Act.** The script returns `bound` and the row it bound:
| Row | Dispatch primitive | Resume primitive | How the role travels |
| -------- | ------------------ | ----------------------------- | ------------------------------------------ |
| `claude` | `Agent` | `SendMessage` | `agentType` — the stage's agent definition |
| `codex` | `collaboration.spawn_agent` \| `multi_agent_v1__spawn_agent` | `collaboration.followup_task` \| `multi_agent_v1__resume_agent` | the agent `.md` body + the skill reference |
| `pi` | `subagent` (the `pi-subagents` package; `subagents_enable` is its loader) | `subagent` — `runs.run({ resume })`, rendered by `pi-bridge.mjs` | the agent `.md` body + the skill file path |
Report which realization won and which primitives it bound to, in one line.
**Verify.** The row is bound by the PRIMITIVE the probe found, never by a product name or a version string: a name is a claim about the host, a present tool is evidence of it. No row applies ⇒ HALT `realization-unavailable` (below) **before any dispatch** — including the case where this skill is itself running inside a subagent and the host forbids nesting — unless Step 0b applies.
### Step 0b: inside pi — the package, its version and its tool shape
**Check.** Only when the probe bound `pi`, or bound nothing: ask the bridge, through your own shell, whether you run inside pi and what is installed.
```bash
node "$SKILL_DIR/scripts/pi-bridge.mjs" probe --project "$PWD"
```
**Skip.** `inPi: false` and no `pi` row bound ⇒ nothing here; Step 0's outcome stands. `inPi` is read off pi's own process marker (`PI_CODING_AGENT=true` on every command its shell runs), never off a product name.
**Act.** You **never** install on your own: every install below is proposed, and runs only on the user's explicit yes.
- `status: missing` (no `pi-subagents`, or removed since the last run) ⇒ tell the user the in-pi cycle needs `pi-subagents@<pinned>` and propose the `install.user` line (or `install.project` if they want it project-local — a project-local package loads only in a trusted project, see `/pair-capability-setup-harness` with `$harness: pi`).
- On **yes**: run the install line the probe printed (stdin closed, foreground), then STOP and ask the user to re-run this skill — the tool appears only in a new session.
- On **no**: HALT `pi-subagents-missing` — the in-pi cycle cannot run without it; the fallback is `pair-cli run --card N` from a shell.
- `status: drift` (installed, a version other than the pinned one) ⇒ warn, naming both versions, and propose aligning it via `/pair-capability-setup-harness` with `$harness: pi` (the probe prints the pinned install line). If the user declines, proceed, and state once in your report that the pi-subagents version is unverified.
- `status: pinned` and Step 0 bound the `pi` row ⇒ nothing to propose.
- `status: pinned` or `status: drift` but Step 0 bound no `pi` row (installed, yet this session did not load the tool) ⇒ HALT `realization-unavailable`, naming the remedy: with `scope: project`, trust the project so pi loads its local packages (see `/pair-capability-setup-harness` with `$harness: pi`); otherwise — or when it was installed earlier in this session — start a new pi session and re-run this skill. Propose nothing else.
- `activationRequired: subagents_enable` from Step 0 ⇒ call `subagents_enable({})` once; `subagent` is available on your next request.
**Verify.** Before the first dispatch, check the tool's shape as THIS session lists it (its name and its parameter schema, copied from your own tool list, never retyped from memory):
```bash
node "$SKILL_DIR/scripts/pi-bridge.mjs" check --tool '<JSON {name, parameters} of the subagent tool>'
```
A mismatch ⇒ HALT `subagent-tool-mismatch` with the bridge's detail (it names the expected version) — no dispatch on a shape nobody verified.
### Step 1: Resolve what is due
**Check.** Before the first `resolve`, resolve `## Blocking Severities` by running `node <skill dir>/scripts/blocking-severities.mjs read <main checkout>/.pair/adoption/tech/automation.md` — r1-3: the SAME grammar `pair-cli`'s `blocking-severities.ts` reads, ported into this dependency-free script (not hand-parsed in-session) so a `cycle-defaults-parity.test.ts` fixture run can hold the two to the identical policy, byte for byte, on every fixture including the default. A HALT from the script IS `automation-policy-malformed` — relay its message verbatim, naming the file and the offending line, never a silent fallback. The prose below documents the grammar the script enforces; read `## Blocking Severities` from the MAIN checkout's `.pair/adoption/tech/automation.md` (US-514 T-1, the #514/AC1 revision: a FLOOR compared by RANK, never a list — the same rule as `severityFloor` / `--severity-floor` elsewhere in the chain) — the same file, the same fenced/HTML-comment-blind extraction `## Eligibility` already documents. **Absent file or absent section** ⇒ the KB default `{ "blockingFloor": "Minor" }` — today's behaviour, byte for byte (every severity except `Questions` blocks), and `pair` itself declares nothing (delta-only adoption, ADR-018/D21). **Present but empty** ⇒ HALT `automation-policy-malformed`, naming the section. **Present**: the first line is ONE severity — `Critical | Major | Minor` — a comma-separated LIST, an unrecognised token, an empty declaration, or a `max-dispatches` line that is not `<positive integer> [warn|block]` all HALT `automation-policy-malformed`, naming the file and the offending line — never a silent fallback. An optional second line, `max-dispatches: <n> [warn|block]` (mode defaults to `warn` when omitted), becomes `"maxDispatches": { "n": <n>, "mode": "<warn|block>" }`; absent ⇒ no ceiling at all — never invent `40`. Merge the result into `<policy JSON>` below (`{ "blockingFloor": "<severity>", "maxDispatches": {…} }`, the `maxDispatches` key omitted when there is no line) — the SAME object every later `resolve`/`packet`/`bind-hosts` call in this invocation reuses (the autonomy `policy` and `--labels` are the exception: they go to YOUR `resolve` calls ONLY — never into a `packet`'s `--policy`, whose JSON carries only `blockingFloor` / `maxDispatches` / `deadDispatchRetries`, because the stage's own Step 0 resolve reads that policy with no `--labels` and an `autonomy` key would escalate `labels-unreadable`; `packet` strips `autonomy` as defence in depth), so red-verify and review-phase (T-2) and this run's own dispatch ceiling (T-3) all act on one value. `resolve`'s own `max-dispatches` WARNING (mode `warn`, at or above the ceiling) is not silent: print it to the operator verbatim before continuing.
Ask the one authority, from the MAIN checkout:
```bash
WV="$(node "$SKILL_DIR/scripts/cycle-state.mjs" version)"
AC_HASH="$(node "$SKILL_DIR/scripts/cycle-state.mjs" ac-hash --story $card --dir ".pair/working/runs/$runId/$card" | node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8")).acHash')" # canonical card hash — the SAME input every stage resolves with
node "$SKILL_DIR/scripts/cycle-state.mjs" resolve --dir ".pair/working/runs/$runId/$card" \
--workflowVersion "$WV" --policy '<policy JSON>' --entry <fresh|pr> [--pr $pr] \
--story $card --inputs <digest> --acHash "$AC_HASH" --runsRoot .pair/working/runs [--contextPolicy '<contextPolicy JSON>'] [--redirects <n>]
```
The PM tool and code host are bound ONCE, here, before the first `resolve` of this invocation — every script a stage runs against this run directory (card hash, PR comment, check, label, scope decision) then goes through that binding, never a re-read of way-of-working mid-cycle (US-492, ADR-018 split):
```bash
node "$SKILL_DIR/scripts/cycle-state.mjs" bind-hosts --dir ".pair/working/runs/$runId/$card"
```
`bound` on a new run, `reused` on a resumed one — and on the run an `other-run` answer makes you adopt, bind that directory the same way before its first dispatch. Report `pm-tool` / `code-host` in the same line as the realization. `{ halt: "host-unsupported" }` ⇒ HALT `host-unsupported` (below) before any dispatch.
**Autonomy policy (US-521).** Resolve the effective autonomy policy ONCE, before the first `resolve`, by the ONE shared script (the same one `pair-cli run` spawns; this skill holds no autonomy rule):
```bash
node "$SKILL_DIR/scripts/autonomy-policy.mjs" resolve --adoption "$MAIN/.pair/adoption/tech/automation.md" \
--args '{"until":"<$until>","prepare":"<$prepare>","merge":"<$merge>"}' # only the arguments actually passed
```
Print its `lines` verbatim (every key, its effective value and its source: `argument` | `adoption` | `adoption (translated from ## Auto-Advance)` | `preset <name>` | `default`), then its `warnings`. `ok: false` ⇒ HALT `automation-policy-malformed` naming each `errors[].key` and reason, before any card is touched. A project that declares nothing and passes nothing resolves to `until: pr`, gates `hold-always`: nothing below changes. Hand `resolve` the result as `--policy '{…,"autonomy":<policy>}'` (the script's `policy` object) only when `active` is `true`, and — only when `policy.until` is `merged` and the merge gate is `hold-when` — the card's CURRENT labels as `--labels '<JSON array>'`, re-read from the PM tool before EVERY `resolve` (labels are live at each boundary) — and, once the card has a PR, that PR's CURRENT labels as `--prLabels '<JSON array>'` (`gh pr view <pr> --json labels`, re-read the same way): the review writes the PR's `risk:*` tier, so the script decides the gate on the card's labels with its `risk:*` replaced by the PR's (an untagged PR is `risk:red` at the merge boundary) — this skill holds no such rule, so a card still tagged `risk:green` whose PR the review raised escalates at the merge boundary BEFORE `cycle-merge.mjs check`. The legacy `--policy '{…,"autoAdvance":{"tiers":[…]}}'` with `--tier`, exactly as before, is passed only when `active` is `false`.
**Empty `## Autonomy` — ask the preset once (US-576).** When the resolution shows nothing declared or passed (every effective source `default`, no translated legacy section) and a human is present, ask `full / escalate-red / supervised` (Enter = `supervised`) and write `preset: <answer>` under `## Autonomy` in `.pair/adoption/tech/automation.md` through `/pair-capability-record-decision`, then resolve again. Unattended (no human to answer): write nothing, run with the KB defaults (every gate held — the supervised behaviour) and print how to set one (`preset: escalate-red` under `## Autonomy`, or `--preset` on `pair run`).
**Prepare phase (US-523, `$card` entry only).** A `$pr` entry never prepares — a card with a PR has started. For a `$card`, BEFORE the first `resolve`, read the card's readiness (through `/pair-next`'s Control-State Resolution — one procedure, this skill re-derives nothing: macrostate `Draft`, `Ready` without a task breakdown, or `Ready`) and its CURRENT labels, then ask the ONE shared script — the same one `pair-cli run --card` and the batch run; this skill holds no prepare rule:
```bash
node "$SKILL_DIR/scripts/cycle-prepare.mjs" decide --gate '<policy.prepare JSON>' --readiness <draft|refined-no-breakdown|ready> \
--attended <true|false> --boundary <B0|B1|B2> --dir "$RUN_DIR" --story <card> --source <effective.prepare.source>
```
The script reads the card's CURRENT labels itself (`--dir`/`--story`); never paste a label, title or any other card text into a command line — it is untrusted host data. `attended` is `true` for an in-session run (a human is here) and `false` when this cycle was dispatched by a batch or a loop. Boundaries: `B0` before refinement, `B1` after it (classification tags now written; a `refined-no-breakdown` card enters here), `B2` after the task breakdown. Print its `route` and follow it — nothing else decides:
| `route` | You |
| --- | --- |
| `nothing-to-prepare` | Continue to the first `resolve` (the card is Ready). |
| `run-interactive` | Compose `/pair-process-refine-story` (Draft) or `/pair-process-plan-tasks` (breakdown missing) with their interactive defaults — phase 0 `/pair-capability-grill` and every human-judgment gate ask, exactly as today (R3.11). Then continue. |
| `skip-needs-human` | `prepare: hold-always` unattended: print that the card needs a human, spawn nothing, write nothing, end the invocation (never re-attempt it this run). |
| `skip-escalated` | The card carries `needs-review` (any readiness, Ready included): end the invocation; a human removes the label, or an attended run's `complete` clears it. |
| `escalate` | `node "$SKILL_DIR/scripts/cycle-prepare.mjs" escalate --dir "$RUN_DIR" --story <card> --boundary <B0\|B1\|B2> --gate '<JSON>' --source <source> (--conditions '<the route's conditions JSON, verbatim>' \| --openQuestionFromCard true)` — `--openQuestionFromCard true` (an open question under `## Open Questions`, see below) makes the script read the question from the card itself, never from your command line; adds `needs-review`, posts ONE marker comment, writes no board state; the status is `escalated`, then Step 5's `on-halt`. |
| `run-autonomous` | Refine (Draft only) with `$approval: auto $prepare: <the gate's mode>` — the ONLY combination that lifts phase 0, `$approval: auto` alone still HALTs it (ADR-021) — then `decide` at `B1`; plan with `/pair-process-plan-tasks $approval: auto`, then `decide` at `B2`; then `cycle-prepare.mjs complete --dir "$RUN_DIR" --story <card> --gate '<JSON>' --source <source> --attended <true\|false> --refinedAutonomously <true\|false> --state <first board state mapped to Ready in the State Mapping of way-of-working.md; no mapping = Ready; none mapped = HALT>`, the one place Ready is written (`--refinedAutonomously true` only when YOU ran the refinement in this prepare — it then also owes `## Assumptions` + the provenance line; `false` for a card that entered at `B1`, already refined by a human; an attended `complete` also clears `needs-review`; fails closed without a `## Task Breakdown` checklist). Any boundary that answers `escalate` stops there. |
An open question the refinement could not settle from the repository (an entry under `## Open Questions` not ticked `- [x]`) escalates at `B1`, even under `hold-never`: an answer is never invented — use `escalate … --openQuestionFromCard true`. A `complete` that answers `completed: false` is a failed prepare — no Ready, `on-halt`. After a successful prepare, `until: ready` ends the invocation (`target-ready`, no worktree, no branch); otherwise continue to the first `resolve`. Every self-answer is on the card under `## Assumptions` with a Notes provenance line, for a human to overturn.
The workflow profile is resolved ONCE, here, right after the binding and before the first `resolve` — by the ONE shared resolver `pair-cli run --card` calls too, never by hand-reading `pair.config.json`:
```bash
node "$SKILL_DIR/scripts/workflow-profile.mjs" resolve --root "$PWD" [--profile $profile] [--workflow-config $workflowConfig] \
[--tier <the card's risk:* label>] --dir ".pair/working/runs/$runId/$card"
```
Print its `table` to the operator **once** — profile name, source (`--workflow-config` | `argument` | `pair.config.json` | `KB default`), hash, then every stage's engine/model/effort/context, each with the level that decided it — and never resolve again mid-cycle (a `pair.config.json` edited while the cycle runs changes nothing until the next invocation). Keep `.contextPolicy` and pass it as `--contextPolicy` to every `resolve` of this invocation: it names only the transitions `cycle-state.mjs`'s own table admits, so `resolve` accepts it by construction. `--dir` records the profile's name and hash beside the run's handoffs; `publish` stamps them into every handoff. The profile is audit only — never part of the input digest, so changing it between invocations invalidates no evidence. A `{ halt, detail }` (exit 1) is `profile-unresolved`, `profile-invalid` or `profile-name-collision` ⇒ HALT (below) before any dispatch. When `$profile` starts with `{` it is the legacy inline `{effort}` object: skip this block (there is no name to resolve) and use it as before. No `$profile`, no `$workflowConfig` and no `workflowProfiles` block ⇒ the KB default: run the script anyway, so the table says so.
**Cycle hooks (US-489).** Before the first `resolve`, load the project's `## Cycle Hooks` once — `node "$SKILL_DIR/scripts/cycle-hooks.mjs" load "$MAIN/.pair/adoption/tech/automation.md"` — and print its `warnings` (an unrecognized hook key) verbatim, once. A load `error` (a malformed `timeout` value; the optional `` `timeout` `` key is per-command seconds, default 600, `0` = none) HALTs before the first stage or `resolve` — print it verbatim and stop. Absent file or section ⇒ `{ hooks: {}, warnings: [] }`: nothing to run and nothing to say (zero-configuration default). YOU execute the hooks, never a stage's agent, and always through the ONE shared executor, `cycle-hooks.mjs run` — the same script `pair-cli run --card` spawns, so both coordinators give the same blocking/logging semantics; this skill holds no hook rule. Hook points: `pre-cycle` once, before the first stage of this invocation (not per round); `pre-<step>` before each dispatch of `next.step` (and `pre-merge` before the merge stage's `check`, `post-merge` after its `run` once the merge executed); `post-<step>` after that stage's handoff advanced; `on-halt` and `post-cycle` in Step 5. The cycle-level points (`pre-cycle`, `post-cycle`, `on-halt`, and `post-merge` — the story worktree is already removed when the merge has executed) run in the main checkout (`--cwd "$MAIN"`); the stage points (`pre-<step>`, `post-<step>`, and `pre-merge`) run in the story worktree — the `path` Step 2's `worktree` script printed (`--cwd "<story worktree path>"`), which Step 2 has already created by the time `pre-<step>` runs, so a stage hook gates the tree the stage works on and never touches the developer's main checkout. `verify` is the one stage whose packet names another tree (`<worktreeRoot>/<story>-review`, a detached review worktree the verify agent itself creates and removes): a `pre-verify`/`post-verify` hook still gates the story worktree at the PR head, never the review worktree, which does not exist yet at `pre-verify` and is gone by `post-verify`.
```bash
node "$SKILL_DIR/scripts/cycle-hooks.mjs" run "$MAIN/.pair/adoption/tech/automation.md" --point <point> --cwd <"$MAIN" for a cycle-level point, the story worktree path for a stage point> [--status <terminal status>]
```
Read the answer, never the exit code: a `halted` object (only a `pre-*` point can carry one) HALTs the cycle **`failed-hook`** before the stage runs — print `halted.output` verbatim, never a summary; `logged` lines (`post-*`, `on-halt`) are printed to the operator and the cycle continues. `pre-cycle` halting ends the invocation before any stage (then Step 5's `on-halt`/`post-cycle` still close it).
The workflow version is never typed: `cycle-state.mjs version` prints the one value this cycle speaks, and every command below is handed that capture. A version outside `<major>.<minor>.<patch>` is refused by whichever command receives it, before it does any work — so a literal remembered from a previous session fails the run rather than mints an identity nothing downstream accepts.
The digest is the script's own — never computed by hand, because both realizations must agree on it:
```bash
node "$SKILL_DIR/scripts/cycle-state.mjs" inputs --story '<card JSON>' --workflowVersion "$WV"
```
**Skip.** Nothing here is skippable, on any turn, including the first.
**Act.** Read `next` and nothing else. `status: other-run` ⇒ the cycle already lives under that run id: adopt it and resolve again. `incompatible` ⇒ stop and report (a legacy run directory is pointed at `migrate-acknowledge`, never migrated in place). `invalid` ⇒ stop and report.
**Verify.** `next.step` is `merge`, `done` or `blocked` ⇒ go to Step 5 (`status: escalated` is a `blocked` step with `reason: escalated`; a `done` with `target: ready` is the `until: ready` stop). Otherwise it names the one stage due now.
### Step 2: Put the stage's worktree in place
**Check.** Run `pre-cycle` once (first pass of this invocation only). The authoring chain runs in the persistent story worktree; the final verifier gets a detached throwaway one.
```bash
node "$SKILL_DIR/scripts/cycle-dispatch.mjs" worktree --main "$PWD" --story $card \
--branch <card branch> --base <base ref> --worktree-root <root>
```
**Skip.** Already present on the same branch ⇒ the script answers `reused: true` and adds nothing. Run it anyway: it is the idempotency, not a check you make yourself.
**Act.** Nothing by hand. The script creates or reuses.
**Verify.** `halt: worktree-conflict` or `halt: worktree-root-invalid` ⇒ HALT (below). Every path segment and git ref this script is handed is validated BEFORE anything is created, so a refused root leaves no directory and registers no worktree. The developer's own checkout is never touched, and no worktree is ever `--force`d or switched.
### Step 3: Render the packet and dispatch exactly one stage
**Check.** Run the hook point `pre-<next.step>` (Step 1's Cycle hooks); a `halted` answer ends the cycle `failed-hook` here — nothing below runs.
```bash
node "$SKILL_DIR/scripts/cycle-dispatch.mjs" packet --next '<next JSON>' --card '<card JSON>' \
--policy '<policy JSON>' --run "$runId" --workflow-version "$WV"
```
**Skip.** Never compose a stage prompt yourself, not even "the obvious one": the packet is byte-identical to what the batch engine composes, and a hand-written variant is a second process wearing the same name.
**Act.** Dispatch `prompt` under `agentType` (Claude) or as the row's role packet (Codex), honouring `next.context`. On `pi`, render the packet with `--style instruction`, write it to a file, and let the bridge turn it into the `subagent` call — both the fresh dispatch and the `reuse` resume, keyed by the role's latest retained run id — then pass the JSON it returned to `record`:
```bash
node "$SKILL_DIR/scripts/pi-bridge.mjs" call --packet <packet file> --tool '<the same tool JSON>'
# call `subagent` with exactly its `arguments`; then, with what the workflow returned:
node "$SKILL_DIR/scripts/pi-bridge.mjs" record --ledger <the `ledger` path call printed> --result '<returned JSON>'
```
The bridge makes the resume's context deterministic: the task opens by telling the revived agent to read its previous session file first, the one named on the `Original session file` line of the host's revive header (the pinned result returns no session file of its own). It resumes a role's run only when it was retained in THIS pi session and the host reported it resumable, and says `degraded: reuse→fresh` once otherwise:
- Each retained run is bound to the parent session that dispatched it, read off `PI_SESSION_ID` (pi exports it to every command its shell runs): a run retained by another pi session (pi exited, the cycle is resumed in a new one), an entry with no binding, or a call with no `PI_SESSION_ID` ⇒ `reuse→fresh` — pi-subagents resolves a retained run only inside its own parent session.
- A child whose result carries `resumability: not-resumable` (stopped run, missing session file, …) is never retained: `record` drops the role, and the next `reuse` runs fresh.
- If the host rejects a resume anyway, the fallback is a same-role fresh stage: `record` the failed pass with a null runId (`--result '{"runId":null,"ok":false}'`), then `call` again — it renders `fresh`. When the rejection left no JSON at all and you call again without a record (Step 4's retry), the bridge sees the unrecorded resume, renders `fresh` itself and drops the rejected id from the role: no later call — retry or re-entry — re-resumes it.
- `fresh` — spawn a NEW subagent. This is the KB default on every transition, and it is **mandatory** into `validate` and `verify`: an independent verifier that inherits the author's context is not independent.
- `reuse` — **resume** the previous subagent of that same role instead of spawning one (`SendMessage` on Claude, whichever of `collaboration.followup_task` / `multi_agent_v1__resume_agent` the probe actually bound on Codex — its own tool namespace has renamed twice in one day, so never hardcode either name yourself; read it from the bound realization). `cycle-state.mjs` returns `reuse` only for `prepare→prepare`, `implement→green` and `green→green`; it is never this skill's call. `cycle-dispatch.mjs context-table` prints the table.
- **Per-stage profile** (the resolved profile's row for `next.step`): pass its `effort`, when not `default`, to `packet` as `--profile '{"effort":"<effort>"}'` — the packet then carries the value and the prompt requests it (the legacy `$profile` object is passed the same way, unchanged). For Codex apply it as a real dispatch-call parameter (`-c model_reasoning_effort=<value>`), never only as prose; for Claude there is no such parameter — never report it as enforced. Apply the stage's resolved model id (`stages.<step>.model.resolved.id`, when not null) to whichever model parameter the bound primitive exposes; where it exposes none, say so once — never claim it was applied. `engine` is a `pair-cli` notion (which binary a stage spawns): in-session, the session's own harness IS the engine, so a stage `engine` is reported in the table and otherwise ignored. `context` reaches you only as `next.context`.
**Verify.** `halt: pipeline-invalid` ⇒ HALT (below): every `--pipeline` value is held to the same grammar the batch engine holds it to, and no packet is rendered from a refused one. Otherwise one stage, one dispatch. Keep only the compact `resolve` output; never read a handoff whole into this session, and never retain a subagent's transcript.
### Step 4: Decide from the file, never from the return value
**Check.** The stage has ended, however it ended. Re-run Step 1.
**Skip.** Nothing. In particular, do not skip the re-resolve because the subagent returned something that looks conclusive — and do not treat a missing or malformed return as a failure on its own.
**Act.** Compare the durable state with what it was before the dispatch:
- the handoff ADVANCED ⇒ the stage succeeded, whatever it printed — run the hook point `post-<step>` of the stage that just advanced (logged, never a stop);
- the handoff did NOT advance ⇒ a **dead dispatch**: re-dispatch the SAME prompt once (every stage is re-entrant, so the retry resumes), then a second unadvanced handoff ends the cycle `failed-<step>`. The budget is `policy.deadDispatchRetries` from `resolve`, defaulted to 1 — it is data, not a number written here.
- the stage **STALLED** — it is still running but makes no progress (no message and no handoff advance for the stage's time bound; typically a wait on a background process the packet forbids) ⇒ **resume it once on the same subagent** with the bound realization's resume primitive (`SendMessage` on Claude, the resume tool the probe bound on Codex), telling it to continue its step with foreground, time-bounded commands only. Where the realization cannot resume that subagent (it is gone, or resume is unavailable), re-dispatch the SAME prompt fresh instead. A stall resume and a dead-dispatch retry spend the SAME `policy.deadDispatchRetries` budget (US-506 AC-12): a second failure of the step, of either kind, ends the cycle `failed-<step>`. Never wait on a stage yourself beyond its time bound, and never start a background wait of your own.
**Verify.** Count the remediation rounds spent against `$rounds` (clamped to `maxFixRounds`). At the bound, stop and print the `next` step the cycle would take — do not spend another round. Otherwise loop to Step 2.
### Step 5: Report the terminal state
**Check.** `next.step` is `merge`, `done` or `blocked`. With the autonomy policy, `merge` appears only under `until: merged` when the merge gate allows it; `next.reason: escalated` (top-level `status: escalated`) is the escalation terminal. Legacy: `merge` appears only when you passed `resolve` the project's `## Auto-Advance` tiers as `--policy '{…,"autoAdvance":{"tiers":[…]}}'` and the card's current `risk:*` label as `--tier`, and that tier is among them; without both, a converged cycle is `done` — today's behavior, byte for byte.
**Skip.** Never.
**Act.** Close the hooks first: on a `failed-*` or `escalate` terminal (including `failed-hook`), a `merge-parked` whose park kind is not `awaiting-human` (park kind `escalated` included), or `merged-closure-unfinished`, run `on-halt --status <terminal>`. EXACTLY these trigger it: any `failed-*` (including `failed-hook`); `escalate`; `escalated`; `merge-parked` with park kind `halted` (a park kind absent or unreadable counts as `halted`); `merged-closure-unfinished`. It runs never on `ready-for-merge`, never on `merged`, and never on `merge-parked` with park kind `awaiting-human`. The executor cannot see the park kind, so YOU skip the `on-halt` call for an `awaiting-human` park; the executor only gates the status name, then `post-cycle --status <terminal>` once — both logged, never a stop — but not when this invocation only stopped at the `$rounds` bound. Report exactly what `resolve` said: `ready-for-merge` when the cycle converged, `escalate` when a human decision is owed, `failed-<stage>` otherwise — with the run directory, the PR and the reviewed head.
**Escalated (`status: escalated`, US-521).** A `hold-when` gate condition fired at `next.stage`. Post the ONE idempotent card comment — `node "$SKILL_DIR/scripts/cycle-merge.mjs" escalate --dir <run dir> --story $card --stage <next.stage> --conditions '<next.conditions JSON array>'` — report `comment.posted` (a failed post never changes the outcome), run `on-halt --status escalated` then `post-cycle`, and stop with terminal `escalated` (the invocation fails: exit 1 for `pair-cli run`). The PR stays open; merging it manually stays possible. Re-invoking re-reads the labels: still matching ⇒ escalated again, no work and no duplicate comment (marker-keyed); gone ⇒ the cycle resumes from its first incomplete step. `escalated` is distinct from the batch's row status `escalate` (a review/fix budget exhausted).
**`until: ready` / `until: pr`.** `done` with `target: ready` (nothing implemented past the prepare→implement boundary) or the converged `ready-for-merge` under `pr`: report it; `merge` is never reached whatever the merge gate says.
**Merge (`next.step: merge`).** The stage is a script, never a subagent, and this skill still decides nothing: run `node "$SKILL_DIR/scripts/cycle-merge.mjs" check --dir <run dir> --story $card --pr <next.pr> --reviewedHead <next.reviewedHead> --cardTier <next.tier>` with one gate flag. Pass `--mergeGate '<the merge gate JSON {mode,has,lacks}>'` only when `active` is `true`, and `--autoAdvance '<the tiers JSON array>'` only when `active` is `false`. It re-reads the tier (the PR's CURRENT `risk:*`, written by the review, drives the merge gate and the approval rule), the remote head and the `pair-review` / `pair-explicit-approval` conclusions live (below 🔴 an absent `pair-explicit-approval` is satisfied — the tier does not require it; at 🔴 it must be `success`); on `mergeAllowed: false` it has already parked the card with a comment naming the failed condition — report its `reason`. When `ciPending: true` (CI is the only obstacle and every unsettled check is still running) do not stop: wait 30 s and re-run the same `check --deferPark true` (polls post no park comment), repeating up to a 30-minute cap (`PAIR_CI_WAIT_CAP_SECONDS` / `PAIR_CI_WAIT_INTERVAL_SECONDS` override, cap 0 = never wait), stopping early on an interrupt; when the wait ends still pending, run one last `check` WITHOUT `--deferPark` so the card is parked once; the first answer with `ciPending` false decides (failed CI still parks/halts at once, still pending after the cap parks as before). On `true`, run `/pair-capability-verify-quality` for the tier, then `cycle-merge.mjs run` with the same flags plus `--gate green|red`, `--message '<squash message per the commit template>'` and `--branch <card branch>` (run from the main checkout), and report its `merged` / `cascaded` / `reason` verbatim. `merged: true, cascaded: false` names the closure step left for a human.
**Verify.** Without the `merge` stage, you have not merged, not closed the card, not deleted a branch and not posted a review: a converged cycle is a card ready for a human. With it, every one of those was done by `cycle-merge.mjs`, and only after it had re-verified the conjunction itself.
## Maintainer Recovery
Two commands replace the hand edits US-487's run needed. They are the maintainer's, never this skill's to invoke on its own judgment; both print the `next` that `resolve` then names, and this skill simply continues from it.
- **The run's LAST handoff, of any stage, must be set aside** (built on a wrong premise, or a bookkeeping mistake, before any later handoff was built on top of it): `node "$SKILL_DIR/scripts/cycle-state.mjs" supersede --dir <run dir> --phase <p> --skill <red-spec|red-verify|implement-phase|green-fix|review-phase> --reason '<why>' --by <who> --workflowVersion "$WV" [--attempt <n>]` (`--skill` defaults to `red-spec` for the original preparation-attempt use). The handoff (and, for a red-spec attempt, its own contract file) become `superseded-<date>-<file>` — visible to a directory listing, invisible to the cycle — and a row lands in `maintainer-interventions.md`; `resolve` then names the step now due (e.g. a failed-custody review superseded ⇒ the review runs again). Only the TAIL of the run can be set aside: a handoff that is not the run's last one is refused (`supersede-not-last`). A sealed contract is refused (`supersede-sealed`), an attempt a rejection already answered too (`supersede-validated`), an unrecognized `--skill` is `supersede-skill-unsupported`, and an unknown phase or attempt is `supersede-not-found`: nothing is renamed.
- **A review escalated on `needsHumanDecision`**: `node "$SKILL_DIR/scripts/cycle-state.mjs" decide --dir <run dir> --phase <r<n>> --finding <id> --decision '<the answer>' --by <who> --workflowVersion "$WV"`. The answer is recorded as its own `recordType: decision` handoff — no handoff renamed, no review re-run — and `resolve` leaves `escalate` for the remediation. One call per finding the review asked about: the review names them in `humanDecisionIds`, and the cycle stays `escalate` — `decide` prints the ids still owed as `pendingDecisions` — until every one is recorded; a finding the review did not escalate is refused (`decide-not-owed`).
## HALT Conditions
| HALT | When | What you print |
| ------------------------ | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `realization-unavailable`| No row's dispatch primitive is present — no subagent primitive, or nesting is forbidden | The probed toolset and the fallback command `pair-cli run --card N [--pr P]` (the `--pr` half only when a PR exists) |
| `worktree-conflict` | `<root>/<card>` exists on another branch, or is not a registered worktree | Both branch names and the path; resolve it by hand — never `--force`, never a checkout switch |
| `worktree-root-invalid` | `--worktree-root` is not an absolute path nor a relative one of safe segments with at most one leading `..` | The value, refused verbatim — nothing is created and no worktree is registered |
| `pipeline-invalid` | A `--pipeline` key or value is outside the grammar the batch engine enforces on it | The offending key and why — no argument packet and no prompt are rendered |
| `pi-subagents-missing` | Inside pi (Step 0b) with no `pi-subagents`, and the user said no to the install | That the in-pi cycle cannot run without it, the pinned install line, and `pair-cli run --card N` |
| `subagent-tool-mismatch` | The `subagent` tool's name or parameters differ from the pinned version's (Step 0b) | The bridge's detail: expected tool, parameters and version, and the setup that aligns it |
| `workflow-version-invalid` | `--workflow-version` is not `<major>.<minor>.<patch>` — the grammar `publish` already enforces | The value, refused verbatim — no argument packet and no prompt are rendered from it. Pass the `version` command's output, never a remembered literal |
| `profile-unresolved` | `$profile` / `$workflowConfig` / `workflowProfiles.default` names a profile no source has (or a `$workflowConfig` path that does not exist) | The script's `detail`: the name, the sources searched, the profiles known — never a silent fall back to the KB default |
| `profile-invalid` | The selected profile does not validate: unknown key or stage, `context: reuse` into a stage the transition table forbids (`validate`, `verify`, …), malformed JSON or `workflowProfiles` block | The script's `detail`, naming the offending field — nothing is dispatched |
| `profile-name-collision` | Two profile files under `workflowProfiles.files` declare the same `name` | Both paths, from the script's `detail` |
| `host-unsupported` | way-of-working declares a `pm-tool` / `code-host` with no adapter in `scripts/host/` | The declared value, the side, and the implemented set — never a GitHub fallback. Adding one: the host-adapter extension guide |
| `automation-policy-malformed` | `autonomy-policy.mjs resolve` answers `ok: false` (unknown key, malformed gate, `has:`/`lacks:` without `hold-when`, an `until` outside the enum, differing legacy coexistence) | Each `errors[].key` and reason, verbatim |
| `usage` | `$card` and `$pr` both given, or neither | The two valid entries |
An unrecognized `resolve` output is a HALT too, never a silent degradation: this skill fails closed everywhere.
## Graceful Degradation
- **No subagent primitive** (opencode, a nested dispatch, pi without `pi-subagents` once the user declined the install): HALT `realization-unavailable` (or `pi-subagents-missing`) and hand over the `pair-cli run --card` line. Nothing is half-run.
- **A Codex dispatch returns nothing structured**: irrelevant by construction — the handoff on disk is the contract, and Step 4 reads it.
- **The remote head moved between stages**: `resolve` reports `failed-resume`. Stop and report; a rebase is never repaired here.
- **A legacy (pre-schema-3) run directory**: `resolve` reports `incompatible`. Stop and point at `migrate-acknowledge`; never write into the legacy directory.
- **A run directory at the per-story ceiling**: `resolve` returns `blocked` / `failed-resume` with `cap: dispatchesPerStory`. It counts the PUBLISHED HANDOFFS in that directory, cumulatively across every resume, so it never clears by retrying — report the detail as it comes, `migrate-acknowledge` included.
- **A long cycle growing this session's context**: only `resolve` outputs are retained. When it still grows, the cycle is resumable — re-invoke on the same `$runId` and it continues from the first incomplete step, re-running no completed stage and opening no second PR.
## Output Format
`{ status, card, pr, runId, realization, terminal, reviewedHead?, roundsSpent, roundsBound, stages: [{ step, phase, context, dispatches, outcome }], halt?, detail? }` — `terminal` is one of `ready-for-merge | target-ready | escalated | escalate | failed-preparation | failed-contract | failed-implement | failed-fix | failed-verify | failed-resume | failed-hook | awaiting-scope-decision`, copied from `resolve`, never synthesized.
## Notes
- This skill is an opt-in execution layer. `/pair-process-implement`, `/pair-process-review` and `/pair-capability-publish-pr` keep working exactly as before, step by step, and none of them changes signature because this exists.
- It owns no cycle rule: caps, budgets, the effective-inputs composition and the freshness table are `cycle-state.mjs`'s data, and the argument packets are `cycle-dispatch.mjs`'s rendering. If you find yourself about to write a number here, it belongs in one of those two files.
- No new agent type: the four existing roles are reused as `agentType` (Claude) or role packet (Codex).
- It never merges, never closes a card, never deletes a branch, never files an issue and never posts a review comment.
- **Automation overview**: see the [automation overview](../../../.pair/knowledge/guidelines/collaboration/automation/delivery-cycle.md) for the whole delivery cycle — stages, autonomy gates, realizations.
Files in this skill
- SKILL.md
- scripts/autonomy-policy.mjs
- scripts/blocking-severities.mjs
- scripts/cycle-dispatch.mjs
- scripts/cycle-hooks.mjs
- scripts/cycle-merge.mjs
- scripts/cycle-prepare.mjs
- scripts/host/adapter-kit.mjs
- scripts/host/azure-devops.mjs
- scripts/host/filesystem.mjs
- scripts/host/github.mjs
- scripts/host/index.mjs
- scripts/host/linear.mjs
- scripts/pi-bridge.mjs
- scripts/run-guard.mjs
- scripts/workflow-profile.mjs
Attribution
Comments
Loading comments…
Newsletter
Get the weekly list of safe new skills, and the ones we pulled.