SLOPSHOPPER

work-band

A band above the prompt in any GitHub repo: the branch, the PR and its checks, the changed files, and per repo, set in ~/.claude/work-band.json with /wb…

panebandguardcommandprompttool
v0.2.0MITupdated 2026-10-17adrianj98/Skills/plugins/work-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · work-band
│ ┃ files ✕ › fix the failing auth test and add an audit log call │ ┃ [ All 0 ] [ PR 0 ] [ Uncommitted 0 ] ● in │ ┃ src ⏺ Read(src/auth.ts) │ ┃ ⠸ audit.ts ⎿ Read 6 lines │ ┃ ⠸ auth.ts ⏺ Update(src/auth.ts) │ ┃ ⠸ cache.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 │ │ › /wb │ ⎿ work-band: Work band hidden. /wb shows it again. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · files
[ All 0 ] [ PR 0 ] [ Uncommitted 0 ] ● in PR ● uncommitted src ⠸ audit.ts ⠸ auth.ts ⠸ cache.ts
Pane · pr-checks
No checks reported.
Pane · server-logs
No output from yet.
Pane · wb-settings
~/.claude/work-band.json → acme/app. Click a setting to change it. Change Band Change Jira site Change Jira projects Change Server Run setup
README

skills

Claude Code plugins I use across projects. This repo is a plugin marketplace.

claude plugin marketplace add adrianj98/skills

Plugins

adversarial-review · v0.1.0

A reviewer that assumes your code is wrong, in a context window that never saw you write it. Modelled on the split-context review Bun used to port a million lines from Zig to Rust — "1 implementer, 2 or more adversarial reviewers per implementer. The implementer doesn't review. The reviewer doesn't implement."

claude plugin install adversarial-review@adrianj98-skills

Or without the plugin system — see Installing without the plugin system.

Ships four levels of the same idea, escalating by how hard it is to skip:

invoke
reviewer subagent"have the adversary review my changes"one pass, cheap
review workflow/adversarial-review:review4 lenses in parallel, then refutation voting
Stop-hook nudgeautomatic, once per diff over ~25 code linesadversary off to silence
PR gateexamples/github-pr-review.ymlnot skippable from your laptop

Full README →

standup · v0.1.0

/standup — a very short list of what you worked on since your last working day. It reads the commits reachable from every worktree of the current repo, so work on a branch checked out somewhere else still counts. It also reads the GitHub PRs you opened or reviewed (through gh), the Jira issues you touched (through an Atlassian MCP server or a jira/acli CLI), and whatever each worktree's aj-log.md gained. Related items become one bullet each. A source that isn't available is skipped.

claude plugin install standup@adrianj98-skills

/standup since friday widens the window; /standup everyone includes every author.

progress · v0.1.0

/progress — where a branch and its pull request stand, in two sections. What it's about is a paragraph from the PR description, the commit bodies and aj-log.md. State is a short list: none yet / draft / open / merged, who approved or is still being waited on and how many review threads are open, which checks fail, conflicts or commits behind the base, what's uncommitted or unpushed, any unanswered question in the conversation, and the one thing left before it merges. /progress 189 takes a PR number, /progress feature/x a branch.

claude plugin install progress@adrianj98-skills

Full README →

deslop · v0.1.0

/deslop — what in this change reads like a machine wrote it, as a report you can act on; /deslop run cuts it in the same pass. Comments that restate the line below them, try/catch around calls that can't throw, wrappers with one caller, the as any that made the error go away, README paragraphs about leveraging a robust and comprehensive toolkit.

claude plugin install deslop@adrianj98-skills

A deterministic scan over the lines your branch added produces candidates; the skill then reads files the diff never touched to learn what this repo's authors actually write, and throws out everything that only looks like slop next to some other codebase. /deslop all audits the whole repo instead.

Full README →

showmethedata · v0.1.0

/showmethedata UserSession — how that data is stored and used by the code. Where it lives at rest, what shape it has in each layer and where those shapes disagree, who writes it, who reads it and what for, one write and one read traced end to end, and what eventually deletes it. Point it at a symbol, a table, a field, a file or a directory; with no target it maps the repo one paragraph per store.

claude plugin install showmethedata@adrianj98-skills

Just a prompt, no script: it orients on the repo's stores and schema files, greps every spelling of the target, follows each writer to where the data lands, reads the migration rather than the model, and reports the drift between layers with a file:line on both sides. It never edits anything.

Full README →

doubt · v0.1.0

/doubt — question the analysis or diagnosis the AI just gave, before you act on it. It splits the analysis into claims, checks the load-bearing ones in a fresh context that never saw the reasoning, and names the explanations it skipped. The first line of the report is Holds, Shaky or Wrong, and anything after it is only what failed or went unchecked.

claude plugin install doubt@adrianj98-skills

Full README →

caveman · v0.1.0

/caveman — short, plain replies for the rest of the session, with no pleasantries, hedging or recap and the answer first. Code, errors and technical names stay exact, and warnings stay in full sentences. /caveman off turns it back.

claude plugin install caveman@adrianj98-skills

Full README →

greenlight · v0.5.0

/greenlight: a skill with no script behind it. It first says what it will do and lists the workflows it will watch, each with its checks and which are required. Then it pulls the default branch (asking GitHub which one that is), merges it into your branch, and pushes. If the merge conflicts, Claude resolves it. If the pull request has a required integration check that came out skipping, Claude reruns that run once. When a check fails, Claude fixes the code and pushes again, up to three times. It then reports each check's result from gh pr checks.

claude plugin install greenlight@adrianj98-skills

Full README →

walkthrough · v0.1.0

/walkthrough — a document that walks a reader through a pull request's code in the order they should read it: an overview, then one stop per idea with the code, a file:line, what it does and why, then the tests and the leftovers. /walkthrough 189 takes a PR number, /walkthrough feature/x a branch.

claude plugin install walkthrough@adrianj98-skills

Full README →

work-band · v0.2.0

A mod, not a skill: a hooks module that draws a band above the prompt in any GitHub repo. It shows the checked-out branch, its PR and checks, and the lines added and removed against the PR's base (or the repo's default branch), and at the end the model and subscription plan (Opus 5.5 · Max). Clicking opens a pane beside the transcript: the checks (with a re-run button), the changed files as a tree, or the dev server's logs. /wb hides and shows the band. Claude tells the band what it is working on through the mod's set_topic tool, drawn in cyan; every prompt you send carries a hidden line with that topic, so Claude re-checks it.

Per repo, set in ~/.claude/work-band.json (nothing is written to the repo), it also shows the Jira issue the branch names, only once you set the Jira site, and a button that starts or restarts the dev server: a turbo serve task or any command, with its port variable, ports and extra env. /wb settings lists this repo's settings in a pane, where a click changes one; /wb setup asks each in turn and offers the servers it finds in turbo.json and package.json.

claude plugin install work-band@adrianj98-skills

Or ./install.sh work-band --global, which copies the folder to ~/.claude/mods/work-band and adds it to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json.

Installing without the plugin system

install.sh copies a plugin's files into ~/.claude/ or a repo's .claude/, rewrites its plugin-relative paths to real ones, and merges any hooks into your existing settings.json rather than overwriting it. It runs from a clone or straight from a curl, and takes the plugin you want:

# every repo, just you
curl -fsSL https://raw.githubusercontent.com/adrianj98/Skills/main/install.sh \
  | bash -s -- adversarial-review --global

# this repo, committed for teammates
./install.sh adversarial-review --local

./install.sh all --global                             every plugin at once
./install.sh --list                                   what's installable
./install.sh adversarial-review --uninstall --global  reverse it

Not everything installable is a plugin. claude-local is just a starter CLAUDE.local.md — personal notes for Claude, uncommitted:

./install.sh claude-local --local     # <repo root>/CLAUDE.local.md, kept out of git
./install.sh claude-local --global    # ~/.claude/CLAUDE.local.md, applies everywhere

It lands beside the repo rather than in .claude/, adds itself to .git/info/exclude rather than the tracked .gitignore, and is never overwritten or deleted once it's yours.

Skills from other repos

A manifest can also pull skills straight from someone else's GitHub repo with a remote line. superpowers installs obra/superpowers that way — all of it, or just the skills you name:

./install.sh superpowers --global                                    # every skill
./install.sh superpowers --skill brainstorming,systematic-debugging --global
./install.sh superpowers --list                                      # what there is to pick
./install.sh superpowers --uninstall --skill brainstorming --global

It downloads one tarball per repo (no git, no API token), copies each skill directory into skills/, and rewrites superpowers:<skill> references to the bare /<skill> names. What it installed is recorded in .claude/.install-receipts/<plugin>, so --uninstall removes exactly that even after upstream adds or drops skills.

Into Devin

Devin reads these plugins as they are, so --devin hands each one to Devin's own plugin system instead of copying files:

./install.sh all --devin                  # or one plugin by name
./install.sh standup --devin --uninstall

That records them in your personal plugins, which Devin Cloud sessions and every machine you sign in to load. Skills arrive namespaced (/standup:standup). The hooks and the adversary subagent only run in local Devin sessions, and the review workflow is Claude Code only. SKILLS_DEVIN_SOURCE=you/Skills installs from a fork.

--globalinto ~/.claude/ (or $CLAUDE_CONFIG_DIR)
--local [PATH]into ./.claude/, or PATH/.claude/ — --repo is an alias
--devininto Devin, through devin plugins install
--privateregister hooks in settings.local.json (gitignored) instead
--no-hookskip any hooks; install everything else
--dry-runprint what would happen, change nothing
--skill NAMEonly that skill from a manifest's remote lines (a,b for several)
--uninstallremove the files and unregister the hooks

Re-running is idempotent. SKILLS_RAW=… points the standalone install at a fork or branch.

