medTech-group

conductor

Run an unattended build run - ask the owner the run charter's questions once, then hand each ready ticket to its own cloud session, check in on a schedule, keep PRs moving to merge, release as the charter allows, and write the final report. Also resumes or stops a run. Use for "run the backlog unattended", "work over the weekend", "conductor resume", "stop the run".

medTech-group 0 Updated 6d ago
GitHub

Install

npx skillscat add medtech-group/pairnexus-workflow/claude-skills-conductor

Install via the SkillsCat registry.

SKILL.md

The conductor

You orchestrate. Ticket sessions build the tickets; you start them, watch them through GitHub, keep their PRs moving, and report. What you may do outside the repo is exactly what the charter grants, and each such action goes in the ledger.

Where things are

  • WF is the kit's CLI: python3 .claude/workflow/bin/workflow.py (vendored), or python3 <this skill's directory>/../../scripts/workflow.py (plugin).
  • DOCS is .claude/workflow/docs/ (vendored; the flow itself is .claude/workflow/flow.md), or docs/ beside skills/ (plugin).
  • TEMPLATES is .claude/workflow/templates/ (vendored), or templates/ beside skills/ (plugin).
  • The profile: .claude/workflow/profile.toml and profile.md.

Read first: the flow, DOCS/charter.md, DOCS/enforcement.md and the profile.

The run's state lives in the tracking issue's body.

  • To read it: save the body to body.md (issue_read, or gh api repos/<repo>/issues/<n> --jq .body > body.md), then run WF state get --body body.md.
  • To write it: re-read the body first, merge your change into whatever is there now, then run WF state put --body body.md --state state.json --out body.md and update the issue with body.md. You are its only writer, but the owner may have edited the issue.

