git:20260822.b43704d to git:20260826.1ed54bd

87 added, 667 removed. Audit A to A.

---
name: wise-workflow-run
description: >-
Start a new run of a registered workflow. The main Claude Code
conversation becomes the conductor — runs pre-flight (control mode +
worktree), resolves the target project, and executes the workflow's
DAG wave by wave via the Skill / Task / Bash tools, tracking per-step
state under a ULID run directory. Invoked as `/wise-workflow-run`
(bare alias) or `/wise:wise-workflow-run` (canonical). Use when the
user says "run the workflow", "start a workflow", "kick off
<workflow-name>", "run the ticket-plan workflow", or types
`/wise-workflow-run`.
argument-hint: "[<workflow-name> [<input1> <free-form remainder…>]]"
allowed-tools: Read, Write, Skill, AskUserQuestion, TodoWrite, Task, Agent, TeamCreate, TeamDelete, SendMessage, Monitor, TaskCreate, TaskList, TaskGet, TaskUpdate, TaskOutput, TaskStop, Bash(${CLAUDE_PLUGIN_ROOT}/scripts/bootstrap-deps.sh:*), Bash(${CLAUDE_PLUGIN_ROOT}/scripts/init-registry.py:*), Bash(${CLAUDE_PLUGIN_ROOT}/scripts/workflows.py:*), Bash(bash:*), Bash(python3:*), Bash(mkdir:*), Bash(git:*), Bash(test:*)
---
# /wise-workflow-run — the conductor
## Why this skill exists
Running a workflow is an orchestration task: parsing a DAG, tracking
per-step state, driving parallel executions, asking for approvals,
and surfacing progress. This skill owns that loop. Together with
`wise-workflow-resume`, it is one of only two skills in the plugin
allowed to invoke other wise action skills (as part of `type: skill`
steps from a validated workflow definition) — see the invariant
documented in `CLAUDE.md`.
## Arguments
Read `$ARGUMENTS`. The first whitespace-separated token is the
`workflow-name`; everything after it is **positional input values**
for the workflow's declared `inputs:` (see [§2](#2-resolve-the-workflow-name)
and [§6c](#6c-collect-workflow-inputs)). When `$ARGUMENTS` is empty,
the skill prompts interactively in [§2](#2-resolve-the-workflow-name).
- `workflow-name` — matches the filename without `.yaml`. Resolution
order: user dir first, bundled second. When absent, [§2](#2-resolve-the-workflow-name)
prompts the user to pick from the available definitions.
- positional inputs — optional. Tokens after the workflow name are
assigned to the declared inputs **in order**, and the **last
declared input absorbs the entire remainder of the line** (verbatim,
spaces preserved) so a trailing free-form prompt works. An input
satisfied positionally is **not** prompted for in §6c. Example:
`/wise-workflow-run ticket-auto PROJ-1 prefer the wise-estimation
skill; never touch infra/*` → `workflow-name=ticket-auto`, first
input `ticket_ids=PROJ-1`, last input `config_prompt=prefer the
wise-estimation skill; never touch infra/*`, no questions asked.
Only the **last** input may contain spaces; every earlier positional
is a single whitespace-delimited token, so a value that would
otherwise contain spaces (e.g. a multi-ticket list) must avoid them
when passed positionally — use `PROJ-1,PROJ-2`, or supply it via the
interactive prompt, which still accepts spaces.
+ The workflow YAML *schema* and step-type *semantics* are documented in
+ `docs/wise/workflows.md`; this skill is the imperative conductor
+ procedure. Three companion files under
+ `skills/wise-workflow-run/references/` carry the low-frequency parts —
+ `preflight.md` (session tagging + questionaries + inputs, read once per
+ run), `step-types.md` (team / supervised / interactive / approval / ask
+ dispatch, read only when a wave contains one), and
+ `roster-resolution.md` (tuning override + model/effort binding, read at
+ first prompt-step dispatch).
+
## Procedure
### 1. Init-check + list + picker — in ONE message
Run the init-check per `${CLAUDE_PLUGIN_ROOT}/references/init-check.md`,
firing in the SAME message: `init-registry.py check`, the data call
`workflows.py list-defs`, and a `select:AskUserQuestion` ToolSearch (so
the §2 picker is ready). On `INIT:ok`, use the `list-defs` output and
proceed to §2. Otherwise follow the reference's fallback — this skill
mutates state, so it **drives the need-python install loop** and
proceeds to §2 only once Python is `READY`.
### 2. Resolve the workflow name
If `$ARGUMENTS` is non-empty, use its first whitespace-separated token
as the workflow-name and skip the rest of this step (the `list-defs`
output from §1 is unused in this case — a cheap fork, not worth
re-ordering for). **Keep the text after that first token** — it is the
positional-input remainder consumed in [§6c](#6c-collect-workflow-inputs).
Call it `ARG_REST` (everything in `$ARGUMENTS` after the first token,
with the single separating run of whitespace stripped; preserve all
inner spacing). When the user typed a bare workflow name, `ARG_REST`
is empty.
Otherwise (user typed bare `/wise-workflow-run`), use the `list-defs`
JSON already captured in §1. If the array is empty, stop with:
```
No workflows are registered yet. Create one with
/wise-workflow-create <name>
or install a marketplace plugin that ships one.
```
Otherwise `AskUserQuestion`:
- Question: `Which workflow do you want to run?`
- Header: `Workflow`
- One option per entry. Label: `[bundled] <name>` for entries with
`source: bundled`, `[user] <name>` for `source: user` — the
bracketed tag is part of the label so the source is visible at a
glance in the picker, not buried in the description. Description:
`<description first sentence, truncated to ~80 chars>`. Mark
shadowed bundled entries with a trailing ` (shadowed by user
definition)` on the description so the user knows which one will
actually run. Add a final `Abort` option.
On pick, strip the `[bundled] ` / `[user] ` prefix from the chosen
label to recover the bare workflow name (or, simpler, look up the
chosen label's entry in the `list-defs` JSON array by label match
and read its `name` field). Set `workflow-name` to that bare name
and continue. On `Abort`, stop cleanly (no state written, no
"error" framing — the user chose to back out).
### 3. Locate the workflow definition
```bash
DEF=$(python3 "${CLAUDE_PLUGIN_ROOT}/scripts/workflows.py" locate-def "<workflow-name>")
```
Non-zero exit → relay stderr and stop.
### 4. Probe requires (with install-retry loop)
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/workflows.py" probe-requires "$DEF"
```
Exit 0 (stdout `OK`) → proceed.
Exit 2 → stdout has one or more `MISSING:` lines. Relay them to the
user with the exact install commands:
```
This workflow requires the following that are not installed:
- <missing item 1>
- <missing item 2>
Install them (out of band):
/plugin install <plugin>@<marketplace>
/plugin install <plugin>@<marketplace>
```
Then `AskUserQuestion`:
- Options: `I've installed them, re-check`, `Abort`.
On `re-check`, re-run the probe. Loop until OK or user aborts. No
auto-install.
### 5. Generate the run ID, tag the session, write stub state
The very first persistent act of a run is to allocate its ULID and
write a stub `state.yaml` that records the Claude Code session the
run was started in. Doing this before pre-flight (rather than after)
is deliberate: if the user later interrupts the conductor and
invokes `/wise-workflow-resume <run-ulid>` from a different session,
resume can compare the stored session UUID against the current one
and offer to send them back to the original session (where the
conductor's TodoWrite list and partial step logs are meaningful).
**5a. Allocate the run ID and run directory:**
```bash
RUN_ID=$(python3 "${CLAUDE_PLUGIN_ROOT}/scripts/workflows.py" new-ulid)
RUNS_ROOT="$(python3 "${CLAUDE_PLUGIN_ROOT}/scripts/workflows.py" runs-root)"
RUN_DIR="$RUNS_ROOT/$RUN_ID"
mkdir -p "$RUN_DIR/logs"
```
- **5b. Capture the current Claude Code session UUID:**
-
- ```bash
- SESSION_ID=$(python3 "${CLAUDE_PLUGIN_ROOT}/scripts/workflows.py" current-session-id)
- ```
-
- Exit 2 means no session jsonl was found — rare, usually when running
- outside a Claude Code conversation. Treat `SESSION_ID` as the literal
- string `null` in the JSON payload below and flag it to the user in
- 5f's question ("session untagged — resume won't be able to send you
- back to this session"). Do not abort.
-
- **5c. Derive the session label:**
-
- ```bash
- SESSION_LABEL=$(python3 "${CLAUDE_PLUGIN_ROOT}/scripts/workflows.py" \
- session-label "$RUN_ID" "<workflow-name>")
- ```
-
- Format is `<run-ulid>_<first-7-hyphen-tokens-of-workflow-name>` —
- short enough to fit in the `/resume` picker, long enough to
- distinguish concurrent workflow sessions.
-
- **5d. Check for an existing session claim:**
-
- A session that already hosts a *live* non-terminal run cannot cleanly
- host a second one — `/resume <session>` would return the user to a
- conductor whose loop belongs to whichever run renamed the session
- most recently, not whichever the user actually wanted. But a run that
- was abandoned mid-flight stays non-terminal (`running`/`paused`/
- `failed`) forever; it is not a live conflict and must not block a new
- run. Probe:
-
- ```bash
- python3 "${CLAUDE_PLUGIN_ROOT}/scripts/workflows.py" \
- find-runs-by-session "$SESSION_ID"
- ```
-
- Each stdout line is
- `<run-id>\t<workflow-name>\t<status>\t<last-activity>\t<fresh|stale>`
- for a non-terminal run in this workspace claiming the same session.
- The 5th field is the genuine-conflict signal:
-
- - **`fresh`** — the run checked in recently; the user is interrupting
- an in-flight conductor. This is the real conflict.
- - **`stale`** — the run's activity froze long ago (abandoned). Not a
- conflict. The classification (idle threshold, `WISE_SESSION_STALE_SECS`,
- default 30 min) is owned by `workflows.py`; do not second-guess it.
-
- Only prompt when **at least one match is `fresh`**. Then `AskUserQuestion`:
-
- - Question: `This Claude Code session already has another running
- workflow (<run-id>, <workflow-name>, <status>). Starting a second
- one means /resume won't cleanly return to either. Continue?`
- - Header: `Session conflict`
- - Options:
- - `Continue anyway — both runs share the session.` — proceed.
- - `Abort this run.` — stop without writing state.
-
- When every match is `stale` (or there are none), do **not** prompt —
- proceed straight to 5e. If any stale matches were present, drop a
- single informational line first (not a question), e.g. `Note: a prior
- run in this session (<run-id>) looks abandoned (idle since
- <last-activity>); proceeding.` so the reclaim is visible.
-
- Skip 5d entirely when `SESSION_ID` is `null` (nothing to conflict
- with) or when stdout was empty.
+ **5b–5d. Session capture, label, and claim check:** Read
+ `${CLAUDE_PLUGIN_ROOT}/skills/wise-workflow-run/references/preflight.md` NOW — once for the whole run; it also carries
+ §5f, §5h, §6b–§6c below — and follow its §5b (capture the session
+ UUID), §5c (derive the session label), and §5d (existing-claim
+ check).
**5e. Write the stub state:**
Build `CTX` as a JSON object with two keys — `claude_session_id`
(JSON-encode `SESSION_ID`, or `null` when it's empty) and
`session_label` (JSON-encode `SESSION_LABEL`), e.g.
`{"claude_session_id":"<uuid>","session_label":"<label>"}`. Then:
```bash
STATE=$(python3 "${CLAUDE_PLUGIN_ROOT}/scripts/workflows.py" \
init-state "$DEF" "$RUN_DIR" "$RUN_ID" "$CTX")
```
This writes `state.yaml` with the session fields, the steps list
(all `pending`), and `status: initializing`. Pre-flight answers get
folded in by `start-run` once they're collected.
- **5f. Prune old run directories (cap 25):**
-
- Workflow runs accumulate under `$RUNS_ROOT/<run-ulid>/` (which
- resolves to `~/.local/share/wise/runs/<cwd-slug>/<run-ulid>/` in the
- default layout)
- — each one keeps its state.yaml and step log files on disk forever
- unless something reclaims them. Cap the per-workspace total at **25**
- so history stays bounded:
-
- ```bash
- python3 "${CLAUDE_PLUGIN_ROOT}/scripts/workflows.py" prune-runs
- ```
-
- - Non-terminal runs (`initializing` / `running` / `paused` / `failed`)
- are **protected** — they're resumable and mustn't be thrown away
- just because the cap fits them out. Even if non-terminal alone
- exceeds 25, all are kept.
- - Among terminal runs (`completed` / `cancelled`), the oldest by
- `last_activity_at` are deleted first until the total is back at 25
- (or the non-terminals-alone figure, whichever is higher).
- - The cap is overridable via the `WISE_RUN_HISTORY_CAP` env var if the
- user wants more history for a given workspace.
- - `prune-runs` prints one `PRUNED:<run-id>` line per deletion on
- stdout; mention the count to the user ("Pruned N old runs.") only
- if any deletions occurred — silent when nothing was over the cap.
-
- This is a file-system cleanup only. Claude Code session transcripts
- (`~/.claude/projects/<slug>/<uuid>.jsonl`) are NEVER touched — those
- belong to the user's Claude Code history, not the wise plugin.
+ **5f. Prune old run directories (cap 25):** follow
+ `${CLAUDE_PLUGIN_ROOT}/skills/wise-workflow-run/references/preflight.md` §5f.
**5g. Read pre-flight pins from the workflow definition:**
Before any of the three pre-flight prompts (rename_session /
control-mode / worktree), read the workflow's `preflight:` block
— it may pin any or all of those answers, in which case the
corresponding prompt is skipped entirely:
```bash
eval "$(python3 "${CLAUDE_PLUGIN_ROOT}/scripts/workflows.py" \
get-preflight "$DEF")"
```
This sets five shell variables:
- `CONTROL_MODE` — `prompt` (default; ask §6a) | `wave-sync` | `synchronous` | `auto-advance`
- `WORKTREE` — `prompt` (default; ask §6b) | `current` | `new`
- `RENAME_SESSION` — `prompt` (default; ask §5h) | `skip`
- `TUNING` — `skip` (default) | `prompt` (ask §6b2)
- `STEP_SELECT` — `skip` (default) | `prompt` (ask §6b3)
Missing keys (or a missing `preflight:` block entirely) resolve to
each key's default — `prompt` for the three original questions
(matching pre-0.42 behaviour), `skip` for the two opt-in
questionaries, so a workflow only gets a tuning / step-selection
prompt by declaring it. Invalid values emit a `WARN:` line on
stderr and fall back to the key's default so the workflow still
runs.
**5h. Prompt the user to rename the session (skip if pinned):**
-
- If `RENAME_SESSION=skip`, skip this subsection entirely and just log:
- `Pre-flight pin: rename_session=skip (declared by workflow).` The
- `/resume` picker will show the raw UUID instead of a friendly label.
-
- Otherwise (`RENAME_SESSION=prompt`), print a short intro with the
- copy-pasteable rename command, then `AskUserQuestion`. The
- question and options are worded as forward-looking atomic actions
- — ("rename AND continue") — rather than past-tense checks ("have
- you renamed?"), because the latter misreads when the user hasn't
- clicked `Continue` yet:
-
- ```
- This run is tagged as:
- <SESSION_LABEL>
-
- To make the run findable in /resume's picker later, rename the
- Claude Code session to match. Run this in a separate message,
- then pick "Rename session and continue" below:
-
- /rename <SESSION_LABEL>
- ```
-
- - Question: `Rename this Claude Code session for /resume's picker?`
- - Header: `Rename`
- - Options:
- - `Rename session and continue` — `I've typed /rename in another message; proceed to pre-flight.`
- - `Skip rename and continue` — `Don't rename. Resume still works via UUID, but the /resume picker shows the raw UUID instead of the friendly label.`
- - `Abort this run` — `Mark the stub state cancelled and stop.`
-
- On `Abort this run`:
- ```bash
- python3 "${CLAUDE_PLUGIN_ROOT}/scripts/workflows.py" update-run \
- "$STATE" status=cancelled completed_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
- ```
+ follow `${CLAUDE_PLUGIN_ROOT}/skills/wise-workflow-run/references/preflight.md` §5h.
### 6. Pre-flight prompts
The two base-control questions below (plus, when the workflow opts
in, the §6b2 tuning and §6b3 step-selection flows — those are
multi-question flows, not single prompts). For the base controls: up
to two `AskUserQuestion`s in sequence (or batched — your call;
batching is fine since answers are independent). Each is skipped
when the workflow's `preflight:` block pinned the answer in §5g.
Pinned answers are logged (`Pre-flight pin: control-mode=wave-sync
(declared by workflow).`) so the user knows why the prompt didn't
appear.
**6a. Control mode (skip if pinned):**
- **`CONTROL_MODE=prompt`** → run the prompt below.
- **`CONTROL_MODE=wave-sync`** → skip; set the answer to `wave-sync`.
- **`CONTROL_MODE=synchronous`** → skip; set the answer to `synchronous`.
- **`CONTROL_MODE=auto-advance`** → skip; set the answer to `auto-advance`.
Prompt:
- Question: `How should the workflow run control progress?`
- Options:
- `Wave-sync (recommended)` — `Run one wave of steps, pause for me between waves. I can ask questions, steer, or abort mid-flight. Approval gates use AskUserQuestion.`
- `Synchronous` — `Run end-to-end without stopping. Approval gates are auto-approved (picking this IS the approval). Step output goes to per-step log files under the run dir, not the chat — tail state.yaml if you want to watch progress.`
- `Auto-advance` — `Run waves back-to-back without a between-wave menu, but still prompt at steps that need input (asks, approvals, AskUserQuestion inside interactive steps). Best when the workflow's only stops should be its own questions.`
- **6b. Worktree (skip if pinned):**
-
- - **`WORKTREE=prompt`** → run the prompt below.
- - **`WORKTREE=current`** → skip; set the answer to `Current tree`.
- - **`WORKTREE=new`** → skip; set the answer to `Dedicated worktree`
- (and go through the worktree-creation path as usual).
-
- Prompt:
-
- - Question: `Run in a dedicated git worktree or in the current tree?`
- - Options:
- - `Current tree` — `Use the cwd/project path as-is (default).`
- - `Dedicated worktree` — `Create a sibling worktree at <project-path>.wise-<run-ulid> on branch wise/<name>-<run-ulid>. Leave cleanup to me — I'll tell you the path at the end.`
-
- Store both answers for step 8; they go into `state.yaml` via
- `start-run` and persist across resume.
-
- **6b2. Model/effort tuning questionary (only if `TUNING=prompt`):**
-
- Fetch the workflow's tuning groups:
-
- ```bash
- python3 "${CLAUDE_PLUGIN_ROOT}/scripts/workflows.py" get-tuning "$DEF"
- ```
-
- → JSON `{groups: [{id, label, steps: [{id, model, effort}], default?}]}`.
- Exit 2 (an `INVALID:` authoring error) or an empty `groups` list →
- log a `WARN:` line and skip this subsection; a broken tuning block
- never blocks the run.
-
- The flow is **two-level** so the common case stays one click:
-
- 1. **Level 1 — profile.** One `AskUserQuestion`:
- - Question: `Model/effort profile for this run?`
- - Header: `Tuning`
- - Options:
- - `Defaults (as tuned) (Recommended)` — `Run every step group on
- the workflow's declared model/effort defaults:` + one line per
- group rendered from the JSON it already carries: the `default`
- string for an advisory group, else the per-step pins (collapse
- when uniform — `opus/high`; list the outliers when mixed —
- `opus/high (codebase-audit: sonnet/high)`).
- - `Economy` — `Run every tunable group on sonnet at high effort —
- cheaper and faster, less planning depth.`
- - `Custom` — `Pick model + effort per step group in a follow-up
- question.`
- 2. **Level 2 — per group (only on `Custom`).** One composite
- `AskUserQuestion` call, one question per group (workflows keep
- these ≤4 by design). Per question:
- - Question: `Model/effort for: <group.label>?`
- - Header: the group id (truncated to 12 chars)
- - Options: `Keep default (<current model/effort summary>)` /
- `Opus · high` / `Sonnet · high` / `Sonnet · low`. `Other`
- accepts a free-text `<model> <effort>` pair — resolve it through
- `resolve-model "<model>" "<effort>"` BEFORE recording, so a
- typo'd model or unsupported effort is caught (and clamped) here
- rather than flowing into dispatch as binding config.
-
- Resolve each group to either `default` or a concrete
- `<model> / <effort>` pair, then persist the choices into run state —
- one `record-output` per group plus the summary, **chained in a single
- Bash invocation** (each call rewrites state.yaml; N separate tool
- calls would be N round-trips for one logical mutation). The run stub
- exists since §4, so `record-output` works; outputs survive resume and
- render into `{{…}}` templates:
-
- ```bash
- python3 .../workflows.py record-output "$STATE" tuning_<group.id> "<default | model / effort>" \
- && python3 .../workflows.py record-output "$STATE" tuning_<group2.id> "…" \
- && python3 .../workflows.py record-output "$STATE" tuning_summary "<one line: '<id>: <choice>; …' or 'all defaults'>"
- ```
-
- The per-group `tuning_<id>` outputs are the machine channel (dispatch
- overrides, `{{tuning_<id>}}` templates); `tuning_summary` is
- display-only.
-
- Log the result (`Pre-flight tuning: authoring=sonnet/high, rest
- default.`). When `TUNING=skip`, record nothing — templates that
- reference `{{tuning_summary}}` render as a raw placeholder and the
- workflow's prompts treat that as "defaults stand". How the choice is
- applied at dispatch is §9's job (the `resolve-team --model/--effort`
- override for step-bound groups; the `{{tuning_<id>}}` /
- `{{tuning_summary}}` outputs for advisory groups with no `steps:`).
-
- **6b3. Step-selection questionary (only if `STEP_SELECT=prompt`):**
-
- Fetch the workflow's optional stages + presets:
-
- ```bash
- python3 "${CLAUDE_PLUGIN_ROOT}/scripts/workflows.py" get-step-select "$DEF"
- ```
-
- → JSON `{optional: [{id, label, steps, ask-group?}], presets: [{id,
- label, description?, skip}]}`. Exit 2 or an empty `optional` list →
- `WARN:` and skip this subsection.
-
- 1. **Level 1 — preset.** One `AskUserQuestion`:
- - Question: `Which stages should this run include?`
- - Header: `Stages`
- - Options: `Full — run everything (Recommended)` first, then one
- option per declared preset (its `description`, plus the skipped
- stage labels), then `Custom` — `Pick the optional stages to skip
- in a follow-up question.`
- 2. **Level 2 — per stage (only on `Custom`).** One composite
- `AskUserQuestion` call with `multiSelect: true` questions — one
- question per distinct `ask-group` (entries without one share a
- question, chunked ≤4 options), listing that group's stages:
- - Question: `<ask-group>: which optional stages should be SKIPPED?
- (select none to run them all)`
- - Options: one per stage — label = `entry.label`, description
- names the step ids it covers.
-
- Resolve the final set of deselected stage ids, then **pre-mark every
- covered step as skipped** in run state so the scheduler never
- launches them (the workflow's `trigger-rule`s — typically
- `none-failed` on the consolidation steps — are authored to flow past
- user-skipped dependencies):
-
- ```bash
- python3 .../workflows.py update-step "$STATE" <step-id-1> status=skipped \
- && python3 .../workflows.py update-step "$STATE" <step-id-2> status=skipped \
- && python3 .../workflows.py record-output "$STATE" skipped_stages "<comma-joined stage ids, or 'none'>"
- ```
-
- (One chained Bash invocation for all the marks — not one tool call
- per step.)
-
- Log it (`Pre-flight step-select: preset=standard — skipping
- deep-dive (research-context).`). The pre-marked statuses live in
- `state.yaml`, so a resumed run keeps the selection with no extra
- bookkeeping. When `STEP_SELECT=skip`, every step runs as authored.
-
- ### 6c. Collect workflow inputs
-
- Some workflows declare an `inputs:` section (top-level in the YAML)
- listing variables the user must supply before the DAG launches.
- Example:
-
- ```yaml
- inputs:
- - name: ticket_id
- prompt: "Which Jira ticket? (PROJ-18572 or a browse URL)"
- validate: "^[A-Z]+-\\d+$"
- extract: "([A-Z]+-\\d+)"
- ```
-
- Enumerate the workflow's declared inputs:
-
- ```bash
- python3 "${CLAUDE_PLUGIN_ROOT}/scripts/workflows.py" list-inputs "$DEF"
- ```
-
- stdout is a JSON array of `{name, prompt, validate?, extract?,
- optional?, options?, default?}`. If the array is empty, skip this
- section entirely and continue to [§7](#7-resolve-the-project).
-
- **First, split the positional remainder.** Map `ARG_REST` (from §2)
- onto the declared inputs in order: each input consumes one
- whitespace-separated token, EXCEPT the **last declared input, which
- absorbs the entire remaining substring verbatim** (inner spaces
- preserved) — that is what lets a trailing free-form prompt arrive
- intact. Build a `positional[<name>]` map of the values you extracted; an
- input with no token (including every input when `ARG_REST` is empty)
- stays absent from it.
-
- Then, for each input in order:
-
- 1. **Resolve the value.** Three cases:
- - **Supplied positionally** (`<name>` is in `positional`) → take
- that as the raw answer and **do not** `AskUserQuestion`; go
- straight to validation in step 2.
- - **Absent and `optional: true`** → default to the empty string,
- store it as `inputs["<name>"]`, and skip both the prompt and
- validation (an optional input the operator didn't supply is just
- blank — its step templates resolve `{{<name>}}` to empty).
- - **Absent and required** → `AskUserQuestion`:
- - Question: the input's `prompt` text.
- - Header: the input's `name` (truncated to 12 chars).
- - **Free-text input** (no `options` declared) → Options: `Other`
- only — the user types the value via free text. (Declaring only
- `Other` satisfies AskUserQuestion's minimum of two options by
- including the implicit "Other" affordance; if the harness
- rejects single-option calls, add a trailing `Cancel run` option
- and abort cleanly when picked.)
- - **Choice input** (`options` declared) → one option per entry:
- label = the entry's `label` (or its `value`), description = the
- entry's `description`. List the `default` value's option first.
- The recorded answer is the chosen option's **`value`** (not its
- label); `Other` free text is allowed and goes through the same
- validation as any typed value.
- - **Batching:** a RUN of consecutive choice inputs is asked as
- ONE composite `AskUserQuestion` call (up to 4 questions per
- call; overflow continues in a next call) — that is the
- "configure everything up front" questionary UX. Free-text
- inputs stay one-per-call as before.
-
- 2. Validate + extract via the engine — empty strings for regexes
- the input didn't declare (run this for positionally-supplied and
- prompted values alike; skip it only for the optional-default-empty
- case above):
-
- ```bash
- CLEAN=$(python3 "${CLAUDE_PLUGIN_ROOT}/scripts/workflows.py" \
- validate-input "<raw-answer>" "<extract-or-empty>" "<validate-or-empty>")
- ```
-
- Exit 0 → `CLEAN` holds the cleaned value; store it as
- `inputs["<name>"]`.
-
- Exit 2 → stderr carries `INVALID:<reason>`. Re-ask the input with
- the reason inlined into the prompt — via `AskUserQuestion` even if
- the rejected value came in positionally (a bad CLI value falls back
- to an interactive fix rather than aborting outright). Cap at 3
- attempts total; after the third, abort the run:
- ```bash
- python3 "${CLAUDE_PLUGIN_ROOT}/scripts/workflows.py" update-run \
- "$STATE" status=cancelled completed_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
- ```
- and tell the user the input couldn't be validated.
-
- Collect the answers into an `inputs_json` dict (e.g.
- `{"ticket_id":"PROJ-18572","note":"small bug"}`). Pass it through
- to [§8](#8-finalise-the-run-worktree--start-run)'s `start-run`
- payload — the engine merges the dict into `state.outputs` so
- `{{<name>}}` templates in step definitions resolve the same way
- captured step outputs do.
+ **6b/6b2/6b3. Worktree + the two opt-in questionaries, and 6c. Collect
+ workflow inputs.** Follow `${CLAUDE_PLUGIN_ROOT}/skills/wise-workflow-run/references/preflight.md` §6b–§6c (already read
+ in 5b). 6b prompts for the worktree unless pinned; 6b2/6b3 run the
+ model-tuning and step-selection questionaries when the pre-flight pins
+ ask for them; 6c collects and validates the workflow's declared
+ inputs (positional split, choice-input batching, `record-output`
+ persistence).
### 7. Resolve the project
`wise` keeps no persisted project registry — the project a run
operates on is derived from the current context. Read the workflow
definition's `project-selection`:
- `any` → `project = null`.
- `current` (default) → **auto-detect from the current directory**:
- `path` = `git rev-parse --show-toplevel` (fall back to `$(pwd)`
when not inside a git repo);
- `name` = the basename of `path` (or the `owner/repo` slug from
`git remote get-url origin` when one is set);
- `kind` = inferred from the repo's contents — `go.mod` → `backend`,
a `package.json` with a React/React-Native dependency → `frontend`,
both backend and frontend markers → `fullstack`, otherwise `other`.
- `prompt` → **ask the user** with `AskUserQuestion`: confirm the
auto-detected `path`/`name`/`kind` above, or let them override each
field (the "Other" free-text option accepts a path/name/kind the
detection missed).
Let `project_json` be the resolved `{path, name, kind}` or the string
`null`.
### 8. Finalise the run (worktree + start-run)
If the user picked a worktree at [§6](#6-pre-flight-prompts)b, create it now:
```bash
WT_DIR="<project.path>.wise-$RUN_ID"
git -C "<project.path>" worktree add "$WT_DIR" -b "wise/<workflow-name>-$RUN_ID"
```
(`<project.path>` is the resolved project path from §7, not a shell
variable — substitute the literal path. `${project.path}` is not valid
shell expansion.)
On git failure, fall back to the original `project.path` and pass
`worktree: null` below; tell the user the worktree couldn't be
created and why. If the worktree WAS created, override
`project.path` to `WT_DIR` in the payload below.
If the worktree was created, carry over any `.worktreeinclude` files
from the original base repo into it — `git worktree add` checks out
only tracked files, so untracked artifacts a tree needs to run
(`.env`, local config) would otherwise be missing:
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/workflows.py" \
apply-worktree-include "<project.path>" "$WT_DIR" || true
```
(`<project.path>` is the ORIGINAL base path from §7 — the source of the
`.worktreeinclude` file and the files it lists — not `$WT_DIR`. Run
this BEFORE overriding `project.path` below. The helper is best-effort:
no `.worktreeinclude`, a non-git base, or a missing listed path are all
silent no-ops and never abort the run, so the `|| true` is belt-and-braces.)
Fold the pre-flight answers into `state.yaml` (flipping
`status: initializing` → `status: running`) via `start-run`:
```bash
CTX='{"control_mode":"<mode>","worktree":<wt-json-or-null>,"project":<project-json-or-null>,"inputs":<inputs-json-or-empty-object>}'
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/workflows.py" start-run "$STATE" "$CTX"
```
(`jq` isn't in `allowed-tools`; construct the JSON yourself as a
literal string from the values you already have. Omit `inputs` or
pass `{}` when the workflow declared none — the engine tolerates
either. Example:
`'{"control_mode":"wave-sync","worktree":null,"project":{"path":"/path/to/project","name":"project","kind":"frontend"},"inputs":{"ticket_id":"TICKET-123"}}'`.)
### 9. Initial TodoWrite
Read the definition's `steps` list. `TodoWrite` one todo per step:
```
{ subject: "<step.id>: <short summary>", activeForm: "Running <step.id>", status: "pending" }
```
Summary hint per type: skill → the skill name; prompt → first 40
chars of the prompt; bash → first 40 chars of the command; approval
→ "awaiting approval"; ask → `ask: <def.output>`. A step §6b3
pre-marked `skipped` gets its todo created as `cancelled` (suffix
the subject with `— deselected at pre-flight`).
Tell the user: `Run <RUN_ID> started. state.yaml: <path>.`
### 10. Main loop
Repeat until a terminal state is reached in [§11](#11-finalise).
**IMPORTANT — turn continuity.** The main loop runs inside a single
Claude Code conversational turn. A turn ends as soon as you emit a
message that does **not** contain a tool call. That will **stall the
run** — the user will see the last output and nothing else, with no
prompt to continue.
The rule is simple and applies equally in wave-sync and synchronous
mode: **every message in the main loop must end with a tool call**.
Prose is fine — encouraged, even, because it's how the user sees
progress — as long as the message it lives in also contains the tool
call that moves the run forward.
Concretely:
- - Announcement prose (9d), step-outcome prose (9e), skip-report prose
- (9b), and the wave-sync menu summary (9g) all go into the **same
+ - Announcement prose (10d), step-outcome prose (10e), skip-report prose
+ (10b), and the wave-sync menu summary (10g) all go into the **same
message** as the tool calls that follow them. Never emit a bare
text-only message mid-loop.
- After every `update-step` bookkeeping batch, your very next tool
call is either (a) the next-wave Bash probe, or (b) the finalise
sequence in [§11](#11-finalise) if the wave just completed pushed
the run terminal. The bookkeeping + the following tool call can
live in one message; they don't have to be split.
- If you find yourself about to write "Step X complete. Running step
Y…" as prose and realise no tool call is following in the same
message, either bundle the next `next-wave` call into the same
message or drop the prose. The run's progress is reported *at* the
- moment of state change (9d and 9e), not between waves.
+ moment of state change (10d and 10e), not between waves.
- **9a. Ask what's next.**
+ **10a. Ask what's next.**
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/workflows.py" next-wave "$DEF" "$STATE"
```
Parse the JSON:
- `runnable: []` — list of step descriptors (each has `id`, `type`,
`definition` — the def merged with templates expanded).
- `to_skip: []` — step ids that should be marked `skipped` per their
`trigger-rule` (e.g. a dep failed and the rule is `all-success`).
- Optional `terminal: "completed" | "failed"` — when runnable and
to_skip are both empty AND no steps are `running` in state.
- **9b. Apply skips.**
+ **10b. Apply skips.**
When `to_skip` is non-empty, emit a prose preamble naming each
skipped step and why, then perform the bookkeeping in the SAME
message:
```
Skipping:
- ⊘ <step.id> (<type>) — <trigger-rule> not met: dep <dep-id>
is <dep-status>
- …
```
In the same message, for each id in `to_skip`:
```bash
python3 .../workflows.py update-step "$STATE" <id> status=skipped
```
And `TodoWrite` to mark the matching todos `cancelled`. Do **not**
emit the skip prose as a standalone message — it must share a
message with the tool calls that follow.
- **9c. Handle terminal.**
+ **10c. Handle terminal.**
If `terminal` is set, jump to [§11](#11-finalise).
- **9d. Run the wave.**
+ **10d. Run the wave.**
Build ONE message with:
1. A prose announcement naming each step about to run.
2. The per-step `update-step` bookkeeping that transitions each to
`status: running`.
3. The per-step execution tool calls (dispatched concurrently).
The announcement format:
```
Wave <N> — <count> step(s):
- ▶ <step.id> (<type>): <one-line description>
- ▶ <step.id> (<type>): <one-line description>
```
`<N>` is a simple counter the conductor increments per wave (1 for
the first wave, 2 for the second, …). Don't persist it in state; it's
display-only and doesn't survive resume.
The one-line description is generated from the step definition:
| Type | Description |
|---|---|
| `skill` | `invoke <def.skill>` + first non-empty payload key if any |
| `prompt` | first 60 chars of rendered `def.prompt` (one line, ellipsise); append ` [→ wise:<role>]` when an agent resolves |
| `supervised-prompt` | as `prompt`, plus ` [supervised]` (runs as a watched background worker) |
| `bash` | `$ ` + first 60 chars of rendered `def.command` + `(cwd: …)` if `def.cwd` is set |
| `approval` | `approval: ` + first 60 chars of rendered `def.message` |
Then, in the same message, for each runnable step allocate a
step-run-ulid and transition its state:
```bash
SID_RUN=$(python3 .../workflows.py new-ulid)
python3 .../workflows.py update-step "$STATE" <step.id> \
status=running run_id=$SID_RUN started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
log="logs/<step.id>.$SID_RUN.log"
```
And dispatch each step's executor — **all in the same message**
(multiple tool calls in one message execute concurrently per Claude
Code's tool-use docs). Per step type:
**Resolving agent / model / effort (prompt steps only).** Before
dispatching a `type: prompt` step, resolve its roster binding in one call:
```
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/workflows.py" resolve-team "$DEF" "<step.id>"
```
- **Tuning override (only when the run recorded one).** If the workflow
- declares a `tuning:` block and the run's state carries a
- `tuning_<group.id>` output that is not `default` (recorded by §6b2 —
- on resume, re-shell `get-tuning "$DEF"` once and read the outputs
- from state rather than trusting conversation memory), and this step's
- id is in that group's `steps:` list, append the user's choice to the
- call:
-
- ```bash
- python3 .../workflows.py resolve-team "$DEF" "<step.id>" --model <m> --effort <e>
- ```
+ **Tuning override (only when the run recorded one).** Binding a
+ recorded `tuning_<group>` choice at dispatch is defined in
+ `${CLAUDE_PLUGIN_ROOT}/skills/wise-workflow-run/references/roster-resolution.md` — read it (with the model/effort rules
+ below) the first time a wave dispatches a `type: prompt` step.
- The override wins over the step's AND every team member's pinned
- model/effort (the engine notes `run tuning override` in each member's
- `reason` — surface it on the 9e line like any other reason). Groups
- with no `steps:` binding (advisory groups) are not applied here —
- their choice reaches the workflow through the `{{tuning_<id>}}` /
- `{{tuning_summary}}` template outputs instead. For a step that
- resolves through `resolve-model` (mode `off` / `auto` / policy
- fallback), apply the same override by passing `<m>` / `<e>` as the
- `resolve-model` arguments in place of the step's own pins.
- → JSON `{mode, lead, members:[{role, lead, model, effort, reason, fell_back,
- next_fallback}], errors}`. These fields apply to `prompt` steps **only** —
- `interactive` steps run inline in this conductor (your own model) and `skill`
- steps run under the invoked skill's frontmatter; ignore `agent:` / `model:` /
- `effort:` on every other step type. A step pinning none of them inherits the
- parent session's model + effort (the harness setup at run time).
Branch on `mode`:
- **`errors` non-empty** → do NOT dispatch. Fail the step loudly with the
- error text on its 9e line + log. An unknown role, a policy keyword
+ error text on its 10e line + log. An unknown role, a policy keyword
(`auto`/`off`) used as a team member, or two leads is an authoring bug —
silently dispatching the wrong agent is worse than failing.
- **`mode: unset`** → the step pinned no `agent:`; apply the workflow's
top-level `agents:` policy (read once from `$DEF`; default `off`). `off` →
one `general-purpose` subagent; `auto` → roster-match (next).
- **`mode: off`** → one `general-purpose` subagent.
- **`mode: auto`** → pick the best-fit roster role. Cache the roster **once
per run**: on the first `auto`/policy-auto step, shell
`python3 "${CLAUDE_PLUGIN_ROOT}/scripts/workflows.py" list-agents` once,
hold the JSON (`{name, description, tools, model, effort}`) in working
memory, reuse it for every later wave/step — never re-shell per wave.
Match the step's rendered prompt intent against the `description`s AND
check `tools` cover what the step does: **writes/edits files** needs
`Write`/`Edit`; **runs shell or git** needs `Bash`; a step needing a tool
**no role has** (e.g. `TeamCreate`, a tracker MCP) fits none. **When no
role both matches the intent and covers the required tools, fall back to
`general-purpose`.** Resolve its model with `resolve-model
"<step.model or 'inherit'>" "<step.effort or ''>"`.
- **`mode: single`** → dispatch `members[0]` as one `wise:<role>` Task.
- **`mode: team`** → dispatch every member and synthesize (team flow below).
- **Model + availability (per member).** All execution is **in-conversation**
- (`Task` subagents under the active subscription — no extra API billing; no
- subprocess/headless backend). Each member's `model` is already resolved (a
- known-retired id swapped for its maintained alias) — pass it as the Task
- `model` param, **omitting** the param when it is `inherit`. If a member's
- `reason` is non-null, **surface it** (9e line + log: `<role>: <reason>`).
- Prefer aliases (`opus`/`sonnet`/`haiku`/`fable`) — they auto-resolve and
- rarely retire. On a LIVE model-unavailable failure for a member (the subagent
- errors model-not-found), retry that member ONCE with its `next_fallback`; if
- that also fails, fail the step.
-
- **Effort (per member).** `Task` has **no per-call effort param**, so each
- member's resolved `effort` is conveyed as a **prompt directive only** —
- best-effort, may be ignored today (forward-looking, Claude-Code-first). Append
- `\n\nReason at <EFFORT> effort — <gloss>.` Glosses: `low` = "be quick, minimal
- exploration"; `medium` = "balance speed and rigour"; `high` = "think
- carefully, weigh alternatives"; `xhigh`/`max` = "reason exhaustively; weigh
- edge cases and failure modes before answering". The `wise:<role>` agent's
- frontmatter `effort:` is its standing baseline; a member with null/unset
- resolved effort → append nothing. Use the **resolved** effort verbatim —
- `resolve-model` / `resolve-team` already applied the model's policy
- ceiling (Opus 5 tops out at `high`, so an authored `xhigh` comes back as
- `high`, with the step-down in `reason`). Never re-raise it to the step's
- authored value.
+ **Model + availability, and effort (per member).** Follow
+ `${CLAUDE_PLUGIN_ROOT}/skills/wise-workflow-run/references/roster-resolution.md` — model is a native Task param (omit on
+ `inherit`, retry once on `next_fallback`); effort is a prompt
+ directive only, appended from the RESOLVED value.
- `type: skill`:
```
Skill({
skill: "<def.skill>",
args: <def.payload as key: value per line, rendered>
})
```
- `type: prompt` — **single agent** (`mode` off / auto / single → one member):
```
Task({
subagent_type: <"wise:<role>" | "general-purpose">,
description: "Workflow step <step.id>",
model: <member.model — include ONLY if not "inherit">,
prompt: "<rendered def.prompt>"
+ [if member.effort] "\n\nReason at <EFFORT> effort — <gloss>."
+ [if def.until] "\n\nEnd your last line with a value that matches the regex /<until>/ so the conductor can capture it."
})
```
If `def.outputs` is set, remember the list so you can extract on
- return. Remember the resolved `agent` / `model` / `effort` too — 9e
+ return. Remember the resolved `agent` / `model` / `effort` too — 10e
reports them.
- - `type: prompt` — **team** (`mode: team` → ≥2 members, conductor-synthesized):
- one logical step worked by several roster roles. Keep it **atomic** — on
- resume mid-step the step re-runs whole (members are idempotent producers),
- so no extra run state is needed.
- 1. **Round 1 — parallel drafts.** Dispatch every **non-lead** member as a
- `Task` **in one message** (they run concurrently). Each: `subagent_type:
- "wise:<member.role>"`, its `model` (omit if `inherit`), and `prompt` = the
- rendered `def.prompt` prefixed with one framing line — *"You are the
- `<role>` on a panel. Give your `<role>` perspective on the task below; do
- NOT try to produce the team's final answer — a synthesis step does that."*
- — plus the member's effort directive. Do **not** pass `until:` to members
- (it governs the synthesis only). Capture each member's returned text as its
- draft and log it under the step log.
- 2. **Round 2 — lead integration** (only when `lead` is set). Dispatch the
- lead as one `Task` (`wise:<lead>`, its model/effort) with `prompt` = the
- rendered `def.prompt` + *"\n\nYour panel produced these inputs:\n`<role>`:
- `<draft>`\n…\n\nReconcile them into one integrated recommendation; call out
- where they disagree."* Capture the lead's proposal.
- 3. **Synthesis — you, the conductor** (main thread, **no** subagent). Merge
- the drafts (and the lead proposal, if any) into the step's single result:
- dedup overlaps, combine complementary points, surface disagreements. This
- synthesis IS the step output — if `def.until` is set, end it with the
- matching final line; if `def.outputs` is set, extract from it. Append the
- synthesis to the step log beneath the member drafts. Remember every
- member's `agent`/`model`/`effort` + the `lead` — 9e reports them.
-
- **Important — `prompt` steps run in an isolated Task subagent.**
- The subagent has its own tool list (for a `general-purpose` dispatch,
- the full set; for a `wise:<role>` dispatch, the role's scoped `tools`
- frontmatter — the reason auto-selection above is tool-aware) but
- **cannot** call `AskUserQuestion` — that tool only works in the main
- conversation. If the step needs to walk the user through a per-item
- wizard, use `type: interactive` below instead. A `prompt` step that tries to AskUserQuestion silently
- degrades (the subagent typically falls back to "list the items
- and return a summary"), which is worse than the step failing
- loudly because it looks like the step worked.
-
- - `type: supervised-prompt` — a `prompt` step run as a **supervised background
- worker** instead of a blocking `Task`, so a worker that hangs mid-turn or goes
- idle without finishing gets nudged back on task rather than silently stalling
- the wave (`Task` has no timeout/heartbeat — a hung subagent hangs the conductor
- indefinitely). Resolve `agent`/`model`/`effort` exactly as `prompt`
- (`resolve-team` → a single member; a team **list** is not supported here — one
- supervised step is one worker). Then follow
- `${CLAUDE_PLUGIN_ROOT}/references/supervise-loop.md`:
- 1. `TeamCreate({ team_name: "wise-<run.id>-<step.id>" })`.
- 2. `TaskCreate` the step's goal, then spawn ONE background worker:
-
- ```text
- Agent({
- team_name: "wise-<run.id>-<step.id>",
- name: "<step.id>-w1",
- run_in_background: true,
- subagent_type: <"wise:<role>" | "general-purpose">,
- model: <member.model — include ONLY if not "inherit">,
- prompt: "<rendered def.prompt>"
- + [if member.effort] "\n\nReason at <EFFORT> effort — <gloss>."
- + [if def.until] "\n\nEnd your last line with a value that matches /<until>/."
- + "\n\nHeartbeat: as your FIRST action each turn and after each significant tool call, run:\n"
- + "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/workflows.py\" worker-heartbeat \"<run.dir>\" \"<step.id>-w1\" \"<phase>\" \"<step.id>\""
- })
- ```
- 3. Arm the supervisor Monitor (`supervise-loop §3`) over the single worker name
- and run the loop — idle handler (§4), Monitor stale handler (§5), escalation
- ladder (§7) — until the worker's task is `completed`/`failed`.
- 4. **Teardown** (`§8`): `TaskStop` the Monitor, collect `TaskOutput` as the step
- result (capture `def.until` / `def.outputs` from it exactly as a `prompt`
- step), shut the worker down, `TeamDelete`. The step stays **atomic** — a
- mid-step resume re-runs it whole, after `TeamDelete`-ing the orphaned team
- (`§9`). All execution is in-conversation (subscription-covered) — the worker
- is a native background `Agent`, never a `claude -p` subprocess.
-
- - `type: interactive`:
-
- Runs inline in the conductor's main conversation instead of
- being spawned as a Task subagent. The conductor — that's you —
- reads the step's `prompt` body and executes it directly, with
- full main-thread tool access (Read, Edit, Write, Bash,
- AskUserQuestion, TodoWrite, etc.).
-
- Use this for step bodies that drive per-item wizards, iterate
- over a variable-length list with user decisions per iteration,
- or otherwise need to prompt the user mid-step. Pre-0.49
- workflows that used `type: prompt` for this pattern quietly
- lost the user-facing prompts (see the warning above) — switch
- them to `interactive`.
-
- Execution model:
- 1. Render `def.prompt` via the template engine (`{{…}}`
- variables) exactly as for a `prompt` step.
- 2. Read the rendered prompt in chat and follow it, emitting
- the final line as the prompt's `until:` contract demands.
- 3. Capture outputs from the final line's regex groups via
- `record-output` just like a `prompt` step.
-
- Trade-offs vs `prompt`:
- - Pro: full tool access, AskUserQuestion works, richer dispatch
- logic across turns (since the conductor stays in context).
- - Con: **sequential only**. An interactive step blocks the
- conductor's main conversation until it completes, so two
- interactive steps in the same wave cannot run in parallel
- (one must finish before the other starts). Non-interactive
- parallel steps in the same wave still run via Task as usual.
- - Con: context budget — the conductor accumulates tool output
- into the main conversation, unlike a Task subagent which
- releases its turn transcript back on return. Use for flows
- that genuinely need user interaction; stay on `prompt` for
- anything that can complete unattended.
-
- **In `wave-sync` or `auto-advance` mode** an interactive step may
- call `AskUserQuestion` freely — that's the point of `interactive`
- (e.g. `ticket-plan`'s `setup` questionnaire).
-
- **In synchronous mode** an interactive step still runs inline and
- captures its `until:` / `outputs:` exactly as above — `interactive`
- is chosen for main-thread tool access, not for prompting. It just
- must not call `AskUserQuestion` (sync mode is a blanket approval);
- treat any decision the body would prompt for as auto-approved and
- proceed, the same way `approval` steps do. `ticket-auto`'s
- `process-tickets` step depends on this combination — it is
- `interactive` for tool access while the run is `synchronous`.
+ - `type: prompt` — **team** (`mode: team`), `type: supervised-prompt`,
+ and `type: interactive`: dispatch per
+ `${CLAUDE_PLUGIN_ROOT}/skills/wise-workflow-run/references/step-types.md` — read that file the FIRST time a wave
+ contains any of these types (a run that never uses them never loads
+ it), then follow its recipe for the type.
- `type: bash`:
```
Bash({
command: "<def.command>",
description: "Workflow step <step.id>",
run_in_background: <true if timeout > 30>,
timeout: <def.timeout * 1000>,
...with explicit working-directory handled by prefixing `cd <def.cwd> && <command>`
(the Bash tool has no cwd field; the engine.sh pattern is to use
`cd <path> && ...` which is already in the allowed-tools grant).
})
```
- - `type: approval`:
-
- **In wave-sync or auto-advance mode** — use `AskUserQuestion`:
- ```
- AskUserQuestion({
- question: "<def.message>",
- header: "Approval — <step.id>",
- options: [
- { label: "Approve", description: "Mark this step completed and continue." },
- { label: "Reject", description: "Mark this step failed and stop the dependent branch." },
- ]
- })
- ```
-
- **In synchronous mode** — auto-approve. Picking synchronous at
- pre-flight is itself an implicit blanket approval to run the whole
- DAG through without stopping. The step transitions directly to
- `completed`, and a one-line note `[sync auto-approved]` is written
- to its log file so the decision is auditable after the fact. Do
- NOT emit `AskUserQuestion` in sync mode — it would stall the run
- and defeat the point of picking synchronous. If the workflow
- genuinely needs a human gate, the user should have picked
- wave-sync or auto-advance (or the gate should be upgraded to a
- `type: prompt` step that encodes the check as a programmable
- condition).
-
- - `type: ask`:
-
- Interactive step that captures an answer from the user and
- records it as a named output so downstream step templates can
- reference it as `{{<output-name>}}`. Two rendering shapes —
- which one you get depends on whether `confirm_label` is
- declared:
-
- **Shape A — free-text capture** (no `confirm_label`). The
- natural fit for "give me a comment" / "type a value":
-
- ```yaml
- - id: user-comments
- type: ask
- question: "<question text>" # required
- header: "<chip label>" # optional — ≤12 chars, defaults to step id
- output: user_comments # required — name under state.outputs
- skip_label: "Skip" # optional — defaults to "Skip"
- ```
-
- `AskUserQuestion` options:
- - `<def.skip_label or 'Skip'>` — description: `Record an empty value and continue.`
- - `Provide input` — description: `Type your answer via the free-text Other affordance.`
-
- Map the result:
- - Picked the skip label → `answer = ""`.
- - Picked "Provide input" → the user's Other-text is the answer.
- - The user picked Other directly with text → that text is the
- answer.
-
- **Shape B — binary choice** (`confirm_label` is declared). The
- natural fit for "yes/no" / "opt-in to this extra stage":
-
- ```yaml
- - id: ask-watch
- type: ask
- question: "<question text>" # required
- header: "<chip label>" # optional
- output: watch_choice # required
- skip_label: "No — I'll watch manually" # optional — defaults to "Skip"
- confirm_label: "Yes — watch pipelines" # required for Shape B
- confirm_value: "yes" # optional — defaults to confirm_label verbatim
- ```
-
- `AskUserQuestion` options:
- - `<def.skip_label>` — description: `Record an empty value and continue.`
- - `<def.confirm_label>` — description: `Record "<def.confirm_value or confirm_label>" and continue.`
-
- Map the result:
- - Picked the skip label → `answer = ""`.
- - Picked the confirm label → `answer = def.confirm_value` (or
- `def.confirm_label` if `confirm_value` isn't set).
- - The user picked Other directly with text → **ignore** and
- re-prompt. Shape B is deliberately binary; free-text doesn't
- apply.
-
- Downstream steps gate with `when: "<output> != ''"` or
- `when: "<output> == '<confirm_value>'"`.
-
- Pick Shape B whenever the question is yes/no — the "Provide
- input" free-text affordance in Shape A misleads users into
- thinking they need to type `yes`.
-
- Record the answer and mark the step completed (both shapes):
-
- ```bash
- python3 "${CLAUDE_PLUGIN_ROOT}/scripts/workflows.py" record-output \
- "$STATE" "<def.output>" "<answer>"
- python3 "${CLAUDE_PLUGIN_ROOT}/scripts/workflows.py" update-step \
- "$STATE" "<step.id>" status=completed completed_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
- ```
-
- **In `wave-sync` or `auto-advance` mode** — render the prompt
- normally (both shapes), exactly as above.
-
- **In synchronous mode** — no prompt. The step records an empty
- string for `<def.output>` (both shapes) and transitions straight
- to `completed`, with a one-line `[sync skipped ask]` note in its
- log. If the workflow genuinely needs a user answer, the user
- should pick wave-sync or auto-advance at pre-flight.
+ - `type: approval` / `type: ask`: dispatch per
+ `${CLAUDE_PLUGIN_ROOT}/skills/wise-workflow-run/references/step-types.md` (read it the first time a wave contains one of
+ these types).
- **9e. Collect and score.**
+ **10e. Collect and score.**
After the wave's tool results come back, build ONE message that
reports every step's outcome (prose) AND performs all the
bookkeeping (tool calls). Structure:
- 1. Prose preamble — one line per step, in the same order 9d
+ 1. Prose preamble — one line per step, in the same order 10d
announced them:
```
Wave <N> results:
- ✓ <step.id>: <one-line outcome>
- ✗ <step.id>: <one-line failure reason>
- ⊘ <step.id>: <skip reason>
```
Outcome wording per type:
| Type | On success | On failure |
|---|---|---|
| `skill` | `<last message first sentence, truncated to ~80 chars>` | `error: <error text, truncated>` |
| `prompt` | `captured <name>=<value>` for each `def.outputs`; else `ok (<duration>s)` | `no match for until:/<regex>/ after <iterations>` or subagent error |
| `bash` | `exit 0 in <duration>s` + first matched group of stdout if `stdout_matches` captures | `exit <code>: <first line of stderr, truncated>` |
| `approval` | `approved` (wave-sync) or `auto-approved` (sync) | `rejected by user` |
For a `prompt` step dispatched to a roster agent or with a `model:` /
`effort:` override, append the resolved knobs to its outcome line —
`[agent=wise:<role> model=<m> effort=<e>]`, omitting any that were not
set — and write the same to the step log so the routing is auditable.
For a **team** step, list each member and mark the lead —
`[team=wise:<lead>*, wise:<role2>, wise:<role3> | synthesized]` (the `*`
flags the lead; `synthesized` notes the conductor merged them) — and write
each member's `model`/`effort` to the step log. When a member's
`resolve-team`/`resolve-model` `reason` is non-null (a retired-id swap or an
effort clamp), append `; <role>: <reason>` so the substitution is visible in
chat, not just the log.
Keep each step's line under ~100 chars — if the "one-line" needs
more room, point at the log file instead (`see logs/<id>.<run-id>.log`).
**Surface the step's output when declared.** If the step def
includes a `surface:` field, render the requested content
inline *immediately after* the step's one-line outcome, so the
user can review it without opening a file. Shapes:
```yaml
surface:
file: <output-name> # read the file at state.outputs[<output-name>]
label: "Drafted PR body" # optional prefix shown above the content
max-lines: 400 # optional cap; default 400
```
Rendering:
```
- ✓ <step.id>: <one-line outcome>
<label> (<N> lines from <path>):
```markdown
<file contents, truncated to max-lines; add "… (<X> more lines)" line if truncated>
```
```
If `state.outputs[<output-name>]` is unset or the file is
missing/unreadable, render `<surface failed: <reason>>` in
place of the block — don't fail the step over a surface miss.
The content goes to the main chat message, not the step log
- (the log already captured the full subagent output). Only
+ (the log already captured its bounded excerpt of the output). Only
declare `surface:` when the content is genuinely worth reading
without extra clicks (drafted text the user has to approve,
small generated reports, etc.) — long binary or noisy outputs
should stay in the log file.
2. For each step in the wave, in the same message:
a. Determine success per type:
- `skill`: tool result returned without an error. On error,
capture the error text.
- `prompt`: extract the final assistant message. If
`def.until` is set, run the regex against the final message;
success only if a group matches. If `def.outputs` is
non-empty, capture the group(s) and `record-output` each.
- `bash`: interpret the tool result's exit code and output.
Apply `def.success.exit_code` (must match) and any
`stdout_matches` regex.
- `approval`: the user's pick (`Approve` → completed, `Reject`
→ failed).
- b. Append the full tool output to the step's log file. Use the
- `workflows.py write-log` subcommand, piping the output to
- stdin — NOT the `Write` tool, which prompts Claude Code's
- per-file permission dialog on first write:
+ b. Append a bounded excerpt of the tool output to the step's log
+ file. Use the `workflows.py write-log` subcommand, piping the
+ output to stdin — NOT the `Write` tool, which prompts Claude
+ Code's per-file permission dialog on first write.
+ Bound the excerpt: when the output is 120 lines or fewer, log
+ it whole; otherwise log the FIRST 60 lines, then a marker line
+ `[... truncated <N> lines — full output was in the conductor
+ transcript]`, then the LAST 60 lines (verdict/final lines
+ always live at the tail, so they are always captured).
+ Re-emitting the full output would double every step's token
+ cost in this conversation for no gain — the log is a debugging
+ aid, not the artifact channel (steps persist real artifacts to
+ files themselves).
+
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/workflows.py" write-log \
"$RUN_DIR" "<step.id>" "<step-run-ulid>" <<'WISE_LOG_EOF'
- <the full step output — subagent final message / bash stdout+stderr / etc.>
+ <the bounded excerpt — first 60 / marker / last 60 lines>
WISE_LOG_EOF
```
The `Bash(${CLAUDE_PLUGIN_ROOT}/scripts/workflows.py:*)` grant
already in `allowed-tools` covers this invocation, so every
subsequent log write runs silently. If the output content
might contain the literal token `WISE_LOG_EOF`, pick a
different heredoc delimiter (e.g. `WISE_LOG_<random 6 chars>`).
c. `update-step status=<completed|failed|skipped> completed_at=<utc>` —
plus `error=<short text>` if failed.
d. `TodoWrite` to update the matching todo (`completed` or
`cancelled`).
- **9f. Persist the run.**
+ **10f. Persist the run.**
`update-run last_activity_at=<utc>` is handled by every `update-step`
call — you don't need a separate write.
- **9g. Yield (wave-sync only).**
+ **10g. Yield (wave-sync only).**
The between-wave user-control menu fires in **`wave-sync` only**.
- `synchronous` and `auto-advance` both skip 9g and chain straight to
+ `synchronous` and `auto-advance` both skip 10g and chain straight to
the next wave (see the skip branch below).
If `state.control_mode == "wave-sync"`:
- 9e already printed each step's outcome in-chat, so don't re-summarise
+ 10e already printed each step's outcome in-chat, so don't re-summarise
here. Optionally tack one line on noting what's likely next (the ids
the next `next-wave` call will surface — cheap to predict from
`depends_on`). Then call `AskUserQuestion` in the SAME message:
- Options: `Continue`, `Pause (you'll resume later)`, `Abort run`, `Modify (freeform instruction)`.
- - `Continue` → loop to 9a.
+ - `Continue` → loop to 10a.
- `Pause` → `update-run status=paused`; print resume command:
```
Resume later with:
/wise-workflow-resume <RUN_ID>
```
Stop.
- `Abort` → `update-run status=cancelled completed_at=<utc>`; jump to [§11](#11-finalise).
- `Modify` → accept free-form instruction ("skip step X", "edit
prompt of Y and re-run", …). Interpret it against the current
state: for a skip, `update-step <id> status=skipped`; for an edit,
- update the in-memory definition and continue. Go back to 9a. If
+ update the in-memory definition and continue. Go back to 10a. If
you cannot interpret the instruction safely, surface what you'd
do and ask again.
If `state.control_mode == "synchronous"` **or
- `state.control_mode == "auto-advance"`**, skip 9g entirely. Don't
- emit a "proceeding" line or duplicate of 9e's results — but DO
+ `state.control_mode == "auto-advance"`**, skip 10g entirely. Don't
+ emit a "proceeding" line or duplicate of 10e's results — but DO
bundle your next `next-wave` Bash call into the **same message** as
- 9e's results and bookkeeping. That single message is the heartbeat
+ 10e's results and bookkeeping. That single message is the heartbeat
of these modes: it reports what the wave did AND kicks off the next
wave's probe, all while keeping the turn open.
In other words, both these modes have the same per-step reporting as
- wave-sync (thanks to 9d's announcements and 9e's outcome lines);
- what they skip is only the between-waves user-control menu (9g's
+ wave-sync (thanks to 10d's announcements and 10e's outcome lines);
+ what they skip is only the between-waves user-control menu (10g's
`AskUserQuestion`). They never have a moment where prose is the
last thing in the message — the next tool call is always there.
The difference between the two: `synchronous` also suppresses every
in-step prompt (approvals auto-approve, asks record empty,
interactive steps don't call `AskUserQuestion`), so it runs fully
unattended. `auto-advance` keeps all those in-step prompts — it only
drops the between-wave menu, so the run still stops wherever a step
genuinely needs the user's input.
### 11. Finalise
Terminal branch:
```bash
python3 .../workflows.py update-run "$STATE" status=<completed|failed|cancelled> completed_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
```
Print a summary:
```
Run <RUN_ID> — <status>.
Workflow: <name>.
Duration: <mm:ss>.
State: <path to state.yaml>.
Logs: <path to logs dir>.
[if worktree] Worktree at <path> — still there; prune with `git worktree remove` when ready.
```
## Guardrails
- Never invoke the `wise:wise` natural-language helper or another
`wise-workflow-*` skill. The only Skill invocations this skill is
allowed to make are the `type: skill` steps of a validated
workflow.
- Never write outside the run directory and (when requested) the
worktree path.
- Never skip the `next-wave` round-trip — do not compute runnable
steps yourself in-conversation. The script is the source of truth
for readiness.
- Never reorder or re-rank a wave — dispatch all runnable steps
together, in one message.
- - Never swallow step output — every step's full output goes to
- `logs/<id>.<step-run-ulid>.log`. The in-chat outcome line in 9e is
- a summary; the log file is the source of truth, and 9e should
- name it when the summary can't fit.
+ - Never swallow step output silently — every step's output is written
+ to `logs/<id>.<step-run-ulid>.log` as a bounded excerpt (first 60 /
+ truncation marker / last 60 lines, per §10e(b)); the full output
+ stays visible in the conductor's own transcript for this turn, it is
+ just not all persisted to the log file. The in-chat outcome line in
+ 10e is a summary; the log file's excerpt is the durable record after
+ the turn ends, and 10e should name the log when the summary can't fit.
- Every message in the main loop **must end with a tool call** — the
full turn-continuity rule is §10. A trailing text-only message stalls
the run, especially across a between-wave transition in synchronous
- or auto-advance mode, where 9g is skipped so there is no
+ or auto-advance mode, where 10g is skipped so there is no
`AskUserQuestion` to prompt the user back in.
- On `Modify`, changes are ephemeral: apply them in state.yaml if
persistent, but never rewrite the definition YAML (the user's
definitions directory is only mutated by `wise-workflow-create` and
`wise-workflow-remove`).