Shared Skills, One Sync Script, and a Loop That Ships Tickets
Every developer on a team now has an AI coding agent in the terminal. That is not the interesting part. The interesting part is that every one of those agents, left alone, makes up its own house style: where migrations live, which branch to cut from, how to name a commit, whether a query gets a test. Ten developers, ten agents, ten opinions — and the opinions drift every week.
Over the summer we built a small system to fix that. It has three pieces: a catalog of skills that pin down how things are done, a sync script that puts the same catalog on every machine within the hour, and the Ralph Loop, an orchestrator that takes a Jira epic and works its tickets end to end — implement, push, review, acceptance-test, address comments — using those very skills. This post is about how the three fit together and the design decisions that came out of watching them fail.
Part 1 — Skills: The Standard, Written Down for Agents
A skill is a Markdown file, SKILL.md, with a YAML frontmatter. The agent loads it on demand when the task matches its description, or when you type its name as a slash command. That is the whole mechanism. What makes it powerful is that it turns tribal knowledge into something an agent reads at the moment it is about to do the thing.
---
name: java-db-migrations
description: Use when work on a Java service touches the Postgres schema —
new table/column/index/sequence, ALTER, seed data, a new service database.
The DDL never lives in the service repo; it goes to the migrations repo
(Flyway, trunk `development`). Also for cutting tickets that involve
schema work. DynamoDB is java-dynamodb; JPA entity code is java-jpa-persistence.
---
## Where the DDL goes
...
Notice what that description does. It says when to use the skill (a schema change), it names the rule that people got wrong most often (DDL never lives in the service repo, and the migrations repo develops on development, not main), and it routes neighbouring cases to their own skills. The agent reads only descriptions until one fires, so a good description is most of the work.
The catalog
We have 51 skills today, grouped by what they do. The folders are only for humans browsing the repo; the agent discovers skills flat.
build/: one skill per layer of a Spring Boot service (style, JPA, migrations, REST, security, outbox, DynamoDB, files, audit) plus the frontend stack. The folders are for browsing; the installer flattens them.The build/ skills are where the standard actually lives. Each one covers a layer of a service and ships the rule and the test that proves it. java-jpa-persistence says every new entity gets a DTO and mapper, and every new query gets a test against a real Postgres container — and points at java-persistence-testing for how. java-messaging-outbox encodes the transactional outbox and idempotent consumers. java-security-authorization encodes fail-closed data scoping. Before these existed, each of those rules lived in a senior developer's head and surfaced in code review, late.
Two knowledge skills deserve a mention because they fix problems tools cannot:
repo-trunksholds the verified trunk per repository. Bitbucket's default branch and branching model are stale on several of our repos: the migrations repo develops ondevelopmentwhile both settings saymain. An agent that trusts the platform cuts the wrong branch. An agent that reads the skill does not.functional-specssends the agent to a local checkout of our functional-specs repository — the specs, the Architecture Decision Records behind them, and a glossary per domain — before it plans a feature. The instruction is blunt: build to those decisions instead of re-deciding them. If an ADR contradicts the ticket, follow the ADR and say so.
What makes a skill good
We wrote a skill about writing skills. Its opening line is the thesis: a skill exists to wrangle determinism out of a stochastic system. The virtue is predictability — the agent taking the same process every run, not producing the same output. A few principles from it that changed how we write them:
- The description is a list of triggers, not a summary. Every word sits in the context window every turn, so a description is pruned harder than the body. One trigger per branch; synonyms are duplication.
- Model-invoked vs user-invoked is a cost decision. A model-invoked skill keeps its description in context (context load) so the agent can fire it alone. A user-invoked one (
disable-model-invocation: true) costs nothing per turn but you have to remember it exists (cognitive load). When the user-invoked ones pile up, you add a router skill that names the others. - Every step ends on a checkable completion criterion. "Every modified model accounted for", not "produce a change list". A vague criterion invites the agent to declare victory early.
- Progressive disclosure. Push what only some branches need into a sibling file behind a pointer, so
SKILL.mdstays legible. The pointer's wording decides whether the agent follows it. - Hunt no-ops sentence by sentence. If deleting a sentence changes nothing about what the agent does, delete the sentence rather than trim it.
Output styles and the always-on plugin
Alongside skills, the repo ships two Claude Code output styles (Concise and Rundown) and a vendored plugin, caveman, that compresses the agent's prose by roughly 65% of output tokens while keeping technical content intact. The reason to know about output styles is economic: an output style replaces part of the agent's own system prompt, so it costs nothing per turn, whereas a rule in CLAUDE.md is re-sent with every message. The reason caveman is a plugin and not a skill is that the always-on behaviour needs hooks (SessionStart, UserPromptSubmit); a bare SKILL.md would only give you an on-demand command.
Part 2 — The Sync Script: Same Catalog on Every Machine, Within the Hour
A catalog nobody installs is a wiki. The second piece is a single Bash script, bs-skills-sync.sh, that sets up a machine and then keeps it current.
git clone <the skills repo>
cd bs-agents-skills
./scripts/bs-skills-sync.sh install # prereqs + cron + first sync
./scripts/bs-skills-sync.sh status # branch, origin/main, installed SHA, tools, cron
./scripts/bs-skills-sync.sh sync # update now (only if origin/main moved)
./scripts/bs-skills-sync.sh sync --force
./scripts/bs-skills-sync.sh cursor add . # register a Cursor project
install does a lot for one command. It installs or upgrades the prerequisites (git, Node.js for the plugin hooks, cron) with whatever package manager it finds — brew, dnf, apt, pacman, zypper. It adds an hourly cron job. It runs the first sync immediately.
origin/main has actually moved.Install from origin/main, never from the working tree
This is the rule that came out of a failure. The first version of the script ran git pull in your checkout and installed from there. If you were developing a skill on a feature branch, the pull found no new commits and reported "already at latest" — and once your PR merged and the remote branch was deleted, the pull failed outright. Machines silently stopped receiving skills for hours, and nobody noticed because the log said everything was fine.
Now sync extracts origin/main into a private directory under ~/.claude and installs from there. You can be on a feature branch, with a dirty tree, mid-rebase, and you still receive every colleague's merged skill on the hour. And your half-finished skill never gets installed behind your back. The cron job itself runs the pinned copy of the script, not your checkout's, so a branch that predates a sync fix cannot stop you from receiving the fix.
The small decisions that keep it boring
- Pruning is scoped. A skill that a previous sync installed and
origin/mainno longer has is removed. A skill you wrote by hand in~/.claude/skillsis left alone. Same for output styles. - Defaults do not overwrite choices. The default output style is set only when the key is absent in
settings.json. A machine that already chose keeps its choice. - The specs checkout is a separate copy. The agent's copy of the functional-specs repo is fast-forwarded hourly and must never carry in-progress spec work. You author specs in your own clone.
- Cursor gets the same catalog. Cursor has no global rules directory, so the script registers projects and generates
.cursor/rules/*.mdc: eachSKILL.mdbecomes an Agent Requested rule with the same description. Registered projects are regenerated on every sync. - Secrets stay at user scope. The Jira and Bitbucket MCP servers are registered with
--scope user. Project scope would write the gateway key into a checked-in.mcp.json. - There is deliberately no cron job for the loop. An unattended loop would claim tickets, push branches and open PRs under one developer's credentials from a laptop that sleeps mid-run. The launcher is on
PATH; a human starts it.
Part 3 — The Ralph Loop: An Epic In, Reviewed PRs Out
The third piece is the one that made the first two necessary. The Ralph Loop is an autonomous ticket implementer. You give it a Jira epic. For up to five hours it works the epic's ready frontier: every unblocked, unclaimed ticket gets a fresh headless agent session that implements it test-first, then the orchestrator pushes behind the repo's own build gate, opens a pull request, has a second session review it, a third acceptance-test it against the local cluster, a fourth address the comments — and leaves the PR open for a human to merge. When a PR merges, the loop closes the ticket, which unblocks downstream tickets, and keeps going.
It is about 3,500 lines of Python with zero runtime dependencies (it has to run on a stock macOS or Fedora python3 with nothing installed), and 338 tests. What follows is the architecture and the scars.
The outer loop
A ticket is ready when four things are true: status Ready for Dev, label ready-for-agent, unassigned, and every blocker Done. The blockers come from the union of two sources: Jira's real issue-link graph and a # Blocked by heading in the description. An unknown blocker — a key in another project, a search that failed — counts as not done. An unresolvable dependency parks the child rather than freeing it.
Jira issue links are directional and the direction is easy to invert. On an issue, an inwardIssue entry sits at the link's inward end and pairs with the type's inward phrase: for "Blocks" that reads "this is blocked by other". So the blocker is the inward side. We read the outward side first. Every edge in the epic inverted, and the loop ran dependents before their prerequisites. It took two fixes; the docstring on that function is now longer than the function.
The per-ticket pipeline
Every phase is a separate headless claude -p invocation with a fresh context and a purpose-built prompt. The orchestrator never trusts an agent's prose claim of success; the only channel from a worker back to the orchestrator is a file on disk.
The marker protocol
The implement prompt ends with the sentence that holds the whole thing together:
You are NOT finished until one of these two files exists on disk: the orchestrator reads ONLY them — a session that ends without BUILD_OK or BUILD_FAIL is treated as dead, and your work is retried or parked no matter what your final message says.
Three outcomes. BUILD_OK means push. BUILD_FAIL is never retried: the worker's own explanation, in plain English, is quoted onto the ticket with a needs-human label. Neither means the session died mid-stream, and the orchestrator reads the last six lines of the log to decide whether that death was transient or terminal.
The marker files, the ticket dump, the siblings file and the PR body draft are all written to .git/info/exclude so that no worker can commit them. That rule exists because one PR shipped a BUILD_OK file to the migrations repo.
What the implement session sees
The implement prompt is eight numbered steps. The ones that changed the quality of the output:
- The ticket, on disk. The full Jira ticket, with its acceptance criteria, is dumped to
.ralph-ticket.mdin the repo root. No MCP round-trip, no summarisation. - The specs, before design. The functional-specs checkout is mounted with
--add-dir. "If an ADR contradicts the ticket, follow the ADR and say so in your commit message." - The siblings. A file with one line per other ticket in the epic. The instruction: before you add a loop, a scheduled rewrite or an event emission, work out its volume at the fleet ceiling the infra spec states — not at the size of your test fixture — and check whether a sibling ticket is the consumer that would eat it. A worker that only ever saw its own ticket optimised for that ticket's acceptance criteria and could not know which sibling consumes what it writes.
- The concurrency test nobody asked for. "The acceptance criteria describe one actor doing one thing, so they will not ask you for a concurrency test; when the code you are writing has a second writer in production, write that red test and make the write conditional yourself."
- The same commands the pipeline runs. Inspect
bitbucket-pipelines.ymland run what it runs, locally, before claiming green. - Do not push. Do not open a PR. Do not merge or rebase from any other branch. The orchestrator owns the git surface between phases.
The base == target invariant
A ticket names its repository and its trunk in a heading section of its description (my-app — trunk development). That single value is both the branch the work is cut from and the destination of its pull request. They can never diverge, because a PR pointing at a branch the work was not based on shows every commit between the two.
This is what put 62 unrelated commits into one migrations-repo PR. The repo's trunk is development; Bitbucket's default branch and its branching model both say main; the loop cut from one and targeted the other. Ancestry checks cannot catch this — main is an ancestor of development, so every --is-ancestor test passes. What distinguishes foreign work is that it is already published somewhere else. So before opening a PR the loop counts commits in the PR range that already sit on another remote branch. Zero is the only healthy answer; an uncountable range reports −1 and the ticket is parked rather than opened on a guess.
Honesty gates
The pipeline has several places where "done" would be a lie, and each one has a check:
- Proof of work on review. A completed review always posts at least one comment (a clean one posts "LGTM"). Zero comments means the review never happened, and the ticket says "UNREVIEWED" instead of "review done". A count of −1 (cannot tell) is not evidence either way.
- A dead address pass is not "no changes needed". No
BUILD_OKafter the address phase looks exactly like "the review needed no code change". Saying "review + fixes done" there was the lie that let unaddressed comments reach a human as ready-to-merge. - A worker that committed but forgot the marker looks identical to a dead one. If commits exist, the ticket says the work is there instead of implying nothing happened.
- Collaudo can never block a PR. The cluster being down, no free slot, a dead session: the PR still exists, and the ticket gets a note that it was not acceptance-tested and a human should do it. The gate is cheap and orchestrator-side — one
kubectlcall — because probing the cluster inside a worker costs a whole agent session that ends in failure.
Failures are typed, not boolean
When a session dies without a marker, the orchestrator classifies the last lines of the log as TRANSIENT or TERMINAL and carries the decisive line with the verdict. Terminal failures (authentication, permission, billing, most 4xx) cannot succeed on any attempt, so retrying them only spends budget. Transient ones (DNS blips, 429, 5xx, "prompt is too long") requeue the ticket for a later poll with no human action — parking a human on an API wobble is wrong. After three transient deaths the ticket is parked anyway, and the comment names the machine-level cause.
The failed log of each attempt is kept under its own name. Diagnosing one ticket needed the first attempt's error line, and every retry had erased it.
Merges, siblings and the spec that moved
The loop never merges. When a human does, three things happen on the next poll. The ticket transitions to Done, which unblocks its dependents. Every other open PR the loop owns on the same repo is rebased onto its own trunk and force-pushed with lease; on conflict, a dedicated session runs the conflict-resolution skill (it is told never to abort), and if the rebase does not finish clean the loop aborts it and asks a human. And the frontier fetch picks up the newly unblocked tickets.
There is one more pass I did not expect to need. A PR is a snapshot of what the ticket and the specs said on the day it was implemented, and both keep moving. So every poll compares the ticket's updated field and the specs checkout's last commit against the last commit on the branch. When either is newer, one resync session re-reads today's acceptance criteria, points at the code that satisfies each one, and closes only the gap — or comments on the PR that there is none. Two subtleties: the comparison uses the author date, because sibling rebases rewrite the committer date and would hide exactly the edits the check exists to catch; and the loop advances its own watermark past every comment and transition it posts itself, because in the tracker those look identical to a human edit and would burn the resync cap.
No credentials in workers
Workers reach Jira and Bitbucket only through two central MCP endpoints behind a gateway, passed per session with --mcp-config --strict-mcp-config, and every Atlassian credential is stripped from their environment. A worker cannot fall back to a raw API call because it has nothing to authenticate with. Git, on the other hand, stays personal: the loop clones and pushes with the developer's own SSH key, so commits are attributed to a person, and no token ever appears in a remote URL.
Because the MCP writes are made by a shared service account, the human who triggered the run has to appear in the text. A prepare-commit-msg hook, installed in each workspace's .git and never committed, stamps Triggered-by: and Agent: trailers on every commit whether or not the agent remembered to write them. A hook rather than a prompt instruction, because agents forget and hooks do not.
The knobs
| Knob | Default | What it bounds |
|---|---|---|
MAX_RUNTIME | 18000 s | Total loop wall clock (~5 h) |
POLL_SECONDS | 300 s | Frontier poll interval |
MAX_CONCURRENT | 2 | Tickets in flight |
SESSION_TIMEOUT | 5400 s | One agent session; the watchdog kills a pipeline at 3× |
FIX_CAP | 2 | Remote-pipeline auto-fix attempts |
RALPH_TRANSIENT_RETRIES | 1 | Extra attempt per phase on a transient death |
RALPH_TRANSIENT_PARK_CAP | 3 | Transient deaths before parking anyway |
RALPH_RESYNC_CAP | 2 | Catch-up passes per ticket before asking a human |
RALPH_COLLAUDO_REPOS | 2 repos | Which repos the local cluster can acceptance-test |
There is no token budget. The budget is time and attempts. Everything is environment variables read once into a frozen dataclass; the only file the loop writes as configuration is the per-run MCP config for workers, mode 0600 because it carries the gateway key.
Testing an orchestrator of agents
The agent sessions are the one thing you cannot unit-test, so the seam is the runner: tests script a fake runner per phase ("the review session dies transiently, the retry succeeds") and assert on Jira transitions, comments, labels, pushes and state files. The git layer is tested against real temporary repositories — trunk inference, foreign-commit counting, the attribution hook — because those bugs were all in the interaction with git itself.
My favourite test file exists because the fakes drifted. A fake grew a method the real class never got; every test passed while the real class raised AttributeError on the first launched pipeline. Now one test asserts the fakes' public methods are a subset of the real classes', and another scans the orchestrator's source for every self.ops.X(, self.jira.X(, self.bb.X( and resolves it against the real class. Drift is a test failure instead of a production crash.
And the prompts are tested too. Not their output — their contracts: the implement prompt demands a PR description; the review prompt uses MCP only; the resync prompt closes only the gap; the rebase prompt forbids abort. If someone edits a prompt and drops the sentence that holds the protocol together, CI says so.
A small joy
Every worker session gets an appended system prompt that compresses its narration into classical Chinese — a real log ends with 畢。TICKET-144 成,建綠 ("done; built green") — with one exception written in capitals: failure reports are for a human, so anything in a BUILD_FAIL file, any statement of being blocked, and the last message of a session that did not reach green are in plain English. The 5% of output a human reads is exactly the failures. Nobody reads the other 95%, so it might as well be cheap.
How the Three Fit
The skills are the standard. The sync makes the standard the default on every machine without anyone doing anything. The loop is what forced the standard to be precise: an interactive session forgives a vague skill because a human is there to nudge; a headless one does exactly what the skill says, and if the skill says nothing about which branch to cut from, you get 62 foreign commits in a PR. Every scar in Part 3 became a sentence in a skill in Part 1, and Part 2 delivered that sentence to every developer within the hour.
Key Takeaways
- Write the standard for the agent, not the wiki. A skill fires at the moment the agent is about to do the thing. Its description is a list of triggers; its steps end on checkable criteria.
- Install from
origin/main, never from a working tree. Your feature branch is not the team's standard, and a deleted remote branch should not stop updates for hours. - The only channel from an agent to an orchestrator is a file it must write. Never trust the final message. No marker means dead, not done.
- Fresh session per phase. Implement, review, acceptance-test and address are different jobs with different prompts. Sharing context between them shares mistakes.
- Type your failures. Transient requeues; terminal parks; the decisive line travels with the verdict; every attempt's log is kept.
- Base and target are one value. Cut from the branch you will merge into, and count foreign commits before opening a PR. Ancestry checks are not enough.
- Build honesty gates where "done" would be a lie. Zero review comments is not a review. A dead address pass is not "nothing to change".
- Hooks over instructions for anything that must always happen. Attribution trailers land because a git hook writes them, not because the agent remembered.
- Test the fakes against the real classes, and test the prompts' contracts. Drift in either is a production crash you can turn into a CI failure.
- Do not put the loop on a cron. The thing that claims tickets and opens PRs under your name deserves a human pressing enter.