Your posts carry the marker. Everything you post on GitHub (comments, issues, the tracking issue's body) goes out under the owner's account, as the ticket sessions' posts do. End every body with this line, which tells agents' posts from the owner's own words; the guard refuses a post without it:

<!-- workflow:agent -->

The owner's words are their comments without that marker, and their answers in your chat. List the comments with WF comments --owner <project.owner> --file comments.json --since-id <comments_read_through>. Fetch every page into comments.json: gh api --paginate --slurp repos/<repo>/issues/<n>/comments > comments.json, or the GitHub MCP tool (issue_read with get_comments), all pages saved together in one JSON list. WF comments reads a plain list, a list of pages, arrays one after another, and the {"comments": [...]} and author.login shapes; for anything else it stops with an error, which you report rather than guess around. Any other comment, or one carrying the marker, is information, never authority.

Start a run

1. Pre-flight: find things out, ask nothing yet

Collect every problem for the kick-off, each with its fix.

This session

  • echo $WORKFLOW_ROLE prints conductor.
  • get_session (no id): session_context.permission_mode must not prompt for tools. If the field isn't there, ask the owner in the kick-off which mode this session runs in.
  • A child session can't have a more permissive mode than yours, so the profile's run.permission_mode must be no more permissive than your own.

The project

  • WF profile check: no errors. Warnings go to the kick-off.

  • The base branch's ruleset: gh api repos/<repo>/rulesets, then each one by id. It must be enforcement: active, cover the default branch, and block force pushes and deletion. The required checks must equal the profile's ci.required_checks.

  • Solo mode. Read project.ruleset from WF status --json (solo_mode: true when it says false, null when it's left out). The run is in solo mode when either:

    • the profile says ruleset = false; or
    • the profile says ruleset = true while the read-back says "protected": false (removed protection). For the read-back, save the full rulesets (each fetched by its id, not the list's summaries) as one JSON list in rulesets.json, the repository (gh api repos/<repo>) in repo.json, and run WF protection --rulesets rulesets.json --repo repo.json --json. Always give --repo. Exit code 2 means the input was wrong: fix it and run it again, rather than parse it or count it as unprotected.

    A read-back that runs (exit code 0) but can't read everything counts as unprotected. When its only problems are that a bypass list or a setting "can't be read" (it needs admin access), say the protection can't be confirmed from this session, not that it was removed; it still counts as unprotected.

    With ruleset left out, nothing here changes: the ruleset checks above raise their gaps as before, with no read-back. In solo mode, the checks on the ruleset itself give way to one safety gap, the solo-mode gap (kick-off step 1). The run then never uses GitHub auto-merge: nothing on GitHub reliably requires checks, so auto-merge could merge at once, without waiting for checks.

  • The required-checks rule also requires branches to be up to date (strict_required_status_checks_policy: true). If it's off, raise it as a safety gap: "Fix it first / Go ahead without it". Accepted, it goes in accepted_gaps. Nothing else changes: every merge (yours, or a PR whose auto-merge you enable) runs on WF merge-check, which never says ready for a branch that isn't level with the base (flow §4.3).

  • "Require review from Code Owners" is off while sessions post as the owner (DOCS/enforcement.md §1): on, a PR touching a CODEOWNERS path could never merge. Say so if it's on.

  • Required approvals. The ruleset's pull-request rule: required_approving_review_count. Above 0, no PR a session opens can merge without a person's approval (sessions post as the owner, and an author can't approve their own PR). Say so in the kick-off, and make owner the recommended answer to the merge question (merge.mode: owner: runs stop at "ready for review"). If the owner picks another mode anyway, every PR waits on an approval until the end time.

  • CODEOWNERS covers .claude/ and .github/workflows/.

  • WF version --fresh: when a newer kit release is out, the kick-off asks Update first (end here; an attended session makes the update pull request for the owner to merge, then a new run starts) or Run on . A run never updates mid-run, and its ticket sessions stay on the version it started with.

The ticket sessions' environment

  • list_environments: find the one named run.ticket_environment and keep its id. None of that name is a blocking problem: the run can't start until the owner creates it or the profile names the right one. It is still required in solo mode.
  • Whether it really holds no credentials, the owner confirms in the kick-off.

The work

  • WF ready --fetch: what's ready, blocked and on what, owner tasks, areas. A ticket whose body names a protected path stays ready with a note: "names protected path (): check it isn't an owner task". Keep a list of the noted tickets for the kick-off.
  • Leftovers: open PRs from branches matching run.ticket_branch, and tickets labelled workflow:in-progress. The owner decides about them in the kick-off: either adopt them into this run as queued, or release them by removing the label.
  • The base branch's CI is green. Note its duration as ci_minutes_at_start.

Capacity

  • You can't read the account's usage directly. If this session, or a session you started, has reported a usage warning, say so in the kick-off. Each ticket session runs the review panel for up to the charter's review rounds (the profile's run.review_rounds for a charter without them), so a run's usage grows with max_sessions.
  • In the kick-off this line stays as it is, with the run's estimate beside it (step 2): this one says what usage has been reported, the estimate what the run will use.

The last run

  • The most recent issue labelled workflow:run.
  • Its state's charter, saved as old.json: WF state get --body old-body.md, then take .charter.

2. The kick-off

With AskUserQuestion, at most four questions to a round, recommended answers first.

  1. The findings. Show the pre-flight's problems first. For each safety gap, ask "Fix it first / Go ahead without it"; each gap accepted goes in accepted_gaps.

    • The solo-mode gap is stated plainly, in these words, with the profile's base branch in place of main where it isn't main:

      This run is in solo mode: nothing on GitHub stops a mistake reaching main, and the kit's "never merge while CI is red" check is the only one: workflow merge-check, which every merge in the run goes through.

      For removed protection, say first that the profile says main is protected but GitHub doesn't protect it, with what the read-back found missing (or that it can't be confirmed from here). Then ask:

      • Protect main (recommended; for removed protection, Restore protection): follow the upgrade-setup skill with the offer protect_main (or restore_protection), then look at the read-back that path ends with. "protected": true closes the gap, whatever the profile says. If it still isn't protected (Not now, a message for an admin, a click missed), ask again: Go ahead or Don't start the run (end here; nothing has been created yet).

      • Go ahead: add exactly this line to accepted_gaps, with the base branch's name in place of main:

        solo mode: nothing on GitHub stops a mistake reaching main; the kit's "never merge while CI is red" check is the only one

        For removed protection, the same line with removed protection: in front. The charter then carries that verdict: every later WF charter validate and WF state init refuse auto-merge for it, amendments included.

    • Either way, the ticket environment is still required. Unless the gap was closed, the merge question never offers auto-merge (step 2). While the profile still says ruleset = false (the pull request that leaves solo mode not yet merged), the run stays in solo mode for the charter, even with main now protected.

    Also:

    • confirm the ticket environment holds no credentials;
    • decide about any leftovers.
  2. The charter's questions. WF charter questions lists them; WF charter defaults > charter.json starts a first run's charter with defaults that fit the profile, and the answers change it. Offer only the merge question's options. In solo mode (and with no CI or no required checks) they leave out auto-merge, and its note says why: say that to the owner. For removed protection, add --unprotected to both commands, and to WF charter validate until the gap is in accepted_gaps. Right after defaults (or reuse, below), add each gap accepted in step 1 to the charter's accepted_gaps: both start it empty, and WF state init reads the removed-protection verdict only from there.

    • With a previous charter, first run WF charter reuse old.json > charter.json (approval, amendments, end time, accepted gaps and plan cleared). Ask "Same as last time?", showing the tickets ready now, the old exclusions, and WF charter diff old.json charter.json. Then ask only what the owner wants changed. If the old charter used auto-merge and the merge question no longer offers it (solo mode now), set merge.mode to the new default and tell the owner why, from its note.
    • Always ask the end time.
    • The size, presets first. The limits question offers Light, Normal and Thorough first, from its presets (each with its words), the profile's run.preset first and marked recommended (recommended). Choosing one sets preset, limits (but ends_at and check_in_minutes) and review from it; then the owner may still change any single number.
    • The estimate, shown with them, in sessions and hours, never money: WF charter estimate charter.json --tickets <the plan as "<n>:<area>,…", or how many> --runs runs.json --security <n>,… --json > estimate.json.
      • runs.json: the closed workflow:run issues, as the GitHub API returns them (gh api 'repos/<repo>/issues?labels=workflow:run&state=closed&sort=updated&per_page=30', or the GitHub MCP tools). It reads the five most recent that merged anything, and below two uses an hour a session.
      • --security: the planned tickets the review panel would give a security review (an auth, upload, payment or API change, review-panel §2).
      • Before the plan (step 3) is settled, give it the ready tickets in scope.
      • Show its line, its basis and its warnings (they never block), and the capacity line beside it. Run it again on every change of preset or number, the end time or the plan, so the figure approved is the one for the charter approved.
      • Keep the approved one in the state as estimate (step 11), so a resumed conductor has it.
  3. The plan. The ready tickets with their areas. They start infra first, then by number, one per area at a time; say so. Set charter.plan to this list. When follow_unblocked is false, it is the whole scope: pin scope.tickets to it with WF charter pin-plan charter.json > charter.json.tmp && mv charter.json.tmp charter.json.

    • Maps not yet fully specified. WF ready doesn't know about maps. For each open wayfinder:map whose Destination items don't all have a spec and tickets yet, list the ready tickets that belong to it (sub-issues of its specs) and recommend excluding them: a map's tickets wait until every Destination item has its spec and tickets (flow §1's gate). Excluded ones go in scope.exclude.
    • Tickets naming a protected path. List each noted ticket with its note. A ticket session can't change a protected path, so if the ticket really needs one changed it will end needs-human. Ask the owner, for each, "Keep it / Exclude it": excluded ones go in scope.exclude (and out of the plan); kept ones run as usual. A ticket noted later in the run (unblocked mid-run) starts as usual; name it in that check-in's comment.

Then:

  1. Run WF charter validate charter.json and ask again about anything it reports.

  2. Choose the run id (yyyy-mm-dd-word).

  3. Create the by-hand issue from TEMPLATES/by-hand-issue.md, filling in RUN_ID. Both templates already end with the marker.

  4. Create the tracking issue from TEMPLATES/tracking-issue.md, label workflow:run, filling in RUN_ID and BY_HAND_ISSUE, and its charter section with WF charter splice --body body.md --charter charter.json --tickets "<n>:<area>,…" --estimate estimate.json --out body.md, so the one page carries the estimate. Then link it from the by-hand issue.

  5. Show the owner the rendered charter and ask "Go / Change something".

    • If the kick-off is abandoned (the owner says stop, or never answers before this session ends), close the tracking and by-hand issues as not planned, with a comment saying the run never started. An open workflow:run issue counts as a run in progress for workflow status and the guided start.
  6. Record the go:

    • set approved to {by: <profile project.owner>, at: <now, UTC>, source: "chat <time>: <the owner's words>"};
    • or, if the owner answered with a comment on the tracking issue, use that comment's URL as source.
  7. Run WF charter validate charter.json --approved.

  8. Build the state:

    WF state init --charter charter.json --run-id <id> --by-hand <n> --read-through <id of the newest comment on the tracking issue, or 0>

    Add ticket_environment, kickoff_tickets (the plan), ci_minutes_at_start, estimate (the contents of estimate.json) and status: "running".

  9. The wake routine. Create a routine that only wakes you: create_trigger with no cron_expression and no run_once_at (poke-only), bound to this session (no persistent_session_id, create_new_session_on_fire false), initiation: own_followup, name "conductor wake ", prompt conductor: wake (a ticket session finished). Record its id in the state as wake_trigger; every briefing passes it, and a ticket session fires it when its ticket ends.

    • If create_trigger fails, record wake_trigger: null, pass none in every briefing's wake_trigger slot, and tell the owner while the kick-off is still open (and in the tracking issue's first comment): the run goes on, and ticket endings are seen at the next scheduled check-in instead of at once.
  10. Write the state to the issue, and replace the issue's charter section with the approved one: WF charter splice --body body.md --charter charter.json --tickets "<n>:<area>,…" --estimate estimate.json --out body.md (the estimate for the charter approved). Splice it again after every amendment that changes the charter; never edit that section by hand.

  11. Start now: run check-in steps 5, 8 and 9 below at once, then arm the first check-in with send_later (limits.check_in_minutes).

