Skip to content

Repository files navigation

Symphony

An autonomous coding orchestrator. Symphony polls a Linear board, picks up tickets in your configured active states, and spawns a headless Claude Code agent per ticket. Each agent clones a fresh workspace, implements the ticket end-to-end (branch → code → tests → PR), and updates Linear as it goes. A live terminal dashboard lets you watch everything in real time.

Linear board  ──poll──▶  Symphony orchestrator  ──spawn──▶  Claude Code agent × N
                                │                                    │
                         /status (HTTP)                     workspace + git + PR
                                │
                         symphony-status (TUI)

Prerequisites

Dependency Min version Purpose
Node.js 20 Runtime
Claude Code CLI latest Agent runner
GitHub CLI (gh) 2.x Agents create PRs
Git 2.x Workspace cloning
Linear account Issue source

Installing prerequisites

macOS
# Node.js — via nvm (recommended) or direct installer
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.zshrc          # or ~/.bashrc if you use bash
nvm install 20
nvm use 20

# Claude Code CLI
npm install -g @anthropic-ai/claude-code

# GitHub CLI
brew install gh

# Authenticate gh (do this once)
gh auth login
Windows

Symphony's workspace hooks run as Bash scripts (bash -l). Windows requires WSL 2 (Windows Subsystem for Linux). Run everything inside a WSL terminal.

# Inside WSL (Ubuntu/Debian):

# Node.js via nvm
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.bashrc
nvm install 20
nvm use 20

# Claude Code CLI
npm install -g @anthropic-ai/claude-code

# GitHub CLI
(type -p wget >/dev/null || (sudo apt update && sudo apt-get install wget -y)) \
  && sudo mkdir -p -m 755 /etc/apt/keyrings \
  && wget -qO- https://cli.github.com/packages/githubcli-archive-keyring.gpg \
     | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \
  && sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
  && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
     | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
  && sudo apt update && sudo apt install gh -y

# Authenticate gh (do this once)
gh auth login

Tip: SSH agent forwarding works differently in WSL. If your repo uses SSH remotes, follow GitHub's WSL SSH guide.


Installation

git clone git@github.com:silas-dsc/symphony.git
cd symphony
npm install
npm run build

Configuration

1. Environment variables

Copy the example and fill in your values:

cp .env.example .env
Variable Required Description
LINEAR_API_KEY Yes Linear personal API key. Generate at linear.app/settings/apiPersonal API keys
ANTHROPIC_API_KEY No Anthropic API key. If omitted, Claude Code uses browser OAuth instead (see Claude Code auth below)

2. Agent MCP servers (optional but recommended)

Symphony passes --mcp-config <path> to each spawned claude process so agents have a known, deterministic set of MCP tools regardless of what the cloned target repo declares. By default, Symphony looks for agent-mcp.json in the orchestrator directory; override with SYMPHONY_AGENT_MCP_CONFIG=/abs/path/to/file.json.

The repo ships an agent-mcp.json that wires up the SuperClaude_Framework MCP server set:

Server Purpose Key required
@playwright/mcp Cross-browser automation & mobile UX verification
@modelcontextprotocol/server-sequential-thinking Multi-step structured reasoning
@upstash/context7-mcp Official library documentation lookup
serena Semantic code analysis & intelligent editing (LSP-backed)
chrome-devtools-mcp Chrome DevTools debugging & perf analysis
@21st-dev/magic Modern UI component generation TWENTYFIRST_API_KEY
@morph-llm/morph-fast-apply Fast-apply context-aware code edits MORPH_API_KEY
tavily (via mcp-remote) Web search & real-time information TAVILY_API_KEY

Playwright is launched in headless + isolated + --ignore-https-errors mode, which is what the agent uses to verify mobile UX: screenshots at 375px, accessibility snapshots, console-log capture, form interaction, network-request counting. The --ignore-https-errors flag lets the agent navigate to the workspace's https://localhost:<port> SSL proxy — required for Firebase Auth and other secure-context features.

