SLOPSHOPPER

traffic-light

A band above the prompt says whether the session's work is done: green once its PR is merged into an integration branch and that push's pipeline passed, yellow…

newbandguardcommandpromptprocess
v1.26.0MITupdated 2026-10-24bahaospanov/skills/traffic-light
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · traffic-light
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /traffic-light ⎿ traffic-light: 🔴 working · no PR yet ● working no PR yet ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
● working no PR yet
README

bahaospanov

Bakhtiyar Ospanov's agent skills, and Claude Code mods: plugins built on function hooks.

Skills

For any agent the skills CLI supports:

npx skills add bahaospanov/skills --skill <skill>

Or in Claude Code, all of them as one plugin, invoked as /bahaospanov-skills:<skill>:

/plugin marketplace add bahaospanov/skills
/plugin install bahaospanov-skills@bahaospanov

Pick one: installing both leaves every skill twice.

User-invoked

Reachable only when you type them (Claude Code: disable-model-invocation: true; Codex: policy.allow_implicit_invocation: false in agents/openai.yaml).

  • release: Integration branch to production: fold fixup commits into their parent, cut waves around migrations and one-time steps, ship wave by wave with a runbook issue, then tag a SemVer release, note the shipped issues for you to close, and build a phone-sized release album.

Model-invoked

Model- or user-reachable.

  • playbook: How a change goes from issue to merged branch, in parts: issues, branches, commits, merge requests, cleanup. A project states its facts (base branch, issue number format, deviations) in a ### Playbook block of its AGENTS.md. Its MR description is mattpocock/skills pr (MIT), whose Summary visuals come from Dex Horthy's HumanLayer show-me (MIT).
  • presentation: Phone-sized slides of what changed for a product's users, one changed place per slide: its page URL and desktop before/after shots with the change outlined, rendered as 4:5 PNGs with a caption to post in a chat; release builds its release album with it.
  • prototype-stages: mattpocock/skills prototype (MIT), its UI branch reworked: whole flows, options grouped by stage in a one-click panel.
  • scheme: ASCII schematic of a code change: control flow, data flow, before/after, or layers.

Mods

Early access; the API changes between releases.

One mod per purpose. Why each one and each of its rules exists: STORY.md.

ModPurpose
git-gatesGit work happens on the user's word
git-tidyGit history stays tidy
git-cleanupMerged work is cleaned up once it shipped
issue-firstFeature work starts from an issue, and each commit names its own
lean-docsDocs worth keeping
lean-commentsComments worth keeping
lean-scriptsScripts worth keeping
close-countdownA Herdr pane counts down before it closes
traffic-lightThe band says whether the session's work is done

Haiku reviews are gated in code first, so a call that cannot fail the review costs no model call. End-of-turn checks read the git diff of repos the turn touched, Bash edits included, and show at most two one-line notices a session; the report reaches Claude with your next message and is acted on when you ask.

git-gates

Pushing is a deploy, so the agent needs the user's word in the current turn: the prompt that opened it or one typed while it ran. If a consent check itself fails, the call is blocked.

CheckRuns onNeedsThen
consentgit commit, push, with or without git's own options (git -C dir push); opening a PR/MR; PR/MR mergecommit, push, ship, deploy, pr, mr, tag or release typed in the current turn (a message typed while it runs or a background task's report does not withdraw it); a push needs one of them other than commit; opening needs pr, mr, pull request, merge request or ship; merge needs "merge", and "merge request" is not it; a protected branch must be named. A negated keyword ("don't push") does not count, and a no to the act itself refuses it, standing grant included, unless the turn also asks for itCall denied
grantsLater commits in the sessionA message asking for a commit per task, or the grant tool after an authorizing messageCommits spend the grant; pushes never

Protected branches come from a repo's own push policy file. Integration branches are the protected ones; with no policy, the remote's default branch and any of dev, develop, main, master that exist. Deleting a branch on origin needs no keyword when origin's head of it already sits in an integration branch.

