SLOPSHOPPER

workflow

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

newbandcommandtimer
A shopper browsing a rack in a slop shop
README

ai-coding-setup — a Claude Code workflow for two-repo (FE + BE) teams

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.

Layout

PathWhat
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.jsonexample 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.zshwt, wts, wtwho, wtrm, wtclean, wtdb, ablogin, friction, metrics
bin/wtconf (prints the effective config), pr-diagram-check, settings-sync
config.example.zshtemplate for your config.local.zsh

Requirements

  • Claude Code (recent; the mod needs CLAUDE_CODE_PLUGIN_DIRS support), macOS (osascript, BSD sed -i '')
  • jq, perl, git, zsh; gh ≥ 2.99 (--attach) for /pr
  • Linear MCP server (linear) for /kickoff, /bugfix and story mode
  • agent-browser CLI for UI verification and screenshots
  • optional: Docker with the backend's Postgres for per-worktree databases
  • optional: rtk (only hooks/rtk-guard.sh uses it — drop that hook if you don't use rtk)
  • optional: python3 with playwright for /pr diagrams

Install

Skills 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

settings.json

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.

Configure

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.

Third-party pieces (install them yourself)

The workflow calls these but does not ship them:

  • Matt Pocock's skills — /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).

  • diagram-design — optional diagrams in /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.
  • Your projects' own PR and change-summary skills (optional; /pr has a built-in fallback).

Leak guard

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.

License

See LICENSE.

Source 2 files
hooks/register.js 122 lines
1// 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}
122
hooks/reader.js 235 lines
1// 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