SLOPSHOPPER

dash

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

newpanebandguardcommandtoast
v0.1.0MITupdated 2026-10-14jquintus/dotfiles/claude/mods/dash
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dash
│ ┃ Dash ✕ › fix the failing auth test and add an audit log call │ ┃ This session · Idle │ ┃ No ticket on this branch. ● dash: dash: gate status: SyntaxError: JSON Parse error: Unexpected │ ┃ ● dash: dash: github: SyntaxError: JSON Parse error: Unexpected ident │ ┃ No PRs linked yet. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ You need to (0) ⏺ Update(src/auth.ts) │ ┃ Nothing. ⎿ 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 │ │ › /dash │ ⎿ dash: Dash opened. │ ● dash: dash: sessions: SyntaxError: JSON Parse error: Unexpected ide │ ● dash: dash: here: SyntaxError: JSON Parse error: Unexpected identif │ │ [ Dash ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ Dash ] ⟨Claude Code's own drawing⟩
Pane · Dash
This session · Idle No ticket on this branch. No PRs linked yet. You need to (0) Nothing.
README

dotfiles

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.


Fresh machine setup

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.

1. Install the Xcode Command Line Tools

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

2. Install Homebrew

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

3. Clone this repo

It must live at ~/dotfiles (some configs reference that absolute path).

git clone https://github.com/jquintus/dotfiles ~/dotfiles
cd ~/dotfiles

4. Install packages and apps

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

5. Symlink the dotfiles

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.

6. Apply macOS system defaults

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.

7. Manual steps (no script can do these)

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:

  • Sign into 1Password, then enable any integrations you use (browser extension, SSH agent).
  • Authenticate the GitHub CLI: 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.
  • Install the Claude Code plugins declared in settings: 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.
  • Sign into Codex: 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.
  • Sign into the Cursor CLI: cursor-agent login, which opens a browser. Check it took with cursor-agent status.
  • Sign into the Copilot CLI: copilot login. It can also read a GITHUB_TOKEN from the environment instead, but the interactive login is the simpler path.
  • Grant Hammerspoon Accessibility permission: System Settings > Privacy & Security > Accessibility > enable Hammerspoon. Required for the global hotkeys in hammerspoon/.
  • Approve Automation for Hammerspoon -> Google Chrome: macOS prompts the first time a Google Meet hotkey runs. Needed by 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.)
  • Approve the Karabiner-Elements driver: Karabiner installs a system extension, so first launch needs more than the usual permission grant. System Settings > General > Login Items & Extensions > Driver Extensions > enable Karabiner. Then System Settings > Privacy & Security > Input Monitoring > enable both 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.
  • Install and configure Logi Options+ (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:
  • Swap the middle button and wheel-mode-shift button, so pressing the wheel toggles ratchet/free-spin and the button behind the wheel is a plain middle click.
  • Add Visual Studio Code as a per-application profile and assign the thumb buttons the keystrokes 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.

  • Install App-Store-only apps by signing into the App Store and downloading them (the Brewfile does not cover these).
  • Install editor plugins (see "Vim & Neovim plugins" below).
  • Set up machine-local private files that intentionally live outside this repo: ~/.pgpass and any real DB connection details (see zsh/_zshrc-aliases).
  • Optional, per-host shell config: this machine picks up zsh/_zshrc-<LocalHostName> if it exists. Find this machine's name with scutil --get LocalHostName, then create that file for host-specific tweaks.
  • Restart or log out so firmware/hotkey changes from step 6 fully apply.

Reference

Homebrew package management

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

macOS system defaults

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.

App user configs (VS Code, Claude Code)

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

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 instructionsAGENTS.mdgenerated from claude/CLAUDE.md
Subagentsagents/*.tomlgenerated from claude/agents/*.md
Skillsskills/<name>symlinked to ~/.claude/skills/<name>
CLAUDE.md as fallbackconfig.tomlone 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.

Vim & Neovim plugins

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.

Vim (vim-plug)

# Installs vim-plug automatically if missing, then installs all plugins
vim +PlugInstall +qall

Or open vim and run :PlugInstall.

Neovim (Packer)

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

Links to consider

Applications installed

A snapshot of /Applications (regenerate with ls /Applications):

  • 1Password
  • Alfred 5
  • AltTab
  • Amphetamine
  • Bear
  • ChatGPT
  • Claude
  • cmux
  • ColorSlurp
  • Commander One
  • Cryptomator
  • Cursor
  • Docker
  • Ghostty
  • Granola
  • Itsycal
  • Karabiner-Elements
  • Keynote
  • logioptionsplus
  • Maccy
  • MacVim
  • Magnet
  • Monosnap
  • Mouse Glide
  • Obsidian
  • OneDrive
  • OrbStack
  • Rectangle
  • Shottr
  • Slack
  • Spotify
  • Whatsapp
  • Wispr Flow
Source 6 files
hooks/register.tsx 790 lines
1import { 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}
790
hooks/phase.ts 129 lines
1import 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}
129
hooks/prs.ts 60 lines
1import 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}
60
hooks/tasks.ts 7 lines
1export 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}
7
hooks/ticket.ts 74 lines
1import 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}
74
types/index.d.ts 87 lines
1export 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