← All posts

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.

skills/ — 51 SKILL.md files plan · 7 grilling grill-with-docs to-spec to-tickets wayfinder triage design · 4 codebase-design domain-modeling prototype improve-arch build · 25 tdd spring-boot-style jpa-persistence db-migrations messaging-outbox dynamodb react-components design-tokens maestro-e2e-test … +16 quality · 7 code-review diagnosing-bugs resolving-conflicts actor-attribution collaudo-locale terraform-hygiene knowledge · 5 functional-specs repo-trunks research teach writing-great-skills meta · 3 ask-matt (router) handoff setup
The catalog. Half of it is 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:

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:

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.

cron, hourly runs the pinned copy git fetch did origin/main move? functional-specs pull --ff-only, every time no → log "already at latest", done yes extract origin/main into ~/.claude/.bs-agents-skills-src — never the working tree skills flat, pruned plugin hooks settings.json output styles default only if unset MCP servers Jira + Bitbucket, user scope agent tools bs-implement on PATH Cursor rules .cursor/rules/*.mdc Everything below the dashed line is installed from origin/main. Your checkout is fast-forwarded only as a courtesy, and only if it is on main and clean.
One sync. The fetch is cheap and runs every hour; the install only runs when 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


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

preflight git auth, MCP, tools every 300 s, for up to 5 h detect merges close ticket, rebase siblings watchdog kill stuck pipelines fetch frontier epic children + blockers resync stale PRs ticket or specs moved? launch plan one process per ready ticket, max 2 in flight idle reasons why nothing is grabbable ready = status Ready for Dev ∧ label ready-for-agent ∧ unassigned ∧ every blocker Done Each poll step is network-facing; a blip costs one poll, never the loop, and never a needs-human label.
The orchestrator polls Jira every five minutes. Nothing here trusts on-disk state over Bitbucket's: a PR merged by a human while no loop was running is discovered and its ticket closed on the next poll.

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.

Scar

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.

orchestrator (Python) worker (fresh Claude session per phase) claim ticket assign, → In Progress, comment prepare workspace clone, feature/KEY from origin/trunk 1 · implement — /tdd reads ticket, specs, siblings · writes BUILD_OK or BUILD_FAIL push & verify no BUILD_OK → no push remote pipeline polled ≤ 20 min 1b · pipeline-fix (≤ 2 attempts) reads pipeline logs, must produce BUILD_OK again base guard, open PR foreign commits = 0 or park → Waiting, PR URL on ticket 2 · review — /code-review one PR comment per finding, or "LGTM" 2b · collaudo — collaudo-locale API-level acceptance run on local minikube, own slot findings become "collaudo:" PR comments 3 · address — one round fix + pin with a test, or reply why not PR open for a human merge merge → ticket Done → siblings rebased proof of work: a review that posted zero comments never happened cluster down or no slot → PR still exists, ticket says "not collaudato" no BUILD_OK after address ≠ "nothing to change": reported as unaddressed
One ticket, four agent sessions. Orange boxes are agents; white boxes are deterministic Python. Every hand-off between the two is a file the worker must write.

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

Scar

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:

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

KnobDefaultWhat it bounds
MAX_RUNTIME18000 sTotal loop wall clock (~5 h)
POLL_SECONDS300 sFrontier poll interval
MAX_CONCURRENT2Tickets in flight
SESSION_TIMEOUT5400 sOne agent session; the watchdog kills a pipeline at 3×
FIX_CAP2Remote-pipeline auto-fix attempts
RALPH_TRANSIENT_RETRIES1Extra attempt per phase on a transient death
RALPH_TRANSIENT_PARK_CAP3Transient deaths before parking anyway
RALPH_RESYNC_CAP2Catch-up passes per ticket before asking a human
RALPH_COLLAUDO_REPOS2 reposWhich 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

Skills the standard, as instructions 51 SKILL.md + specs checkout Sync same standard on every machine hourly, from origin/main only Ralph Loop applies the standard unattended /tdd, /code-review, collaudo, specs every scar in the loop becomes a rule in a skill, and the skill reaches everyone within the hour A merged change to the skills repo alters every agent session company-wide. That is the point, and the blast radius.
The feedback loop. The Ralph Loop is the harshest consumer of the skills, because nobody is watching it work.

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

  1. 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.
  2. 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.
  3. 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.
  4. Fresh session per phase. Implement, review, acceptance-test and address are different jobs with different prompts. Sharing context between them shares mistakes.
  5. Type your failures. Transient requeues; terminal parks; the decisive line travels with the verdict; every attempt's log is kept.
  6. 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.
  7. 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".
  8. Hooks over instructions for anything that must always happen. Attribution trailers land because a git hook writes them, not because the agent remembered.
  9. 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.
  10. Do not put the loop on a cron. The thing that claims tickets and opens PRs under your name deserves a human pressing enter.