git-tidy

The rules' text lives in the playbook; the commit-order, follow-up and feature refusals name the part.

CheckRuns onNeedsThen
messagesA git commitConventional Commits subject, no Co-Authored-By trailer naming Claude or Anthropic (in the message or a --trailer flag) and no "Generated with Claude Code" lineCommit denied
message adviceA git commitNo reviewer pre-answers ("no X changed"); then Haiku: a body only when the cause is subtleCommit runs; the user gets the advice
fresh baseOpening an MR/PR: gh pr create, glab mr create, a GitLab API POST to merge_requestsThe source branch, as origin has it, contains the target's latest commit, fetched first; skipped when the message says not to rebaseCall denied
landed branchA git pushThe branch's pushed head already sits in a protected branch, and the message names no new MRPush denied
every commit worksA git push of 2 to 15 commits no remote hasSonnet: no commit removes something a later one stops using, or uses something a later one adds; skipped when the message says the order is finePush denied
one commit per changeA git push of new commits to a branch whose commits not yet in an integration branch number 2 to 15, pushed or notNo fixup!, squash! or amend! subject; then Sonnet: no later commit spends most of its diff fixing, reworking or extending lines an earlier one added, or matches an earlier one's API contract change in another app; skipped when the message says to keep the commitsPush held: the agent shows the user the reason and asks; any answer lets the same commits through
one feature per MRA git push of new commits to a branch whose commits not yet in an integration branch number 1 to 15, pushed or notIts commit messages name at most one issue; then Sonnet: no piece of it could ship alone apart from the rest; skipped when the message says one MR or to keep them togetherPush held: the agent shows the user the features, recommends a branch and MR each, and asks; any answer lets the branch through for the session

Integration branches are found as git-gates finds them.

git-cleanup

Merged is not shipped: a branch is cleaned up and its issue filled in only once the pipeline holding the merge has passed. Closing the issue is left to you.

CheckRuns onNeedsThen
merged firstRemoving a branch, local or on origin; removing a worktree keeps its branch and is not heldThe branch sits in an integration branch, a merged PR/MR has it as source branch, or the current turn's message says it merged (or to abandon it)Call denied
pipeline firstRemoving a worktree or branch, local or on origin; rewriting an issue's body (checklist ticks, How to test)The work (the branch, the commit a squash merge landed as, or the newest integration commit naming the issue) landed and a pipeline holding it passed; skipped when the message says not to waitCall denied while it runs or after it failed; a GitLab pipeline parked at manual or skipped holds nothing back
stale workThe end of a turnA branch the session committed to or pushed that sits in an integration branch, its worktree clean, no pipeline holding it still running or failedRemoves the worktree and the branch, local and on origin, fast-forwards a local integration branch behind origin, and prints one line saying what went; the worktree the session works in stays

Integration branches are found as git-gates finds them. A branch sits in an integration branch when its head does, or when every commit of it has a copy there (a rebase merge). Pipelines are read with gh on GitHub and, on GitLab, with the gitlab_token option: a read_api token, asked when the plugin is enabled, kept in the keychain on macOS and in ~/.claude/.credentials.json elsewhere. With no token or no pipeline holding the work, the pipeline check holds nothing back and a log line says why; merged first still applies, and on GitLab sees a squash merge only with the token.

issue-first