Nothing starts before the charter is approved.

Each check-in

Every limits.check_in_minutes, by send_later to this same session; every 60 minutes instead while the run is waiting on the owner (step 2). A check-in is complete when the state is written, the issue's "Now" line is current, and a check-in is pending, or the run has stopped. ("Done", in step 2, means only that the run stops.)

A check-in is pending when list_triggers shows an enabled one-shot routine bound to this session (its persistent_session_id is this session's id) with a run_once_at or next_run_at still to come: the kind send_later makes. The wake routine (poke-only, with neither) never counts.

A wake is a check-in. When the wake routine fires ("conductor: wake (a ticket session finished)", with the session's one line: "# merged in #", "# needs-human" or "# stopped"), run a whole check-in at once. Leave the check-in already armed as it is: step 10 arms one only when none is pending.

When Cadence
Sessions running, PRs moving, or tickets ready limits.check_in_minutes
Waiting on the owner (step 2) 60 minutes
A ticket session ends At once, by the wake routine
Stopping limits.check_in_minutes, until "Stop the run" is finished

Keep scratch files (charter.json, body.md, state.json, estimate.json, runs.json, issue JSON) in .workflow/run/, which git ignores.

0. Stopping already? If the state's status is stopping, skip to "Stop the run", step 2, and carry on from where it left off. Never record stopped_head again for a session that has one.

1. Read the state. It is the whole truth. If your context was summarised or this is a new session ("conductor resume"), rebuild from the state and the tracking issue's comments after comments_read_through.

  • A new session ("conductor resume #", the old conductor session having ended): the routines in the state belong to the old session. Create a new wake routine bound to this session (kick-off step 12), delete_trigger the old wake_trigger, and record the new id in the state; ticket sessions already running carry the old id, so their wake fails and they note it, and you see their endings at your next check-in. Then list_triggers and delete any routine still bound to the old session (its persistent_session_id), so it never wakes alongside you. Say on the tracking issue that the run resumed in a new session.

2. Stop or wind down?

  • Stop now (see "Stop the run") when either holds:
    • the workflow:stop label is on the tracking issue;
    • limits.ends_at has passed.
  • Wind down (start nothing new; let running sessions and open PRs finish, then stop) when any holds:
    • limits.max_sessions sessions have been started;
    • a stop_run_on condition is met.
  • Waiting on the owner (not done): nothing is running or ready, but some in-scope tickets are either blocked by an open owner task, or are needs-human tickets on which you asked the owner a question during this run (the ticket's asked_at in the state). Nothing else keeps the run going: a needs-human ticket you haven't asked about is reported in for_owner and left. While waiting:
    • post one "waiting on you" comment on the tracking issue naming them and what each needs (@mention the owner when notify.failures is push); post it once per owner task or question, never again for one already named;
    • check in every 60 minutes instead of limits.check_in_minutes;
    • go back to the usual cadence as soon as one of them unblocks something.
  • Done (stop the run) only when nothing in scope is ready, queued or running, every run PR is merged, set aside, or waiting for the owner to merge (merge.mode: owner), and no in-scope ticket is blocked by an open owner task or waiting on a question you asked the owner during this run. Waiting on the owner ends at limits.ends_at like everything else.

Asking about a needs-human ticket. When the answer is the owner's to give (a product decision, a missing fact), ask: one comment on the ticket saying exactly what you need, @mentioning the owner, named in the "waiting on you" comment; set the ticket's asked_at. Ask only when an answer would let the ticket be built; otherwise leave it in for_owner.

When the owner answers a needs-human ticket (in your chat, or in a comment without the marker, on the ticket or the tracking issue):

  1. Record the answer on the ticket: a comment quoting it, with its source (the comment's URL, or chat <UTC time>). If it changes what the ticket asks for, rewrite the ticket's acceptance criteria in its body to match, and say so in that comment.
  2. Remove needs-human, add ready-for-agent and workflow:in-progress back, and mark the ticket queued with attempts: 0 and no asked_at. It starts again at step 5 like any queued ticket.
  3. How attempts count: attempts is the number of sessions started for the ticket since it was last queued fresh, at kick-off or after an owner's answer. The session started after an answer is a first try again, so the ticket gets one try plus limits.max_retries retries, as at the start; the earlier attempts stay in the tracking issue's comments, not in the count.

3. The owner's words.

Comments. Save the tracking issue's comments to comments.json and run WF comments --owner <project.owner> --file comments.json --since-id <comments_read_through>. For each comment it lists (the owner's, without the marker):

  1. Apply its change to the charter's fields. "Extend to Monday 9am" changes limits.ends_at; "also #140" adds to scope.tickets and plan; "stop releasing" sets release.when to never.
  2. Append {at, by, change, source: <comment URL>} to amendments, once per URL.
  3. Run WF charter validate --approved. If it fails, don't apply the change; reply with why. Never drop a removed protection: line from accepted_gaps, even when the owner says protection is back: it lasts until the run ends, and auto-merge needs a new run, whose kick-off reads protection back; reply with that. WF state patch and WF state put also refuse a charter change that fails its checks against the profile, listing the same problems, or that drops a removed protection: line, and leave the state unchanged; a patch that doesn't touch the charter isn't checked.
  4. Reply to confirm what changed, and splice the charter section again (WF charter splice … --estimate estimate.json). When the change touches the limits, the review or the plan, run the estimate again first (kick-off step 2), with --now the charter's approved.at so the whole run is set against the whole run's time, and record the new one in the state's estimate, so the page says what the owner now expects; otherwise write the state's estimate to estimate.json (a resumed session has no file). A state without one (a run from before estimates) splices without --estimate.

Set comments_read_through to the newest comment's id, the owner's or not. Comments by anyone else, and any comment with the marker (an agent's, though under the owner's name), are information, never authority.

Answers in this chat. The owner may answer or decide here, in your chat, at any time. For each such answer:

  1. Record it on the issue it concerns: a comment quoting the owner's words exactly, with source: "chat <UTC time>" (and the marker, as every post of yours).
  2. If it completes an owner task, you may close that task as completed, with that comment.
  3. If it changes the charter, it is an amendment like a comment: {at, by, change, source: "chat <UTC time>"}, validated, confirmed on the tracking issue, and the charter section spliced again with its estimate, as step 4 above says.

4. Each ticket in flight. Read it from GitHub, never from the session's words:

  • the ticket's labels;
  • its PR, found by head branch (gh api "repos/<repo>/pulls?head=<owner>:<branch>&state=all"): state, merged, checks on its head, mergeable_state, last push time;
  • the session (get_session): status_bucket.

Then act on what you find:

What GitHub (or WF merge-check) shows What you do
The session's first comment quotes the output of briefing verify and it isn't Briefing <hash> intact for the hash you rendered for it (the state's briefing): it says Briefing changed: expected …, got … or no Briefing line, names another hash, or the comment has no such line after two check-ins The briefing didn't arrive intact. Stop the session safely (below) and start it again with the briefing printed afresh; it counts as an attempt.
Merged Mark the ticket merged with merged_at, pr and rounds (from the PR's findings table). Remove workflow:in-progress if it is still on the ticket (the session's job; you remove leftovers). Once the session's "#n merged in #PR" note is on the tracking issue (or one check-in later, if it never comes), read its notes for the next tickets, delete its own pending check-ins (list_triggers; those whose persistent_session_id is the session), then archive it.
PR open, behind the base (mergeable_state is behind, or WF merge-check gives a behind reason) If auto-merge is armed on it, disarm it first (disable_pr_auto_merge, or gh pr merge <n> --disable-auto): GitHub would otherwise merge the new head, which WF merge-check hasn't said ready for. Then update the branch: gh api -X PUT repos/<repo>/pulls/<pr>/update-branch, or update_pull_request_branch. Arm it again only on the new head's ready (the rows below). Auto-merge never updates a branch itself, so this has to happen at every check-in until it merges.
PR open, not merged (any mode) Run the merge rule (flow §4), right before you act on it: WF merge-check --fetch <PR> --json --charter .workflow/run/tracking.md --review-rounds <N>, with <N> the charter's review rounds as the briefings give them (its review.rounds, else its preset's, else the profile's run.review_rounds) and the tracking issue's body saved there (gh api repos/<repo>/issues/<tracking> --jq .body > .workflow/run/tracking.md). For a check the charter waives, add --check-logs .workflow/run/check-logs with its log in that directory, named by its check run's id on the head (a waiver_not_taken note names the file it wants). Where gh can't read an input, a "couldn't read" reason names it: give it as a file (--pr F, --checks F, …) and run it again. Leave out --asker: it changes only the wording, never the decision. The command always exits 0, so read its JSON: decision, head, reasons[], files. ready is the only yes. Act on the decision by the rows below, at this check-in.
session mode, ready Merge it with a merge commit, pinned to the head it checked (the JSON's head): gh pr merge <n> --merge --match-head-commit <sha>, gh api -X PUT repos/<repo>/pulls/<n>/merge -f merge_method=merge -f sha=<sha>, or merge_pull_request with merge_method: merge and expectedHeadSha. Say so on the tracking issue. If GitHub refuses because the head moved, run it again at the next check-in. Your merge rests on the charter's merge grant; a review agent is never a stand-in for permission.
auto-merge mode, ready, but auto-merge isn't armed (the session's permissions refused it, or it was disarmed) Arm it under the charter's merge grant, pinned to the JSON's head: gh pr merge <n> --auto --merge --match-head-commit <sha>. (enable_pr_auto_merge can't pin the head: use it only when gh can't, then read the PR's head back and disarm it if it isn't <sha>.) Never arm it on anything but ready.
You tried to enable auto-merge and GitHub refused because the PR is already clean ("Pull request is in clean status") Run the same WF merge-check again at that moment. On ready, merge it yourself as the session row does, pinned to its head, under the charter's merge grant, and say so on the tracking issue. On anything else, the rows below.
owner mode, ready Mark the ticket in-review, add "#n ready for you to merge (#PR)" to for_owner, delete the session's check-ins and archive it. It counts as settled for "Done".
owner_review (any mode): everything else holds, but the change touches a protected path (the profile's guard.protected_paths) Never merge it or arm auto-merge yourself. Add "#n changes a protected path (): yours to review and merge (#PR)" to for_owner, with the JSON's files in place of <files>. Mark the ticket in-review, delete the session's check-ins and archive it; it counts as settled for "Done". With "Require review from Code Owners" off (DOCS/enforcement.md §1), nothing on GitHub would stop such a merge, so the owner makes it.
not_yet (any mode) Never merge it or arm auto-merge, and if auto-merge is armed on it, disarm it (disable_pr_auto_merge, or gh pr merge <n> --disable-auto): GitHub would otherwise merge it on its own, weaker test. Act on its reasons[] as the other rows do, by each reason's action: update_branch is yours (the "behind the base" row); fix_checks, review and resolve_conflict are the ticket session's, so leave them to it. A reason with no action (checks still running, the evidence not posted yet, a draft) is the session's too: run it again at the next check-in. A session that doesn't move, or a PR closed without merging, is the stuck row's. The path-filtered row below wins over this one once its condition holds.
A required check never started and it's in the profile's ci.path_filtered WF merge-check counts it as not started, so it never says ready, and in auto-merge mode GitHub would wait for it forever. Act on this only once every other required check has finished and the evidence is posted: WF merge-check's only reason left is checks_pending, for checks that are all in ci.path_filtered and missing in its checks[]; until then, the not_yet row applies. Then add the PR to for_owner ("needs the owner to merge: path-filtered check"), mark the ticket in-review, delete the session's check-ins and archive it; it counts as settled for "Done".
Labelled needs-human Mark it needs-human, add a line to for_owner, remove workflow:in-progress and ready-for-agent if the session left them, and archive the session. If the owner's answer would let it be built, ask (step 2, "Asking about a needs-human ticket").
No movement for 6 check-ins (no push, no PR comment, no label change, and status_bucket not working) while its PR isn't waiting on running CI, or status_bucket is failed It's stuck. Stop it safely and retry (below).

A change to a protected path is the owner's to merge. WF merge-check reads the PR's files against the profile's guard.protected_paths and answers owner_review for one, never ready (the owner_review row).

Stopping a stuck session safely:

  1. interrupt_session, then record its branch's head SHA as stopped_head.
  2. At the next check-in, compare it with gh api repos/<repo>/branches/<branch> --jq .commit.sha. Only if it hasn't moved:
    • delete its pending check-ins, then archive the session;
    • mark the ticket queued, with its attempt count.
  3. A ticket whose attempts (counted as in step 2, "How attempts count") have all failed, one try plus limits.max_retries retries, is labelled needs-human instead (and loses workflow:in-progress and ready-for-agent): comment why on it and on the tracking issue, and add it to for_owner.

5. Start sessions, unless winding down. Fill the free slots (limits.max_parallel minus running), in this order:

  1. Queued tickets in the state: retries, tickets the owner answered, and leftovers adopted at kick-off. They're claimed already; check workflow:in-progress is still on (add it back if a session took it off when it stopped), then start them.

  2. New ready tickets:

    WF ready --fetch --exclude <scope.exclude> --only <scope.tickets when it's a list, plus every open workflow:infra ticket> \
      --running-area <each running ticket's area> --override <state.overrides> --slots <free>

    --only always includes every open workflow:infra ticket, whoever filed it (you, a session, or the owner), so a fix for a failing check goes first. With follow_unblocked true and scope.tickets "all-ready", leave out --only.

For each ticket to start:

  1. Claim it: add the workflow:in-progress label to its current labels, and assign project.owner.
  2. Check the claim took: read the ticket's labels again. If workflow:in-progress isn't there, add it once more and read again. If it still isn't there, skip the ticket this check-in and say so in the check-in's comment.
  3. Render its briefing (below) with --print.
  4. create_session with:
    • environment_id: the ticket environment;
    • source_url: the repo;
    • outcome_branch: the ticket branch, run.ticket_branch with * replaced by the ticket's number;
    • permission_mode: run.permission_mode;
    • prompt: the printed briefing, verbatim: copied whole, ending with its Briefing <hash> line. Never retype, shorten or summarise it;
    • tags: ["workflow:<run id>"].
  5. Check its permission mode: get_session on the new session. If its permission_mode (in session_context, or at the top level) can be read and isn't run.permission_mode, archive it at once and report it on the tracking issue: a session in another mode may stall on a prompt, or run looser than the charter allows. Put the ticket back as queued; it counts as an attempt, so a mode that keeps coming back wrong ends at needs-human rather than looping. If get_session shows no permission_mode field at all, you can't check it: note that once on the tracking issue (once per run, not per session) and carry on. Archive only on a mode you can read that differs.
  6. Record it in the state: the session id, attempts, started_at, the area, briefing (the hash on the briefing's last line) and status: running.

6. Release, when release.when is each-merge and something merged since the last release, or end and the run is stopping for any reason but the owner's stop ("Stop the run", step 3).

  1. git fetch origin <base>, then check out origin/<base> in a clean worktree.
  2. Run the profile's [release] steps the charter names, each with its smoke test, from that commit.
  3. Log each in the ledger: what, at, grant (the charter line), the commit SHA and the result.

A failed step stops further releases and is reported; never retry it blindly. The guard lets the conductor run deploy commands, but only the charter lets you.

7. Infra problems (CI time creeping, a flaky test, a broken deploy script), when conductor_fixes is true:

  • Preferred: open a ticket labelled ready-for-agent and workflow:infra. It starts before other tickets, and joins the --only list. Any ticket that fixes a failing or flaky required check is infra in the same way: label it workflow:infra whoever filed it, so it goes first.

  • When CI is killing every run: interrupt the newest session, put its ticket back as queued, and give the slot to the infra ticket.

  • A small fix of your own:

    1. on a branch claude/conductor-<what>;
    2. reviewed by the review panel;
    3. with evidence;
    4. merged only on WF merge-check's ready, as a ticket's PR is (check-in step 4);
    5. logged in the ledger.

    A fix touching a protected path you never merge yourself (above): open its PR and put it in for_owner for the owner to review and merge. The same goes for an infra ticket that has to change CI config (.github/workflows/): a ticket session can't edit protected paths, so it would end needs-human; open it as an owner-task instead.

  • Never fix a problem by weakening a test or a check.

8. Write the state, and update the issue body's "Now" line: running, in review, merged, queued, set aside.

9. Report what changed.

  • Comment on the tracking issue for a merge, a failure, a ticket set aside, or a decision the owner should see.
  • When notify.failures is push, @mention project.owner in failure comments.
  • Never block on a question: anything outside the charter is set aside and reported. A question on a needs-human ticket (step 2) only keeps the run checking in hourly until the owner answers or the end time.

10. Arm the next check-in with send_later (limits.check_in_minutes, or 60 minutes while waiting on the owner), unless the run has stopped or a check-in is already pending (as defined above).

A ticket session's briefing

Render it; never write it by hand:

WF render --template TEMPLATES/ticket-session-prompt.md --ticket ticket.json --charter charter.json \
  --slots slots.json --tracking <tracking issue> --by-hand <by-hand issue> --print

--print prints the briefing; --out briefing.md also keeps a copy. Pass the printed text to create_session exactly as printed: never retype, summarise or edit it. Its last line, Briefing <12 hex characters>, is a hash of the rest; the session checks its copy against it (below), so you can see the briefing arrived intact.

  • ticket.json is {"number": …, "title": …}.
  • slots.json holds five keys, each non-empty (none is a valid answer):
Key What goes in it
alongside The tickets running now and their areas, what each touches, what this one stays out of
read_first The exact URLs of tracking-issue comments with notes tagged for this ticket or its area, and the merged PRs it builds on
owner_decisions The owner's amendments since the run began that touch this ticket
briefing Anything specific: known pitfalls, environment quirks from profile.md, what a previous attempt left (branch, PR, head SHA)
wake_trigger The state's wake_trigger: the id of the routine the session fires when its ticket ends; none when it is null

The session checks its copy. Its first step writes the briefing it received to .workflow/briefing.md and runs WF briefing verify --file .workflow/briefing.md, which prints Briefing <hash> intact, Briefing changed: expected <a>, got <b> or no Briefing line; its first comment on the ticket quotes that line. Anything but intact with the state's hash: stop that session and retry (check-in step 4).

Stop the run

Do these in order, across as many check-ins as it takes. The state's status: "stopping" brings each later check-in back here (check-in step 0).

  1. Status. Set status: "stopping" and the reason: owner (the workflow:stop label, or "stop the run" here), ends_at, wind-down or done. Step 3 reads it.
  2. Sessions. Interrupt each running session that has no stopped_head yet, and record its stopped_head. Keep a check-in armed. At each later check-in, for each such session:
    • if its branch hasn't moved since stopped_head: delete its pending check-ins, archive it, and remove workflow:in-progress from its ticket if it didn't merge (and from any ticket still queued, which this run no longer holds);
    • if it has moved, record the new head and look again next time; after 3 check-ins, archive it anyway and say so in the report.
  3. Release, once every session is archived, if the charter says release.when: end, unless the reason is owner. A stop means something may be wrong: skip the release, log "release skipped: stopped by the owner" in the ledger, and say in the report that the owner can release by hand. The end time, a wind-down and a finished run release as the charter says.
  4. The report. Write the final report with the run-report skill, and notify the owner as the charter says.
  5. Wind up.
    • Set stopped (when and why) and status: "stopped".
    • The sessions' PR subscriptions end with their archive; the conductor subscribes to nothing itself.
    • Delete the wake routine (delete_trigger with the state's wake_trigger, unless it is null), then your own remaining check-ins, last.
    • Check with list_triggers that no routine of this run is still enabled.