SLOPSHOPPER

workflow

Workflow skills, hooks, agents, and tools for Claude Code

guardprocesstimer
★ 1v1.103.0GPL-3.0-or-laterupdated 2026-10-24denormans/claude-code-workflow
A shopper browsing a rack in a slop shop
README

Claude Code Workflow

A Claude Code plugin that runs software work as a workflow: it shapes a request into a design, plans it, dispatches a team of implementer and reviewer agents, opens the pull request, answers review feedback, and cleans up after the merge. It ships as skills, hooks, agents, slash commands, and the TypeScript CLI tools those use.

This repository is a published snapshot. Development happens elsewhere, and each release lands here as a single commit. It does not take issues, pull requests or other contributions, but you're welcome to fork it and adapt it for your own use.

Requirements

Everything below is needed for a run with no optional integration configured:

  • Claude Code 2.1.290 or newer.
  • Agent teams turned on. Set "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1" in the env block of ~/.claude/settings.json.
  • tmux or iTerm2. Each teammate runs in its own pane. Team dispatch stops rather than run a team you can't see.
  • Node.js 22.22.2 or a later 22.x, 24.15 or a later 24.x, or 26 or newer, on PATH. The CLI tools run as node --experimental-transform-types, and their dependencies support only those versions.
  • npm. The plugin installs its CLI dependencies with it on first use.
  • git and the GitHub CLI (gh), signed in. The pull request steps use gh.
  • jq. The hooks use it to read their input.
  • Python 3 (python3). The command-guard hooks use it to parse shell commands, and without it they let every command through. bin/claude-sessions is a Python script.
  • ffmpeg on PATH, and network access the first time a walkthrough video renders. Each architecture doc gets a walkthrough video, rendered with HyperFrames: npx fetches it, and it uses an installed Chrome or downloads one.

Installation

1. Add the marketplace

claude plugin marketplace add https://github.com/denormans/claude-code-workflow.git

2. Install the plugin

claude plugin install workflow@denormans-workflow

3. Restart Claude Code

The plugin takes effect on the next session.

Updating

claude plugin update workflow@denormans-workflow

Restart Claude Code after updating.

Optional integrations

None of these is required. Each one changes what the workflow does when it is present:

IntegrationWhen it's configuredWithout it
Jira (an Atlassian MCP server, plus any JIRA_* or ATLASSIAN_* variable)Work is tracked on a ticket, and branches are named after itNo ticket; branches are named from a short description
Notion (a Notion MCP server, plus NOTION_PAGE_URL)Design docs are published to NotionDesign docs stay as local files
ElevenLabs (ELEVEN_LABS_API_KEY)Each architecture doc's walkthrough video is narratedThe video is silent, with the narration as subtitles
A macOS voice (WALKTHROUGH_MAC_VOICE)The walkthrough video is narrated by that voice, when ElevenLabs isn't configuredWith ELEVEN_LABS_API_KEY unset, Kokoro narrates it if WALKTHROUGH_KOKORO_VOICE is set; otherwise the video is silent, with the narration as subtitles
A Kokoro voice (WALKTHROUGH_KOKORO_VOICE)The walkthrough video is narrated by that local voice, when neither ElevenLabs nor a macOS voice is configuredWith the other two unset as well, the video is silent, with the narration as subtitles
The board (ai-workflow-app)Questions and approvals go to the boardThe workflow asks in the terminal
CodeRabbitIts review is part of the pull request loopOnly the agent review runs

The board is a separate application and is not published. Every variable the plugin reads is listed under Environment variables below.

Environment variables

Set these in the env block of a Claude Code settings file: your project's .claude/settings.local.json for a setting that belongs to one project, or ~/.claude/settings.json for every project. Only CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS is required. Leave the others unset unless you want what they do.

Claude Code

These are Claude Code's own settings. The workflow depends on what they do.

VariableWhat it does
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMSSet to 1 to turn on agent teams. Required.
BASH_MAX_TIMEOUT_MSControls the ceiling on how long Claude Code lets a command run when Claude asks for longer than the default. For a foreground command the ceiling is the larger of this and BASH_DEFAULT_TIMEOUT_MS, and 10 minutes out of the box, so a value under 10 minutes can lower it. In a session that limits background commands, a value above 2 hours raises their 2-hour maximum, and a lower value leaves that maximum in place. See Long-running sessions.
BASH_DEFAULT_TIMEOUT_MSClaude Code's timeout for a foreground command started without one: 2 minutes out of the box. The foreground ceiling is the larger of this and BASH_MAX_TIMEOUT_MS, so a value above 10 minutes can raise that ceiling too. In a session that limits background commands, it also replaces their default when set higher than it: 30 minutes, or 10 minutes in a -p run whose prompt is passed as text. Set above 2 hours, it raises the background maximum too. Prefer BASH_MAX_TIMEOUT_MS for long-running sessions. Claude Code's tools reference has the details.
CLAUDE_CONFIG_DIRClaude Code's configuration directory. Default: ~/.claude. The plugin reads team state from the same directory, so set it only if you have moved Claude Code's.

