Dispatcher Skill

SkillDev tools

Use when you want the orchestrator to pick the next repo to work on across your whole portfolio — it enumerates candidate repos below the confinement root, resolves free/busy from each repo's session.lock lease, ranks the FREE ones by backlog priority × staleness × readiness, recommends the single most worthwhile one via AskUserQuestion, atomically claims it, and routes you to the chosen entry command. Triggers: "what should I work on next", "dispatch me to a repo", "pick the next project", "run /dispatcher". <example>Context: operator finished a session and wants the next-best repo across the portfolio. user: "/dispatcher" assistant: "Ranked 18 free repos — top recommendation: Pencil-Designs (score 4.50, 90d stale). Confirm via the picker, I'll claim its lease atomically, then route you to /session deep."</example>

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Dispatcher Skill skill

What this skill tells your AI

The instructions your AI receives, as published by kanevry/session-orchestrator in skills/dispatcher/SKILL.md and read by ahel’s review.

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.lock claim, 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 /discovery directly 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.md AUQ-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.
  • recommendedranked[0] or null (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 (see skills/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 fieldSourceNotes
autonomyresolveDispatcherAutonomy({ committed, env, ownerConfig }) from scripts/lib/config/dispatcher-autonomy.mjsThe effective dial. Defaults to 'off' when unset (fail-closed).
confidenceFloorthe confidence-floor from the parsed dispatcher-autonomy: block (default 0.5)Same source object as autonomy.
confidencemode-selector selectMode(signals).confidence (0..1 float) for the recommended session-typeThe same mode-selector the Phase-2 heuristic and autopilot use.
cicheckCiStatus({ repoRoot: R }){ status } | nullCRITICAL (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 genuine ABSENCE (no CI configured) pass ci = null. Since #1031 checkCiStatus no longer returns null on failure — an unreadable state comes back as {severity:'warn', ok:false, degraded:<reason>}, and Phase-1 rank.mjs surfaces exactly that as readiness.ciDegraded with readiness.ciStatus === 'unknown'. Wrap that state as { status: 'unknown' } (the engine warns 'CI signal unknown — treated as absent' and G3 still passes) rather than flattening it to nullnull claims a measured absence the probe never established. 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.
resourceVerdictthe host resource verdict string ('green'|'warn'|'degraded'|'critical') from rank.mjs (readiness.resourceVerdict) or a fresh evaluate(probe(), thresholds).verdictHost-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.
recentRunsreadRecentAutopilotRuns({ repoRoot: R }) from scripts/lib/autopilot/recent-runs.mjsNEW 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' AND verdict.suitable === true. Keying only on verdict.suitable is a fail-OPEN bug — it would auto-launch even in advisory/off mode. The autonomy field inside deps is ADVISORY-ONLY inside the engine (it pushes a warning but never flips suitable); the CALLER is responsible for the autonomy === 'autonomous-gated' half of the AND. resolveDispatcherAutonomy defaults to 'off' when no dispatcher-autonomy: config is present, so an absent config forces the inform-and-ask ELSE branch.
  • null is "no signal", not a failure (NICE-b). Passing ci = null or resourceVerdict = null on a fetch/probe failure is the CORRECT honest wiring: the engine treats each null as absent ⇒ the corresponding gate (G3 / G4) PASSES and a warning is recorded ('CI signal absent' / 'resource signal absent'). A null signal 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 === false REGARDLESS of confidence — the four-gate AND fails on G3/G4, and the engine words the rationale FORCED: 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: ci wrapped as { status: ciStatus } (a bare string or a synthesized-absent object would hit the malformed-PASS branch and mask a real red) and the real resourceVerdict string ('critical' must arrive lowercase-or-normalizable, NOT replaced by a fabricated 'green'). With the signals wired through, a CI-red / resource-critical repo in autonomous-gated mode 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. resolveDispatcherAutonomy returns '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 it FORCED: CI red / FORCED: resource critical), so even in autonomous-gated mode 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), surface verdict.rationale (the one-line gate breakdown) and every entry in verdict.warnings to 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' AND verdict.suitable === true) — in that branch proceed straight to Phase 3. In EVERY other case (any non-autonomous-gated dial, 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).

  • AskUserQuestion is a deferred tool. Call ToolSearch with "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-line description explaining the trade-off.
  • This skill runs at coordinator level. Never call AskUserQuestion from 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-heartbeat, fs-error, …) → exclude R, re-rank the remaining free candidates (drop R from free, 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 housekeeping or /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 / free empty) → report "all repos busy", and offer resume (an in-progress session) or wait via AUQ. Never force a selection of a busy repo.
  • vault off / glab missing → degrade per the warnings array: 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]
FlagDescription
--jsonEmit { candidates, free, ranked, warnings, recommended } as a single JSON object to stdout.
--dry-runExplicit 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 / --versionPrint usage / version and exit 0.

Data → stdout, warnings/errors → stderr (never mixed). Exit codes follow .claude/rules/cli-design.md:

CodeMeaning
0Success
1User/input error (e.g. bad --start-dir)
2System 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 acquire MUST 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 === false candidate.
  • 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.suitable alone (ignoring autonomy === 'autonomous-gated') auto-launches in advisory/off mode. ALWAYS gate on BOTH.
  • Feeding the bare CI string into the enginerank.mjs exposes ciStatus as a bare string; computeSuitabilityVerdict wants { status }. Wrap it ({ status: ciStatus }) or pass null — 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).
  • Flattening a DEGRADED CI reading to null (#1031)readiness.ciDegraded set (⇒ readiness.ciStatus === 'unknown') means the state could NOT be read, which is weaker than absence, not equal to it. Pass { status: 'unknown' }; null asserts a measured absence nothing established.
  • Synthesizing an absent signal instead of passing null (NICE-b) — on a resource-probe failure, pass 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 recentRuns below 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 call readRecentAutopilotRuns with limit < 5 on the launch-gate read (the reader honours a small limit literally 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.mjs without a claim) is NON-MUTATING. The ONLY mutating step is the Phase-3 atomic claim.
  • The atomic claim is linkSync create-or-fail via acquire(...). ok:false ⇒ exclude the repo and re-rank — this is the concurrency guard, not an error to swallow.
  • Phase 2 uses AskUserQuestion with 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 confirmation requires autonomy === 'autonomous-gated' AND verdict.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 (acquire atomic claim) · scripts/lib/autonomy/suitability.mjs (computeSuitabilityVerdict four-gate engine, #682) · scripts/lib/config/dispatcher-autonomy.mjs (resolveDispatcherAutonomy effective dial, #682) · scripts/lib/autopilot/recent-runs.mjs (readRecentAutopilotRuns kill-switch-history reader, #682).

Signals

GitHub stars
50
Forks
7
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
dispatcher
Source
github.com/kanevry/session-orchestrator