This directory contains a Go implementation of Symphony focused on GitHub Projects v2 as the issue tracker.
Warning
This is an experimental implementation for trusted environments. It currently launches a
shell-based agent.command by default. Codex app-server execution is available as an opt-in
runtime for agent.kind: codex.
- Loads a repository-owned
WORKFLOW.md - Polls a GitHub Projects v2 board for issues in configured active states
- Creates a deterministic workspace per issue
- Runs workspace lifecycle hooks
- Renders the agent prompt for the current issue and turn
- Runs either
agent.commandor a Codex app-server turn inside the workspace - Reconciles running work against GitHub Project status and retries failed runs with backoff
- Updates GitHub Project status and a persistent
## Codex Workpadissue comment when possible - Monitors long-running issue loops and creates backlog sub-issues when work needs to be split
- Go 1.23+
- A GitHub token with access to the target Project v2 and its issues
read:projectpermission for classic PATs, or equivalent fine-grained project accessIssues: readpermission whentracker.read_issue_dependenciesis enabledIssues: writepermission whenloop_monitor.enabledis true and sub-issue creation is needed
cd go
export GITHUB_TOKEN=...
go run ./cmd/symphony ./WORKFLOW.github.mdTo print setup commands for a GitHub Project:
go run ./cmd/symphony setup-github-project ./WORKFLOW.github.mdSymphony writes Info and higher logs to stdout by default. The GitHub tracker also has a debug
Project scan summary for setup troubleshooting, including the configured owner/project, requested
states, total Project items, matched issues, and counts/examples for skipped items such as non-Issue
items, missing Status values, state mismatches, assignee mismatches, and repository mismatches.
Runtime events that are emitted include dispatch decisions, retry/backoff scheduling, GitHub Project status transitions, workspace cleanup, pull request merge attempts, and per-issue run completion. Each event carries the issue identifier, state, and (where relevant) PR number, retry count, and error so a tail/grep over the log file is enough to follow what Symphony is doing.
To persist logs to a file that another shell can tail -f or rg while Symphony runs, set
observability.log_file in the workflow front matter:
observability:
log_json: true
log_level: info
log_file: ~/symphony-logs/runtime.log
dashboard_enabled: true
refresh_ms: 1000
render_interval_ms: 16
run_health:
enabled: true
quiet_after_ms: 300000
suspect_after_ms: 600000
self_report_timeout_ms: 120000When log_file is set, Symphony appends structured events to the file and mirrors them to
stdout. When the terminal dashboard is enabled, the dashboard owns stdout and mirrored logs are
written to stderr instead. The path supports ~/ and $ENV expansion, and any missing parent
directories are created at startup. The file is opened in append mode so restarts preserve prior
history.
The terminal dashboard is enabled by default and renders a compact lipgloss status frame with the
current running agents. Set observability.dashboard_enabled: false to keep stdout log-only.
Run health is also enabled by default. For shell-based agent.command, health is based on time
since dispatch or the last completed turn. For Codex app-server turns, streamed app-server events
also refresh run activity and populate session, event, and token fields in the dashboard. Runs are
active before quiet_after_ms, quiet before suspect_after_ms, suspect until
self_report_timeout_ms elapses, and then stalled.
Without log_file, you can still capture stdout from another session via tee:
go run ./cmd/symphony ./WORKFLOW.github.md 2>&1 | tee symphony.logThen in another shell:
tail -f ~/symphony-logs/runtime.log
rg 'failed|retry|backoff|panic' ~/symphony-logs/runtime.logSymphony selects per-agent defaults from agent.kind in the workflow front matter:
agent.kind |
Default agent.command |
Default tracker.workpad_marker |
|---|---|---|
codex (or omitted) |
mkdir -p .tmp && TMPDIR="$PWD/.tmp" TMP="$PWD/.tmp" TEMP="$PWD/.tmp" codex exec --sandbox workspace-write --skip-git-repo-check < "$SYMPHONY_PROMPT_FILE" |
## Codex Workpad |
claude-code |
cat "$SYMPHONY_PROMPT_FILE" | claude -p --dangerously-skip-permissions |
## Claude Workpad |
Both agent.command and tracker.workpad_marker can be overridden per workflow. agent.kind is normalized to lower case and an unknown value is rejected at workflow load time.
claude-code requires the Claude Code CLI installed and on PATH. The default command runs Claude Code with --dangerously-skip-permissions because Symphony already isolates each issue inside a per-issue workspace; if you need stricter sandboxing, set agent.command explicitly.
agent.runtime selects how the active agent is executed:
agent.runtime |
Supported agent.kind |
Behavior |
|---|---|---|
command (omitted) |
codex, claude-code |
Write the prompt to a temporary file, then run agent.command. |
app-server |
codex |
Start agent.app_server_command, send the prompt over the Codex app-server protocol, and stream status events into the dashboard. |
agent.app_server_command defaults to codex app-server. The command runtime remains the default
for backward compatibility.
Minimal Codex app-server example:
agent:
kind: codex
runtime: app-server
app_server_command: codex app-server
max_concurrent_agents: 4
max_turns: 20Minimal claude-code example:
agent:
kind: claude-code
max_concurrent_agents: 4
max_turns: 20When agent.kind: claude-code is used, Symphony enables a one-way fallback to Codex by
default for the current active run. If the Claude Code command exits nonzero with the reserved
wrapper exit code 88, Symphony classifies it as claude_limit. A narrow stderr/stdout
classifier remains as a compatibility path for known usage/rate/quota wording and structured
rate_limit_error output. Ordinary task failures still use the normal failed-run retry/backoff
path.
On fallback, Symphony records an explicit attempt boundary instead of treating the same attempt as
if it silently changed agents. The failed Claude Code attempt and the running/completed Codex
fallback attempt are written to the workspace fallback state, Symphony writes a Workpad note such as
claude-code limit reached; retrying with codex, and the same turn is retried with the fallback
command. The Workpad note also calls out that workspace and issue context are preserved while
Claude-only hooks, slash-skill assumptions, and per-agent restrictions are advisory in Codex.
The fallback is one-way only for the current issue state. Symphony writes
.symphony/agent-fallback.json inside the issue workspace before starting Codex; if the
orchestrator restarts while the issue is still in that state and the fallback is not completed, the
next run resumes with Codex instead of starting Claude Code again. If the persisted fallback is
already completed, Symphony skips duplicate agent execution and lets the normal successful handoff
path run. If the issue later changes state, for example from In Progress to Rework, the stale
fallback record is ignored and the configured primary agent.kind can run again.
The default fallback is equivalent to:
agent:
kind: claude-code
fallback:
enabled: true
kind: codex
on: [claude_limit]
command: |
mkdir -p .tmp
TMPDIR="$PWD/.tmp" TMP="$PWD/.tmp" TEMP="$PWD/.tmp" \
codex exec --sandbox workspace-write --skip-git-repo-check < "$SYMPHONY_PROMPT_FILE"To disable automatic fallback:
agent:
kind: claude-code
fallback:
enabled: falseTo customize only the fallback command:
agent:
kind: claude-code
fallback:
kind: codex
on: [claude_limit]
command: |
mkdir -p .tmp
TMPDIR="$PWD/.tmp" TMP="$PWD/.tmp" TEMP="$PWD/.tmp" \
codex exec --sandbox workspace-write --skip-git-repo-check --model gpt-5.4 < "$SYMPHONY_PROMPT_FILE"The classifier intentionally does not match generic command failures. Prefer a thin Claude Code
wrapper that owns message parsing and exits 88 for claude_limit; when Claude Code later exposes
native structured exit codes, map them in that wrapper. The compatibility text classifier recognizes
rate_limit_error-style output, and otherwise requires known usage/rate/quota or HTTP 429 wording to
also mention a Claude/Anthropic context.
Use YAML front matter plus a Go text/template prompt body. The prompt receives:
.Issue: normalized issue metadata.Issue.Comments: non-Workpad issue comments from repository owners or organization members for additional human instructions and context.Issue.PRReviewComments: unresolved review thread comments from repository owners or organization members on linked pull requests (bot comments older than the latest commit are skipped).Turn: current turn number
Minimal GitHub Projects example:
---
tracker:
kind: github
token: $GITHUB_TOKEN
owner: miyataka
owner_type: user
project_number: 1
status_field: Status
priority_field: Priority
allowed_repositories:
- miyataka/api
- miyataka/frontend
start_state: In Progress
handoff_state: Human Review
rework_state: Rework
merging_state: Merging
done_state: Done
workpad_marker: "## Codex Workpad"
read_issue_dependencies: true
backlog_states: [Backlog]
active_states: [Todo, In Progress, Rework]
monitor_states: [Human Review, Merging]
terminal_states: [Done, Closed, Cancelled, Canceled, Duplicate]
pull_request:
auto_merge: false
merge_method: MERGE
require_approval: true
require_passing_checks: true
required_check_names: []
loop_monitor:
enabled: true
interval_ms: 3600000
max_runtime_ms: 21600000
min_turns: 3
sub_issue_state: Backlog
workspace:
root: ~/code/symphony-workspaces
cleanup_orphans: false
cleanup_stale_after_days: 0
hooks:
after_create: |
git clone "$SYMPHONY_REPOSITORY_SSH_URL" .
git fetch origin --prune
base_branch="$(git symbolic-ref --short refs/remotes/origin/HEAD | sed 's|^origin/||')"
git checkout -B "$SYMPHONY_BRANCH" "origin/$base_branch"
{
echo ".symphony/"
echo ".tmp/"
} >> .git/info/exclude
before_run: |
git fetch origin --prune
after_run: |
git rm -f --ignore-unmatch .symphony/prompt.md >/dev/null 2>&1 || true
changes="$(git status --porcelain -- . ':(exclude).symphony' ':(exclude).tmp')"
prompt_cleanup="$(git diff --cached --name-only -- .symphony/prompt.md)"
if [ -z "$changes$prompt_cleanup" ]; then
echo "no non-Symphony workspace changes to commit" >&2
exit 1
fi
git add -A -- . ':(exclude).symphony' ':(exclude).tmp'
git commit -m "$SYMPHONY_ISSUE_IDENTIFIER: agent update"
git push -u origin "$SYMPHONY_BRANCH"
gh pr view "$SYMPHONY_BRANCH" --repo "$SYMPHONY_REPOSITORY" >/dev/null 2>&1 || \
gh pr create --repo "$SYMPHONY_REPOSITORY" --head "$SYMPHONY_BRANCH" \
--title "$SYMPHONY_ISSUE_TITLE" --body "Automated work for $SYMPHONY_ISSUE_URL"
agent:
max_concurrent_agents: 4
max_turns: 20
command: |
mkdir -p .tmp
TMPDIR="$PWD/.tmp" TMP="$PWD/.tmp" TEMP="$PWD/.tmp" \
codex exec --sandbox workspace-write --skip-git-repo-check < "$SYMPHONY_PROMPT_FILE"
---
You are working on GitHub issue {{ .Issue.Identifier }}.
Title: {{ .Issue.Title }}
URL: {{ .Issue.URL }}
Repository: {{ .Issue.RepositoryNameWithOwner }}
{{ .Issue.Description }}
{{ if .Issue.Comments }}Issue comments from repository owners or organization members:
{{ range .Issue.Comments }}
- {{ .Author }} {{ .URL }}
{{ .Body }}
{{ end }}
{{ end }}
{{ if .Issue.PRReviewComments }}Unresolved PR review comments from repository owners or organization members:
{{ range .Issue.PRReviewComments }}
- {{ .Author }}{{ if .AuthorIsBot }} (bot){{ end }} on PR #{{ .PRNumber }} {{ .Path }}{{ if .Line }}:{{ .Line }}{{ end }} {{ .URL }}
{{ .Body }}
{{ end }}
{{ end }}
Instructions:
1. Work only inside the current workspace.
2. Do not edit, stage, or commit `.symphony/` or `.tmp/`; they are Symphony runtime files.
3. Do not run `git commit`, `git push`, `gh pr create`, or `gh pr edit`; Symphony hooks publish the branch and PR after the turn.
4. Run validation before handoff.SYMPHONY_ISSUE_IDSYMPHONY_ISSUE_IDENTIFIERSYMPHONY_ISSUE_TITLESYMPHONY_ISSUE_URLSYMPHONY_ISSUE_STATESYMPHONY_BRANCHSYMPHONY_REPOSITORYSYMPHONY_REPOSITORY_SSH_URLSYMPHONY_REPOSITORY_HTML_URLSYMPHONY_AGENT_KIND(codexorclaude-code; during fallback this is the active fallback kind)SYMPHONY_PROMPT_FILEforagent.command; whenagent.commandis configured this prompt file is outside the workspace and removed after the command exitsSYMPHONY_TURNforagent.command
When the tracker supports writeback, Symphony updates the configured status_field:
Todoitems move totracker.start_statebefore dispatch- successful active runs move to
tracker.handoff_state
tracker.monitor_states are polled for writeback policies but do not dispatch agents. This lets
Symphony watch Human Review for requested changes and Merging for completed PRs without
starting another run while review or merge is still pending.
tracker.backlog_states are Project Status options that Symphony recognises as captured but
not-yet-dispatchable work. Items in a backlog state are excluded from agent dispatch so they will
not run until a human moves them into an active state. Use them as a holding area before work is
ready for Symphony:
Backlog: accepted but not dispatchable yet — Symphony will not pick the issue up.Todo: ready for Symphony dispatch — the orchestrator may move it intotracker.start_stateand run an agent once concurrency and repository filters allow.
Backlog states only influence the setup-github-project output (so the Project Status field is
created with a Backlog option). They are not used for dispatch, writeback, or workspace
lifecycle. To make backlog items eligible for agent runs, move them to Todo (or another
active_states entry) instead of adding Backlog to tracker.active_states.
It also creates or updates one issue comment containing tracker.workpad_marker, defaulting to
## Codex Workpad. This comment is the handoff surface for workspace path, status, and execution
notes.
When loop_monitor.enabled is true, Symphony checks running issues every
loop_monitor.interval_ms milliseconds. If a run has exceeded loop_monitor.max_runtime_ms and
has completed at least loop_monitor.min_turns, Symphony treats it as a suspected loop, creates a
new issue in the same repository, adds it to the configured GitHub Project, moves it to
loop_monitor.sub_issue_state, and records the created sub-issue in the parent Workpad. Each
running issue can trigger this once per Symphony process run.
The default interval is one hour, the default runtime threshold is six hours, the default minimum
turn count is three, and new breakdown issues default to Backlog.
When tracker.read_issue_dependencies is true, Symphony reads GitHub issue dependencies through
the REST API and populates .Issue.BlockedBy with open blockers. Issues with open blockers are not
dispatched.
Symphony also reads pull requests referenced by GitHub's closedByPullRequestsReferences field and
exposes them through .Issue.PullRequests. Each PR includes review decision, merge state, status
check rollup state, comment count, and unresolved review thread count so workflows can decide
whether to hand off, rework, or wait.
When linked PR data is present, Symphony applies two conservative state transitions:
tracker.handoff_statemoves totracker.rework_stateif any linked PR has requested changes or unresolved review threads.tracker.handoff_statemoves totracker.merging_stateif any linked PR is open, non-draft, approved, and has passing checks.tracker.merging_statemoves totracker.done_stateif any linked PR is merged.tracker.merging_statemoves back totracker.rework_stateif a linked PR gets requested changes, unresolved review threads, or failing checks before merge.
When pull_request.auto_merge is true, a ready PR in tracker.merging_state is merged with
pull_request.merge_method. required_check_names can restrict readiness to specific check names;
when it is empty, the GitHub check rollup state is used.
Terminal issues remove their workspaces on startup. workspace.cleanup_orphans also removes
workspace directories that no longer match visible Project items, and
workspace.cleanup_stale_after_days removes old workspace directories by modification time.
Each running issue writes a durable lease to .symphony/run_lease.json in its workspace. The lease
contains the issue id, state, run id, process id, status, start time, and heartbeat time. While the
orchestrator process is alive it refreshes the heartbeat every 30 seconds.
On startup and each poll, active Project states such as In Progress and Rework are still
dispatchable, but they are not dispatched blindly. If the issue workspace has a non-stale running
lease from a previous process, Symphony skips the issue so a restarted orchestrator does not start a
second agent in the same workspace. A running lease becomes stale after five minutes without a
heartbeat; Symphony then marks the stale lease and may retry the issue from its current Project
state.
On graceful shutdown, Symphony cancels active runs, waits for their goroutines to clean up, records
the lease as interrupted, and updates the Workpad to say the issue remains in its current state for
a future retry. If cleanup does not finish before the shutdown grace period, the Workpad records that
cleanup was interrupted; a fresh process will continue to honor the still-running lease until it
goes stale.
agent.command runs in its own process group. Cancellation and turn timeouts kill that process
group, not just the immediate bash -lc wrapper, so shell-spawned child processes do not continue
working on the branch after the orchestrator has stopped the run.
Reusable shell snippets live in go/examples/:
github-after-create.shclones the issue repository and checks out the Symphony branch.github-before-run.shrefreshes refs before each agent turn.github-after-run-pr.shcommits changes, pushes the branch, and creates a PR if needed.
make all- Codex app-server execution is opt-in and keeps one synchronous app-server session per issue run.
WORKFLOW.mdis read once at startup; SPEC §6.2 dynamic reload is not implemented yet. Seedocs/hot_reload.mdfor the proposed conformance path.