Skip to content

Latest commit

 

History

History
449 lines (352 loc) · 22 KB

README.md

File metadata and controls

449 lines (352 loc) · 22 KB

Symphony

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.

What is in it

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.

Setting it up

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@symphony

Or 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 --help

There 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.

Linked

./install.sh

Links 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.

As a plugin

./install.sh --plugin

Registers 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.

What the links do

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.

What the hooks do

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 on

It 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.sh

Once 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>

Two more steps

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.sh

Using it

Three 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.

Releasing a gem

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.

Wiring a gem to it

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.

Releasing

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.

When it fails

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.

Configuring it

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.

Making it yours

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.md

Edit 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.

Choosing each agent's model

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.

Running the tests

./run_tests.sh

Every 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.

Licence

MIT. See LICENSE.