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…

Bakhtiyar Ospanov's agent skills, and Claude Code mods: plugins built on function hooks.
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.
Reachable only when you type them (Claude Code: disable-model-invocation: true; Codex: policy.allow_implicit_invocation: false in agents/openai.yaml).
Model- or user-reachable.
### 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).prototype (MIT), its UI branch reworked: whole flows, options grouped by stage in a one-click panel.Early access; the API changes between releases.
One mod per purpose. Why each one and each of its rules exists: STORY.md.
| Mod | Purpose |
|---|---|
| git-gates | Git work happens on the user's word |
| git-tidy | Git history stays tidy |
| git-cleanup | Merged work is cleaned up once it shipped |
| issue-first | Feature work starts from an issue, and each commit names its own |
| lean-docs | Docs worth keeping |
| lean-comments | Comments worth keeping |
| lean-scripts | Scripts worth keeping |
| close-countdown | A Herdr pane counts down before it closes |
| traffic-light | The 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.
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.
| Check | Runs on | Needs | Then |
|---|---|---|---|
| consent | git commit, push, with or without git's own options (git -C dir push); opening a PR/MR; PR/MR merge | commit, 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 it | Call denied |
| grants | Later commits in the session | A message asking for a commit per task, or the grant tool after an authorizing message | Commits 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.
The rules' text lives in the playbook; the commit-order, follow-up and feature refusals name the part.
| Check | Runs on | Needs | Then |
|---|---|---|---|
| messages | A git commit | Conventional Commits subject, no Co-Authored-By trailer naming Claude or Anthropic (in the message or a --trailer flag) and no "Generated with Claude Code" line | Commit denied |
| message advice | A git commit | No reviewer pre-answers ("no X changed"); then Haiku: a body only when the cause is subtle | Commit runs; the user gets the advice |
| fresh base | Opening an MR/PR: gh pr create, glab mr create, a GitLab API POST to merge_requests | The source branch, as origin has it, contains the target's latest commit, fetched first; skipped when the message says not to rebase | Call denied |
| landed branch | A git push | The branch's pushed head already sits in a protected branch, and the message names no new MR | Push denied |
| every commit works | A git push of 2 to 15 commits no remote has | Sonnet: no commit removes something a later one stops using, or uses something a later one adds; skipped when the message says the order is fine | Push denied |
| one commit per change | A git push of new commits to a branch whose commits not yet in an integration branch number 2 to 15, pushed or not | No 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 commits | Push held: the agent shows the user the reason and asks; any answer lets the same commits through |
| one feature per MR | A git push of new commits to a branch whose commits not yet in an integration branch number 1 to 15, pushed or not | Its 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 together | Push 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.
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.
| Check | Runs on | Needs | Then |
|---|---|---|---|
| merged first | Removing a branch, local or on origin; removing a worktree keeps its branch and is not held | The 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 first | Removing 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 wait | Call denied while it runs or after it failed; a GitLab pipeline parked at manual or skipped holds nothing back |
| stale work | The end of a turn | A branch the session committed to or pushed that sits in an integration branch, its worktree clean, no pipeline holding it still running or failed | Removes 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.
| Check | Runs on | Needs | Then |
|---|---|---|---|
| feature issue | A feat commit | An 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 turn | Commit denied; Claude looks for the issue, else drafts one and asks the user before creating it |
| issue number | A git commit | The issue or ticket your messages or the branch name, at the end of the subject | Commit 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.
| Check | Runs on | Flags | Then |
|---|---|---|---|
| docs-review | A doc grown in a git checkout | Haiku: text nobody reads after the task (runbooks, setup pages, narration) | Claude gets the reason |
| docs-no-repeat-code | A doc line being written | Identifiers that already appear together in one code file | Claude gets the hint; the write goes ahead |
| limit-docs | The end of a turn | New or grown docs, prose outweighing code, doc lines repeating code | A 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.
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.
| Check | Runs on | Flags | Then |
|---|---|---|---|
| limit-edits | A Write or Edit | More than 3 added comment lines, or a comment-heavy region around an edit that adds comments | Claude gets the guidance |
| limit-turns | The end of a turn | More than 3 new comment lines per file in the turn's diff | A 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.
| Check | Runs on | Flags | Then |
|---|---|---|---|
| scripts-review | A script written or grown in a git checkout | Haiku: scripts you could just type again when needed | Claude 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.
A Herdr subtask closing its own pane shows it first, so whoever watches can stop it.
| Part | What it does |
|---|---|
close_pane_later tool | Closes 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 |
| Cancel | A 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).
A band above the prompt says whether you can leave the session.
| Light | When |
|---|---|
| 🟢 safe to leave | The 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 PR | The PR is open, failing checks included, or merged with its pipeline still running |
| 🔴 working | Claude 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.
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
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.
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/ is written by /plugin-types types, run inside a session started as above. Regenerate, never edit, when:
head -1 types/claude-code.d.ts vs claude --version)$ is enabled or disabledCommit the result; git diff types/ shows what the update changed.
hooks/register.tsx 346 lines1import { 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}
346types/index.d.ts 14 lines1export 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