{"_id":"@bourbonbaggers/ai-dispatcher","name":"@bourbonbaggers/ai-dispatcher","dist-tags":{"latest":"0.1.0"},"versions":{"0.1.0":{"name":"@bourbonbaggers/ai-dispatcher","version":"0.1.0","description":"Turn plain-language ideas into verified production changes with Codex or Claude Code.","license":"MIT","repository":{"type":"git","url":"git+https://github.com/BourbonBaggers/ai-dispatcher.git"},"bugs":{"url":"https://github.com/BourbonBaggers/ai-dispatcher/issues"},"homepage":"https://github.com/BourbonBaggers/ai-dispatcher#readme","keywords":["ai-agents","coding-agents","github-automation","vibe-coding","codex","claude-code","devops","autonomous-coding"],"type":"module","bin":{"ai-dispatcher":"bin/ai-dispatcher.mjs"},"engines":{"node":">=24"},"scripts":{"start":"node bin/ai-dispatcher.mjs","dashboard":"node bin/ai-dispatcher.mjs dashboard","docs:routing":"node scripts/generate-routing-docs.mjs","typecheck":"tsc -p tsconfig.json --noEmit","test":"node --test \"test/**/*.test.ts\""},"devDependencies":{"@types/node":"^24.13.3","typescript":"^5.8.3"},"gitHead":"9483dc8705b23c3606cc88c1c6260c57cb1987ca","_id":"@bourbonbaggers/ai-dispatcher@0.1.0","_nodeVersion":"24.13.1","_npmVersion":"11.8.0","dist":{"integrity":"sha512-ZJym+0RJEI0ZoMFqZ/hqAhfV6yabwvIsjHo7avJ+lrWR96XN38VtEuuyNC8adm0fhQqigVAGBX68nEU0vrK4tQ==","shasum":"b2ba03258f42bac21985b7f5954ef84b60a9778c","tarball":"https://registry.npmjs.org/@bourbonbaggers/ai-dispatcher/-/ai-dispatcher-0.1.0.tgz","fileCount":68,"unpackedSize":771593,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCICtdo27GH4Q9kNdRqV2TDOgJTrEOwVPT88bfFJfwWTDjAiB9uaVNp/M0J7zOk5kJz7JmOwokkzs97FOM2IzgmL7UUQ=="}]},"_npmUser":{"name":"kreusch","email":"jay@bourbonbaggers.com"},"directories":{},"maintainers":[{"name":"kreusch","email":"jay@bourbonbaggers.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/ai-dispatcher_0.1.0_1786114176678_0.8623977131027727"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-07T14:49:36.519Z","0.1.0":"2026-08-07T14:49:36.833Z","modified":"2026-08-07T14:49:37.081Z"},"maintainers":[{"name":"kreusch","email":"jay@bourbonbaggers.com"}],"description":"Turn plain-language ideas into verified production changes with Codex or Claude Code.","homepage":"https://github.com/BourbonBaggers/ai-dispatcher#readme","keywords":["ai-agents","coding-agents","github-automation","vibe-coding","codex","claude-code","devops","autonomous-coding"],"repository":{"type":"git","url":"git+https://github.com/BourbonBaggers/ai-dispatcher.git"},"bugs":{"url":"https://github.com/BourbonBaggers/ai-dispatcher/issues"},"license":"MIT","readme":"# ai-dispatcher\n\nDescribe a problem or feature in plain language, walk away, and come back to a working,\nverified update in your production app. ai-dispatcher uses the AI coding tools you already\nhave — ChatGPT with Codex, Claude with Claude Code, or both — and keeps the work moving\nthrough testing, fixes, and release until the result is healthy.\n\n[![CI](https://github.com/BourbonBaggers/ai-dispatcher/actions/workflows/ci.yml/badge.svg)](https://github.com/BourbonBaggers/ai-dispatcher/actions/workflows/ci.yml)\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)\n[![Node.js 24+](https://img.shields.io/badge/Node.js-24%2B-339933?logo=node.js&logoColor=white)](https://nodejs.org/)\n\n## For non-technical readers\n\n### Who is this for?\n\nai-dispatcher is for people who are discovering “vibe coding”: you can describe what you\nwant to build, but you do not want to babysit an unfinished change or learn the mechanics\nof testing and deployment before your idea becomes real. It is also for small teams that\nwant the same dependable path from a request to a working production result.\n\n### What problem does it solve?\n\nGood ideas and important fixes often get stuck between “someone should handle this” and\n“it is working in production.” Work waits in a queue, developers lose time to repetitive\nfollow-up, automated checks fail without a clear owner, and a task can appear finished\nbefore anyone has verified the result.\n\n### Issue creation flow: where the work begins\n\nThe quality of the queue is established before ai-dispatcher ever sees it:\n\n1. A person starts a GitHub-connected AI chat session and describes the outcome they want.\n2. The chat inspects the repository and asks focused questions about current behavior,\n   scope, edge cases, and tradeoffs.\n3. Together, they refine the request until “done” is specific enough to verify without\n   guessing.\n4. The chat drafts a GitHub issue with context, acceptance criteria, boundaries, and\n   verification steps.\n5. A person reviews and approves that issue for the dispatcher’s queue.\n\nA vague issue creates vague work; a carefully refined issue creates a queue that autonomous\nagents can actually work to completion. See the\n[quality issue example](docs/quality-issue-example.md) for the pattern.\n\n### How does it help?\n\nai-dispatcher watches that approved queue, gives one task at a time to an AI coding agent,\nand keeps ownership of the work until there is a trustworthy outcome. It checks the\nagent’s change, retries and repairs failures, and keeps the task from disappearing during\nthe handoff. If deployment automation is configured, it can continue through release\nand health verification, closing the task only after the new version is confirmed healthy.\n\nPeople get a ready-to-review change when human judgment is useful, or a clear,\nevidence-backed escalation when automation has genuinely run out of options. The result\nis less coordination overhead, a smaller unattended backlog, and a more honest answer to\n“what is the status of this fix?”\n\n```mermaid\nflowchart LR\n  A[Approved task] --> B[AI agent makes the change]\n  B --> C[Automated checks]\n  C -->|Pass| D[Ready to review or release]\n  C -->|Fail| E[Retry and repair]\n  E --> B\n  D --> F[Optional release and health check]\n  F --> G[Task closed when healthy]\n```\n\n## Five-minute read\n\nThe operating loop is simple:\n\n1. It finds one approved task and records ownership before work begins.\n2. It gives the task to an AI coding agent in an isolated workspace.\n3. It evaluates the proposed change and its automated checks.\n4. It retries, repairs, or escalates when something fails instead of abandoning the task.\n5. With release automation enabled, it verifies the deployed result before closing the task.\n\nThe service is self-contained and targets one explicit repository at a time; there is no\nhard-coded repository fallback. The working rules for dispatcher-launched agents live in\n[`AGENTS.md`](AGENTS.md); `CLAUDE.md` remains a symlink to it.\n\nTrusted operator checkouts may also contain an optional, gitignored\n`AGENTS.private.md` with local deployment and service details. It is not required for a\npublic clone and must never be committed. Public behavior and contribution rules belong\nin `AGENTS.md`, this README, and the other tracked documentation.\n\n## Quickstart\n\nFor an installed CLI, initialize the dispatcher once, check the prerequisites, and then\nrun it:\n\n```bash\nnpm install --global @bourbonbaggers/ai-dispatcher\nai-dispatcher init --repo owner/repo\nai-dispatcher doctor\nai-dispatcher --once\n```\n\n`init` creates the private configuration, clones the target mirror, and creates the\nworktree and state directories. `doctor` checks GitHub access, Git, Node.js, and at least\none authenticated coding agent. Authentication remains owned by `gh`, Codex, and Claude\nCode; no token is written to the dispatcher configuration.\n\nFor development or a one-off local checkout, use the direct Node path instead:\n\n```bash\nnode bin/ai-dispatcher.mjs --repo owner/repo --dry-run\nnode bin/ai-dispatcher.mjs --repo owner/repo --once \\\n  --repo-dir ~/dispatcher/mirror --worktree-dir ~/dispatcher/worktrees\n```\n\nWhat to expect:\n\n1. `--dry-run` validates config and shows the next dispatch decision without launching an agent.\n2. `--once` performs a single scan and stops at a ready PR when autoship is disabled.\n3. The issue body is fetched by the launched agent itself; it is never interpolated into\n   the bootstrap prompt or shell command.\n4. The PR body must reference `Issue: #<number>` and must not use GitHub auto-close keywords.\n\nIf you want to inspect the running service without mutating anything, use the read-only\n`status`, `history`, or `report` commands documented below.\n\n## Further reading\n\n- Architecture and lifecycle: [Lifecycle](#lifecycle), [State model](#state-model), and\n  [`ROUTING.md`](ROUTING.md)\n- Issue creation: [`docs/quality-issue-example.md`](docs/quality-issue-example.md)\n- Dogfood runbook: [Repeatable dogfood demo target and runbook (#71)](#repeatable-dogfood-demo-target-and-runbook-71)\n- Tests: [Development](#development) and the [`test/`](test) suite\n- Contribution and security guidance: [`AGENTS.md`](AGENTS.md) and the repository policy notes\n\n## Security model\n\n- **Credentials stay outside the repo.** `gh auth` and the agent CLIs own their own login\n  state. This service never stores a GitHub, OpenAI, or Anthropic token.\n- **Issue text is untrusted data.** Bodies, titles, comments, labels, and branch names are\n  treated as input, not shell code. The agent fetches the issue body itself.\n- **The dashboard is read-only but not authenticated.** Keep it on loopback or behind an\n  authenticated proxy when you need to inspect live output.\n- **Autoship is operator-configured.** Without `DISPATCHER_AUTOSHIP_CMD`, the default\n  boundary is a ready-for-review PR. With autoship, dispatcher-owned recovery continues\n  through deploy verification before an issue is closed.\n\n## Release checklist\n\n- Confirm `LICENSE`, `SECURITY.md`, and `CONTRIBUTING.md` are present and accurate.\n- Run `npm run typecheck`, `npm test`, and `bash scripts/shellcheck-ci.sh`.\n- Verify the hygiene gate passes and no tracked build artifacts, `.env` files, or\n  private-residue references were introduced.\n- Keep the macOS status app build output out of the release tree unless you are\n  intentionally producing a local artifact.\n\n## What this is / is not\n\n**This is:**\n\n- a serial issue dispatcher for one repository at a time\n- a launcher that keeps the agent in an isolated checkout\n- a recovery-driven workflow with durable state and explicit evidence\n- a ready-PR handoff by default\n\n**This is not:**\n\n- a public issue intake bot for arbitrary repos without operator review of the target\n- an always-on deployer that merges or closes issues by itself\n- a generic workflow engine with hidden defaults or a hard-coded repository fallback\n\n## Lifecycle\n\nOn each scan when no run is active:\n\n1. **Resume first.** A run left `interrupted`, `timed_out`, or `token_exhausted` still\n   owns its claim and is resumed before fresh work, up to a finite cap.\n2. **Select one fresh issue.** Open issues are evaluated oldest-first and filtered by the\n   allowlist contract below.\n3. **Claim, then label.** The state row is the authoritative lock; `agent-working` is\n   written only after the claim succeeds.\n4. **Launch and supervise.** `dispatch-agent.sh` clones an isolated checkout, writes a\n   bootstrap prompt, checkpoints progress, and waits for the real CI verdict.\n5. **Recover or finalize.** Agent, CI, merge, and deploy failures use evidence-based\n   retries, repairs, lateral handoff, and final frontier escalation before a terminal\n   outcome is recorded.\n\nFor coding, CI, merge, and deploy, `autoship-held` is valid only with durable evidence\nthat the assigned-model repair budget and the automatic frontier attempt both failed.\nLegacy holds without that proof clear and resume themselves. Markdown conflicts are not\na human gate: deterministic generated-file repair handles safe generated-only conflicts,\nand all other conflicts enter the agent repair ladder.\n\nThe dispatcher is strictly serial: only one agent runs at a time, enforced by a\nsingle-instance lock plus the fact that each run is driven to completion before the loop\ncontinues.\n\n## The label contract\n\nAn authorized issue does not need assignment labels. The dispatcher derives provider,\nmodel, and effort atomically at pickup:\n\n| Label                           | Meaning                                                                            |\n| ------------------------------- | ---------------------------------------------------------------------------------- |\n| `dispatch:ready`                | normal provider-neutral admission signal                                           |\n| `type:*`                        | exactly one of `bug`, `enhancement`, `refactor`, `chore`, `docs`, `ops`, `research` |\n| `priority:*`                    | exactly one of `queue-jump`, `normal`, `background`; affects queue order only       |\n| `risk:*`                        | exactly one of `low-stakes`, `normal`, `destructive`; informs route safeguards      |\n| legacy workload characteristics | migration/advisory evidence; no longer required from issue authors                 |\n| `agent:codex` / `agent:claude` / `agent:opencode` | constrains pickup to that specific agent; selects the best model within that agent's supported routes |\n| `model:*` / `effort:*`          | dispatcher output for visibility; non-authoritative unless `route:human-override` is present |\n| `route:human-override`          | makes one compatible agent/model pair and optional effort an explicit initial pin  |\n| `agent-working`                 | the dispatcher is actively on it (added on claim, cleared on non-resumable finish) |\n| `needs-input` / `blocked`       | held for a human during normal selection; `blocked` can be conservatively re-audited only when the queue is otherwise idle |\n\nThe `model:*` allowlist is **data-driven**: it is derived from the curated registry in\n`src/models.ts`, not hand-maintained. The dispatchable lanes cover the configured Codex\nand Claude CLIs from tiny through frontier, including `model:gpt-5.4-mini`,\n`model:gpt-5.6-luna`, `model:gpt-5.6-terra`, `model:gpt-5.4`, `model:gpt-5.6-sol`,\n`model:gpt-5.5`, `model:claude-haiku-4.5`, `model:claude-sonnet-5`, and\n`model:claude-opus-4.8`. Some registry entries are documentation-only, provider-specific\ninternal names, or explicit reserve lanes such as `model:claude-fable-5`; those are\nexplained in `src/models.ts` and are not treated as ordinary dispatchable choices.\n\n**OpenCode Zen fallback** (#56): When both Codex and Claude are confirmed exhausted for\nthe same quota window (5-hour, weekly, or monthly), the recovery ladder uses OpenCode Zen\ninstead of escalating the route tier. OpenCode models are excluded from normal pickup and\nscarcity-weighted routing; they are eligible *only* after quota exhaustion is proven on\nboth primary providers OR when explicitly requested via `agent:opencode`. OpenCode selection\npreserves the original route and does not reset the recovery ledger. Enable with\n`OPENCODE_API_KEY` and `OPENCODE_FALLBACK_ENABLED=true` (disabled by default). See ROUTING.md\nfor the deterministic model-selection table.\n\n**Agent-level overrides** (#58): A single `agent:codex`, `agent:claude`, or `agent:opencode`\nlabel constrains the initial pickup to that agent. When present, the dispatcher selects the\nbest compatible model within that agent's supported routes using the same capacity-aware\nranking as normal. Multiple conflicting agent labels block the issue with explanatory feedback.\nThis is independent of model-level overrides and the recovery ladder.\n\nLabels are never passed to a shell. The dispatcher looks up its selected registry entry\nand effort in frozen maps; only those constants reach the CLI. Missing, partial, stale,\nor conflicting ordinary assignment labels cannot wedge an issue. An invalid explicit\nhuman override is rejected visibly rather than silently violated.\n\n## Capacity-aware routing & evidence (#319)\n\nAt pickup the dispatcher derives a base route from type/risk, **reads the issue text** to\nderive the technical axes the author is no longer asked for, adjusts at most one route down\nor up from that evidence, derives effort from the route, then reads live Codex and Claude\nusage windows.\n\nCandidates come from the registry's declared route span, and the winner is the one with the\nlowest **scarcity-weighted burn**. Under flat subscriptions the real currency is a\nprovider's rolling usage window — running one dry can remove the pool for days — and list\nprice is the best proxy for how fast a model drains it. So cheapest-first and headroom\npreservation are the same rule, right up until a window nears its limit, where scarcity\novertakes the price gap and work moves to the other provider on its own. Below roughly 60%\nof a window spent, capacity has no effect on the choice at all.\n\nIssue text is untrusted input: it can move the route one step, never into a frontier model.\nThe complete decision and failure policy is in [`ROUTING.md`](ROUTING.md).\n\nEvery terminal run records an **attempt** into `telemetry.json` (alongside dispatcher\nstate); attempts fold into per-issue records, and a terminal issue gets a cost-summary\ncomment. The model is honest about what it can't measure: token counts are `unavailable`\n(the launcher emits none) and are never estimated from elapsed time, billed cost is only\never an amount a provider actually reported, a total nothing contributed to reads as\n`unavailable` rather than `$0.00`, capacity falls back to `unknown` when the bounded live\nreaders fail, and an issue is _successful_ only when merged **and** deployed **and** free of\nmaterial human repair — never on a clean exit or a PR alone. Each attempt stores the price\nsnapshot active when it ran, so a later price change cannot rewrite historical cost.\n\n```bash\n# Print the routing analytics report (completed features by model, success by task\n# category, first-attempt/retry rates, frontier utilization, recommendations).\nnode bin/ai-dispatcher.mjs report --state-dir ~/dispatcher/state\n```\n\n## Local status and history\n\n`status` and `history` are read-only and lock-free. They read\n`DISPATCHER_STATE_DIR` / `--state-dir`, never acquire `dispatcher.lock`, and never mutate\ndurable state. `status` also performs a bounded read-only GitHub check for open issues\nwith `agent-working` when a repo is supplied through `--repo` / `DISPATCHER_REPO` or can\nbe inferred from existing state; use `--no-github` for a strictly local read. If both\n`state.json` and `state.json.backup` are unreadable, they fail closed instead of\nreporting idle.\n\n```bash\nai-dispatcher status --state-dir ~/dispatcher/state\nai-dispatcher status --state-dir ~/dispatcher/state --repo owner/repo\nai-dispatcher status --state-dir ~/dispatcher/state --json\nai-dispatcher status --state-dir ~/dispatcher/state --follow\nai-dispatcher status --state-dir ~/dispatcher/state --json --follow\nai-dispatcher status --state-dir ~/dispatcher/state --no-github\nai-dispatcher history --state-dir ~/dispatcher/state\nai-dispatcher history --state-dir ~/dispatcher/state --json --limit 50\n```\n\nHuman `status` output is exactly `idle` only when the live dispatcher has no claimed work\nand no checked GitHub issue has `agent-working`. If no live lock exists and no claimed\nwork is durable, it prints `offline`. If durable claimed work exists, it prints `active:`\nwith the locally known issue, PR, branch, agent/model, phase, status, and exact `gh`\ncommands for inspection. If durable state is idle but GitHub still has `agent-working`, it\nprints `attention:` with the labelled issue and PR-search command instead of hiding behind\n`idle`.\n\nEach run persists an explicit trusted phase in `state.json`. Provider stdout is never\nparsed as phase evidence. The current phase is one of:\n`claimed`, `preparing`, `agent_working`, `publishing`, `waiting_ci`, `autoshipping`,\n`deploying`, `verifying`, `recovering`, or `held`. Older state rows without `phase` are\nmapped from durable status on read.\n\nAgent output is stored under `<state-dir>/run-output/<run-id>.jsonl`. Entries are\nappend-only JSON lines containing the existing rendered/redacted output plus trusted\nphase and lifecycle events. They have monotonic `seq` numbers and `timestamp`\nmilliseconds. Output files are pruned with their retained run records.\n\nVersioned JSON schemas:\n\n```ts\n// ai-dispatcher status --json\n{\n  version: 1,\n  service: { state: \"online\", pid: number } |\n    { state: \"offline\", pid: number | null, reason: \"missing\" | \"stale\" | \"corrupt\" },\n  stateSource: \"primary\" | \"backup\" | \"empty\",\n  current: null | RunSummary,\n  github: GithubStatusEvidence\n}\n\n// ai-dispatcher history --json\n{ version: 1, runs: RunSummary[] }\n\n// ai-dispatcher status --json --follow\n{ version: 1, event: RunOutputEntry }\n```\n\n`RunSummary` includes issue identifiers, optional PR, branch, agent/model/effort, durable\nstatus, current phase, trigger, timestamps/duration, last commit, plan path, recovery and\nexhaustion evidence, failure summary, and optional `ghCommand`. `RunOutputEntry` is one\nof `output`, `phase`, or `lifecycle`.\n\n## One-shot ship for ad hoc pull requests (#27)\n\n`ship` lets work created in an ordinary interactive coding session (no dispatcher issue,\nno agent run) use the same merge/deploy/health/rollback machinery as autoship, for exactly\none named PR:\n\n```bash\nai-dispatcher ship --repo owner/repo --pr 123\nai-dispatcher ship --repo owner/repo --pr 123 --issue 456   # close #456 after verified delivery\n```\n\nIt requires `DISPATCHER_AUTOSHIP_CMD` to be configured — there is nothing to ship with\notherwise. It makes exactly one pass and never retries, repairs, or escalates: if the PR\nis a draft, has a merge conflict, carries a GitHub auto-close keyword (`Closes`/`Fixes`/\n`Resolves #n`, checked so merging can never close an issue before deployment is verified),\nor CI is not green, it reports what to fix and exits non-zero. Rerun it once that is\nresolved — an already-merged PR is redeployed and reverified by its exact merge SHA, so\nrerunning after a partial failure is safe. `--issue` is optional; without it, no issue\noperation occurs at all. Exit code is `0` only when production is verified delivered.\n\n## Web dashboard\n\n`dashboard` serves a read-only one-page HTML status view for every local user-level\n`ai-dispatcher*.service` instance it can discover. Each dispatcher instance appears under\nits own tab. The page shows the same durable/GitHub status evidence as `status`, the\nsystemd process state, the most recent scan, and the next poll time when an instance is\nidle. Expanding **Live stream** opens an on-demand SSE stream for the current run output;\nclosed accordions do not hold a stream open.\n\n```bash\nai-dispatcher dashboard --host 127.0.0.1 --port 8787\n```\n\nThe dashboard has **no authentication**. Even though it's read-only, its responses\ninclude issue metadata, live agent output, and repository/filesystem state, so it\ndefaults to loopback (`127.0.0.1`) and refuses to bind any other host unless you pass\n`--allow-remote`, which prints a prominent warning before it starts listening.\n\n**Safe remote viewing:** keep the dashboard bound to loopback and forward a local port\nover SSH instead of exposing it on the network:\n\n```bash\nssh -L 8787:127.0.0.1:8787 <dev-server>\n# then open http://127.0.0.1:8787/ on your machine\n```\n\nAn authenticated reverse proxy in front of a loopback-bound dashboard is the other\nsupported option if you need it reachable without an SSH session open.\n\nOn the dev server, install it as an auto-starting user service:\n\n```bash\nscripts/install-dashboard-service.sh\n```\n\nThe installer creates and enables `ai-dispatcher-dashboard.service`, binding to\n`127.0.0.1:8787` by default. Override `DASHBOARD_HOST` only if you have decided to accept\nthe exposure — the installer then adds `--allow-remote` for you and prints the same\nwarning at install time. Also override `DASHBOARD_PORT`, `DASHBOARD_CHECKOUT`,\n`DASHBOARD_NODE_BIN`, or `DASHBOARD_UNIT` as needed.\n\nThe same page is available as a compact popover-friendly view at `/compact`. The Mac mini\nmenu bar app in [`macos/DispatcherStatusBar`](macos/DispatcherStatusBar) opens\n`http://<dispatcher-host>:8787/compact` when pointed at a non-loopback dashboard, which\nrequires the dev-server instance to be explicitly opted in with\n`DASHBOARD_HOST=<dispatcher-host>` (or another non-loopback address) as described above\n— an explicit, documented tradeoff for that always-on menu bar view, not the default.\nPrefer switching it to an SSH tunnel or authenticated reverse proxy target if the dev\nserver is reachable by anyone other than its operator. Build, install, and launch-at-login\nsteps are documented in\n[`docs/macos-menu-bar.md`](docs/macos-menu-bar.md).\n\n## Provisioning\n\nTo prepare a fresh dispatcher host, run `scripts/provision-agents.sh` with the target\nrepository and checkout paths supplied explicitly. The script accepts either CLI flags or\ndocumented environment variables and refuses to fall back to any private deployment\ndefaults.\n\n```bash\nscripts/provision-agents.sh \\\n  --repo owner/repo \\\n  --repo-dir /srv/ai-dispatcher \\\n  --worktree-dir /srv/ai-dispatcher-worktrees \\\n  --env-source-dir /srv/ai-dispatcher-env\n```\n\nEquivalent environment variables:\n\n- `DISPATCHER_REPO`\n- `DISPATCHER_REPO_DIR`\n- `DISPATCHER_WORKTREE_DIR`\n- `DISPATCHER_ENV_SOURCE_DIR`\n\n## On-demand policy cleanup for target repositories (#28)\n\n`target policy-cleanup` uses the configured escalation model\n(`DISPATCHER_CI_ESCALATION_MODEL`, the same setting used for CI repair escalation — no new\nmodel setting is introduced) to audit a target repository's committed agent-instruction\nfiles (`AGENTS.md`, `CLAUDE.md`) for conflicts with the canonical dispatcher policy\n(`src/target-policy.ts`), and to repair only real conflicts:\n\n```bash\nai-dispatcher target policy-cleanup --repo owner/repo\nai-dispatcher target policy-cleanup --repo owner/repo --dry-run   # report only, no PR\n```\n\nThis is explicitly invoked only — it never runs during a normal scan. The model returns\nstructured JSON naming full replacement content for the (at most two) files that\nconflict; the audited file set is a fixed allowlist, so an out-of-scope path in the\nverdict, or an out-of-scope change in the resulting working tree, aborts before anything\nis published. A clean repository produces no branch or PR. A real conflict is committed\nto a dedicated `dispatcher/policy-cleanup-<timestamp>` branch and opened as a ready (never\ndraft) PR with no GitHub auto-close keyword and no associated issue — delivery from there\nis the existing one-shot [`ship`](#one-shot-ship-for-ad-hoc-pull-requests-27) command's\njob, not this command's.\n\n## Repeatable dogfood demo target and runbook (#71)\n\nUse the local fixture repository in [`docs/dogfood-demo-target/`](docs/dogfood-demo-target/)\nwhen you want a small, repeatable demo target without depending on a private production\nrepo. It contains one intentionally simple issue and the exact intake labels documented\nin the fixture itself.\n\n### Demo target setup\n\nThe first dogfood pass should stay in the safe path:\n\n- `dispatch:ready`\n- `agent:codex`\n- `priority:p1`\n- `effort:small`\n- autoship disabled\n\nThe fixture issue body is at [`docs/dogfood-demo-target/issues/1.md`](docs/dogfood-demo-target/issues/1.md).\nIts label manifest is at [`docs/dogfood-demo-target/labels.md`](docs/dogfood-demo-target/labels.md).\n\nIf you want a throwaway GitHub target, create a small repository from those files and\nopen a single issue that matches the fixture. The repository itself can stay public or\nlocal; the important part is that the issue and labels are deterministic and tiny.\n\n### Safe demo path\n\n1. Point `--repo` at the demo repository.\n2. Run a dry run first to confirm intake and routing without launching anything:\n\n```bash\nnode bin/ai-dispatcher.mjs --repo owner/repo --dry-run\n```\n\n3. Run a single scan with autoship disabled:\n\n```bash\nnode bin/ai-dispatcher.mjs --repo owner/repo --once\n```\n\n4. Inspect the expected handoff artifacts:\n- the issue should be claimed and a run should start\n- the agent should work in an isolated checkout\n- the terminal output should end at a ready-PR handoff, not deploy or issue closure\n- the PR body should reference `Issue: #<number>`\n\n### Representative output\n\nExpect the terminal to show a dry-run summary first, then a one-scan handoff such as:\n\n```text\n[dry-run] would start codex (claude-sonnet-5, effort small) on issue #1 — branch dispatcher/issue-1.\nPR ready — CI green, handing off to autoship\n```\n\nThe exact model name and branch name can vary with the routing configuration, but the\nflow should still stop at the PR-ready boundary when autoship is disabled.\n\n### Troubleshooting\n\n- Missing `gh`: install the GitHub CLI and confirm `gh auth status` works before running\n  the dispatcher.\n- Missing agent CLI auth: confirm the `codex` or `claude` login is available on the host\n  that runs the demo.\n- Failed CI: leave autoship disabled for the first pass, fix the target repository, and\n  rerun the single-scan path after the issue is green again.\n\nFor remote viewing, keep the target dashboard and the demo repository itself on loopback\nor use SSH port forwarding rather than binding them broadly on the LAN.\n\n## Requirements\n\n- **Node.js 24+** (the service runs its TypeScript directly via native type-stripping; no\n  build step, no `dist/`).\n- **`git`, `gh`, and the agent CLIs** (`codex` and/or `claude`) on `PATH` on the host that\n  runs the agents. This is normally the dev server.\n- Authentication is owned by those CLIs — `gh auth`, and the agent CLIs' own credentials.\n  **This service never stores a GitHub, OpenAI, or Anthropic token.** For a headless box,\n  the operator's credentials go in `~/.dispatcher/env` (chmod 600, never committed), which\n  `dispatch-agent.sh` sources; this is where `CLAUDE_CODE_OAUTH_TOKEN` (from\n  `claude setup-token`) or an `ANTHROPIC_API_KEY` belongs. The Claude capacity adapter\n  reads only the OAuth assignment as inert data; it never sources, logs, or persists it.\n\n## Configuration\n\nConfiguration is CLI-flag → environment → documented default. Repository identity is the\none value with no default. Copy `.env.example` to `.env` (never commit it) for the\nenvironment form; every variable is documented there.\n\n| Flag                       | Env                                 | Default                    | Meaning                                                                                        |\n| -------------------------- | ----------------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------- |\n| `--repo <owner/repo>`      | `DISPATCHER_REPO`                   | _(required)_               | target repository; canonical `owner/repository`, validated, no fallback                        |\n| `--repo-dir <path>`        | `DISPATCHER_REPO_DIR`               | _(required)_               | pristine mirror clone kept on `origin/main`                                                    |\n| `--worktree-dir <path>`    | `DISPATCHER_WORKTREE_DIR`           | _(required)_               | parent dir for per-run checkouts                                                               |\n| —                          | `DISPATCHER_ENV_SOURCE_DIR`         | _(optional)_               | checkout whose `.env` seeds each run checkout                                                  |\n| `--interval <seconds>`     | `DISPATCHER_POLL_INTERVAL_SECONDS`  | `900`                      | poll interval                                                                                  |\n| `--max-minutes <min>`      | `DISPATCHER_MAX_RUNTIME_MINUTES`    | `90`                       | per-run wall-clock budget                                                                      |\n| `--state-dir <path>`       | `DISPATCHER_STATE_DIR`              | `./state`                  | durable state directory                                                                        |\n| `--autoship-deploy-dir`    | `DISPATCHER_AUTOSHIP_DEPLOYMENT_DIR` | state/repo-specific      | dedicated checkout used only for merge/deploy/rollback                                         |\n| `--autoship-timeout-minutes` | `DISPATCHER_AUTOSHIP_TIMEOUT_MINUTES` | `120`                  | complete merge/deploy/verify/rollback command ceiling                                           |\n| `--blocked-audit-model`    | `DISPATCHER_BLOCKED_QUEUE_AUDIT_MODEL` | `claude-sonnet-5`      | non-frontier model used to audit stale `blocked` holds when the normal queue is drained         |\n| `--blocked-audit-effort`   | `DISPATCHER_BLOCKED_QUEUE_AUDIT_EFFORT` | `effort:low`          | effort label used for stale `blocked` audits                                                    |\n| `--blocked-audit-max`      | `DISPATCHER_BLOCKED_QUEUE_AUDIT_MAX_CANDIDATES` | `3`             | maximum blocked issues audited in one otherwise-idle scan                                       |\n| `--author-auth <mode>`     | `DISPATCHER_ISSUE_AUTHOR_AUTH_MODE` | `author-allowlist`         | `author-allowlist` requires the original issue author to be trusted; `none` allows all authors |\n| `--trusted-authors <list>` | `DISPATCHER_TRUSTED_ISSUE_AUTHORS`  | _(required for allowlist)_ | comma-separated GitHub usernames, matched case-insensitively                                   |\n| `--log-level <level>`      | `DISPATCHER_LOG_LEVEL`              | `info`                     | `debug\\|info\\|warn\\|error`                                                                     |\n| —                          | `NTFY_URL` / `NTFY_TOPIC`           | _(optional)_               | ntfy push notifications; disabled if unset                                                     |\n\nAutoship and recovery also use environment-only configuration:\n\n| Env | Default | Meaning |\n| --- | --- | --- |\n| `DISPATCHER_AUTOSHIP_CMD` | disabled | repository-specific merge/deploy/health/rollback command |\n| `DISPATCHER_GENERATED_CONFLICT_ALLOWLIST` | `docs/memory.md,docs/researcher.md` | exact generated paths eligible for deterministic conflict recovery |\n| `DISPATCHER_GENERATED_CONFLICT_REGEN_CMD` | disabled | target-repository command to regenerate allowlisted files |\n| `DISPATCHER_GENERATED_CONFLICT_MAX_ATTEMPTS` | `1` | deterministic generated-conflict attempts per pass |\n| `DISPATCHER_GENERATED_CONFLICT_CI_WAIT_SECONDS` | `900` | CI wait after generated-conflict repair |\n| `DISPATCHER_CI_SELF_HEAL_MAX_ATTEMPTS` | `2` | assigned-model repairs for each of agent/CI/merge/deploy |\n| `DISPATCHER_CI_ESCALATION_MODEL` | `claude-opus-4-8` | one final automatic model attempt after repairs |\n\n`author-allowlist` fails closed when trusted authors are missing or malformed. Untrusted\nissues are left open, marked `needs-input`, and commented once. The check uses only the\noriginal GitHub issue author's login; labels, assignees, comments, issue edits, branch\ncontents, commit authors, and model output cannot override it. This mitigates arbitrary\npublic issue submission, not compromise of a trusted GitHub account.\n\n## Running\n\n```bash\n# One scan and exit — the safest way to try it.\nnode bin/ai-dispatcher.mjs --repo owner/repo --once \\\n  --repo-dir ~/dispatcher/mirror --worktree-dir ~/dispatcher/worktrees\n\n# Validate config + report the next dispatch WITHOUT launching or mutating anything.\nnode bin/ai-dispatcher.mjs --repo owner/repo --dry-run\n\n# Poll forever (the service mode).\nnode bin/ai-dispatcher.mjs --repo owner/repo --interval 900\n```\n\nThe poll interval is configurable with `--interval` (in seconds) and defaults to 900 seconds if not specified.\n\nInstalled as a bin (`npm link` or `npm i -g`), the same commands are `ai-dispatcher …`.\n\n### As a service (systemd)\n\n```ini\n# /etc/systemd/system/ai-dispatcher.service\n[Unit]\nDescription=AI issue dispatcher\nAfter=network-online.target\n\n[Service]\nType=simple\nWorkingDirectory=/home/<operator>/ai-dispatcher\nEnvironmentFile=/home/<operator>/ai-dispatcher/.env\n# REQUIRED. The dispatcher spawns gh, node/npm, codex, and claude by name. A systemd\n# service does NOT inherit your login PATH, so without this it cannot find them and every\n# scan dies with \"gh exited 1\". Point PATH at wherever those CLIs actually live -- gh is\n# often in ~/bin and the Node CLIs under an nvm bin. git is on the default PATH already.\nEnvironment=PATH=/home/<operator>/bin:/home/<operator>/.nvm/versions/node/<ver>/bin:/usr/local/bin:/usr/bin:/bin\nExecStart=/home/<operator>/.nvm/versions/node/<ver>/bin/node bin/ai-dispatcher.mjs --repo owner/repo --interval 900\nRestart=on-failure\nRestartSec=30\n# SIGTERM triggers a graceful shutdown: the in-flight run finishes its current agent,\n# state is flushed, and the lock is released. On restart a surviving launcher whose\n# command exactly matches the durable run is terminated as a process tree before that\n# run is reconciled to `interrupted` (resumable).\n\n[Install]\nWantedBy=multi-user.target\n```\n\nLogs are one JSON object per line on stdout, ready for `journalctl`/`docker logs`.\n\n\n**Running more than one repo.** One process polls one --repo. To dispatch several repos,\nrun one unit per repo, each with its OWN DISPATCHER_STATE_DIR, DISPATCHER_REPO_DIR,\nDISPATCHER_WORKTREE_DIR, and autoship deployment checkout (the state dir carries the\nsingle-instance lock, so shared dirs collide). Autoship, when enabled, is per-instance via\nDISPATCHER_AUTOSHIP_CMD.\n\nAutoship never runs the deployment command from an agent issue checkout. The dispatcher\npasses `AUTOSHIP_DEPLOYMENT_CHECKOUT` (default:\n`<DISPATCHER_STATE_DIR>/autoship-deployments/<owner>-<repo>`) and runs the command from\nthat directory. It also passes exact immutable context:\n`AUTOSHIP_PR_HEAD_SHA`, `AUTOSHIP_BASE_SHA`, `AUTOSHIP_PR_NUMBER`, `AUTOSHIP_ISSUE_NUMBER`,\n`AUTOSHIP_BRANCH`, and `AUTOSHIP_REPO`. Repo-specific commands should deploy the exact\nmerged SHA they produce, record last-known-good before changing production, and emit one\nterminal status line on every completion:\n`::autoship:: state=<state> health=<pass|fail|unknown> pr_head=<sha> merged=<sha> deployed=<sha|-> rollback=<sha|-> last_good=<sha|-> checkout=<path>`.\nRecognized states are `merge_succeeded_deployment_not_attempted`,\n`deployment_failed_rollback_succeeded`, `deployment_failed_rollback_failed`,\n`deployment_state_unknown`, and `shipped`. Missing control output is always reported as\nunknown production state, including on exit zero. A `shipped` report is accepted only\nwith both merged and deployed SHAs plus passing health.\nIf production already contains an older requested merge, the command must report it\ndelivered without deploying that older SHA over newer production.\n\nAutoship commands have a 120-minute default ceiling\n(`DISPATCHER_AUTOSHIP_TIMEOUT_MINUTES`). Timeout terminates the entire deploy process\ngroup—not just its wrapper shell—so no orphaned build, SSH process, or deploy lock can\npoison the recovery attempt.\n\nAutoship can repair a green PR that is blocked only by generated-file merge conflicts.\nThe recoverable paths are exact and explicit: `DISPATCHER_GENERATED_CONFLICT_ALLOWLIST`\ndefaults to `docs/memory.md,docs/researcher.md`. Set\n`DISPATCHER_GENERATED_CONFLICT_REGEN_CMD` to the target repository's generation command\nwhen those files must be recreated by a hook or script. Recovery is bounded by\n`DISPATCHER_GENERATED_CONFLICT_MAX_ATTEMPTS` (default `1`) and waits up to\n`DISPATCHER_GENERATED_CONFLICT_CI_WAIT_SECONDS` (default `900`) for repaired-branch CI\nbefore autoship may merge.\n\nEvery delivery phase uses one recovery contract: agent exit/zero-commit/no-PR failures,\nred CI, mergeability or file-conflict failures, deploy failures, unhealthy/unknown\nproduction reports, and failure to close the shipped issue. Recovery is evidence-based:\ntransient failures retry the same model, deterministic failures repair with the concrete\ntarget, shallow or incomplete attempts may increase effort, provider-specific misses and\ncapacity failures can hand off laterally, and capability escalation moves one route at a\ntime before a final frontier attempt. The resumed agent receives the exact recovery\nreason as data in its prompt, including conflicting file names and CI evidence.\n\nThe phase budgets are independent: spending the CI ladder does not consume the merge or\ndeploy ladder. The original issue assignment is retained separately from the currently\nrunning escalation model, so a successful frontier repair in one phase does not make\nfrontier the \"assigned\" model for later phases. Intermediate attempts and escalation do not send operator push\nnotifications. Only failure after the frontier attempt stamps `autoship-held`, retains\nthe issue claim, posts the exhausted evidence, and sends one high-priority page.\n\nGitHub transport/auth/read failures are parked as unknown and rechecked; they are not\nmisreported as red CI, merge failure, or operator removal of an exhausted hold.\n\nWhen no active, resumable, parked, held, or normally eligible issue remains, the\ndispatcher can audit a bounded slice of the `blocked` queue. It reads each candidate's\nbody, checks referenced dependency issue states, and asks the configured non-frontier\nmodel for a conservative JSON verdict. Any open/unknown dependency, unreadable issue\nbody, model failure, invalid audit configuration, or label mutation failure leaves labels\nunchanged and creates no claim. The first issue proven workable has only `blocked`\nremoved, gets an audit comment, and waits for a later normal scan; `needs-input`,\n`autoship-held`, and other holds are never cleared by this path.\n\nDraft and review-required PRs are promoted and admin-merged. Mixed Markdown/source\nconflicts that the deterministic generated-file repair cannot resolve are handed to the\nagent rather than held. An already-merged PR is deployed by exact merge SHA and verified\ninstead of standing down for manual production verification.\n\n**Self-shipping.** When the dispatcher ships changes to *itself*, point\n`DISPATCHER_AUTOSHIP_CMD` at this repo's\n[`scripts/self-ship.sh`](scripts/self-ship.sh) and set `DISPATCHER_AUTOSHIP_DEPLOYMENT_DIR`\nto the checkout the systemd unit runs *from* (e.g. `~/ai-dispatcher`) so a restart serves\nthe merged code. `self-ship.sh` re-gates and merges, then hands the restart to a **detached**\ntransient unit (outside the dispatcher's own cgroup, so the restart does not kill the ship\ncommand mid-flight) which verifies health and **rolls back** to the previous commit if the\nnew code does not come up. The detached unit keeps restarting last-known-good until it is\nhealthy; it does not page the operator from this intermediate failure. The restarted\ndispatcher then owns the normal deploy-repair → frontier → exhausted ladder. See\n[`.env.example`](.env.example) for the exact variables.\n\n## Run outcome semantics\n\nA run's terminal `status` is never \"succeeded\" for merely opening a PR or observing\ngreen CI at hand-off. The issue claim remains durable until the handoff or delivery\nstate is itself durable, preventing an unfinished PR from being silently re-claimed.\n\nThe terminal statuses:\n\n- **`pr_ready`** — the agent exited cleanly with a PR and green CI at handoff. This is\n  the terminal ready-PR output when autoship is disabled; with autoship configured it is\n  immediately re-gated and cannot become `shipped` until production is verified. It\n  retains the issue claim (but not the `agent-working` label), preventing redispatch.\n- **`shipped`** — the only TRUE success: the PR is merged, production health passed,\n  and the linked issue was closed.\n- **`ci_pending`** — the agent finished and opened a PR, but CI had not resolved yet.\n  **Parked**: the claim stays, and the next scan re-checks CI ONLY — it does not\n  relaunch the agent to wait on a check that is already running.\n- **`ci_failed`** — a ladder-in-progress marker (CI is definitively red, or a deploy\n  failure is being escalated). Drives the repair → frontier → exhausted ladder described\n  above and keeps the issue claim across the relaunch. Always resolved further within the\n  same finalize pass; a run should not be found sitting in this status across a scan\n  boundary in normal operation.\n- **`held`** — the assigned-model repair attempts and the final frontier attempt for a\n  delivery phase all failed. This is the sole coding/CI/merge/deploy operator-handoff\n  state. `autoship-held` and the retained claim prevent a fresh-from-scratch rerun.\n- **`failed`** — the agent itself crashed, gave up (zero commits), or exited non-zero.\n  This is an intermediate classification that immediately enters the same repair →\n  frontier → exhausted ladder; it is not an operator handoff or 24-hour deferral.\n\n`interrupted` / `timed_out` / `token_exhausted` are unchanged: crash/timeout recovery,\nresumed by relaunching the agent on the next scan.\n\n**The linked issue closes only on a verified `shipped`, never on merge.** PR bodies\nnever carry a GitHub auto-close keyword (`Closes`/`Fixes`/`Resolves #n`) -- merging\ncloses the issue instantly, before the deploy that follows the merge has even started,\nlet alone passed its health check. `autoshipRun` calls `github.closeIssue` itself, once,\nonly after the ship command's own exit code AND its mandatory terminal `::autoship::`\nstatus line prove the exact merged/deployed SHA is healthy -- not merely on reaching the\nsuccess branch.\nMerge is not the same as delivery: an issue is closed only after the deployed revision\npasses health verification.\n\nOnly the autoship evaluation/finalization path in `dispatcher.ts` writes verified\n`shipped` or exhausted `held`; `exhaustRun` is the single hold/page function. Fresh CI\nreads transition `ci_pending`/`ci_failed` both right after a\nfresh/resumed/self-healed/escalated run and on every parked recheck.\n\n## State model\n\nAll durable state is one atomically-written JSON file plus a lock, under `--state-dir`:\n\n- `state.json` — runs (status, claim, resume/progress counters, parked/ladder CI\n  state, PR/commit), provider cooldown windows, per-phase recovery budgets, and verified\n  frontier-exhaustion proof. Written\n  temp-file-then-rename, so a crash mid-write never corrupts it. Each successful write\n  also refreshes an atomic backup. A corrupt primary is preserved as `.corrupt-<ts>` and\n  restored from that backup; if neither copy is readable, startup fails closed rather\n  than discarding every durable claim.\n- `dispatcher.lock` — single-instance guard. A second dispatcher against the same state\n  dir refuses to start. Creation is atomic, a lock from a dead process is reclaimed, and\n  process identity prevents PID reuse from turning a stale lock into a permanent block.\n\nEvery runner terminal observation is checkpointed as awaiting finalization before control\nreturns to the loop. After a kill/restart, the dispatcher completes that exact run's\nrepair/autoship path before fresh selection; replayed telemetry uses the attempt id as an\nidempotency key. Legacy `succeeded` rows are treated as unverified PR handoffs and\nre-enter the same finalization path rather than being accepted as production success;\nlegacy `shipped` rows written before the recovery ledger are likewise reverified because\nolder self-restarts could persist that word before issue closure survived.\n\nNo database is required. The lock plus serial execution provide one durable claim at a\ntime, with recovery state stored atomically on disk.\n\n## Stopping / interrupting\n\n`SIGINT` / `SIGTERM` abort the poll loop and release the lock. A run that was mid-agent\nwhen the process died has any surviving, exactly matched launcher process tree terminated,\nthen is reconciled to `interrupted` on the next start and resumed from the first\nmilestone without a `[DONE]` marker — completed work is never redone, and the\nper-run branch/checkout are always preserved.\n\n## Limits and non-goals\n\n- **Default path ends at a ready PR.** It never merges, closes issues, or deploys unless\n  an operator explicitly configures `DISPATCHER_AUTOSHIP_CMD`.\n- **Serial, single-host.** One agent at a time, on the host where the CLIs are installed.\n\n## Development\n\n```bash\nnpm install       # only devDependency is typescript (for typecheck)\nnpm run typecheck # tsc --noEmit\nnpm test          # node --test over test/**/*.test.ts (zero runtime deps)\n```\n","readmeFilename":"README.md","_rev":"1-12cd3164e330a5d98e34956cb091a1f1"}