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

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.
Everything below is needed for a run with no optional integration configured:
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1" in the env block of ~/.claude/settings.json.PATH. The CLI tools run as node --experimental-transform-types, and their dependencies support only those versions.gh), signed in. The pull request steps use gh.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.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.claude plugin marketplace add https://github.com/denormans/claude-code-workflow.git
claude plugin install workflow@denormans-workflow
The plugin takes effect on the next session.
claude plugin update workflow@denormans-workflow
Restart Claude Code after updating.
None of these is required. Each one changes what the workflow does when it is present:
| Integration | When it's configured | Without it |
|---|---|---|
Jira (an Atlassian MCP server, plus any JIRA_* or ATLASSIAN_* variable) | Work is tracked on a ticket, and branches are named after it | No ticket; branches are named from a short description |
Notion (a Notion MCP server, plus NOTION_PAGE_URL) | Design docs are published to Notion | Design docs stay as local files |
ElevenLabs (ELEVEN_LABS_API_KEY) | Each architecture doc's walkthrough video is narrated | The 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 configured | With 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 configured | With 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 board | The workflow asks in the terminal |
| CodeRabbit | Its review is part of the pull request loop | Only 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.
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.
These are Claude Code's own settings. The workflow depends on what they do.
| Variable | What it does |
|---|---|
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS | Set to 1 to turn on agent teams. Required. |
BASH_MAX_TIMEOUT_MS | Controls 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_MS | Claude 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_DIR | Claude 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. |
| Variable | What 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_URL | The Notion page design docs are published under. Unset: design docs stay as local files. |
ELEVEN_LABS_API_KEY | ElevenLabs 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_ID | The ElevenLabs voice for that narration. Default: the voice the CLI pins. |
WALKTHROUGH_MAC_VOICE | A 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_VOICE | A 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_BIN | Path 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_S | When 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_S | Seconds between checks for the board's answer to that prompt. Default: 3. |
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:
| Variable | What it does |
|---|---|
TEST_LOCK_ROOT | The directory the lock is kept under, in a subdirectory private to you. Default: /tmp. |
TEST_LOCK_BEAT_SECONDS | Seconds 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_DASHBOARD | The dashboard command it sends those heartbeats with. Default: the plugin's own bin/dashboard. |
TEST_LOCK_HELD | test-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. |
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.
| Variable | What it does |
|---|---|
RENOVATE_MONITOR_SCOPE | Which 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_EXCLUDE | Repositories inside the scope to skip, in the same list form; owner/prefix-* also works. Default: none. |
RENOVATE_MONITOR_AUTHOR | The pull request author to watch. Default: app/renovate. |
RENOVATE_MONITOR_BOT_AUTHORS | More 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_SECONDS | Seconds between polls. Default: 900. |
RENOVATE_MONITOR_MAX_CI_RETRIES | How many times to retry CI that looks flaky before reporting the pull request to you. Default: 2. |
RENOVATE_MONITOR_MERGE_DELAY_SECONDS | Minimum seconds between two merges. Default: 120; 0 turns the delay off. |
RENOVATE_MONITOR_HOLD_FOR_HUMAN_PRS | Set 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_HOURS | Hours 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_TZ | Time zone for those hours, as an IANA name such as America/Chicago. Default: the machine's local time zone. |
RENOVATE_MONITOR_STATE_FILE | Where the monitor keeps its state. Default: a file in the session's own store. |
Only someone running the plugin's own test suite needs these.
| Variable | What it does |
|---|---|
TEST_CONCURRENCY | How 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_MS | Milliseconds each call to the board binary may take. Default: 5000; the test script sets 30000. |
MAILBOX_CHECK_BUDGET_MS | Replaces the session check's 100-second time budget. Used by tests only. |
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.
pnpm install
claude --plugin-dir /path/to/your/clone
--plugin-dir loads the plugin from your clone for that session only.
| Directory | Contents |
|---|---|
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.
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.
hooks/wake.ts 148 lines1import 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