A board for a sentinel-hill hub: every ticket worktree with its PR, checks and next step, the validate run, and guard rails that enforce the hub's steering

My Claude Code mods, one folder per mod. Needs Claude Code 2.1.287 or newer.
| Mod | What it does |
|---|---|
| usage-band | A band above the prompt showing context fill, tokens, cost and rate limits: stat tiles in the desktop app, a coloured row in the terminal |
| plan-progress | Reads a phased plan into a live progress tree: phases, steps, elapsed time and an estimate of what is left |
| test-user | A Haiku test user that clicks through your locally running app and reports UI/UX issues: its task list, findings and failures in a live pane |
| change-card | A short card explaining what an agent changed on this branch: a headline, a few one-line changes, where to look first and what was not checked |
| control-tower | A board for a sentinel-hill hub: every ticket worktree with its PR, checks and next step, the validate run above the prompt, and guard rails that enforce the hub's steering |
All five mods share one look, dark only: square slate panels on a navy ground, hairline rules, small spaced capitals for labels, light wide numerals, arc gauges and square-ended segment tracks. Amber marks what needs a look or is under way, periwinkle what is done, red what failed. In the desktop app's Code tab they draw as images, in Barlow where it is installed and the system sans otherwise; in the terminal they use the same colours as text.

At the prompt of a terminal Claude Code session:
/plugin install usage-band --marketplace WeaponizedLego/claude-code-mods
/plugin install plan-progress --marketplace WeaponizedLego/claude-code-mods
Answer y to add the marketplace, then pick the user scope. Or from a shell:
claude plugin marketplace add WeaponizedLego/claude-code-mods
claude plugin install usage-band@claude-code-mods
claude plugin install plan-progress@claude-code-mods
Update later with claude plugin marketplace update claude-code-mods, then claude plugin update usage-band@claude-code-mods.

When a plan with several phases is approved in plan mode, the mod reads its phases and steps (## Phase 1: ... headings, other work headings, or a nested list) into a tree and opens it in a pane. In the terminal it looks like this:
Move auth to sessions 20m in · ~40m left
━━━━━━━━━━━━━━━━━━ 33% 2/6 steps · phase 2/3
✓ 1 Setup 20m
├ ✓ Add the sessions table 10m
└ ✓ Write the session store 10m
● 2 Switch over
├ ● Replace token checks in the middleware
├ ○ Update the login endpoint
└ ○ Remove the old token helpers
○ 3 Verify
└ ○ Run the auth test suite
The status line carries the short form (▰▰▱▱ 2/6 · phase 2/3: Switch over · 20m in · ~40m left). Steps move when Claude calls the mod's plan_mark tool, or by themselves when Claude's tasks or todos are named like a step. Without plan mode, ask Claude to lay the work out in phases and it calls plan_set. The estimate is the pace so far times the steps left.
During a run of small steps Claude can forget to mark them. When the tree hasn't moved for a while (at least 6 tool calls, and 3 minutes or 1.5× the pace so far, whichever is longer), the next tool result reminds Claude which step the tree shows. Claude can then catch up in one call: plan_mark with through: true closes every earlier open step and splits the elapsed time evenly between them.
/plan-progress opens the tree (it also works mid-turn); /plan-progress clear stops tracking.

A test user for the app you are running locally. Ask Claude to "test the app" (or a feature, a page, a flow) and it hands the job to test-user:tester, a subagent on Haiku that drives the app in the browser the way a person would: it works out what to test, finds the app (the URL you gave, .claude/launch.json, package.json), plans a handful of user tasks, works through them and reports each problem it sees. It never edits code, stays on localhost and only uses test data.
When you just say "test the app", Claude points it at the feature most recently built in the session; with nothing built, the tester looks at git diff and git log for a recent feature, and failing that tests the whole application.
The pane shows the run as it goes: status, tasks passed out of total, a count per severity (blocker, major, minor, polish), the task list with a note on each failure, a card per finding (worst first, with where and how to reproduce), a red card when it could not test at all, and earlier runs folded at the bottom. The status line carries the short form (test-user ● 3/6 tasks · 2 findings · 4m · Pay with the test card) and a toast says how it ended.
The session gets the full record, not just the tester's closing message: it rides back on the Agent result when the tester ran in the foreground, and on the next prompt (or the background task's notification) otherwise; test_report reads it any time. A tester whose turn ends early and is resumed carries on with the same run.
Claude gets the same reporting tools (test_plan, test_task, test_finding, test_finish), so a test it runs by hand shows in the pane too, and it can amend the latest tester run with what it re-checked. If the tester stops without a verdict, the record decides it; a "passed" never hides a failed task.
/test-user opens the pane; /test-user run [what to test] starts a run; /test-user clear forgets the runs.

/changes opens a Changes pane and asks Claude to explain what changed on the branch since it forked from origin/HEAD (or main/master), uncommitted and untracked files included. Run from a folder that is not itself a repository but holds checkouts (a task folder with one worktree per repo), it reads each of them and prefixes their paths with the folder's name. Claude answers by filling in the mod's change_card tool, which keeps the card short by refusing anything over its limits: a 70-character headline, a one-sentence gist, at most 5 one-line changes (with an optional before → after), at most 3 places to look first and 3 things nobody checked. Every changed file has to sit under one of the changes (a path ending in / covers a folder), so nothing hides.
When the code moves on after the card was written, the pane says which files and /changes asks Claude to update just what they affect. After a turn that leaves a sizeable diff (over 5 files or 200 lines) a toast suggests /changes, once per card; nothing is written unasked.
/changes <worktree or branch> explains that one instead of the session's own checkout: a worktree by its path, folder name, branch or any unique part of those (/changes PLAT-421), or a local branch at its tip. When the session's checkout has nothing changed (say, main with the work in .claude/worktrees/), /changes lists the worktrees that do. /changes <ref> compares against another ref; /changes clear forgets the card.

For a sentinel-hill hub: a repo you run agents from, with a repos.yaml registry, scripts/worktree.sh and one worktree per ticket under work/<repo>/<KEY>-<slug>. The mod does nothing in any other folder.
The pane opens when a session starts in the hub (or with /tower). It reads every worktree under work/ from disk and asks gh about its PR: one card per ticket, those that need you first, each with a big number (unresolved review threads, checks passed, commits ahead, time since merge) beside a five-stop track (start, build, PR, feedback, close). Under each card are buttons for the next step: Review and Finish while you build, Handle feedback when a PR has unresolved threads, Fix checks when they fail, Close task once it is merged. They run the hub's own skills (/review, /task-finish, /handle-pr-feedback, /task-close). A field at the bottom starts a ticket with /task-start or /implement-ticket. The board syncs every 3 minutes and after a push or gh pr create; a toast says when a PR gets new review threads or is merged.
Above the prompt, a band shows the repo's validate commands from repos.yaml as Claude runs them (done out of total, one bar per command, what is running or failed), or says when the hub is not set up (repos.local.yaml missing, or a gh token without read:packages) with a button that walks you through it. The status line names the ticket Claude is working in: ● OPS-123 · platform-apps · base develop · unattended.

Guard rails refuse tool calls that break the hub's steering, with the reason as the tool result so Claude fixes the call and carries on:
git add -A, ., -u and the like: add files by namecommit_format with the branch's key, or that carries a Co-Authored-By or "Generated with" linegit mergegh pr create from a ticket worktree without --draft, or without the label and assignee the repo's pr_labels and pr_assignee ask forrepos.local.yaml (the worktree is the place), or into /tmp (use work/scratch/)The Unattended and Interactive buttons set the hub's mode: in interactive mode each prompt reminds Claude to stop for approval after the plan. /tower refresh syncs now; /tower interactive and /tower unattended set the mode.
Put it in its own folder (<mod>/.claude-plugin/plugin.json, <mod>/hooks/...) and add an entry to .claude-plugin/marketplace.json. Check it with claude plugin validate ..
hooks/register.tsx 445 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Board, Mode, Task, Validation } from '../types'
5import { checkBash, checkWrite } from './guards'
6import type { GuardContext } from './guards'
7import { findHub, loadRegistry, needsYou, scan, stageOf, taskOf, worktreeIn } from './hub'
8import type { Io, Registry } from './hub'
9import { C, emptySvg, footerSvg, headerSvg, metaOf, setupSvg, shortCommand, statOf, taskSvg, validationSvg } from './look'
10
11const PANE = 'control-tower'
12const TITLE = 'Control Tower'
13const SYNC_MS = 180_000
14const TICK_MS = 60_000
15// Roughly one cell of the desktop's code font, in CSS pixels.
16const CELL_PX = 8
17const KEY = /^[A-Z][A-Z0-9]+-\d+$/
18
19const boardAtom = atom({ plugin: 'control-tower', key: 'board' } as const, null)
20const syncingAtom = atom({ plugin: 'control-tower', key: 'isSyncing' } as const, false)
21const validationAtom = atom({ plugin: 'control-tower', key: 'validation' } as const, null)
22const modeAtom = atom({ plugin: 'control-tower', key: 'mode' } as const, 'unattended')
23const activeAtom = atom({ plugin: 'control-tower', key: 'active' } as const, null)
24const blockedAtom = atom({ plugin: 'control-tower', key: 'blocked' } as const, 0)
25const nowAtom = atom({ plugin: 'control-tower', key: 'now' } as const, 0)
26const draftAtom = atom({ plugin: 'control-tower', key: 'draft' } as const, '')
27
28// The engine calls the hub's scan needs, every failure read as "not there".
29const ioOf = ($: EngineInterface): Io => ({
30 cwd: () => $.session.cwd(),
31 now: () => $.clock.now(),
32 exists: path => $.fs.exists(path).catch(() => false),
33 read: path => $.fs.read(path).catch(() => ''),
34 list: path => $.fs.list(path).catch(() => []),
35 run: (argv, cwd, timeoutMs = 20_000) => $.process.run(argv, { cwd, timeoutMs }).catch(() => ({ exitCode: 1, stdout: '', stderr: '' })),
36})
37
38// The registry files, read once per load and again at each sync.
39let registry: Registry | null = null
40async function registryOf($: EngineInterface, hub: string): Promise<Registry> {
41 return (registry ??= await loadRegistry(ioOf($), hub))
42}
43
44async function setStatus($: EngineInterface) {
45 const board = await read($, boardAtom)
46 if (!board) return $.ui.status(undefined)
47 const [active, mode] = await Promise.all([read($, activeAtom), read($, modeAtom)])
48 const task = board.tasks.find(t => t.path === active)
49 const where = active ? taskOf(active, board.hub) : null
50 if (where) {
51 $.ui.status(`● ${where.key} · ${where.repo} · base ${task?.base ?? '…'} · ${mode}`)
52 return
53 }
54 const need = board.tasks.filter(needsYou).length
55 const setup = !board.setup.hasLocalRepos || board.setup.hasPackagesScope === false ? ' · setup incomplete' : ''
56 $.ui.status(`control tower · ${board.tasks.length} tasks${need ? ` · ${need} need you` : ''}${setup}`)
57}
58
59// What changed between two syncs that is worth a toast: new review threads, a merge.
60function news(before: Task[], after: Task[]): string[] {
61 const out: string[] = []
62 for (const t of after) {
63 const old = before.find(b => b.path === t.path)
64 if (!old || !t.pr) continue
65 if (t.pr.unresolved > (old.pr?.unresolved ?? 0)) out.push(`${t.key} has ${t.pr.unresolved} unresolved review ${t.pr.unresolved === 1 ? 'thread' : 'threads'}`)
66 if (old.pr?.state === 'OPEN' && t.pr.state === 'MERGED') out.push(`${t.key} merged: close the task to remove its worktree`)
67 }
68 return out
69}
70
71async function sync($: EngineInterface) {
72 const board = await read($, boardAtom)
73 if (!board || (await read($, syncingAtom))) return
74 await update($, syncingAtom, () => true)
75 try {
76 registry = await loadRegistry(ioOf($), board.hub)
77 const next = await scan(ioOf($), board.hub, registry)
78 const before = (await read($, boardAtom))?.tasks ?? []
79 await update($, boardAtom, () => next)
80 if (board.syncedAt > 0) for (const line of news(before, next.tasks)) $.ui.toast(`Control Tower: ${line}`)
81 } finally {
82 await update($, syncingAtom, () => false)
83 const t = await $.clock.now()
84 await update($, nowAtom, () => t)
85 await setStatus($)
86 }
87}
88
89async function openPane($: EngineInterface) {
90 await $.ui.open({ id: PANE, title: TITLE }).catch(() => ({ isPlaced: false }))
91}
92
93// A hub skill by its slash command; a plain prompt where the command cannot run.
94async function runSkill($: EngineInterface, name: string, args: string) {
95 try {
96 await $.command.run({ command: name, args })
97 } catch {
98 await $.prompt.submit({ text: `/${name} ${args}`.trim(), asUser: true })
99 }
100}
101
102async function startTicket($: EngineInterface, skill: 'task-start' | 'implement-ticket') {
103 const key = (await read($, draftAtom)).trim().toUpperCase()
104 if (!KEY.test(key)) {
105 $.ui.toast('Control Tower: type a ticket key like OPS-123 first')
106 return
107 }
108 await update($, draftAtom, () => '')
109 await runSkill($, skill, key)
110}
111
112async function setMode($: EngineInterface, mode: Mode) {
113 await update($, modeAtom, () => mode)
114 await setStatus($)
115}
116
117const SETUP_PROMPT =
118 'Set up this sentinel-hill hub. Ask me which folder holds my clones of the repos in repos.yaml, run scripts/setup-local-repos.sh with it, ' +
119 'then run scripts/setup-github-auth.sh. If gh lacks the read:packages scope, tell me to run `gh auth refresh -s read:packages` myself.'
120
121async function guardContext($: EngineInterface, board: Board): Promise<GuardContext> {
122 const prefixes = new Set(board.tasks.map(t => t.key.split('-')[0]!))
123 const active = await read($, activeAtom)
124 if (active) prefixes.add(taskOf(active, board.hub)?.key.split('-')[0] ?? '')
125 prefixes.delete('')
126 return { hub: board.hub, registry: await registryOf($, board.hub), active, keyPrefixes: [...prefixes] }
127}
128
129async function refuse($: EngineInterface, why: string) {
130 await update($, blockedAtom, n => n + 1)
131 return { deny: why }
132}
133
134// Remembers the worktree a tool call names, for the status line and a bare `git commit`.
135async function touch($: EngineInterface, board: Board, text: string) {
136 const path = worktreeIn(text, board.hub) ?? (text.startsWith(`${board.hub}/work/`) ? text : null)
137 const where = path ? taskOf(path, board.hub) : null
138 if (!where) return
139 const worktree = `${board.hub}/work/${where.repo}/${where.folder}`
140 if ((await read($, activeAtom)) === worktree) return
141 await update($, activeAtom, () => worktree)
142 await setStatus($)
143}
144
145const collapse = (s: string) => s.trim().replace(/\s+/g, ' ')
146
147/** The validate commands a Bash call runs, and the repo whose list they belong to. */
148function validateRun(command: string, reg: Registry, activeRepo: string | undefined): { repo: string; commands: string[] } | null {
149 const parts = command.split(/&&|;|\n/).map(collapse)
150 const repos = Object.entries(reg.repos).sort(([a], [b]) => Number(b === activeRepo) - Number(a === activeRepo))
151 for (const [repo, facts] of repos) {
152 const list = facts.validate.map(collapse)
153 const commands = parts.filter(p => list.includes(p))
154 if (commands.length) return { repo, commands }
155 }
156 return null
157}
158
159async function markChecks($: EngineInterface, repo: string, commands: string[], status: Validation['checks'][number]['status']) {
160 const reg = registry
161 if (!reg) return
162 await update($, validationAtom, v => {
163 const base: Validation =
164 v && v.repo === repo ? v : { repo, checks: (reg.repos[repo]?.validate ?? []).map(command => ({ command: collapse(command), status: 'pending' as const })) }
165 return { ...base, checks: base.checks.map(c => (commands.includes(c.command) ? { ...c, status } : c)) }
166 })
167}
168
169const isHub = async ($: EngineInterface) => (await read($, boardAtom)) !== null
170
171export const register: Register = on => {
172 on('session.start', async ($, e, next) => {
173 const result = await next(e)
174 const hub = await findHub(ioOf($))
175 if (!hub) {
176 await update($, boardAtom, () => null)
177 return result
178 }
179
180 await $.command.register({
181 name: 'tower',
182 description: 'Show the Control Tower board (refresh: sync now; interactive or unattended: set the mode)',
183 argumentHint: '[refresh | interactive | unattended]',
184 immediate: true,
185 })
186
187 // After a reload the board is still in the session's state; a new session starts from disk.
188 const kept = await read($, boardAtom)
189 if (!kept || kept.hub !== hub) {
190 await update($, boardAtom, () => ({ hub, tasks: [], setup: { hasLocalRepos: true, hasPackagesScope: null }, syncedAt: 0 }))
191 }
192 registry = null
193 void sync($)
194 $.clock.every(SYNC_MS, () => void sync($))
195 $.clock.every(TICK_MS, () => void $.clock.now().then(t => update($, nowAtom, () => t)))
196 if (!kept) await openPane($)
197 await setStatus($)
198 return result
199 })
200
201 on('command.run', { command: 'tower' }, async ($, e) => {
202 if (!(await isHub($))) return { text: 'Control Tower works in a sentinel-hill hub: start the session in the folder that holds repos.yaml.' }
203 const arg = e.args.trim().toLowerCase()
204 if (arg === 'interactive' || arg === 'unattended') {
205 await setMode($, arg)
206 return { text: `Mode: ${arg}.` }
207 }
208 await openPane($)
209 void sync($)
210 return { text: arg === 'refresh' ? 'Syncing the board.' : 'Control Tower is open.' }
211 })
212
213 // Interactive mode adds the approval gates AGENTS.md describes for "interactive".
214 on('prompt.submit', async ($, e, next) => {
215 const board = await read($, boardAtom)
216 if (!board) return next(e)
217 await update($, validationAtom, () => null)
218 if ((await read($, modeAtom)) !== 'interactive') return next(e)
219 const note = 'Control Tower: interactive mode is on. After writing a task plan, show it and stop for approval before implementing (the hub\'s "interactive" mode).'
220 return next({ ...e, context: [...(e.context ?? []), note] })
221 }).catch(($, e, next) => next(e))
222
223 on('tool.call', async ($, e, next) => {
224 const board = await read($, boardAtom)
225 const input = e as unknown as { file_path?: string; path?: string; command?: string }
226 const text = input.file_path ?? input.path ?? input.command
227 if (board && typeof text === 'string') await touch($, board, text)
228 return next(e)
229 }).catch(($, e, next) => next(e))
230
231 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
232 const board = await read($, boardAtom)
233 if (!board) return next(e)
234 const why = checkBash(e.command, await guardContext($, board))
235 if (why) return refuse($, why)
236
237 const active = await read($, activeAtom)
238 const run = validateRun(e.command, await registryOf($, board.hub), active ? taskOf(active, board.hub)?.repo : undefined)
239 if (run) await markChecks($, run.repo, run.commands, 'running')
240 const ran = await next(e)
241 if (run) await markChecks($, run.repo, run.commands, ran.deny !== undefined ? 'pending' : ran.isError ? 'failed' : 'passed')
242 if (/\bgh pr (create|ready|edit)\b|\bgit push\b|worktree\.sh (add|remove)/.test(e.command)) void sync($)
243 return ran
244 }).catch(($, e, next) => next(e))
245
246 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
247 const board = await read($, boardAtom)
248 if (!board) return next(e)
249 const why = checkWrite(e.file_path, e.content, '', await guardContext($, board))
250 return why ? refuse($, why) : next(e)
251 }).catch(($, e, next) => next(e))
252
253 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
254 const board = await read($, boardAtom)
255 if (!board) return next(e)
256 const why = checkWrite(e.file_path, e.new_string, e.old_string, await guardContext($, board))
257 return why ? refuse($, why) : next(e)
258 }).catch(($, e, next) => next(e))
259
260 on('session.end', async ($, e, next) => {
261 if (e.reason === 'clear') {
262 await update($, validationAtom, () => null)
263 await update($, blockedAtom, () => 0)
264 }
265 return next(e)
266 })
267
268 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
269 const [board, isSyncing, mode, blocked] = await Promise.all([read($, boardAtom), read($, syncingAtom), read($, modeAtom), read($, blockedAtom)])
270 await read($, nowAtom)
271 const now = await $.clock.now()
272 const table = $.ui.resolve(e)
273 const { Box, Text, Button, Link } = table
274 // The mobile app draws no text field: there a ticket is started from the prompt.
275 const Input = 'Input' in table ? table.Input : null
276
277 if (!board) {
278 return (
279 <Box flexDirection="column" key="control-tower">
280 <Text color={C.muted}>Control Tower works in a sentinel-hill hub: start the session in the folder that holds repos.yaml.</Text>
281 </Box>
282 )
283 }
284
285 const actions = (t: Task) => {
286 const stage = stageOf(t)
287 const pr = t.pr ? <Link key={`${t.key}-pr`} href={t.pr.url} label={`PR #${t.pr.number}`} /> : null
288 if (stage === 'feedback') {
289 return [<Button key={`${t.key}-feedback`} label="Handle feedback" variant="primary" onPress={() => runSkill($, 'handle-pr-feedback', t.key)} />, pr]
290 }
291 if (stage === 'merged' || stage === 'closed') {
292 return [<Button key={`${t.key}-close`} label="Close task" variant="primary" onPress={() => runSkill($, 'task-close', t.key)} />, pr]
293 }
294 if (stage === 'implementing') {
295 return [
296 <Button key={`${t.key}-review`} label="Review" onPress={() => runSkill($, 'review', t.path)} />,
297 <Button key={`${t.key}-finish`} label="Finish" onPress={() => runSkill($, 'task-finish', t.path)} />,
298 ]
299 }
300 const fix = t.pr?.checksFailed ? (
301 <Button
302 key={`${t.key}-checks`}
303 label="Fix checks"
304 variant="primary"
305 onPress={() => $.prompt.submit({ text: `The checks on ${t.key}'s PR are failing. Read them with gh pr checks in ${t.path}, fix the cause there, validate, commit and push.` })}
306 />
307 ) : null
308 return [fix, pr]
309 }
310
311 const controls = [
312 <Button key="refresh" label={isSyncing ? 'Syncing…' : 'Refresh'} onPress={() => sync($)} />,
313 <Button key="unattended" label="Unattended" variant={mode === 'unattended' ? 'primary' : 'secondary'} onPress={() => setMode($, 'unattended')} />,
314 <Button key="interactive" label="Interactive" variant={mode === 'interactive' ? 'primary' : 'secondary'} onPress={() => setMode($, 'interactive')} />,
315 ]
316 const ticket = !Input ? [] : [
317 <Input
318 key="ticket"
319 label="Start a ticket"
320 placeholder="OPS-123"
321 onInput={(v: string) => void update($, draftAtom, () => v)}
322 onSubmit={(v: string) => void update($, draftAtom, () => v).then(() => startTicket($, 'task-start'))}
323 />,
324 <Button key="start" label="Start" onPress={() => startTicket($, 'task-start')} />,
325 <Button key="implement" label="Implement" onPress={() => startTicket($, 'implement-ticket')} />,
326 ]
327
328 if (e.surface !== 'terminal') {
329 const { Svg } = $.ui.resolve(e)
330 const width = Math.max(300, e.props.bodyColumns * CELL_PX)
331 const head = headerSvg(board, isSyncing, width, now)
332 const foot = footerSvg(board, blocked, mode, width)
333 const empty = emptySvg(width)
334 return (
335 <Box flexDirection="column" gap={1} key="control-tower">
336 <Svg source={head.source} alt={head.alt} width={width} height={head.height} />
337 <Box flexDirection="row" flexWrap="wrap" gap={1}>
338 {controls}
339 </Box>
340 {board.tasks.length === 0 && <Svg source={empty.source} alt="No tasks under work/ yet" width={width} height={empty.height} />}
341 {board.tasks.map((t, i) => {
342 const drawn = taskSvg(t, i, width, now)
343 return (
344 <Box flexDirection="column" gap={1} key={t.path}>
345 <Svg source={drawn.source} alt={drawn.alt} width={width} height={drawn.height} />
346 <Box flexDirection="row" flexWrap="wrap" gap={1} alignItems="center">
347 {actions(t)}
348 </Box>
349 </Box>
350 )
351 })}
352 <Box flexDirection="row" flexWrap="wrap" gap={1} alignItems="flex-end">
353 {ticket}
354 </Box>
355 <Svg source={foot.source} alt={foot.alt} width={width} height={foot.height} />
356 </Box>
357 )
358 }
359
360 const need = board.tasks.filter(needsYou).length
361 return (
362 <Box flexDirection="column" key="control-tower">
363 <Box flexDirection="row" justifyContent="space-between">
364 <Text bold color={C.text}>
365 CONTROL TOWER
366 </Text>
367 <Text color={C.muted}>{`${board.tasks.length} tasks${need ? ` · ${need} need you` : ''}${isSyncing ? ' · syncing…' : ''}`}</Text>
368 </Box>
369 <Box flexDirection="row" gap={1}>
370 {controls}
371 </Box>
372 {board.tasks.length === 0 && <Text color={C.muted}>No tasks under work/ yet. Start a ticket below.</Text>}
373 {board.tasks.map((t, i) => {
374 const stat = statOf(t, now)
375 return (
376 <Box flexDirection="column" borderStyle="round" borderColor={needsYou(t) ? C.accent : C.stroke} paddingX={1} key={t.path}>
377 <Box flexDirection="row" justifyContent="space-between">
378 <Text bold color={C.text} wrap="truncate">{`${String(i + 1).padStart(2, '0')} ${t.key} ${t.title}`}</Text>
379 <Text color={C.muted}>{` ${t.repo}`}</Text>
380 </Box>
381 <Box flexDirection="row">
382 <Text bold color={stat.isHot ? C.accent : C.text}>{`${stat.value} ${stat.label}`}</Text>
383 <Text color={C.muted} wrap="truncate">{` · ${metaOf(t)}`}</Text>
384 </Box>
385 <Box flexDirection="row" gap={1}>
386 {actions(t)}
387 </Box>
388 </Box>
389 )
390 })}
391 <Box flexDirection="row" gap={1}>
392 {ticket}
393 </Box>
394 <Text color={C.muted}>{`${board.setup.hasLocalRepos ? '✓' : '✗'} repos.local.yaml · ${board.setup.hasPackagesScope === false ? '✗' : '✓'} gh read:packages · ${blocked} blocked · ${mode}`}</Text>
395 </Box>
396 )
397 })
398
399 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
400 if (e.props.hasSurvey) return next(e)
401 const board = await read($, boardAtom)
402 if (!board) return next(e)
403 const validation = await read($, validationAtom)
404 const isSetUp = board.setup.hasLocalRepos && board.setup.hasPackagesScope !== false
405 if (isSetUp && !validation) return next(e)
406 const { Box, Text, Button } = $.ui.resolve(e)
407 const setUp = <Button key="setup" label="Set up hub" variant="primary" onPress={() => $.prompt.submit({ text: SETUP_PROMPT })} />
408 const hide = <Button key="hide" label="Hide" onPress={() => update($, validationAtom, () => null)} />
409
410 if (e.surface !== 'terminal') {
411 const { Svg } = $.ui.resolve(e)
412 const width = Math.max(300, e.props.bodyColumns * CELL_PX - 120)
413 const drawn = !isSetUp ? setupSvg(board, width) : validationSvg(validation!, width)
414 return (
415 <Box flexDirection="row" gap={1} alignItems="center" key="control-tower-band">
416 <Svg source={drawn.source} alt={drawn.alt} width={width} height={drawn.height} />
417 {!isSetUp ? setUp : hide}
418 </Box>
419 )
420 }
421
422 if (!isSetUp) {
423 return (
424 <Box flexDirection="row" gap={1}>
425 <Text color={C.accent}>● Hub not set up</Text>
426 <Text color={C.muted}>{[!board.setup.hasLocalRepos && 'repos.local.yaml missing', board.setup.hasPackagesScope === false && 'gh lacks read:packages'].filter(Boolean).join(' · ')}</Text>
427 {setUp}
428 </Box>
429 )
430 }
431 const v = validation!
432 return (
433 <Box flexDirection="row" gap={1}>
434 <Text bold color={C.text}>{`validate ${v.checks.filter(c => c.status === 'passed').length}/${v.checks.length}`}</Text>
435 {v.checks.map((c, i) => (
436 <Text key={`check-${i}`} color={c.status === 'passed' ? C.done : c.status === 'pending' ? C.dim : c.status === 'failed' ? C.hot : C.accent}>
437 {`${c.status === 'passed' ? '●' : c.status === 'failed' ? '✗' : c.status === 'running' ? '◐' : '○'} ${shortCommand(c.command)}`}
438 </Text>
439 ))}
440 {hide}
441 </Box>
442 )
443 })
444}
445hooks/guards.ts 298 lines1import type { Registry } from './hub'
2import { taskOf, worktreeIn } from './hub'
3
4export type GuardContext = {
5 hub: string
6 registry: Registry
7 // The worktree most recently touched: where a bare `git commit` most likely runs.
8 active: string | null
9 // Jira project prefixes seen under work/ (OPS, APP), for the keys-in-code check.
10 keyPrefixes: string[]
11}
12
13// The release scopes repos.yaml names for the platform repos' `[SCOPE][KEY] Subject`.
14const SCOPES = ['BREAKING', 'FEATURE', 'BUGFIX', 'HOTFIX', 'SETUP', 'FIX']
15const PREFIX = 'Control Tower:'
16
17type Parsed = { segments: string[][]; heredocs: string[] }
18
19/** Splits a shell command into simple commands and their words, quotes removed, heredoc bodies kept aside. */
20export function parseShell(src: string): Parsed {
21 const segments: string[][] = []
22 const heredocs: string[] = []
23 let words: string[] = []
24 let word = ''
25 let hasWord = false
26 let pending: string[] = []
27 let i = 0
28
29 const endWord = () => {
30 if (hasWord) words.push(word)
31 word = ''
32 hasWord = false
33 }
34 const endSegment = () => {
35 endWord()
36 if (words.length) segments.push(words)
37 words = []
38 }
39 // At a newline: the bodies of the heredocs opened on the line just ended.
40 const readBodies = () => {
41 for (const delimiter of pending) {
42 const lines: string[] = []
43 while (i < src.length) {
44 const end = src.indexOf('\n', i)
45 const line = src.slice(i, end < 0 ? src.length : end)
46 i = end < 0 ? src.length : end + 1
47 if (line.trim() === delimiter) break
48 lines.push(line)
49 }
50 heredocs.push(lines.join('\n'))
51 }
52 pending = []
53 }
54 const readDelimiter = () => {
55 i += src[i] === '-' ? 1 : 0
56 while (src[i] === ' ' || src[i] === '\t') i++
57 const quote = src[i] === "'" || src[i] === '"' ? src[i] : ''
58 if (quote) i++
59 let d = ''
60 while (i < src.length && !(quote ? src[i] === quote : /[\s;&|)]/.test(src[i]!))) d += src[i++]
61 if (quote) i++
62 if (d) pending.push(d)
63 }
64 // `$( ... )` kept whole as part of the word, heredocs inside it read as above.
65 const readSubstitution = () => {
66 let depth = 0
67 let raw = ''
68 while (i < src.length) {
69 const c = src[i]!
70 if (c === "'" || c === '"') {
71 const close = src.indexOf(c, i + 1)
72 const end = close < 0 ? src.length : close + 1
73 raw += src.slice(i, end)
74 i = end
75 continue
76 }
77 if (c === '<' && src[i + 1] === '<') {
78 raw += '<<'
79 i += 2
80 readDelimiter()
81 continue
82 }
83 if (c === '\n' && pending.length) {
84 raw += '\n'
85 i++
86 readBodies()
87 continue
88 }
89 raw += c
90 i++
91 if (c === '(') depth++
92 else if (c === ')' && --depth === 0) break
93 }
94 return raw
95 }
96
97 while (i < src.length) {
98 const c = src[i]!
99 if (c === '\\' && i + 1 < src.length) {
100 word += src[i + 1]
101 hasWord = true
102 i += 2
103 } else if (c === "'") {
104 const close = src.indexOf("'", i + 1)
105 word += src.slice(i + 1, close < 0 ? src.length : close)
106 hasWord = true
107 i = close < 0 ? src.length : close + 1
108 } else if (c === '"') {
109 i++
110 while (i < src.length && src[i] !== '"') {
111 if (src[i] === '\\' && i + 1 < src.length) {
112 word += src[i + 1]
113 i += 2
114 } else if (src[i] === '$' && src[i + 1] === '(') {
115 i++
116 word += `$${readSubstitution()}`
117 } else word += src[i++]
118 }
119 hasWord = true
120 i++
121 } else if (c === '$' && src[i + 1] === '(') {
122 i++
123 word += `$${readSubstitution()}`
124 hasWord = true
125 } else if (c === '<' && src[i + 1] === '<') {
126 endWord()
127 i += 2
128 readDelimiter()
129 } else if (c === '\n') {
130 endSegment()
131 i++
132 readBodies()
133 } else if (';&|()'.includes(c)) {
134 endSegment()
135 i++
136 } else if (c === ' ' || c === '\t') {
137 endWord()
138 i++
139 } else {
140 word += c
141 hasWord = true
142 i++
143 }
144 }
145 endSegment()
146 return { segments, heredocs }
147}
148
149const isGit = (w: string[]) => /(^|\/)git$/.test(w[0] ?? '')
150
151/** A git command's subcommand and arguments, with `-C <dir>` read out of the global options. */
152function gitCall(w: string[]): { dir?: string; sub: string; args: string[] } | null {
153 let dir: string | undefined
154 let i = 1
155 while (i < w.length && w[i]!.startsWith('-')) {
156 if (w[i] === '-C') dir = w[++i]
157 else if (w[i] === '-c') i++
158 i++
159 }
160 return i < w.length ? { dir, sub: w[i]!, args: w.slice(i + 1) } : null
161}
162
163/** The worktree a command runs in: its own -C or a `cd` before it, else the active one. */
164function worktreeFor(dir: string | undefined, cdDir: string | undefined, ctx: GuardContext): string | null {
165 for (const d of [dir, cdDir]) {
166 if (!d) continue
167 const abs = d.startsWith('/') ? d : `${ctx.hub}/${d.replace(/^\.\//, '')}`
168 const t = taskOf(abs, ctx.hub) ?? (worktreeIn(abs, ctx.hub) ? taskOf(worktreeIn(abs, ctx.hub)!, ctx.hub) : null)
169 if (t) return `${ctx.hub}/work/${t.repo}/${t.folder}`
170 return null
171 }
172 return ctx.active
173}
174
175const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
176
177/** The first-line pattern a repo's `commit_format` allows for one key. */
178export function commitPattern(format: string, key: string): RegExp {
179 const scope = `(?:\\[SKIP\\])?(?:\\[(?:${SCOPES.join('|')})\\])?`
180 const body = escape(format)
181 .replace(/^\\\[SCOPE\\\]/, scope)
182 .replace(/\\\[SCOPE\\\]/, scope)
183 .replace(/KEY/, escape(key))
184 .replace(/ Subject$/, ' \\S')
185 return new RegExp(`^${body}`)
186}
187
188function commitMessage(args: string[], heredocs: string[]): string | null {
189 for (let i = 0; i < args.length; i++) {
190 const a = args[i]!
191 let value: string | undefined
192 if (a === '-m' || a === '--message') value = args[i + 1]
193 else if (a.startsWith('--message=')) value = a.slice(10)
194 else if (/^-m./.test(a)) value = a.slice(2)
195 else if (/^-[a-zA-Z]*m$/.test(a)) value = args[i + 1]
196 if (value === undefined) continue
197 // `-m "$(cat <<'EOF' ... EOF)"`: the message is the heredoc's body.
198 return value.includes('<<') && heredocs.length ? heredocs[0]! : value
199 }
200 return null
201}
202
203const suggestion = (format: string, key: string, subject: string) => {
204 const rest = subject.replace(/^(\[[^\]]*\])+\s*/, '').trim()
205 const text = rest ? rest[0]!.toUpperCase() + rest.slice(1) : 'Subject'
206 return format.includes('SCOPE')
207 ? `[<SCOPE>][${key}] ${text}, SCOPE one of ${SCOPES.join(' ')}; or [SKIP][${key}] ${text} when it releases nothing`
208 : format.replace('KEY', key).replace('Subject', text)
209}
210
211function checkCommit(args: string[], heredocs: string[], worktree: string | null, ctx: GuardContext): string | null {
212 const message = commitMessage(args, heredocs)
213 if (message === null) return null
214 if (/co-authored-by:/i.test(message) || /generated with \[?claude/i.test(message)) {
215 return `${PREFIX} no AI attribution in commits: drop the Co-Authored-By or "Generated with" line (AGENTS.md).`
216 }
217 const task = worktree ? taskOf(worktree, ctx.hub) : null
218 const format = task ? ctx.registry.repos[task.repo]?.commitFormat : undefined
219 if (!task || !format) return null
220 const subject = message.split('\n')[0]!.trim()
221 if (commitPattern(format, task.key).test(subject)) return null
222 return `${PREFIX} ${task.repo} commits are "${format}" with the key from the branch (${task.key}). Try: ${suggestion(format, task.key, subject)}`
223}
224
225const hasFlag = (args: string[], long: string, short: string) => args.some(a => a === long || a === short || a.startsWith(`${long}=`))
226const flagValues = (args: string[], long: string, short: string) =>
227 args.flatMap((a, i) => (a === long || a === short ? [args[i + 1] ?? ''] : a.startsWith(`${long}=`) ? [a.slice(long.length + 1)] : []))
228
229function checkPrCreate(args: string[], worktree: string | null, ctx: GuardContext): string | null {
230 const body = flagValues(args, '--body', '-b').join('\n')
231 if (/generated with \[?claude/i.test(body)) return `${PREFIX} no "Generated with" line in PR bodies (AGENTS.md).`
232 // The draft, label and assignee rules are for a ticket's PR; a PR elsewhere is not the hub's.
233 const task = worktree ? taskOf(worktree, ctx.hub) : null
234 if (!task) return null
235 const facts = ctx.registry.repos[task.repo]
236 const missing: string[] = []
237 if (!hasFlag(args, '--draft', '-d')) missing.push('--draft')
238 const labels = flagValues(args, '--label', '-l').flatMap(l => l.split(','))
239 if (facts?.prLabels.length && !labels.some(l => facts.prLabels.includes(l.trim()))) missing.push(`--label (one of ${facts.prLabels.join(', ')})`)
240 if (facts?.prAssignee && !hasFlag(args, '--assignee', '-a')) missing.push(`--assignee ${facts.prAssignee}`)
241 if (missing.length === 0) return null
242 return `${PREFIX} ${task.repo} PRs need ${missing.join(', ')}${facts?.prLabels.length ? '; its CI fails without a label and an assignee' : ''}.`
243}
244
245/** Why a Bash command breaks the hub's steering, or null when it does not. */
246export function checkBash(command: string, ctx: GuardContext): string | null {
247 const { segments, heredocs } = parseShell(command)
248 let cdDir: string | undefined
249 for (const w of segments) {
250 if (w[0] === 'cd') {
251 cdDir = w[1]
252 continue
253 }
254 if (isGit(w)) {
255 const call = gitCall(w)
256 if (!call) continue
257 const worktree = worktreeFor(call.dir, cdDir, ctx)
258 if (call.sub === 'add') {
259 const wide = call.args.find(a => ['-A', '--all', '-u', '--update', '.', './', ':/', '*'].includes(a) || /^-[a-zA-Z]*[Au][a-zA-Z]*$/.test(a))
260 if (wide) return `${PREFIX} add files by name, never \`git add ${wide}\` (steering/conventions.md). Nothing was staged.`
261 }
262 if (call.sub === 'commit') {
263 const why = checkCommit(call.args, heredocs, worktree, ctx)
264 if (why) return why
265 }
266 if (call.sub === 'merge') return `${PREFIX} never \`git merge\`; changes land through pull requests (steering/conventions.md).`
267 }
268 if (w[0] === 'gh' && w[1] === 'pr' && w[2] === 'create') {
269 const why = checkPrCreate(w.slice(3), worktreeFor(undefined, cdDir, ctx), ctx)
270 if (why) return why
271 }
272 }
273 return null
274}
275
276const TEMP = /^\/(?:private\/)?(?:tmp|var\/folders)\//
277const PROSE = /\.(md|mdx|markdown|txt|rst)$/i
278
279/** Why writing `added` to `path` breaks the hub's steering, or null. `added` is the new text only. */
280export function checkWrite(path: string, added: string, removed: string, ctx: GuardContext): string | null {
281 if (TEMP.test(path)) return `${PREFIX} temporary files go in ${ctx.hub}/work/scratch/, not ${path.split('/').slice(0, 3).join('/')}/ (AGENTS.md).`
282
283 for (const [repo, clone] of Object.entries(ctx.registry.clones)) {
284 if (path !== clone && !path.startsWith(`${clone.replace(/\/$/, '')}/`)) continue
285 const active = ctx.active ? taskOf(ctx.active, ctx.hub) : null
286 const there = active?.repo === repo ? ` Write in ${ctx.active}${path.slice(clone.replace(/\/$/, '').length)} instead.` : ` Work in a worktree under work/${repo}/ instead.`
287 return `${PREFIX} ${path} is in your original clone of ${repo}, which the hub never changes (AGENTS.md).${there}`
288 }
289
290 const task = taskOf(path, ctx.hub)
291 if (!task || PROSE.test(path) || ctx.keyPrefixes.length === 0) return null
292 const keys = new RegExp(`\\b(?:${ctx.keyPrefixes.map(escape).join('|')})-\\d+\\b`, 'g')
293 const before = new Set(removed.match(keys) ?? [])
294 const fresh = [...new Set(added.match(keys) ?? [])].filter(k => !before.has(k))
295 if (fresh.length === 0) return null
296 return `${PREFIX} no Jira keys in code or comments (steering/conventions.md): found ${fresh.join(', ')}. Keys belong in the branch, the commits and the PR.`
297}
298hooks/hub.ts 248 lines1import type { FsEntry } from 'claude-code'
2
3import type { Board, Pr, Setup, Stage, Task } from '../types'
4
5export type RepoFacts = {
6 commitFormat?: string
7 validate: string[]
8 prLabels: string[]
9 prAssignee?: string
10}
11
12// What the hub's two registry files say: repos.yaml (committed) and
13// repos.local.yaml (each person's clone paths).
14export type Registry = {
15 repos: Record<string, RepoFacts>
16 clones: Record<string, string>
17}
18
19// What the hub's scan needs from the engine, handed in by the hooks module.
20export type Io = {
21 cwd: () => Promise<string>
22 now: () => Promise<number>
23 exists: (path: string) => Promise<boolean>
24 read: (path: string) => Promise<string>
25 list: (path: string) => Promise<FsEntry[]>
26 run: (argv: string[], cwd: string, timeoutMs?: number) => Promise<{ exitCode: number; stdout: string; stderr: string }>
27}
28
29const WORKTREE = /^([A-Z][A-Z0-9]+-\d+)-([a-z0-9-]+)$/
30
31const unquote = (v: string) => v.replace(/^(['"])(.*)\1$/, '$2')
32// A trailing `# comment`, but not a `#` inside quotes.
33const uncomment = (v: string) => v.replace(/(?:^|\s+)#(?=(?:[^"']|"[^"]*"|'[^']*')*$).*$/, '').trim()
34const inlineList = (v: string) =>
35 v
36 .replace(/^\[|\]$/g, '')
37 .split(',')
38 .map(s => unquote(s.trim()))
39 .filter(Boolean)
40
41/** Reads the `repos:` block of repos.yaml: just the shape the hub writes, not YAML at large. */
42export function parseRepos(text: string): Record<string, RepoFacts> {
43 const repos: Record<string, RepoFacts> = {}
44 let inRepos = false
45 let repo: RepoFacts | undefined
46 let list: string[] | undefined
47 for (const raw of text.split(/\r?\n/)) {
48 if (!raw.trim() || raw.trim().startsWith('#')) continue
49 if (/^\S/.test(raw)) {
50 inRepos = /^repos:\s*$/.test(raw)
51 repo = list = undefined
52 continue
53 }
54 if (!inRepos) continue
55 const name = /^ {2}([a-z0-9][a-z0-9-]*):\s*$/i.exec(raw)
56 if (name) {
57 repo = repos[name[1]!] = { validate: [], prLabels: [] }
58 list = undefined
59 continue
60 }
61 if (!repo) continue
62 const item = /^\s+-\s+(.*)$/.exec(raw)
63 if (item && list) {
64 list.push(unquote(uncomment(item[1]!)))
65 continue
66 }
67 const field = /^ {4}([a-z_]+):\s*(.*)$/.exec(raw)
68 if (!field) continue
69 const value = uncomment(field[2]!)
70 list = undefined
71 if (field[1] === 'commit_format') repo.commitFormat = unquote(value)
72 else if (field[1] === 'pr_assignee') repo.prAssignee = unquote(value)
73 else if (field[1] === 'validate') list = value ? (repo.validate = inlineList(value)) : (repo.validate = [])
74 else if (field[1] === 'pr_labels') list = value ? (repo.prLabels = inlineList(value)) : (repo.prLabels = [])
75 }
76 return repos
77}
78
79/** Reads repos.local.yaml: ` name: /path/to/clone` lines, commented ones skipped. */
80export function parseClones(text: string): Record<string, string> {
81 const clones: Record<string, string> = {}
82 for (const line of text.split(/\r?\n/)) {
83 const m = /^ {2}([a-z0-9][a-z0-9-]*):\s*(\S.*)$/i.exec(line)
84 if (m) clones[m[1]!] = unquote(uncomment(m[2]!))
85 }
86 return clones
87}
88
89
90/** The hub this session works from: the folder holding repos.yaml and scripts/worktree.sh, at or above the session's folder. */
91export async function findHub(io: Io): Promise<string | null> {
92 const cwd = await io.cwd()
93 // A session started inside a worktree belongs to the hub above work/.
94 const start = cwd.includes('/work/') ? cwd.slice(0, cwd.indexOf('/work/')) : cwd
95 for (let dir = start; dir && dir !== '/'; dir = dir.slice(0, dir.lastIndexOf('/')) || '/') {
96 if ((await io.exists(`${dir}/repos.yaml`)) && (await io.exists(`${dir}/scripts/worktree.sh`))) return dir
97 }
98 return null
99}
100
101export async function loadRegistry(io: Io, hub: string): Promise<Registry> {
102 const [repos, local] = await Promise.all([io.read(`${hub}/repos.yaml`), io.read(`${hub}/repos.local.yaml`)])
103 return { repos: parseRepos(repos), clones: parseClones(local) }
104}
105
106type Rollup = { status?: string; conclusion?: string; state?: string }
107
108export function countChecks(rollup: Rollup[]): Pick<Pr, 'checksPassed' | 'checksFailed' | 'checksTotal'> {
109 let passed = 0
110 let failed = 0
111 for (const c of rollup) {
112 const verdict = (c.conclusion || c.state || '').toUpperCase()
113 if (['SUCCESS', 'NEUTRAL', 'SKIPPED'].includes(verdict)) passed++
114 else if (['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE'].includes(verdict)) failed++
115 }
116 return { checksPassed: passed, checksFailed: failed, checksTotal: rollup.length }
117}
118
119const THREADS =
120 'query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100){nodes{isResolved}}}}}'
121
122async function fetchPr(io: Io, path: string): Promise<Pr | undefined> {
123 const view = await io.run(['gh', 'pr', 'view', '--json', 'number,url,state,isDraft,title,mergedAt,statusCheckRollup'], path)
124 if (view.exitCode !== 0) return undefined
125 let raw: { number: number; url: string; state: Pr['state']; isDraft: boolean; title: string; mergedAt?: string | null; statusCheckRollup?: Rollup[] }
126 try {
127 raw = JSON.parse(view.stdout)
128 } catch {
129 return undefined
130 }
131 let unresolved = 0
132 if (raw.state === 'OPEN') {
133 // gh fills {owner} and {repo} from the worktree's remote.
134 const threads = await io.run(['gh', 'api', 'graphql', '-F', 'owner={owner}', '-F', 'name={repo}', '-F', `number=${raw.number}`, '-f', `query=${THREADS}`], path)
135 try {
136 const nodes: { isResolved: boolean }[] = JSON.parse(threads.stdout).data.repository.pullRequest.reviewThreads.nodes
137 unresolved = nodes.filter(n => !n.isResolved).length
138 } catch {
139 unresolved = 0
140 }
141 }
142 return {
143 number: raw.number,
144 url: raw.url,
145 state: raw.state,
146 isDraft: raw.isDraft,
147 title: raw.title,
148 mergedAt: raw.mergedAt ?? undefined,
149 unresolved,
150 ...countChecks(raw.statusCheckRollup ?? []),
151 }
152}
153
154/** The plan's first heading, without a leading ticket key. */
155export function planTitle(plan: string, key: string): string | undefined {
156 const heading = /^#+\s+(.+)$/m.exec(plan)?.[1]?.trim()
157 if (!heading) return undefined
158 return heading.replace(new RegExp(`^\\[?${key}\\]?\\s*[:·—-]?\\s*`), '').trim() || undefined
159}
160
161/** A PR title without its `[SCOPE][KEY]` prefix. */
162export const prTitle = (title: string) => title.replace(/^(\[[^\]]+\]\s*)+/, '').trim()
163
164async function readTask(io: Io, hub: string, repo: string, folder: string, withPr: boolean): Promise<Task | null> {
165 const m = WORKTREE.exec(folder)
166 if (!m) return null
167 const path = `${hub}/work/${repo}/${folder}`
168 if (!(await io.exists(`${path}/.git`))) return null
169 const [key, slug] = [m[1]!, m[2]!]
170 const plan = await io.read(`${hub}/work/${repo}/${folder}.plan.md`)
171 const [branch, develop, status] = await Promise.all([
172 io.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], path),
173 io.run(['git', 'rev-parse', '-q', '--verify', 'origin/develop'], path),
174 io.run(['git', 'status', '--porcelain'], path),
175 ])
176 const base = develop.exitCode === 0 ? 'develop' : 'main'
177 const ahead = await io.run(['git', 'rev-list', '--count', `origin/${base}..HEAD`], path)
178 const pr = withPr ? await fetchPr(io, path) : undefined
179 return {
180 path,
181 repo,
182 key,
183 slug,
184 title: planTitle(plan, key) ?? (pr ? prTitle(pr.title) : slug.replace(/-/g, ' ')),
185 branch: branch.stdout.trim(),
186 base,
187 hasPlan: plan !== '',
188 ahead: Number(ahead.stdout.trim()) || 0,
189 isDirty: status.stdout.trim() !== '',
190 pr,
191 }
192}
193
194async function readSetup(io: Io, hub: string): Promise<Setup> {
195 const auth = await io.run(['gh', 'auth', 'status'], hub, 10_000)
196 const said = `${auth.stdout}\n${auth.stderr}`
197 return {
198 hasLocalRepos: await io.exists(`${hub}/repos.local.yaml`),
199 hasPackagesScope: auth.exitCode === 0 ? said.includes('read:packages') : null,
200 }
201}
202
203/** Every ticket worktree under <hub>/work/, read from disk, with its PR from gh. */
204export async function scan(io: Io, hub: string, registry: Registry, withPr = true): Promise<Board> {
205 const repos = (await io.list(`${hub}/work`).catch(() => [])).filter(e => e.kind === 'dir' && e.name in registry.repos)
206 const found = await Promise.all(
207 repos.map(async r => {
208 const entries = await io.list(`${hub}/work/${r.name}`).catch(() => [])
209 return Promise.all(entries.filter(e => e.kind === 'dir').map(e => readTask(io, hub, r.name, e.name, withPr)))
210 }),
211 )
212 const tasks = found.flat().filter((t): t is Task => t !== null)
213 return { hub, tasks: sortTasks(tasks), setup: await readSetup(io, hub), syncedAt: await io.now() }
214}
215
216export function stageOf(t: Task): Stage {
217 if (!t.pr) return 'implementing'
218 if (t.pr.state === 'MERGED') return 'merged'
219 if (t.pr.state === 'CLOSED') return 'closed'
220 return t.pr.unresolved > 0 ? 'feedback' : 'pr'
221}
222
223/** Whether the next move is the person's: comments to answer, a merged PR to close, red checks. */
224export const needsYou = (t: Task) => {
225 const s = stageOf(t)
226 return s === 'feedback' || s === 'merged' || s === 'closed' || (s === 'pr' && (t.pr?.checksFailed ?? 0) > 0)
227}
228
229const ORDER: Record<Stage, number> = { feedback: 0, merged: 1, closed: 2, implementing: 3, pr: 4 }
230export const sortTasks = (tasks: Task[]) =>
231 [...tasks].sort((a, b) => Number(needsYou(b)) - Number(needsYou(a)) || ORDER[stageOf(a)] - ORDER[stageOf(b)] || a.key.localeCompare(b.key))
232
233/** The worktree a path or command names, as an absolute path, or null. */
234export function worktreeIn(text: string, hub: string): string | null {
235 const m = /(?:^|[\s"'=])((?:\/[^\s"']*)?work\/([a-z0-9-]+)\/([A-Z][A-Z0-9]+-\d+-[a-z0-9-]+))(?=$|[\s"'/;&|)])/.exec(text)
236 if (!m) return null
237 const [, whole, repo, folder] = m
238 if (whole!.startsWith('/') && !whole!.startsWith(`${hub}/work/`)) return null
239 return `${hub}/work/${repo}/${folder}`
240}
241
242/** What a worktree path says about its task: repo and key. */
243export function taskOf(path: string, hub: string): { repo: string; key: string; folder: string } | null {
244 const rest = path.startsWith(`${hub}/work/`) ? path.slice(hub.length + 6) : null
245 const m = rest ? /^([a-z0-9-]+)\/(([A-Z][A-Z0-9]+-\d+)-[a-z0-9-]+)(?:\/|$)/.exec(rest) : null
246 return m ? { repo: m[1]!, folder: m[2]!, key: m[3]! } : null
247}
248hooks/look.ts 259 lines1import type { Board, Task, Validation } from '../types'
2import { needsYou, stageOf } from './hub'
3
4// Dark only: square slate panels on a navy ground, hairline rules and light wide
5// numerals. Amber marks what needs a look or runs now, periwinkle what is done,
6// teal what merged, red what failed. An SVG is drawn as an image, so it takes
7// real colours; the terminal uses the same.
8export const C = {
9 ground: '#1f2433',
10 card: '#282e40',
11 raised: '#2f3549',
12 stroke: '#353c50',
13 edge: '#4a5168',
14 text: '#e8eaf0',
15 soft: '#c4c8d4',
16 muted: '#9aa1b5',
17 dim: '#6b7287',
18 accent: '#f5a524',
19 ember: '#3d3220',
20 done: '#8b90d6',
21 good: '#3fc9b4',
22 hot: '#f0605d',
23} as const
24
25const SANS = `font-family="Barlow, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif"`
26
27export const esc = (t: string) => t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
28
29// Text in an image cannot truncate itself: cut it to an estimated width, capitals
30// running wider than mixed case.
31const charW = (size: number, spacing = 0, caps = false) => size * (caps ? 0.68 : 0.55) + spacing
32export const fit = (t: string, px: number, size: number, spacing = 0, caps = false) => {
33 const max = Math.max(3, Math.floor(px / charW(size, spacing, caps)))
34 return t.length > max ? `${t.slice(0, max - 1)}…` : t
35}
36const labelW = (t: string) => t.length * charW(10, 1.2, true)
37const numW = (t: string, size: number) => t.length * size * 0.56
38
39const text = (x: number, y: number, size: number, fill: string, body: string, extra = '') =>
40 `<text x="${x}" y="${y}" font-size="${size}" fill="${fill}" ${extra}>${body}</text>`
41const label = (x: number, y: number, fill: string, body: string, px: number, extra = '') =>
42 text(x, y, 10, fill, esc(fit(body.toUpperCase(), px, 10, 1.2, true)), `letter-spacing="1.2" ${extra}`)
43/** A big light numeral, the look's headline figure. */
44const num = (x: number, y: number, size: number, fill: string, body: string, extra = '') =>
45 text(x, y, size, fill, esc(body), `font-weight="300" letter-spacing="1" ${extra}`)
46const square = (x: number, y: number, fill: string, s = 6) => `<rect x="${x}" y="${y}" width="${s}" height="${s}" fill="${fill}"/>`
47/** A small meta line, its parts set apart by slashes in the track colour. */
48const slashed = (x: number, y: number, fill: string, parts: string[], px: number) => {
49 const whole = fit(parts.join(' / '), px, 11)
50 const spans = whole
51 .split(' / ')
52 .map(s => `<tspan>${esc(s)}</tspan>`)
53 .join(`<tspan fill="${C.edge}"> / </tspan>`)
54 return `<text x="${x}" y="${y}" font-size="11" fill="${fill}">${spans}</text>`
55}
56
57const svg = (W: number, H: number, body: string) =>
58 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" ${SANS}>${body}</svg>`
59const card = (W: number, H: number, stroke: string = C.stroke) => `<rect x="0.5" y="0.5" width="${W - 1}" height="${H - 1}" fill="${C.card}" stroke="${stroke}"/>`
60const hollow = (W: number, H: number) => `<rect x="0.5" y="0.5" width="${W - 1}" height="${H - 1}" fill="none" stroke="${C.edge}" stroke-dasharray="3 4"/>`
61
62export function ago(ms: number): string {
63 const m = Math.max(0, Math.round(ms / 60_000))
64 if (m < 1) return 'just now'
65 if (m < 60) return `${m}m ago`
66 const h = Math.round(m / 60)
67 return h < 48 ? `${h}h ago` : `${Math.round(h / 24)}d ago`
68}
69const short = (ms: number) => ago(ms).replace(' ago', '').replace('just now', '0m')
70
71export type Stat = { value: string; label: string; isHot: boolean }
72
73/** The big number on a task's card, and what it counts. */
74export function statOf(t: Task, now: number): Stat {
75 const pr = t.pr
76 switch (stageOf(t)) {
77 case 'implementing':
78 return { value: String(t.ahead), label: t.ahead === 1 ? 'commit' : 'commits', isHot: false }
79 case 'feedback':
80 return { value: String(pr!.unresolved), label: 'unanswered', isHot: true }
81 case 'merged':
82 return { value: pr!.mergedAt ? short(now - Date.parse(pr!.mergedAt)) : 'OK', label: 'since merge', isHot: false }
83 case 'closed':
84 return { value: '--', label: 'pr closed', isHot: true }
85 case 'pr':
86 return pr!.checksTotal
87 ? { value: `${pr!.checksPassed}/${pr!.checksTotal}`, label: pr!.checksFailed ? `${pr!.checksFailed} failing` : 'checks', isHot: pr!.checksFailed > 0 }
88 : { value: '--', label: 'no checks', isHot: false }
89 }
90}
91
92/** The line under a card's track: what the task is waiting on. */
93export function metaOf(t: Task): string {
94 const pr = t.pr
95 const checks = pr && pr.checksTotal ? `checks ${pr.checksPassed}/${pr.checksTotal}` : ''
96 switch (stageOf(t)) {
97 case 'implementing':
98 return [t.hasPlan ? 'plan' : 'no plan', t.isDirty ? 'uncommitted changes' : 'clean', `base ${t.base}`].join(' · ')
99 case 'feedback':
100 return [`pr #${pr!.number}`, `${pr!.unresolved} unresolved`, checks].filter(Boolean).join(' · ')
101 case 'merged':
102 return [`pr #${pr!.number} merged`, t.isDirty ? 'uncommitted changes' : 'worktree clean'].join(' · ')
103 case 'closed':
104 return `pr #${pr!.number} closed without merge`
105 case 'pr':
106 return [`${pr!.isDraft ? 'draft ' : ''}pr #${pr!.number}`, checks, 'waiting for review'].filter(Boolean).join(' · ')
107 }
108}
109
110const STAGE_AT = { implementing: 1, pr: 2, feedback: 3, merged: 4, closed: 4 } as const
111const TRACK = ['start', 'build', 'pr', 'feedback', 'close']
112export const CARD_H = 134
113
114/** The key's colour: amber when it needs the person, teal once merged, periwinkle while it moves. */
115const keyColor = (t: Task) => (needsYou(t) ? C.accent : stageOf(t) === 'merged' ? C.good : C.done)
116
117/** One task as a card: number, key and repo; title; the big stat beside the five-stop track; what it waits on. */
118export function taskSvg(t: Task, index: number, width: number, now: number): { source: string; height: number; alt: string } {
119 const W = Math.max(300, Math.round(width))
120 const H = CARD_H
121 const hot = needsYou(t)
122 const stage = stageOf(t)
123 const p: string[] = [card(W, H, hot ? C.accent : C.stroke)]
124
125 const n = String(index + 1).padStart(2, '0')
126 p.push(text(16, 26, 11, C.muted, n, 'letter-spacing="1"'))
127 p.push(text(40, 26, 12, keyColor(t), esc(t.key), 'font-weight="600" letter-spacing="1"'))
128 const keyW = 40 + t.key.length * charW(12, 1, true) + 14
129 p.push(label(W - 16, 26, C.muted, t.repo, W - 16 - keyW, 'text-anchor="end"'))
130 p.push(`<rect x="16" y="38" width="2" height="14" fill="${C.text}"/>`)
131 p.push(text(26, 50, 14, C.text, esc(fit(t.title, W - 42, 14)), 'font-weight="500"'))
132
133 // The big number: amber when it needs the person, muted once merged.
134 const stat = statOf(t, now)
135 const size = 38
136 p.push(num(16, 94, size, stat.isHot ? C.accent : stage === 'merged' ? C.muted : C.text, stat.value))
137 const statW = Math.max(88, numW(stat.value, size), labelW(stat.label))
138 p.push(label(16, 110, C.muted, stat.label, statW + 4))
139
140 // The track: five square segments, done periwinkle, the current one amber, the rest to come.
141 const x0 = 16 + statW + 22
142 const x1 = W - 16
143 const at = STAGE_AT[stage]
144 if (x1 - x0 >= 120) {
145 const gap = 4
146 const seg = (x1 - x0 - gap * 4) / 5
147 TRACK.forEach((name, i) => {
148 const x = x0 + i * (seg + gap)
149 const fill = i < at ? C.done : i === at ? C.accent : C.edge
150 p.push(`<rect x="${x.toFixed(1)}" y="70" width="${seg.toFixed(1)}" height="4" fill="${fill}"/>`)
151 if (seg >= 40) p.push(text(x, 90, 9, i === at ? C.accent : C.muted, esc(fit(name.toUpperCase(), seg, 9, 1, true)), 'letter-spacing="1"'))
152 })
153 }
154 p.push(slashed(16, 124, hot ? C.soft : C.muted, metaOf(t).split(' · '), W - 32))
155
156 const alt = `${t.key} ${t.repo}: ${t.title}. ${stat.value} ${stat.label}. ${metaOf(t)}`
157 return { source: svg(W, H, p.join('')), height: H, alt }
158}
159
160/** The pane's heading: the hub over the tower's name, and how many tasks need the person. */
161export function headerSvg(board: Board, isSyncing: boolean, width: number, now: number): { source: string; height: number; alt: string } {
162 const W = Math.max(300, Math.round(width))
163 const H = 62
164 const need = board.tasks.filter(needsYou).length
165 const line = [`${board.tasks.length} ${board.tasks.length === 1 ? 'task' : 'tasks'}`, need ? `${need} need you` : '', isSyncing ? 'syncing…' : `synced ${ago(now - board.syncedAt)}`]
166 .filter(Boolean)
167 .join(' · ')
168 const body =
169 label(2, 12, C.muted, 'sentinel-hill hub', W - 4) +
170 num(1, 38, 24, C.text, 'Control tower') +
171 square(2, H - 13, need ? C.accent : C.edge) +
172 label(16, H - 6, C.muted, line, W - 20)
173 return { source: svg(W, H, body), height: H, alt: `Control Tower: ${line}` }
174}
175
176/** No worktrees under work/ yet. */
177export function emptySvg(width: number): { source: string; height: number } {
178 const W = Math.max(300, Math.round(width))
179 const H = 96
180 const body = hollow(W, H) + num(16, 46, 26, C.muted, 'No tasks') + label(16, 72, C.muted, 'Start a ticket below, or /task-start OPS-123', W - 32)
181 return { source: svg(W, H, body), height: H }
182}
183
184/** The pane's last line: setup, guards and mode. */
185export function footerSvg(board: Board, blocked: number, mode: string, width: number): { source: string; height: number; alt: string } {
186 const W = Math.max(300, Math.round(width))
187 const H = 22
188 const items: [string, boolean][] = [
189 ['repos.local.yaml', board.setup.hasLocalRepos],
190 ['gh read:packages', board.setup.hasPackagesScope !== false],
191 ]
192 let x = 2
193 const p: string[] = []
194 for (const [name, ok] of items) {
195 p.push(square(x, 8, ok ? C.done : C.accent))
196 p.push(label(x + 12, 15, ok ? C.muted : C.accent, name, 180))
197 x += 12 + labelW(name) + 14
198 }
199 const right = `${blocked ? `guards ${blocked} blocked · ` : ''}${mode}`
200 if (W - x > labelW(right)) p.push(label(W - 2, 15, blocked ? C.soft : C.muted, right, W - x, 'text-anchor="end"'))
201 const alt = `Setup: repos.local.yaml ${items[0]![1] ? 'present' : 'missing'}, gh read:packages ${items[1]![1] ? 'ok' : 'missing'}. ${right}`
202 return { source: svg(W, H, p.join('')), height: H, alt }
203}
204
205/** The band while the hub is not set up. */
206export function setupSvg(board: Board, width: number): { source: string; height: number; alt: string } {
207 const W = Math.max(300, Math.round(width))
208 const H = 58
209 const missing = [!board.setup.hasLocalRepos && 'repos.local.yaml missing', board.setup.hasPackagesScope === false && 'gh token lacks read:packages'].filter(Boolean) as string[]
210 const done = 2 - missing.length
211 const body =
212 card(W, H, C.accent) +
213 num(16, 40, 30, C.accent, `${done}/2`) +
214 text(84, 25, 14, C.text, 'Hub not set up', 'font-weight="500"') +
215 label(84, 43, C.soft, missing.join(' · '), W - 100)
216 return { source: svg(W, H, body), height: H, alt: `Hub not set up: ${missing.join(', ')}` }
217}
218
219export const shortCommand = (c: string) => c.replace(/^npm run /, '').replace(/ -- --app .*?bin\/([\w-]+)\.ts"?$/, ' $1').replace(/ -w (\S+)$/, ' ($1)')
220
221/** The band while validate runs: done out of total, one thin bar per command with its name under it. */
222export function validationSvg(v: Validation, width: number): { source: string; height: number; alt: string } {
223 const W = Math.max(300, Math.round(width))
224 const H = 72
225 const total = v.checks.length
226 const done = v.checks.filter(c => c.status === 'passed').length
227 const running = v.checks.find(c => c.status === 'running')
228 const failed = v.checks.filter(c => c.status === 'failed')
229 const p: string[] = [card(W, H, failed.length ? C.hot : C.stroke)]
230
231 // done / total in light numerals, the slash in the track colour
232 const size = 32
233 const lead = String(done)
234 p.push(
235 `<text x="16" y="50" font-size="${size}" font-weight="300" letter-spacing="1"><tspan fill="${failed.length ? C.hot : C.text}">${lead}</tspan>` +
236 `<tspan fill="${C.edge}" font-size="22"> / </tspan><tspan fill="${C.muted}">${total}</tspan></text>`,
237 )
238 const lx = 16 + numW(lead, size) + numW(' / ', 22) + numW(String(total), size) + 18
239
240 const state = failed.length ? `${failed.length} failed` : running ? 'running' : done === total ? 'all passed' : 'waiting'
241 const now = failed[0] ?? running
242 p.push(label(lx, 22, C.muted, `validate · ${v.repo}`, W - lx - 120))
243 p.push(label(W - 16, 22, failed.length ? C.hot : running ? C.accent : done === total ? C.good : C.muted, state, 110, 'text-anchor="end"'))
244
245 // Done periwinkle, running half amber, failed red, waiting on the track.
246 const gap = 10
247 const bar = (W - 16 - lx - gap * (total - 1)) / total
248 v.checks.forEach((c, i) => {
249 const x = lx + i * (bar + gap)
250 const at = `x="${x.toFixed(1)}" y="36" height="3"`
251 if (c.status === 'running') p.push(`<rect ${at} width="${bar.toFixed(1)}" fill="${C.stroke}"/><rect ${at} width="${(bar * 0.5).toFixed(1)}" fill="${C.accent}"/>`)
252 else p.push(`<rect ${at} width="${bar.toFixed(1)}" fill="${c.status === 'passed' ? C.done : c.status === 'failed' ? C.hot : C.edge}"/>`)
253 const color = c.status === 'failed' ? C.hot : c.status === 'running' ? C.accent : C.muted
254 if (bar >= 30) p.push(text(x, 56, 11, color, esc(fit(shortCommand(c.command), bar, 11))))
255 })
256 const alt = `Validate ${v.repo}: ${done} of ${total} passed, ${state}${now ? `: ${now.command}` : ''}`
257 return { source: svg(W, H, p.join('')), height: H, alt }
258}
259types/index.d.ts 78 lines1// implementing: a worktree with no PR yet. pr: an open PR with nothing to answer.
2// feedback: an open PR with unresolved review threads. merged and closed: the PR
3// ended; the worktree waits for task-close.
4export type Stage = 'implementing' | 'pr' | 'feedback' | 'merged' | 'closed'
5
6export type Pr = {
7 number: number
8 url: string
9 state: 'OPEN' | 'MERGED' | 'CLOSED'
10 isDraft: boolean
11 title: string
12 checksPassed: number
13 checksFailed: number
14 checksTotal: number
15 // Review threads not yet resolved; 0 when the PR is not open.
16 unresolved: number
17 mergedAt?: string
18}
19
20export type Task = {
21 // Absolute path of the worktree under <hub>/work/<repo>/<KEY>-<slug>.
22 path: string
23 repo: string
24 key: string
25 slug: string
26 // From the plan file's first heading, else the PR title, else the slug.
27 title: string
28 branch: string
29 base: string
30 hasPlan: boolean
31 // Commits on the branch that the base does not have.
32 ahead: number
33 isDirty: boolean
34 pr?: Pr
35}
36
37export type Setup = {
38 hasLocalRepos: boolean
39 // null when gh could not say (not installed, not signed in).
40 hasPackagesScope: boolean | null
41}
42
43export type Board = {
44 hub: string
45 tasks: Task[]
46 setup: Setup
47 syncedAt: number
48}
49
50export type CheckStatus = 'pending' | 'running' | 'passed' | 'failed'
51
52export type Validation = {
53 repo: string
54 checks: { command: string; status: CheckStatus }[]
55}
56
57export type Mode = 'unattended' | 'interactive'
58
59declare module 'claude-code' {
60 interface PluginState {
61 'control-tower': {
62 // null outside a hub: the mod then stays out of the way.
63 board: Board | null
64 isSyncing: boolean
65 validation: Validation | null
66 mode: Mode
67 // The worktree most recently touched by a tool call.
68 active: string | null
69 // Tool calls the guards refused this session.
70 blocked: number
71 // Bumped by the ticker so "synced 2m ago" redraws.
72 now: number
73 // What is typed in the pane's ticket field; read when Start is pressed.
74 draft: string
75 }
76 }
77}
78