Shipyard is a suite of six Claude Code plugins for project tooling. Each one owns a slice of a repo's setup, reads a small committed config file, and renders its artifact from that.
| Plugin | Owns | Config | Renders |
|---|---|---|---|
keel |
the git lifecycle — how work starts, lands, and ships | .keel.json |
lifecycle artifacts + a PreToolUse guard |
rigging |
CI pipelines | .rigging.json |
.github/workflows/ci.yml |
stow |
baseline repo files | .stow.json |
.gitignore (managed blocks) |
ballast |
the pytest runner | .ballast.json |
pytest.ini |
hull |
secret scanning | .hull.json |
.github/workflows/security.yml |
bosun |
dependency updates | .bosun.json |
.github/dependabot.yml |
All six version in lockstep — they ship from one marketplace, are developed together, and the whole suite is what gets installed. A single version number is what keeps six near-identical architectures honest about being one thing.
Every plugin is dogfooded here: the files in the "Renders" column above are this repo's own, generated by the plugin that owns them, with byte-identity tests asserting the committed copy equals a fresh render.
This README documents keel. The other five are driven entirely by their
init skill — see each plugin's skills/init/SKILL.md.
Most teams write their git conventions into a CONTRIBUTING.md that nobody
re-reads, and then discover violations in code review — or after a bad push.
The obvious fix is a GitHub template repo carrying the conventions and some
hooks. keel started as exactly that, and was rewritten as a plugin for two
reasons:
- Template repos never update. A repo created from a template in January cannot receive a February bugfix. Every derived repo forks the logic permanently.
- A guard that ships inside the repo it guards can disable itself. The
predecessor's hook matched only
Bash, so anEditto its own source turned enforcement off.
A plugin fixes both: the logic lives outside your repo and updates centrally.
What stays in your repo is a small, committed .keel.json describing the
topology you want.
Committing on a protected branch:
$ git commit -m "quick fix"
[keel/protected-write] 'main' is protected. Start a branch with
keel:start-work; changes reach it via PR. Run keel:doctor to see the full
picture.
Multiple problems in one command surface together, rather than one at a time:
$ git commit -m x && git push origin main
[keel/protected-write] 'main' is protected. Start a branch with
keel:start-work; changes reach it via PR. Also: [protected-write] This pushes
directly to protected branch 'main'. Open a PR instead (keel:finish-work). Run
keel:doctor to see the full picture.
And when keel can't determine something, it says so instead of guessing:
[keel/changelog] Could not compare against the base branch, so the CHANGELOG
check was skipped. Run 'git fetch' and retry if you want it enforced.
At session start it tells Claude what this repo's lifecycle actually is:
This repository uses keel for its git lifecycle.
- Topology: gitflow (feature/* -> PR -> develop -> release/* -> PR -> main)
- Protected: develop, main (changes reach them via PR)
- Review policy: review
- Current branch: feature/parser-fix
Read this before anything else, because it is keel's central honesty commitment.
keel's PreToolUse hook is advisory. It watches git/gh commands as
Claude Code runs them and warns or blocks before they execute — catching an
honest mistake (committing on main, opening a PR against the wrong branch,
merging with the wrong strategy) about 30 seconds earlier than CI would.
That is genuinely useful, and it is all it is.
The real boundary is GitHub branch protection and required checks,
configured through the keel:protect skill. Branch protection is
unbypassable and applies equally to humans, CI, and Claude Code. The hook is
not a substitute for it — see
"What keel does not do" below for exactly where the
line sits.
/plugin marketplace add submtd/shipyard
/plugin install keel@shipyard
Install only what you want — the six are independent and share no code.
rigging, stow, ballast, hull, and bosun install the same way.
Committed at the repo root. Nothing security-relevant lives here — capability comes from GitHub, not this file — so a fork inheriting it is harmless. A missing file disables keel entirely (no orientation, no guard); a malformed one raises loudly rather than silently allowing everything.
| Field | Default | Allowed values |
|---|---|---|
topology |
"gitflow" |
"gitflow", "trunk" |
branches.production |
"main" |
any branch name |
branches.integration |
"develop" |
any branch name (ignored under trunk: it is forced to equal branches.production) |
prefixes.feature |
"feature/" |
any string prefix |
prefixes.release |
"release/" |
any string prefix |
prefixes.hotfix |
"hotfix/" |
any string prefix |
contributions |
"both" |
"fork", "branch", "both" (loaded, validated, and read by the start-work skill only -- no rule in the guard hook enforces it) |
reviewPolicy |
"review" |
"approval" (requires APPROVED), "review" (APPROVED or COMMENTED), "none" |
mergeStrategy.toIntegration |
"squash" |
"squash", "merge", "rebase" |
mergeStrategy.toProduction |
"merge" |
"squash", "merge", "rebase" |
requireChangelog |
true |
true, false |
Under trunk topology there is no integration branch and no release
branches: any branch merges straight to production (trunk-based development
is not prefix-strict the way gitflow is), and mergeStrategy.toIntegration /
mergeStrategy.toProduction still apply as configured but the integration
edge never fires. The changelog gate still applies to every work branch, not
just feature/*/hotfix/*.
This repository's own .keel.json uses trunk, since it has
no develop branch.
In lifecycle order:
keel:start-work— begin a change: creates a correctly-named branch from an up-to-date base, choosing fork or branch based on config and your GitHub capability.keel:sync— bring a stale branch or fork back up to date and resolve conflicts before continuing or opening a PR.keel:finish-work— verify a change is ready: run checks, update the changelog, prompt for CLAUDE.md impact, and open the PR against the correct base.keel:review— read a PR's full diff, evaluate it against project standards, and post a review.keel:respond-to-review— read review feedback, evaluate it, make the changes, and reply.keel:land— merge an approved PR with the strategy the repo's config requires, once the gates are satisfied.keel:release— prepare a release: pick the version, roll the changelog, open the release PR.keel:ship— after a release PR merges (or, under trunk, when it's time to cut a release from production): tag it, publish notes, back-merge into integration.keel:protect— configure real, server-side enforcement: GitHub branch protection, required reviews, and force-push locks.keel:doctor— explain what keel currently sees, what a rule name means, and why it decided what it did, when you were blocked or warned unexpectedly.keel:init— set up keel in a repo: scaffold.keel.json, a changelog, PR/issue templates, CODEOWNERS, the changelog CI gate, and optionally a license — detecting defaults and never overwriting existing files.
The guard evaluates every git/gh action against these. Rule names appear
in [keel/<rule-name>] messages.
protected-write— triggers on acommiton, or apushwhose destination refspec resolves to, a protected branch (production or integration). Tag-only pushes are exempt.pr-edge— triggers when a PR's head/base pair isn't a valid edge for the configured topology (e.g.feature/* → integration,release/* → production,hotfix/* → production, or theproduction → integrationback-merge; under trunk, any branch may target production -- it is not prefix-strict -- except the production/integration branch targeting itself).changelog— triggers when a PR's CHANGELOG.md Unreleased section has gained no content (or doesn't exist), whenrequireChangelogis true. Under gitflow this applies only tofeature/hotfixPRs; release and back-merge PRs are exempt. Under trunk it applies to every work branch (any branch is a valid work branch there), except release branches and the production/integration branch itself.merge-strategy— triggers when a PR merge's strategy doesn't match the one configured for its base (mergeStrategy.toIntegration/mergeStrategy.toProduction).review— triggers on a PR merge that has requested changes outstanding, or that lacks the review statereviewPolicyrequires. Applied identically to fork and same-repo PRs.capability— triggers (as a warning only) when you appear to lack merge permission on the repository for a PR merge.
Every fact the rules read is tri-state (true/false/unknown), and unknown never produces a block — only a warning. The hook would rather stay quiet than block on ignorance.
- It runs only inside Claude Code. Plain
git/ghtyped into a terminal, or run by CI, bypasses it completely. keel has no presence outside a Claude Code session. - It does not parse shell constructs adversarially.
bash -c,eval, subshells, backticks/$(), and other command substitution are not inspected — deliberately. Agh pr createwrapped in a heredoc'd$(cat <<'EOF' … EOF)slips straight past the guard, and keel's own first PR was opened exactly that way. That is the design working, not failing: keel's predecessor tried to close such holes, and a review of that code found roughly 20 verified ways to evade its checks while also producing false positives (e.g. splitting a quoted commit message and classifying a phantom push). Escalating that arms race isn't worth it: it buys false confidence without buying real coverage. - It is not a security control. It cannot stop someone determined to evade it, and it is not meant to. Its job is to catch honest mistakes early, not to gate access.
- Known limitation: the protected-branch rule keys on branch name, not
repository identity.
protected-writeblocks a push whose destination branch is namedmain(or whateverbranches.production/branches.integrationare), regardless of which repository thatmainbelongs to. It cannot distinguish your fork'smainfrom the canonical repo'smain— so pushing to a protected-named branch is denied even on a fork you fully control. Thekeel:syncskill works around this by rebasing againstupstream/<base>rather than pushing to a protected-named branch directly.
The real boundary is GitHub branch protection, configured via
keel:protect. keel's hook is the earlier, friendlier warning — not the
gate.
The decision layer is a pure function with no I/O, which is what makes the rule table exhaustively testable:
plugins/keel/
├── keel/
│ ├── config.py .keel.json -> Config ┐
│ ├── actions.py command string -> [Action] │ pure: no subprocess,
│ ├── facts.py tri-state facts │ no I/O, no globals
│ ├── rules.py (action, facts, config) -> Verdict
│ ├── render.py Verdict -> hook JSON
│ └── scaffold.py init helpers (pure) ┘
│ ├── gitio.py all git subprocess calls ┐ all I/O, every call
│ └── ghio.py all gh subprocess calls ┘ timed out
├── hooks/
│ ├── guard.py PreToolUse entrypoint
│ ├── orient.py SessionStart entrypoint
│ └── hooks.json hook registration
├── skills/ the eleven skills
├── templates/ init's canonical scaffolded artifacts
└── tests/ the test suite
Rules key on (action, base, head, headIsFork, capability). There is
deliberately no role concept — no contributor/maintainer/solo inference
from remote URLs. Fork and same-repo PRs are judged identically, which is
what keeps a maintainer's own same-repo PR from skipping the review gate.
Capability comes from GitHub (gh repo view --json viewerPermission), not
from guessing at remote URLs.
Stdlib only at runtime — no third-party dependencies. Tests need pytest.
pip install 'pytest>=8,<9'
python3 -m pytest -q
CI runs the suite on Python 3.9 and 3.12. The pytest bound matches what
rigging renders into CI, so a pytest major release can't turn CI red in a
repo whose own code never changed — it does mean a deliberate bump when
pytest 9 lands, since Dependabot doesn't track versions inside run: steps.
.github/workflows/ci.yml and security.yml are rendered output. Do
not edit them by hand — a byte-identity test compares each committed file
against a fresh render, so a hand edit fails CI. Edit the config or the
registry and regenerate.
Their triggers come from pushBranches in .rigging.json / .hull.json
(default ["main"]). Pull requests always trigger both workflows, so push
is restricted to the long-lived branches; without that, a PR raised from a
branch in this repo would run everything twice.
The action pins are authored here:
| Action | Authored in |
|---|---|
actions/checkout |
plugins/rigging/rigging/plan.py, plugins/hull/hull/plan.py |
actions/setup-python, actions/setup-node |
plugins/rigging/rigging/stacks.py |
gitleaks/gitleaks-action |
plugins/hull/hull/scanners.py |
trufflesecurity/trufflehog |
plugins/hull/hull/scanners.py |
Every ref is pinned to a full commit SHA with a trailing # v7-style
comment. The SHA is the pin; the comment is for humans and for Dependabot.
Dependabot doesn't know these files are generated — it edits the rendered workflow and leaves the registry alone, which fails the byte-identity dogfood tests, so its PRs can never go green on their own. That is the guarantee working, not a bug. To land one:
git checkout dependabot/...
python3 scripts/sync_action_pins.py # carries the bump into the registries
python3 -m pytest -q
git commit -am "chore(deps): sync action pins into the registries"
One pin is the exception: trufflesecurity/trufflehog. Dependabot only bumps
refs that appear in a workflow committed to this repo, and shipyard's own
security.yml uses gitleaks — so trufflehog is shipped to consumers but
never dogfooded here, and Dependabot never proposes a bump for it. When a new
trufflehog release ships, edit action_ref and action_ref_version on the
"trufflehog" entry in plugins/hull/hull/scanners.py by hand, then run
scripts/sync_action_pins.py to regenerate the goldens.
The changelog gate also applies to dependabot/* branches (they classify
as other under trunk), so such a PR needs a CHANGELOG.md entry like any
other change.
v0.9.0. All six plugins have shipped and version in lockstep. Everything before 0.3.0 was pre-release; see the CHANGELOG.
rigging now drives pnpm, yarn (both majors), and bun — not just npm — takes a
custom testCommand when a repo's real test command isn't the default, and can
run a postgres, mysql, or redis service alongside the tests (rigging owns
the image tag, port, credentials, and health check, and hands the job a
connection URL — whose database name the repo can set, so a harness that
guards its test DB by name works end to end). hull adds a license-free
trufflehog scanner alongside gitleaks. Two init
skills still refuse to scaffold rather than render an artifact that cannot
work: hull:init in an organization-owned repo with no scanner license, and
rigging:init when a JavaScript toolchain is genuinely undeterminable (an
ambiguous Yarn major, or two package-manager lockfiles at the root). Both say
why.
Each plugin's init skill scaffolds its config and renders its artifact,
never overwriting a file that already exists:
keel:init—.keel.json, a changelog, PR/issue templates, CODEOWNERS, the changelog CI gate, and an optional license.rigging:init— CI forpythonandnoderepos →ci.ymlfrom.rigging.json.stow:init—.gitignoresections per detected stack, spliced as managed blocks so they update in place without clobbering hand-written lines →.gitignorefrom.stow.json.ballast:init— import-mode, testpaths, pythonpath, and a denylist ofaddOptsthat are hostile in a committed config (interactive debuggers hang CI;--lf/--ffmake which tests run depend on a local cache) →pytest.inifrom.ballast.json.hull:init— an injection-safe gitleaks scan, stack-agnostic since a secret scanner works the same way in any language →security.ymlfrom.hull.json.bosun:init— github-actions always-on (every plugin-using repo pins action refs), pip/npm added when detected →dependabot.ymlfrom.bosun.json.
The roster is closed at six. A seventh plugin, fathom
(debugging/profiling), was in the original blueprint and will not be built —
see the decision record.
Nothing in the suite depends on it.
MIT — see LICENSE.