Dispatcher Skill
Cross-repo autopilot front-door — enumerate → rank → owner-AUQ → atomic claim → route. Read-only until the operator confirms; the only mutating step is the atomic
session.lockclaim, and it happens BEFORE any launch.
Soul
The dispatcher answers one question: "of all my repos, which is the most worthwhile to work on right now, and is it free?" It scans the confinement-root children, resolves each repo's free/busy status from its session.lock v2 lease (same lease semantics as the vault-status board), ranks only the FREE ones by priority × staleness × readiness, and recommends the single best one. You confirm via a picker, it claims the lease atomically (winning the race or excluding-and-re-ranking on a loss), then routes you to the entry command for that repo. Busy repos are listed-as-such, never selected.
When to use
- You just finished a session and want the orchestrator to pick the next-best repo across your whole portfolio.
- You want a ranked, free/busy-aware view of every candidate repo before committing to one.
- You want the atomic claim handled for you so two parallel sessions never both grab the same repo.
When NOT to use
- Single-repo work where you already know the target — just run
/session,/plan, or/discoverydirectly in that repo. - A cross-repo read-only health dashboard (open issues/MRs/CI per repo) — that is
/portfolio(gitlab-portfolio), not the dispatcher. - Writing issues/MRs back to GitLab/GitHub — use
gitlab-ops. - Inside a subagent — the dispatcher is coordinator-only because Phase 2 uses
AskUserQuestion(unavailable in dispatched agents; see.claude/rules/ask-via-tool.mdAUQ-004).
Phase 1: Enumerate + Rank
Run the read path (non-mutating). Either invoke the CLI directly or call runDispatch from the module:
node scripts/lib/dispatcher/cli.mjs --json
The JSON object has keys { candidates, free, ranked, warnings, recommended }:
candidates— every repo found below the confinement root (busy ones LISTED, not dropped).free— the subset with no live lease (free === true).ranked— the free candidates sorted DESC by score;ranked[0]is the recommendation.recommended—ranked[0]ornull(no free candidates).warnings— human-readable degradation notes (glab/gh missing, host probe failed). Surface every warning to the operator — they explain why a repo was ranked on partial signals.
Ranking combines three signals per repo (implementation: scripts/lib/dispatcher/rank.mjs): backlog priority (critical/high counts), staleness (days since the last completed session — older = more worthwhile, capped at 90d), and readiness (CI status × host resource verdict — only ever dampens). A null priority (glab/gh missing) is ranked on staleness × readiness alone with a warning; the dispatcher NEVER blocks on a missing CLI.
Phase 1.5: Verdict gate (autonomy-gated launch) — #682
Decides ONE thing for the recommended repo R (
ranked[0]): may the dispatcher launch WITHOUT per-selection confirmation, or must it inform-and-ask? This sits BETWEEN ranking (Phase 1) and the Owner-AUQ (Phase 2). It is a pre-launch decision, NOT a per-iteration kill-switch — the autopilot loop's 10 kill-switches are reused unchanged once a session is running (seeskills/autopilot/SKILL.md § Pre-Loop Verdict Gate).
Compute the suitability verdict for R via the pure four-gate engine computeSuitabilityVerdict(deps) from scripts/lib/autonomy/suitability.mjs. The engine is pure + DI: the dispatcher gathers every signal and passes it in.
Source each verdict input as follows (the dispatcher already has most in hand from Phase 1):
deps field |
Source | Notes |
|---|---|---|
autonomy |
resolveDispatcherAutonomy({ committed, env, ownerConfig }) from scripts/lib/config/dispatcher-autonomy.mjs |
The effective dial. Defaults to 'off' when unset (fail-closed). |
confidenceFloor |
the confidence-floor from the parsed dispatcher-autonomy: block (default 0.5) |
Same source object as autonomy. |
confidence |
mode-selector selectMode(signals).confidence (0..1 float) for the recommended session-type |
The same mode-selector the Phase-2 heuristic and autopilot use. |
ci |
checkCiStatus({ repoRoot: R }) → { status } | null |
CRITICAL (NICE-b): Phase-1 rank.mjs exposes only the BARE status string (readiness.ciStatus). The engine's G3 gate expects an OBJECT { status } — wrap it as { status: ciStatus } when you HAVE a status; on a CI-fetch FAILURE pass ci = null (checkCiStatus already returns null on failure — pass it straight through). Do NOT synthesize { status: undefined } (or {}): that present-but-unusable object hits the engine's MALFORMED branch ('CI signal malformed — treated as absent') instead of the clean ABSENT branch ('CI signal absent'). Both pass G3 + warn, but null is the honest "no signal" — reserve the malformed branch for a genuinely unexpected shape. A bare string ALSO hits the malformed branch — always wrap or null. |
resourceVerdict |
the host resource verdict string ('green'|'warn'|'degraded'|'critical') from rank.mjs (readiness.resourceVerdict) or a fresh evaluate(probe(), thresholds).verdict |
Host-level — already fetched once in Phase 1. NICE-b: on a genuine probe FAILURE (no signal), prefer resourceVerdict = null over synthesizing 'green'. null = "no signal" ⇒ G4 passes + warns ('resource signal absent') — honest. Synthesizing 'green' fabricates a positive signal the host never reported and can let an autonomous launch proceed against an unknown host state. Pass the real verdict string when you have one; null when you do not. |
recentRuns |
readRecentAutopilotRuns({ repoRoot: R }) from scripts/lib/autopilot/recent-runs.mjs |
NEW reader. Reads <R>/.orchestrator/metrics/autopilot.jsonl, returns the most-recent records (newest-last), never throws ([] on missing/unreadable). Pass the TRUE count — the engine's G2 gate omits-with-warn below 5 runs and otherwise checks fired/N < 0.2. |
The launch decision (FAIL-CLOSED invariant):
verdict = computeSuitabilityVerdict({ autonomy, confidenceFloor, confidence, ci, resourceVerdict, recentRuns })
IF autonomy === 'autonomous-gated' AND verdict.suitable === true:
# MAY launch WITHOUT per-selection confirmation:
# skip the Phase-2 AUQ and proceed straight to Phase 3 (atomic claim) → Phase 4 (route).
ELSE:
# INFORM the operator of verdict.rationale + verdict.warnings,
# then run the Phase-2 AUQ (ask before launch). NEVER auto-launch.
- Gate on BOTH conditions. The launch wiring MUST check
autonomy === 'autonomous-gated'ANDverdict.suitable === true. Keying only onverdict.suitableis a fail-OPEN bug — it would auto-launch even inadvisory/offmode. Theautonomyfield insidedepsis ADVISORY-ONLY inside the engine (it pushes a warning but never flipssuitable); the CALLER is responsible for theautonomy === 'autonomous-gated'half of the AND.resolveDispatcherAutonomydefaults to'off'when nodispatcher-autonomy:config is present, so an absent config forces the inform-and-ask ELSE branch. nullis "no signal", not a failure (NICE-b). Passingci = nullorresourceVerdict = nullon a fetch/probe failure is the CORRECT honest wiring: the engine treats eachnullas absent ⇒ the corresponding gate (G3 / G4) PASSES and a warning is recorded ('CI signal absent'/'resource signal absent'). Anullsignal does NOT by itself block an autonomous launch — it just surfaces a warning the operator sees. Reserve synthesized objects/strings for real signals; never fabricate a positive ('green'/{ status: 'green' }) to paper over a missing probe.- forcedFail path is reachable end-to-end (NICE-c). When CI is red OR the resource verdict is critical, the engine sets
verdict.suitable === falseREGARDLESS of confidence — the four-gate AND fails on G3/G4, and the engine words the rationaleFORCED: CI red/FORCED: resource critical/FORCED: CI red + resource critical. For this branch to be reachable, the wiring MUST actually pass the live signals through:ciwrapped as{ status: ciStatus }(a bare string or a synthesized-absent object would hit the malformed-PASS branch and mask a real red) and the realresourceVerdictstring ('critical'must arrive lowercase-or-normalizable, NOT replaced by a fabricated'green'). With the signals wired through, a CI-red / resource-critical repo inautonomous-gatedmode falls to the ELSE branch: the gate INFORMS the operator (rationale + warnings) and ASKS via the Phase-2 AUQ — it NEVER auto-launches. (Wave 4 covers this with an integration test; this prose is the wiring contract.) - Why fail-closed is the default.
resolveDispatcherAutonomyreturns'off'when no config is present, so an unconfigured repo correctly takes the ELSE branch (inform + ask). A CI-red or resource-critical repo also fails the engine's G3/G4 gate (the rationale words itFORCED: CI red/FORCED: resource critical), so even inautonomous-gatedmode it falls to inform + ask. The dispatcher never auto-launches against a non-green verdict. - Inform-branch content. When taking the ELSE branch under
autonomous-gated(verdict not suitable), surfaceverdict.rationale(the one-line gate breakdown) and every entry inverdict.warningsto the operator BEFORE the AUQ, so they understand WHY confirmation is still required.
Phase 2: Owner-AUQ
Conditional (#682): SKIP this phase when Phase 1.5 green-lit an autonomous launch (
autonomy === 'autonomous-gated'ANDverdict.suitable === true) — in that branch proceed straight to Phase 3. In EVERY other case (any non-autonomous-gateddial, OR a non-suitable verdict) this phase RUNS: inform the operator of the verdict first, then ask. The dispatcher NEVER auto-launches outside the green-verdict autonomous-gated branch.
Present the decision to the operator via the AskUserQuestion tool — never inline prose (.claude/rules/ask-via-tool.md AUQ-001..005, enforced).
AskUserQuestionis a deferred tool. CallToolSearchwith"select:AskUserQuestion"ONCE per session before the first use to load its schema.- Option 1 is always the recommendation, labelled
(Recommended): the top-ranked free repo paired with a recommended session-type. Options 2–4 are overrides (other high-ranked free repos, or other session-types for the same repo). 2–4 options total, each with a one-linedescriptionexplaining the trade-off. - This skill runs at coordinator level. Never call
AskUserQuestionfrom inside a subagent — if a sub-step needs the decision, bubble it back to the coordinator (AUQ-004).
Recommended session-type heuristic for option 1: high critical/high backlog ⇒ /session deep; stale-but-clean (no backlog signal, high staleness) ⇒ /discovery or /session housekeeping; unscoped/new work ⇒ /plan. Offer the alternatives as the other options.
Phase 3: Atomic claim
Claim the lease for repo R BEFORE launching anything. This runs in BOTH branches — the operator-confirmed Phase-2 path AND the autonomous-gated green-verdict path that skipped Phase 2 (#682). The claim ALWAYS precedes the Phase-4 route: an autonomous launch does not bypass the atomic claim, it only bypasses the per-selection AUQ.
// via the module (preferred — returns the acquire() result verbatim)
import { claimRepo } from 'scripts/lib/dispatcher/cli.mjs';
const res = claimRepo({ repoRoot: R, sessionId, mode, ttlHours, semanticSessionId });
Or reuse the primitive directly: acquire({ sessionId, mode, ttlHours, repoRoot, semanticSessionId }) from scripts/lib/session-lock.mjs. The claim is a linkSync create-or-fail = atomic.
ok: true→ the claim is held. Proceed to Phase 4.ok: false(race lost / busy — reasons:active,stale-pid-alive,stale-pid-dead,fs-error, …) → exclude R, re-rank the remaining free candidates (drop R fromfree, re-run Phase 1's rank step), and re-present Phase 2. Loop until a claim succeeds or no free candidate remains (then Phase 5).
Do NOT reinvent the claim — always go through claimRepo/acquire. The ok:false path is the load-bearing concurrency guard: two parallel dispatchers can both recommend R, but only one wins the linkSync; the loser must re-rank, never force.
Phase 4: Route
With the lease held, the coordinator invokes the chosen entry slash-command for repo R:
/session housekeepingor/session deep— execution modes./plan— read-only planning precursor (produces a wave plan; does not execute)./discovery— read-only investigation precursor (maps scope; does not execute).
/plan and /discovery are read-only precursors, NOT execution modes — the menu may route to them, but they only produce artifacts for a later execution session. The full mode taxonomy lives in the mode-selector surface (P2 of this epic); the dispatcher only routes to the entry command the operator picked.
Phase 5: Edge cases
- No free candidate (
recommended === null/freeempty) → report "all repos busy", and offerresume(an in-progress session) orwaitvia AUQ. Never force a selection of a busy repo. - vault off / glab missing → degrade per the
warningsarray: rank on staleness × readiness only, surface the warning, continue. A missing CLI is never fatal. - Host resource probe failed → readiness is scored without resource dampening (a warning says so); ranking still completes.
- Bad
--start-dir→ CLI exits 1 (user/input error); fix the path and re-run.
CLI
node scripts/lib/dispatcher/cli.mjs [--json] [--dry-run] [--repo <name>] [--start-dir <path>] [--help] [--version]
| Flag | Description |
|---|---|
--json |
Emit { candidates, free, ranked, warnings, recommended } as a single JSON object to stdout. |
--dry-run |
Explicit non-mutating rank (the read path is already non-mutating; documents intent). |
--repo <name> |
Filter the human-readable table to one repoName (informational; does not change ranking). |
--start-dir <path> |
Override the scan root (defaults to the confinement root). |
--help / --version |
Print usage / version and exit 0. |
Data → stdout, warnings/errors → stderr (never mixed). Exit codes follow .claude/rules/cli-design.md:
| Code | Meaning |
|---|---|
0 |
Success |
1 |
User/input error (e.g. bad --start-dir) |
2 |
System error (unexpected dispatch failure) |
Anti-Patterns
- Inline prose for the Phase-2 decision — always
AskUserQuestion(AUQ-001). A numbered markdown list of repos is a bug. - Launching before claiming — Phase 3's
acquireMUST succeed before Phase 4. Launching then claiming re-opens the race the lease exists to close. - Forcing a busy repo when none are free — report and offer resume/wait; never select a
free === falsecandidate. - Treating a missing glab/gh as fatal — null priority degrades to staleness × readiness with a warning; never block.
- Re-implementing the claim — go through
claimRepo/acquire; do not hand-roll a lockfile. - Ignoring
ok:false— on a lost race you MUST exclude-and-re-rank, not retry the same repo or proceed without the lease. - Running this from a subagent — coordinator-only (AUQ is unavailable in subagents).
- Fail-OPEN verdict gate (#682) — keying the autonomous launch on
verdict.suitablealone (ignoringautonomy === 'autonomous-gated') auto-launches inadvisory/offmode. ALWAYS gate on BOTH. - Feeding the bare CI string into the engine —
rank.mjsexposesciStatusas a bare string;computeSuitabilityVerdictwants{ status }. Wrap it ({ status: ciStatus }) or passnull— a bare string silently hits the malformed-absent branch. A red CI fed as a bare string would PASS G3 (masked as malformed) instead of forcing the FORCED-fail branch (NICE-c). - Synthesizing an absent signal instead of passing
null(NICE-b) — on a CI-fetch or resource-probe failure, passci = null/resourceVerdict = null(honest "no signal" ⇒ gate passes + warns). Do NOT synthesize{ status: undefined }(hits the malformed branch) and do NOT fabricate'green'/{ status: 'green' }(invents a positive signal the host never reported and can green-light an autonomous launch against an unknown state). - Pre-truncating
recentRunsbelow 5 — passing fewer than the true on-disk count when ≥ 5 runs exist falsely triggers the engine's <5-run omission branch and skips the kill-switch gate. Pass the TRUE count; never callreadRecentAutopilotRunswithlimit < 5on the launch-gate read (the reader honours a smalllimitliterally and will not clamp it upward). - Auto-launching against a non-green verdict — a CI-red / resource-critical / low-confidence verdict ALWAYS falls to inform + ask, even under
autonomous-gated. Never proceed straight to claim on a non-suitable verdict.
Critical Rules
- The read path (
runDispatch/cli.mjswithout a claim) is NON-MUTATING. The ONLY mutating step is the Phase-3 atomic claim. - The atomic claim is
linkSynccreate-or-fail viaacquire(...).ok:false⇒ exclude the repo and re-rank — this is the concurrency guard, not an error to swallow. - Phase 2 uses
AskUserQuestionwith option 1 = recommendation(Recommended); coordinator-only. - Busy repos are LISTED, never selected (PRD: "busy repos listed as such, not selected").
- glab/gh/host-probe degradation is surfaced as a warning and never blocks ranking.
- Phase 1.5 verdict gate (#682) is FAIL-CLOSED:
MAY launch without per-selection confirmationrequiresautonomy === 'autonomous-gated'ANDverdict.suitable === true. Every other case informs the operator (rationale + warnings) and asks via Phase-2 AUQ. The dispatcher NEVER auto-launches outside that single green branch. The 10 autopilot kill-switches are reused unchanged once a session is running — the verdict gate is a pre-launch decision, not a kill-switch. - Implementation files:
scripts/lib/dispatcher/cli.mjs(orchestration:runDispatch,claimRepo) ·scripts/lib/dispatcher/enumerate.mjs(enumeration + free/busy) ·scripts/lib/dispatcher/rank.mjs(scoring) ·scripts/lib/session-lock.mjs(acquireatomic claim) ·scripts/lib/autonomy/suitability.mjs(computeSuitabilityVerdictfour-gate engine, #682) ·scripts/lib/config/dispatcher-autonomy.mjs(resolveDispatcherAutonomyeffective dial, #682) ·scripts/lib/autopilot/recent-runs.mjs(readRecentAutopilotRunskill-switch-history reader, #682).
Source: hashgraph-online/awesome-codex-plugins → plugins/Kanevry/session-orchestrator/skills/dispatcher/SKILL.md