Prerequisites:

  • Playwright downloads its own Chromium build on first run. If your machine has restricted network egress, pre-install via npx playwright install chrome (or specify --executable-path in agent-mcp.json).
  • Serena is launched via uvx. Install uv (curl -LsSf https://astral.sh/uv/install.sh | sh) if you want Serena's semantic editing tools; without uv the server simply fails to start and the other servers keep working.
  • The three API-keyed servers (Magic, Morphllm, Tavily) pick up keys from your .env — see .env.example. If a key is unset, that server's tools return auth errors at call time; the rest stay available.

To disable an individual server: delete its entry from agent-mcp.json. To bypass entirely: delete agent-mcp.json and unset SYMPHONY_AGENT_MCP_CONFIG. Agents will then run with whatever MCPs are configured user-level in ~/.claude.json.

3. WORKFLOW.md

WORKFLOW.md is the single configuration file that controls both the orchestrator and the prompt sent to each agent. It uses YAML front matter for settings, with the rest of the file as a Liquid-templated prompt.

A minimal example:

---
tracker:
  kind: linear
  project_slug: "ALL"       # or a specific project slug; "ALL" = whole team
  team_key: "ENG"           # required when project_slug is "ALL"
  active_states:
    - In Progress
  terminal_states:
    - Done
    - Cancelled
    - Canceled
    - Closed
    - Duplicate

polling:
  interval_ms: 30000        # how often to poll Linear (ms)

github_preview:
  enabled: true
  repo_owner: my-org
  repo_name: my-repo
  comment_pattern: 'deployed to .*? Preview \(Web\) PR #(?<pr>\d+)'
  url_template: 'https://preview-web-pr-{{pr}}.example.com/'
  keepalive_interval_ms: 180000

workspace:
  root: ~/code/workspaces   # where per-ticket clones are created

hooks:
  after_create: |           # runs once after workspace is cloned
    git clone git@github.com:my-org/my-repo.git .
    npm install
  before_remove: |          # runs before a workspace is deleted
    echo "Cleaning up"

agent:
  max_concurrent_agents: 3
  max_turns: 30
  max_retry_backoff_ms: 300000

notifications:
  slack:
    webhook_url: $SLACK_COMPLETION_WEBHOOK_URL
    user_map:
      jane@example.com: U01234567
      John Linear: U08976543
---

You are an autonomous coding agent working on {{ issue.identifier }}: {{ issue.title }}
...

All configuration fields

Field Default Description
tracker.kind linear Only linear is supported
tracker.project_slug Linear project slug, or "ALL" to watch a whole team
tracker.team_key Linear team key (e.g. "ENG"); required when project_slug is "ALL"
tracker.active_states ["Todo","In Progress"] States that trigger agent dispatch
tracker.terminal_states ["Done","Cancelled",…] States that stop a running agent and clean up its workspace
tracker.endpoint https://api.linear.app/graphql Linear GraphQL endpoint
tracker.api_key $LINEAR_API_KEY Override env-var lookup with a literal key (not recommended)
polling.interval_ms 30000 Poll interval in milliseconds
github_preview.enabled false When true, poll GitHub PR comments for preview deployment comments and keep matching preview URLs warm
github_preview.repo_owner GitHub repo owner to poll with gh api
github_preview.repo_name GitHub repo name to poll with gh api
github_preview.comment_pattern Case-insensitive regex used to detect deployment comments; use the first capture group or a named pr group for the PR number
github_preview.url_template Preview URL template; must include {{pr}} so Symphony can build the keepalive URL
github_preview.comment_poll_limit 100 Number of recent GitHub issue comments to inspect on each orchestrator tick
github_preview.keepalive_interval_ms 180000 Interval between keepalive requests while the PR remains open
github_preview.request_timeout_ms 30000 Timeout for both gh api calls and preview warm-up requests
workspace.root system temp dir Absolute path (supports ~) where per-ticket workspaces are created
hooks.after_create Shell script run once after the workspace directory is created
hooks.before_run Shell script run before each agent attempt
hooks.after_run Shell script run after each agent attempt
hooks.before_remove Shell script run before the workspace is deleted
hooks.timeout_ms 600000 Timeout for any single hook (ms); after_create can be slow on cold caches
agent.max_concurrent_agents 10 Total agents running in parallel
agent.max_turns 20 Maximum Claude turns per attempt before the agent is considered stalled
agent.max_retry_backoff_ms 300000 Maximum retry back-off (ms) for failed agents
agent.max_concurrent_agents_by_state {} Per-state concurrency cap, e.g. { "in progress": 2 }
notifications.slack.webhook_url Slack incoming webhook URL. When set, Symphony posts a delivery update after tracked issues move into a completion state
notifications.slack.user_map {} Map Linear names or emails to Slack user IDs or raw mention strings so involved people are tagged in completion posts
server.port 7777 Port for the status HTTP server (loopback only)
auto_update.enabled true Periodically pull new commits from the Symphony git remote, rebuild, and restart
auto_update.interval_ms 300000 Poll interval (ms) for the self-updater
auto_update.remote origin Git remote to fetch from
auto_update.branch current branch Branch to track on the remote; defaults to whichever branch Symphony is checked out on
auto_update.repo_root Symphony checkout Absolute path to the Symphony git working tree (rarely needs overriding)
auto_update.build_command npm run build Command run after a successful pull
auto_update.install_command npm install Command run when package.json or package-lock.json changes
retrospective.enabled false When true, run a retrospective sub-agent each time a Symphony-tracked ticket reaches a terminal state — appends one structured JSON line to the lessons log
retrospective.trigger_states ["Done"] Terminal states that trigger a retrospective; case-insensitive
retrospective.lessons_path <symphony>/lessons/lessons.jsonl Absolute or relative path to the JSONL file the retrospective appends to
retrospective.commit_lessons true After each retrospective, commit lessons.jsonl and push it to the tracked branch (reuses auto_update.remote/branch/repo_root). Set false to keep the prior manual-commit behaviour
retrospective.max_turns 15 Max Claude turns per retrospective before it's aborted
retrospective.timeout_ms 300000 Hard wall-clock timeout per retrospective run
merge_conflicts.enabled false When true, each orchestrator tick scans open PRs and spawns a sub-agent to resolve the conflicts on any GitHub reports as CONFLICTING
merge_conflicts.repo_owner github_preview.repo_owner GitHub repo owner whose open PRs are scanned; falls back to the github_preview owner
merge_conflicts.repo_name github_preview.repo_name GitHub repo name whose open PRs are scanned; falls back to the github_preview name
merge_conflicts.max_turns 30 Max Claude turns per resolution before it's aborted
merge_conflicts.timeout_ms 1200000 Hard wall-clock timeout per resolution run (20 min)
merge_conflicts.max_concurrent 2 Maximum conflict-resolution sub-agents running at once
merge_conflicts.retry_interval_ms 600000 Minimum delay before re-attempting a PR that is still conflicting after a prior run
merge_conflicts.request_timeout_ms 30000 Timeout for the gh pr list call that finds conflicting PRs
dependabot.enabled false When true, each orchestrator tick scans the repo's open GitHub Dependabot alerts and files a Linear ticket for each new one, then lets the normal poll loop dispatch an agent to fix it
dependabot.repo_owner github_preview.repo_owner GitHub repo owner whose Dependabot alerts are scanned; falls back to the github_preview owner
dependabot.repo_name github_preview.repo_name GitHub repo name whose Dependabot alerts are scanned; falls back to the github_preview name
dependabot.team_key tracker.team_key Linear team key the tickets are created under; falls back to the tracker team key
dependabot.target_state first tracker.active_states entry Workflow state the ticket is created in — must be one of tracker.active_states so the agent picks it up
dependabot.assignee_email Email (or name) of the Linear user to assign each ticket to; empty leaves it unassigned
dependabot.label dependabot Linear label applied to every ticket; also the dedupe key carrier so the same alert isn't filed twice
dependabot.min_severity low Only file tickets for alerts at or above this severity: low, medium, high, critical
dependabot.max_open_tickets 1 Hard cap on how many Dependabot tickets may be open (in a non-terminal state) at once. The default keeps dependency bumps serialized — the next alert isn't filed until the current ticket is Done/Cancelled
dependabot.request_timeout_ms 30000 Timeout for the gh api call that lists Dependabot alerts
firebase_logs.enabled false When true, Symphony scans Firebase function logs for errors every run_interval_ms (via the gcloud CLI) and files a Linear ticket for each new fixable error signature, then lets the normal poll loop dispatch an agent to fix it
firebase_logs.project_id query_insights.project_id, then $GCLOUD_PROJECT / $FIREBASE_PROJECT_ID GCP / Firebase project id whose function logs are scanned
firebase_logs.team_key tracker.team_key Linear team key the tickets are created under
firebase_logs.target_state first tracker.active_states entry Workflow state the ticket is created in — must be one of tracker.active_states so the agent picks it up
firebase_logs.assignee_email Email (or name) of the Linear user to assign each ticket to; empty leaves it unassigned
firebase_logs.label firebase-logs Linear label applied to every ticket; also the dedupe key carrier so the same error isn't filed twice
firebase_logs.min_severity ERROR Minimum Cloud Logging severity to pull: WARNING, ERROR, CRITICAL, ALERT, EMERGENCY
firebase_logs.lookback_hours 24 How many hours of logs each scan spans
firebase_logs.min_occurrences 1 Floor on occurrences — error signatures seen fewer times than this in the window are too quiet to ticket
firebase_logs.max_log_entries 1000 Hard cap on how many log entries gcloud logging read returns per scan
firebase_logs.max_open_tickets 5 Hard cap on how many firebase-logs tickets may be open (in a non-terminal state) at once
firebase_logs.max_tickets_per_run 5 Max tickets filed in a single scan
firebase_logs.run_interval_ms 21600000 How often the log scan runs (~6 hours). Internally gated, so it's a cheap no-op on every other tick
firebase_logs.gcloud_timeout_ms 60000 Timeout for the gcloud logging read call

Prompt template variables

The text below the YAML front matter is a Liquid template. Available variables:

Variable Type Description
issue.id string Linear internal UUID
issue.identifier string Human identifier, e.g. ENG-123
issue.title string Issue title
issue.description string | null Issue description (Markdown)
issue.state string Current workflow state name
issue.priority number | null Priority (0 = none, 1 = urgent, 4 = low)
issue.url string | null Linear issue URL
issue.labels string[] Label names (lowercased)
issue.branchName string | null Suggested git branch name from Linear
attempt number | null Retry attempt number (null on first attempt)
symphony.root string Absolute path to the Symphony orchestrator directory (where WORKFLOW.md lives)
relevant_lessons string Past lessons ranked by keyword overlap with the ticket, injected as a block (empty string when none)
reassignment_instruction string | null The reviewer's rework brief when a ticket has been sent back from review (null otherwise)

Claude Code authentication

Claude Code must be authenticated before Symphony can use it. Two options:

Option A — Browser OAuth (no API key needed)

Run the interactive CLI once and log in:

claude
# Type /login and follow the browser prompt

macOS: Credentials are stored in the system Keychain under Claude Code-credentials. They persist across reboots automatically.

Windows (WSL): Credentials are stored in ~/.claude/.credentials.json inside WSL. Re-authenticate if you get Not logged in errors after a reboot.

Option B — API key

Add ANTHROPIC_API_KEY=sk-ant-... to .env. This takes precedence over OAuth credentials and is better suited for server/CI environments.

Get a key at console.anthropic.com/settings/keys.


Running Symphony

# Start the orchestrator (reads WORKFLOW.md from the current directory)
node dist/index.js

# Or specify a different workflow file
node dist/index.js /path/to/WORKFLOW.md

# Override the status server port
node dist/index.js --port 8080

# Start under the supervisor wrapper so self-updates are picked up automatically
./bin/symphony-supervisor.sh                 # forwards args to dist/index.js
# or equivalently:
npm run start:watch

Auto-update from GitHub

When auto_update.enabled is true (the default), Symphony periodically:

  1. Runs git fetch <remote> <branch> against its own checkout.
  2. If new commits exist and the working tree is clean, fast-forward pulls them.
  3. Re-runs the install command (only when package.json or package-lock.json changed) and then the build command.
  4. Exits with code 75 to ask the supervisor wrapper to relaunch Symphony on the fresh build.

The in-process self-updater always exits on update — actual restart is performed by bin/symphony-supervisor.sh. Run Symphony under the supervisor (or any process manager that re-runs on exit code 75, e.g. systemd with RestartForceExitStatus=75) to get hands-off updates. If launched directly with node dist/index.js, Symphony will still pull and rebuild but exit instead of restarting.

Self-update is skipped — never destructive — when:

  • The working tree has uncommitted changes,
  • The local branch is ahead of the remote, or
  • HEAD is detached and auto_update.branch is not set.

Symphony will:

  1. Validate configuration
  2. Fetch the Linear team URL for display in the TUI
  3. Poll Linear every polling.interval_ms milliseconds
  4. Spawn a Claude Code agent for each eligible ticket (up to max_concurrent_agents)
  5. Retry failed agents with exponential back-off
  6. Clean up workspaces when tickets reach a terminal state

Stop with Ctrl-C. In-flight agents are given 2 seconds to exit cleanly.


Status dashboard

While Symphony is running, open a second terminal:

node dist/status.js
┌ SYMPHONY STATUS
Agents: 2/3
Throughput: 142 tps
Runtime: 4m 12s
Tokens: in 84,231 | out 12,450 | total 96,681
Rate Limits: claude (five_hour) | status allowed | resets in 4h 31m | overage n/a
Project: https://linear.app/my-org/team/ENG/all
Next refresh: 1s

├ Running

  ISSUE                                  STAGE          PID      AGE / TURN  TOKENS     SESSION       EVENT
  ───────────────────────────────────────────────────────────────────────────────────────────────────────────
● ENG-42: Add Stripe webhook handling    In Progress    98123    3m 2s / 8   24,300     ab12...ef56   tool_use: Read src/payments/webhook.ts
● ENG-51: Fix login redirect loop        In Progress    98456    1m 18s / 3  8,100      cd34...gh78   tool_use: Bash git status

Press q or Ctrl-C to exit. Options:

node dist/status.js --port 8080       # connect to a non-default port
node dist/status.js --refresh-ms 500  # faster refresh

Architecture overview

src/
  index.ts          — entry point; CLI args, logger, starts orchestrator + status server
  orchestrator.ts   — poll loop, dispatch, retry queue, state reconciliation
  agent.ts          — spawns `claude` subprocess, streams JSON events, returns AgentResult
  retrospective.ts  — spawns a one-shot retrospective `claude` process per terminal ticket
  lessons.ts        — ranks past lessons by keyword overlap with a ticket for dispatch-time injection
  lessons-sync.ts   — commits + pushes lessons.jsonl after each retrospective (serialized, rebase-on-push)
  merge-conflict.ts — scans open PRs each tick; spawns a `claude` process to resolve conflicts on each conflicting PR
  dependabot.ts     — scans open Dependabot alerts each tick; files a Linear ticket per new alert for the normal poll loop to pick up
  firebase-logs.ts  — scans Firebase function logs for errors (via `gcloud`); files a Linear ticket per new fixable error signature
  meta-improve.ts   — CLI that reads lessons.jsonl and proposes prompt edits on a branch
  linear.ts         — GraphQL client for Linear (issues, states, team URL)
  workspace.ts      — creates/removes per-ticket directories; runs hooks via bash -l
  config.ts         — parses WORKFLOW.md (YAML front matter + Liquid prompt template)
  server.ts         — tiny HTTP server on 127.0.0.1:<port> serving GET /status as JSON
  status.ts         — full-screen ANSI TUI; polls /status and re-renders in place
  types.ts          — shared TypeScript interfaces

The agent prompt

The text below the YAML front matter in WORKFLOW.md is the Liquid template rendered into the prompt for each spawned agent. Symphony passes the ticket description through verbatim as the spec: the agent reads the ticket as written and implements it directly — branch, code, PR — then flips the Linear issue to your review state. There is no refine/plan/test/review pipeline in front of the agent; the ticket text is the spec. This keeps each attempt cheap and removes a reinterpretation layer the request could drift through.

The rendered prompt also carries, conditionally (each renders nothing when empty):

  • a continuation note on retries, so a resumed attempt reuses its existing branch/PR instead of starting over;
  • a reviewer rework brief when a ticket is sent back from review;
  • a Relevant past lessons block (see Per-ticket lesson retrieval below).

Edit the body of WORKFLOW.md to change what every agent is told — it hot-reloads on save. The agent is pointed at docs/AGENT_MEMORY.md for domain vocabulary, conventions, and known pitfalls, and told to keep the diff surgical, use pnpm, and keep pnpm typecheck && pnpm lint green before pushing.

Orchestrator-triggered prompts

A few prompts under prompts/ are run by the orchestrator itself — not by the per-ticket agent — and stay active regardless of the agent prompt:

Prompt When File
Retrospective After a tracked ticket reaches a terminal state — appends one structured lesson line. prompts/RETROSPECTIVE.md
Resolve merge conflicts Orchestrator-triggered for any open PR GitHub reports as conflicting (when merge_conflicts.enabled): merges the base branch into the PR branch, resolves so both sides' intent survives, and pushes to the PR branch. Never merges the PR. prompts/RESOLVE_CONFLICTS.md
Meta-improvement / meta-review npm run meta-improve reads accumulated lessons and proposes narrow prompt edits on branches, each with an independent meta-review comment. prompts/META_IMPROVE.md, prompts/META_REVIEW.md

Project memory — docs/AGENT_MEMORY.md

A persistent, gitable knowledge base the agent reads before investigating the codebase. Records domain vocabulary, roles, architectural decisions, file and naming conventions, common pitfalls, and "things that look like bugs but aren't". The meta-improve pass can append to this file when a retrospective's root cause is "agent didn't know about " — so the next ticket starts with the rule already known.

Rules the meta-improve pass adds carry an invisible marker comment with a stable id and a confidence counter (e.g. <!-- mem:firestore-loader-limit added=2026-05-01 sources=TEA-4181 confidence=2 -->). Each retrospective scores the marked rules relevant to its ticket (reinforced / violated / stale via the memory_feedback field), and the meta-improve pass uses those tallies to promote proven rules, strengthen ones agents keep missing, and retire stale ones — so memory self-corrects instead of only growing. Markers carry no meaning for an agent acting on the rule; they exist only for this loop.

Per-ticket lesson retrieval

Before dispatching an agent, Symphony reads lessons/lessons.jsonl, ranks past lessons by keyword overlap with the ticket (deterministic token matching — no vector store; the corpus is small enough that it isn't worth one), and injects the most relevant instructive misses into the agent's prompt as a Relevant past lessons block (src/lessons.ts), so a mistake a related ticket already paid for is on the table before the agent starts — rather than waiting weeks for the batch meta-improve pass to fold it into a prompt. The agent treats each lesson as a warning to confirm, not a rule to obey blindly. Retrieval is best-effort: a missing or empty lessons file simply omits the block.

Automatic merge-conflict resolution

When merge_conflicts.enabled is true, every orchestrator tick scans the configured repo's open pull requests (gh pr list) and resolves the conflicts on each one GitHub reports as CONFLICTING. It runs on all open PRs with conflicts — not just the ticket currently in flight.

For each conflicting PR the resolver clones the repo into a conflict-pr-<n> workspace (reusing the hooks.after_create clone) and merges the base branch into the PR (head) branch — re-creating the conflict locally without merging the PR itself. It then classifies the conflict and routes it:

  • Lockfile-only conflicts take a deterministic fast-path — no LLM. When every conflicted file is a lockfile Symphony knows how to regenerate (pnpm-lock.yamlpnpm install --lockfile-only, package-lock.jsonnpm install --package-lock-only), it resolves the source manifests (which merged cleanly), regenerates the lockfile, commits, and pushes — saving a full Claude session on the most common, lowest-judgement conflicts.
  • Everything else spawns a one-shot Claude session (prompts/RESOLVE_CONFLICTS.md) that, for each conflicted file, reads all three versions (ancestor / PR side / base side), names the intent of each side, and merges both intents so neither change is lost. Only when two intents genuinely contradict does it pick a winner — the side with the better overall outcome, usually the latest update, judged from commit recency, the PR's stated goal, and whether the resolution keeps tests passing. Every winner-takes-all call is justified in one sentence and noted in a single PR comment.

Either way it commits and pushes to the PR branch only (never force-push, never the base branch) and never merges, approves, closes, or otherwise state-changes the PR — a human still reviews and merges.

It never races the dispatch loop. Before resolving, it drops any conflicting PR whose branch maps to a Linear ticket currently in an active state (the identifier embedded in the branch name is matched against the active ticket set). Those PRs belong to a running or about-to-run agent that resolves its own conflicts; the resolver only touches PRs whose ticket is past active work (e.g. In Review) or has no matching ticket. If the active-ticket lookup fails, it skips dispatch for that cycle rather than risk a duelling push.

Resolutions run in the background (up to merge_conflicts.max_concurrent at once) so a long session never blocks the orchestrator tick; a PR that stays conflicting after a run isn't re-attempted until merge_conflicts.retry_interval_ms has elapsed, and tracking (plus the workspace) is dropped once the PR is no longer conflicting. Requires the gh CLI to be authenticated, same as the GitHub preview warmer.

Automatic Dependabot triage

When dependabot.enabled is true, every orchestrator tick reads the configured repo's open GitHub Dependabot alerts (gh api repos/<owner>/<repo>/dependabot/alerts?state=open) and files a Linear ticket for the most severe new alert. It does not spawn its own fix agent — it hands the work to Symphony's existing pipeline by creating the ticket directly in an active state, so the normal poll loop dispatches an agent that bumps the dependency, runs pnpm install, tests the affected code, fixes any breakage, and opens a PR.

Only dependabot.max_open_tickets Dependabot tickets are ever open at once (default 1). Each tick the watcher counts Dependabot-labelled tickets in a non-terminal state; once that cap is reached it files nothing, so the next alert isn't picked up until the current ticket reaches a terminal state (Done/Cancelled). This serializes dependency bumps instead of opening a PR per alert simultaneously. Eligible alerts are sorted worst-first, so the single open ticket always targets the highest-severity vulnerability.

Each filed ticket:

  1. Is created in team dependabot.team_key, in state dependabot.target_state (which must be one of tracker.active_states, or config validation fails — otherwise the ticket would never be dispatched), assigned to dependabot.assignee_email, and tagged with the dependabot.label.
  2. Carries a deterministic, machine-generated description: affected package + ecosystem, manifest path, severity, vulnerable range, first patched version, GHSA/CVE IDs, advisory summary, references, and a pnpm-/monorepo-aware acceptance-criteria checklist. (When no patched version is published yet, the checklist instead asks the agent to assess mitigation or dismissal.)
  3. Hides a <!-- symphony-dependabot:<owner>/<repo>#<alert-number> --> marker in the description. Before filing anything, the watcher reads back every ticket carrying dependabot.label and skips alerts whose key is already present — so the same alert is never filed twice, even across orchestrator restarts. An in-process set covers the same-run fast path.

Alerts below dependabot.min_severity are ignored. If the ticket read-back fails (Linear hiccup), the watcher files nothing that tick rather than risk duplicates or breaching the open cap, and retries next tick. Once the agent's PR merges, the alert flips to fixed on GitHub and stops being reported. Requires the gh CLI to be authenticated with access to the repo's Dependabot alerts (a token with security_events read, or repo scope), same as the other GitHub-backed features.

Automatic Firebase function-log triage

When firebase_logs.enabled is true, Symphony periodically (default every ~6 hours) scans the project's Firebase function logs for error-severity entries and files a Linear ticket for each new fixable error — then, like the Dependabot and PostHog watchers, hands the work to the normal poll loop by creating the ticket directly in an active state (Dev in Progress), so an agent reproduces the error, fixes the root cause, adds a regression test, and opens a PR.

Logs are read via the gcloud logging read CLI over both Cloud Functions generations — resource.type="cloud_function" (gen1) and resource.type="cloud_run_revision" (gen2) — at severity>=firebase_logs.min_severity over the last firebase_logs.lookback_hours. The raw entries are then:

  1. Grouped into signatures. The first line of each message is kept (the error type + message; the trailing stack varies) and volatile bits — numbers, UUIDs, hex, URLs, quoted strings — are blanked out, so the same bug from many invocations collapses to one signature, tallied by occurrence count and highest severity, per function.
  2. Filtered to fixable errors. Signatures that look like transient/infrastructure noise — DEADLINE_EXCEEDED, UNAVAILABLE, rate-limit/quota (RESOURCE_EXHAUSTED, 429/503/504), socket churn (ECONNRESET/ETIMEDOUT/EPIPE), gRPC ABORTED, and Cloud Functions execution timeouts — are dropped, because an agent editing the app can't fix them. Everything else (a TypeError, an unhandled rejection, a bad assertion, …) is treated as fixable. Signatures below firebase_logs.min_occurrences are ignored as too quiet.

Each filed ticket is created in team firebase_logs.team_key, in state firebase_logs.target_state (which must be one of tracker.active_states, or config validation fails), assigned to firebase_logs.assignee_email, tagged with firebase_logs.label, and carries a deterministic description: function name, severity, occurrence count, first/last-seen timestamps, a sample log/stack, and an acceptance-criteria checklist. A hidden <!-- symphony-firebase-logs:<hash> --> marker (a hash of function + signature) dedupes against every non-terminal ticket carrying the label before anything is filed, so the same error is never double-filed across restarts — while a closed ticket can be re-filed if the function later regresses. The worst signatures (severity, then volume) are filed first, up to firebase_logs.max_tickets_per_run per run and firebase_logs.max_open_tickets open at once. If the scan or the ticket read-back fails, the watcher files nothing that run and retries on the next interval rather than risk duplicates.

Requires the gcloud CLI to be authenticated with Logs Viewer access to the project (gcloud auth login, or a service account). Run symphony-firebase-logs --dry-run to print the grouped fixable errors without filing any tickets.

Continuous self-improvement

Symphony has a two-stage feedback loop that lets the workflow learn from its own misses without unsupervised prompt drift.

Stage 1 — per-ticket retrospective (automatic). When retrospective.enabled is true and a Symphony-tracked Linear issue reaches a retrospective.trigger_states state (default just Done), the orchestrator spawns a one-shot Claude session inside the workspace before cleanup. That session reads the Linear comments (Intent Brief → Workpad → QA results → Delivery → human review comments), the git diff, and the GitHub PR thread, then appends one structured JSON line to lessons/lessons.jsonl. See prompts/RETROSPECTIVE.md for the schema. It also scores any docs/AGENT_MEMORY.md rules relevant to the ticket via the memory_feedback field, closing the trust loop on past memory edits. The retrospective never modifies code, Linear, or GitHub — it just records.

Once the line is appended, the orchestrator commits lessons.jsonl and pushes it to the tracked branch (retrospective.commit_lessons, on by default). This needs no manual step and keeps the Symphony working tree clean — which matters because self-update refuses to pull over a dirty tree, so an uncommitted lessons file would otherwise stall auto-updates. Concurrent retrospectives are serialized and coalesced into one commit, and a push that races a freshly merged meta-improve PR is rebased and retried automatically.

Stage 2 — meta-improvement pass (operator-triggered). Run npm run meta-improve to spawn a Claude session in the Symphony repo with prompts/META_IMPROVE.md. It reads lessons/lessons.jsonl (filtered to a configurable window, default 30 days), clusters lessons by primary_miss and tags, and identifies up to 3 patterns that meet the actionability threshold (≥ 3 occurrences with agreeing root cause and a clear proposed edit). For each pattern it then:

  1. Creates an individual branch meta-improve/<date>-<slug> off main, applies a narrow (≤ 20-line) edit to one WORKFLOW.md or prompts/*.md file, pushes, and opens an individual PR.
  2. Cherry-picks every pattern's commit into a long-lived proposed branch (force-refreshed each run) and opens or updates a combined PR from proposed → main.
  3. Writes META_IMPROVE_REPORT.md on the proposed branch summarising what was done and what wasn't.

The pass also reconciles docs/AGENT_MEMORY.md rule confidence from the accumulated memory_feedback tallies — promoting proven rules, strengthening ones agents keep missing, and retiring stale ones — as one extra memory-maintenance PR (its own branch and meta-review, outside the 3-pattern cap).

Stage 3 — independent meta-review (automatic). For every PR opened (individual + combined), the meta-pass dispatches a Meta-reviewer sub-agent (prompts/META_REVIEW.md) that reads the diff and the motivating lessons with fresh eyes and posts one structured ## 🔍 Meta-review comment with: verdict (approve / request changes / discuss), risk level, what the edit does, whether it actually addresses the stated pattern, concrete concerns, and a recommended next step. It's advisory — it doesn't submit a formal GitHub review and doesn't merge.

The operator's contract: open the PR list, read each PR's meta-review comment, click merge on the ones they agree with, close the ones they don't. To take everything in one go, merge the combined proposed → main PR; the individual PRs close automatically when their commits land in main. The meta-pass never merges, never pushes to main, never edits .ts files, and never adds new prompts.

npm run meta-improve                    # last 30 days, default lessons path
npm run meta-improve -- --window 7d     # last week only
npm run meta-improve -- --dry-run       # write report to /tmp, don't push, don't open PRs

Once an individual or combined PR is merged, Symphony's existing auto_update loop picks up the new prompts on its next poll and restarts. The next batch of retrospectives is the regression test: if the targeted pattern stops appearing in lessons.jsonl, the change worked.

The lessons file is git-tracked by default so improvements travel with the repo. If you'd rather keep ticket-level data out of git, add lessons/lessons.jsonl to .gitignore locally — the meta-pass reads the file path from the workflow config so a local-only file works the same way.


Development

npm run dev        # run with tsx (no build step, hot-ish reload via restart)
npm run build      # compile TypeScript → dist/

The project uses NodeNext module resolution. All imports inside src/ must include the .js extension (TypeScript compiles these to .js in dist/).


Troubleshooting

Symptom Fix
tracker.api_key is required Make sure .env exists with LINEAR_API_KEY=… and you ran node dist/index.js (not tsx src/index.ts without dotenv)
Not logged in · Please run /login Re-authenticate Claude Code: run claude, type /login
Hook times out Increase hooks.timeout_ms in WORKFLOW.md; default is 10 min
Status TUI shows Connection error The orchestrator isn't running, or is on a different port (use --port)
issue_title shows as identifier only The orchestrator was started before a recent update — restart it
Agents stall with no events for 5 min Symphony auto-terminates stalled agents and retries; check logs for the error

Platform notes

macOS Windows
Shell for hooks /bin/bash login shell Requires WSL 2 — hooks will fail on native Windows
nvm nvm.sh nvm-windows (outside WSL) or nvm.sh inside WSL
Claude Code credentials macOS Keychain (persist across reboots) ~/.claude/.credentials.json in WSL (may need re-auth after reboot)
SSH keys ~/.ssh/ + ssh-agent via Keychain Needs explicit ssh-agent setup in WSL — see GitHub docs
gh auth brew install gh && gh auth login Install inside WSL as shown in Prerequisites
File paths Standard POSIX Use WSL paths (/home/user/…), not Windows paths (C:\…)

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages