SLOPSHOPPER

control-tower

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

newpanebandguardcommandtoast
★ 1v0.2.0no licenseupdated 2026-10-24WeaponizedLego/claude-code-mods/control-tower
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · control-tower
│ ┃ control-tower ✕ › fix the failing auth test and add an audit log call │ ┃ Control Tower works in a sentinel-hill hub: │ ┃ start the session in the folder that holds ⏺ Read(src/auth.ts) │ ┃ repos.yaml. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · control-tower
Control Tower works in a sentinel-hill hub: start the session in the folder that holds repos.yaml.
README

claude-code-mods

My Claude Code mods, one folder per mod. Needs Claude Code 2.1.287 or newer.

ModWhat it does
usage-bandA 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-progressReads a phased plan into a live progress tree: phases, steps, elapsed time and an estimate of what is left
test-userA 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-cardA 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-towerA 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.

usage-band in the desktop app

Install

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.

plan-progress

plan-progress in the desktop app

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.

test-user

test-user in the desktop app

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.

change-card

change-card in the desktop app

/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.

control-tower

control-tower in the desktop app

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.

control-tower's validate band

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 name
  • a commit message that does not match the repo's commit_format with the branch's key, or that carries a Co-Authored-By or "Generated with" line
  • git merge
  • gh pr create from a ticket worktree without --draft, or without the label and assignee the repo's pr_labels and pr_assignee ask for
  • writing into a clone listed in repos.local.yaml (the worktree is the place), or into /tmp (use work/scratch/)
  • a Jira key in code or comments inside a worktree (prose files are fine)

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.

Adding a mod

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 ..

Source 5 files
hooks/register.tsx 445 lines
1import { 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}
445
hooks/guards.ts 298 lines
1import 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}
298
hooks/hub.ts 248 lines
1import 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}
248
hooks/look.ts 259 lines
1import 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
259
types/index.d.ts 78 lines
1// 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