Agentic development flow for Rails 8 projects: orchestrated /feature, /fix and /review commands, eleven specialist subagents, and hard guardrail hooks. Pairs…

Five plugins that teach Claude to build, review, test and ship Rails 8 applications: the stack doctrine it should follow, the flow it should work in, an independent QA pass that does not trust the developer, a design system, and a release lifecycle.
Install it, then talk to Claude normally. The plugins add commands and specialist agents; you do not need to learn the internals to get value on day one.
/plugin marketplace add fmanimashaun/claude-skills
/plugin install rails-stack@claude-skills # the doctrine — start here
Then add the flows you want:
/plugin install rails-flow@claude-skills # build/fix/review loop
/plugin install design-flow@claude-skills # UI, design system, assets
/plugin install qa-flow@claude-skills # independent QA
/plugin install pipeline@claude-skills # release lifecycle
Verify:
/plugin # lists what is installed
/rails-flow:setup-flow # scaffolds CLAUDE.md, guardrails and the brain into your project
claude.ai / Claude Desktop. Download the .skill files from the latest release and upload them in Settings → Capabilities → Skills. Each is self-contained.
No plugin support (Agent SDK, older clients). Copy the skill directories into your project's .claude/skills/:
git clone https://github.com/fmanimashaun/claude-skills.git /tmp/cs
mkdir -p .claude/skills && cp -R /tmp/cs/skills/* .claude/skills/
For every project. Same copy, into ~/.claude/skills/ instead.
Updating. /plugin marketplace update claude-skills, then restart Claude Code. To confirm what you actually got — installed versions drift from what you think you installed — run /rails-flow:toolchain-check.
| plugin | what it does |
|---|---|
| rails-stack | The doctrine: Rails 8.1, Hotwire, pure RSpec, Tailwind v4, a design system, and the review rules. Bundles seven skills — no commands, it just makes Claude write the right code. |
| rails-flow | The build loop — /feature, /fix, /review, plus a durable project memory and an autonomous driver. |
| design-flow | UI and design system work — components, tokens, audits, a curated asset library, and an optional pen.dev tier for exploring screens visually before any code is written. |
| qa-flow | An independent QA engineer that treats the developer's claims as unverified and produces evidence. |
| pipeline | Build → verify → certify → release, with circuit breakers for unattended runs. |
rails-stack| skill | covers |
|---|---|
rails-8 | the stack — models, jobs, auth, APIs, deployment |
hotwire | Turbo, Stimulus, Hotwire Native |
design-system | the design system: tokens, components, art direction, reference research |
code-review | correctness review classes — the bugs a reviewer must find |
quality-pass | reuse, simplification, efficiency, altitude — advisory, never blocking |
derived-artifacts | anything whose numbers come from somewhere else |
parallel-session-lane | working as one of several agent sessions in one repo |
The shortest path from empty directory to something real:
/rails-flow:setup-flow # 1. scaffold doctrine + memory into the project
/design-flow:setup # 2. tokens, brand pack, design system
/rails-flow:feature # 3. describe what you want; it plans, builds, reviews
/qa-flow:verify # 4. an independent pass that does not trust step 3
Steps 1–2 are once per project. Steps 3–4 are the loop.
feature fix review issues brief spec slice curate explain graph handoff pr-comments report setup-flow toolchain-audit · memory: brain brain-review brain-sync · autonomous: drive escalate toolchain-check · parallel sessions: coordinate
setup component tokens variants mobile audit critique canvas port compose · assets: assets generate
setup-qa cases verify certify functional smoke crawl walkthrough
setup-pipeline pipeline status board ack release install-hooks · cloud: setup-cloud deploy-cloud
Three loops, each with a different job:
rails-flow) — plan, implement, review, remember. Spec-first, IA before code.qa-flow) — a separate agent that assumes nothing from the build loop and produces evidence rather than assurances.pipeline) — gates the two above into a release, and stops rather than digging when an unattended run goes wrong.The separation is the point: a build agent that also signs off its own work is a build agent that signs off its own work.
Read next: architecture for the design reasoning and what we deliberately did not adopt · harness doctrine for when a hook should fail open versus closed · code-review graph for the optional tool-gated review integration.
The toolchain reports its own bugs. From any project using it:
/rails-flow:report
That files a structured, version-pinned, deduplicated issue on this repo. Every issue in the tracker arrived that way, and it is the fastest route to a fix — the report carries the versions and paths a maintainer would otherwise have to ask for.
skills/ the seven skills — the doctrine that ships
plugins/ rails-flow · qa-flow · pipeline · design-flow
dist/ packaged .skill files for claude.ai
docs/ architecture, coverage and wiki pages
scripts/ the gates that keep all of the above honest
.claude/ maintainer tooling — not distributed
CLAUDE.md is the maintainer's guide to this repo. If you are here to build a Rails app, you want the plugins above, not that file.
Components version independently; the marketplace tag is the release label. Every change lands in CHANGELOG.md under its component, and a release publishes one block per component it bumps. Versions are assigned at promotion, never before — a version number on unshipped work is a claim about something you cannot install.
MIT — see LICENSE.
hooks/register.js 12 lines1// hooks.json names ONE module, so this file registers every mod rails-flow ships.
2// Add a mod here with one import and one call; do not add a second path to hooks.json.
3import { register as contextNudge } from './context-nudge.mjs'
4import { register as laneBand } from './lane-band.js'
5import { register as budgetGuard } from './budget-guard.mjs'
6
7export function register(on, options) {
8 contextNudge(on, options)
9 laneBand(on, options)
10 budgetGuard(on, options)
11}
12hooks/context-nudge.mjs 114 lines1// context-nudge: shows how full this session's context window is, and once per climb past a
2// threshold adds ONE line only Claude reads, asking it to offer a handoff and a /clear (#1547).
3// It blocks nothing and rewrites no prompt text: every hook returns next(e), the prompt hook with at
4// most one added context line. Mods need Claude Code 2.1.287 or later.
5//
6// It also carries budget-guard's account-usage view (#1677), because a plugin registers each event once:
7// the 7-day and 5-hour windows join the status line, and once per level reached (warn, then block) one
8// usage line rides on a prompt, so Claude knows its budget without being told. budget-guard.mjs holds the
9// pure helpers; this module passes them data only, never `$`.
10//
11// The figure is the engine's own. `percent` is `tokens` over `window` as a whole percentage, the status
12// line's used_percentage; `tokens` is uncached, cache-written and cache-read input together. It is absent
13// until the first response of a window, and after a compaction until the next response (types for 2.1.287).
14
15// A starting value, not a measured one: nothing has been measured about where a handoff stops being
16// cheap (#1547). RAILS_FLOW_CONTEXT_NUDGE_PCT overrides it, whole percent, 1 to 99.
17const DEFAULT_THRESHOLD = 70
18
19import { budgetLine, DEFAULT_BLOCK, DEFAULT_WARN, level, limitsLabel, msUntil, RESUME_TEXT, windowOf } from './budget-guard.mjs'
20
21// Each window's last reading, and the highest level announced since it was last below warn.
22const WINDOWS = ['five_hour', 'seven_day']
23const reading = { five_hour: null, seven_day: null }
24const announced = { five_hour: null, seven_day: null }
25// The pending resume after the 5-hour window resets, so it is scheduled once.
26let resume = null
27
28// The last fill the engine measured, in whole percent, or null while the live window has no reading.
29let percent = null
30// The line has been added since the fill last fell below the threshold or lost its reading.
31let nudged = false
32
33// The line itself. It rides on a prompt, so its size is a cost: every character is billed again on
34// each later request, which is why it is added once per climb and kept this short.
35export function nudgeLine(fill) {
36 return (
37 `Context note: this session's context window is ${fill}% full. Finish the current step, then offer to ` +
38 'write a handoff with /rails-flow:handoff and tell the user to run /clear (not /compact: the handoff already holds it) before new work. ' +
39 'Say this once; do not repeat it.'
40 )
41}
42
43// The threshold in force: the environment's whole percent when it is one, else the default.
44// A whole percent from 1 to 100, or the default.
45function pct(raw, dflt) {
46 const n = Number.parseInt(raw ?? '', 10)
47 return Number.isInteger(n) && n >= 1 && n <= 100 ? n : dflt
48}
49
50async function budgetLevels($) {
51 return [
52 pct(await $.env.get('RAILS_FLOW_BUDGET_WARN_PCT'), DEFAULT_WARN),
53 pct(await $.env.get('RAILS_FLOW_BUDGET_BLOCK_PCT'), DEFAULT_BLOCK),
54 ]
55}
56
57async function threshold($) {
58 const raw = await $.env.get('RAILS_FLOW_CONTEXT_NUDGE_PCT')
59 const n = Number.parseInt(raw ?? '', 10)
60 return Number.isInteger(n) && n >= 1 && n <= 99 ? n : DEFAULT_THRESHOLD
61}
62
63// Only a prompt from a person at an interactive surface carries the line: `composer` (Enter at the prompt),
64// `bridge` (Remote Control from a phone or the web), or no origin at all, which the engine defines as the
65// user's own. Every other kind is refused on purpose, so the line is not used up on a message the person
66// never reads: a peer session, a scheduled task, a background notification, another plugin, a channel
67// relay. `sdk` is refused too: `claude -p` and the Agent SDK have nobody to run /clear, so asking Claude to
68// tell the user to would only waste the line (mod hooks do run there; the docs say so). The kinds are a
69// closed set in the engine's types for 2.1.287; docs/evidence/audits/2026-10-02-mods-api-2.1.287.md.
70function isTheirs(origin) {
71 return origin === undefined || origin.kind === 'composer' || origin.kind === 'bridge'
72}
73
74export function register(on) {
75 // Runs after each turn, and whenever the fill moved: the figure is pushed, not polled
76 on('session.measure', async ($, e, next) => {
77 percent = e.context.percent ?? null
78 const levels = await budgetLevels($)
79 for (const k of WINDOWS) {
80 reading[k] = windowOf(e.rateLimits, k)
81 if (level(reading[k]?.pct, ...levels) === null) announced[k] = null
82 }
83 const limits = limitsLabel(e.rateLimits)
84 const parts = [percent === null ? null : `context ${percent}%`, limits ?? null].filter(Boolean)
85 $.ui.status(parts.length ? parts.join(' · ') : undefined)
86 // At the 5-hour hard level, schedule one prompt for just after the reset, so the work resumes by itself.
87 const five = reading.five_hour
88 if (resume === null && level(five?.pct, ...levels) === 'block' && (await $.env.get('RAILS_FLOW_AUTO_RESUME')) !== '0') {
89 const ms = msUntil(five.resetsAt, await $.clock.now())
90 if (ms !== null) resume = $.clock.after(ms + 120000, () => { resume = null; void $.prompt.submit({ text: RESUME_TEXT }) })
91 }
92 if (percent === null || percent < (await threshold($))) nudged = false
93 return next(e)
94 })
95
96 // Runs when a prompt is submitted
97 on('prompt.submit', async ($, e, next) => {
98 const lines = []
99 const levels = await budgetLevels($)
100 for (const k of WINDOWS) {
101 const lvl = level(reading[k]?.pct, ...levels)
102 if (lvl !== null && lvl !== announced[k] && !(announced[k] === 'block' && lvl === 'warn')) {
103 announced[k] = lvl
104 lines.push(budgetLine(k, reading[k].pct, lvl, reading[k].resetsAt, k === 'five_hour' && resume !== null))
105 }
106 }
107 if (percent !== null && !nudged && isTheirs(e.origin) && percent >= (await threshold($))) {
108 nudged = true
109 lines.push(nudgeLine(percent))
110 }
111 return lines.length ? next({ ...e, context: [...(e.context ?? []), ...lines] }) : next(e)
112 })
113}
114hooks/lane-band.js 89 lines1// lane-band: a read-only band above the prompt showing where this session is working.
2// Shows the branch, the worktree directory, the assigned lane (RAILS_FLOW_LANE) and the number of
3// uncommitted files, refreshed every two seconds. It blocks nothing and rewrites nothing: every hook
4// returns next(e) unchanged.
5// Verified in the terminal only. `AbovePrompt` is also raised on the Desktop Code tab, but this band's
6// data comes from `$.process`, which Claude Code's type declarations mark "CLI only", and it has not been
7// run on Desktop. The VS Code chat panel draws nothing.
8// Tested with Claude Code 2.1.287 (`claude plugin validate` and `claude plugin test`); mods need that version or later.
9
10// What the band shows. Reset to null when the working directory is not a git repository.
11let info = null
12
13// Run one git command and return its trimmed stdout, or null when git fails or cannot start.
14async function git($, args) {
15 try {
16 const r = await $.process.run(['git', ...args], { timeoutMs: 5000 })
17 return r.exitCode === 0 ? r.stdout.trim() : null
18 } catch {
19 return null
20 }
21}
22
23// Read branch, worktree and dirty count, then ask Claude Code to draw the band again.
24// `--no-optional-locks` keeps `git status` from rewriting .git/index while the user's own git runs.
25async function refresh($) {
26 const top = await git($, ['rev-parse', '--show-toplevel'])
27 let next = null
28 if (top !== null) {
29 const branch = (await git($, ['branch', '--show-current'])) || 'detached HEAD'
30 const status = (await git($, ['--no-optional-locks', 'status', '--porcelain'])) ?? ''
31 let lane = ''
32 try {
33 lane = (await $.env.get('RAILS_FLOW_LANE')) || ''
34 } catch {
35 // Keep the band without a lane rather than losing it
36 }
37 next = {
38 branch,
39 worktree: top.split('/').pop(),
40 lane,
41 dirty: status === '' ? 0 : status.split('\n').length,
42 }
43 }
44 info = next
45 $.ui.invalidate('ui.render')
46}
47
48// True while a refresh is running, so a slow one is never overlapped by the next tick.
49let busy = false
50
51// The timer callback. A throw or a rejection here (a failed redraw request, say) is caught here, so
52// the band never depends on how the host treats a callback that fails.
53async function tick($) {
54 if (busy) return
55 busy = true
56 try {
57 await refresh($)
58 } catch {
59 // Keep whatever the band last showed
60 } finally {
61 busy = false
62 }
63}
64
65export function register(on) {
66 // Runs before your first prompt, and again after a reload (which cancels the old timer). The git
67 // calls run on a repeating timer, the documented way to do background work, so neither the first
68 // prompt nor the end of a turn ever waits on them. The first refresh comes one interval after start.
69 on('session.start', async ($, e, next) => {
70 $.clock.every(2000, () => tick($))
71 return next(e)
72 })
73
74 // Runs each time Claude Code draws the band above the prompt
75 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
76 const theirs = await next(e)
77 if (info === null) return theirs
78 const { Box, Text } = $.ui.resolve(e)
79 const parts = [info.branch, info.worktree]
80 if (info.lane) parts.push('lane ' + info.lane)
81 parts.push(info.dirty === 0 ? 'clean' : info.dirty + ' uncommitted')
82 // Keep what the mods after this one draw, and put the line above it
83 return Box({
84 flexDirection: 'column',
85 children: [Text({ dimColor: true, children: [parts.join(' · ')] }), ...(theirs ? [theirs] : [])],
86 })
87 })
88}
89hooks/budget-guard.mjs 114 lines1// budget-guard: keeps agent fan-out from spending an account's usage limit (#1676, #1677).
2// Two refusals on the tool.call event, and pure helpers that context-nudge.mjs uses to show the limits.
3// Mods need Claude Code 2.1.287 or later; the declarations cited are in
4// docs/evidence/audits/2026-10-07-mods-tool-call-ratelimits-2.1.292.md.
5//
6// 1. A Workflow whose script has agents only relay SendMessage is refused at any usage. Each agent pays its whole
7// startup context (measured: ~62k tokens) to send a message a direct SendMessage call sends for a few
8// hundred. A script that really does work and also messages can say so with the marker below.
9// 2. At or past the hard threshold of the 7-day window, a new Workflow or Agent is refused unless
10// RAILS_FLOW_BUDGET_ALLOW=1 is set. Direct tool use is never touched.
11// The rate-limit windows appear only on a claude.ai Pro or Max subscription or behind a gateway spend
12// limit, and only after the first response; with no reading, nothing is refused for usage. A gateway's
13// window is `spend_limit`, not the 7-day one, so the weekly refusal does not apply behind a gateway alone.
14// A Workflow started by `name` or `scriptPath` carries no `script`, so the relay check cannot see it.
15
16export const DEFAULT_WARN = 80
17export const DEFAULT_BLOCK = 90
18export const STARTUP_TOKENS = 60000
19export const NOT_A_RELAY = '// budget-guard: not a relay'
20
21// The 7-day window's percent used, or null with no reading.
22export function weekly(rateLimits) {
23 const w = (rateLimits ?? []).find((r) => r.kind === 'seven_day')
24 return typeof w?.percentUsed === 'number' ? w.percentUsed : null
25}
26
27// "week 96% · 5h 40%", or undefined with no reading. Only the two subscription windows.
28export function limitsLabel(rateLimits) {
29 const names = { seven_day: 'week', five_hour: '5h' }
30 const parts = (rateLimits ?? [])
31 .filter((r) => names[r.kind] && typeof r.percentUsed === 'number')
32 .map((r) => `${names[r.kind]} ${Math.round(r.percentUsed)}%`)
33 return parts.length ? parts.join(' · ') : undefined
34}
35
36// One window's reading, `{ pct, resetsAt }`, or null. `resetsAt` is ISO 8601 in a mod (the status line's
37// own JSON uses epoch seconds instead; the two are not mixed here).
38export function windowOf(rateLimits, kind) {
39 const w = (rateLimits ?? []).find((r) => r.kind === kind)
40 return typeof w?.percentUsed === 'number' ? { pct: w.percentUsed, resetsAt: w.resetsAt } : null
41}
42
43// The level a reading is at: 'block', 'warn' or null.
44export function level(pct, warn = DEFAULT_WARN, block = DEFAULT_BLOCK) {
45 if (pct === null || pct === undefined) return null
46 return pct >= block ? 'block' : pct >= warn ? 'warn' : null
47}
48
49// " (resets 14:05 UTC)" from an ISO time, or "" without one.
50export function resetText(resetsAt) {
51 const t = resetsAt ? new Date(resetsAt) : null
52 return t && !Number.isNaN(t.getTime()) ? ` (resets ${t.toISOString().slice(11, 16)} UTC)` : ''
53}
54
55// Milliseconds from `now` until `resetsAt`, or null.
56export function msUntil(resetsAt, now) {
57 const t = resetsAt ? Date.parse(resetsAt) : Number.NaN
58 return Number.isNaN(t) ? null : Math.max(0, t - now)
59}
60
61// The one line Claude reads when a window first reaches a level. The point of the warn line is to write
62// things down while there is still budget to do it. Kept short: it is billed again on every later request.
63export function budgetLine(kind, pct, lvl, resetsAt, willResume = false) {
64 const name = kind === 'five_hour' ? '5-hour' : 'weekly'
65 const head = `Usage note: the ${name} limit is ${Math.round(pct)}% used${resetText(resetsAt)}.`
66 if (lvl === 'warn')
67 return `${head} Update the handoff now (/rails-flow:handoff) and commit and push work in progress; avoid Workflow and Agent fan-out.`
68 const wait = kind === 'five_hour' && willResume ? ' rails-flow will resume this session after the reset.' : ''
69 const refuse = kind === 'seven_day' ? ' New Workflow and Agent calls are refused.' : ''
70 return `${head} Stop starting new work: finish this step, update the handoff, commit and push, then stop.${refuse}${wait}`
71}
72
73// What the resume prompt says once the 5-hour window has reset.
74export const RESUME_TEXT =
75 'The 5-hour usage limit has reset. Resume: read the handoff (HANDOFF.md, or the latest PR or issue comment you wrote), check the branch and its pushed SHA, then continue from the recorded next step.'
76
77// A workflow that spawns agents to relay SendMessage. The marker opts out a script that really does work.
78export function isRelay(script) {
79 return typeof script === 'string' && !script.includes(NOT_A_RELAY) && /\bSendMessage\b/.test(script) && /\bagent\s*\(/.test(script)
80}
81
82async function blockAt($) {
83 const n = Number.parseInt((await $.env.get('RAILS_FLOW_BUDGET_BLOCK_PCT')) ?? '', 10)
84 return Number.isInteger(n) && n >= 1 && n <= 100 ? n : DEFAULT_BLOCK
85}
86
87async function overBlock($) {
88 if ((await $.env.get('RAILS_FLOW_BUDGET_ALLOW')) === '1') return null
89 const pct = weekly((await $.session.usage()).rateLimits)
90 return pct !== null && pct >= (await blockAt($)) ? pct : null
91}
92
93export function register(on) {
94 on('tool.call', { tool: 'Workflow' }, async ($, e, next) => {
95 if (isRelay(e.script))
96 return {
97 deny:
98 `rails-flow budget-guard: this workflow spawns agents to relay SendMessage, about ${STARTUP_TOKENS / 1000}k tokens of startup ` +
99 `each. Call SendMessage directly, once per recipient, in one turn. If the agents do real work, add the line "${NOT_A_RELAY}".`,
100 }
101 const pct = await overBlock($)
102 if (pct !== null)
103 return { deny: `rails-flow budget-guard: the weekly limit is ${Math.round(pct)}% used; new workflows are refused. Set RAILS_FLOW_BUDGET_ALLOW=1 to override.` }
104 return next(e)
105 })
106
107 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
108 const pct = await overBlock($)
109 if (pct !== null)
110 return { deny: `rails-flow budget-guard: the weekly limit is ${Math.round(pct)}% used; new agents are refused (about ${STARTUP_TOKENS / 1000}k tokens each). Set RAILS_FLOW_BUDGET_ALLOW=1 to override.` }
111 return next(e)
112 })
113}
114