Adding a plugin to this repo

  1. plugins/<name>/ with a .claude-plugin/plugin.json
  2. Auto-discovered subdirs: skills/<name>/SKILL.md, agents/*.md, commands/*.md, workflows/*.js, hooks/hooks.json, bin/, .mcp.json
  3. Add an entry to .claude-plugin/marketplace.json
  4. claude plugin validate .
  5. Optional: a plugins/<name>/install.manifest, plus the name in plugins/index, so install.sh <name> works too. install.sh knows nothing about any particular plugin — the manifest lists what to copy where, which hooks to register, and which plugin-relative paths to rewrite:
   name   Some Plugin
   about  One line, shown by install.sh --list.

   file      skills/x/SKILL.md  skills/x/SKILL.md   /x
   bin       bin/x              bin/x               the helper
   hook      Stop  scripts/x.sh  hooks/x.sh         the nudge
   personal  NOTES.md           NOTES.md            beside the repo, never clobbered
   remote    owner/repo@v1  skills/*  skills     every skill dir in that GitHub repo
   remote    owner/repo     skills/x  skills/x   or just one

   rewrite  ${CLAUDE_PLUGIN_ROOT}/bin/x  @ROOT@/bin/x
   tip      /x — try this first

@ROOT@ is the .claude/ directory, @BASE@ the repo root (or the config dir when installing globally).

License

MIT

Source 4 files
hooks/register.tsx 1770 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Account, Check, CheckState, DiffStat, FilesMode, Jira, Pr, RepoSettings, Serve, Task } from '../types'
5import {
6  SETTINGS_FILE,
7  describe,
8  detectServers,
9  formatSettingsFile,
10  isEnabled,
11  issueKey,
12  jiraSite,
13  parseEnv,
14  parsePorts,
15  parseSettingsFile,
16  planArgv,
17  portEnv,
18  ports,
19  repoOf,
20  serverArgv,
21  serverEnv,
22  serverKind,
23  serverMatch,
24} from './settings'
25import { ancestorsOf, buildRows, marksOf, parseNameStatus, parseStatus, relativeTo } from './tree'
26import type { Change } from './tree'
27
28const pr = atom({ plugin: 'work-band', key: 'pr' } as const, null)
29const jira = atom({ plugin: 'work-band', key: 'jira' } as const, null)
30const serve = atom({ plugin: 'work-band', key: 'serve' } as const, { state: 'stopped', tasks: [] })
31const canServe = atom({ plugin: 'work-band', key: 'canServe' } as const, false)
32const isHidden = atom({ plugin: 'work-band', key: 'isHidden' } as const, false)
33// The port the server runs on: the repo's first configured port, or the one
34// last chosen for this repo (remembered across sessions in $.store).
35const port = atom({ plugin: 'work-band', key: 'port' } as const, 3000)
36// `owner/repo`, this repo's settings and its default branch.
37const repo = atom({ plugin: 'work-band', key: 'repo' } as const, '')
38const settings = atom({ plugin: 'work-band', key: 'settings' } as const, {} as RepoSettings)
39const defaultBranch = atom({ plugin: 'work-band', key: 'defaultBranch' } as const, '')
40const portKey = (r: string) => `port:${r}`
41// What Claude says it is working on now: one to three short labels it sets
42// through the set_topic tool; empty until it does.
43const topic = atom({ plugin: 'work-band', key: 'topic' } as const, [] as string[])
44export const TOPIC_TOOL = 'mcp__work-band__set_topic'
45export const TOPIC_MAX = 3
46export const TOPIC_PROMPT = `The work band above the prompt shows what you are working on. Call ${TOPIC_TOOL} with one to three short labels (two to four words each) when you start on something and whenever the topic changes: a new feature, a different bug, another area of the code. Call it again as one piece of work moves on: from designing to building, from building to testing, from one bug to the next. Do not call it when the topic is unchanged.`
47// The line attached, unseen, to every prompt the person sends: the topic the
48// band shows now, so the model re-checks it at the moment intent most often
49// changes, instead of hearing about the tool once at session start.
50export const topicNudge = (topics: readonly string[]): string =>
51  topics.length === 0
52    ? `The work band shows no topic yet. Call ${TOPIC_TOOL} with what this prompt is about before anything else.`
53    : `The work band shows: ${topicText(topics)}. If this prompt moves the work to something else, call ${TOPIC_TOOL} before anything else; if it is the same work, leave it.`
54// The Files pane: which view, which folders the person opened (All) or
55// closed (PR, Uncommitted), a tick when the file lists change, and the
56// spinner frame of the file being edited.
57const filesMode = atom({ plugin: 'work-band', key: 'filesMode' } as const, 'pr' as FilesMode)
58const openDirs = atom({ plugin: 'work-band', key: 'openDirs' } as const, [] as string[])
59const closedDirs = atom({ plugin: 'work-band', key: 'closedDirs' } as const, [] as string[])
60const filesTick = atom({ plugin: 'work-band', key: 'filesTick' } as const, 0)
61const editFrame = atom({ plugin: 'work-band', key: 'editFrame' } as const, 0)
62// Lines this branch adds and removes against its PR's base, or develop.
63const diffStat = atom({ plugin: 'work-band', key: 'diffStat' } as const, null as DiffStat | null)
64// Runs whose re-run was asked for, until a refresh shows them going again.
65const rerunAsked = atom({ plugin: 'work-band', key: 'rerunAsked' } as const, [] as string[])
66// Which Claude Code account the session runs as, from `claude auth status`.
67const account = atom({ plugin: 'work-band', key: 'account' } as const, null as Account | null)
68// PRs whose merge this session already told the model about.
69const mergedTold = atom({ plugin: 'work-band', key: 'mergedTold' } as const, [] as number[])
70// Which task's logs the pane shows; `tick` changes to redraw it.
71// The branch checked out in the session's repo, shown first on the band.
72const branch = atom({ plugin: 'work-band', key: 'branch' } as const, '')
73const BRANCH_MAX = 40
74const logView = atom({ plugin: 'work-band', key: 'logView' } as const, { task: '', tick: 0 })
75
76// `t` is the shell alias `npx turbo run serve --ui=tui`. The TUI needs a
77// terminal, which a spawned child has none of, so it streams instead.
78// The band runs in any GitHub repo whose settings do not turn it off; in any
79// other folder the mod registers nothing and draws nothing. Whether it is on
80// is kept in `canServe`, state that outlives a hot reload of this module.
81// Whichever Jira MCP server is connected: claude.ai's "Atlassian Rovo", its
82// older "Atlassian", a local `atlassian` server. Every one serves getJiraIssue.
83export const pickJiraTool = (names: readonly string[], op = 'getJiraIssue'): string | undefined =>
84  names.find(n => new RegExp(`^mcp__.+__${op}$`).test(n))
85
86// The labels the model handed set_topic, trimmed, blanks dropped, at most
87// TOPIC_MAX; undefined when the input is not a list of strings.
88export const parseTopics = (input: unknown): string[] | undefined => {
89  const list = (input as { topics?: unknown } | null)?.topics
90  if (!Array.isArray(list) || !list.every(t => typeof t === 'string')) return undefined
91  return (list as string[]).map(t => t.trim()).filter(t => t !== '').slice(0, TOPIC_MAX)
92}
93export const topicText = (topics: readonly string[]): string => topics.join(' · ')
94
95// `text` cut to `width` cells with an ellipsis, whole when it fits.
96export const fitWidth = (text: string, width: number): string =>
97  text.length <= width ? text : width <= 1 ? '…' : `${text.slice(0, width - 1)}…`
98
99// The issue's summary from getJiraIssue's JSON, JSON escapes undone.
100export const parseJiraTitle = (text: string): string | undefined => {
101  const raw = text.match(/"summary"\s*:\s*"((?:[^"\\]|\\.)*)"/)?.[1]
102  if (!raw) return undefined
103  try {
104    return JSON.parse(`"${raw}"`) as string
105  } catch {
106    return raw
107  }
108}
109// The issue's status name from getJiraIssue's JSON: `"status":{...,"name":"To Do",...}`.
110export const parseJiraStatus = (text: string): string | undefined => {
111  const raw = text.match(/"status"\s*:\s*\{[^{}]*?"name"\s*:\s*"((?:[^"\\]|\\.)*)"/)?.[1]
112  if (!raw) return undefined
113  try {
114    return JSON.parse(`"${raw}"`) as string
115  } catch {
116    return raw
117  }
118}
119
120// The transitions getTransitionsForJiraIssue offers, unavailable ones left out.
121export const parseTransitions = (text: string): { id: string; name: string; to: string }[] => {
122  try {
123    const raw = (JSON.parse(text) as { transitions?: unknown }).transitions
124    if (!Array.isArray(raw)) return []
125    return raw.flatMap((t: { id?: unknown; name?: unknown; isAvailable?: unknown; to?: { name?: unknown } }) =>
126      typeof t.id === 'string' && typeof t.name === 'string' && t.isAvailable !== false
127        ? [{ id: t.id, name: t.name, to: typeof t.to?.name === 'string' ? t.to.name : t.name }]
128        : [],
129    )
130  } catch {
131    return []
132  }
133}
134
135// The picker holds at most four options: a longer list is shown three at a
136// time with `More…` last, and the final page up to four.
137export const MORE = 'More…'
138export const transitionPage = (names: readonly string[], page: number): string[] => {
139  const start = page * 3
140  const rest = names.slice(start)
141  return rest.length <= 4 ? [...rest] : [...rest.slice(0, 3), MORE]
142}
143
144// The common Jira statuses by name; any other is its words' initials, or
145// its first four letters when it is one word.
146const STATUS_ABBREVIATIONS: Record<string, string> = {
147  'to do': 'TODO',
148  'in progress': 'PROG',
149  'in review': 'REV',
150  'code review': 'REV',
151  'in testing': 'TEST',
152  'ready for review': 'RFR',
153  'ready for test': 'RFT',
154  'ready for dev': 'RFD',
155  'selected for development': 'SEL',
156  backlog: 'BKLG',
157  blocked: 'BLKD',
158  'on hold': 'HOLD',
159  done: 'DONE',
160  closed: 'CLSD',
161  resolved: 'RSLV',
162  reopened: 'REOP',
163  open: 'OPEN',
164  cancelled: 'CANC',
165  canceled: 'CANC',
166}
167
168export const abbreviateStatus = (name: string): string => {
169  const words = name.trim().split(/\s+/).filter(Boolean)
170  if (words.length === 0) return ''
171  const known = STATUS_ABBREVIATIONS[words.join(' ').toLowerCase()]
172  if (known) return known
173  if (words.length > 1) return words.map(w => w.charAt(0)).join('').toUpperCase()
174  return words[0]!.slice(0, 4).toUpperCase()
175}
176
177export const jiraUrl = (site: string, key: string) => `https://${site}/browse/${key}`
178const POLL_MS = 120_000
179const MIN_PR_GAP_MS = 30_000
180
181const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]/g
182
183// One entry of `gh pr view --json statusCheckRollup`: a CheckRun (name,
184// status, conclusion, detailsUrl) or a StatusContext (context, state, targetUrl).
185type RawCheck = {
186  name?: string
187  context?: string
188  workflowName?: string
189  status?: string | null
190  conclusion?: string | null
191  state?: string | null
192  startedAt?: string | null
193  completedAt?: string | null
194  detailsUrl?: string | null
195  targetUrl?: string | null
196}
197
198export const verdictOf = (c: RawCheck): CheckState => {
199  const verdict = (c.conclusion || c.state || '').toUpperCase()
200  if (c.status && c.status !== 'COMPLETED') return 'pending'
201  if (verdict === 'SUCCESS') return 'pass'
202  if (['FAILURE', 'CANCELLED', 'TIMED_OUT', 'ERROR', 'ACTION_REQUIRED', 'STARTUP_FAILURE'].includes(verdict)) return 'fail'
203  if (verdict === 'PENDING' || verdict === 'EXPECTED' || verdict === '') return 'pending'
204  return 'skipped' // SKIPPED, NEUTRAL, STALE
205}
206
207const CHECK_ORDER: Record<CheckState, number> = { fail: 0, pending: 1, pass: 2, skipped: 3 }
208
209// The checks as the pane lists them: failures first, then running, passed, skipped.
210export const toChecks = (raw: readonly RawCheck[]): Check[] =>
211  raw
212    .map(
213      (c): Check => ({
214        name: c.name ?? c.context ?? '?',
215        workflow: c.workflowName || undefined,
216        state: verdictOf(c),
217        startedAt: c.startedAt && !c.startedAt.startsWith('0001') ? c.startedAt : undefined,
218        completedAt: c.completedAt && !c.completedAt.startsWith('0001') ? c.completedAt : undefined,
219        url: c.detailsUrl ?? c.targetUrl ?? undefined,
220      }),
221    )
222    .sort((a, b) => CHECK_ORDER[a.state] - CHECK_ORDER[b.state] || a.name.localeCompare(b.name))
223
224export const countChecks = (raw: readonly RawCheck[]) => {
225  const states = raw.map(verdictOf)
226  return {
227    passed: states.filter(s => s === 'pass').length,
228    failed: states.filter(s => s === 'fail').length,
229    pending: states.filter(s => s === 'pending').length,
230  }
231}
232
233// ` 31 files changed, 455 insertions(+), 444 deletions(-)`; either count is
234// left out when it is zero, and an empty diff prints nothing.
235export const parseShortstat = (text: string): { added: number; removed: number } => ({
236  added: Number(text.match(/(\d+) insertions?\(\+\)/)?.[1] ?? 0),
237  removed: Number(text.match(/(\d+) deletions?\(-\)/)?.[1] ?? 0),
238})
239
240// A check's GitHub Actions run and job, from its details link:
241// https://github.com/<owner>/<repo>/actions/runs/<run>/job/<job>. The job
242// number there is the databaseId `gh run rerun --job` takes.
243export const runOf = (url: string | undefined): { run: string; job?: string } | undefined => {
244  const m = url?.match(/\/actions\/runs\/(\d+)(?:\/job\/(\d+))?/)
245  return m ? { run: m[1]!, job: m[2] } : undefined
246}
247
248// The gh command that re-runs one check: a failed job alone; a skipped one
249// (never ran) only runs again with its whole run, so the run.
250export const rerunOne = (c: Check): string[] | undefined => {
251  const at = runOf(c.url)
252  if (!at) return undefined
253  if (c.state === 'fail') return at.job ? ['gh', 'run', 'rerun', at.run, '--job', at.job] : ['gh', 'run', 'rerun', at.run, '--failed']
254  if (c.state === 'skipped') return ['gh', 'run', 'rerun', at.run]
255  return undefined
256}
257
258// Every failed or skipped check, one gh command per run: the whole run when
259// any of its jobs was skipped (that also re-runs its failures), else only its
260// failed jobs. A run still going is left alone; gh refuses it.
261export const rerunAll = (checks: readonly Check[]): string[][] => {
262  const runs = new Map<string, CheckState[]>()
263  for (const c of checks) {
264    const at = runOf(c.url)
265    if (at) runs.set(at.run, [...(runs.get(at.run) ?? []), c.state])
266  }
267  return [...runs]
268    .filter(([, states]) => !states.includes('pending'))
269    .flatMap(([run, states]) =>
270      states.includes('skipped')
271        ? [['gh', 'run', 'rerun', run]]
272        : states.includes('fail')
273          ? [['gh', 'run', 'rerun', run, '--failed']]
274          : [],
275    )
276}
277
278// `claude auth status` prints JSON: { loggedIn, authMethod, orgName,
279// subscriptionType: "team" | "max" | "pro" | ..., email }.
280export const parseAuthStatus = (text: string): Account | null => {
281  let raw: { loggedIn?: boolean; authMethod?: string; subscriptionType?: string; orgName?: string; email?: string }
282  try {
283    raw = JSON.parse(text)
284  } catch {
285    return null
286  }
287  if (!raw.loggedIn) return { plan: 'signed out' }
288  if (raw.authMethod !== 'claude.ai') return { plan: 'API key', org: raw.orgName || undefined }
289  const plan = raw.subscriptionType ? raw.subscriptionType[0]!.toUpperCase() + raw.subscriptionType.slice(1) : 'claude.ai'
290  return { plan, org: raw.orgName || undefined, email: raw.email || undefined }
291}
292
293// The session's model as the band names it: `claude-opus-5-5[1m]` → `Opus 5.5 1M`,
294// `claude-sonnet-4-5-20250929` → `Sonnet 4.5`; anything else as given.
295export const modelLabel = (raw: string): string => {
296  const m = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(\[1m\])?$/i.exec(raw.trim())
297  if (!m) return raw.trim()
298  const [, family, major, minor, wide] = m
299  return `${family![0]!.toUpperCase()}${family!.slice(1)} ${major}${minor ? `.${minor}` : ''}${wide ? ' 1M' : ''}`
300}
301
302// 1h 02m · 4m 31s · 12s
303export const formatDuration = (ms: number): string => {
304  const sec = Math.max(0, Math.round(ms / 1000))
305  const h = Math.floor(sec / 3600)
306  const m = Math.floor((sec % 3600) / 60)
307  const s = sec % 60
308  if (h) return `${h}h ${String(m).padStart(2, '0')}m`
309  if (m) return `${m}m ${String(s).padStart(2, '0')}s`
310  return `${s}s`
311}
312
313// Turbo has no live per-task status, so it is read off the stream, whose
314// lines turbo 2.11 prefixes with the task:
315//   //:serve: cache bypass, force executing <hash>     the task started
316//   //#bad:  ERROR  command (...) npm run bad exited (3)  it failed
317// A one-shot task (kill-port) prints nothing when it succeeds; it is done
318// once a task depending on it starts, or the whole run exits 0.
319const STARTED = /^[^:\s]+:(\S+?): cache (?:bypass|miss|hit)/
320const FAILED = /^[^#\s]+#(\S+?):\s+ERROR\s+command .*exited \((\d+)\)/
321
322const OUTPUT = /^[^:\s]+:(\S+?): /
323
324// A port typed or picked in the menu: a whole number from 1 to 65535.
325export const parsePort = (text: string): number | undefined => {
326  const n = Number(text.trim())
327  return text.trim() !== '' && Number.isInteger(n) && n >= 1 && n <= 65535 ? n : undefined
328}
329
330// The ports tried, in order, when the remembered one is taken: the remembered
331// one first, then 1920-1990 in a random order (`random` is Math.random; a
332// test passes its own).
333export const RANDOM_PORTS = { from: 1920, to: 1990 } as const
334export const candidatePorts = (preferred: number, random: () => number = Math.random): number[] => {
335  const pool = Array.from({ length: RANDOM_PORTS.to - RANDOM_PORTS.from + 1 }, (_, i) => RANDOM_PORTS.from + i)
336  for (let i = pool.length - 1; i > 0; i--) {
337    const j = Math.floor(random() * (i + 1))
338    ;[pool[i], pool[j]] = [pool[j]!, pool[i]!]
339  }
340  return [preferred, ...pool.filter(at => at !== preferred)]
341}
342
343const taskName = (taskId: string) => taskId.replace(/^.*#/, '')
344
345// The task a stream line was printed by, if it carries turbo's prefix.
346export const taskOfLine = (line: string): string | undefined => line.replace(ANSI, '').match(OUTPUT)?.[1]
347
348// The tasks `turbo run serve --dry=json` plans, dependencies first.
349export const parseDry = (json: string): Task[] => {
350  const raw = JSON.parse(json) as {
351    tasks: { taskId: string; dependencies?: string[]; resolvedTaskDefinition?: { persistent?: boolean } }[]
352  }
353  return raw.tasks
354    .map(
355      (t): Task => ({
356        name: taskName(t.taskId),
357        state: 'pending',
358        persistent: Boolean(t.resolvedTaskDefinition?.persistent),
359        deps: (t.dependencies ?? []).map(taskName),
360      }),
361    )
362    .sort((a, b) => a.deps.length - b.deps.length || a.name.localeCompare(b.name))
363}
364
365const upsert = (tasks: readonly Task[], name: string, change: (t: Task) => Task): Task[] =>
366  tasks.some(t => t.name === name)
367    ? tasks.map(t => (t.name === name ? change(t) : t))
368    : [...tasks, change({ name, state: 'pending', persistent: false, deps: [] })]
369
370export const applyLine = (tasks: readonly Task[], rawLine: string): readonly Task[] => {
371  const line = rawLine.replace(ANSI, '')
372  const failed = line.match(FAILED)
373  if (failed) return upsert(tasks, failed[1]!, t => ({ ...t, state: 'failed', code: Number(failed[2]) }))
374  const started = line.match(STARTED)
375  if (!started) return tasks
376  const name = started[1]!
377  const next = upsert(tasks, name, t => ({ ...t, state: 'running' }))
378  const deps = next.find(t => t.name === name)!.deps
379  return next.map(t => (deps.includes(t.name) && t.state !== 'failed' ? { ...t, state: 'done' } : t))
380}
381
382// The run is over: a clean exit finished the one-shot tasks; anything else
383// still marked running was stopped with it.
384export const finishTasks = (tasks: readonly Task[], code: number | null): Task[] =>
385  tasks.map(t =>
386    t.state === 'running' || t.state === 'pending'
387      ? { ...t, state: code === 0 && !t.persistent && t.state === 'running' ? 'done' : 'stopped' }
388      : t,
389  )
390
391// Planned tasks first, in plan order, with what the stream already showed.
392export const mergePlan = (plan: readonly Task[], seen: readonly Task[]): Task[] => [
393  ...plan.map(p => {
394    const s = seen.find(t => t.name === p.name)
395    return s ? { ...p, state: s.state, code: s.code } : p
396  }),
397  ...seen.filter(t => !plan.some(p => p.name === t.name)),
398]
399
400const TASK_MARK: Record<Task['state'], string> = {
401  pending: '○',
402  running: '●',
403  done: '✓',
404  failed: '✗',
405  stopped: '■',
406}
407const TASK_COLOR: Record<Task['state'], 'success' | 'error' | 'subtle' | undefined> = {
408  pending: undefined,
409  running: 'success',
410  done: 'subtle',
411  failed: 'error',
412  stopped: undefined,
413}
414
415// $.state outlives a reload: a Serve saved by an older version of this mod
416// has no `tasks`, and reading it as an array would blank the whole band.
417const tasksOf = (s: Serve): readonly Task[] => s.tasks ?? []
418
419// Each task's recent output, for the logs pane. Kept in the module, not in
420// $.state: it changes on every line and only the pane reads it.
421const LOGS_PANE = 'server-logs'
422// The one task a plain (not turbo) server shows, its lines its logs.
423const PLAIN_TASK = 'server'
424const LOG_LINES = 500
425const LOG_REDRAW_MS = 250
426const logs = new Map<string, string[]>()
427let logRedrawAt = -Infinity
428
429export const keepLines = (kept: readonly string[], lines: readonly string[], max: number): string[] =>
430  [...kept, ...lines].slice(-max)
431
432// `//:serve: listening on 1776` → `listening on 1776`
433export const withoutPrefix = (line: string): string => line.replace(ANSI, '').replace(OUTPUT, '')
434
435async function recordLogs($: EngineInterface, lines: readonly string[]) {
436  const isPlain = serverKind(await read($, settings)) === 'plain'
437  const byTask = new Map<string, string[]>()
438  for (const line of lines) {
439    const task = isPlain ? PLAIN_TASK : taskOfLine(line)
440    if (task) byTask.set(task, [...(byTask.get(task) ?? []), isPlain ? line : withoutPrefix(line)])
441  }
442  for (const [task, added] of byTask) logs.set(task, keepLines(logs.get(task) ?? [], added, LOG_LINES))
443
444  const view = await read($, logView)
445  const now = await $.clock.now()
446  if (byTask.has(view.task) && now - logRedrawAt >= LOG_REDRAW_MS) {
447    logRedrawAt = now
448    await update($, logView, v => ({ ...v, tick: v.tick + 1 }))
449  }
450}
451
452async function toggleLogs($: EngineInterface, task: string) {
453  const view = await read($, logView)
454  const isOpen = (await $.ui.panes()).some(pane => pane.id === LOGS_PANE)
455  if (isOpen && view.task === task) return $.ui.close({ id: LOGS_PANE })
456  await update($, logView, v => ({ task, tick: v.tick + 1 }))
457  await $.ui.open({ id: LOGS_PANE, title: `${task} logs`, closeOnEscape: true })
458}
459
460const FILES_PANE = 'files'
461const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
462const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
463const EDIT_LINGER_MS = 1500
464
465// Kept in the module, not $.state: the repo's 12k paths would be copied on
466// every write. `filesTick` redraws the pane when they change.
467let repoTop = ''
468let allFiles: string[] = []
469let prChanges: Change[] = []
470let uncommitted: Change[] = []
471const editing = new Map<string, number>() // path → how many edits are in flight
472let spinTimer: { cancel: () => void } | null = null
473
474async function refreshFiles($: EngineInterface) {
475  const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'])
476  if (top.exitCode !== 0) return
477  repoTop = top.stdout.trim()
478  const base = (await read($, pr))?.base ?? ((await read($, defaultBranch)) || 'main')
479  const [ls, status, diff] = await Promise.all([
480    $.process.run(['git', '-C', repoTop, 'ls-files']),
481    $.process.run(['git', '-C', repoTop, 'status', '--porcelain=v1', '-uall']),
482    $.process.run(['git', '-C', repoTop, 'diff', '--name-status', `origin/${base}...HEAD`]),
483  ])
484  allFiles = ls.exitCode === 0 ? ls.stdout.split('\n').filter(Boolean) : []
485  uncommitted = status.exitCode === 0 ? parseStatus(status.stdout) : []
486  prChanges = diff.exitCode === 0 ? parseNameStatus(diff.stdout) : []
487  await update($, filesTick, n => n + 1)
488}
489
490async function isFilesOpen($: EngineInterface) {
491  return (await $.ui.panes()).some(pane => pane.id === FILES_PANE)
492}
493
494// The filetree mod (data-goblin/claude-code-filetree), when it is installed:
495// its /filetree command opens its own pane, which this mod can neither list
496// nor close, so the Files button opens or refocuses it rather than toggling.
497async function hasFiletree($: EngineInterface) {
498  try {
499    return (await $.command.list()).some(c => c.name === 'filetree')
500  } catch {
501    return false
502  }
503}
504
505async function toggleFiles($: EngineInterface) {
506  if (await hasFiletree($)) {
507    const { text } = await $.command.run({ command: 'filetree' })
508    // filetree answers a refusal (no fullscreen layout, a narrow terminal) as text.
509    if (text && !/^File tree on /.test(text)) $.ui.toast(text)
510    return
511  }
512  if (await isFilesOpen($)) return $.ui.close({ id: FILES_PANE })
513  await $.ui.open({ id: FILES_PANE, title: 'Files', closeOnEscape: true })
514  await refreshFiles($)
515}
516
517async function toggleDir($: EngineInterface, dir: string) {
518  const flip = (dirs: string[]) => (dirs.includes(dir) ? dirs.filter(d => d !== dir) : [...dirs, dir])
519  if ((await read($, filesMode)) === 'all') await update($, openDirs, flip)
520  else await update($, closedDirs, flip)
521}
522
523async function startEdit($: EngineInterface, path: string) {
524  editing.set(path, (editing.get(path) ?? 0) + 1)
525  spinTimer ??= $.clock.every(120, () => void update($, editFrame, f => f + 1).catch(() => {}))
526  await update($, editFrame, f => f + 1) // show it now, not at the first tick
527}
528
529async function endEdit($: EngineInterface, path: string) {
530  const left = (editing.get(path) ?? 1) - 1
531  if (left > 0) editing.set(path, left)
532  else editing.delete(path)
533  if (editing.size === 0 && spinTimer) {
534    spinTimer.cancel()
535    spinTimer = null
536    await update($, editFrame, f => f + 1)
537  }
538  if (await isFilesOpen($)) await refreshFiles($)
539}
540
541const CHECKS_PANE = 'pr-checks'
542
543async function toggleChecks($: EngineInterface) {
544  if ((await $.ui.panes()).some(pane => pane.id === CHECKS_PANE)) return $.ui.close({ id: CHECKS_PANE })
545  const current = await read($, pr)
546  await $.ui.open({ id: CHECKS_PANE, title: current ? `PR #${current.number} checks` : 'PR checks', closeOnEscape: true })
547  void refreshAll($) // the pane shows what gh says now; a merge it sees is told
548}
549
550const MARK_COLOR = { pr: 'suggestion', uncommitted: 'warning' } as const
551
552async function rerun($: EngineInterface, commands: readonly string[][], what: string) {
553  const runs = commands.map(argv => argv[3]!)
554  await update($, rerunAsked, asked => [...new Set([...asked, ...runs])])
555  const results = await Promise.all(commands.map(argv => $.process.run(argv)))
556  const failed = results.find(r => r.exitCode !== 0)
557  if (failed) {
558    $.ui.toast(`Re-run of ${what} failed: ${(failed.stderr || failed.stdout).trim().split('\n')[0]}`)
559    await update($, rerunAsked, asked => asked.filter(r => !runs.includes(r)))
560    return
561  }
562  $.ui.toast(`Re-run started: ${what}`)
563  $.clock.after(5_000, () => void refreshAll($)) // pick up the new attempt
564}
565
566const CHECK_MARK: Record<CheckState, string> = { fail: '✗', pending: '…', pass: '✓', skipped: '–' }
567const CHECK_COLOR: Record<CheckState, 'error' | 'warning' | 'success' | 'subtle'> = {
568  fail: 'error',
569  pending: 'warning',
570  pass: 'success',
571  skipped: 'subtle',
572}
573
574// A running task's dot lights up while it prints, and dims FLASH_MS after.
575const FLASH_MS = 400
576const flashTimers = new Map<string, { cancel: () => void }>()
577
578const setFlash = (tasks: readonly Task[], names: ReadonlySet<string>, isOn: boolean): Task[] =>
579  tasks.map(t => (names.has(t.name) ? { ...t, flash: isOn } : t))
580
581async function flash($: EngineInterface, names: ReadonlySet<string>, mine: number) {
582  const fresh = new Set([...names].filter(n => !flashTimers.has(n)))
583  if (fresh.size) await update($, serve, (s): Serve => ({ ...s, tasks: setFlash(tasksOf(s), fresh, true) }))
584  for (const name of names) {
585    flashTimers.get(name)?.cancel()
586    flashTimers.set(
587      name,
588      $.clock.after(FLASH_MS, () => {
589        flashTimers.delete(name)
590        if (mine !== generation) return
591        void update($, serve, (s): Serve => ({ ...s, tasks: setFlash(tasksOf(s), new Set([name]), false) })).catch(() => {})
592      }),
593    )
594  }
595}
596
597let child: AsyncGenerator<unknown, unknown> | null = null
598let generation = 0
599let lastPrFetch = 0
600let plan: Task[] | null = null
601
602async function refreshPr($: EngineInterface): Promise<Pr | null> {
603  lastPrFetch = await $.clock.now()
604  const { exitCode, stdout } = await $.process.run([
605    'gh', 'pr', 'view', '--json',
606    'number,state,isDraft,statusCheckRollup,title,baseRefName,url',
607  ])
608  if (exitCode !== 0) {
609    await update($, pr, () => null)
610    return null
611  }
612  const raw = JSON.parse(stdout)
613  const next: Pr = {
614    number: raw.number,
615    state: String(raw.state).toLowerCase(),
616    isDraft: Boolean(raw.isDraft),
617    title: raw.title ?? '',
618    ...countChecks(raw.statusCheckRollup ?? []),
619    checks: toChecks(raw.statusCheckRollup ?? []),
620    base: raw.baseRefName || undefined,
621    url: raw.url || undefined,
622  }
623  await update($, pr, () => next)
624  const going = new Set(next.checks?.filter(c => c.state === 'pending').map(c => runOf(c.url)?.run))
625  await update($, rerunAsked, asked => asked.filter(run => !going.has(run)))
626  return next
627}
628
629// Jira runs only where the repo's settings name its site.
630async function refreshJira($: EngineInterface, current: Pr | null) {
631  const cfg = await read($, settings)
632  const site = jiraSite(cfg)
633  if (!site) {
634    await update($, jira, () => null)
635    return
636  }
637  const branch = (await $.process.run(['git', 'branch', '--show-current'])).stdout.trim()
638  const key = issueKey(cfg, current?.title ?? '', branch)
639  if (!key) {
640    await update($, jira, () => null)
641    return
642  }
643  const tool = pickJiraTool((await $.tool.list()).map(t => t.name))
644  if (!tool) {
645    await update($, jira, (): Jira => ({ key, error: 'no Jira MCP connected' }))
646    return
647  }
648  const input = { tool, cloudId: site, issueIdOrKey: key, fields: ['summary', 'status'] }
649  // A refresh every two minutes must never open a permission dialog.
650  const { decision } = await $.tool.check({ tool, input })
651  if (decision !== 'allow') {
652    await update($, jira, (): Jira => ({ key, error: `allow ${tool} to show the title` }))
653    return
654  }
655  const result = await $.tool.call(input as never)
656  const text = 'text' in result && typeof result.text === 'string' ? result.text : ''
657  const title = parseJiraTitle(text)
658  const statusName = parseJiraStatus(text)
659  const status = statusName ? abbreviateStatus(statusName) : undefined
660  await update($, jira, (): Jira => (title ? (status ? { key, title, status } : { key, title }) : { key, error: 'no title' }))
661}
662
663// Clicking the status: the issue's transitions in a picker, the chosen one
664// applied, the band refreshed. A click may open the tools' permission dialog.
665async function changeJiraStatus($: EngineInterface) {
666  const j = await read($, jira)
667  const site = jiraSite(await read($, settings))
668  if (!j || !('title' in j) || !site) return
669  const names = (await $.tool.list()).map(t => t.name)
670  const list = pickJiraTool(names, 'getTransitionsForJiraIssue')
671  const move = pickJiraTool(names, 'transitionJiraIssue')
672  if (!list || !move) {
673    $.ui.toast('The Jira MCP cannot change statuses')
674    return
675  }
676  const got = await $.tool.call({ tool: list, cloudId: site, issueIdOrKey: j.key } as never)
677  if ('deny' in got) {
678    $.ui.toast(`Could not list ${j.key}'s statuses: ${got.deny}`)
679    return
680  }
681  const all = parseTransitions('text' in got && typeof got.text === 'string' ? got.text : '')
682  const options = all.filter(t => abbreviateStatus(t.to) !== j.status)
683  if (options.length === 0) {
684    $.ui.toast(`No status changes offered for ${j.key}`)
685    return
686  }
687  let choice: string = MORE
688  for (let page = 0; choice === MORE; page++) {
689    try {
690      choice = await $.ui.ask(`Move ${j.key} to which status?`, {
691        header: 'Jira',
692        options: transitionPage(
693          options.map(t => t.name),
694          page,
695        ),
696      })
697    } catch {
698      return // dismissed
699    }
700  }
701  const picked = options.find(t => t.name.toLowerCase() === choice.trim().toLowerCase())
702  if (!picked) {
703    $.ui.toast(`"${choice}" is not a status ${j.key} can move to`)
704    return
705  }
706  const done = await $.tool.call({ tool: move, cloudId: site, issueIdOrKey: j.key, transition: { id: picked.id } } as never)
707  if ('deny' in done || done.isError) {
708    $.ui.toast(`${j.key} was not moved: ${('deny' in done ? done.deny : done.text) ?? ''}`.trim())
709    return
710  }
711  $.ui.toast(`${j.key} → ${picked.to}`)
712  await refreshJira($, await read($, pr))
713}
714
715// A check that could not run (git failed) leaves the flag as it was, so it
716// never turns off a band that is on; only a remote that is not on GitHub, or
717// settings that turn the band off, turn it off.
718async function checkRepo($: EngineInterface): Promise<boolean> {
719  const { exitCode, stdout } = await $.process.run(['git', 'remote', 'get-url', 'origin'])
720  if (exitCode !== 0) return read($, canServe)
721  answered = true
722  const name = repoOf(stdout) ?? ''
723  await update($, repo, () => name)
724  const cfg = name ? await loadSettings($, name) : {}
725  const ok = name !== '' && isEnabled(cfg)
726  await update($, canServe, () => ok)
727  return ok
728}
729
730// ~/.claude/work-band.json (or under CLAUDE_CONFIG_DIR).
731async function settingsPath($: EngineInterface): Promise<string | undefined> {
732  const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? ((await $.env.get('HOME')) ? `${await $.env.get('HOME')}/.claude` : undefined)
733  return dir ? `${dir}/${SETTINGS_FILE}` : undefined
734}
735
736async function readSettingsFile($: EngineInterface): Promise<Record<string, RepoSettings>> {
737  const path = await settingsPath($)
738  if (!path) return {}
739  const text = await $.fs.read(path).catch(() => '')
740  return parseSettingsFile(typeof text === 'string' ? text : '')
741}
742
743async function loadSettings($: EngineInterface, name: string): Promise<RepoSettings> {
744  const cfg = (await readSettingsFile($))[name] ?? {}
745  await update($, settings, () => cfg)
746  return cfg
747}
748
749// Writes this repo's entry and applies it at once.
750async function saveSettings($: EngineInterface, cfg: RepoSettings) {
751  const name = await read($, repo)
752  const path = await settingsPath($)
753  if (!name || !path) {
754    $.ui.toast('No GitHub repo here, so there is nothing to save settings for')
755    return
756  }
757  const all = await readSettingsFile($)
758  all[name] = cfg
759  await $.fs.write(path, formatSettingsFile(all))
760  const wasOn = await read($, canServe)
761  await update($, settings, () => cfg)
762  await update($, canServe, () => isEnabled(cfg))
763  if (isEnabled(cfg) && !wasOn) await activate($)
764  void refreshAll($)
765}
766
767// Whether the band is on in this session. Once git has named the remote,
768// that answer stands: another repo is never checked again. Only while git
769// has not answered (the check failed) does a hook try again, at most every
770// RECHECK_MS and MAX_RETRIES times in all, and a match switches the band on
771// then and there. A hot reload
772// forgets `answered`, so another repo is checked once more after one.
773const RECHECK_MS = 30_000
774const MAX_RETRIES = 5
775let answered = false
776let lastCheck = Number.NEGATIVE_INFINITY
777let checking: Promise<boolean> | null = null
778let failedChecks = 0
779async function isOn($: EngineInterface): Promise<boolean> {
780  if (await read($, canServe)) return true
781  if (answered || failedChecks >= MAX_RETRIES) return false
782  const now = await $.clock.now()
783  if (now - lastCheck < RECHECK_MS) return false
784  lastCheck = now
785  checking ??= checkRepo($)
786    .then(async ok => {
787      if (!answered) failedChecks += 1
788      if (ok) await activate($)
789      return ok
790    })
791    .finally(() => {
792      checking = null
793    })
794  return checking
795}
796
797// The model is told only of a merge this session saw happen (open, then
798// merged), never of a PR that was already merged when the session started.
799// It is told what happened, not what to do about it.
800export type MergeFacts = { by?: string; at?: string; commit?: string }
801// `jiraKey` null: Jira is off for this repo, so nothing is said of it.
802export const mergePrompt = (pr: { number: number; title: string; base?: string; url?: string }, jiraKey: string | null | undefined, facts: MergeFacts = {}): string =>
803  [
804    `PR #${pr.number} ("${pr.title}") was merged${pr.base ? ` into ${pr.base}` : ''}${facts.by ? ` by ${facts.by}` : ''}${facts.at ? ` at ${facts.at}` : ''}${facts.commit ? `, merge commit ${facts.commit.slice(0, 7)}` : ''}.`,
805    pr.url ?? '',
806    jiraKey === null ? '' : jiraKey ? `Jira issue: ${jiraKey}.` : 'No Jira issue key in the PR title or the branch name.',
807  ]
808    .filter(Boolean)
809    .join(' ')
810
811// Who merged it, when, and the merge commit, read once when the merge is seen.
812export const parseMergeFacts = (json: string): MergeFacts => {
813  try {
814    const j = JSON.parse(json) as { mergedBy?: { login?: string }; mergedAt?: string; mergeCommit?: { oid?: string } }
815    return { by: j.mergedBy?.login, at: j.mergedAt, commit: j.mergeCommit?.oid }
816  } catch {
817    return {}
818  }
819}
820
821async function tellIfMerged($: EngineInterface, before: Pr | null, after: Pr | null) {
822  if (!before || !after || before.number !== after.number) return
823  if (before.state !== 'open' || after.state !== 'merged') return
824  let isNew = false
825  await update($, mergedTold, told => (told.includes(after.number) ? told : ((isNew = true), [...told, after.number])))
826  if (!isNew) return
827  const branch = (await $.process.run(['git', 'branch', '--show-current'])).stdout.trim()
828  const cfg = await read($, settings)
829  const key = jiraSite(cfg) ? (issueKey(cfg, after.title, branch) ?? undefined) : null
830  $.ui.toast(`PR #${after.number} merged`)
831  const got = await $.process.run(['gh', 'pr', 'view', String(after.number), '--json', 'mergedBy,mergedAt,mergeCommit'])
832  const facts = got.exitCode === 0 ? parseMergeFacts(got.stdout) : {}
833  void $.prompt.submit({ text: mergePrompt(after, key, facts) })
834}
835
836async function refreshDiff($: EngineInterface, current: Pr | null) {
837  const base = current?.base ?? ((await read($, defaultBranch)) || 'main')
838  const { exitCode, stdout } = await $.process.run(['git', 'diff', '--shortstat', `origin/${base}...HEAD`])
839  await update($, diffStat, (): DiffStat | null => (exitCode === 0 ? { ...parseShortstat(stdout), base } : null))
840}
841
842async function refreshAccount($: EngineInterface) {
843  const { exitCode, stdout } = await $.process.run(['claude', 'auth', 'status'], { timeoutMs: 15_000 })
844  await update($, account, () => (exitCode === 0 ? parseAuthStatus(stdout) : null))
845}
846
847// The PR poll, which is how a merge is seen while the session sits idle. A
848// hot reload cancels the timer without running session.start again, so the
849// band re-arms it whenever it draws.
850let pollTimer: { cancel(): void } | null = null
851function ensurePolling($: EngineInterface) {
852  pollTimer ??= $.clock.every(POLL_MS, () => void refreshAll($))
853}
854
855async function refreshAll($: EngineInterface) {
856  try {
857    const before = await read($, pr)
858    const head = await $.process.run(['git', 'branch', '--show-current'])
859    if (head.exitCode === 0) await update($, branch, () => head.stdout.trim())
860    const current = await refreshPr($)
861    await refreshDiff($, current)
862    await tellIfMerged($, before, current)
863    await refreshJira($, current)
864  } catch (err) {
865    $.ui.log(`work-band refresh failed: ${String(err)}`, { to: 'debug' })
866  }
867}
868
869// Until the server answers on its port, `server` flashes blue; once it
870// answers it turns green and the port is not checked again. The first check
871// is a couple of seconds in, after kill-port has cleared the port; the band
872// cannot wait on kill-port's own state, which turbo leaves running.
873const BLINK_MS = 500
874const BLINKS_PER_CHECK = 4
875let upTimer: { cancel(): void } | null = null
876
877function watchUp($: EngineInterface, mine: number) {
878  upTimer?.cancel()
879  let ticks = 0
880  const timer = $.clock.every(BLINK_MS, () =>
881    void (async () => {
882      const s = await read($, serve)
883      if (mine !== generation || s.state !== 'running' || s.up) {
884        timer.cancel()
885        if (upTimer === timer) upTimer = null
886        return
887      }
888      await update($, serve, (s): Serve => ({ ...s, blink: !s.blink }))
889      if (++ticks % BLINKS_PER_CHECK) return
890      const r = await $.process.run(['curl', '-s', '-o', '/dev/null', '-m', '2', serverUrl(await read($, port))])
891      if (r.exitCode !== 0 || mine !== generation) return
892      timer.cancel()
893      if (upTimer === timer) upTimer = null
894      await update($, serve, (s): Serve => (s.state === 'running' ? { ...s, up: true, blink: false } : s))
895    })().catch(() => {}),
896  )
897  upTimer = timer
898}
899
900// The pids of every server this Claude process started (a process whose
901// command holds `match`, such as `turbo run serve`), and of everything under
902// them. `ps` rows are `pid ppid command`.
903export const serveTrees = (ps: string, me: number, match = 'turbo run serve'): number[] => {
904  const rows = ps
905    .split('\n')
906    .map(l => /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(l))
907    .filter((m): m is RegExpExecArray => m !== null)
908    .map(m => ({ pid: Number(m[1]), ppid: Number(m[2]), command: m[3]! }))
909  const out = match ? rows.filter(r => r.ppid === me && r.command.includes(match)).map(r => r.pid) : []
910  for (let i = 0; i < out.length; i++) for (const r of rows) if (r.ppid === out[i] && !out.includes(r.pid)) out.push(r.pid)
911  return out
912}
913
914// Leaving the stream kills only npm, not the turbo, yarn, server and vite
915// under it, and a hot reload of this module loses `child` altogether, so a
916// restart would start a second server beside the first. Every server tree
917// this Claude process started is found with `ps` and killed instead.
918async function killServers($: EngineInterface) {
919  const me = Number((await $.process.run(['sh', '-c', 'echo $PPID'])).stdout.trim())
920  if (!me) return
921  const match = serverMatch(await read($, settings))
922  if (!match) return
923  const pids = serveTrees((await $.process.run(['ps', '-axo', 'pid=,ppid=,command='])).stdout, me, match)
924  if (pids.length) await $.process.run(['kill', '-TERM', ...pids.map(String)])
925}
926
927async function stopServe($: EngineInterface) {
928  ++generation
929  if (child) {
930    const old = child
931    child = null
932    await old.return(undefined) // leaving the stream kills the child
933  }
934  await killServers($).catch(() => {})
935  await update($, serve, (s): Serve => ({ state: 'stopped', tasks: finishTasks(tasksOf(s), null) }))
936}
937
938// Chrome's tab already on this server comes to the front, else a new tab
939// opens. Matching the origin plus `/` keeps :1776 from matching :17760.
940export const CHROME_FOCUS_TAB = `on run argv
941  set origin to item 1 of argv
942  tell application "Google Chrome"
943    repeat with w in windows
944      set i to 0
945      repeat with t in tabs of w
946        set i to i + 1
947        set u to URL of t
948        if u is origin or u starts with (origin & "/") then
949          set active tab index of w to i
950          set index of w to 1
951          activate
952          return "reused"
953        end if
954      end repeat
955    end repeat
956    open location origin
957    activate
958    return "opened"
959  end tell
960end run`
961
962export const serverUrl = (at: number) => `http://localhost:${at}`
963
964// A Button that opens a URL rather than a Link: a terminal without OSC 8
965// draws a Link's URL beside its text, and the band has no room for that.
966async function openUrl($: EngineInterface, url: string) {
967  await $.process.run(['open', url])
968}
969
970async function openServer($: EngineInterface) {
971  const url = serverUrl(await read($, port))
972  const chrome = await $.process.run(['osascript', '-e', CHROME_FOCUS_TAB, url]).catch(() => null)
973  if (chrome?.exitCode === 0) return
974  // Not Chrome, or macOS refused the terminal control of it: a plain open.
975  await $.process.run(['open', url])
976}
977
978// Something is listening on the port (lsof answers its pids, exit 0).
979async function isPortTaken($: EngineInterface, at: number) {
980  const { exitCode, stdout } = await $.process.run(['lsof', '-nP', `-iTCP:${at}`, '-sTCP:LISTEN', '-t'])
981  return exitCode === 0 && stdout.trim() !== ''
982}
983
984// At session start: keep the remembered port if it is free, else take a
985// random free one in 1920-1990 for this session (the remembered one stays stored).
986async function pickFreePort($: EngineInterface) {
987  const preferred = await read($, port)
988  for (const at of candidatePorts(preferred)) {
989    if (await isPortTaken($, at)) continue
990    if (at !== preferred) {
991      await update($, port, () => at)
992      $.ui.toast(`Port ${preferred} is in use; the server will use ${at}`)
993    }
994    return
995  }
996}
997
998async function choosePort($: EngineInterface) {
999  const current = await read($, port)
1000  let answer: string
1001  try {
1002    answer = await $.ui.ask(`Which port should the server use? It is on ${current} now.`, {
1003      header: 'port',
1004      options: ports(await read($, settings)).slice(0, 4).map(String),
1005    })
1006  } catch {
1007    return // dismissed
1008  }
1009  const chosen = parsePort(answer)
1010  if (chosen === undefined) {
1011    $.ui.toast(`"${answer}" is not a port (1-65535)`)
1012    return
1013  }
1014  await update($, port, () => chosen)
1015  await $.store.set(portKey(await read($, repo)), chosen)
1016  if ((await read($, serve)).state === 'running') await restartServe($)
1017}
1018
1019async function openServeMenu($: EngineInterface) {
1020  const isRunning = (await read($, serve)).state === 'running'
1021  const at = await read($, port)
1022  let choice: string
1023  try {
1024    choice = isRunning
1025      ? await $.ui.ask(`The server is running on ${at}. What should happen to it?`, {
1026          header: 'server',
1027          options: ['Restart', 'Kill', 'Change port', 'Cancel'],
1028        })
1029      : await $.ui.ask(`The server is stopped. Start it on ${at}?`, {
1030          header: 'server',
1031          options: ['Start', 'Change port', 'Cancel'],
1032        })
1033  } catch {
1034    return // dismissed
1035  }
1036  if (choice === 'Restart' || choice === 'Start') return restartServe($)
1037  if (choice === 'Kill') return stopServe($)
1038  if (choice === 'Change port') return choosePort($)
1039}
1040
1041async function loadPlan($: EngineInterface, mine: number) {
1042  const dry = planArgv(await read($, settings))
1043  if (!dry) return
1044  if (!plan) {
1045    const { exitCode, stdout } = await $.process.run(dry, { timeoutMs: 120_000 })
1046    if (exitCode !== 0) return
1047    plan = parseDry(stdout)
1048  }
1049  const planned = plan
1050  if (mine === generation) await update($, serve, (s): Serve => ({ ...s, tasks: mergePlan(planned, tasksOf(s)) }))
1051}
1052
1053async function restartServe($: EngineInterface) {
1054  const mine = ++generation
1055  if (child) {
1056    const old = child
1057    child = null
1058    await old.return(undefined) // leaving the stream kills the child
1059  }
1060  await killServers($).catch(() => {})
1061  logs.clear()
1062  // The command, its port variable and any extra env come from the repo's
1063  // settings (alertd-core's sets ORCHESTRATOR_SINGLETON_GATE=false there).
1064  const cfg = await read($, settings)
1065  const argv = serverArgv(cfg)
1066  if (!argv.length) return
1067  const stream = $.process.spawn({ argv, env: serverEnv(cfg, await read($, port)) })
1068  child = stream
1069  // A plain server is one task, `server`, whose lines are its logs.
1070  const first: readonly Task[] = serverKind(cfg) === 'plain' ? [{ name: PLAIN_TASK, state: 'running', persistent: true, deps: [] }] : (plan ?? [])
1071  await update($, serve, (): Serve => ({ state: 'running', tasks: first }))
1072  watchUp($, mine)
1073  void loadPlan($, mine).catch(() => {})
1074
1075  void (async () => {
1076    let rest = ''
1077    try {
1078      let step = await stream.next()
1079      while (!step.done) {
1080        const lines = (rest + step.value.text).split(/\r?\n|\r/)
1081        rest = lines.pop() ?? ''
1082        if (mine === generation && lines.some(l => STARTED.test(l.replace(ANSI, '')) || FAILED.test(l.replace(ANSI, '')))) {
1083          await update($, serve, (s): Serve => ({ ...s, tasks: lines.reduce(applyLine, tasksOf(s)) }))
1084        }
1085        if (mine === generation) await recordLogs($, lines)
1086        const printing = new Set(lines.map(taskOfLine).filter((n): n is string => n !== undefined))
1087        if (mine === generation && printing.size) await flash($, printing, mine)
1088        step = await stream.next()
1089      }
1090      if (mine === generation) {
1091        child = null
1092        const { code, signal } = step.value
1093        await update($, serve, (s): Serve => ({
1094          state: 'exited',
1095          code: code ?? null,
1096          signal: signal ?? undefined,
1097          tasks: finishTasks(applyLine(tasksOf(s), rest), code),
1098        }))
1099      }
1100    } catch (err) {
1101      if (mine === generation) {
1102        child = null
1103        await update($, serve, (s): Serve => ({ ...s, state: 'exited', code: null, tasks: finishTasks(tasksOf(s), null) }))
1104        $.ui.log(`work-band: server stream failed: ${String(err)}`, { to: 'debug' })
1105      }
1106    }
1107  })().catch(() => {
1108    // the module unloaded while the child ran: nothing left to update
1109  })
1110}
1111
1112// ---------- settings: the pane, one question per setting, the wizard ----------
1113const SETTINGS_PANE = 'wb-settings'
1114const SETTING_KEYS = ['enabled', 'jira.site', 'jira.projects', 'server.kind', 'server.portEnv', 'server.ports', 'server.env'] as const
1115type SettingKey = (typeof SETTING_KEYS)[number]
1116
1117const KEEP = 'Keep as is'
1118const copy = (cfg: RepoSettings): RepoSettings => JSON.parse(JSON.stringify(cfg)) as RepoSettings
1119
1120// The servers the repo looks like it has, from its turbo.json and package.json.
1121async function detectedServers($: EngineInterface) {
1122  const top = repoTop || '.'
1123  const [turbo, pkg] = await Promise.all([
1124    $.fs.read(`${top}/turbo.json`).catch(() => undefined),
1125    $.fs.read(`${top}/package.json`).catch(() => undefined),
1126  ])
1127  return detectServers(typeof turbo === 'string' ? turbo : undefined, typeof pkg === 'string' ? pkg : undefined)
1128}
1129
1130// Asks for one setting and returns the settings with it changed; undefined
1131// when the question was dismissed or the answer did not parse (a toast says
1132// why). Each question takes free text through the dialog's Other.
1133async function askSetting($: EngineInterface, key: SettingKey, cfg: RepoSettings): Promise<RepoSettings | undefined> {
1134  const next = copy(cfg)
1135  // The dialog pads one option with Yes/No, so a lone option gets Keep as is.
1136  const ask = (question: string, header: string, options: string[]) => {
1137    const list = [...new Set(options.filter(Boolean))].slice(0, 4)
1138    return $.ui.ask(question, { header, options: list.length < 2 ? [...list, KEEP] : list }).catch(() => undefined)
1139  }
1140  if (key === 'enabled') {
1141    const a = await ask('Show the work band in this repo?', 'band', ['On', 'Off'])
1142    if (a === undefined) return undefined
1143    if (a === KEEP) return next
1144    if (a === 'Off') next.enabled = false
1145    else delete next.enabled
1146    return next
1147  }
1148  if (key === 'jira.site') {
1149    const current = jiraSite(cfg)
1150    const a = await ask('Which Jira site does this repo use? Type it under Other (yourco.atlassian.net), or None to turn Jira off.', 'Jira', ['None', ...(current ? [current] : [])])
1151    if (a === undefined) return undefined
1152    if (a === KEEP) return next
1153    const site = a.trim().replace(/^https?:\/\//, '').replace(/\/.*$/, '')
1154    if (a === 'None' || !site) {
1155      if (next.jira) delete next.jira.site
1156    } else next.jira = { ...next.jira, site }
1157    return next
1158  }
1159  if (key === 'jira.projects') {
1160    const current = cfg.jira?.projects?.join(', ')
1161    const a = await ask('Which Jira projects count? Type their keys under Other (AL, OPS), or Any.', 'projects', ['Any', ...(current ? [current] : [])])
1162    if (a === undefined) return undefined
1163    if (a === KEEP) return next
1164    const list = a === 'Any' ? [] : a.split(/[\s,]+/).map(k => k.trim().toUpperCase()).filter(Boolean)
1165    next.jira = { ...next.jira, projects: list.length ? list : undefined }
1166    if (!list.length) delete next.jira.projects
1167    return next
1168  }
1169  if (key === 'server.kind') {
1170    const found = await detectedServers($)
1171    const a = await ask('Which dev server should the band run? Or type a command under Other.', 'server', ['None', ...found.map(f => f.label)])
1172    if (a === undefined) return undefined
1173    if (a === KEEP) return next
1174    if (a === 'None') {
1175      next.server = { ...next.server, kind: 'none' }
1176      return next
1177    }
1178    const hit = found.find(f => f.label === a)
1179    const command = hit ? hit.command : a.trim().split(/\s+/).filter(Boolean)
1180    if (!command.length) return undefined
1181    next.server = { ...next.server, kind: hit ? hit.kind : command.includes('turbo') ? 'turbo' : 'plain', command }
1182    return next
1183  }
1184  if (key === 'server.portEnv') {
1185    const a = await ask('Which environment variable does the server read its port from?', 'port var', [portEnv(cfg), 'PORT'])
1186    if (a === undefined) return undefined
1187    if (a === KEEP) return next
1188    const name = a.trim()
1189    if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
1190      $.ui.toast(`"${a}" is not a variable name`)
1191      return undefined
1192    }
1193    next.server = { ...next.server, portEnv: name }
1194    return next
1195  }
1196  if (key === 'server.ports') {
1197    const a = await ask('Which ports may the server use, first choice first? Type them under Other (3000, 3001).', 'ports', [ports(cfg).join(', '), '3000'])
1198    if (a === undefined) return undefined
1199    if (a === KEEP) return next
1200    const list = parsePorts(a)
hooks/settings.ts 144 lines
1// The band's per-repo settings, kept in one file in the person's home folder
2// (`~/.claude/work-band.json`), keyed by `owner/repo`; nothing is stored in
3// the repo. A repo with no entry gets the defaults: the band on, no Jira, no
4// server.
5
6import type { RepoSettings, ServerKind } from '../types'
7
8export const SETTINGS_FILE = 'work-band.json'
9
10// `git@github.com:alert-d/alertd-core.git`, `https://github.com/o/r` → `o/r`;
11// undefined for a remote that is not on GitHub.
12export const repoOf = (remote: string): string | undefined =>
13  /github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/.exec(remote.trim())?.[1]
14
15// The whole file, one entry per repo; anything that is not an object reads
16// as no settings.
17export const parseSettingsFile = (text: string): Record<string, RepoSettings> => {
18  try {
19    const all = JSON.parse(text) as unknown
20    return all && typeof all === 'object' && !Array.isArray(all) ? (all as Record<string, RepoSettings>) : {}
21  } catch {
22    return {}
23  }
24}
25
26export const formatSettingsFile = (all: Record<string, RepoSettings>): string => `${JSON.stringify(all, null, 2)}\n`
27
28export const isEnabled = (s: RepoSettings): boolean => s.enabled !== false
29
30// Jira runs only where its site is set.
31export const jiraSite = (s: RepoSettings): string | undefined => s.jira?.site?.trim() || undefined
32
33export const DEFAULT_KEY_PATTERN = '[A-Z][A-Z0-9]+-\\d+'
34export const keyPattern = (s: RepoSettings): RegExp => {
35  try {
36    return new RegExp(`\\b(?:${s.jira?.keyPattern || DEFAULT_KEY_PATTERN})\\b`)
37  } catch {
38    return new RegExp(`\\b(?:${DEFAULT_KEY_PATTERN})\\b`)
39  }
40}
41
42// The issue key in the PR title, else the branch, from an allowed project.
43export const issueKey = (s: RepoSettings, ...texts: readonly string[]): string | undefined => {
44  const pattern = keyPattern(s)
45  const projects = s.jira?.projects?.length ? s.jira.projects : undefined
46  for (const text of texts) {
47    const key = text.match(pattern)?.[0]
48    if (key && (!projects || projects.includes(key.split('-')[0]!))) return key
49  }
50  return undefined
51}
52
53export const TURBO_SERVE = ['npx', 'turbo', 'run', 'serve', '--ui=stream']
54
55export const serverKind = (s: RepoSettings): ServerKind => {
56  const k = s.server?.kind
57  if (k === 'turbo' || k === 'plain') return s.server?.command?.length || k === 'turbo' ? k : 'none'
58  return 'none'
59}
60
61// What starts the server; empty when there is none.
62export const serverArgv = (s: RepoSettings): readonly string[] => {
63  const kind = serverKind(s)
64  if (kind === 'none') return []
65  return s.server?.command?.length ? s.server.command : TURBO_SERVE
66}
67
68// Turbo's plan of the tasks the server runs: the same command, dry.
69export const planArgv = (s: RepoSettings): readonly string[] | undefined => {
70  if (serverKind(s) !== 'turbo') return undefined
71  const argv = serverArgv(s).filter(a => !a.startsWith('--ui'))
72  return [...argv, '--dry=json']
73}
74
75export const portEnv = (s: RepoSettings): string => s.server?.portEnv?.trim() || 'PORT'
76export const ports = (s: RepoSettings): readonly number[] => {
77  const list = (s.server?.ports ?? []).filter(p => Number.isInteger(p) && p > 0 && p < 65536)
78  return list.length ? list : [3000]
79}
80
81// What a process of this server's command looks like in `ps`, to find one
82// this session started and lost track of.
83export const serverMatch = (s: RepoSettings): string => serverArgv(s).filter(a => !a.startsWith('-')).slice(1).join(' ')
84
85export const serverEnv = (s: RepoSettings, at: number): Record<string, string> => ({
86  FORCE_COLOR: '0',
87  ...(s.server?.env ?? {}),
88  [portEnv(s)]: String(at),
89})
90
91// A server the repo looks like it has, offered by the wizard, never started
92// unasked.
93export type ServerChoice = { label: string; kind: ServerKind; command: string[] }
94export const detectServers = (turboJson: string | undefined, packageJson: string | undefined): ServerChoice[] => {
95  const out: ServerChoice[] = []
96  try {
97    const tasks = Object.keys((JSON.parse(turboJson ?? '') as { tasks?: object; pipeline?: object }).tasks ?? {})
98    if (tasks.some(t => t === 'serve' || t.endsWith('#serve'))) out.push({ label: 'turbo run serve', kind: 'turbo', command: TURBO_SERVE })
99  } catch {
100    // no turbo.json, or not JSON
101  }
102  try {
103    const scripts = (JSON.parse(packageJson ?? '') as { scripts?: Record<string, string> }).scripts ?? {}
104    for (const name of ['dev', 'serve', 'start']) {
105      if (scripts[name]) out.push({ label: `npm run ${name}`, kind: 'plain', command: ['npm', 'run', name] })
106    }
107  } catch {
108    // no package.json, or not JSON
109  }
110  return out
111}
112
113// `1776, 1791 1792` → [1776, 1791, 1792]; undefined when any part is not a port.
114export const parsePorts = (text: string): number[] | undefined => {
115  const parts = text.split(/[\s,]+/).filter(Boolean)
116  const list = parts.map(Number)
117  return parts.length && list.every(p => Number.isInteger(p) && p > 0 && p < 65536) ? list : undefined
118}
119
120// `A=1 B=two` → { A: '1', B: 'two' }; undefined when a part has no `=`.
121export const parseEnv = (text: string): Record<string, string> | undefined => {
122  const parts = text.split(/\s+/).filter(Boolean)
123  if (!parts.every(p => /^[A-Za-z_][A-Za-z0-9_]*=/.test(p))) return undefined
124  return Object.fromEntries(parts.map(p => [p.slice(0, p.indexOf('=')), p.slice(p.indexOf('=') + 1)]))
125}
126
127// One line per setting, as the settings pane lists them.
128export const describe = (s: RepoSettings): { key: string; label: string; value: string }[] => {
129  const kind = serverKind(s)
130  return [
131    { key: 'enabled', label: 'Band', value: isEnabled(s) ? 'on' : 'off' },
132    { key: 'jira.site', label: 'Jira site', value: jiraSite(s) ?? 'none (Jira off)' },
133    { key: 'jira.projects', label: 'Jira projects', value: s.jira?.projects?.join(', ') || 'any' },
134    { key: 'server.kind', label: 'Server', value: kind === 'none' ? 'none' : `${kind}: ${serverArgv(s).join(' ')}` },
135    ...(kind === 'none'
136      ? []
137      : [
138          { key: 'server.portEnv', label: 'Port variable', value: portEnv(s) },
139          { key: 'server.ports', label: 'Ports', value: ports(s).join(', ') },
140          { key: 'server.env', label: 'Extra env', value: Object.entries(s.server?.env ?? {}).map(([k, v]) => `${k}=${v}`).join(' ') || 'none' },
141        ]),
142  ]
143}
144
hooks/tree.ts 113 lines
1// The Files pane's tree: pure functions over repo-relative paths.
2
3export type Mark = 'uncommitted' | 'pr'
4
5export type Change = { path: string; status: string }
6
7export type Row = {
8  path: string
9  name: string
10  depth: number
11  isDir: boolean
12  isOpen: boolean
13  mark?: Mark
14  status?: string
15}
16
17// `git status --porcelain=v1 -uall`: `XY path`, or `XY old -> new` for a rename.
18export const parseStatus = (porcelain: string): Change[] =>
19  porcelain
20    .split('\n')
21    .filter(line => line.length > 3)
22    .map(line => ({
23      status: line.slice(0, 2).trim() || '?',
24      path: line.slice(3).split(' -> ').pop()!.replace(/^"|"$/g, ''),
25    }))
26
27// `git diff --name-status base...HEAD`: `M\tpath`, or `R100\told\tnew`.
28export const parseNameStatus = (diff: string): Change[] =>
29  diff
30    .split('\n')
31    .filter(Boolean)
32    .map(line => {
33      const [status = '', ...paths] = line.split('\t')
34      return { status: status.charAt(0), path: paths[paths.length - 1] ?? '' }
35    })
36    .filter(c => c.path)
37
38// Every folder above a path: `a/b/c.ts` → `a`, `a/b`.
39export const ancestorsOf = (path: string): string[] => {
40  const parts = path.split('/').slice(0, -1)
41  return parts.map((_, i) => parts.slice(0, i + 1).join('/'))
42}
43
44type Dir = { dirs: Map<string, Dir>; files: Set<string> }
45
46const newDir = (): Dir => ({ dirs: new Map(), files: new Set() })
47
48const treeOf = (paths: Iterable<string>): Dir => {
49  const root = newDir()
50  for (const path of paths) {
51    const parts = path.split('/')
52    let dir = root
53    for (const part of parts.slice(0, -1)) {
54      let next = dir.dirs.get(part)
55      if (!next) dir.dirs.set(part, (next = newDir()))
56      dir = next
57    }
58    dir.files.add(parts[parts.length - 1]!)
59  }
60  return root
61}
62
63// The mark a folder shows: uncommitted if any file under it is, else pr.
64export const folderMarks = (marks: ReadonlyMap<string, Mark>): Map<string, Mark> => {
65  const out = new Map<string, Mark>()
66  for (const [path, mark] of marks) {
67    for (const dir of ancestorsOf(path)) {
68      if (out.get(dir) !== 'uncommitted') out.set(dir, mark)
69    }
70  }
71  return out
72}
73
74// The rows a pane shows, folders first then files, each level by name.
75// `isOpen(dir)` says which folders are expanded.
76export const buildRows = (
77  paths: Iterable<string>,
78  isOpen: (dir: string) => boolean,
79  marks: ReadonlyMap<string, Mark>,
80  statuses: ReadonlyMap<string, string>,
81): Row[] => {
82  const dirMarks = folderMarks(marks)
83  const rows: Row[] = []
84  const walk = (dir: Dir, prefix: string, depth: number) => {
85    for (const name of [...dir.dirs.keys()].sort()) {
86      const path = prefix + name
87      const open = isOpen(path)
88      rows.push({ path, name, depth, isDir: true, isOpen: open, mark: dirMarks.get(path) })
89      if (open) walk(dir.dirs.get(name)!, `${path}/`, depth + 1)
90    }
91    for (const name of [...dir.files].sort()) {
92      const path = prefix + name
93      rows.push({ path, name, depth, isDir: false, isOpen: false, mark: marks.get(path), status: statuses.get(path) })
94    }
95  }
96  walk(treeOf(paths), '', 0)
97  return rows
98}
99
100// Each file's mark: uncommitted wins over pr when a file is both.
101export const marksOf = (pr: readonly Change[], uncommitted: readonly Change[]): Map<string, Mark> => {
102  const marks = new Map<string, Mark>()
103  for (const c of pr) marks.set(c.path, 'pr')
104  for (const c of uncommitted) marks.set(c.path, 'uncommitted')
105  return marks
106}
107
108// `/repo/src/a.ts` under `/repo` → `src/a.ts`; a path outside → undefined.
109export const relativeTo = (top: string, file: string): string | undefined => {
110  const root = top.endsWith('/') ? top : `${top}/`
111  return file.startsWith(root) ? file.slice(root.length) : undefined
112}
113
types/index.d.ts 80 lines
1export type CheckState = 'pass' | 'fail' | 'pending' | 'skipped'
2export type Check = {
3  name: string
4  workflow?: string
5  state: CheckState
6  startedAt?: string
7  completedAt?: string
8  url?: string
9}
10export type Pr = {
11  number: number
12  state: string
13  isDraft: boolean
14  passed: number
15  failed: number
16  pending: number
17  title: string
18  checks?: Check[]
19  base?: string
20  url?: string
21}
22export type Account = { plan: string; org?: string; email?: string }
23export type DiffStat = { added: number; removed: number; base: string }
24export type FilesMode = 'all' | 'pr' | 'uncommitted'
25// `status` is the issue's status abbreviated (To Do → TODO), absent when Jira gave none.
26export type Jira = { key: string; title: string; status?: string } | { key: string; error: string }
27export type Task = {
28  name: string
29  state: 'pending' | 'running' | 'done' | 'failed' | 'stopped'
30  code?: number
31  persistent: boolean
32  deps: string[]
33  // lit while the task prints
34  flash?: boolean
35}
36export type Serve = {
37  state: 'stopped' | 'running' | 'exited'
38  code?: number | null
39  signal?: string
40  tasks: readonly Task[]
41  // the port answered: the server is up, and the port is not checked again
42  up?: boolean
43  // on/off while it starts, so `server` flashes
44  blink?: boolean
45}
46
47export type ServerKind = 'turbo' | 'plain' | 'none'
48// One repo's entry in ~/.claude/work-band.json; every field optional.
49export type RepoSettings = {
50  enabled?: boolean
51  jira?: { site?: string; keyPattern?: string; projects?: string[] }
52  server?: { kind?: ServerKind; command?: string[]; portEnv?: string; ports?: number[]; env?: Record<string, string> }
53}
54
55declare module 'claude-code' {
56  interface PluginState {
57    'work-band': { pr: Pr | null; jira: Jira | null; serve: Serve; canServe: boolean; isHidden: boolean; logView: { task: string; tick: number }; mergedTold: number[]
58      port: number
59      filesMode: FilesMode
60      openDirs: string[]
61      closedDirs: string[]
62      filesTick: number
63      editFrame: number
64      diffStat: DiffStat | null
65      rerunAsked: string[]
66      account: Account | null
67      // What Claude says it is working on: the labels set_topic was last called with.
68      topic: string[]
69      // the checked-out branch; '' when HEAD is detached
70      branch: string
71      // `owner/repo` of the origin remote, '' until known or off GitHub
72      repo: string
73      // this repo's entry in ~/.claude/work-band.json
74      settings: RepoSettings
75      // the repo's default branch, from gh; the diff's base with no PR
76      defaultBranch: string
77    }
78  }
79}
80