Integrations

VariableWhat it does
JIRA_*, ATLASSIAN_*Any variable whose name starts with either prefix and whose value is not blank turns Jira on. What the value says doesn't matter. Unset: no tickets.
NOTION_PAGE_URLThe Notion page design docs are published under. Unset: design docs stay as local files.
ELEVEN_LABS_API_KEYElevenLabs API key used to narrate an architecture doc's walkthrough video. Unset: a macOS voice narrates it if WALKTHROUGH_MAC_VOICE is set, else a Kokoro voice if WALKTHROUGH_KOKORO_VOICE is; otherwise the video is silent, with the narration as subtitles.
ELEVEN_LABS_VOICE_IDThe ElevenLabs voice for that narration. Default: the voice the CLI pins.
WALKTHROUGH_MAC_VOICEA macOS voice, named as say -v '?' lists it, that narrates the walkthrough video when ELEVEN_LABS_API_KEY is unset. Unset (and ELEVEN_LABS_API_KEY unset too): a Kokoro voice narrates it if WALKTHROUGH_KOKORO_VOICE is set; otherwise the video is silent, with the narration as subtitles. Premium voices (System Settings → Accessibility → Read & Speak → System voice → Manage Voices…; Spoken Content before macOS 26) sound far better than the built-in ones.
WALKTHROUGH_KOKORO_VOICEA Kokoro speaker, such as af_heart, that narrates the walkthrough video locally and for free when neither ELEVEN_LABS_API_KEY nor WALKTHROUGH_MAC_VOICE is set. Only American (a…) and British (b…) English speakers can be used; the list is Kokoro's VOICES.md. Needs uv (brew install uv). The first render downloads the model and its Python packages, about 2 GB and 6 minutes, measured on one cold run in October 2026; later renders reuse them and work offline.
AI_WORKFLOW_APP_BINPath to the board's ai-workflow-app binary. Default: ai-workflow-app found on PATH. If the path you give does not exist, the board is treated as absent; PATH is not searched instead.
PERMISSION_HOLD_DEADLINE_SWhen a tool call needs permission, the prompt is also posted to the board, and whichever answer comes first wins. This is how many seconds the board side waits. Default: 570. A value that is not a whole number below 600 uses the default.
PERMISSION_POLL_INTERVAL_SSeconds between checks for the board's answer to that prompt. Default: 3.

Test lock

Agents run their tests through test-lock (bin/test-lock), so that one user's agents on a machine take turns running tests. It reads these:

VariableWhat it does
TEST_LOCK_ROOTThe directory the lock is kept under, in a subdirectory private to you. Default: /tmp.
TEST_LOCK_BEAT_SECONDSSeconds between the heartbeats it sends the board for the agent's session (and a teammate's lead's) while its tests wait or run. Default: 300.
TEST_LOCK_DASHBOARDThe dashboard command it sends those heartbeats with. Default: the plugin's own bin/dashboard.
TEST_LOCK_HELDtest-lock sets it to 1 for the command it runs, so a test-lock inside that command does not wait on itself. Any non-empty value, even one that is only spaces, skips the lock, so leave it unset.

Renovate monitor

The Renovate monitor (the renovate-prs skill) merges Renovate's dependency-update pull requests once their CI passes. Major version bumps are never merged; it reports them to you instead. It reads these, and only RENOVATE_MONITOR_SCOPE is required to run it. The full behaviour is in skills/renovate-prs/references/monitor-runbook.md.

