Everything waiting on you (questions in any session, PRs to review, merge or apply) and everything you're waiting on

My personal macOS dotfiles. Originally based on https://jooooel.com/sync-dotfiles-using-git/
All the dotfiles start with an underscore (_zshrc, _gitconfig, ...) so they aren't hidden when browsing the repo. This makes ls behave nicely. The install script maps each one to its real dotted location.
Do these in order. Steps 1-6 get a working machine; step 7 covers the things no script can do (permissions, sign-ins) and must be done by hand.
Gives you git so you can clone this repo. (Homebrew's installer in step 2 also triggers this, so you can skip straight to step 2 if you prefer.)
xcode-select --install
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
The installer prints a "Next steps" note telling you to add Homebrew to your PATH. On Apple Silicon that is:
eval "$(/opt/homebrew/bin/brew shellenv)"
Run that now so brew works in the current shell. (Once step 5 links ~/.zshrc and you open a new terminal, Homebrew is on PATH for good.)
It must live at ~/dotfiles (some configs reference that absolute path).
git clone https://github.com/jquintus/dotfiles ~/dotfiles
cd ~/dotfiles
Reads the Brewfile in the repo root: CLI tools, casks (GUI apps), and taps.
brew bundle install
Note: the Brewfile has no Mac App Store (mas) entries, so App-Store-only apps are not installed here. Install those by hand from the App Store (see step 7).
Links every config into place. No dependencies beyond bash, so it is safe to run even before brew bundle if you like.
./scripts/install-mac.sh
The one-to-one links are declared in scripts/links.manifest (a source | target | description table) that the script loops over. To add a config later, add a line to the manifest rather than editing the script. bin/* is linked by a glob, so new scripts there are picked up automatically.
The script also installs Claude Code, which is deliberately not in the Brewfile: the cask lags upstream and never self-updates. The native installer drops a self-updating launcher in ~/.local/bin/claude (already on PATH), and the step is skipped when that launcher already exists. It only needs curl, and warns rather than fails if the download does not work. Confirm with claude doctor: it should report native and Auto-updates: enabled.
It installs Codex the same way and for a related reason: the codex cask is current with upstream, but it is not the standalone install the background app-server daemon starts from, so codex agents refuses to run under it. The official installer lands a self-updating build in ~/.codex/packages/standalone and links it into ~/.local/bin/codex. Confirm with codex doctor: the runtime line should say standalone, not brew.
It installs the Cursor CLI the same way, for the first reason rather than the second: the cursor-cli cask pins a build inside the Caskroom, which fights the binary's own auto-update. The official installer lands a self-updating build in ~/.local/share/cursor-agent/versions and links it into ~/.local/bin twice, as cursor-agent and as the much more collidable agent. Confirm with cursor-agent about.
Copilot's CLI is the exception that stays in the Brewfile. Its cask is marked auto_updates, so the binary updates itself with copilot update and brew never has to catch up.
Last, it runs bin/codex-sync, which hands Codex the same subagents, skills and global instructions Claude Code runs under. See "Codex" below for what that generates and when to rerun it.
Open a new terminal (or source ~/.zshrc) after this so the new shell config takes effect.
Sets system preferences via defaults write (key repeat, menu-bar clock seconds, muted startup chime, disabled F11 Show Desktop, and more). Kept separate from linking so symlinking never mutates OS state. Idempotent, safe to re-run. Prompts for sudo once (to mute the startup chime, which lives in firmware).
./scripts/macos-defaults.sh
Some changes need a logout or restart to fully take effect.
These require clicking through UI, signing in, or granting OS permissions. There is no way to automate them, so run through the checklist by hand:
gh auth login. The token lives in gh/hosts.yml, which is gitignored and machine-local by design, so it is never in this repo.claude/settings.json lists the marketplaces and enables the plugins, but declaring one does not fetch it. Claude Code prompts on first run; to skip the prompt: `` claude plugin install search-sessions@search-sessions ` The search-sessions` binary itself comes from the Brewfile. The plugin is the separate piece that lets Claude search session history on your behalf.codex login. install-mac.sh installs and configures the CLI, but the credentials land in ~/.codex/auth.json, which is machine-local and never in this repo. Confirm with codex doctor.cursor-agent login, which opens a browser. Check it took with cursor-agent status.copilot login. It can also read a GITHUB_TOKEN from the environment instead, but the interactive login is the simpler path.hammerspoon/.hammerspoon/meet.lua. (The matching Chrome-side setting, "Allow JavaScript from Apple Events," is set by macos-defaults.sh and takes effect on Chrome's next launch.)karabiner_grabber and Karabiner-Elements. Without these, Caps Lock silently stays Caps Lock and every Hyper binding in hammerspoon/launcher.lua is dead. See karabiner/README.md.brew install --cask logi-options+, deliberately not in the Brewfile — it was installed by hand, so a brew bundle dump --force would drop the line again). The MX Anywhere 3 needs it for two things, both of which must be clicked in by hand because Options+ keeps its settings in its own sqlite database:Ctrl+- and Ctrl+Shift+-, so they navigate back and forward there. VS Code ignores mouse thumb buttons natively.Both of these have to live in Options+ rather than Karabiner, and the agent has to keep running for them to work. karabiner/README.md explains why.
~/.pgpass and any real DB connection details (see zsh/_zshrc-aliases).zsh/_zshrc-<LocalHostName> if it exists. Find this machine's name with scutil --get LocalHostName, then create that file for host-specific tweaks.The Brewfile is a snapshot of installed Homebrew taps, formulae, and casks.
# Install everything from the Brewfile (e.g. on a new machine)
brew bundle install
# Re-snapshot after installing/removing packages
brew bundle dump --force
# Check whether all Brewfile entries are installed
brew bundle check
# Show installed packages NOT in the Brewfile (add --force to uninstall them)
brew bundle cleanup
scripts/macos-defaults.sh holds every defaults write (and a few related nvram / duti calls). Each setting is annotated with where it lives in System Settings. Run it with ./scripts/macos-defaults.sh; it is idempotent.
Some apps keep their user config outside $HOME (e.g. under Application Support). Those live in their own repo dirs and are symlinked into place by install-mac.sh:
| Repo dir | Symlinked to |
|---|---|
vscode/settings.json | ~/Library/Application Support/Code/User/settings.json |
claude/settings.json | ~/.claude/settings.json |
Because they're symlinks, editing the app's settings (via its UI or by hand) writes straight back to this repo. So after changing a setting, remember to git commit && git push here. The diff shows up under vscode/ or claude/.
Note: the Claude source dir is claude/, not .claude/, on purpose. A .claude/settings.json would be read as project settings whenever Claude runs in this repo (double-registering hooks). Keep it as claude/.
Codex reads the same ideas as Claude Code out of a different shape of config, so bin/codex-sync translates rather than symlinks. Rerun it after editing claude/CLAUDE.md or anything in claude/agents/:
codex-sync
| What | ~/.codex/... | How |
|---|---|---|
| Global instructions | AGENTS.md | generated from claude/CLAUDE.md |
| Subagents | agents/*.toml | generated from claude/agents/*.md |
| Skills | skills/<name> | symlinked to ~/.claude/skills/<name> |
CLAUDE.md as fallback | config.toml | one key, written once and never rewritten |
Only the skills are symlinks, because SKILL.md is the same format in both tools, so edits flow both ways. The other two have to be generated: a Claude subagent is YAML frontmatter plus markdown, and a Codex custom agent is a TOML file with name, description and developer_instructions. Anything the script generates carries a marker line in its first line, and it only ever deletes files carrying that marker or symlinks pointing into ~/.claude.
Two translations worth knowing about. Codex has no @-import, so the machine-local ~/.claude/CLAUDE.work.md is inlined into the generated AGENTS.md (it stays out of this public repo either way).
And Codex has no per-agent tool allowlist. sandbox_mode = "read-only" looks like the equivalent and is not: it also cuts network access and makes every command ask for approval, which breaks an agent like archer that is supposed to read code and run scanners. So the Claude tools: line is copied in as a comment and nothing enforces it but the agent's own instructions — the same thing holding the line in the Claude prompt already.
Skills are read from ~/.claude/skills rather than from this repo, so Codex also sees skills installed on this machine that the repo does not track.
The install script symlinks both configs (~/.vimrc and ~/.config/nvim/init.lua), but plugins are managed separately. Each editor uses a different plugin manager, and both auto-bootstrap the manager on first launch.
# Installs vim-plug automatically if missing, then installs all plugins
vim +PlugInstall +qall
Or open vim and run :PlugInstall.
Packer auto-clones itself on the first launch, so this is a three-step dance:
nvim # 1. prints "Installing packer.nvim..." and clones it, then quit
nvim # 2. reopen so packer is on the runtime path
# 3. run :PackerSync inside nvim to install/sync all plugins
Note:
neo-vim/int.luahardcodes the repo path (/Users/jq/dotfiles/neo-vim/lua), so the Neovim config will break if the repo is moved. This is why step 3 clones to~/dotfiles.
A snapshot of /Applications (regenerate with ls /Applications):
hooks/register.tsx 790 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PrItem, Question, Reply, SessionPr, Task, Ticket } from '../types'
5import { STAGE_LABEL, idlePhase, isSubagentTest, pillOf, signalOf, stageFromText, type Stage } from './phase'
6import { describe, shortTitle, stackOrder } from './prs'
7import { ago } from './tasks'
8import { catchupPrompt, parseIssue, parseIssues, ticketFromBranch } from './ticket'
9
10const PANE = 'dash'
11const questions = atom({ plugin: 'dash', key: 'questions' } as const, [])
12const reviews = atom({ plugin: 'dash', key: 'reviews' } as const, [])
13const merges = atom({ plugin: 'dash', key: 'merges' } as const, [])
14const applies = atom({ plugin: 'dash', key: 'applies' } as const, [])
15const prs = atom({ plugin: 'dash', key: 'prs' } as const, [])
16const tasks = atom({ plugin: 'dash', key: 'tasks' } as const, [])
17const ticket = atom({ plugin: 'dash', key: 'ticket' } as const, null)
18const expanded = atom({ plugin: 'dash', key: 'expanded' } as const, null)
19const isLaterOpen = atom({ plugin: 'dash', key: 'isLaterOpen' } as const, false)
20const myTickets = atom({ plugin: 'dash', key: 'myTickets' } as const, [])
21const isTicketsOpen = atom({ plugin: 'dash', key: 'isTicketsOpen' } as const, true)
22const allMine = atom({ plugin: 'dash', key: 'allMine' } as const, [])
23const isOthersOpen = atom({ plugin: 'dash', key: 'isOthersOpen' } as const, false)
24const tripped = atom({ plugin: 'dash', key: 'tripped' } as const, null)
25const now = atom({ plugin: 'dash', key: 'now' } as const, 0)
26const phase = atom({ plugin: 'dash', key: 'phase' } as const, null)
27
28const LOCAL_MS = 60_000
29const GITHUB_MS = 5 * 60_000
30const TICKET_MS = 15 * 60_000
31const LEASE_S = (LOCAL_MS * 2.5) / 1000
32const GET_ISSUE = 'mcp__claude_ai_Linear__get_issue'
33const LIST_ISSUES = 'mcp__claude_ai_Linear__list_issues'
34const qKey = (q: Question) => `${q.id}@${q.askedAt}`
35
36let hasSessions = false
37// qKey -> the model's verdict on whether that finished turn waits on Josh.
38type Verdict = { isWaiting: boolean; summary: string; gist: string; replies: Reply[] }
39const verdicts = new Map<string, Verdict>()
40
41const TRIAGE = `You triage the last message a coding agent sent its user.
42Decide whether it ends waiting on the user: a decision, an answer, an approval, or input it needs before it can continue. Numbered options with a recommendation or default still count as waiting.
43A status report, a summary of finished work, or "I'll report when it's done" is not waiting.
44Asking the user to approve, review, or merge a pull request is not waiting: PR approval is tracked elsewhere.
45An optional offer the agent does not need in order to continue ("say if you want X", "I can also…") is not waiting.
46When it is waiting, also offer the replies the user is most likely to send: the agent's recommendation, its default, and any options it named. With several numbered decisions, offer one reply that accepts every recommendation. At most 4 replies. Each reply is written as the user, in the first person, one line, specific enough to act on.
47Reply with JSON only:
48{"waiting": true or false,
49 "summary": "what the user has to decide, at most 70 characters",
50 "gist": "one or two plain sentences: the situation and the choice",
51 "replies": [{"label": "at most 40 characters", "reply": "the message to send"}]}`
52
53// Every model call and every send passes this shared breaker first
54// (bin/gate.py: one locked state file for all sessions; a trip is sticky).
55async function gate($: EngineInterface, kind: 'model' | 'send', key: string): Promise<boolean> {
56 const res = await $.process.run(['python3', '-I', `${$.plugin.root}/bin/gate.py`, 'take', kind, key])
57 if (res.exitCode === 0) return true
58 if (res.exitCode === 2) await update($, tripped, () => res.stdout.trim())
59 else $.ui.toast(res.stdout.trim())
60 return false
61}
62
63async function cachePath($: EngineInterface): Promise<string> {
64 return `${(await $.env.get('HOME')) ?? ''}/.claude/dash/verdicts.json`
65}
66
67// Verdicts are shared on disk, so sessions don't each pay for the same message.
68async function loadVerdicts($: EngineInterface): Promise<Record<string, Verdict>> {
69 try {
70 return JSON.parse(await $.fs.read(await cachePath($)))
71 } catch {
72 return {}
73 }
74}
75
76async function triage($: EngineInterface, q: Question): Promise<Verdict> {
77 const fallback = { isWaiting: q.hasQuestionMark === true, summary: q.summary, gist: '', replies: [] }
78 if (!(await gate($, 'model', qKey(q)))) return fallback
79 const res = await $.model.complete({
80 model: 'claude-haiku-5-5',
81 system: TRIAGE,
82 prompt: q.text.slice(-6000),
83 maxTokens: 600,
84 timeoutMs: 30_000,
85 })
86 if (!res.isAnswered) return fallback
87 try {
88 const j = JSON.parse(res.text.slice(res.text.indexOf('{'), res.text.lastIndexOf('}') + 1))
89 const replies: Reply[] = Array.isArray(j.replies)
90 ? j.replies
91 .filter((r: any) => r?.label && r?.reply)
92 .slice(0, 4)
93 .map((r: any) => ({ label: String(r.label).slice(0, 40), reply: String(r.reply).replace(/\s+/g, ' ') }))
94 : []
95 return { isWaiting: j.waiting === true, summary: String(j.summary || q.summary), gist: String(j.gist || ''), replies }
96 } catch {
97 return fallback
98 }
99}
100
101const MORE_CONTEXT =
102 "Before I decide, give me more context on this: what led here, what each option changes, and what you'd worry about with each. Keep it short."
103
104// Types `text` into the session's prompt and submits it, as if Josh typed it.
105// Refuses when the prompt already holds a draft, so it never mixes with one.
106const inFlight = new Set<string>()
107
108async function send($: EngineInterface, q: Question, text: string) {
109 // A second click while the first is in flight is dropped before any await.
110 if (inFlight.has(q.id)) return
111 inFlight.add(q.id)
112 try {
113 await sendOnce($, q, text)
114 } finally {
115 inFlight.delete(q.id)
116 }
117}
118
119async function sendOnce($: EngineInterface, q: Question, text: string) {
120 if (!q.surface) {
121 $.ui.toast(`${q.name}: no cmux pane to send to`)
122 return
123 }
124 const screen = await $.process.run(['cmux', 'read-screen', '--surface', q.surface, '--lines', '15'])
125 const prompt = [...screen.stdout.matchAll(/^❯ ?(.*)$/gm)].pop()?.[1]?.trim()
126 if (prompt === undefined) {
127 $.ui.toast(`${q.name}: couldn't find its prompt; not sent`)
128 return
129 }
130 if (prompt) {
131 $.ui.toast(`${q.name} has a draft in its prompt; not sent`)
132 return
133 }
134 if (!(await gate($, 'send', q.id))) return
135 await $.process.run(['cmux', 'send', '--surface', q.surface, text])
136 await $.process.run(['cmux', 'send-key', '--surface', q.surface, 'Enter'])
137 $.ui.toast(`Sent to ${q.name}: ${text.slice(0, 60)}`)
138 await update($, questions, list => list.filter(x => qKey(x) !== qKey(q)))
139 await update($, expanded, () => null)
140}
141
142// AskUserQuestion always waits; a finished turn waits if the model says so.
143// At most one uncached message is triaged per refresh, so a burst of new
144// sessions is paced by the local timer, never by a loop.
145async function waiting($: EngineInterface, candidates: Question[]): Promise<Question[]> {
146 const disk = await loadVerdicts($)
147 let hasSpent = false
148 const out: Question[] = []
149 for (const q of candidates) {
150 if (q.kind === 'ask') {
151 out.push(q)
152 continue
153 }
154 let v = verdicts.get(qKey(q)) ?? disk[qKey(q)]
155 if (!v && !hasSpent) {
156 hasSpent = true
157 v = await triage($, q)
158 disk[qKey(q)] = v
159 try {
160 await $.fs.write(await cachePath($), JSON.stringify(disk))
161 } catch (err) {
162 fail($, 'verdict cache', err)
163 }
164 }
165 if (!v) continue
166 verdicts.set(qKey(q), v)
167 if (v.isWaiting) out.push({ ...q, summary: v.summary, gist: v.gist, replies: v.replies })
168 }
169 return out
170}
171let hasGithub = false
172let isTicketLoading = false
173
174// Errors go to the debug log (`claude --debug`), never the transcript.
175function fail($: EngineInterface, what: string, err: unknown) {
176 $.ui.log(`dash: ${what}: ${String(err).slice(0, 300)}`, { to: 'debug' })
177}
178
179async function scan($: EngineInterface, ...args: string[]): Promise<any> {
180 const res = await $.process.run(['python3', '-I', `${$.plugin.root}/bin/scan.py`, ...args], { timeoutMs: 45_000 })
181 if (res.exitCode !== 0) throw new Error(`scan ${args[0]} exited ${res.exitCode}: ${res.stderr.slice(-300)}`)
182 return JSON.parse(res.stdout)
183}
184
185// Toast each item that wasn't there last time (never on the first load).
186function announce($: EngineInterface, before: string[], items: { key: string; text: string }[], isFirst: boolean) {
187 if (isFirst) return
188 for (const i of items) if (!before.includes(i.key)) $.ui.toast(i.text)
189}
190
191async function resetBreaker($: EngineInterface) {
192 await $.process.run(['python3', '-I', `${$.plugin.root}/bin/gate.py`, 'reset'])
193 await update($, tripped, () => null)
194}
195
196// One session leads (bin/gate.py lead): only it scans sessions, calls the
197// model, and queries GitHub and Linear. The rest read what it writes.
198async function isLeader($: EngineInterface): Promise<boolean> {
199 const sid = await $.session.id()
200 const res = await $.process.run(['python3', '-I', `${$.plugin.root}/bin/gate.py`, 'lead', sid, String(LEASE_S)])
201 return res.exitCode === 0
202}
203
204async function dashDir($: EngineInterface): Promise<string> {
205 return `${(await $.env.get('HOME')) ?? ''}/.claude/dash`
206}
207
208async function readShared($: EngineInterface): Promise<Record<string, any>> {
209 try {
210 return JSON.parse(await $.fs.read(`${await dashDir($)}/shared.json`))
211 } catch {
212 return {}
213 }
214}
215
216// Written to a temp file and moved into place, so a reader never sees half.
217async function writeShared($: EngineInterface, patch: Record<string, unknown>) {
218 const path = `${await dashDir($)}/shared.json`
219 const tmp = `${path}.${await $.session.id()}.tmp`
220 await $.fs.write(tmp, JSON.stringify({ ...(await readShared($)), ...patch }))
221 await $.process.run(['mv', tmp, path])
222}
223
224async function showQuestions($: EngineInterface, next: Question[]) {
225 const before = (await read($, questions)).map(qKey)
226 announce($, before, next.map(q => ({ key: qKey(q), text: `${q.name} is asking: ${q.summary.slice(0, 80)}` })), !hasSessions)
227 hasSessions = true
228 await update($, questions, () => next)
229}
230
231async function dropQuestion($: EngineInterface) {
232 try {
233 const sid = await $.session.id()
234 await update($, questions, list => list.filter(x => x.id !== sid))
235 const shared = await readShared($)
236 if (!Array.isArray(shared.questions) || !shared.questions.some((x: Question) => x.id === sid)) return
237 await writeShared($, { questions: shared.questions.filter((x: Question) => x.id !== sid) })
238 } catch (err) {
239 fail($, 'drop question', err)
240 }
241}
242
243async function showGithub($: EngineInterface, data: any) {
244 const toast = (label: string, before: PrItem[], next: PrItem[]) =>
245 announce($, before.map(p => p.key), next.map(p => ({ key: p.key, text: `${label}: ${p.repo}#${p.number}` })), !hasGithub)
246 toast('Ready to merge', await read($, merges), data.merges)
247 toast('Ready to apply', await read($, applies), data.applies)
248 await update($, reviews, () => data.reviews as PrItem[])
249 await update($, merges, () => data.merges as PrItem[])
250 await update($, applies, () => data.applies as PrItem[])
251 await update($, allMine, () => data.mine as SessionPr[])
252 hasGithub = true
253}
254
255// A PR belongs to this session when the session linked it, or when its
256// title carries this session's ticket (ABC-123 …).
257function sessionPrsOf(mine: SessionPr[], everyMine: SessionPr[], tk: Ticket | null): SessionPr[] {
258 const linkedKeys = mine.map(p => p.key)
259 const byTicket = tk ? everyMine.filter(p => !linkedKeys.includes(p.key) && p.title.includes(tk.id)) : []
260 return [...mine, ...byTicket]
261}
262
263// Where this session is in the flow (sticky), what it's doing this turn, and
264// what it waits on between turns. Shown in the pane and as a cmux sidebar pill.
265let stage: Stage | null = null
266let activity: string | null = null
267let isTurnRunning = false
268let hasTimer = false
269let wasAborted = false
270// When a subagent last ran a test or drove a browser; it reads as Testing for
271// TESTING_MS after, so the local refresh clears it once the subagent stops.
272let subagentTestAt = 0
273const TESTING_MS = 90_000
274
275async function publish($: EngineInterface) {
276 try {
277 let text: string
278 const isTesting = (await $.clock.now()) - subagentTestAt < TESTING_MS
279 if (isTurnRunning) text = activity ?? (isTesting && stage !== 'build' ? 'Testing' : stage ? STAGE_LABEL[stage] : 'Working')
280 else {
281 const sid = await $.session.id()
282 const q = (await read($, questions)).find(x => x.id === sid)
283 const prsHere = sessionPrsOf(await read($, prs), await read($, allMine), await read($, ticket))
284 text = idlePhase({ stage, isAsking: !!q, askSummary: q?.summary ?? '', hasTimer, wasAborted, isTesting, prs: prsHere })
285 }
286 if (text === (await read($, phase))) return
287 await update($, phase, () => text)
288 const ws = await $.env.get('CMUX_WORKSPACE_ID')
289 if (!ws) return
290 const pill = pillOf(text)
291 await $.process.run(['cmux', 'set-status', 'dash', text, '--workspace', ws, '--icon', pill.icon, '--color', pill.color, '--priority', '100'])
292 } catch (err) {
293 fail($, 'phase', err)
294 }
295}
296
297async function refreshLocal($: EngineInterface) {
298 try {
299 const st = await $.process.run(['python3', '-I', `${$.plugin.root}/bin/gate.py`, 'status'])
300 const t = JSON.parse(st.stdout).tripped
301 await update($, tripped, () => (t ? String(t.reason) : null))
302 } catch (err) {
303 fail($, 'gate status', err)
304 }
305 try {
306 if (await isLeader($)) {
307 const data = await scan($, 'sessions')
308 const next = await waiting($, data.questions as Question[])
309 await writeShared($, { questions: next })
310 await showQuestions($, next)
311 await publish($)
312 return
313 }
314 // Followers pick up everything the leader wrote on this one fast tick.
315 const shared = await readShared($)
316 if (shared.questions) await showQuestions($, shared.questions)
317 if (shared.github) await showGithub($, shared.github)
318 const list = JSON.parse(await $.fs.read(`${await dashDir($)}/tickets.json`))
319 if (Array.isArray(list) && list.length) await update($, myTickets, () => list)
320 } catch (err) {
321 fail($, 'sessions', err)
322 }
323 await publish($)
324}
325
326// `isForced` lets a follower rescan right after it pushed or opened a PR.
327async function refreshGithub($: EngineInterface, isForced = false) {
328 try {
329 if (isForced || (await isLeader($))) {
330 const data = await scan($, 'github')
331 await writeShared($, { github: data })
332 await showGithub($, data)
333 }
334 } catch (err) {
335 fail($, 'github', err)
336 }
337 try {
338 const data = await scan($, 'here', await $.session.id())
339 if (data.ticketHint && !ticketHint) {
340 ticketHint = data.ticketHint
341 void refreshTicket($)
342 }
343 await update($, prs, () => data.prs as SessionPr[])
344 await update($, tasks, () => data.tasks as Task[])
345 } catch (err) {
346 fail($, 'here', err)
347 }
348 await publish($)
349}
350
351// Only the tasks change fast; refresh them with the local scan's cadence.
352async function refreshTasks($: EngineInterface) {
353 try {
354 const data = await scan($, 'here', await $.session.id(), '--no-prs')
355 await update($, tasks, () => data.tasks as Task[])
356 } catch (err) {
357 fail($, 'tasks', err)
358 }
359}
360
361let ticketHint: string | null = null
362
363// The branch names the ticket; mid-rebase (detached HEAD) the session name or
364// a linked PR's title does.
365async function currentTicketId($: EngineInterface): Promise<string | null> {
366 const { stdout } = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'])
367 return ticketFromBranch(stdout.trim()) ?? ticketHint
368}
369
370// Linear connects after session start, so a miss retries in 20s, not 5 min.
371async function refreshTicket($: EngineInterface, attempt = 0) {
372 if (isTicketLoading) return
373 isTicketLoading = true
374 try {
375 const id = await currentTicketId($)
376 if (!id) return
377 const ran = await $.tool.call({ tool: GET_ISSUE, id, fields: ['title', 'status', 'statusType', 'url'] })
378 const next = ran.deny === undefined && !ran.isError ? parseIssue(id, ran.result) : null
379 if (next) await update($, ticket, () => next)
380 else if (attempt < 15) $.clock.after(20_000, () => void refreshTicket($, attempt + 1))
381 } catch (err) {
382 fail($, 'ticket', err)
383 if (attempt < 15) $.clock.after(20_000, () => void refreshTicket($, attempt + 1))
384 } finally {
385 isTicketLoading = false
386 }
387}
388
389// Links in a pane aren't clickable in every terminal; a Button always is.
390async function openUrl($: EngineInterface, url: string) {
391 await $.process.run(['open', url])
392}
393
394async function refreshMyTickets($: EngineInterface, attempt = 0, isForced = false) {
395 try {
396 if (!isForced && !(await isLeader($))) return
397 const ran = await $.tool.call({
398 tool: LIST_ISSUES,
399 assignee: 'me',
400 fields: ['title', 'status', 'statusType', 'url'],
401 limit: 100,
402 })
403 const list = ran.deny === undefined && !ran.isError ? parseIssues(ran.result) : []
404 if (list.length) {
405 await update($, myTickets, () => list)
406 // The browser dash can't reach Linear; it reads this copy.
407 await $.fs.write(`${(await $.env.get('HOME')) ?? ''}/.claude/dash/tickets.json`, JSON.stringify(list))
408 }
409 else if (attempt < 15) $.clock.after(20_000, () => void refreshMyTickets($, attempt + 1, isForced))
410 } catch (err) {
411 fail($, 'my tickets', err)
412 if (attempt < 15) $.clock.after(20_000, () => void refreshMyTickets($, attempt + 1, isForced))
413 }
414}
415
416async function toggle($: EngineInterface): Promise<string> {
417 const pane = (await $.ui.panes()).find(p => p.id === PANE)
418 if (pane?.isShown) {
419 await $.ui.close({ id: PANE })
420 return 'Dash closed.'
421 }
422 await $.ui.open({ id: PANE, title: 'Dash' })
423 void refreshLocal($)
424 void refreshGithub($)
425 return 'Dash opened.'
426}
427
428// Ready for review (out of draft + request the team) is reversible, so one
429// click does it, through the shared actions.py and its breaker. Merge and
430// apply aren't, so those open the PR to finish there.
431async function act($: EngineInterface, next: string, url: string) {
432 if (next !== 'Ready for review') {
433 await openUrl($, url)
434 return
435 }
436 const res = await $.process.run(['python3', '-I', `${$.plugin.root}/bin/actions.py`, 'ready', url], { timeoutMs: 90_000 })
437 $.ui.toast(`${url.split('/').slice(-3).join('/')}: ${res.stdout.trim() || res.stderr.trim().slice(0, 120)}`)
438 void refreshGithub($, true)
439}
440
441async function goTo($: EngineInterface, q: Question) {
442 if (!q.workspace || !q.surface) return
443 await $.process.run(['cmux', 'select-workspace', '--workspace', q.workspace])
444 await $.process.run(['cmux', 'focus-panel', '--panel', q.surface, '--workspace', q.workspace])
445}
446
447export const register: Register = on => {
448 on('session.start', async ($, e, next) => {
449 await $.command.register({ name: 'dash', description: 'Toggle the pane of what needs you and what you are waiting on', immediate: true })
450 await $.command.register({ name: 'catchup', description: "Summarize what's been done on this branch's ticket so far" })
451 void refreshLocal($)
452 void refreshGithub($)
453 void refreshTicket($)
454 void refreshMyTickets($)
455 $.clock.every(LOCAL_MS, () => {
456 void refreshLocal($)
457 void refreshTasks($)
458 })
459 $.clock.every(GITHUB_MS, () => void refreshGithub($))
460 $.clock.every(TICKET_MS, () => {
461 void refreshTicket($)
462 void refreshMyTickets($)
463 })
464 $.clock.every(30_000, async () => update($, now, () => Date.now()))
465
466 return next(e)
467 })
468
469 on('command.run', { command: 'dash' }, async $ => ({ text: await toggle($) }))
470
471 on('command.run', { command: 'catchup' }, async ($, e) => {
472 const t = await read($, ticket)
473 const id = e.args.trim().toUpperCase() || t?.id || (await currentTicketId($))
474 if (!id) return { text: 'No ticket on this branch. Try /catchup ABC-123.' }
475 const cwd = await $.session.cwd()
476 const transcripts = `~/.claude/projects/${cwd.replace(/[^A-Za-z0-9]/g, '-')}/*.jsonl`
477 void $.prompt.submit({ text: catchupPrompt(id, t?.id === id ? t.title : '', transcripts) })
478
479 return { text: `Catching up on ${id}…` }
480 })
481
482 on('turn.start', async ($, e, next) => {
483 // the user just answered, so this session's question is gone now, not at the leader's next scan.
484 void dropQuestion($)
485 isTurnRunning = true
486 hasTimer = false
487 wasAborted = false
488 activity = null
489 stage = stageFromText(e.text) ?? stage
490 void publish($)
491 return next(e)
492 })
493
494 on('turn.complete', async ($, e, next) => {
495 const r = await next(e)
496 if (e.agentId) return r
497 isTurnRunning = false
498 wasAborted = e.reason === 'aborted'
499 activity = null
500 void publish($)
501 return r
502 })
503
504 on('session.end', async ($, e, next) => {
505 const ws = await $.env.get('CMUX_WORKSPACE_ID')
506 if (ws) await $.process.run(['cmux', 'clear-status', 'dash', '--workspace', ws])
507 return next(e)
508 }).catch(($, e, next) => next(e))
509
510 // A Linear write likely moved the ticket; a git/gh command likely moved a PR.
511 // Main-loop calls also move the phase.
512 on('tool.call', async ($, e, next) => {
513 if (!e.agentId) {
514 const sig = signalOf(e.tool, e as Record<string, any>, stage)
515 if ('stage' in sig) stage = sig.stage ?? null
516 if (sig.activity) activity = sig.activity
517 if (e.tool === 'ScheduleWakeup' || e.tool === 'Monitor') hasTimer = true
518 void publish($)
519 } else if (isSubagentTest(e.tool, e as Record<string, any>)) {
520 subagentTestAt = await $.clock.now()
521 void publish($)
522 }
523 const ran = await next(e)
524 if (!e.agentId && (e.tool === 'AskUserQuestion' || activity === 'Testing')) {
525 activity = null
526 void publish($)
527 }
528 if (e.tool.startsWith('mcp__claude_ai_Linear__save')) {
529 void refreshTicket($)
530 void refreshMyTickets($, 0, true)
531 }
532 if (e.tool === 'Bash' && /\b(git push|gh (pr|api))\b/.test((e as { command?: string }).command ?? '')) {
533 void refreshGithub($, true)
534 }
535
536 return ran
537 }).catch(($, e, next) => next(e))
538
539 // One clickable line above the prompt: what needs you, and the pane toggle.
540 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
541 if (e.props.hasSurvey) return next(e)
542 const counts = [
543 [(await read($, questions)).length, 'question'],
544 [(await read($, merges)).length, 'to merge'],
545 [(await read($, applies)).length, 'to apply'],
546 ] as const
547 const todo = counts.filter(([n]) => n > 0).map(([n, w]) => (w === 'question' && n > 1 ? `${n} questions` : `${n} ${w}`))
548 const trip = await read($, tripped)
549 const { Box, Button } = $.ui.resolve(e)
550 const below = await next(e)
551
552 return (
553 <Box flexDirection="column">
554 <Button key="dash-toggle" label={['Dash', ...(trip ? ['⚠ breaker tripped'] : []), ...todo].join(' · ')} onPress={() => toggle($)} />
555 {below}
556 </Box>
557 )
558 })
559
560 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
561 const { Box, Button, Text } = $.ui.resolve(e)
562 const t = (await read($, now)) || (await $.clock.now())
563 const since = (iso: string | null) => (iso ? ago(t - Date.parse(iso)) : '')
564 const tk: Ticket | null = await read($, ticket)
565 const trip = await read($, tripped)
566 const mine = await read($, prs)
567 const running = await read($, tasks)
568 const qs = await read($, questions)
569 const open = await read($, expanded)
570 // Your own PRs are priorities; other people's review requests can wait.
571 // PRs already listed in This session (with their action) aren't repeated.
572 const todo = [
573 ['Merge', await read($, merges)],
574 ['Apply (Atlantis)', await read($, applies)],
575 ] as const
576 const later = await read($, reviews)
577 const assigned = await read($, myTickets)
578 const everyMine = await read($, allMine)
579 const sessionPrs = sessionPrsOf(mine, everyMine, tk)
580 const sessionKeys = sessionPrs.map(p => p.key)
581 const others = everyMine.filter(p => !sessionKeys.includes(p.key))
582 const todoHere = todo.map(([verb, list]) => [verb, list.filter(p => !sessionKeys.includes(p.key))] as const)
583 const total = qs.length + todoHere.reduce((n, [, l]) => n + l.length, 0)
584 const othersOpen = await read($, isOthersOpen)
585 const ticketsOpen = await read($, isTicketsOpen)
586 const laterOpen = await read($, isLaterOpen)
587 const ph = await read($, phase)
588 const needsYou = ph ? pillOf(ph).icon === 'hand.raised.fill' : false
589
590
591 // Fixed column widths keep every row aligned.
592 const W = { link: 25, status: 19, next: 22 }
593 const head = (cols: string[]) => (
594 <Box>
595 <Box width={2} flexShrink={0} />
596 <Box width={W.link} flexShrink={0}><Text dimColor>{cols[0]}</Text></Box>
597 <Box width={W.status} flexShrink={0}><Text dimColor>{cols[1]}</Text></Box>
598 <Box width={W.next} flexShrink={0}><Text dimColor>{cols[2]}</Text></Box>
599 <Text dimColor>{cols[3]}</Text>
600 </Box>
601 )
602 const TONE = { good: 'success', you: 'suggestion', wait: 'warning', bad: 'error', done: 'inactive' } as const
603 const row = (key: string, label: string, url: string, status: string, tone: keyof typeof TONE | null, next: string, title: string, position = '') => (
604 <Box key={key}>
605 <Box width={2} flexShrink={0} />
606 <Box width={W.link} flexShrink={0}>
607 <Button key={`open-${key}`} label={label} onPress={() => openUrl($, url)} />
608 {position ? <Text dimColor> {position}</Text> : null}
609 </Box>
610 <Box width={W.status} flexShrink={0}><Text color={tone ? TONE[tone] : undefined} wrap="truncate-end">{status}</Text></Box>
611 <Box width={W.next} flexShrink={0}>
612 {next ? <Button key={`act-${key}`} label={next} onPress={() => act($, next, url)} /> : <Text> </Text>}
613 </Box>
614 <Box flexShrink={1}><Text dimColor wrap="truncate-end">{title}</Text></Box>
615 </Box>
616 )
617
618 return (
619 <Box flexDirection="column">
620 {trip && (
621 <Box flexDirection="column" marginBottom={1}>
622 <Text color="error" bold>Breaker tripped: {trip}</Text>
623 <Text dimColor>Model calls and sends are off in every session until you reset.</Text>
624 <Button key="reset-breaker" label="Reset breaker" onPress={() => resetBreaker($)} />
625 </Box>
626 )}
627 <Text>
628 <Text bold>This session</Text>
629 {ph ? <Text color={needsYou ? 'suggestion' : undefined} dimColor={!needsYou}> · {ph}</Text> : null}
630 </Text>
631 {tk ? (
632 <Box>
633 <Box width={2} flexShrink={0} />
634 <Box width={W.link} flexShrink={0}><Button key="ticket" label={tk.id} onPress={() => openUrl($, tk.url)} /></Box>
635 <Box width={W.status} flexShrink={0}><Text>{tk.status}</Text></Box>
636 <Box flexShrink={1}><Text dimColor wrap="truncate-end">{tk.title}</Text></Box>
637 </Box>
638 ) : (
639 <Text dimColor> No ticket on this branch.</Text>
640 )}
641 <Text> </Text>
642 {sessionPrs.length === 0 ? (
643 <Text dimColor> No PRs linked yet.</Text>
644 ) : (
645 head(['PR', 'Status', 'Next for you', 'Title'])
646 )}
647 {stackOrder(sessionPrs).map(({ pr: p, position }) => {
648 const d = describe(p)
649 return row(p.key, `${p.repo}#${p.number}`, p.url, d.status, d.tone, d.next, shortTitle(p.title), position)
650 })}
651 {running.length > 0 && <Text> </Text>}
652 {running.length > 0 && head(['Running', 'For', '', ''])}
653 {running.map(task => (
654 <Box key={task.id}>
655 <Box width={2} flexShrink={0} />
656 <Box width={W.link} flexShrink={0}><Text>{task.kind}</Text></Box>
657 <Box width={W.status} flexShrink={0}>
658 <Text>
659 {task.delaySeconds !== undefined && task.startedAt
660 ? `due in ${ago(Date.parse(task.startedAt) + task.delaySeconds * 1000 - t)}`
661 : since(task.startedAt)}
662 </Text>
663 </Box>
664 <Box flexShrink={1}><Text dimColor wrap="truncate-end">{task.label}</Text></Box>
665 </Box>
666 ))}
667
668 <Text> </Text>
669 <Text bold>You need to ({total})</Text>
670 {total === 0 && <Text dimColor> Nothing.</Text>}
671 {total > 0 && head(['Where', 'Waiting', 'Do', 'What'])}
672 {qs.map(q => (
673 <Box flexDirection="column" key={qKey(q)}>
674 <Box>
675 <Box width={2} flexShrink={0} />
676 <Box width={W.link} flexShrink={0}>
677 <Button
678 key={`q-${qKey(q)}`}
679 label={`${open === qKey(q) ? '▾' : '▸'} ${q.name}`.slice(0, W.link - 6)}
680 onPress={() => update($, expanded, cur => (cur === qKey(q) ? null : qKey(q)))}
681 />
682 </Box>
683 <Box width={W.status} flexShrink={0}><Text>{since(q.askedAt)}</Text></Box>
684 <Box width={W.next} flexShrink={0}>
685 <Button
686 key={`ans-${qKey(q)}`}
687 label="Answer"
688 onPress={() => update($, expanded, cur => (cur === qKey(q) ? null : qKey(q)))}
689 />
690 </Box>
691 <Box flexShrink={1}><Text dimColor wrap="truncate-end">{q.summary}</Text></Box>
692 </Box>
693 {open === qKey(q) && (
694 <Box flexDirection="column" marginLeft={4} marginY={1}>
695 {q.gist && <Text bold>{q.gist}</Text>}
696 {q.kind === 'ask' && <Text dimColor>Answer this one in the session.</Text>}
697 {(q.replies ?? []).length > 0 && (
698 <Box flexDirection="column" marginTop={1}>
699 {(q.replies ?? []).map((r, i) => (
700 <Box key={`r-${i}`}>
701 <Box width={44} flexShrink={0}>
702 <Button key={`reply-${qKey(q)}-${i}`} label={r.label} onPress={() => send($, q, r.reply)} />
703 </Box>
704 <Box flexShrink={1}><Text dimColor wrap="truncate-end">{r.reply}</Text></Box>
705 </Box>
706 ))}
707 </Box>
708 )}
709 <Box marginTop={1}>
710 {q.kind === 'text' && q.surface && (
711 <Button key={`more-${qKey(q)}`} label="More context" onPress={() => send($, q, MORE_CONTEXT)} />
712 )}
713 <Text> </Text>
714 {q.surface && <Button key={`go-${qKey(q)}`} label="Go to session" onPress={() => goTo($, q)} />}
715 </Box>
716 <Box marginTop={1} flexDirection="column">
717 {q.prompt && <Text dimColor wrap="truncate-end">You asked: {q.prompt.split('\n')[0]}</Text>}
718 <Text>{q.text.split('\n').slice(-30).join('\n')}</Text>
719 </Box>
720 </Box>
721 )}
722 </Box>
723 ))}
724 {todoHere.flatMap(([verb, list]) =>
725 list.map(p =>
726 row(`${verb}-${p.key}`, `${p.repo.split('/').pop()}#${p.number}`, p.url, '', null,
727 verb === 'Apply (Atlantis)' ? 'Apply' : verb, shortTitle(p.title)),
728 ),
729 )}
730
731 {others.length > 0 && (
732 <Box flexDirection="column" marginTop={1}>
733 <Button
734 key="others"
735 label={`${othersOpen ? '▾' : '▸'} My other PRs (${others.length})`}
736 onPress={() => update($, isOthersOpen, v => !v)}
737 />
738 {othersOpen && head(['PR', 'Status', 'Next for you', 'Title'])}
739 {othersOpen &&
740 stackOrder(others).map(({ pr: p, position }) => {
741 const d = describe(p)
742 return row(`other-${p.key}`, `${p.repo}#${p.number}`, p.url, d.status, d.tone, d.next, shortTitle(p.title), position)
743 })}
744 </Box>
745 )}
746
747 {assigned.length > 0 && (
748 <Box flexDirection="column" marginTop={1}>
749 <Button
750 key="tickets"
751 label={`${ticketsOpen ? '▾' : '▸'} My tickets (${assigned.length})`}
752 onPress={() => update($, isTicketsOpen, v => !v)}
753 />
754 {ticketsOpen &&
755 assigned.map(a => (
756 <Box key={`tk-${a.id}`}>
757 <Box width={2} flexShrink={0} />
758 <Box width={W.link} flexShrink={0}>
759 <Button key={`open-tk-${a.id}`} label={a.id} onPress={() => openUrl($, a.url)} />
760 </Box>
761 <Box width={W.status} flexShrink={0}>
762 <Text bold={a.id === tk?.id}>{a.status}</Text>
763 </Box>
764 <Box flexShrink={1}>
765 <Text dimColor wrap="truncate-end">{a.title}</Text>
766 </Box>
767 </Box>
768 ))}
769 </Box>
770 )}
771
772 {later.length > 0 && (
773 <Box flexDirection="column" marginTop={1}>
774 <Button
775 key="later"
776 label={`${laterOpen ? '▾' : '▸'} Later (${later.length})`}
777 onPress={() => update($, isLaterOpen, v => !v)}
778 />
779 {laterOpen && head(['PR', 'Author', 'Do', 'Title'])}
780 {laterOpen &&
781 later.map(p =>
782 row(`later-${p.key}`, `${p.repo.split('/').pop()}#${p.number}`, p.url, p.author ?? '', null, 'Review', shortTitle(p.title)),
783 )}
784 </Box>
785 )}
786 </Box>
787 )
788 })
789}
790hooks/phase.ts 129 lines1import type { SessionPr } from '../types'
2import { describe } from './prs'
3
4// Where a session is in Josh's usual flow: start-ticket, then
5// /masterdevs:feature (refine, plan, unattended build), then pr-merge-ready,
6// then a human approval, then merge. Sticky across turns.
7export type Stage = 'ticket' | 'refine' | 'plan' | 'build' | 'pr' | 'approval' | 'merge'
8
9export const STAGE_LABEL: Record<Stage, string> = {
10 ticket: 'Starting ticket',
11 refine: 'Refining the brief',
12 plan: 'Planning',
13 build: 'Building',
14 pr: 'In PR loop',
15 approval: 'Waiting for approval',
16 merge: 'Merging',
17}
18
19// Skills arrive as a slash command in the prompt or a Skill tool call; match both.
20const SKILLS: [RegExp, Stage][] = [
21 [/\b(start-ticket|adopt-ticket)\b/, 'ticket'],
22 [/\bmasterdevs:(feature|refine)\b/, 'refine'],
23 [/\bmasterdevs:plan\b/, 'plan'],
24 [/\b(pr-merge-ready|pr-push|copilot-respond|merge-origin)\b/, 'pr'],
25 [/\bmerge-it\b/, 'merge'],
26]
27
28export function stageFromText(text: string): Stage | null {
29 const head = text.slice(0, 400)
30 for (const [re, stage] of SKILLS) if (re.test(head)) return stage
31 return null
32}
33
34// What a tool call says about the stage (sticky) or the activity (this turn only).
35export type Signal = { stage?: Stage | null; activity?: string }
36
37export const TEST_CMD = /\b(pytest|vitest|jest|playwright|unittest|tox|go test|cargo test|make test|(npm|pnpm|yarn|bun)( run)? test|plugin test)\b/
38
39// A subagent's test run or browser session is testing too (manual QA); the
40// main loop's browser use without a loop is investigation (see signalOf).
41export function isSubagentTest(tool: string, input: Record<string, any>): boolean {
42 if (tool === 'Bash') return TEST_CMD.test(String(input.command ?? '')) || /\bcmux browser\b/.test(String(input.command ?? ''))
43 return /^mcp__(claude-in-chrome|cmux-cua)__/.test(tool)
44}
45
46export function signalOf(tool: string, input: Record<string, any>, current: Stage | null): Signal {
47 const cmd = String(input.command ?? '')
48 const reason = String(input.reason ?? input.description ?? '')
49 switch (tool) {
50 case 'Skill': {
51 const stage = stageFromText(String(input.skill ?? ''))
52 return stage ? { stage } : {}
53 }
54 case 'Agent': {
55 const type = String(input.subagent_type ?? '')
56 if (/masterdevs:architect|^Plan$/.test(type)) return { stage: 'plan' }
57 if (/masterdevs:(engineer|reviewer)/.test(type)) return { stage: 'build' }
58 if (type === 'eng-skills:pr-reviewer') return { stage: 'pr' }
59 return current ? {} : { activity: 'Researching' }
60 }
61 case 'Workflow':
62 return { stage: 'build' }
63 // The loops wait on timers between turns; the reason names what for.
64 case 'ScheduleWakeup':
65 case 'Monitor':
66 if (/approval|gate check|merge-it/i.test(reason)) return { stage: 'approval' }
67 if (/masterdevs|build/i.test(reason)) return { stage: 'build' }
68 if (/\bPR\b|CI|copilot/i.test(reason)) return { stage: 'pr' }
69 return {}
70 case 'AskUserQuestion':
71 return { activity: 'Waiting for your answer' }
72 case 'Bash':
73 if (/\bgh pr merge\b/.test(cmd)) return { stage: 'merge' }
74 if (/\bgh pr (create|ready)\b/.test(cmd)) return { stage: 'pr' }
75 if (/\b(wt switch|git worktree add)\b/.test(cmd)) return { stage: 'ticket' }
76 // Lasts only while the command runs (register.tsx clears it after).
77 if (TEST_CMD.test(cmd)) return { activity: 'Testing' }
78 return {}
79 case 'Edit':
80 case 'Write':
81 case 'NotebookEdit':
82 // Inside a loop, edits are the loop's; on their own they are hand work.
83 if (current === 'build' || current === 'pr') return {}
84 return { stage: null, activity: 'Writing code' }
85 }
86 if (/^mcp__(datadog|sentry|claude-in-chrome)__/.test(tool) && !current) return { activity: 'Investigating' }
87 return {}
88}
89
90// Between turns: what the session is waiting on.
91export function idlePhase(o: {
92 stage: Stage | null
93 isAsking: boolean
94 askSummary: string
95 hasTimer: boolean
96 wasAborted: boolean
97 isTesting?: boolean
98 prs: SessionPr[]
99}): string {
100 if (o.isAsking) {
101 if ((o.stage === 'refine' || o.stage === 'plan') && /\bplan\b/i.test(o.askSummary)) return 'Waiting for plan approval'
102 return 'Waiting for your answer'
103 }
104 // engineers test constantly inside a build, so their test runs never mask Building.
105 if (o.isTesting && o.stage !== 'build') return 'Testing'
106 if (o.hasTimer && o.stage) return STAGE_LABEL[o.stage]
107 const open = o.prs.find(p => p.state === 'OPEN')
108 if (open) {
109 const status = describe(open).status
110 if (status === 'Approved') return 'Ready to merge'
111 if (status === 'Awaiting review') return 'PR loop done, waiting for approval'
112 if (status === 'Your review') return 'Ready for your review'
113 return `PR: ${status}`
114 }
115 if (o.prs.some(p => p.state === 'MERGED')) return 'Merged'
116 if (o.wasAborted) return 'Stopped'
117 return 'Idle'
118}
119
120// The pill: needs-you states stand out; the rest stay quiet.
121export function pillOf(phase: string): { icon: string; color: string } {
122 if (/^(Waiting for your|Waiting for plan|Ready for your|Ready to merge)/.test(phase)) return { icon: 'hand.raised.fill', color: '#FFC04D' }
123 if (/^PR: (CI failed|Merge conflicts)/.test(phase)) return { icon: 'exclamationmark.triangle.fill', color: '#FF8A80' }
124 if (/waiting for approval|^PR:/i.test(phase)) return { icon: 'clock', color: '#E5E5EA' }
125 if (phase === 'Building' || phase === 'Writing code' || phase === 'Testing') return { icon: 'hammer.fill', color: '#A8D8FF' }
126 if (phase === 'Idle' || phase === 'Merged' || phase === 'Stopped') return { icon: 'moon.fill', color: '#C7C7CC' }
127 return { icon: 'sparkle', color: '#A8D8FF' }
128}
129hooks/prs.ts 60 lines1import type { SessionPr } from '../types'
2
3// good: ready to go. you: your move. wait: someone else's. bad: broken. done: closed.
4export type Tone = 'good' | 'you' | 'wait' | 'bad' | 'done'
5export type Row = { status: string; isBad: boolean; next: string; tone: Tone }
6
7const NEXT: Record<string, string> = { apply: 'Apply', 'mark ready': 'Ready for review', merge: 'Merge' }
8
9// One plain-language status per PR, and what Josh has to do next (or '').
10export function describe(p: SessionPr): Row {
11 const next = (p.action && NEXT[p.action]) || ''
12 const row = (status: string, tone: Tone, n = next): Row => ({ status, isBad: tone === 'bad', next: n, tone })
13 if (p.state !== 'OPEN') return row(p.state === 'MERGED' ? 'Merged' : 'Closed', 'done', '')
14 if (p.action === 'conflicts') return row('Merge conflicts', 'bad', '')
15 if (p.ci === 'fail') return row(`CI failed (${p.failed})`, 'bad')
16 if (p.action === 'apply') return row('Plan ready', 'good')
17 if (p.ci === 'pending') return row('CI running', 'wait')
18 if (p.isDraft) {
19 if (p.copilot === 'requested') return row('Copilot reviewing', 'wait')
20 return p.action === 'mark ready' ? row('Your review', 'you') : row('In PR loop', 'wait')
21 }
22 if (p.action === 'merge') return row('Approved', 'good')
23 // Out of draft: Copilot's comments are the agent's; only reviewers' count.
24 const human = p.humanThreads ?? p.threads
25 if (human) return row(`${human} reviewer comment${human > 1 ? 's' : ''}`, 'wait')
26 return row('Awaiting review', 'wait')
27}
28
29// "ABC-123 feat(billing): store invoice totals" -> "store invoice totals"
30export function shortTitle(title: string): string {
31 return title
32 .replace(/^[A-Z]{2,6}-\d+\s+/, '')
33 .replace(/^\w+(\([^)]*\))?!?:\s*/, '')
34}
35
36export type Stacked = { pr: SessionPr; position: string }
37
38// Orders PRs bottom-up within each stack (a PR whose base is another's head
39// sits on it) and labels each "2/4". A PR on its own gets no label.
40export function stackOrder(prs: SessionPr[]): Stacked[] {
41 const key = (repo: string, branch?: string | null) => `${repo}:${branch ?? ''}`
42 const byHead = new Map(prs.map(p => [key(p.repo, p.head), p]))
43 const parent = (p: SessionPr) => byHead.get(key(p.repo, p.base))
44 const children = (p: SessionPr) => prs.filter(c => c !== p && parent(c) === p)
45 const out: Stacked[] = []
46 for (const root of prs.filter(p => !parent(p))) {
47 const chain: SessionPr[] = []
48 const walk = (p: SessionPr) => {
49 if (chain.includes(p)) return
50 chain.push(p)
51 children(p).forEach(walk)
52 }
53 walk(root)
54 chain.forEach((p, i) => out.push({ pr: p, position: chain.length > 1 ? `${i + 1}/${chain.length}` : '' }))
55 }
56 // A cycle has no root; keep its PRs rather than drop them.
57 for (const p of prs) if (!out.some(o => o.pr === p)) out.push({ pr: p, position: '' })
58 return out
59}
60hooks/tasks.ts 7 lines1export function ago(ms: number): string {
2 const m = Math.max(0, Math.round(ms / 60_000))
3 if (m < 60) return `${m}m`
4 if (m < 24 * 60) return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
5 return `${Math.floor(m / 1440)}d${Math.floor((m % 1440) / 60)}h`
6}
7hooks/ticket.ts 74 lines1import type { Ticket } from '../types'
2
3// ABC-123 from `jq/abc-123-invoice-totals`; only at the start of a path segment,
4// so `feat/los-reconcile-replace-2` is not read as REPLACE-2.
5export function ticketFromBranch(branch: string): string | null {
6 const m = branch.match(/(?:^|\/)([a-z]{2,6}-\d+)(?=$|[-_/])/i)
7 return m ? m[1]!.toUpperCase() : null
8}
9
10// The issue JSON out of an MCP tool result, whichever wrapping it arrives in.
11export function parseIssue(id: string, result: unknown): Ticket | null {
12 const text = textOf(result)
13 if (!text) return null
14 try {
15 const j = JSON.parse(text)
16 if (!j?.title) return null
17 return {
18 id: j.id ?? id,
19 title: j.title,
20 status: j.status ?? '',
21 statusType: j.statusType ?? '',
22 url: j.url ?? '',
23 }
24 } catch {
25 return null
26 }
27}
28
29const ORDER = ['started', 'unstarted', 'backlog', 'triage']
30
31// Open issues out of a list_issues result, in-progress first.
32export function parseIssues(result: unknown): Ticket[] {
33 const text = textOf(result)
34 if (!text) return []
35 try {
36 const j = JSON.parse(text)
37 const list: any[] = Array.isArray(j) ? j : j.issues ?? []
38 return list
39 .filter(i => i?.id && !['completed', 'canceled', 'duplicate'].includes(i.statusType) && i.status !== 'Duplicate')
40 .map(i => ({ id: i.id, title: i.title ?? '', status: i.status ?? '', statusType: i.statusType ?? '', url: i.url ?? '' }))
41 .sort((a, b) => rank(a.statusType) - rank(b.statusType))
42 } catch {
43 return []
44 }
45}
46
47function rank(t: string): number {
48 const i = ORDER.indexOf(t)
49 return i < 0 ? ORDER.length : i
50}
51
52function textOf(r: any): string | null {
53 if (typeof r === 'string') return r
54 if (Array.isArray(r)) return r.map(textOf).filter(Boolean).join('')
55 if (r && typeof r === 'object') {
56 if (typeof r.text === 'string') return r.text
57 if (r.content !== undefined) return textOf(r.content)
58 if (r.title) return JSON.stringify(r)
59 }
60 return null
61}
62
63export function catchupPrompt(id: string, title: string, transcripts: string): string {
64 return `Catch me up on ${id}${title ? ` (${title})` : ''}. I have several threads open and am switching back into this one. Don't change anything; just gather and report.
65
66Gather:
67- The Linear ticket ${id}: description, status, and recent comments.
68- Commits on this branch that aren't on origin/main, plus any uncommitted changes.
69- This branch's PR: CI, review state, and unresolved threads.
70- The most recent Claude sessions in this worktree (${transcripts}): what I asked for and where each one ended.
71
72Report in at most 10 bullets, grouped as Done, In flight, and Next or blocked. End with the single next action you'd take.`
73}
74types/index.d.ts 87 lines1export type Reply = { label: string; reply: string }
2
3export type Question = {
4 id: string
5 name: string
6 kind: 'ask' | 'text'
7 hasQuestionMark?: boolean
8 summary: string
9 gist?: string
10 replies?: Reply[]
11 text: string
12 askedAt: string | null
13 prompt: string | null
14 cwd: string
15 surface: string | null
16 workspace: string | null
17}
18
19export type PrItem = {
20 key: string
21 repo: string
22 number: number
23 title: string
24 url: string
25 author?: string | null
26 isDraft?: boolean
27}
28
29export type SessionPr = {
30 key: string
31 repo: string
32 number: number
33 title: string
34 url: string
35 state: string
36 isDraft: boolean
37 ci: 'pass' | 'fail' | 'pending' | 'none'
38 failed: number
39 pending: number
40 copilot: 'none' | 'requested' | 'reviewed'
41 threads: number
42 humanThreads?: number
43 approved: boolean
44 action: 'apply' | 'conflicts' | 'mark ready' | 'merge' | null
45 base?: string | null
46 head?: string | null
47}
48
49export type Task = {
50 id: string
51 kind: 'agent' | 'shell' | 'monitor' | 'workflow' | 'wakeup'
52 label: string
53 startedAt: string | null
54 delaySeconds?: number
55}
56
57export type Ticket = {
58 id: string
59 title: string
60 status: string
61 statusType: string
62 url: string
63}
64
65declare module 'claude-code' {
66 interface PluginState {
67 dash: {
68 questions: Question[]
69 reviews: PrItem[]
70 merges: PrItem[]
71 applies: PrItem[]
72 prs: SessionPr[]
73 tasks: Task[]
74 ticket: Ticket | null
75 myTickets: Ticket[]
76 allMine: SessionPr[]
77 isOthersOpen: boolean
78 isTicketsOpen: boolean
79 expanded: string | null
80 tripped: string | null
81 isLaterOpen: boolean
82 now: number
83 phase: string | null
84 }
85 }
86}
87