CheckRuns onNeedsThen
feature issueA feat commitAn issue (#107, #BLK-23) at the end of the subject, or in the branch name (feat/catalog-menu-107), or "no issue" typed by the user in the current turnCommit denied; Claude looks for the issue, else drafts one and asks the user before creating it
issue numberA git commitThe issue or ticket your messages or the branch name, at the end of the subjectCommit runs; the user gets the suggestion

A repo with no remote has no issue tracker: feature issue is skipped, and only a ticket (#BLK-23) counts as an issue. Issues you typed bind only commits in the session's repo and its worktrees; a branch ending in its issue number (perf/mobile-lcp-89) lets the subject end with that one instead. Enabled in a repo's own .claude/settings.json, it applies to everyone working there.

lean-docs

CheckRuns onFlagsThen
docs-reviewA doc grown in a git checkoutHaiku: text nobody reads after the task (runbooks, setup pages, narration)Claude gets the reason
docs-no-repeat-codeA doc line being writtenIdentifiers that already appear together in one code fileClaude gets the hint; the write goes ahead
limit-docsThe end of a turnNew or grown docs, prose outweighing code, doc lines repeating codeA one-line notice; say "trim docs" to act on it

No check reads a skill's folder, the one holding SKILL.md, or anything under it: a skill is read again on every use.

Media and marketing work is out of scope too: a file its own checkout ignores (a local reels/ or renders/ folder) or one under a folder holding a .lean-skip file.

lean-comments

No comments by default: keep the ones that record a measured number, a trap or an invariant, cut the ones that restate the code or narrate the change.

CheckRuns onFlagsThen
limit-editsA Write or EditMore than 3 added comment lines, or a comment-heavy region around an edit that adds commentsClaude gets the guidance
limit-turnsThe end of a turnMore than 3 new comment lines per file in the turn's diffA one-line notice; say "trim comments" to act on it

No check reads a file installed under ~/.agents/skills, ~/.claude/skills or ~/.claude/plugins: it is someone else's code. A link from there into a checkout is followed, and the file is checked.

Media and marketing work is out of scope too: a file its own checkout ignores (a local reels/ or renders/ folder) or one under a folder holding a .lean-skip file.

lean-scripts

CheckRuns onFlagsThen
scripts-reviewA script written or grown in a git checkoutHaiku: scripts you could just type again when neededClaude gets the reason

A script is a shell file, a file in a scripts, bin, tools or ops folder, or one starting with a shebang. A skill's folder is skipped: the skill runs its scripts on every use.

Media and marketing work is out of scope too: a file its own checkout ignores (a local reels/ or renders/ folder) or one under a folder holding a .lean-skip file.

close-countdown

A Herdr subtask closing its own pane shows it first, so whoever watches can stop it.

PartWhat it does
close_pane_later toolCloses this session's Herdr pane, or pane, after seconds (default 60) and returns at once; note is shown beside the countdown
The band⏻ Closing this pane in 42s · <note> [ ✋ Keep open ] [ ❌ Close now ] above the prompt, a draining bar under it
CancelA message typed into the session, ✋ Keep open or /keep-open; ❌ Close now closes the pane at once

It runs herdr pane close, so the session needs herdr on its PATH and a Herdr pane (HERDR_PANE_ID).

traffic-light

A band above the prompt says whether you can leave the session.

LightWhen
🟢 safe to leaveThe PR is merged into dev, develop, main or master and every GitHub Actions run of that push passed; a run a newer push cancelled is judged by the newer one
🟡 waiting on PRThe PR is open, failing checks included, or merged with its pipeline still running
🔴 workingClaude is asking you, a turn is running where the light would be green, the repo holds uncommitted or unpushed work, there is no PR, it closed unmerged or merged elsewhere, the pipeline failed, or work began after the PR merged

The repo is the one Claude last edited a file in. A feature branch is judged by its own PR; on an integration branch, or once the worktree is gone, by the PR a gh pr command last printed. A turn that changes nothing, such as a question, ends green again. A turn that edits a file git does not ignore, or creates a commit, branch, worktree, issue or PR, leaves that merged PR unable to turn the light green: green waits for the new work's PR. The light refreshes after git and gh commands, and every 30 seconds while yellow. /traffic-light prints it; /traffic-light demo shows each color.

It runs git and gh, so the session needs both on its PATH and gh signed in.

Install mods

Mods load only with function hooks enabled, so export this in your shell profile first:

export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1

Without it Claude Code skips the mods silently. Then, in Claude Code:

/plugin marketplace add bahaospanov/skills
/plugin install <mod>@bahaospanov

Develop

One folder per mod. tsconfig.json and types/ are shared. An installed mod carries only its own folder, so code two mods share is copied into each one's hooks/shared/.

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir ./<mod> --debug

Saving a file under <mod>/hooks/ reloads the mod. Repeat --plugin-dir to load several.

Check

npm run typecheck                 # tsc over every mod and its tests
npm run check:shared              # hooks/shared/ copies are identical across mods
npm run check:continuations       # every mod sending follow-up prompts is named where git-gates reads them
npm run check:version             # every plugin.json carries package.json's version
claude plugin validate ./<mod>    # what the engine sees the module hook and call
claude plugin test ./<mod>        # the mod's tests/

Types

types/ is written by /plugin-types types, run inside a session started as above. Regenerate, never edit, when:

  • Claude Code updates (head -1 types/claude-code.d.ts vs claude --version)
  • a plugin that adds to $ is enabled or disabled
  • an MCP server is connected or disconnected

Commit the result; git diff types/ shows what the update changed.

Source 2 files
hooks/register.tsx 346 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ProcessRunResult, Register } from 'claude-code'
3
4import type { Color, Light } from '../types'
5
6type Engine = EngineInterface
7
8const INTEGRATION = ['dev', 'develop', 'main', 'master']
9const PR_URL = /https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)/
10const PR_COMMAND = /\bgh\s+pr\s+(create|view|merge|ready|checks|edit)\b/
11const GIT_COMMAND = /\b(git|gh)\s/
12const CHANGE_COMMAND =
13  /\b(git\s+(-C\s+\S+\s+)?(commit|revert|cherry-pick|worktree\s+add|checkout\s+-[bB]|switch\s+(-[cC]|--create))|gh\s+(pr|issue)\s+create)\b/
14const POLL_MS = 30_000
15const PIPELINE_START_MS = 3 * 60_000
16const DEMO_STEP_MS = 4_000
17
18const light = atom({ plugin: 'traffic-light', key: 'light' } as const, null)
19const pr = atom({ plugin: 'traffic-light', key: 'pr' } as const, null)
20const workDir = atom({ plugin: 'traffic-light', key: 'workDir' } as const, null)
21const spent = atom({ plugin: 'traffic-light', key: 'spent' } as const, [])
22
23const DOT: Record<Color, string> = { green: '🟢', yellow: '🟡', red: '🔴' }
24const HEADLINE: Record<Color, string> = {
25  green: 'safe to leave',
26  yellow: 'waiting on PR',
27  red: 'working',
28}
29const THEME = { green: 'success', yellow: 'warning', red: 'error' } as const
30
31const PENDING_CHECK = new Set(['QUEUED', 'IN_PROGRESS', 'PENDING', 'EXPECTED', 'WAITING', 'REQUESTED'])
32const FAILED_CHECK = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE'])
33const PASSED_RUN = new Set(['success', 'skipped', 'neutral'])
34
35type Check = { status?: string; conclusion?: string; state?: string }
36type Pr = {
37  number: number
38  url: string
39  state: 'OPEN' | 'CLOSED' | 'MERGED'
40  baseRefName: string
41  mergedAt: string | null
42  mergeCommit: { oid: string } | null
43  statusCheckRollup: Check[] | null
44}
45type Run = {
46  id: number
47  workflow_id: number
48  name: string
49  status: string
50  conclusion: string | null
51  created_at: string
52}
53
54// A green verdict's `pr` is the URL of the merged PR it stands for.
55type Verdict = Light & { pr?: string }
56
57const lit = (color: Color, reason: string): Light => ({ color, reason })
58const ASKING = lit('red', 'Claude is asking you')
59const WORKING = lit('red', 'Claude is working')
60const DEMO = [
61  lit('green', '#182 merged into dev, deploy-dev passed'),
62  lit('yellow', '#182 open, checks running'),
63  lit('red', 'Claude is asking you'),
64]
65
66let asking = false
67let working = false
68// The PR the last verdict judged green, and the one green when the running
69// turn began: the turn's first change spends that one.
70let greenPr: string | undefined
71let pending: string | undefined
72let isDemo = false
73let refreshing: Promise<void> | undefined
74let isStale = false
75
76async function run($: Engine, argv: string[], cwd?: string): Promise<ProcessRunResult | undefined> {
77  try {
78    return await $.process.run(argv, { cwd, timeoutMs: 20_000 })
79  } catch {
80    return undefined
81  }
82}
83
84async function gitRoot($: Engine, dir: string) {
85  const out = await run($, ['git', '-C', dir, 'rev-parse', '--show-toplevel'])
86  return out?.exitCode === 0 ? out.stdout.trim() : undefined
87}
88
89async function unfinished($: Engine, root: string) {
90  const status = await run($, ['git', '-C', root, 'status', '--porcelain'])
91  if (status?.exitCode === 0 && status.stdout.trim() !== '') return 'uncommitted changes'
92  const ahead = await run($, ['git', '-C', root, 'rev-list', '--count', '@{upstream}..HEAD'])
93  if (ahead?.exitCode === 0 && Number(ahead.stdout.trim()) > 0) return 'unpushed commits'
94  return undefined
95}
96
97async function featureBranch($: Engine, root: string) {
98  const out = await run($, ['git', '-C', root, 'branch', '--show-current'])
99  const name = out?.exitCode === 0 ? out.stdout.trim() : ''
100  return name === '' || INTEGRATION.includes(name) ? undefined : name
101}
102
103async function viewPr($: Engine, target: string, cwd?: string): Promise<Pr | 'none' | 'error'> {
104  const fields = 'number,url,state,baseRefName,mergedAt,mergeCommit,statusCheckRollup'
105  const out = await run($, ['gh', 'pr', 'view', target, '--json', fields], cwd)
106  if (out === undefined) return 'error'
107  if (out.exitCode !== 0) return /no (open )?pull requests? found/i.test(out.stderr) ? 'none' : 'error'
108  return JSON.parse(out.stdout) as Pr
109}
110
111function checksText(rollup: Check[] | null) {
112  // A CheckRun carries status + conclusion, a commit StatusContext only state.
113  const states = (rollup ?? []).map(
114    check => check.state ?? (check.status === 'COMPLETED' ? check.conclusion : check.status) ?? '',
115  )
116  if (states.some(state => PENDING_CHECK.has(state))) return 'checks running'
117  if (states.some(state => FAILED_CHECK.has(state))) return 'checks failing'
118  return 'waiting for merge'
119}
120
121async function listRuns($: Engine, path: string) {
122  const out = await run($, ['gh', 'api', path])
123  return out?.exitCode === 0 ? (JSON.parse(out.stdout) as { workflow_runs: Run[] }).workflow_runs : undefined
124}
125
126// A run cancelled by a newer push to the same branch (concurrency
127// cancel-in-progress) is settled by that newer run, which carries this commit.
128async function settle($: Engine, repo: string, base: string, one: Run) {
129  if (one.conclusion !== 'cancelled') return one
130  const runs = await listRuns($, `repos/${repo}/actions/workflows/${one.workflow_id}/runs?branch=${base}&event=push&per_page=1`)
131  const newest = runs?.[0]
132  return newest !== undefined && newest.created_at > one.created_at ? newest : one
133}
134
135async function pipeline($: Engine, repo: string, base: string, sha: string, mergedAt: string): Promise<Light> {
136  const runs = await listRuns($, `repos/${repo}/actions/runs?head_sha=${sha}&event=push`)
137  if (runs === undefined) return lit('red', "can't read the pipeline")
138  if (runs.length === 0) {
139    const age = (await $.clock.now()) - Date.parse(mergedAt)
140    return age < PIPELINE_START_MS ? lit('yellow', 'pipeline not started yet') : lit('green', 'no pipeline ran')
141  }
142  const settled = await Promise.all(runs.map(one => settle($, repo, base, one)))
143  const names = (list: Run[]) => [...new Set(list.map(one => one.name))].join(', ')
144  const running = settled.filter(one => one.status !== 'completed')
145  if (running.length > 0) return lit('yellow', `${names(running)} running`)
146  const failed = settled.filter(one => !PASSED_RUN.has(one.conclusion ?? ''))
147  if (failed.length > 0) return lit('red', `${names(failed)} ${failed[0]?.conclusion ?? 'failed'}`)
148  return lit('green', `${names(settled)} passed`)
149}
150
151async function prLight($: Engine, found: Pr): Promise<Light> {
152  const label = `#${found.number}`
153  if (found.state === 'OPEN') return lit('yellow', `${label} open, ${checksText(found.statusCheckRollup)}`)
154  if (found.state === 'CLOSED') return lit('red', `${label} closed without merging`)
155  if (!INTEGRATION.includes(found.baseRefName)) {
156    return lit('red', `${label} merged into ${found.baseRefName}, not dev or main`)
157  }
158  const repo = PR_URL.exec(found.url)
159  if (repo === null || found.mergeCommit === null || found.mergedAt === null) {
160    return lit('green', `${label} merged into ${found.baseRefName}`)
161  }
162  const after = await pipeline($, `${repo[1]}/${repo[2]}`, found.baseRefName, found.mergeCommit.oid, found.mergedAt)
163  return lit(after.color, `${label} merged into ${found.baseRefName}, ${after.reason}`)
164}
165
166async function judge($: Engine): Promise<Verdict> {
167  const dir = (await read($, workDir)) ?? (await $.session.cwd())
168  const root = await gitRoot($, dir)
169  const left = root === undefined ? undefined : await unfinished($, root)
170  if (left !== undefined) return lit('red', left)
171  const branch = root === undefined ? undefined : await featureBranch($, root)
172  const tracked = await read($, pr)
173  const found =
174    branch !== undefined ? await viewPr($, branch, root) : tracked !== null ? await viewPr($, tracked) : 'none'
175  if (found === 'error') return lit('red', "can't reach GitHub (gh)")
176  if (found === 'none') return lit('red', 'no PR yet')
177  const verdict = await prLight($, found)
178  if (verdict.color !== 'green') return verdict
179  if ((await read($, spent)).includes(found.url)) return lit('red', `new work since #${found.number} merged`)
180  return { ...verdict, pr: found.url }
181}
182
183async function show($: Engine, next: Light) {
184  await update($, light, () => next)
185}
186
187function face(next: Light) {
188  if (asking) return ASKING
189  return working && next.color === 'green' ? WORKING : next
190}
191
192function refresh($: Engine): Promise<void> {
193  if (isDemo) return Promise.resolve()
194  if (refreshing !== undefined) {
195    isStale = true
196    return refreshing
197  }
198  refreshing = (async () => {
199    do {
200      isStale = false
201      const { pr: merged, ...next } = await judge($).catch((): Verdict => lit('red', "couldn't read the work state"))
202      greenPr = merged
203      if (!isDemo) await show($, face(next))
204    } while (isStale)
205  })().finally(() => {
206    refreshing = undefined
207  })
208  return refreshing
209}
210
211function soon($: Engine) {
212  $.clock.after(1, () => void refresh($))
213}
214
215async function ask($: Engine) {
216  asking = true
217  if (!isDemo) await show($, ASKING)
218}
219
220function stopAsking($: Engine) {
221  if (!asking) return
222  asking = false
223  soon($)
224}
225
226async function remember($: Engine, file: string) {
227  const root = await gitRoot($, file.slice(0, Math.max(file.lastIndexOf('/'), 1)))
228  if (root === undefined) return
229  await update($, workDir, () => root)
230  soon($)
231  return root
232}
233
234async function edited($: Engine, file: string) {
235  const root = await remember($, file)
236  if (root === undefined || pending === undefined) return
237  const ignored = await run($, ['git', '-C', root, 'check-ignore', '-q', file])
238  if (ignored?.exitCode !== 0) await spend($)
239}
240
241async function spend($: Engine) {
242  const merged = pending
243  if (merged === undefined) return
244  pending = undefined
245  await update($, spent, list => [...list, merged])
246  soon($)
247}
248
249function demo($: Engine) {
250  isDemo = true
251  DEMO.forEach((step, i) => $.clock.after(1 + i * DEMO_STEP_MS, () => void show($, step)))
252  $.clock.after(DEMO.length * DEMO_STEP_MS, () => {
253    isDemo = false
254    void refresh($)
255  })
256}
257
258export const register: Register = on => {
259  on('session.start', async ($, e, next) => {
260    await $.command.register({
261      name: 'traffic-light',
262      description: 'Refresh the traffic light; "demo" shows green, yellow and red',
263    })
264    $.clock.every(POLL_MS, () => {
265      void read($, light).then(now => (now?.color === 'yellow' ? refresh($) : undefined))
266    })
267    soon($)
268
269    return next(e)
270  })
271
272  on('command.run', { command: 'traffic-light' }, async ($, e) => {
273    if (e.args.trim() === 'demo') {
274      demo($)
275      return { text: 'Showing green, yellow and red for 4 seconds each, then the real state.' }
276    }
277    await refresh($)
278    const now = await read($, light)
279
280    return { text: now === null ? 'No state yet.' : `${DOT[now.color]} ${HEADLINE[now.color]} · ${now.reason}` }
281  })
282
283  on('prompt.submit', ($, e, next) => {
284    asking = false
285    soon($)
286    return next(e)
287  })
288
289  on('turn.start', async ($, e, next) => {
290    if (e.agentId === undefined) {
291      working = true
292      pending = greenPr
293      if (!isDemo && (await read($, light))?.color === 'green') await show($, WORKING)
294    }
295    return next(e)
296  })
297
298  on('turn.complete', ($, e, next) => {
299    asking = false
300    if (e.agentId === undefined) {
301      working = false
302      pending = undefined
303    }
304    soon($)
305    return next(e)
306  })
307
308  on('classic.PermissionRequest', async ($, e, next) => {
309    await ask($)
310    return next(e)
311  })
312
313  on('tool.call', async ($, e, next) => {
314    if (e.tool === 'AskUserQuestion') await ask($)
315    const ran = await next(e)
316    try {
317      stopAsking($)
318      if (e.tool === 'Edit' || e.tool === 'Write') await edited($, e.file_path)
319      if (e.tool === 'NotebookEdit') await edited($, e.notebook_path)
320      if (e.tool === 'Bash') {
321        if (CHANGE_COMMAND.test(e.command)) await spend($)
322        const url = PR_COMMAND.test(e.command) ? PR_URL.exec(ran.text ?? '')?.[0] : undefined
323        if (url !== undefined) await update($, pr, () => url)
324        if (GIT_COMMAND.test(e.command)) soon($)
325      }
326    } catch {}
327
328    return ran
329  }).catch(($, e, next) => next(e))
330
331  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
332    const now = await read($, light)
333    if (now === null || e.props.hasSurvey) return next(e)
334    const { Box, Text } = $.ui.resolve(e)
335
336    return (
337      <Box>
338        <Text color={THEME[now.color]} weight="bold">
339          ● {HEADLINE[now.color]}
340        </Text>
341        <Text dimColor>  {now.reason}</Text>
342      </Box>
343    )
344  })
345}
346
types/index.d.ts 14 lines
1export type Color = 'green' | 'yellow' | 'red'
2export type Light = { color: Color; reason: string }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'traffic-light': {
7      light: Light | null
8      pr: string | null
9      workDir: string | null
10      spent: readonly string[]
11    }
12  }
13}
14