Millwright decouples its four external integrations behind adapter interfaces so the orchestrator stays generic. The default setup ships with GitHub Projects + GitHub + Slack + Claude Code as the reference implementation. Swapping any concern is a folder-and-registry change, not a rewrite.
| Concern | Base class | Default adapter |
|---|---|---|
| Issue tracking (board state) | Adapters::IssueTracker |
Adapters::GithubProjects::IssueTracker |
| Version control (repos, PRs, CI, worker auth) | Adapters::VersionControl |
Adapters::Github::VersionControl |
| Update channel (human-facing notifications) | Adapters::UpdateChannel |
Adapters::Slack::UpdateChannel |
| Coding agent (worker process) | Adapters::CodingAgent |
Adapters::ClaudeCode::CodingAgent |
Each base class lives in lib/adapters/<concern>.rb and raises
NotImplementedError from every method.
config.yml:
adapters:
issue_tracker: github_projects
version_control: github
update_channel: slack
coding_agent: claude_codeOrchestrator::Context.new(config:) and RoutineEnv.new(config) both read
this block and build via Adapters::Registry.
Adapters::Registry.register(:update_channel, :slack, Adapters::Slack::UpdateChannel)
Adapters::Registry.build(:update_channel, :slack, config)Built-in adapters self-register from lib/adapters/registry.rb. To add a
new adapter, create lib/adapters/<provider>/<concern>.rb inheriting the
base class, then add one register line at the bottom of registry.rb.
Handlers never inline provider-specific verbs like gh issue comment … or
curl … $webhook. Those come from #prompts accessors on the adapters:
@ctx.vcs.prompts.post_issue_comment(issue_number: 42, repo: "u/r")
@ctx.issue_tracker.prompts.mark_plan_ready(issue_number: 42, repo: "u/r")
@ctx.update_channel.prompts.send_messageThis is why handler prompt bodies are adapter-neutral English — swapping Slack for Discord or GitHub for GitLab changes the worker's verbs without touching handler code.
The CodingAgent adapter has no #prompts accessor — coding-agent
differences are about how to invoke, not what to ask. The prompt body
stays neutral.
VersionControl#worker_env returns the hash that gets merged into the
worker's spawn environment. The GitHub adapter returns
{ "GH_TOKEN" => …, "GIT_AUTHOR_NAME" => …, … }. There is no separate
auth interface — the token is consumed by git push, gh, glab etc.,
so it lives with the VCS adapter.
WorkerRunner#spawn_worker composes:
env = @vcs.worker_env.merge(@coding_agent.env_overrides)
argv = @coding_agent.command(prompt_path: …)
# stdin routing comes from @coding_agent.stdin_modetest/adapters/contracts/{issue_tracker,version_control,update_channel,coding_agent}_contract.rb
are plain Ruby modules. Each concrete adapter test includes the matching
contract module and gets uniform coverage of return-value shape and basic
invariants.
To add an adapter, create a test file under test/adapters/,
include the contract module, define build_adapter (a hermetic instance —
stub the transport), and run bundle exec rake. The contract tests will
exercise the new adapter the same way they exercise the GitHub / Slack /
Claude Code defaults.
Copy the matching skeleton from docs/adapters/:
EXAMPLE_issue_tracker.rbEXAMPLE_version_control.rbEXAMPLE_update_channel.rbEXAMPLE_coding_agent.rb
Each # TODO: block describes inputs, expected return shape, idempotency
requirements, and any side-effect contracts.
- Cross-provider PR↔issue linking. GitHub finds the linked PR by
branch-name convention (
NNN-…). Linear or GitLab adapters would need an explicitissue_linkfield plumbed through. - Routine-specific verbs.
lib/routines/security_scan.rbandweekly_digest.rbstill callgh pr list --search/gh issue viewdirectly rather than going through the adapter. Those calls are routine-specific (not part of the general orchestrator loop), so the adapter contracts were kept tight rather than expanded to cover them. A non-GitHub setup would need to provide its own routine implementations for those scripts.