VariableWhat it does
RENOVATE_MONITOR_SCOPEWhich repositories to watch: a space- or comma-separated list of owner/* or owner/repo. Required; the monitor stops with an error without it.
RENOVATE_MONITOR_EXCLUDERepositories inside the scope to skip, in the same list form; owner/prefix-* also works. Default: none.
RENOVATE_MONITOR_AUTHORThe pull request author to watch. Default: app/renovate.
RENOVATE_MONITOR_BOT_AUTHORSMore logins to treat as bots, as a space- or comma-separated list. Bots are detected without it; this is a fallback. Default: none.
RENOVATE_MONITOR_POLL_SECONDSSeconds between polls. Default: 900.
RENOVATE_MONITOR_MAX_CI_RETRIESHow many times to retry CI that looks flaky before reporting the pull request to you. Default: 2.
RENOVATE_MONITOR_MERGE_DELAY_SECONDSMinimum seconds between two merges. Default: 120; 0 turns the delay off.
RENOVATE_MONITOR_HOLD_FOR_HUMAN_PRSSet to 1, true, yes or on to hold merges in a repository while a person has an open, non-draft pull request there. Default: off.
RENOVATE_MONITOR_HOLD_RELAX_HOURSHours when that hold is lifted, as START-END on a 24-hour clock, e.g. 0-8. Default: the hold always applies.
RENOVATE_MONITOR_HOLD_RELAX_TZTime zone for those hours, as an IANA name such as America/Chicago. Default: the machine's local time zone.
RENOVATE_MONITOR_STATE_FILEWhere the monitor keeps its state. Default: a file in the session's own store.

Working on the plugin

Only someone running the plugin's own test suite needs these.

VariableWhat it does
TEST_CONCURRENCYHow many test workers pnpm test runs at once. Default: a cap of at most 4, worked out from the machine's size. An explicit value is used as given.
DASHBOARD_INVOKE_TIMEOUT_MSMilliseconds each call to the board binary may take. Default: 5000; the test script sets 30000.
MAILBOX_CHECK_BUDGET_MSReplaces the session check's 100-second time budget. Used by tests only.

Long-running sessions

A hooks module in the plugin (hooks/wake.ts) wakes a waiting session: it checks the watched PRs and the board every few minutes while the session is idle. The Renovate monitor and gh pr checks --watch still run as background commands. In non-interactive sessions (and in every session in v2.1.285 through v2.1.287), Claude Code stops a background command after at most 2 hours by default; setting BASH_MAX_TIMEOUT_MS above 2 hours raises that ceiling. Set it in the env block of ~/.claude/settings.json, e.g. "BASH_MAX_TIMEOUT_MS": "86400000" for one day. Claude Code's tools reference has the details.

Working from a clone

pnpm install
claude --plugin-dir /path/to/your/clone

--plugin-dir loads the plugin from your clone for that session only.

DirectoryContents
skills/Markdown skill files
hooks/Claude Code hooks
agents/Agent definitions
commands/Slash commands
tools/TypeScript CLI tool packages (pnpm workspace)
bin/Shell wrappers for CLI tools
scripts/Standalone scripts too large to be a bin/ wrapper (launched through one)

pnpm typecheck, pnpm lint and pnpm test are the checks. CLAUDE.md has the conventions.

License

Copyright (C) 2026 Michael D. Norman

This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.

This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.

The full licence text is in LICENSE. Third-party material, and the licences of the packages this project depends on, are listed in THIRD-PARTY-NOTICES.md.

Source 1 files
hooks/wake.ts 148 lines
1import type { Register } from 'claude-code'
2
3// The session's own waker (FLOW-400). While the session is idle it asks `mailbox check` whether
4// anything should wake it, and starts a turn carrying whatever is new. It only schedules: what is
5// worth waking for is decided by the CLI. It also beats the board while the agent works.
6
7const TICK_MS = 60_000
8// Keep in step with CHECK_INTERVAL_MINUTES in tools/cli/src/pr-monitor/poll.ts, which the
9// pr-monitor command text and the wedge bound's idle-minutes figure derive from; the mod cannot
10// import it.
11const CHECK_INTERVAL_MS = 180_000
12// Keep CHECK_TOTAL_BUDGET_MS in tools/cli/src/mailbox/check.ts below this: a check that runs past it
13// is killed and delivers nothing.
14const CHECK_TIMEOUT_MS = 120_000
15// A beat is sent at most this often, and only after a tool call or turn end since the last one:
16// a beat must mean the agent is doing something (FLOW-321). The board marks a session hung after
17// 60 minutes without one.
18const BEAT_INTERVAL_MS = 300_000
19// `dashboard session heartbeat` invokes the board once per live leaf, 5s each.
20const BEAT_TIMEOUT_MS = 30_000
21// `mailbox check` exits 0 on a complete read and 3 on a partial one: there is no board binary, a board
22// read failed or its output was unparseable, or the registry was unreadable, so the answers listed
23// may be a subset of what is released.
24const PARTIAL_EXIT = 3
25// A check prints one entry per wake reason, each opening with a source tag; it indents an answer's
26// continuation lines, so only an entry's first line can open with a tag. Entries split before a known
27// tag at the start of a line.
28const ENTRY_START = /\n(?=\[(?:dashboard|pr-monitor|renovate-pr-monitor|team)\] )/
29const RELEASED_ITEM = /^\[dashboard\] item (\S+) /
30
31export const register: Register = (on) => {
32  // Module memory, so a reload starts all of these at their initial value: the next turn.start sets
33  // the turn state, and the next tool call re-arms the beat.
34  let isTurnRunning = false
35  let turnStarts = 0
36  let hasActivitySinceBeat = false
37  let lastBeatAt = Number.NEGATIVE_INFINITY
38
39  on('turn.start', ($, e, next) => {
40    isTurnRunning = true
41    turnStarts += 1
42    return next(e)
43  })
44
45  on('tool.call', ($, e, next) => {
46    hasActivitySinceBeat = true
47    return next(e)
48  })
49
50  // A subagent's turn ends carrying its agentId; only the main loop's end leaves the session idle.
51  on('turn.complete', ($, e, next) => {
52    hasActivitySinceBeat = true
53    if (e.agentId === undefined) {
54      isTurnRunning = false
55    }
56    return next(e)
57  })
58
59  on('session.start', async ($, e, next) => {
60    const started = await next(e)
61    // A `-p` run or the SDK has nobody at the prompt to wake.
62    if (!e.isInteractive) {
63      return started
64    }
65
66    let lastCheckAt = 0
67
68    $.clock.every(TICK_MS, async () => {
69      // Read every tick, not once: after a `/clear` or a resume the process goes on under another
70      // session id.
71      const sessionId = await $.session.id()
72      // Released answers stay released until consumed, so without this every check would re-deliver
73      // them. Kept per session in $.store, so a reload or restart does not re-deliver either.
74      const deliveredKey = `delivered-board-items:${sessionId}`
75      const now = await $.clock.now()
76      if (hasActivitySinceBeat && now - lastBeatAt >= BEAT_INTERVAL_MS) {
77        hasActivitySinceBeat = false
78        lastBeatAt = now
79        // A failed or timed-out beat is dropped, not retried: the next activity re-arms one, and a
80        // rejection here must not cost this tick its check.
81        await $.process
82          .run([`${$.plugin.root}/bin/dashboard`, 'session', 'heartbeat', '--session', sessionId], {
83            timeoutMs: BEAT_TIMEOUT_MS,
84          })
85          .catch(() => undefined)
86      }
87
88      if (isTurnRunning || now - lastCheckAt < CHECK_INTERVAL_MS) {
89        return
90      }
91
92      lastCheckAt = now
93      const turnStartsBefore = turnStarts
94      // Residual: a death line or a PR change is recorded when the check prints it (a death as
95      // reported, a PR change as the new baseline), so it is lost if the check then rejects at its
96      // timeout, or if the submit below rejects. An answer is not: it is re-printed until consumed.
97      const { exitCode, stdout } = await $.process.run(
98        [`${$.plugin.root}/bin/mailbox`, 'check', '--session', sessionId],
99        { timeoutMs: CHECK_TIMEOUT_MS }
100      )
101
102      const output = stdout.trim()
103      const entries = output === '' ? [] : output.split(ENTRY_START)
104      // Every entry that is not an answer is delivered whatever the exit code and whatever the
105      // session is doing: `$.prompt.submit` queues behind a running turn. A death line or a PR
106      // change is printed once and recorded as reported; a "could not be polled" line is never
107      // recorded and repeats each check until the PR polls again.
108      // Answer entries are re-printed until consumed, so a stale or unreliable read can wait.
109      const isAnswerReadUsable =
110        (exitCode === 0 || exitCode === PARTIAL_EXIT) &&
111        !isTurnRunning &&
112        turnStarts === turnStartsBefore
113      const delivered = new Set(asStringArray(await $.store.get(deliveredKey)))
114      const reportedIds = entries.flatMap((entry) => releasedItemId(entry) ?? [])
115      const wake = entries.filter((entry) => {
116        const id = releasedItemId(entry)
117        if (id === undefined) {
118          return true
119        }
120
121        return isAnswerReadUsable && !delivered.has(id)
122      })
123
124      if (wake.length > 0) {
125        await $.prompt.submit({ text: wake.join('\n') })
126      }
127
128      if (isAnswerReadUsable) {
129        // Recorded only after the submit, so one that rejects leaves the answer to be delivered by
130        // the next check. Only ids the check still reports are kept on a complete read: a consumed
131        // answer drops out, so the list stays as small as the board's open set. A partial read
132        // cannot tell consumed from unread, so it only adds.
133        await $.store.set(
134          deliveredKey,
135          exitCode === 0 ? reportedIds : [...new Set([...delivered, ...reportedIds])]
136        )
137      }
138    })
139
140    return started
141  })
142}
143
144const releasedItemId = (entry: string): string | undefined => RELEASED_ITEM.exec(entry)?.[1]
145
146const asStringArray = (value: unknown): string[] =>
147  Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
148