/work status panel above the prompt, and /tickets /who /story without a Claude turn (read-only)

Skills, subagents, hooks and a small Claude Code mod that take a tracker issue from plan to merged PR:
/kickoff <issue> plan: context.md → grilling → spec / tickets (both repos in one run)
/work ticket loop: parallel implementer subagents in their own worktrees,
then review → teach → squash-merge → commit, one ticket at a time
/implement one ticket in this session
/bugfix <issue> quick path for obvious bugs, full root-cause path otherwise
/deep-review review sized by a risk classifier; parallel specialists + a refute phase
/pr PR via the project's own PR skill, with before/after screenshots
/address-review unresolved PR threads, one by one
/commit one clean commit, terse message
It assumes a frontend repo and a backend repo worked on side by side, Linear as the tracker, GitHub for PRs and agent-browser for UI checks. Everything specific to your stack — repo paths, dev and setup commands, ports, the database, tracker labels — is configuration (see Configure). WORKFLOW.md is the one-page tour.
| Path | What |
|---|---|
skills/ | the slash commands above (SKILL.md each; deep-review/prompts/ are the reviewer prompts) |
agents/ | subagents: implementer, planner, Explore, context-gatherer, reviewer-* |
hooks/ | worktree-create.sh (per-task worktrees, ports, per-worktree DB via lib/db.sh), rules-guard.sh + rules-engine.jq + rules-check.sh (project rules your linter can't express), bash-guard.sh (agents never start/kill dev servers or run watch-mode tests), rtk-guard.sh, pr-created.sh, notify.sh; lib/config.sh = every setting with its default |
rules/my-frontend.json | example rules file for the rules engine |
mods/workflow/ | Claude Code mod: task status band above the prompt, /tickets /who /story without a model turn |
shell/worktrees.zsh | wt, wts, wtwho, wtrm, wtclean, wtdb, ablogin, friction, metrics |
bin/ | wtconf (prints the effective config), pr-diagram-check, settings-sync |
config.example.zsh | template for your config.local.zsh |
CLAUDE_CODE_PLUGIN_DIRS support), macOS (osascript, BSD sed -i '')jq, perl, git, zsh; gh ≥ 2.99 (--attach) for /prlinear) for /kickoff, /bugfix and story modehooks/rtk-guard.sh uses it — drop that hook if you don't use rtk)python3 with playwright for /pr diagramsSkills and hooks refer to ~/.agents/…, so clone the repo there (or let install.sh link ~/.agents to your clone):
git clone <URL of this repo> ~/.agents
~/.agents/install.sh # links skills/* and agents/*.md into ~/.claude; safe to re-run
~/.agents/install.sh --dry-run # show what it would do
Merge into ~/.claude/settings.json:
{
"env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/.agents/mods/workflow" },
"permissions": { "allow": ["Bash(~/.agents/bin/wtconf:*)"] },
"hooks": {
"PreToolUse": [
{ "matcher": "Bash", "hooks": [
{ "type": "command", "command": "$HOME/.agents/hooks/rtk-guard.sh" },
{ "type": "command", "command": "$HOME/.agents/hooks/bash-guard.sh" } ] },
{ "matcher": "Edit|Write|MultiEdit", "hooks": [
{ "type": "command", "command": "$HOME/.agents/hooks/rules-guard.sh" } ] }
],
"PostToolUse": [
{ "matcher": "Bash", "hooks": [ { "type": "command", "command": "$HOME/.agents/hooks/pr-created.sh" } ] }
],
"WorktreeCreate": [
{ "hooks": [ { "type": "command", "command": "$HOME/.agents/hooks/worktree-create.sh" } ] }
],
"Notification": [
{ "hooks": [ { "type": "command", "command": "$HOME/.agents/hooks/notify.sh" } ] }
]
}
}
bin/settings-sync merges a repo-kept settings file (~/.agents/settings.v3.1.json, not included — create your own) with the live one; skip it if you edit ~/.claude/settings.json directly.
cp ~/.agents/config.example.zsh ~/.agents/config.local.zsh # then edit
echo 'source ~/.agents/shell/worktrees.zsh' >> ~/.zshrc
~/.agents/bin/wtconf # check what the hooks and skills will see
config.local.zsh holds your real values: repo paths, the deployed API and base-branch FE, the remote-API port, dev and setup commands, the patterns bash-guard uses to spot server starts and watch-mode tests, the per-worktree database (container, prefix, migrate command — or off), what your SSO login screen looks like, tracker labels and statuses, and your project's PR skill. Every key and its default is documented in config.example.zsh and hooks/lib/config.sh. The hooks, the shell helpers and the mod read the file directly; skills read it through wtconf. Don't commit it to a public repo — the leak guard refuses a config.local.zsh.
Rules files are named after the repo directories: rules/<basename of FE_REPO>.json and rules/<basename of BE_REPO>.json (the example matches the default my-frontend).
Per-project knowledge that isn't a single value — test commands, conventions the codebase reviewer enforces, which directories count as modules for /pr diagrams — goes in a worker contract, ~/.agents/projects/<repo>.md, pulled into each worktree through the repo's gitignored CLAUDE.local.md (@~/.agents/projects/<repo>.md). Write your own; none are published.
The workflow calls these but does not ship them:
/kickoff and friends load grilling, domain-modeling, to-spec, to-tickets, handoff (plus grill-with-docs, writing-great-skills) from mattpocock/skills: npx skills add mattpocock/skills # pick the seven above; install into ~/.agents/skills
then link them into ~/.claude/skills/ (re-run ./install.sh).
/pr (cathrynlavery/diagram-design). In Claude Code: /plugin marketplace add cathrynlavery/diagram-design, then /plugin install diagram-design@diagram-design; PNG export needs pip install playwright && playwright install chromium. bin/pr-diagram-check tells /pr whether it is available; without it /pr simply skips the diagram./pr has a built-in fallback).The repo is generated from a private one. .githooks/pre-commit (enable with git config core.hooksPath .githooks) and the leak-guard GitHub Actions workflow scan for home paths, emails, tokens and a committed config.local.zsh (scripts/public-denylist.txt) and run gitleaks. Keep secrets in your environment, never in this repo.
See LICENSE.
hooks/register.js 122 lines1// workflow mod: /work status band above the prompt, and /tickets /who /story.
2// Read-only by design: reads files through $.fs and env through $.env; never
3// writes, never touches tool calls or prompts, never calls the model.
4// Plain JS (no JSX): trees are built with the global `h`.
5
6import {
7 feServers,
8 headline,
9 locate,
10 paths,
11 portLine,
12 prLine,
13 readTask,
14 storyText,
15 ticketsText,
16 whoText,
17} from './reader.js'
18
19const REFRESH_MS = 20_000
20
21// What reader.js reads through. `$` stays in this file (the engine follows it
22// into same-file functions only); env names must be literals.
23async function io($) {
24 return {
25 read: path => $.fs.read(path),
26 list: path => $.fs.list(path),
27 exists: path => $.fs.exists(path),
28 cwd: () => $.session.cwd(),
29 env: {
30 HOME: await $.env.get('HOME'),
31 AGENTS_CONFIG: await $.env.get('AGENTS_CONFIG'),
32 WT_ROOT: await $.env.get('WT_ROOT'),
33 FE_REPO: await $.env.get('FE_REPO'),
34 BE_REPO: await $.env.get('BE_REPO'),
35 WT_REMOTE_FE_PORT: await $.env.get('WT_REMOTE_FE_PORT'),
36 },
37 }
38}
39
40// The command's lines go to the transcript as a notice: shown to the person,
41// never sent to the model, and no turn starts.
42const say = ($, text) => {
43 $.ui.log(text)
44 return {}
45}
46
47export function register(on) {
48 on('session.start', async ($, e, next) => {
49 await $.command.register({
50 name: 'tickets',
51 description: 'Ticket frontier for this side: ready / blocked / done (no Claude turn)',
52 })
53 await $.command.register({
54 name: 'who',
55 description: 'Who holds the shared FE port and which task worktrees run a FE (no Claude turn)',
56 })
57 await $.command.register({
58 name: 'story',
59 description: 'Story mode: Linear id, branch, base, PR per ticket (no Claude turn)',
60 })
61 $.clock.every(REFRESH_MS, () => $.ui.invalidate('ui.render'))
62 return next(e)
63 })
64
65 on('turn.complete', ($, e, next) => {
66 if (e.agentId === undefined) $.ui.invalidate('ui.render')
67 return next(e)
68 })
69
70 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
71 if (e.props.hasSurvey) return next(e)
72 let lines
73 try {
74 const fs = await io($)
75 const loc = await locate(fs)
76 if (!loc) return next(e)
77 const task = await readTask(fs, loc)
78 const servers = await feServers(fs, loc.paths)
79 lines = [headline(task), [prLine(task), portLine(servers, loc.paths.remotePort)].filter(Boolean).join(' ')]
80 } catch {
81 return next(e)
82 }
83 const { Box, Text } = $.ui.resolve(e)
84 return h(
85 Box,
86 { flexDirection: 'column' },
87 h(Text, { wrap: 'truncate' }, lines[0]),
88 h(Text, { dimColor: true, wrap: 'truncate' }, lines[1]),
89 )
90 }).catch(($, e, next) => next(e))
91
92 on('command.run', { command: 'tickets' }, async $ => {
93 try {
94 const fs = await io($)
95 const loc = await locate(fs)
96 return say($, loc ? ticketsText(await readTask(fs, loc)) : 'tickets: no task here (not a task worktree or scratch dir)')
97 } catch (err) {
98 return say($, `tickets: ${err?.message ?? err}`)
99 }
100 })
101
102 on('command.run', { command: 'who' }, async $ => {
103 try {
104 const fs = await io($)
105 const p = await paths(fs)
106 return say($, whoText(await feServers(fs, p), p.remotePort))
107 } catch (err) {
108 return say($, `who: ${err?.message ?? err}`)
109 }
110 })
111
112 on('command.run', { command: 'story' }, async $ => {
113 try {
114 const fs = await io($)
115 const loc = await locate(fs)
116 return say($, loc ? storyText(await readTask(fs, loc)) : 'story: no task here (not a task worktree or scratch dir)')
117 } catch (err) {
118 return say($, `story: ${err?.message ?? err}`)
119 }
120 })
121}
122hooks/reader.js 235 lines1// Shared reader for the panel and the commands. Read-only. It never sees `$`
2// (a mod passes `$` only to functions in its own file): register.js hands it
3// `io`, plain functions over $.fs reads, the session's cwd and the env values
4// it needs. Every function returns data or throws; callers decide what a
5// failure looks like (the panel draws nothing).
6
7const SIDES = ['fe', 'be']
8
9// Same rule as _slug in ~/.agents/shell/worktrees.zsh:
10// TODO-12-add-due-dates | dev/todo-7-1-x | todo-12 -> todo-12 | todo-7-1
11export function slugOf(branch) {
12 const m = /[a-z]+-[0-9]+(-[0-9]+)?/i.exec(branch)
13 return m ? m[0].toLowerCase() : branch.replace(/^[^/]*\//, '')
14}
15
16// `export NAME=value` lines of the config file -> { NAME: value }. Quotes are stripped, $HOME /
17// ${HOME} / a leading ~ expanded; anything fancier is ignored.
18export function parseConfig(text, home) {
19 const out = {}
20 for (const line of text.split('\n')) {
21 const m = /^\s*export\s+([A-Z_][A-Z0-9_]*)=(?:'([^']*)'|"([^"]*)"|([^\s#]*))/.exec(line)
22 if (!m) continue
23 let v = m[2] ?? m[3] ?? m[4] ?? ''
24 if (m[2] === undefined) v = v.replace(/\$\{HOME\}|\$HOME(?![A-Za-z0-9_])/g, home)
25 if (v.startsWith('~/')) v = home + v.slice(1)
26 out[m[1]] = v
27 }
28 return out
29}
30
31// Same precedence as hooks/lib/config.sh: the config file (~/.agents/config.local.zsh, or
32// $AGENTS_CONFIG), then the environment, then the neutral defaults. A missing config file is fine.
33// io.env: { HOME, AGENTS_CONFIG, WT_ROOT, FE_REPO, BE_REPO, WT_REMOTE_FE_PORT }, unset ones undefined.
34export async function paths(io) {
35 const home = io.env.HOME ?? ''
36 const expand = p => (p && p.startsWith('~/') ? home + p.slice(1) : p)
37 const file = expand(io.env.AGENTS_CONFIG) || `${home}/.agents/config.local.zsh`
38 let cfg = {}
39 try {
40 if (await io.exists(file)) cfg = parseConfig(await io.read(file), home)
41 } catch {
42 cfg = {}
43 }
44 const get = name => cfg[name] || expand(io.env[name])
45 return {
46 scratch: `${home}/.agents/scratch`,
47 wtRoot: get('WT_ROOT') || `${home}/Workspace/my-worktrees`,
48 feRepo: get('FE_REPO') || `${home}/Workspace/my-frontend`,
49 beRepo: get('BE_REPO') || `${home}/Workspace/my-backend`,
50 remotePort: get('WT_REMOTE_FE_PORT') || '3000',
51 }
52}
53
54const under = (cwd, root) => cwd === root || cwd.startsWith(root + '/')
55
56// The task this session runs in, or null. A task worktree is
57// <WT_ROOT>/<slug>-<side>; a main checkout counts when the scratch dir of its
58// current branch's slug exists.
59export async function locate(io) {
60 const p = await paths(io)
61 const cwd = await io.cwd()
62 let slug, side
63 if (under(cwd, p.wtRoot)) {
64 const m = /^(.+)-(fe|be)$/.exec(cwd.slice(p.wtRoot.length + 1).split('/')[0])
65 if (!m) return null
66 ;[, slug, side] = m
67 } else {
68 const root = under(cwd, p.feRepo) ? p.feRepo : under(cwd, p.beRepo) ? p.beRepo : null
69 if (!root) return null
70 side = root === p.feRepo ? 'fe' : 'be'
71 const head = (await io.read(`${root}/.git/HEAD`)).trim()
72 const ref = /^ref: refs\/heads\/(.+)$/.exec(head)
73 if (!ref) return null
74 slug = slugOf(ref[1])
75 }
76 const dir = `${p.scratch}/${slug}`
77 if (!(await io.exists(`${dir}/context.md`))) return null
78 return { slug, side, dir, paths: p }
79}
80
81function frontmatter(text) {
82 const m = /^---\n([\s\S]*?)\n---/.exec(text)
83 const out = {}
84 if (m) for (const line of m[1].split('\n')) {
85 const kv = /^([\w-]+):\s*(.*)$/.exec(line)
86 if (kv) out[kv[1]] = kv[2].trim()
87 }
88 return out
89}
90
91function field(text, name) {
92 const m = new RegExp(`^\\*\\*${name}:\\*\\*\\s*(.*)$`, 'm').exec(text)
93 const v = m ? m[1].trim() : ''
94 return v === '—' || v === '-' ? '' : v
95}
96
97// "03; cross-repo be/02" -> { same: ['03'], cross: ['be/02'] }. Parenthesised
98// asides are notes, and "None …" means no blockers.
99export function parseBlockers(value) {
100 if (!value || /^none\b/i.test(value)) return { same: [], cross: [] }
101 const v = value.replace(/\([^)]*\)/g, '')
102 const cross = [...v.matchAll(/\b(fe|be)\/(\d{2})\b/g)].map(m => `${m[1]}/${m[2]}`)
103 const same = [...v.replace(/\b(fe|be)\/\d{2}\b/g, '').matchAll(/\b(\d{2})\b/g)].map(m => m[1])
104 return { same, cross }
105}
106
107export function parseTicket(name, text) {
108 const num = (/^(\d{2})/.exec(name) ?? [])[1] ?? name.replace(/\.md$/, '')
109 const heading = (/^#\s+(.*)$/m.exec(text) ?? [])[1] ?? name
110 const title = heading.replace(/^\d{2}\s*[—–-]\s*/, '').replace(/^\[[^\]]+\]\s*/, '')
111 const status = field(text, 'Status')
112 const pr = field(text, 'PR')
113 return {
114 num,
115 title,
116 isDone: /^done\b/i.test(status),
117 status: status.split(/[\s(.]/)[0] || 'todo',
118 blockers: parseBlockers(field(text, 'Blocked by')),
119 linear: field(text, 'Linear'),
120 branch: field(text, 'Branch'),
121 base: field(text, 'Base'),
122 pr: (/\/pull\/(\d+)/.exec(pr) ?? [])[1] ?? '',
123 prStatus: field(text, 'PR status'),
124 }
125}
126
127async function readTickets(io, dir, side) {
128 const tdir = `${dir}/tickets/${side}`
129 if (!(await io.exists(tdir))) return []
130 const names = (await io.list(tdir))
131 .filter(e => e.kind === 'file' && e.name.endsWith('.md'))
132 .map(e => e.name)
133 .sort()
134 const out = []
135 for (const name of names) out.push(parseTicket(name, await io.read(`${tdir}/${name}`)))
136 return out
137}
138
139// Everything the panel and the commands show for the task at `loc`.
140export async function readTask(io, loc) {
141 const context = frontmatter(await io.read(`${loc.dir}/context.md`))
142 const mode = context.mode === 'story' ? 'story' : 'local'
143 const tickets = await readTickets(io, loc.dir, loc.side)
144 const done = new Set(tickets.filter(t => t.isDone).map(t => t.num))
145 const crossDone = new Set()
146 for (const other of SIDES.filter(s => s !== loc.side))
147 for (const t of await readTickets(io, loc.dir, other)) if (t.isDone) crossDone.add(`${other}/${t.num}`)
148 for (const t of tickets) {
149 t.waitingOn = [
150 ...t.blockers.same.filter(n => !done.has(n)),
151 ...t.blockers.cross.filter(c => !crossDone.has(c)),
152 ]
153 }
154 const open = tickets.filter(t => !t.isDone)
155 return {
156 ...loc,
157 mode,
158 tickets,
159 doneCount: done.size,
160 ready: open.filter(t => t.waitingOn.length === 0),
161 blocked: open.filter(t => t.waitingOn.length > 0),
162 waitingCross: [...new Set(open.flatMap(t => t.waitingOn.filter(w => w.includes('/'))))].sort(),
163 }
164}
165
166// Running FE dev servers, from the .shots/.fe-url files `wts` writes (and its
167// trap removes): [{ port, name }]. No lsof.
168export async function feServers(io, p) {
169 const dirs = []
170 if (await io.exists(p.wtRoot))
171 for (const e of await io.list(p.wtRoot))
172 if (e.kind === 'dir' && !e.name.startsWith('.')) dirs.push({ name: e.name, path: `${p.wtRoot}/${e.name}` })
173 dirs.push({ name: p.feRepo.split('/').pop(), path: p.feRepo })
174 const out = []
175 for (const d of dirs) {
176 const f = `${d.path}/.shots/.fe-url`
177 if (!(await io.exists(f))) continue
178 const port = (/:(\d+)/.exec(await io.read(f)) ?? [])[1]
179 if (port) out.push({ port, name: d.name })
180 }
181 return out.sort((a, b) => Number(a.port) - Number(b.port) || a.name.localeCompare(b.name))
182}
183
184export const holderOf = (servers, port) => servers.find(s => s.port === port)?.name
185
186// ---- text, shared by the band and the commands ---------------------------
187
188export function headline(task) {
189 const parts = [
190 `${task.slug} · ${task.side} · ${task.mode}`,
191 `tickets ${task.doneCount}/${task.tickets.length} done`,
192 ]
193 if (task.ready.length) parts.push(`wave: ${task.ready.map(t => t.num).join(', ')}`)
194 if (task.waitingCross.length) parts.push(`waiting: ${task.waitingCross.join(', ')}`)
195 return parts.join(' ')
196}
197
198export function prLine(task) {
199 const prs = task.tickets.filter(t => t.pr)
200 return prs.length ? `PRs: ${prs.map(t => `#${t.pr} ${t.prStatus || '?'}`).join(' · ')}` : ''
201}
202
203// `port` = the configured remote-API FE port (paths().remotePort), shared by one task at a time.
204export const portLine = (servers, port) => `:${port} → ${holderOf(servers, port) ?? 'free'}`
205
206export function ticketsText(task) {
207 const rows = [
208 ...task.ready.map(t => `ready ${t.num} ${t.title}`),
209 ...task.blocked.map(t => `blocked ${t.num} ${t.title} — by ${t.waitingOn.join(', ')}`),
210 ...task.tickets.filter(t => t.isDone).map(t => `done ${t.num} ${t.title}`),
211 ]
212 return [`${task.slug} · ${task.side} · ${task.mode} — ${task.doneCount}/${task.tickets.length} done`, ...rows].join('\n')
213}
214
215export function whoText(servers, port) {
216 const others = servers.filter(s => s.port !== port)
217 return [portLine(servers, port), ...others.map(s => `:${s.port} → ${s.name}`)].join('\n')
218}
219
220export function storyText(task) {
221 if (task.mode !== 'story') return `${task.slug}: mode ${task.mode} — /story is for story mode only`
222 return [
223 `${task.slug} · ${task.side} · story`,
224 ...task.tickets.map(t =>
225 [
226 t.num,
227 t.linear || '—',
228 t.branch || '—',
229 `base ${t.base || '—'}`,
230 t.pr ? `#${t.pr} ${t.prStatus || '?'}` : 'no PR',
231 ].join(' '),
232 ),
233 ].join('\n')
234}
235