Context-free agents that write the documents around code — issues, pull request bodies, feature plans, code reviews and repo audits — each to a fixed set of rules, and each stamped with the versions that produced it.
Every agent here is spawned in isolation. It cannot narrate work it remembers doing, because it did not do any: it has to read the diff, the repo, or the issue. That is the whole idea, and it is why what comes out describes what is actually there.
A scribe also starts without your CLAUDE.md and the rules files every session
loads, and reads only the rules its own instructions name. This needs Claude
Code 2.1.271 or later. An older version starts every scribe with every rule, as
it did before.
| Agent | Writes |
|---|---|
issue-scribe |
One standalone issue from a spec |
pr-scribe |
A pull request body, from the real diff |
plan-scribe |
A feature plan in four stages, from reading the repo |
review-scribe |
A pull request review, drafted before it is posted |
audit-scribe |
An audit of a section of a repo, with no pull request open |
They share one list of checks in rules/review-checks.md, one writing style in
rules/writing-style.md, and one stamp generator that names the versions behind
every document.
Before a draft goes out, a second reader reads it with nothing else in front of
it. The reader says what each sentence means and flags any it had to read twice.
The scribe then rewrites those sentences, as rules/draft-reading.md sets out.
Every scribe does this before its draft is posted, filed, published or returned.
A plan's page is built by bin/render-plan.sh from the plan issue. Every plan
gets the same layout, from templates/plan-page.css, so no page is designed by
hand.
Three commands drive them: /feature-plan, /review and /audit.
Needs git, zsh, jq, and the GitHub CLI gh already authenticated.
The quickest way, with no clone:
claude plugin marketplace add DYB-Development/symphony
claude plugin install symphony@symphonyOr clone it, which lets you run either install and edit the rules in place:
git clone https://github.com/DYB-Development/symphony.git
cd symphony
./install.sh --helpThere are two ways to install from a clone, and you want one, not both. Installing both registers every hook twice, so whichever you run second refuses.
./install.shLinks rules, agents and bin into ~/.claude, links each command into
~/.claude/commands one file at a time, and merges the hooks into
~/.claude/settings.json. It also links the up command into ~/.local/bin,
which needs to be on your shell's path. Anything already at one of those paths is moved aside
to <path>.backup first, and running it again changes nothing. Set
CLAUDE_CONFIG_DIR to install somewhere other than ~/.claude.
It also sets the status line to bin/status-line.sh when no status line is set,
and leaves one set to another command alone. The status line it sets reruns
every 2 seconds, so a scribe's progress keeps moving while the session waits on
it, and running the install again adds that to a status line it set before.
On a branch whose name starts with a task issue's number, the status line shows
the progress of the plan that issue is listed under: the plan's title, the
task's stage, one square per stage of the plan in stage order, and the closed
units out of all units. A stage's square is
green when every unit of that stage is closed, yellow when some are, and grey
when none are. A unit's stage is read from the Part of line of its issue.
Under it, a second bar shows how many of the task's acceptance criteria are
ticked out of all of them. A criterion is ticked once the commit that covers it
lands, with ~/.claude/bin/tick-criterion.sh <owner/repo> <issue> <position>. It
asks GitHub at most once a minute for each repo and branch, and gives up on a
read after a second and a half. When GitHub cannot be read, it keeps showing the
last count, marked out of date, or says progress is unavailable when it never
had one.
Above the plan's progress it shows a flag for each item this session has waited
on the owner for since the owner's last prompt, one to a line, such as a question or a pull
request to review. An agent marks a pull request or plan with
~/.claude/bin/owner-turn.sh "<kind>" "<what is wanted>" ["<link>"].
When a turn ends while the owner is waited on, the same flags, worded the same
way, are the last lines of the turn's output in the terminal. A turn that ends
with nothing waiting on the owner prints no flag.
While the session works, the top line says what it is doing, taken from the
description of its most recent tool call, and it goes away when the turn ends.
Under it, each running scribe of the session shows its type and target above a
bar of how far through its numbered steps it is, filled partway through a long
step by the position the scribe marks inside it, written as 3/9 or as
criterion 3 of 9.
Commands are run as /review. A file edited in this clone takes effect in the
next session, with no reinstall, which is what makes this the mode to use while
changing the rules themselves.
./install.sh --pluginRegisters this clone as a marketplace and installs it. Commands are namespaced,
so a review is /symphony:review, and updates come through
claude plugin update rather than git pull.
Rules under ~/.claude/rules are read in every session on the machine, so the
writing style and the development process apply everywhere, not only here.
The commands are linked file by file rather than as a directory, so your own commands can live alongside these.
The plugin carries its hooks itself. A linked install merges the same entries into your settings, replacing any it put there before, and leaving hooks and settings that are not its own alone:
| Event | Runs | Why |
|---|---|---|
SessionStart |
writing-style-hook.sh |
Carries the writing rules and the banned phrase list into the session |
SessionStart |
expert-handoff.sh notice |
Says which gems with patterns in your experts files the repo uses without their expert installed, so it gets installed with the_local |
SubagentStart |
writing-style-hook.sh |
A subagent receives no rules of its own, so it gets them here |
PostToolUse |
decision-gate.sh arm |
Answering a question settles a choice, which has to be recorded |
PostToolUse |
answer-capture.sh record |
Records each answered question in that repo's review list, where the owner approves it before it settles later questions |
PostToolUse |
agent-progress.sh record |
Logs each step a subagent marked, from what the mark printed, so a target or step passed in a shell variable is logged as its value |
PostToolUse |
review-block.sh clear |
Clears the session's review mark once a shell command posts the review with review-draft.sh --post or drops it with review-block.sh drop |
PostToolUseFailure |
answer-capture.sh record |
Records a question window the owner rejected as rejected in that repo's review list |
PostToolUseFailure |
agent-progress.sh record |
Logs each step a subagent marked in a command that then failed, from that command's output |
PreToolUse |
main-clone-gate.sh check |
Refuses an edit or a branch-changing git command aimed at a repo's main clone, which is kept for its owner |
PreToolUse |
expert-handoff.sh check |
Refuses an edit to a file an installed expert owns, by your experts files, unless that gem's own develop or install agent makes it, and names the agent to hand the work to |
PreToolUse |
decision-gate.sh check |
Refuses a commit while that choice is still unrecorded |
PreToolUse |
review-block.sh check |
Refuses, while the session is reviewing a pull request, an edit outside its temporary folders and the review draft, a git commit, push, merge, rebase or reset, and a run of the repo's tests |
PreToolUse |
agent-progress.sh record |
Logs each script a subagent is about to run |
PreToolUse |
one-question-gate.sh check |
Refuses a question prompt that asks more than one question |
PreToolUse |
question-check.sh check |
Refuses a question the draft reader could not follow on one read, and lets it through with a warning when the reader fails |
PreToolUse |
answer-gate.sh check |
Refuses a question an approved repo answer or a default already settles, quoting that answer, and lets it through with a warning when the search fails |
PreToolUse |
description-gate.sh check |
Refuses a shell command that carries no description of what it does |
PreToolUse |
agent-model.sh set |
Gives a spawned agent the model your agent models file names for it |
PreToolUse |
review-block.sh start |
Marks the session as reviewing a pull request when it spawns the review scribe, and a mark more than a day old is ignored |
PreToolUse |
owner-turn.sh record |
Records a question prompt, and each item an agent marks with owner-turn.sh, as something the owner is waited on for |
UserPromptSubmit |
owner-turn.sh record |
Records when the owner last prompted, which ends that session's flag in the status line |
Stop |
owner-turn.sh stop |
Shows the session's flags as the last lines of the turn when the owner is waited on |
Stop |
stop-check.sh check |
Refuses a turn that ends by asking the owner something in plain text with no question window, once per turn, and lets the turn stop with a warning when the transcript cannot be read |
PreToolUse |
working-line.sh record |
Keeps the description of the session's most recent tool call as its working line |
Stop |
working-line.sh record |
Clears the working line when the session ends its turn |
PreToolUse |
session-worktrees.sh record |
Records which worktree each session and subagent transcript works in, so usage.sh finds work done from a session started in another repo |
SubagentStop |
agent-progress.sh record |
Logs that a subagent finished |
Stop |
turn-sound.sh play |
Plays a sound when a session finishes its turn |
Stop |
stray-test-workers.sh |
Stops any Rails test worker whose test run has gone, since nothing else will and it can run for days |
Notification |
turn-sound.sh play |
Plays the same sound when a session asks permission to run a tool |
Skip the hooks and the package still loads, but the writing rules never reach a subagent, the decision gate never fires, agents can work in the main clone, and no progress is logged, all without saying so.
To stop the sound in every open session, and to bring it back:
~/.claude/bin/turn-sound.sh off
~/.claude/bin/turn-sound.sh onIt plays with afplay, so it is silent on a machine without it.
To see which step each running scribe is on:
~/.claude/bin/agent-progress.shOnce a pull request is open, a session watches it in the background with no model running. The watch opens it in the browser when every CI check passes, exits naming a failed check, and otherwise exits when it is merged or closed:
~/.claude/bin/pr-wait.sh <owner/repo> <pr-number>The rules keep three working-tree files that must never reach a commit — the decision log, the ticket reference and the resume bookmark. Add them to your global gitignore:
.decisions.md
.ticket
start_here.md
Then, once per repo you use the issue schema in, create its labels:
~/.claude/bin/issue-bootstrap.shThree commands start the work. Each one spawns exactly one scribe, and the scribe reads the repo rather than the conversation.
| Run | What happens |
|---|---|
/feature-plan |
A request too big for one issue becomes a plan in four stages, filed as an issue with its units already written as issue bodies |
/review <n> |
A pull request is reviewed against every check, drafted for you to read, and posted only when you say so |
/audit |
One named section of a repo is measured and scored, and nothing is posted or filed |
A review starts with a pattern scan, which matches every line the pull request
adds against the pattern packs in rules/patterns/. The core pack applies to
every repo. A pack for a framework or a library applies when the reviewed repo's
Gemfile.lock or package.json at the pull request's head lists it. Each hit
becomes an inline comment in the draft. When a pattern is wrong about one line,
a comment on that line reading symphony-allow: <pattern name> excuses it from that one pattern.
A marker naming no pattern the review knows is reported as a hit of its own.
Tasks in dyb_web's Pipelines hub are run by conductor (https://github.com/DYB-Development/conductor), not by symphony. Conductor runs the commands and agent sessions dyb_web gives each step, so symphony holds only your own workflow.
The other two scribes are spawned by name when you need them: issue-scribe
writes one standalone issue from a spec, and pr-scribe writes a pull request
body from the real diff. Ask for either and one is spawned.
Three files appear in a repo as you work, and none of them should be committed:
| File | Holds |
|---|---|
.decisions.md |
A choice settled while working, which the pull request scribe renders into the body |
.ticket |
The ticket the branch's hours are billed to |
start_here.md |
Where to pick up, written only when stopping mid-issue |
To get a Rails app running in the browser, type up in its folder. In the
app's main clone it first pulls the latest commits of the checked-out branch,
and it stops there if the pull fails. A linked worktree is not pulled. It then
runs the app's bin/setup --skip-server, starts bin/dev on the first free
port counting up from 3000, and opens the browser at that address once the
server answers. A folder without an executable bin/setup and bin/dev is
refused and nothing in it is changed.
To open the app on a phone instead, type up phone. It does the same, but
starts the app's bin/phone on the first free port and opens no browser on the
Mac, since bin/phone prints the address for the phone. An app without an
executable bin/phone is refused and nothing in it is changed.
Two things worth knowing. Nothing posts without being read first — a review is drafted and rendered for a person, and an audit posts nothing at all. Nothing merges — a review approves or requests changes only once its draft has been read and posted.
One workflow in this repo releases every gem, so no gem holds a copy of the release steps and no release needs an API key typed in.
.github/workflows/gem-release.yml is called by a gem rather than run on its
own. Registering it against a gem on rubygems.org lets GitHub hand RubyGems a
short-lived token for that one run, which is what replaces the key.
Run the setup script against the gem's working copy:
~/.claude/bin/gem-release-setup.sh ~/projects/gems/tally
It writes .github/workflows/release.yml into the gem and prints the trusted
publisher to register, field by field. Register it at the address the script
prints, commit the workflow, and the gem is wired.
The fields matter in one non-obvious way: the workflow filename RubyGems wants
is gem-release.yml, the workflow in this repo, not release.yml, the one in
the gem. The token names the workflow that actually ran, and that is this one.
Raise the version in the gem's version.rb, open a pull request, and merge it.
The workflow reads the gemspec on every push to main, compares the version
with rubygems.org, and releases when it is higher. A push that does not change
the version is a run that says there is nothing to release and stops.
The release builds the gem, pushes it to rubygems.org, and then tags the commit it was built from. Pushing first is deliberate: a rejected gem leaves no tag behind, so the next run is a clean retry. Nothing is done by hand and nothing is typed in.
The same run can be started from the gem's Actions tab, under Release, with Run workflow. It reads the gemspec and compares it with rubygems.org exactly as a merge does, so a version already published is still a run that stops. Reach for it when a release failed and the fix was somewhere other than the gem, or when a version was merged before the gem was wired to the workflow.
Before publishing, every check runs and one run names every problem it found rather than the first. It refuses a version whose tag already exists, a gemspec Ruby cannot read, and a push host trusted publishing cannot authenticate against.
A failed release opens an issue in the gem's own repository, labelled
release-failure, carrying what the release printed before it stopped. A second failure of the same
version comments on that issue rather than opening another.
Everything works unset. These change what the package reads, and they belong in your own shell or settings rather than in a file here, so an update never overwrites them.
| Variable | Changes |
|---|---|
SYMPHONY_OVERLAY_DIR |
Where your own rules files are read from, instead of ~/.config/symphony/rules |
SYMPHONY_EXPERTS_DIR |
Where the path patterns each gem's expert owns are read from, instead of ~/.config/symphony/experts |
SYMPHONY_AGENT_MODELS_FILE |
Where the model each agent runs on is read from, instead of ~/.config/symphony/agent-models.json |
CLAUDE_CONFIG_DIR |
Where the installer links to, instead of ~/.claude |
CLAUDE_WRITING_STYLE_FILE |
One writing rules file, ahead of the overlay and the shipped one |
CLAUDE_BANNED_PHRASES_FILE |
One phrase list, on the same terms |
AUDIT_RUBRIC_FILE |
The cost bands and horizons an audit is scored against |
ISSUE_BOOTSTRAP_OWNERS |
The accounts issue-bootstrap.sh --all-repos syncs labels across |
SYMPHONY_IDENTIFIERS |
A file of terms no shipped file may name, checked by the suite |
READ_DRAFT_SMOKE |
Set to 1 and the suite runs one real read through claude |
RENDER_PLAN_SMOKE |
Set to a plan issue as owner/repo#N and the suite renders it through GitHub |
ISSUE_BOOTSTRAP_OWNERS is empty by default and --all-repos does nothing until
you set it, because that flag writes to every non-archived repo of every account
named.
SYMPHONY_IDENTIFIERS is unset by default, so the suite reports nothing to check
and passes. A list of names cannot be kept here without carrying the names the
check exists to keep out.
Every rules file this package ships can be replaced without editing this package,
so claude plugin update and git pull never overwrite your version.
Put a file of the same name in ~/.config/symphony/rules/ and it wins:
mkdir -p ~/.config/symphony/rules
cp rules/pr-body.md ~/.config/symphony/rules/pr-body.mdEdit that copy. Every session and every scribe is told at startup that the overlay exists and that a file in it replaces the shipped one, so the pull request scribe now follows your template and everything else follows the rules here.
| To change | Replace |
|---|---|
| How a pull request body reads | pr-body.md |
| What an issue must contain, and its labels | issue-schema.md |
| What a review looks for | review-checks.md |
| How a review is written and posted | pr-review.md |
| What an audit measures and reports | repo-audit.md |
| What an audit's findings cost | audit-rubric.json |
| The shape of a feature plan | feature-plan.md |
| How everything is written | writing-style.md |
| Who a draft is read for before it goes out | draft-reading.md |
| The phrases nothing may use | banned-phrases.txt |
| The development process followed | develop_process_rules.md |
| When a decision is recorded | decision-log.md |
| Where saved answers live and how they are searched | saved-answers.md |
Replace a whole file, not part of one — the overlay swaps files, it does not merge them. Take a copy of the shipped one and edit it, so nothing a scribe expects to find goes missing.
Set SYMPHONY_OVERLAY_DIR to keep the overlay somewhere else, such as a
directory your team shares.
Put a file at ~/.config/symphony/agent-models.json, next to the rules
overlay, that maps an agent's name to haiku, sonnet or opus:
{
"pr-scribe": "sonnet",
"issue-scribe": "haiku",
"keystone_ui-develop": "sonnet",
"read-draft": "opus",
"judge-claims": "opus"
}An agent spawned by that name runs on the model given, whether it is one of
the scribes here or an agent another package installed. read-draft is the
draft reader and judge-claims is the claim judge, and each runs on Opus when
the file does not name it.
A spawn that names its own model keeps it. An agent the file does not name, or names with any other value, runs on the model it would run on without the file. A missing file, or one that is not a mapping of names to models, changes nothing and never stops an agent being spawned.
The file is read on every spawn and every draft read, so a change takes effect on the next one with no reinstall.
./run_tests.shEvery file named *_test.zsh anywhere under the repo root is a suite and is
picked up with no registration.
Suites run at the same time, one per CPU core, and PARALLEL_WORKERS=<n> sets
another number. A run prints a dot for each suite that passes and an F for each
that fails, then the full output of each failed suite and the command that
reruns it.
The suites run in a shuffled order, and the run prints the seed it used.
./run_tests.sh --seed <n> repeats that order, and ./run_tests.sh <suite>...
runs only the suites named.
Wording that an agent, rule or command file must contain, or must not, is not
tested sentence by sentence. It is a rule in checks/rules.json, a versioned
list that bin/check-content.sh checks in one suite run. Adding a requirement
is one reviewed line in that list.
No suite calls the network by default. READ_DRAFT_SMOKE=1 ./run_tests.sh also
sends one sentence to a real reader, which needs claude logged in and is billed
like any other run. RENDER_PLAN_SMOKE=owner/repo#N ./run_tests.sh renders that
plan issue's page through GitHub's markdown service.
MIT. See LICENSE.