SLOPSHOPPER

hq

HQ session view of the crew board: a live pane, a band above the prompt and toasts when a task needs you.

panebandguardcommandtoaststatus
v0.1.0MITupdated 2026-10-19ritamgh/claude-skills/mods/hq
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hq
│ ┃ Crew board ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ tab-focus · tab-projects · new-lead ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ All clear ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /hq │ ⎿ hq: Crew board refreshed. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Crew board
tab-focus · tab-projects · new-lead All clear
README

Claude skills

A composable Claude Code setup for planning, background workers, local code exploration and evidence-driven delivery. It includes thirteen skills plus their command-line helpers, hooks, five orchestration agents, the HQ mod and an idea-research workflow. It is a standalone checkout, not a copy of anyone's home directory, settings, credentials or git history.

Requirements

The shell toolchain targets Linux/GNU, not macOS: bash 4+, git, jq, flock (util-linux), tmux, GNU coreutils and the usual Linux process tools. Python 3.11+ is required, with the stdlib sqlite3 module built with FTS5. No pip packages are needed for the included Python helpers.

Use a Claude Code build with the mods runtime (claude-code) and the Workflow tool (enableWorkflows). These are version-specific APIs, so check that your build exposes them rather than assuming every Claude Code release does. HQ web and its tests require Bun. Node.js is used by the browser tests and workflow test harnesses.

Optional, per feature:

  • jcode, installed and authenticated, for jw and jcode-backed workers.
  • gh, authenticated, for HQ pull-request information.
  • Neovim + diffview.nvim for crew-diff.
  • curl + an ntfy server/topic for crew-notify, configured in ~/.config/crew/notify.toml. Keep notification credentials outside this checkout.
  • graphify for the graphify skill. Its SKILL.md documents the CLI/package and offline flags.
  • Playwright + Chromium for screenshots and browser tests. The lens README documents discovery and configuration.
  • Tailscale only if you deliberately enable the remote HQ arrangement described below.
  • The sqlite3 CLI and util-linux script for the shell test suites that use them.

Install

Clone the repository URL from the hosting page, then run:

git clone <repository-url> ~/Developer/claude-skills
cd ~/Developer/claude-skills
./install.sh --dry-run
./install.sh

Keep the checkout at that location: installation creates symlinks, not copies. Skills go to ~/.claude/skills/<name>, executables/helpers to ~/.claude/bin/, hooks to ~/.claude/hooks/, agents to ~/.claude/agents/, HQ to ~/.claude/mods/hq, and the workflow to ~/.claude/workflows/idea-research.js. Existing items are moved to ~/.claude/backups/claude-skills-install/<timestamp>/ before linking. Re-running skips links that already point to the checkout. The installer respects $HOME and does not import local changes back into the repository.

Merge, do not replace, the hook entries from settings.example.json into your own ~/.claude/settings.json. Omit its explanatory _comment property. The installer never edits settings. HQ needs no settings entry in the reference setup: Claude Code picks the mod up from ~/.claude/mods/hq, where the installer links it (if your build wants an explicit enable step, follow its mods documentation). Enable workflows for idea-research and orchestrate's saved workflows. Restart or reload Claude Code as appropriate for your build. The examples use ~/.claude/bin/... explicitly because this bin directory need not be on PATH.

Skills

ask-panel adds an Ask widget to an HTML report so questions are answered in the page rather than the session that generated it. Dependencies: Python for injection, the included lens server and claude -p for local Q&A, or the viewer's Claude artifact sampling capability. Browser tests use Node and Playwright.

crew turns the main session into a dispatcher: substantive tasks go to workers, sub-leads or orchestrate while the main conversation stays responsive. Dependencies: the crew CLI and sibling helpers, bash, tmux, git, jq, flock, Python and Claude Code. HQ, jcode workers and the diff viewer are optional additions.

crew-diff opens a task branch or uncommitted changes in diffview.nvim beside the current pane. Dependencies: the included crew-diff helper, tmux, Neovim and diffview.nvim.

evidence-contract grounds nontrivial implementation plans in repository evidence, marks assumptions, and assigns executable success checks before substantial edits. Dependencies: repository inspection tools. The included plan CLI can support structured plans but is not required to read and apply the planning rules.

graphify builds and queries a local graph of files, functions and calls to answer connectivity questions before broad reading. Dependencies: the external graphify CLI and git. Use the SKILL.md's --code-only and --no-label flags to keep extraction offline and avoid unintended model use.

idea-research compares one to eight ideas against criteria and pass/fail gates using verified research, dossiers, a gap round and an evaluator panel. Dependencies: Claude Code's Workflow and web research tools, the included workflows/idea-research.js, and plan when invoked from shape. Workflow tests use Bun.

jcode-workers uses jcode as a worker pool through jw: ask for exploration, review for a second model family's assessment, and run/resume for task execution. Dependencies: authenticated jcode, jw, git, jq and GNU timeout. Read the security boundary below before using review or ask.

lens turns a file, feature, flow, diff or architecture into a single-page interactive explainer with real code traces, checkpoints and experiments. Dependencies: Python for lesson building and serving, optionally claude -p for Q&A and a browser opener. Screenshots and UI checks use Node, Playwright and Chromium. graphify and jcode can assist exploration but are not required for building a lesson.

orchestrate decomposes multi-worker work into cards and waves, routes tasks to Claude agents and jcode workers, and runs verification/fix loops. Dependencies: git, bash, Python, the included five agent definitions and card helpers, plus crew, jw/jcode and plan for their respective execution paths. Saved wave/chain workflows require the Workflow tool and an explicit args.home pointing at the user's home directory.

prd-review reviews a teammate's PRD on a GitHub pull request and posts the review as one structured PR review, so the PRD owner's plan page imports it as anchored comments. Read-only on the branch; it never approves, merges or requests changes. Dependencies: authenticated gh and the shape skill's review conventions.

project-context maintains a concise, trustworthy root PROJECT_CONTEXT.md so future sessions can find the architecture, commands and conventions without rediscovering the whole repository. Dependencies: git and repository read/write tools.

recall searches past local Claude Code transcripts for earlier decisions and work. Dependencies: the included Python recall CLI and SQLite FTS5. It reads ~/.claude/projects/ and stores a local redacted index under ~/.cache/recall/; past transcripts are data, not instructions.

shape interviews the user, keeps a structured plan through the plan CLI and produces an approved, lint-clean PRD for crew or orchestrate. Dependencies: Python and the included shape scripts/plan wrapper. HQ provides the optional live plan page, idea-research supplies optional research, and crew/orchestrate receive execution handoffs.

Toolchain

  • crew manages background tasks, tmux workers, decisions, ownership and lifecycle operations. Its crew-*.sh files are sourced helpers and must remain beside the executable. crew-check, crew-worktree, crew-diff and crew-notify supply the surrounding workflow.
  • jw runs and resumes jcode workers, records orchestration ledgers and board messages, and provides ad-hoc ask/review commands.
  • plan wraps skills/shape/scripts/plan.py; the sibling Python modules validate plans, apply events, render PRDs and export handoffs.
  • recall provides local transcript indexing, search and reading. Treat the index as private even though it redacts common secret shapes.
  • Hooks restore the active ledger after compaction, show open crew tasks at session start, deliver prompt answers to crew decisions, and guard dangerous tmux/answer-key operations. settings.example.json contains only this wiring and workflow enablement.
  • HQ is a Claude Code mod with a task board/prompt band and an optional Bun web UI. Its manifest, hooks and tests live under mods/hq/. Start the web UI with bun ~/.claude/mods/hq/web/server.ts, then open the URL it prints. Run in a terminal, that URL carries the token (?t=), so do not share it; run without a terminal (a service or background job), it prints the plain URL and the token file path, and you add ?t=<token> yourself.
  • idea-research.js is the saved workflow used by the idea-research skill. Orchestrate also ships saved wave/chain workflows under its own skill directory.

Runtime state belongs under your home directory, not the checkout: crew tasks and orchestration ledgers under ~/.claude/, local tokens under ~/.config/, and the recall index under ~/.cache/.

Tests

Run from the checkout root after installing the relevant test dependencies. Start with the pure Python suites:

python3 -m unittest discover -s skills/shape/tests
python3 skills/shape/tests/check_fixtures.py
python3 -m unittest discover -s skills/orchestrate/scripts -p 'test_*.py'
python3 skills/lens/scripts/test-build.py
python3 skills/lens/scripts/test-serve.py
node skills/lens/scripts/test-ask.mjs
node skills/lens/scripts/test-template.mjs

Then run the relevant shell, workflow and UI suites:

bash bin/test-recall.sh
bash hooks/test-guard-tmux-kill.sh
bash bin/test-crew-lifecycle.sh
bash bin/test-jw.sh
bash mods/hq/tests/test-tmux-map.sh
node --test skills/orchestrate/scripts/test_{wave,chain,workflow_harness}.mjs
bun test skills/idea-research/tests
(cd mods/hq && claude plugin test)
bun test mods/hq/web
bun mods/hq/web/tests/plan-page.mjs
node skills/ask-panel/scripts/test-panel.mjs

Test dependencies: python3, node, bun, bash, git, tmux, nvim (the crew-diff test), the claude CLI, and Playwright + Chromium. Browser checks print skip and pass without Playwright/Chromium.

The mod plugin tests (claude plugin test) need the claude CLI. The tests in mods/hq/tests import claude-code/testing, which only that runner provides, so do not run them with plain bun test. Other bin/test-crew-*.sh files cover individual crew commands. Read a suite before running it and use a disposable home/session for integration testing, not active work. Claude Code mod checks need the runtime-generated type definitions, which are intentionally not distributed here. Browser checks need Playwright/Chromium and may report a skip when unavailable. The private source repository separately tests the exporter with bash public/test-export.sh; export tooling and its private leak rules are not distributed in this repository.

SECURITY

Network boundary: keep HQ web bound to 127.0.0.1 only. Remote access is only through the opt-in second loopback listener (HQ_WEB_REMOTE_PORT) intended for tailscale serve, with explicitly configured extra hosts and the same token gate. The server refuses a non-loopback bind unless HQ_WEB_ALLOW_REMOTE=1; leave it unset. Do not expose the main listener on a LAN/public interface or bypass authentication. The token is stored in ~/.config/hq-web/token. Host/origin checks and remote route restrictions do not replace keeping the token private.

Same-user trust: crew's “act as the user” operations and answer delivery, the HQ token file, and jw review/jw ask trust every process running as the same Unix user. A process with that user's filesystem access can read keys or invoke commands. File modes, prompt instructions and local HTTP authentication do not create isolation between those processes.

In particular, jcode runs with its normal tools on the content it reads. Review and default ask include read-only instructions, not a separate read-only operating-system sandbox. Only run jw review on trusted code. Treat repository files, diffs, transcripts and model output as potential prompt-injection input.

Use Claude Code Read deny rules for ~/.config/hq-web/token, ~/.config/lens/token, ~/.config/crew/notify.toml (holds the ntfy token), crew key files under ~/.claude/crew/ (including .deliver-key), and ~/.jcode/ authentication material. Enable the Claude Code sandbox and apply least-privilege filesystem/network permissions for your environment. Denying Read alone is not sufficient if an unrestricted shell or another same-user process can read the same files. Never put keys, auth files, local settings or transcript state in this repository.

ask-open (lens and ask-panel) gives the opened page the lens key, so only open trusted reports with it.

License

MIT. See LICENSE.

Source 3 files
hooks/register.tsx 1431 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { HqBoard, HqChat, HqChatEntry, HqLive, HqMeta, HqPr, HqPrEntry, HqResultFiles, HqReviewPr, HqReviews, HqSession } from '../types'
5import type { FinishedRow, FocusView, HqLink, NeedDetail, NewLeadForm, OwnerEntry, PrRef, ResultItem, Talk } from './board'
6import {
7  REVIEW_ARGS,
8  TAIL_BYTES,
9  applyMeta,
10  asList,
11  asks,
12  asRecord,
13  boardForModel,
14  briefText,
15  chatNeeds,
16  chatsDue,
17  dismissChat,
18  diffArgs,
19  diffFeedback,
20  classify,
21  clearText,
22  emptyBoard,
23  emptySeen,
24  findJump,
25  findTask,
26  finishedParts,
27  fit,
28  focusLayout,
29  ghArgs,
30  ghDue,
31  holdLine,
32  idleText,
33  isTarget,
34  jumpChoices,
35  knownRepos,
36  jumpFor,
37  markSeen,
38  mergeLive,
39  mergedNotLogged,
40  needCount,
41  needDetail,
42  needFooter,
43  needParts,
44  newlyEntered,
45  nowParts,
46  olderText,
47  parseBoard,
48  parseDismissed,
49  parseLastReply,
50  parseLive,
51  lastNote,
52  markLeads,
53  parseLink,
54  parseMeta,
55  parseOwner,
56  parseTalk,
57  parsePr,
58  parseReviewPrs,
59  parseSeen,
60  parseStat,
61  parseStatusNote,
62  parseStatusLastTs,
63  parseStatusTs,
64  prFor,
65  prKey,
66  prTasks,
67  pruneDismissed,
68  prunePrs,
69  projectCards,
70  projectName,
71  projectsFooter,
72  quietLine,
73  relayPrompt,
74  resolveLead,
75  resultBody,
76  resultItems,
77  reviewDue,
78  shortName,
79  showTarget,
80  spawnArgv,
81  spawnOutcome,
82  closeArgv,
83  closeOutcome,
84  wakeArgv,
85  wakeOutcome,
86  splitChats,
87  splitRow,
88  stripMarks,
89  summarize,
90  summaryCounts,
91  taskDot,
92  wrapText,
93} from './board'
94
95const PANE = 'hq-board'
96// Rows the pane asks for while it sits inline above the prompt (the main screen); a dock ignores it.
97const PANE_ARGS = { id: PANE, title: 'Crew board', rows: 16 }
98const HIDDEN_HINT = 'Crew board hidden at this width — type /hq to show it'
99const ROLE = [
100  '# HQ role',
101  "You are HQ, the user's command post over their crews. You relay; you do not do crew work yourself (no code edits, no investigations).",
102  '- An instruction for a project, task or lead: call hq_board and take the task\'s `lead` (else the project\'s entry under `leads`; never an hq session). SendMessage it the user\'s words, faithfully, prefixed "From HQ (user): ". Then confirm in one line: "→ sent to <session>: <gist>" with the same `Open: <link>` as below when the session has a `link`. If it is ambiguous which lead, ask the user, listing the candidates.',
103  '- If a task\'s lead has `leadGuessed` (a guess by project) and the project has more than one live session in hq_board, ask the user which session before sending.',
104  '- The user answers a question ("D3 yes", "answer <task> <text>" or similar): call hq_answer {id, answer} (id: the D-id, else the task id) with their words verbatim, then SendMessage the lead it returns: "From HQ (user): <D-id or id> answered. Answer: <text>". Confirm in one line.',
105  '- If the user asks about a task whose `pr.state` is MERGED while crew still shows it open: tell that task\'s lead "From HQ (user): PR #<N> is merged on GitHub; log <id> landed" and nothing more. You never log landed yourself.',
106  '- Relaying a lead\'s reply or result to the user (also a cross-session idle notice): at most 3 short lines. The verdict in bold; the one or two facts the user needs; any decision they must make as `**Needs you:** …`. Then `Open: <link>` (the session\'s `link` in hq_board; omit it when there is none) and `tmux://<tmux target>` (its `tmux` target as a link, e.g. `tmux://claude:7`; omit it when there is none). Point to the full report path instead of copying it; do not restate what the user can read in the session.',
107  '- Never invent instructions, and never approve anything (merges, landing, holds) the user did not say.',
108  '- Status questions: answer from hq_board or `crew show <id>`.',
109  "- Questions about an HTML report's content belong in that page's Ask panel; say so instead of answering.",
110].join('\n')
111const POLL_MS = 15_000
112const TASK_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
113const DECISION_ID = /^D\d+$/i
114
115// Only a successful poll sets `board`, so it is the toast baseline; a failed one sets `error`.
116const board = atom({ plugin: 'hq', key: 'board' } as const, null)
117const error = atom({ plugin: 'hq', key: 'error' } as const, null)
118const view = atom({ plugin: 'hq', key: 'view' } as const, 'focus')
119const holdAtom = atom({ plugin: 'hq', key: 'holds' } as const, {})
120// What the last gh query found per task, and the tasks already toasted as merged on GitHub.
121const prAtom = atom({ plugin: 'hq', key: 'prs' } as const, {})
122const prToasted = atom({ plugin: 'hq', key: 'prToasted' } as const, [])
123// The PRs whose review is asked of the user, what the user has looked at (kept in the store too), and when each
124// task on the board entered its current state.
125const reviewAtom = atom({ plugin: 'hq', key: 'reviews' } as const, { fetched: 0, prs: null })
126const seenAtom = atom({ plugin: 'hq', key: 'seen' } as const, emptySeen())
127const enteredAtom = atom({ plugin: 'hq', key: 'entered' } as const, {})
128// When each task last wrote a line to its status file: a task that keeps reporting is alive.
129const lastAtom = atom({ plugin: 'hq', key: 'last' } as const, {})
130// The Needs-you items of each live session's last reply, by transcript path.
131const chatAtom = atom({ plugin: 'hq', key: 'chats' } as const, {})
132// Which chat replies the user dismissed with Seen (kept in the store too).
133const dismissedAtom = atom({ plugin: 'hq', key: 'dismissed' } as const, {})
134// The summary.md / report.md text of each task in RESULTS, and the task whose result the pane shows in full.
135const resultsAtom = atom({ plugin: 'hq', key: 'results' } as const, {})
136const detailAtom = atom({ plugin: 'hq', key: 'detail' } as const, null)
137// The whole text of each open question (hold.md / ask.md) and the last note of each task, by task id, and the line of
138// NEEDS YOU whose page the pane shows.
139const questionAtom = atom({ plugin: 'hq', key: 'questions' } as const, {})
140const noteAtom = atom({ plugin: 'hq', key: 'notes' } as const, {})
141const needAtom = atom({ plugin: 'hq', key: 'need' } as const, null)
142// The first line of that page's question that is shown.
143const needScroll = atom({ plugin: 'hq', key: 'needScroll' } as const, 0)
144// The New lead form, null while it is closed.
145const leadAtom = atom({ plugin: 'hq', key: 'newLead' } as const, null)
146// The feature owners and talk threads of the registry (.owners, .talk), and the owner whose Wake form is open.
147const ownerAtom = atom({ plugin: 'hq', key: 'owners' } as const, [])
148const talkAtom = atom({ plugin: 'hq', key: 'talks' } as const, [])
149const wakeAtom = atom({ plugin: 'hq', key: 'wake' } as const, null)
150const SEEN_ALL = '[Seen all]'
151const WAKE_MARK = ' [Wake]'
152const CLOSE_MARK = ' ✕'
153const DISMISS = ' [Dismiss]'
154const RESULT_CHARS = 20_000
155const DETAIL_LINES = 30
156// A question longer than this is paged: the page shows this many of its lines, and Up / Down move by half of them.
157const NEED_LINES = 20
158const NEED_STEP = NEED_LINES / 2
159const CONTEXT_LINES = 12
160const NOTE_CHARS = 2_000
161
162// What the atoms hold may be of an older shape (state outlives a reload of this module): a read that is not of the
163// shape the pane expects counts as empty, so it can never throw while drawing.
164const isBoard = (b: unknown): b is HqBoard => {
165  const value = b as Partial<Record<keyof HqBoard, unknown>> | null
166
167  return !!value && typeof value === 'object' && (['needsYou', 'running', 'waiting', 'done', 'elsewhere', 'parked'] as const).every(key => Array.isArray(value[key]))
168}
169const readBoard = async ($: EngineInterface): Promise<HqBoard | null> => {
170  const value = await read($, board)
171
172  return isBoard(value) ? value : null
173}
174const readReviews = async ($: EngineInterface): Promise<HqReviews> => {
175  const value = (await read($, reviewAtom)) as Partial<HqReviews> | null
176
177  return { fetched: typeof value?.fetched === 'number' ? value.fetched : 0, prs: Array.isArray(value?.prs) ? value.prs : null }
178}
179const saneChats = (raw: unknown): Record<string, HqChatEntry> =>
180  Object.fromEntries(Object.entries(asRecord<Partial<HqChatEntry>>(raw)).filter(([, c]) => Array.isArray(c?.items) && typeof c?.at === 'number')) as Record<string, HqChatEntry>
181
182async function crew($: EngineInterface, args: string[]) {
183  const home = await $.env.get('HOME')
184  if (!home) throw new Error('HOME is not set')
185
186  return $.process.run([`${home}/.claude/bin/crew`, ...args], { timeoutMs: 20_000 })
187}
188
189// bin/tmux-map: every live claude session with its full name and cwd, and its tmux pane if it has one.
190async function liveSessions($: EngineInterface): Promise<HqLive[]> {
191  try {
192    const ran = await $.process.run([`${$.plugin.root}/bin/tmux-map`], { timeoutMs: 10_000 })
193
194    return ran.exitCode === 0 ? parseLive(ran.stdout) : []
195  } catch {
196    return []
197  }
198}
199
200// The open question of each task that has one, by id: hold.md for a held task, ask.md for another task with an open
201// decision. `lines` has its first line, `full` all of it.
202async function readHolds($: EngineInterface, b: HqBoard): Promise<{ lines: Record<string, string>; full: Record<string, string> }> {
203  const home = await $.env.get('HOME')
204  const lines: Record<string, string> = {}
205  const full: Record<string, string> = {}
206
207  for (const t of b.needsYou.filter(t => (t.state === 'held' || t.decision) && TASK_ID.test(t.id))) {
208    try {
209      const text = await $.fs.read(`${home}/.claude/crew/${t.id}/${t.state === 'held' ? 'hold' : 'ask'}.md`)
210      lines[t.id] = holdLine(text)
211      full[t.id] = text.trim().slice(0, RESULT_CHARS)
212    } catch {
213      // no hold.md: the task shows without its question
214    }
215  }
216
217  return { lines, full }
218}
219
220// From each task's status file: when it entered its current state (the first line of the last run of that state), when
221// it last wrote a line, and that line's note.
222async function readStatus($: EngineInterface, b: HqBoard): Promise<{ entered: Record<string, number>; last: Record<string, number>; notes: Record<string, string> }> {
223  const home = await $.env.get('HOME')
224  const entered: Record<string, number> = {}
225  const last: Record<string, number> = {}
226  const notes: Record<string, string> = {}
227
228  for (const t of [...b.needsYou, ...b.running, ...b.waiting, ...b.done].filter(t => TASK_ID.test(t.id))) {
229    try {
230      const text = await $.fs.read(`${home}/.claude/crew/${t.id}/status`)
231      const [enteredAt, lastAt] = [parseStatusTs(text), parseStatusLastTs(text)]
232      if (enteredAt !== undefined) entered[t.id] = enteredAt
233      if (lastAt !== undefined) last[t.id] = lastAt
234      notes[t.id] = parseStatusNote(text).slice(0, NOTE_CHARS)
235    } catch {
236      // no readable status file: the task counts as not new
237    }
238  }
239
240  return { entered, last, notes }
241}
242
243// One crew file as [text, mtime]: kept from the cache while its mtime is unchanged, [null, 0] when it is missing.
244async function readCached($: EngineInterface, path: string, was: [string | null, number]): Promise<[string | null, number]> {
245  try {
246    const { mtimeMs } = await $.fs.stat(path)
247    if (was[1] === mtimeMs && was[0] !== null) return was
248
249    return [(await $.fs.read(path)).slice(0, RESULT_CHARS), mtimeMs]
250  } catch {
251    return [null, 0]
252  }
253}
254
255// summary.md of each task in RESULTS and of each open question (report.md only for one without a summary), read again
256// only when its mtime changes.
257async function readResults($: EngineInterface, b: HqBoard, entered: Record<string, number>, prev: Record<string, HqResultFiles>) {
258  const home = await $.env.get('HOME')
259  const now = await $.clock.now()
260  const out: Record<string, HqResultFiles> = {}
261
262  for (const { id } of [...resultItems(b, entered, now), ...asks(b)].filter(r => TASK_ID.test(r.id))) {
263    const was = prev[id]
264    const [summary, summaryAt] = await readCached($, `${home}/.claude/crew/${id}/summary.md`, [was?.summary ?? null, was?.summaryAt ?? 0])
265    const [report, reportAt] = (summary ?? '').trim() === '' ? await readCached($, `${home}/.claude/crew/${id}/report.md`, [was?.report ?? null, was?.reportAt ?? 0]) : [null, 0]
266    out[id] = { summary, summaryAt, report, reportAt }
267  }
268
269  return out
270}
271
272// The lead, repo path and branch from each open task's meta file.
273async function readMetas($: EngineInterface, b: HqBoard): Promise<Record<string, HqMeta>> {
274  const home = await $.env.get('HOME')
275  const out: Record<string, HqMeta> = {}
276
277  for (const t of [...b.needsYou, ...b.running, ...b.waiting, ...b.done].filter(t => TASK_ID.test(t.id))) {
278    try {
279      out[t.id] = parseMeta(await $.fs.read(`${home}/.claude/crew/${t.id}/meta`))
280    } catch {
281      // no readable meta: the task keeps the repo heuristic
282    }
283  }
284
285  return out
286}
287
288// The chain links ($CREW_ROOT/.links/<sub sessionId>); a link that cannot be read is left out.
289async function readLinks($: EngineInterface): Promise<HqLink[]> {
290  const dir = `${await $.env.get('HOME')}/.claude/crew/.links`
291  const links: HqLink[] = []
292
293  try {
294    for (const e of (await $.fs.list(dir)).filter(e => e.kind !== 'dir' && !e.name.includes('.tmp.'))) {
295      try {
296        links.push(parseLink(await $.fs.read(`${dir}/${e.name}`)))
297      } catch {
298        // no readable link: its session counts as a lead if a task says so
299      }
300    }
301  } catch {
302    // no links folder: no chains
303  }
304
305  return links
306}
307
308// The lead named in every task's meta file, of any age and state (not only the tasks crew ls still lists).
309async function readLed($: EngineInterface): Promise<string[]> {
310  const dir = `${await $.env.get('HOME')}/.claude/crew`
311  let ids: string[] = []
312
313  try {
314    ids = (await $.fs.list(dir)).filter(e => e.kind === 'dir' && TASK_ID.test(e.name)).map(e => e.name)
315  } catch {
316    // no crew folder: no tasks
317  }
318  const metas = await Promise.all(ids.map(id => $.fs.read(`${dir}/${id}/meta`).then(parseMeta, () => ({}) as HqMeta)))
319
320  return metas.flatMap(m => (m.lead ? [m.lead] : []))
321}
322
323// The registry of feature owners ($CREW_ROOT/.owners/<id>/owner with the last line of its notes.md) and the talk
324// threads ($CREW_ROOT/.talk/<thread>/meta). A folder that cannot be read is left out; `.removed` is skipped.
325async function readRegistry($: EngineInterface): Promise<{ owners: OwnerEntry[]; talks: Talk[] }> {
326  const home = await $.env.get('HOME')
327  const owners: OwnerEntry[] = []
328  const talks: Talk[] = []
329  const folders = async (dir: string) => {
330    try {
331      return (await $.fs.list(dir)).filter(e => e.kind === 'dir' && !e.name.startsWith('.') && TASK_ID.test(e.name)).map(e => e.name)
332    } catch {
333      return []
334    }
335  }
336
337  for (const id of await folders(`${home}/.claude/crew/.owners`)) {
338    try {
339      const owner = parseOwner(await $.fs.read(`${home}/.claude/crew/.owners/${id}/owner`))
340      const note = await $.fs.read(`${home}/.claude/crew/.owners/${id}/notes.md`).then(lastNote, () => '')
341      if (owner.feature === id) owners.push({ ...owner, note: note.slice(0, NOTE_CHARS) })
342    } catch {
343      // no readable owner file: it shows nowhere
344    }
345  }
346  for (const id of await folders(`${home}/.claude/crew/.talk`)) {
347    try {
348      talks.push(parseTalk(await $.fs.read(`${home}/.claude/crew/.talk/${id}/meta`)))
349    } catch {
350      // no readable meta: the thread shows nowhere
351    }
352  }
353
354  return { owners, talks }
355}
356
357// One gh query; any failure (no auth, not a GitHub repo, network) is just "no PR known".
358async function fetchPr($: EngineInterface, ref: PrRef, cwd: string): Promise<HqPr | null> {
359  try {
360    const ran = await $.process.run(ghArgs(ref), { cwd, timeoutMs: 20_000 })
361
362    return ran.exitCode === 0 ? parsePr(ran.stdout) : null
363  } catch {
364    return null
365  }
366}
367
368// The one search for PRs whose review is asked of the user. A failed search keeps the last list.
369async function fetchReviews($: EngineInterface): Promise<HqReviewPr[] | null> {
370  try {
371    const ran = await $.process.run(REVIEW_ARGS, { timeoutMs: 20_000 })
372
373    return ran.exitCode === 0 ? parseReviewPrs(ran.stdout) : null
374  } catch {
375    return null
376  }
377}
378
379let isReading = false
380
381// Reads the end of each live session's transcript when the file changed since the last read: one `stat` for all of
382// them, then a `tail` for each changed one, reading more of the end while neither a reply nor a prompt is in it. No
383// model is involved, and what is read stays in the pane's state: it is never logged, toasted or sent to the model.
384async function refreshChats($: EngineInterface, sessions: HqSession[]) {
385  if (isReading) return
386  isReading = true
387
388  try {
389    const paths = sessions.flatMap(s => (s.transcript ? [s.transcript] : []))
390    const cache = saneChats(await read($, chatAtom))
391    const next: Record<string, HqChatEntry> = Object.fromEntries(Object.entries(cache).filter(([path]) => paths.includes(path)))
392    let changed = Object.keys(next).length !== Object.keys(cache).length
393
394    if (paths.length > 0) {
395      const stats = parseStat((await $.process.run(['stat', '-c', '%n|%s|%Y', '--', ...paths], { timeoutMs: 10_000 })).stdout)
396
397      for (const path of Object.keys(next).filter(path => !stats[path])) {
398        delete next[path]
399        changed = true
400      }
401      for (const path of chatsDue(stats, next)) {
402        const { size, mtime } = stats[path]!
403        let chat: HqChat | null = null
404        let failed = false
405        for (const bytes of TAIL_BYTES) {
406          try {
407            const tail = await $.process.run(['tail', '-c', String(bytes), '--', path], { timeoutMs: 10_000 })
408            if (tail.exitCode !== 0 || tail.isStdoutTruncated) throw new Error('unreadable')
409            chat = parseLastReply(tail.stdout, size > bytes)
410          } catch {
411            // a read that failed, at either size, is not kept: the next poll tries again
412            failed = true
413            break
414          }
415          if (chat !== null || size <= bytes) break
416        }
417        delete next[path]
418        if (!failed) next[path] = { items: chat?.items ?? [], at: chat?.at || mtime * 1000, size, mtime }
419        changed = true
420      }
421    }
422    if (changed) await update($, chatAtom, () => next)
423  } catch {
424    // an unreadable transcript leaves its session without chat items
425  } finally {
426    isReading = false
427  }
428}
429
430let isRefreshing = false
431
432// Asks GitHub (outside the board poll, at most three queries each time, the review search among them): who asked
433// the user for a review, and about the PRs of the tasks that are due. Caches the answers, and toasts each task the
434// first time it is seen merged on GitHub but not logged landed.
435async function refreshPrs($: EngineInterface) {
436  if (isRefreshing) return
437  isRefreshing = true
438
439  try {
440    const b = await readBoard($)
441    if (!b) return
442
443    const now = await $.clock.now()
444    const reviews = await readReviews($)
445    const searching = reviewDue(reviews, now)
446    if (searching) {
447      const prs = await fetchReviews($)
448      await update($, reviewAtom, () => ({ fetched: now, prs: prs ?? reviews.prs }))
449    }
450    const found: Record<string, HqPrEntry> = {}
451    for (const { task, ref } of ghDue(prTasks(b), asRecord<HqPrEntry>(await read($, prAtom)), now, searching ? 2 : undefined)) {
452      found[task.id] = { key: prKey(task)!, fetched: now, pr: await fetchPr($, ref, task.repoPath!) }
453    }
454    const cache = asRecord<HqPrEntry>(await read($, prAtom))
455    const kept = prunePrs(b, cache)
456    if (Object.keys(found).length === 0 && Object.keys(kept).length === Object.keys(cache).length) return
457
458    await update($, prAtom, () => ({ ...kept, ...found }))
459
460    const prs = asRecord<HqPrEntry>(await read($, prAtom))
461    const seen = asList<string>(await read($, prToasted))
462    for (const { task } of mergedNotLogged(b, prs).filter(m => !seen.includes(m.task.id))) {
463      $.ui.toast(`${shortName(task.id)} merged on GitHub — not logged`)
464      await update($, prToasted, list => [...list, task.id])
465    }
466  } catch {
467    // a failed refresh leaves the cache as it was
468  } finally {
469    isRefreshing = false
470  }
471}
472
473// Switches the tmux client to the session's pane; says why not (and toasts it) instead of throwing.
474async function jump($: EngineInterface, to: HqSession): Promise<string | null> {
475  const reason = await switchClient($, to.target)
476  if (reason) $.ui.toast(reason)
477
478  return reason
479}
480
481// The user asks for the changes of a task in review: `crew diff <id> --focus` opens them in nvim next to the lead. Its
482// one-line answer, or why it failed, is toasted; a failure to run it at all is toasted too, not thrown.
483async function diffInPane($: EngineInterface, id: string): Promise<void> {
484  if (!TASK_ID.test(id)) return $.ui.toast(`diff failed: no task "${id}"`)
485
486  try {
487    $.ui.toast(diffFeedback(await crew($, diffArgs(id)), id))
488  } catch (failure) {
489    $.ui.toast(`diff failed: ${String(failure)}`)
490  }
491}
492
493async function switchClient($: EngineInterface, target: string): Promise<string | null> {
494  if (!isTarget(target)) return `tmux: refusing the target "${target}"`
495  if (!(await $.env.get('TMUX'))) return "HQ isn't running inside tmux — start it with tmux"
496
497  try {
498    const ran = await $.process.run(['tmux', 'switch-client', '-t', target])
499
500    return ran.exitCode === 0 ? null : `tmux: ${ran.stderr.trim() || `switch-client exited ${ran.exitCode}`}`
501  } catch (failure) {
502    return `tmux: ${String(failure)}`
503  }
504}
505
506// hq_answer: records the user's answer to a held task or an open decision (a D-id, or the id of a task that has
507// one) and names the lead session to notify.
508async function answerHold($: EngineInterface, id: string, answerText: string) {
509  const answer = answerText.trim()
510  if (answer === '') return { deny: 'the answer is empty' }
511  if (!TASK_ID.test(id)) return { deny: `no held task "${id}"` }
512
513  const decision = DECISION_ID.test(id) ? id.toUpperCase() : undefined
514  await poll($)
515  const b = await readBoard($)
516  const task = b?.needsYou.find(t => (decision ? t.decision === decision : t.id === id))
517  if (!b || !task) return { deny: decision ? `no open decision "${id}"` : `no held task "${id}"` }
518  if (task.state !== 'held' && !task.decision) return { deny: `task ${id} is not held (state: ${task.state})` }
519
520  const ran = await crew($, ['answer', decision ?? id, answer])
521  if (ran.exitCode !== 0) return { deny: ran.stderr.trim() || `crew answer exited ${ran.exitCode}` }
522
523  const lead = resolveLead(b, task)
524
525  return {
526    result: JSON.stringify({
527      ok: true,
528      id: task.id,
529      ...(task.decision ? { decision: task.decision } : {}),
530      lead: lead ? lead.session.name : null,
531      ...(lead?.guessed ? { leadGuessed: true } : {}),
532      ...(lead ? {} : { note: 'no live session for this project: tell the user' }),
533    }),
534  }
535}
536
537// The user answers a question on its page: the same as hq_answer, then HQ's model is asked to tell the lead, as it
538// does for an answer typed in chat. Says why not (and toasts it) instead of throwing.
539async function answerInPane($: EngineInterface, id: string, text: string): Promise<boolean> {
540  const answered = await answerHold($, id, text)
541  if ('deny' in answered) {
542    $.ui.toast(`answer not recorded: ${answered.deny}`)
543
544    return false
545  }
546
547  const result = JSON.parse(answered.result) as { id: string; decision?: string; lead: string | null; leadGuessed?: boolean }
548  $.ui.toast(`${result.decision ?? result.id} answered${result.lead ? ` — telling ${result.lead}` : ' — no live lead to tell'}`)
549  void poll($)
550  // a turn of HQ's own: not waited for here, so the page closes at once
551  $.prompt
552    .submit({ text: relayPrompt(result, text.trim()) })
553    .then(submitted => submitted.drop !== undefined && $.ui.toast(`recorded, but the lead was not told: ${submitted.drop}`))
554    .catch(failure => $.ui.toast(`recorded, but the lead was not told: ${String(failure)}`))
555
556  return true
557}
558
559// Opens the New lead form with the folders HQ knows: where live sessions run and the repos of crew tasks.
560async function openLeadForm($: EngineInterface) {
561  const home = (await $.env.get('HOME')) ?? ''
562  const choices = knownRepos(await liveSessions($), await readBoard($), home)
563  await update($, leadAtom, () => ({ repo: '', name: '', text: '', model: '', choices }))
564}
565
566// Starts a top-level lead: `crew spawn --top` as argv, a plain command and never a model turn. Toasts what happened
567// (the window, or why not) instead of throwing; true when it started or queued.
568async function startLead($: EngineInterface, form: NewLeadForm): Promise<boolean> {
569  const home = (await $.env.get('HOME')) ?? ''
570  const built = spawnArgv(`${home}/.claude/bin/crew`, form, home)
571  if ('error' in built) {
572    $.ui.toast(`not started: ${built.error}`)
573
574    return false
575  }
576
577  const ran = await $.process.run(built.argv, { timeoutMs: 30_000 }).catch(failure => ({ exitCode: 127, stdout: '', stderr: String(failure) }))
578  const outcome = spawnOutcome(ran)
579  if (!outcome.ok) {
580    $.ui.toast(`not started: ${outcome.error}`)
581
582    return false
583  }
584
585  $.ui.toast(outcome.queued ? `${form.name.trim()} queued: waiting for RAM (crew spawn --resume ${outcome.id})` : `${form.name.trim()} started in ${outcome.window}`)
586  void poll($)
587
588  return true
589}
590
591async function openWakeForm($: EngineInterface, feature: string) {
592  await update($, wakeAtom, () => feature)
593}
594
595// Wakes an owner: `crew wake FEATURE -- text` as argv with CREW_AS_USER=1, a plain command and never a model turn.
596// Toasts what happened (woke in a window, queued, or why not) instead of throwing; true when it woke or queued.
597async function wakeOwner($: EngineInterface, feature: string, text: string): Promise<boolean> {
598  const home = (await $.env.get('HOME')) ?? ''
599  const built = wakeArgv(`${home}/.claude/bin/crew`, feature, text)
600  if ('error' in built) {
601    $.ui.toast(`not woken: ${built.error}`)
602
603    return false
604  }
605
606  const ran = await $.process.run(built.argv, { env: built.env, timeoutMs: 30_000 }).catch(failure => ({ exitCode: 127, stdout: '', stderr: String(failure) }))
607  const outcome = wakeOutcome(ran)
608  if (!outcome.ok) {
609    $.ui.toast(`not woken: ${outcome.error}`)
610
611    return false
612  }
613
614  $.ui.toast(outcome.message)
615  void poll($)
616
617  return true
618}
619
620// Closes a session's window: `crew close NAME` as argv with CREW_AS_USER=1 (HQ is the user's), a plain command and never a
621// model turn. Toasts what happened (the closed window, or crew's reason for refusing) instead of throwing.
622async function closeSession($: EngineInterface, name: string): Promise<void> {
623  const home = (await $.env.get('HOME')) ?? ''
624  const built = closeArgv(`${home}/.claude/bin/crew`, name)
625  if ('error' in built) {
626    $.ui.toast(`not closed: ${built.error}`)
627
628    return
629  }
630
631  const ran = await $.process.run(built.argv, { env: built.env, timeoutMs: 30_000 }).catch(failure => ({ exitCode: 127, stdout: '', stderr: String(failure) }))
632  const outcome = closeOutcome(ran)
633  $.ui.toast(outcome.ok ? outcome.message : `not closed: ${outcome.error}`)
634  void poll($)
635}
636
637// The user has looked at everything (no id) or at one task: JUST FINISHED drops it.
638async function see($: EngineInterface, id?: string) {
639  const next = markSeen(parseSeen(await read($, seenAtom)), await $.clock.now(), id)
640  await update($, seenAtom, () => next)
641  try {
642    await $.store.set('seen', next)
643  } catch {
644    // not persisted: the seen-state lasts until HQ closes
645  }
646}
647
648// The Needs-you items that sessions left in chat and nobody recorded.
649const allChats = async ($: EngineInterface, b: HqBoard) => chatNeeds(b, saneChats(await read($, chatAtom)), parseDismissed(await read($, dismissedAtom)))
650// Only the sessions whose last reply is under a day old are a need: the band and /hq count those.
651const chatsOf = async ($: EngineInterface, b: HqBoard) => splitChats(await allChats($, b), await $.clock.now()).fresh
652
653// Drops the dismissals of replies that were replaced and of sessions that are gone.
654async function forgetDismissed($: EngineInterface, b: HqBoard) {
655  const was = parseDismissed(await read($, dismissedAtom))
656  const kept = pruneDismissed(b, saneChats(await read($, chatAtom)), was)
657  if (Object.keys(kept).length === Object.keys(was).length) return
658  await update($, dismissedAtom, () => kept)
659  try {
660    await $.store.set('chatSeen', kept)
661  } catch {
662    // not persisted: the dismissals lasted until HQ closes
663  }
664}
665
666// The user dismisses what a session said in chat: the items stay hidden until its next reply.
667async function dismiss($: EngineInterface, key: string, at: number) {
668  const next = dismissChat(parseDismissed(await read($, dismissedAtom)), key, at)
669  await update($, dismissedAtom, () => next)
670  try {
671    await $.store.set('chatSeen', next)
672  } catch {
673    // not persisted: the dismissal lasts until HQ closes
674  }
675}
676
677// The user dismisses the sessions whose questions are over a day old.
678async function dismissAll($: EngineInterface, chats: { key: string; at: number }[]) {
679  for (const { key, at } of chats) await dismiss($, key, at)
680}
681
682let inflight: Promise<void> | null = null
683
684// One poll at a time; a caller that arrives meanwhile waits for the one under way.
685function poll($: EngineInterface): Promise<void> {
686  inflight ??= pollOnce($).finally(() => {
687    inflight = null
688  })
689
690  return inflight
691}
692
693async function pollOnce($: EngineInterface) {
694  try {
695    const prev = await readBoard($)
696    const [ran, live] = await Promise.all([crew($, ['ls']), liveSessions($)])
697    if (ran.exitCode !== 0) throw new Error(ran.stderr.trim() || `crew ls exited ${ran.exitCode}`)
698    const parsed = parseBoard(ran.stdout)
699    const next = markLeads(applyMeta(mergeLive(parsed, live), await readMetas($, parsed)), await readLinks($), await readLed($))
700    await update($, board, () => next)
701    const asked = await readHolds($, next)
702    const registry = await readRegistry($)
703    await update($, ownerAtom, () => registry.owners)
704    await update($, talkAtom, () => registry.talks)
705    await update($, holdAtom, () => asked.lines)
706    await update($, questionAtom, () => asked.full)
707    const { entered, last, notes } = await readStatus($, next)
708    await update($, enteredAtom, () => entered)
709    await update($, lastAtom, () => last)
710    await update($, noteAtom, () => notes)
711    const results = await readResults($, next, entered, asRecord<HqResultFiles>(await read($, resultsAtom)))
712    await update($, resultsAtom, () => results)
713    await refreshChats($, next.elsewhere.filter(s => s.kind === 'claude'))
714    await forgetDismissed($, next)
715    await update($, error, () => null)
716    for (const message of newlyEntered(prev, next)) $.ui.toast(message)
717    void refreshPrs($)
718  } catch (failure) {
719    await update($, error, () => String(failure))
720  }
721}
722
723export const register: Register = on => {
724  on('session.start', async ($, e, next) => {
725    await $.command.register({
726      name: 'hq',
727      description:
728        'Refresh the crew board and show its pane; "show <id>" prints one task, "view" switches Focus and Projects, "go <name>" jumps to a session or task in tmux, "seen" clears JUST FINISHED, "brief" says what changed since you last looked',
729      argumentHint: '[show <id> | view | go <name> | seen | brief]',
730    })
731    await $.tool.register({
732      name: 'hq_board',
733      description:
734        'Read-only. The crew board as JSON: open tasks (id, short name, repo, state, age, note, decision id and question when one is open), live claude sessions (name, project, state, age, tmux target, `link`: the claude.ai URL that opens the session, when it has one), the lead session of each task (`leadGuessed` when it is only a guess by project) and its GitHub PR (number, state) when known, and the best lead per project. Call it before relaying an instruction.',
735      isDeferred: false,
736    })
737    await $.tool.register({
738      name: 'hq_answer',
739      description:
740        "Records the user's answer to a held crew task or an open decision (runs crew answer). id: a decision id such as D3, or a held task; answer: the user's words verbatim. Returns the lead session to notify.",
741      inputSchema: {
742        type: 'object',
743        properties: { id: { type: 'string' }, answer: { type: 'string' } },
744        required: ['id', 'answer'],
745      },
746      isDeferred: false,
747    })
748    try {
749      const saved = parseSeen(await $.store.get('seen'))
750      await update($, seenAtom, () => saved)
751    } catch {
752      // no store: nothing has been looked at yet
753    }
754    try {
755      const dismissed = parseDismissed(await $.store.get('chatSeen'))
756      await update($, dismissedAtom, () => dismissed)
757    } catch {
758      // no store: nothing has been dismissed yet
759    }
760    const opened = await $.ui.open(PANE_ARGS)
761    if (!opened.isPlaced) $.ui.status(HIDDEN_HINT)
762    $.clock.after(1, () => void poll($))
763    $.clock.every(POLL_MS, () => void poll($))
764
765    return next(e)
766  })
767
768  on('tool.call', { tool: 'mcp__hq__hq_board' }, async $ => {
769    await poll($)
770    const [b, asked, prs] = [await readBoard($), asRecord<string>(await read($, holdAtom)), asRecord<HqPrEntry>(await read($, prAtom))]
771
772    return b ? { result: JSON.stringify(boardForModel(b, asked, prs)) } : { deny: (await read($, error)) ?? 'the board is not loaded yet' }
773  })
774
775  on('tool.call', { tool: 'mcp__hq__hq_answer' }, ($, e) => answerHold($, String(e.id ?? ''), String(e.answer ?? '')))
776
777  on('prompt.compose', async ($, e, next) => {
778    const composed = await next(e)
779
780    return { sections: [...composed.sections, { id: 'hq-role', text: ROLE, scope: 'session' as const }] }
781  })
782
783  on('command.run', { command: 'hq' }, async ($, e) => {
784    const [sub, ...ids] = e.args.trim().split(/\s+/)
785
786    if (sub === 'show') {
787      const [id] = ids
788      if (ids.length !== 1 || !id || !TASK_ID.test(id)) return { text: 'usage: /hq show <task id>' }
789      const ran = await crew($, ['show', id])
790      if (ran.exitCode === 0) await see($, id)
791
792      return { text: (ran.exitCode === 0 ? ran.stdout : ran.stderr || ran.stdout).trimEnd() }
793    }
794
795    if (sub === 'go') {
796      const b = (await readBoard($)) ?? emptyBoard()
797      const query = ids.length === 1 ? ids[0]! : ''
798      const named = b.elsewhere.find(s => s.kind === 'claude' && s.target !== '' && s.name === query)
799      const task = named ? undefined : findTask(b, query)
800      const hit = named ?? (task ? jumpFor(b, task)?.session : undefined) ?? (ids.length === 1 ? findJump(b, query) : undefined)
801
802      if (!hit) {
803        const choices = jumpChoices(b)
804
805        return { text: choices.length === 0 ? 'No tmux sessions known yet.' : `No match for "${ids.join(' ')}". Try: ${choices.slice(0, 12).join(', ')}` }
806      }
807
808      const reason = await jump($, hit)
809      if (!reason && task) await see($, task.id)
810
811      return { text: reason ?? `Jumped to ${hit.name} (${showTarget(hit.target)})` }
812    }
813
814    if (sub === 'brief') {
815      await poll($)
816      const failure = await read($, error)
817      const b = await readBoard($)
818      if (failure || !b) return { text: `crew ls failed: ${failure ?? 'the board is not loaded yet'}` }
819
820      const [now, last] = [await $.clock.now(), await $.store.get('lastBrief')]
821      const isFirst = typeof last !== 'number'
822      const text = briefText(b, isFirst ? now - 86_400_000 : last, now, isFirst, (await readStatus($, b)).entered, mergedNotLogged(b, asRecord<HqPrEntry>(await read($, prAtom))))
823      await $.store.set('lastBrief', now)
824
825      return { text }
826    }
827
828    if (sub === 'seen') {
829      await see($)
830
831      return { text: '✓/✗ marks cleared' }
832    }
833
834    if (sub === 'view') {
835      const next = (await read($, view)) === 'focus' ? 'projects' : 'focus'
836      await update($, view, () => next)
837
838      return { text: `View: ${next}` }
839    }
840
841    $.ui.status(undefined)
842    await poll($)
843    await $.ui.open(PANE_ARGS)
844    const current = await readBoard($)
845    const failure = await read($, error)
846
847    const reviews = (await readReviews($)).prs?.length ?? 0
848
849    return { text: failure ?? (current && summarize(current, reviews, await chatsOf($, current))) ?? 'Crew board refreshed.' }
850  })
851
852  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
853    const current = await readBoard($)
854    const reviews = (await readReviews($)).prs?.length ?? 0
855    const chats = current ? await chatsOf($, current) : []
856    const line = current && summarize(current, reviews, chats)
857
858    if (e.props.hasSurvey || !line) return next(e)
859
860    const { Box, Text } = $.ui.resolve(e)
861
862    return (
863      <Box>
864        <Text color={needCount(current, reviews, chats) > 0 ? 'warning' : undefined}>{fit(line, e.props.bodyColumns)}</Text>
865      </Box>
866    )
867  })
868
869  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
870    const { Box, Button, Input, Link, Text } = $.ui.resolve(e)
871    const current = await readBoard($)
872    const failure = await read($, error)
873    const mode = await read($, view)
874    const asked = asRecord<string>(await read($, holdAtom))
875    const prs = asRecord<HqPrEntry>(await read($, prAtom))
876    const now = await $.clock.now()
877    const reviewPrs = (await readReviews($)).prs ?? []
878    const results = asRecord<HqResultFiles>(await read($, resultsAtom))
879    const detailRead = await read($, detailAtom)
880    const detail = typeof detailRead === 'string' ? detailRead : null
881    const needRead = await read($, needAtom)
882    const needKey = typeof needRead === 'string' ? needRead : null
883    const scrolledRead = await read($, needScroll)
884    const scrolled = typeof scrolledRead === 'number' ? scrolledRead : 0
885    const leadRead = (await read($, leadAtom)) as (NewLeadForm & { choices: string[] }) | null
886    const leadForm = leadRead && typeof leadRead === 'object' && Array.isArray(leadRead.choices) ? leadRead : null
887    const wakeRead = await read($, wakeAtom)
888    const wakeFeature = typeof wakeRead === 'string' ? wakeRead : null
889    const owners = asList<OwnerEntry>(await read($, ownerAtom))
890    const talks = asList<Talk>(await read($, talkAtom))
891    const questions = asRecord<string>(await read($, questionAtom))
892    const notes = asRecord<string>(await read($, noteAtom))
893    const last = asRecord<number>(await read($, lastAtom))
894    const ctx = { now, entered: asRecord<number>(await read($, enteredAtom)), seen: parseSeen(await read($, seenAtom)), prs }
895    // all of them: the layout puts the sessions whose reply is over a day old apart
896    const chats = current ? await allChats($, current) : []
897    // inside the pane's padding of 1
898    const width = Math.max(10, e.props.bodyColumns - 2)
899
900    const tab = (key: 'focus' | 'projects', label: string) => (
901      <Button
902        key={`tab-${key}`}
903        plain
904        onPress={async () => {
905          await update($, detailAtom, () => null)
906          await update($, needAtom, () => null)
907          await update($, needScroll, () => 0)
908          await update($, leadAtom, () => null)
909          await update($, wakeAtom, () => null)
910          await update($, view, () => key)
911        }}
912      >
913        <Text bold={mode === key} dimColor={mode !== key}>
914          {label}
915        </Text>
916      </Button>
917    )
918
919    const jumpToLead = (b: HqBoard, id: string) => {
920      const task = findTask(b, id)
921
922      return task ? jumpFor(b, task)?.session : undefined
923    }
924
925    // A row of JUST FINISHED: a result opens its page and counts as seen; one without a page (its PR ended on GitHub)
926    // jumps to the lead that has to log it, and counts as seen once there.
927    const openFinished = async (b: HqBoard, row: FinishedRow) => {
928      if (row.detail) {
929        await see($, row.id)
930        await update($, detailAtom, () => row.id)
931
932        return
933      }
934      const hit = jumpToLead(b, row.id)
935      if (hit && !(await jump($, hit))) await see($, row.id)
936    }
937
938    const layoutOf = (b: HqBoard) => focusLayout({ board: b, plan: classify(b, ctx), ctx, holds: asked, requested: reviewPrs, chats, files: results, last, questions, owners, talks })
939
940    const focus = (b: HqBoard, layout: FocusView) => {
941      // inside the two columns of the glyph or indent that starts every line
942      const inner = width - 2
943      const needs = needParts(layout.needs, inner)
944      const nowLines = nowParts(layout.now, inner - CLOSE_MARK.length)
945      const finished = finishedParts(layout.finished, inner)
946      const heading = `JUST FINISHED${layout.newCount > 0 ? ` · ${layout.newCount} new` : ''}`
947      // with something new the line is padded so that [Seen all] ends at the right edge
948      const headingText = layout.newCount > 0 ? splitRow(heading, SEEN_ALL, width)[0] : heading
949
950      return (
951        <Box flexDirection="column">
952          {(layout.needs.length > 0 || layout.older.length > 0) && (
953            <Box flexDirection="column" marginTop={1}>
954              {layout.needs.length > 0 && <Text bold>{`NEEDS YOU (${layout.total})`}</Text>}
955              {layout.needs.map((n, i) => {
956                const open = (
957                  <Button
958                    key={`need-${n.key}`}
959                    plain
960                    onPress={async () => {
961                      await update($, needScroll, () => 0)
962                      await update($, needAtom, () => n.key)
963                    }}
964                  >
965                    <Text color={n.kind === 'pr' ? 'yellow' : 'magenta'}>{'? '}</Text>
966                    <Text wrap="truncate-end">{`${needs[i]!.name}  ${needs[i]!.msg}  `}</Text>
967                  </Button>
968                )
969                const goKey = n.id ? `go-${n.id}` : n.kind === 'chat' ? `go-chat-${n.session?.name}` : `go-pr-${i}`
970                // where to go: at the right edge, or on a dim line of its own when the pane is too narrow for both
971                const goAt = (text: string, dim: boolean) =>
972                  n.session ? (
973                    <Button key={goKey} plain onPress={() => void jump($, n.session!)}>
974                      <Text color="cyan" dimColor={dim}>
975                        {text}
976                      </Text>
977                    </Button>
978                  ) : (
979                    <Text dimColor>{text}</Text>
980                  )
981
982                return (
983                  <Box flexDirection="column">
984                    <Box>
985                      {open}
986                      {needs[i]!.below === '' && goAt(needs[i]!.go, false)}
987                    </Box>
988                    {needs[i]!.below !== '' && <Box>{goAt(`  ${needs[i]!.below}`, true)}</Box>}
989                  </Box>
990                )
991              })}
992              {layout.morePrs > 0 && <Text dimColor>{`  +${layout.morePrs} more review requests`}</Text>}
993              {layout.older.length > 0 && (
994                <Box>
995                  <Text dimColor wrap="truncate-end">
996                    {olderText(layout.older, width - DISMISS.length)}
997                  </Text>
998                  <Button key="dismiss-older" plain onPress={() => void dismissAll($, layout.older)}>
999                    <Text dimColor>{DISMISS}</Text>
1000                  </Button>
1001                </Box>
1002              )}
1003            </Box>
1004          )}
1005          {layout.now.length > 0 && (
1006            <Box flexDirection="column" marginTop={1}>
1007              <Text bold>NOW</Text>
1008              {layout.now.map((e, i) => {
1009                const hit = e.session && e.session.target !== '' ? e.session : undefined
1010                const busy = e.session?.state === 'busy'
1011                const texts = [
1012                  <Text bold={e.session !== undefined} dimColor={e.session === undefined}>{`  ${nowLines[i]!.name}`}</Text>,
1013                  <Text color={busy ? 'cyan' : undefined} dimColor={!busy}>
1014                    {nowLines[i]!.status}
1015                  </Text>,
1016                  <Text wrap="truncate-end">{nowLines[i]!.lines[0]}</Text>,
1017                ]
1018
1019                return (
1020                  <Box flexDirection="column">
1021                    {hit ? (
1022                      <Box>
1023                        <Button key={`go-${hit.name}`} plain onPress={() => void jump($, hit)}>
1024                          {texts}
1025                        </Button>
1026                        <Button key={`close-${hit.name}`} plain onPress={() => void closeSession($, hit.name)}>
1027                          <Text color="cyan">{CLOSE_MARK}</Text>
1028                        </Button>
1029                      </Box>
1030                    ) : (
1031                      <Box>{texts}</Box>
1032                    )}
1033                    {nowLines[i]!.lines.slice(1).map(line => (
1034                      <Text wrap="truncate-end">{`  ${line}`}</Text>
1035                    ))}
1036                    {e.owners?.map(o => (
1037                      <Box key={`owner-${o.feature}`}>
1038                        <Text dimColor wrap="truncate-end">
1039                          {fit(`  ${o.text}`, inner - WAKE_MARK.length)}
1040                        </Text>
1041                        <Button key={`wake-${o.feature}`} plain onPress={() => void openWakeForm($, o.feature)}>
1042                          <Text color="cyan">{WAKE_MARK}</Text>
1043                        </Button>
1044                      </Box>
1045                    ))}
1046                    {e.talks?.map(t => (
1047                      <Text dimColor wrap="truncate-end">
1048                        {fit(`  ${t}`, inner)}
1049                      </Text>
1050                    ))}
1051                  </Box>
1052                )
1053              })}
1054            </Box>
1055          )}
1056          {layout.finished.length > 0 && (
1057            <Box flexDirection="column" marginTop={1}>
1058              <Box>
1059                <Text bold>{headingText}</Text>
1060                {layout.newCount > 0 && (
1061                  <Button key="seen-all" plain onPress={() => void see($)}>
1062                    <Text dimColor>{SEEN_ALL}</Text>
1063                  </Button>
1064                )}
1065              </Box>
1066              {layout.finished.map((row, i) => (
1067                <Button key={`result-${row.id}`} plain onPress={() => void openFinished(b, row)}>
1068                  <Text color={row.mark === '✗' ? 'red' : 'green'}>{`${row.mark === '✗' ? '✗' : row.isNew ? '●' : ' '} `}</Text>
1069                  <Text wrap="truncate-end">{`${finished[i]!.name}  ${finished[i]!.verdict}  `}</Text>
1070                  {finished[i]!.lead !== '' && <Text dimColor>{finished[i]!.lead}</Text>}
1071                </Button>
1072              ))}
1073              {layout.earlier > 0 && <Text dimColor>{`  +${layout.earlier} earlier`}</Text>}
1074            </Box>
1075          )}
1076          {(layout.isClear || layout.idle.length > 0) && (
1077            <Box marginTop={1}>
1078              <Text dimColor wrap="truncate-end">
1079                {layout.isClear ? clearText(layout.idle.length) : idleText(layout.idle, width)}
1080              </Text>
1081            </Box>
1082          )}
1083        </Box>
1084      )
1085    }
1086
1087    // One result in full: summary.md, else report.md, else the note; a missing file only shortens what is shown.
1088    const detailView = (b: HqBoard, item: ResultItem) => {
1089      const files = results[item.id]
1090      const hit = jumpToLead(b, item.id)
1091      const lines = wrapText(stripMarks(resultBody(files, item.note)), width, DETAIL_LINES)
1092
1093      return (
1094        <Box flexDirection="column">
1095          <Box marginTop={1}>
1096            <Button key="result-back" plain onPress={() => update($, detailAtom, () => null)}>
1097              <Text>← Back</Text>
1098            </Button>
1099          </Box>
1100          <Box marginTop={1}>
1101            <Text bold color={item.mark === '✓' ? 'green' : 'red'}>{`${item.mark} ${item.name} · ${item.project}`}</Text>
1102          </Box>
1103          <Box flexDirection="column" marginTop={1}>
1104            {lines.map(line => (
1105              <Text>{line === '' ? ' ' : line}</Text>
1106            ))}
1107          </Box>
1108          {hit && (
1109            <Box marginTop={1}>
1110              <Button key="result-go" plain onPress={() => void jump($, hit)}>
1111                <Text>{`→ ${hit.name} (${hit.target})`}</Text>
1112              </Button>
1113            </Box>
1114          )}
1115          <Box marginTop={1}>
1116            <Text dimColor>{`crew show ${item.id}`}</Text>
1117          </Box>
1118        </Box>
1119      )
1120    }
1121
1122    // One NEEDS YOU line in full: what it is and where it comes from, the whole text wrapped to the pane, what to
1123    // decide with, and what can be done (answer, go, dismiss, open the PR).
1124    const needView = (d: NeedDetail) => {
1125      const footer = needFooter(d)
1126      const back = async () => {
1127        await update($, needScroll, () => 0)
1128        await update($, needAtom, () => null)
1129      }
1130      const all = wrapText(stripMarks(d.text), width, Infinity)
1131      const top = Math.min(Math.max(0, scrolled), Math.max(0, all.length - NEED_LINES))
1132      const end = Math.min(all.length, top + NEED_LINES)
1133      const move = (to: number) => update($, needScroll, () => Math.max(0, Math.min(to, all.length - NEED_LINES)))
1134
1135      return (
1136        <Box flexDirection="column">
1137          <Box marginTop={1}>
1138            <Button key="need-back" plain onPress={back}>
1139              <Text>← Back</Text>
1140            </Button>
1141          </Box>
1142          <Box marginTop={1} flexDirection="column">
1143            <Text bold color={d.kind === 'pr' ? 'yellow' : 'magenta'}>{`? ${d.title}`}</Text>
1144            {d.meta.map(line => (
1145              <Text dimColor wrap="truncate-end">
1146                {line}
1147              </Text>
1148            ))}
1149          </Box>
1150          <Box flexDirection="column" marginTop={1}>
1151            {all.slice(top, end).map(line => (
1152              <Text>{line === '' ? ' ' : line}</Text>
1153            ))}
1154            {all.length > NEED_LINES && (
1155              <Box>
1156                {top > 0 && (
1157                  <Button key="need-up" plain onPress={() => move(top - NEED_STEP)}>
1158                    <Text color="cyan">▲ Up  </Text>
1159                  </Button>
1160                )}
1161                {end < all.length && (
1162                  <Button key="need-down" plain onPress={() => move(top + NEED_STEP)}>
1163                    <Text color="cyan">▼ Down  </Text>
1164                  </Button>
1165                )}
1166                <Text dimColor>{`lines ${top + 1}-${end} of ${all.length}`}</Text>
1167              </Box>
1168            )}
1169          </Box>
1170          {d.url && (
1171            <Box marginTop={1}>
1172              <Link href={d.url} />
1173            </Box>
1174          )}
1175          {d.context.map(c => (
1176            <Box flexDirection="column" marginTop={1}>
1177              <Text bold dimColor>
1178                {c.label}
1179              </Text>
1180              {wrapText(stripMarks(c.text), width - 2, CONTEXT_LINES).map(line => (
1181                <Text>{`  ${line}`}</Text>
1182              ))}
1183            </Box>
1184          ))}
1185          {d.answer && (
1186            <Box marginTop={1}>
1187              <Input
1188                key="need-answer"
1189                label="Answer "
1190                placeholder="type it, then Enter"
1191                submitLabel="answer"
1192                onSubmit={async text => {
1193                  if (await answerInPane($, d.answer!, text)) await back()
1194                }}
1195              />
1196            </Box>
1197          )}
1198          {(d.go || d.dismiss || d.diff) && (
1199            <Box marginTop={1}>
1200              {d.go && (
hooks/board.ts 1703 lines
1import type { HqBoard, HqChat, HqChatEntry, HqLive, HqMeta, HqParked, HqPr, HqPrEntry, HqResultFiles, HqReviewPr, HqReviews, HqSeen, HqSession, HqTask } from '../types'
2
3// Pure helpers over the text of `crew ls`; nothing here touches `$`.
4
5export const TOAST_STATES = ['review', 'held', 'done', 'landed', 'failed']
6
7// What a store or an atom held, when it may be of an older shape: anything but an object (a list) is empty.
8export const asRecord = <T = unknown>(value: unknown): Record<string, T> =>
9  value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, T>) : {}
10export const asList = <T = unknown>(value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : [])
11
12export const emptyBoard = (): HqBoard => ({
13  needsYou: [],
14  running: [],
15  waiting: [],
16  done: [],
17  elsewhere: [],
18  parked: [],
19  parkedMore: 0,
20})
21
22const HEADER = /^[A-Z][A-Z ]*$/
23const AGE = '\\d+[smhd]'
24// `  <id> <state> <age>  <shape> <repo> <note>`; shape, repo and note follow the age.
25const TASK = new RegExp(`^\\s+(\\S+)\\s+(\\S+)\\s+(${AGE})\\s*(.*)$`)
26const DETAIL = /^(?:ship|scout|draft)?\s*(\S+)\s*(.*)$/
27// A note that starts with the id of an open decision: `D3 · the rest` (crew ls adds it).
28const DECISION = /^(D\d+) ·(?: (.*))?$/
29// `  proc   pid <n> run <age>  <args>`; the claude rows are ignored: tmux-map lists those in full.
30const SESSION = new RegExp(`^\\s+(claude|proc)\\s+(.+?)\\s+(\\S+)\\s+(${AGE})\\s*(.*)$`)
31// `  P2   10-15  <repo> <note>`.
32const PARKED = /^\s+(P\d+)\s+(\d\d-\d\d)\s+(\S+)\s+(.*)$/
33const MORE = /^\s+\+(\d+) more\s*$/
34
35export function parseBoard(text: string): HqBoard {
36  const board = emptyBoard()
37  let section = ''
38
39  for (const line of text.split('\n')) {
40    if (HEADER.test(line)) {
41      section = line
42      continue
43    }
44
45    if (section === 'NEEDS YOU' || section === 'RUNNING' || section === 'WAITING ON OTHERS' || section === 'DONE') {
46      const m = TASK.exec(line)
47      if (!m) continue
48      const [, repo = '-', detail = ''] = DETAIL.exec(m[4]!) ?? []
49      const decided = DECISION.exec(detail.trim())
50      const note = decided ? (decided[2] ?? '') : detail
51      const task: HqTask = { id: m[1]!, state: m[2]!, age: m[3]!, repo, note: note.trim(), ...(decided ? { decision: decided[1]! } : {}) }
52      const list = {
53        'NEEDS YOU': board.needsYou,
54        RUNNING: board.running,
55        'WAITING ON OTHERS': board.waiting,
56        DONE: board.done,
57      }[section]!
58      list.push(task)
59    } else if (section === 'RUNNING ELSEWHERE') {
60      const m = SESSION.exec(line)
61      if (!m || m[1] !== 'proc') continue
62      const session: HqSession = {
63        kind: 'proc',
64        name: m[2]!,
65        state: m[3]!,
66        age: m[4]!,
67        seconds: ageSeconds(m[4]!),
68        where: m[5]!,
69        target: '',
70        window: '',
71      }
72      board.elsewhere.push(session)
73    } else if (section === 'PARKED') {
74      const m = PARKED.exec(line)
75      if (!m) {
76        board.parkedMore = Number(MORE.exec(line)?.[1] ?? board.parkedMore)
77        continue
78      }
79      const parked: HqParked = { pri: m[1]!, date: m[2]!, repo: m[3]!, text: m[4]! }
80      board.parked.push(parked)
81    }
82  }
83
84  return board
85}
86
87const stateById = (board: HqBoard): Map<string, string> =>
88  new Map([...board.needsYou, ...board.running, ...board.waiting, ...board.done].map(t => [t.id, t.state]))
89
90// One "<id> → <state>" per task whose state is a toast state it was not in at the previous poll.
91// The first poll (no previous board) reports nothing.
92export function newlyEntered(prev: HqBoard | null, next: HqBoard): string[] {
93  if (prev === null) return []
94
95  const before = stateById(prev)
96
97  return [...next.needsYou, ...next.running, ...next.waiting, ...next.done]
98    .filter(t => TOAST_STATES.includes(t.state) && before.get(t.id) !== t.state)
99    .map(t => `${t.id} → ${t.state}`)
100}
101
102// Tasks that wait for the user's answer: held ones and those with an open decision (a D-id).
103export const isAsk = (t: HqTask): boolean => t.state === 'held' || t.decision !== undefined
104export const asks = (board: HqBoard): HqTask[] => board.needsYou.filter(isAsk)
105
106// What waits for the user: questions (a held task, or a task with an open decision, one in review included), the PRs
107// whose review was asked of them, and the sessions that left Needs-you items in their last reply without recording
108// them (`chats`, one each however many items). A task in review without an open decision is the lead still verifying: it does not wait for the user.
109export const needCount = (board: HqBoard, reviews = 0, chats: ChatNeed[] = []): number =>
110  asks(board).length + reviews + asList(chats).length
111
112// The band line, or null when nothing needs you and nothing runs. `reviews` counts the PRs whose review was
113// asked of the user; a failed task without a question does not need them.
114export function summarize(board: HqBoard, reviews = 0, chats: ChatNeed[] = []): string | null {
115  const needs = needCount(board, reviews, chats)
116  const running = board.running.length
117  const sessions = board.elsewhere.filter(s => s.kind === 'claude').length
118
119  if (needs === 0 && running === 0) return null
120
121  const parts: string[] = []
122  if (needs > 0) parts.push(`⚠ ${needs} need you`)
123  if (running > 0) parts.push(`${running} running`)
124  if (sessions > 0) parts.push(`${sessions} sessions live`)
125
126  return parts.join(' · ')
127}
128
129// --- display width: wide characters (CJK, emoji) take two columns, combining marks none ---
130
131const ZERO_WIDTH = /[\p{Mn}\p{Me}​-‏⁠︀-️\u0000-\u001f\u007f-\u009f]/u
132const WIDE = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-꓏가-힣豈-﫿︰-﹯＀-⦆¢-₩\u{1F300}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F900}-\u{1F9FF}\u{20000}-\u{3FFFD}]|\p{Emoji_Presentation}/u
133const PICTOGRAPH = /\p{Extended_Pictographic}/u
134
135const charCols = (ch: string): number => (ZERO_WIDTH.test(ch) ? 0 : WIDE.test(ch) ? 2 : 1)
136
137// The columns `text` takes on a terminal. A pictograph followed by U+FE0F is drawn wide.
138export function cols(text: string): number {
139  let total = 0
140  let previous = ''
141  for (const ch of text) {
142    total += ch === '️' && PICTOGRAPH.test(previous) && charCols(previous) === 1 ? 1 : charCols(ch)
143    previous = ch
144  }
145
146  return total
147}
148
149// How many of `chars` fit in `width` columns.
150function takeCols(chars: string[], width: number): number {
151  let used = 0
152  let n = 0
153  for (const ch of chars) {
154    used += charCols(ch)
155    if (used > width) break
156    n += 1
157  }
158
159  return n
160}
161
162export const cutCols = (text: string, width: number): string => {
163  const chars = Array.from(text)
164
165  return chars.slice(0, takeCols(chars, width)).join('')
166}
167export const padCols = (text: string, width: number): string => text + ' '.repeat(Math.max(0, width - cols(text)))
168
169export function fit(text: string, width: number): string {
170  if (width <= 0) return ''
171  if (cols(text) <= width) return text
172
173  return cutCols(text, width - 1) + '…'
174}
175
176export const shortName = (id: string): string => id.replace(/^(\d{4}-)+/, '') || id
177
178// The first clause of a task's note, without a leading "verified".
179export function shortNote(note: string): string {
180  const clause = (note.split(';')[0] ?? '').trim()
181
182  return clause.replace(/^(lead )?verified:?\s+/i, '')
183}
184
185export const projectName = (repo: string): string => (repo === '' || repo === '-' ? 'other' : repo)
186
187export function sessionProject(cwd: string): string {
188  const base = cwd.replace(/\/+$/, '').split('/').pop() ?? ''
189
190  return base === '' || base === '?' ? 'other' : base
191}
192
193export function taskDot(state: string): { glyph: string; color: string } {
194  if (state === 'review') return { glyph: '●', color: 'yellow' }
195  if (state === 'held') return { glyph: '●', color: 'magenta' }
196  if (state === 'failed') return { glyph: '●', color: 'red' }
197  if (state === 'done' || state === 'landed') return { glyph: '✓', color: 'green' }
198
199  return { glyph: '●', color: 'cyan' }
200}
201
202// `left` padded or cut so that `right` ends exactly at `width`, with a space between.
203export function splitRow(left: string, right: string, width: number): [string, string] {
204  const room = Math.max(0, width - cols(right) - 1)
205
206  return [padCols(fit(left, room), room + 1), right]
207}
208
209const UNIT_SECONDS: Record<string, number> = { s: 1, m: 60, h: 3600, d: 86400 }
210
211function ageSeconds(age: string): number {
212  const m = /^(\d+)([smhd])$/.exec(age)
213
214  return m ? Number(m[1]) * UNIT_SECONDS[m[2]!]! : Infinity
215}
216
217const isToday = (t: HqTask): boolean => ageSeconds(t.age) < 86400
218
219export type HqCounts = { busy: number; idle: number; doneToday: number; parked: number }
220
221export function summaryCounts(board: HqBoard): HqCounts {
222  const claude = board.elsewhere.filter(s => s.kind === 'claude')
223  const busy = claude.filter(s => s.state === 'busy').length + board.elsewhere.length - claude.length
224
225  return {
226    busy,
227    idle: claude.length - claude.filter(s => s.state === 'busy').length,
228    doneToday: board.done.filter(isToday).length,
229    parked: board.parked.length + board.parkedMore,
230  }
231}
232
233// --- what goes where: NEEDS YOU / NOW / JUST FINISHED, and the tasks that wait on others ---
234
235export const FINISHED_TTL_MS = 12 * 3_600_000
236export const emptySeen = (): HqSeen => ({ all: 0, ids: {} })
237
238// `ended`: GitHub says the task's PR merged or closed (`pr` is its number).
239export type FinishedItem = { task: HqTask; mark: '✓' | '✗'; text: string; at: number; ended?: 'merged' | 'closed'; pr?: number }
240// `reviews`: tasks in review whose PR is still open (or unknown): they wait for the user, so they never expire.
241export type Focus = { asks: HqTask[]; reviews: HqTask[]; now: HqTask[]; finished: FinishedItem[]; waiting: HqTask[] }
242// `entered`: when each task entered its state (ms); `seen`: what the user has looked at; `prs`: the gh answers.
243export type FocusContext = { now: number; entered: Record<string, number>; seen: HqSeen; prs: Record<string, HqPrEntry> }
244
245// The board as the user needs it: only real questions need them; running work is NOW; what ended since they last
246// looked (and within 12 hours) is JUST FINISHED, a waiting task whose PR ended on GitHub among it; the rest of the
247// waiting tasks only count in the footer.
248export function classify(board: HqBoard, ctx: FocusContext): Focus {
249  const enteredAt = (t: HqTask) => ctx.entered[t.id] ?? ctx.now - ageSeconds(t.age) * 1000
250  const isNew = ({ task, at }: FinishedItem) => at > Math.max(ctx.seen.all, ctx.seen.ids[task.id] ?? 0) && ctx.now - at < FINISHED_TTL_MS
251
252  const ended = new Map<string, FinishedItem>()
253  for (const task of prTasks(board).filter(t => !isAsk(t))) {
254    const pr = prFor(task, ctx.prs)
255    if (!pr || pr.state === 'OPEN') continue
256    const at = pr.at ?? ctx.prs[task.id]!.fetched
257
258    ended.set(
259      task.id,
260      pr.state === 'MERGED'
261        ? { task, mark: '✓', text: `#${pr.number} merged on GitHub — log landed?`, at, ended: 'merged', pr: pr.number }
262        : { task, mark: '✗', text: `#${pr.number} closed on GitHub`, at, ended: 'closed', pr: pr.number },
263    )
264  }
265  const plain = [...board.needsYou.filter(t => !isAsk(t)), ...board.done]
266    .filter(t => !ended.has(t.id) && t.state !== 'dropped')
267    .map((task): FinishedItem => ({ task, mark: task.state === 'failed' ? '✗' : '✓', text: shortNote(task.note), at: enteredAt(task) }))
268
269  return {
270    asks: asks(board),
271    reviews: board.needsYou.filter(t => t.state === 'review' && !isAsk(t) && !ended.has(t.id)),
272    now: board.running.filter(t => !isAsk(t)),
273    finished: [...ended.values(), ...plain].filter(isNew).sort((a, b) => b.at - a.at),
274    waiting: board.waiting.filter(t => !isAsk(t) && !ended.has(t.id)),
275  }
276}
277
278export const MAX_REVIEW_PRS = 5
279
280export type ProjectCard = {
281  name: string
282  tasks: HqTask[]
283  busy: HqSession[]
284  idle: number
285  waiting: number
286  hasNeeds: boolean
287  // needs-you or running task, or a busy session: worth a full card
288  isActive: boolean
289  doneCount: number
290}
291
292const MAX_DONE_PER_CARD = 3
293
294// One card per project: its questions and running tasks, what just finished, how many wait on others, and its
295// claude sessions. Cards with a question come first, then the most recently active.
296export function projectCards(board: HqBoard, focus: Focus): ProjectCard[] {
297  const cards = new Map<string, ProjectCard & { done: HqTask[]; latest: number }>()
298  const card = (name: string) => {
299    let c = cards.get(name)
300    if (!c) cards.set(name, (c = { name, tasks: [], busy: [], idle: 0, waiting: 0, hasNeeds: false, isActive: false, doneCount: 0, done: [], latest: Infinity }))
301
302    return c
303  }
304
305  for (const t of focus.asks) {
306    const c = card(projectName(t.repo))
307    c.tasks.push(t)
308    c.hasNeeds = true
309    c.latest = Math.min(c.latest, ageSeconds(t.age))
310  }
311  for (const t of focus.now) {
312    const c = card(projectName(t.repo))
313    c.tasks.push(t)
314    c.latest = Math.min(c.latest, ageSeconds(t.age))
315  }
316  for (const { task: t } of focus.finished) {
317    const c = card(projectName(t.repo))
318    c.done.push(t)
319    c.latest = Math.min(c.latest, ageSeconds(t.age))
320  }
321  for (const t of focus.waiting) card(projectName(t.repo)).waiting += 1
322  for (const s of board.elsewhere.filter(s => s.kind === 'claude')) {
323    const c = card(sessionProject(s.where))
324    if (s.state === 'busy') c.busy.push(s)
325    else c.idle += 1
326    c.latest = Math.min(c.latest, s.seconds)
327  }
328
329  return [...cards.values()]
330    .sort((a, b) => Number(b.hasNeeds) - Number(a.hasNeeds) || a.latest - b.latest || a.name.localeCompare(b.name))
331    .map(({ done, latest: _latest, ...c }) => ({
332      ...c,
333      isActive: c.tasks.length > 0 || c.busy.length > 0,
334      doneCount: done.length,
335      tasks: [...c.tasks, ...done.sort((a, b) => ageSeconds(a.age) - ageSeconds(b.age)).slice(0, MAX_DONE_PER_CARD)],
336    }))
337}
338
339const joinCounts = (items: [number, (n: number) => string][], separator: string): string =>
340  items.filter(([n]) => n > 0).map(([n, text]) => text(n)).join(separator)
341
342export const projectsFooter = (c: HqCounts): string =>
343  joinCounts([[c.parked, n => `◇ ${n} parked`], [c.doneToday, n => `✓ ${n} done today`]], ' · ')
344
345// One line for the projects without a card: idle sessions as ○N, tasks done today as ✓N.
346export function quietLine(cards: ProjectCard[]): string {
347  const parts = cards
348    .filter(c => !c.isActive)
349    .map(c => [c.name, c.idle > 0 && `○${c.idle}`, c.waiting > 0 && `⏸${c.waiting}`, c.doneCount > 0 && `✓${c.doneCount}`].filter(Boolean).join(' '))
350
351  return parts.length === 0 ? '' : `quiet  ${parts.join(' · ')}`
352}
353
354// --- live claude sessions (bin/tmux-map) and jumping to the tmux pane one runs in ---
355
356const TARGET = /^[A-Za-z0-9_.:-]+$/
357const HQ_SESSION = /^hq/
358
359export const isTarget = (s: string): boolean => TARGET.test(s)
360
361// A transcript path is handed to `stat` and `tail` as an argument: an absolute .jsonl path of plain characters,
362// without "..".
363const TRANSCRIPT = /^\/[A-Za-z0-9._/-]+\.jsonl$/
364export const isTranscript = (s: string): boolean => TRANSCRIPT.test(s) && !s.includes('..')
365
366// A session's link is only ever a claude.ai/code URL of plain characters: HQ prints it to the user.
367const SESSION_LINK = /^https:\/\/claude\.ai\/code\/[A-Za-z0-9_-]+$/
368export const isSessionLink = (s: string): boolean => SESSION_LINK.test(s)
369
370// `claude:6.0` shows as `claude:6`; other panes keep their index.
371export const showTarget = (target: string): string => target.replace(/\.0$/, '')
372
373export function fmtAge(seconds: number): string {
374  if (seconds < 60) return `${Math.round(seconds)}s`
375  if (seconds < 3600) return `${Math.round(seconds / 60)}m`
376
377  return seconds < 86400 ? `${Math.round(seconds / 3600)}h` : `${Math.round(seconds / 86400)}d`
378}
379
380// Lines of bin/tmux-map: `name<TAB>target<TAB>window<TAB>cwd<TAB>status<TAB>seconds<TAB>transcript<TAB>link`; target and
381// window are empty for a session outside tmux, the transcript when the file is not there, the link without Remote Control.
382export function parseLive(text: string): HqLive[] {
383  return text
384    .split('\n')
385    .map(line => line.split('\t'))
386    .filter(([name, target = '', , cwd, state, seconds]) => name && cwd && state && !Number.isNaN(Number(seconds)) && (target === '' || isTarget(target)))
387    .map(([name, target, window, cwd, state, seconds, transcript = '', link = '']) => ({
388      name: name!,
389      target: target!,
390      window: window!,
391      cwd: cwd!,
392      state: state!,
393      seconds: Number(seconds),
394      transcript: isTranscript(transcript) ? transcript : '',
395      link: isSessionLink(link) ? link : '',
396    }))
397}
398
399// The board with its claude sessions taken from tmux-map (full names, full cwd), not from `crew ls`, which cuts
400// both. hq sessions are left out: they are the viewer, not a crew.
401export function mergeLive(board: HqBoard, live: HqLive[]): HqBoard {
402  const sessions = live
403    .filter(l => !HQ_SESSION.test(l.name))
404    .map(l => ({
405      kind: 'claude' as const,
406      name: l.name,
407      state: l.state,
408      age: fmtAge(l.seconds),
409      seconds: l.seconds,
410      where: l.cwd,
411      target: l.target,
412      window: l.window,
413      ...(l.transcript ? { transcript: l.transcript } : {}),
414      ...(l.link ? { link: l.link } : {}),
415    }))
416
417  return { ...board, elsewhere: [...board.elsewhere.filter(s => s.kind === 'proc'), ...sessions] }
418}
419
420// A project's claude sessions, best lead first: busy, then the most recent.
421function rankedSessions(board: HqBoard, project: string): HqSession[] {
422  return board.elsewhere
423    .filter(s => s.kind === 'claude' && sessionProject(s.where) === project)
424    .sort((a, b) => Number(b.state === 'busy') - Number(a.state === 'busy') || a.seconds - b.seconds)
425}
426
427export const pickLead = (board: HqBoard, project: string): HqSession | undefined => rankedSessions(board, project)[0]
428
429// The session of a project to jump to: its best-ranked claude session that runs in tmux.
430export const repoJump = (board: HqBoard, project: string): HqSession | undefined =>
431  rankedSessions(board, project).find(s => s.target !== '')
432
433// A task of the board by its id or its short name.
434export const findTask = (board: HqBoard, query: string): HqTask | undefined =>
435  [...board.needsYou, ...board.running, ...board.waiting, ...board.done].find(t => t.id === query || shortName(t.id) === query)
436
437// `/hq go <query>`: a session name, else a project name.
438export const findJump = (board: HqBoard, query: string): HqSession | undefined =>
439  board.elsewhere.find(s => s.kind === 'claude' && s.target !== '' && s.name === query) ?? repoJump(board, query)
440
441export function jumpChoices(board: HqBoard): string[] {
442  const inTmux = board.elsewhere.filter(s => s.kind === 'claude' && s.target !== '')
443
444  return [...new Set([...inTmux.map(s => s.name), ...inTmux.map(s => sessionProject(s.where))])].sort()
445}
446
447// --- holds, the model's view of the board, and the brief ---
448
449export const holdLine = (text: string): string =>
450  text
451    .split('\n')
452    .map(line => line.trim())
453    .find(line => line !== '') ?? ''
454
455// What the hq_board tool returns: open tasks (with their lead, `leadGuessed` when it is a guess by project, and
456// their PR), claude sessions, and the best lead session per project.
457export function boardForModel(board: HqBoard, holds: Record<string, string>, prs: Record<string, HqPrEntry> = {}) {
458  const tasks = [...board.needsYou, ...board.running, ...board.waiting].map(t => {
459    const lead = resolveLead(board, t)
460    const pr = prFor(t, prs)
461
462    return {
463      id: t.id,
464      name: shortName(t.id),
465      repo: projectName(t.repo),
466      state: t.state,
467      age: t.age,
468      note: shortNote(t.note),
469      ...(t.decision ? { decision: t.decision } : {}),
470      ...(holds[t.id] ? { question: holds[t.id] } : {}),
471      ...(lead ? { lead: lead.session.name, ...(lead.guessed ? { leadGuessed: true } : {}) } : {}),
472      ...(pr ? { pr: { number: pr.number, state: pr.state } } : {}),
473    }
474  })
475  const claude = board.elsewhere.filter(s => s.kind === 'claude')
476  const sessions = claude.map(s => ({
477    name: s.name,
478    project: sessionProject(s.where),
479    state: s.state,
480    age: s.age,
481    ...(s.target ? { tmux: s.target } : {}),
482    ...(s.link ? { link: s.link } : {}),
483  }))
484  const leads: Record<string, string> = {}
485  for (const project of new Set(sessions.map(s => s.project))) {
486    const lead = pickLead(board, project)
487    if (lead) leads[project] = lead.name
488  }
489
490  return { tasks, sessions, leads }
491}
492
493// --- the task's own lead (meta file) and its pull request ---
494
495// A task's meta file is `key=value` lines; the last `lead=`, `repo=`, `branch=` and `worktree=` win, an empty value does not count.
496// The Diff action reads branch and worktree the way crew diff does (the last line of a key wins, an empty one clears it):
497// when that leaves neither, `diffCleared` is set so canDiff says no even though an earlier value is still in `branch`.
498export function parseMeta(text: string): HqMeta {
499  const meta: HqMeta = {}
500  const last: Record<string, string> = {}
501
502  for (const line of text.split('\n')) {
503    const m = /^(lead|repo|branch|worktree)=(.*)$/.exec(line.trim())
504    if (!m) continue
505    if (m[1] === 'branch' || m[1] === 'worktree') last[m[1]] = m[2]!
506    if (!m[2]) continue
507    if (m[1] === 'lead') meta.lead = m[2]
508    else if (m[1] === 'repo') meta.repoPath = m[2]
509    else if (m[1] === 'branch') meta.branch = m[2]
510    else meta.worktree = m[2]
511  }
512  if (!last.branch && !last.worktree && (meta.branch !== undefined || meta.worktree !== undefined)) meta.diffCleared = true
513
514  return meta
515}
516
517export function applyMeta(board: HqBoard, metas: Record<string, HqMeta>): HqBoard {
518  const withMeta = (t: HqTask): HqTask => (metas[t.id] ? { ...t, ...metas[t.id] } : t)
519
520  return {
521    ...board,
522    needsYou: board.needsYou.map(withMeta),
523    running: board.running.map(withMeta),
524    waiting: board.waiting.map(withMeta),
525    done: board.done.map(withMeta),
526  }
527}
528
529// A chain link ($CREW_ROOT/.links/<sub sessionId>): the sub-lead's name and the name of the lead it reports to.
530export type HqLink = { sub: string; parent: string }
531
532export function parseLink(text: string): HqLink {
533  const value = (key: string): string => text.split('\n').filter(l => l.startsWith(`${key}=`)).pop()?.slice(key.length + 1).trim() ?? ''
534
535  return { sub: value('sub_name'), parent: value('parent_name') }
536}
537
538// Marks the live top-level leads: claude sessions named as the lead of a task, or as the parent of a chain link, that are
539// not themselves a sub-lead (the sub of a link). `led` is the lead of every task meta under $CREW_ROOT, of any age and
540// state (a lead stays a lead after crew ls forgets its tasks); the board's own tasks count too. Other sessions stay plain.
541export function markLeads(board: HqBoard, links: HqLink[], led: string[] = []): HqBoard {
542  const subs = new Set(links.map(l => l.sub))
543  const leads = new Set([...[...board.needsYou, ...board.running, ...board.waiting, ...board.done].map(t => t.lead), ...led, ...links.map(l => l.parent)])
544  const marked = (s: HqSession): HqSession => (s.kind === 'claude' && leads.has(s.name) && !subs.has(s.name) ? { ...s, lead: true } : s)
545
546  return { ...board, elsewhere: board.elsewhere.map(marked) }
547}
548
549// The session that leads a task: the one its meta names, when it is live; else the best session of its project,
550// marked as a guess.
551export function resolveLead(board: HqBoard, task: HqTask): { session: HqSession; guessed: boolean } | undefined {
552  const named = task.lead ? board.elsewhere.find(s => s.kind === 'claude' && s.name === task.lead) : undefined
553  if (named) return { session: named, guessed: false }
554
555  const guess = pickLead(board, projectName(task.repo))
556
557  return guess ? { session: guess, guessed: true } : undefined
558}
559
560// Where to jump for a task: its exact lead, if it is in tmux; else a session of its project that is.
561export function jumpFor(board: HqBoard, task: HqTask): { session: HqSession; guessed: boolean } | undefined {
562  const lead = resolveLead(board, task)
563  if (lead && !lead.guessed) return lead.session.target === '' ? undefined : lead
564
565  const guess = repoJump(board, projectName(task.repo))
566
567  return guess ? { session: guess, guessed: true } : undefined
568}
569
570export type PrRef = { kind: 'number'; key: string; n: number } | { kind: 'branch'; key: string; branch: string }
571
572const BRANCH = /^[A-Za-z0-9._/][A-Za-z0-9._/-]*$/
573
574// What to ask GitHub about a task: the PR number its note mentions, else its meta branch (a plain ref name only).
575export function prRef(task: HqTask): PrRef | undefined {
576  const n = /PR #(\d{1,7})\b/.exec(task.note)?.[1]
577  if (n) return { kind: 'number', key: `#${n}`, n: Number(n) }
578  if (task.branch && BRANCH.test(task.branch)) return { kind: 'branch', key: `branch:${task.branch}`, branch: task.branch }
579
580  return undefined
581}
582
583// What a cached answer is about: the repo path and the reference. A task with no repo or no reference has none.
584export function prKey(task: HqTask): string | undefined {
585  const ref = prRef(task)
586
587  return ref && task.repoPath ? `${task.repoPath}|${ref.key}` : undefined
588}
589
590// The cached PR of a task, only when it was asked about the task's current repo and reference.
591export function prFor(task: HqTask, prs: Record<string, HqPrEntry>): HqPr | null | undefined {
592  const entry = prs[task.id]
593
594  return entry && entry.key === prKey(task) ? entry.pr : undefined
595}
596
597// The cache without the answers that no longer match their task, or whose task left the board.
598export function prunePrs(board: HqBoard, prs: Record<string, HqPrEntry>): Record<string, HqPrEntry> {
599  const tasks = prTasks(board)
600
601  return Object.fromEntries(Object.entries(prs).filter(([id, entry]) => entry.key === prKey(tasks.find(t => t.id === id) ?? ({} as HqTask))))
602}
603
604const PR_FIELDS = 'number,state,mergedAt,closedAt,url'
605
606export const ghArgs = (ref: PrRef): string[] =>
607  ref.kind === 'number'
608    ? ['gh', 'pr', 'view', String(ref.n), '--json', PR_FIELDS]
609    : ['gh', 'pr', 'list', '--head', ref.branch, '--state', 'all', '--json', PR_FIELDS, '--limit', '1']
610
611// The PR in gh's JSON (an object from `pr view`, a list from `pr list`); null for none or anything unreadable.
612// A merged or closed PR carries `at`, the time it ended (ms).
613export function parsePr(stdout: string): HqPr | null {
614  try {
615    const parsed: unknown = JSON.parse(stdout)
616    const pr = Array.isArray(parsed) ? parsed[0] : parsed
617    const { number, state, mergedAt, closedAt } = (pr ?? {}) as Record<string, unknown>
618    if (typeof number !== 'number' || (state !== 'OPEN' && state !== 'MERGED' && state !== 'CLOSED')) return null
619
620    const at = state === 'OPEN' ? NaN : Date.parse(String(state === 'MERGED' ? mergedAt : closedAt))
621
622    return Number.isNaN(at) ? { number, state } : { number, state, at }
623  } catch {
624    return null
625  }
626}
627
628// The tasks gh is asked about: the ones that wait on someone else, and those in review.
629export const prTasks = (board: HqBoard): HqTask[] => [...board.waiting, ...board.needsYou.filter(t => t.state === 'review')]
630
631export function mergedNotLogged(board: HqBoard, prs: Record<string, HqPrEntry>): { task: HqTask; pr: HqPr }[] {
632  return prTasks(board).flatMap(task => {
633    const pr = prFor(task, prs)
634
635    return pr?.state === 'MERGED' && task.state !== 'landed' ? [{ task, pr }] : []
636  })
637}
638
639export const PR_TTL_MS = 5 * 60_000
640export const PR_QUERIES_PER_POLL = 3
641
642// The gh queries to make now: tasks with something to ask and a repo to ask in, whose answer is missing, was about
643// another reference or is older than the TTL; the oldest first, never more than `limit` (three by default).
644export function ghDue(tasks: HqTask[], cache: Record<string, HqPrEntry>, now: number, limit = PR_QUERIES_PER_POLL): { task: HqTask; ref: PrRef }[] {
645  return tasks
646    .flatMap(task => {
647      const ref = prRef(task)
648      const cached = cache[task.id]
649      const fetched = cached && cached.key === prKey(task) ? cached.fetched : 0
650
651      return ref && task.repoPath && now - fetched >= PR_TTL_MS ? [{ task, ref, fetched }] : []
652    })
653    .sort((a, b) => a.fetched - b.fetched)
654    .slice(0, limit)
655    .map(({ task, ref }) => ({ task, ref }))
656}
657
658// The user has looked at everything (no id) or at one task. Entries too old to matter any more are dropped.
659export function markSeen(seen: HqSeen, now: number, id?: string): HqSeen {
660  if (id === undefined) return { all: now, ids: {} }
661  const ids = Object.fromEntries(Object.entries(seen.ids).filter(([, at]) => now - at < 2 * FINISHED_TTL_MS))
662
663  return { all: seen.all, ids: { ...ids, [id]: now } }
664}
665
666// What the store held: anything that is not the right shape is no seen-state at all.
667export function parseSeen(raw: unknown): HqSeen {
668  const { all, ids } = (raw ?? {}) as { all?: unknown; ids?: unknown }
669  const entries = Object.entries((ids && typeof ids === 'object' ? ids : {}) as Record<string, unknown>).filter(([, at]) => typeof at === 'number')
670
671  return { all: typeof all === 'number' ? all : 0, ids: Object.fromEntries(entries) as Record<string, number> }
672}
673
674// One search for the PRs whose review is asked of the user; it takes one of the poll's gh queries.
675export const REVIEW_ARGS = ['gh', 'search', 'prs', '--review-requested=@me', '--state=open', '--json', 'number,title,repository,url,updatedAt']
676
677export const reviewDue = (cache: HqReviews, now: number): boolean => now - cache.fetched >= PR_TTL_MS
678
679// The PRs in gh's JSON list; null for anything unreadable. Titles lose their control characters.
680export function parseReviewPrs(stdout: string): HqReviewPr[] | null {
681  try {
682    const parsed: unknown = JSON.parse(stdout)
683    if (!Array.isArray(parsed)) return null
684
685    return parsed.flatMap((item): HqReviewPr[] => {
686      const { number, title, repository, url, updatedAt } = (item ?? {}) as Record<string, unknown>
687      const repo = (repository ?? {}) as { nameWithOwner?: unknown; name?: unknown }
688      const name = typeof repo.nameWithOwner === 'string' ? repo.nameWithOwner : repo.name
689      if (typeof number !== 'number' || typeof title !== 'string' || typeof url !== 'string' || typeof name !== 'string') return []
690      const at = Date.parse(String(updatedAt))
691
692      return [{ repo: name, number, title: title.replace(/[\u0000-\u001f\u007f]/g, ' '), url, updatedAt: Number.isNaN(at) ? 0 : at }]
693    })
694  } catch {
695    return null
696  }
697}
698
699// The time a task entered its current state: the first line of the last run of one state in its status file
700// (`ts<TAB>state<TAB>note`). A later line with the same state, such as an answer note, does not move it.
701export function parseStatusTs(text: string): number | undefined {
702  let state = ''
703  let ts = ''
704  for (const line of text.split('\n').filter(l => l.trim() !== '')) {
705    const [at = '', now = ''] = line.split('\t')
706    if (now !== state) [state, ts] = [now, at]
707  }
708  const ms = Date.parse(ts)
709
710  return Number.isNaN(ms) ? undefined : ms
711}
712
713// The time of the last line of a status file, whatever its state: a task that keeps reporting is alive.
714export function parseStatusLastTs(text: string): number | undefined {
715  const last = text.split('\n').filter(l => l.trim() !== '').pop() ?? ''
716  const ms = Date.parse(last.split('\t')[0] ?? '')
717
718  return Number.isNaN(ms) ? undefined : ms
719}
720
721const fmtElapsed = (ms: number): string => fmtAge(ms / 1000)
722
723const FINISHED = ['done', 'landed']
724
725// "Since you last looked", plain text. `entered` is when each task entered its current state (ms, from its status
726// file); a task is new when that is after `since`, one with no time is not.
727export function briefText(
728  board: HqBoard,
729  since: number,
730  now: number,
731  isFirst: boolean,
732  entered: Record<string, number>,
733  merged: { task: HqTask; pr: HqPr }[] = [],
734): string {
735  const isNew = (t: HqTask) => (entered[t.id] ?? -Infinity) > since
736  const finished = board.done.filter(t => FINISHED.includes(t.state) && isNew(t))
737  const failed = board.needsYou.filter(t => t.state === 'failed' && isNew(t))
738  const fresh = board.needsYou.filter(t => t.state !== 'failed' && isNew(t))
739  const waiting = board.needsYou.filter(t => !isNew(t))
740  const withNote = (label: string, t: HqTask) => {
741    const note = shortNote(t.note)
742
743    return note === '' ? label : `${label} — ${note}`
744  }
745  const lines = [isFirst ? 'Last 24h:' : `Since your last look (${fmtElapsed(now - since)} ago):`]
746
747  if (finished.length > 0) lines.push(`  landed or done: ${finished.map(t => shortName(t.id)).join(', ')} (${finished.length})`)
748  if (failed.length > 0) lines.push(`  failed: ${failed.map(t => withNote(shortName(t.id), t)).join('; ')}`)
749  if (fresh.length > 0) lines.push(`  needs you: ${fresh.map(t => withNote(`${shortName(t.id)} (${t.state})`, t)).join('; ')}`)
750  if (finished.length + failed.length + fresh.length === 0) lines.push('  nothing new')
751  if (merged.length > 0) lines.push(`  merged on GitHub, not logged: ${merged.map(m => `${shortName(m.task.id)} (PR #${m.pr.number})`).join(', ')}`)
752  if (waiting.length > 0) lines.push(`  still waiting: ${waiting.map(t => `${shortName(t.id)} (${t.state}, ${t.age})`).join(', ')}`)
753  if (board.running.length > 0) lines.push(`  running: ${board.running.length}`)
754
755  return lines.join('\n')
756}
757
758// --- what sessions say in chat: the Needs-you block of the last reply of each live session ---
759
760// How much of the end of a transcript is read, and how much more if neither a reply nor a prompt is in it yet.
761export const TAIL_BYTES = [262_144, 1_048_576]
762const CHAT_ITEM_MAX = 100
763
764// Terminal escapes and other control characters in transcript text would reach the screen as they are.
765const CONTROL = /[\u0000-\u001f\u007f-\u009f]/g
766// What a user "prompt" with no origin can be besides a prompt: a slash command (`/word`, not a path), the tags of
767// shell and slash-command echoes, notifications and reminders, a message from another session, the summary after
768// a compaction.
769const NOT_A_PROMPT =
770  /^(?:\/[A-Za-z][\w:-]*(?:\s|$)|<(?:bash-input|bash-stdout|bash-stderr|local-command-[a-z]+|command-(?:name|message|args)|task-notification|system-reminder|teammate-message|user-prompt-submit-hook)\b|Caveat:|Another Claude session sent|This session is being continued)/
771
772type Block = { type?: unknown; text?: unknown }
773type TranscriptRecord = {
774  type?: unknown
775  timestamp?: unknown
776  isMeta?: unknown
777  isSidechain?: unknown
778  origin?: { kind?: unknown; body?: unknown } | null
779  message?: { content?: unknown }
780}
781
782const blocksOf = (rec: TranscriptRecord): Block[] => (Array.isArray(rec.message?.content) ? (rec.message.content as Block[]) : [])
783const textOf = (rec: TranscriptRecord): string => {
784  const content = rec.message?.content
785
786  return typeof content === 'string' ? content : blocksOf(rec).filter(b => b?.type === 'text' && typeof b.text === 'string').map(b => b.text as string).join('\n')
787}
788
789// A record that answers the session: a prompt the user typed, or their words relayed by HQ. Tool results, meta
790// records, sidechains, and what other senders put in the user's seat (notifications, other sessions) are not answers.
791function isAnswer(rec: TranscriptRecord): boolean {
792  if (rec.type !== 'user' || rec.isSidechain === true) return false
793  const origin = rec.origin
794  if (origin && typeof origin === 'object' && origin.kind === 'peer' && typeof origin.body === 'string' && origin.body.startsWith('From HQ (user)')) return true
795  if (rec.isMeta === true || blocksOf(rec).some(b => b?.type === 'tool_result')) return false
796  if (origin && typeof origin === 'object' && origin.kind !== 'human') return false
797
798  return origin?.kind === 'human' || !NOT_A_PROMPT.test(textOf(rec).trimStart())
799}
800
801const NEEDS_LABEL = /^\s*(?:\*\*|__)?needs you\s*(?:\*\*|__)?\s*:\s*(?:\*\*|__)?\s*(.*)$/i
802// What ends a block: a rule, a heading, or a Next line.
803const BLOCK_END = /^\s*(?:-{3,}|#{1,6}\s.*|(?:\*\*|__)?next(?:\s+steps?)?\s*(?:\*\*|__)?\s*:.*)$/i
804const FENCE = /^\s*(```+|~~~+)/
805const BULLET = /^(\s*)(?:[-*•]|\d+[.)])\s+(.*)$/
806// An item that only says there is nothing: "nothing", "none" or "n/a" (also "no") alone, with a short qualifier, or with
807// a sentence after it ("nothing yet. Once the review is done..."). "None of these fit" is a request, not an empty block.
808const NOTHING = /^(?:[\s—–-]*(?:nothing|none|n\/a)(?:\s+(?:yet|right now|for now|at the moment|needed|pending|else))?\s*(?:[.!;:,—–-]|$)|no(?:\s+(?:right now|for now|else|yet|needed|at the moment))?[.!]?$)/i
809
810const cleanItem = (text: string): string =>
811  text
812    .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
813    .replace(/\*\*|__|`/g, '')
814    .replace(CONTROL, ' ')
815    .replace(/\s+/g, ' ')
816    .trim()
817    .slice(0, 400)
818
819// The items of the "Needs you" blocks in a message: the text after the label and the bullet or numbered lines below
820// it, up to the end of the message or the next rule, heading or Next line. Markdown is stripped, a line is one item.
821export function parseNeeds(text: string): string[] {
822  const items: string[] = []
823  let first = -1
824  let joins = false
825  let fence = ''
826  // the indent of the first bullet of the block: bullets indented deeper are part of the item above
827  let base = -1
828
829  // a lead-in such as "two things:" is no item when bullets follow it
830  const close = () => {
831    if (first >= 0 && items.length > first + 1 && items[first]!.trimEnd().endsWith(':')) items.splice(first, 1)
832    first = -1
833  }
834
835  for (const line of text.split('\n')) {
836    const marker = FENCE.exec(line)?.[1]
837    if (fence === '' && marker) fence = marker[0]!
838    else if (fence !== '' && marker?.[0] === fence) fence = ''
839    if (fence !== '' || marker) continue
840    const label = NEEDS_LABEL.exec(line)
841    if (label) {
842      close()
843      base = -1
844      first = items.length
845      items.push(label[1]!)
846      joins = true
847      continue
848    }
849    if (first < 0) continue
850    if (BLOCK_END.test(line)) {
851      close()
852      continue
853    }
854    if (line.trim() === '') {
855      joins = false
856      continue
857    }
858    const bullet = BULLET.exec(line)
859    const last = items.length - 1
860    if (bullet && base < 0) base = bullet[1]!.length
861    if (bullet && bullet[1]!.length <= base) {
862      items.push(bullet[2]!)
863      joins = true
864    } else if (joins) {
865      items[last] += ` ${bullet ? bullet[2]! : line}`
866    }
867  }
868  close()
869
870  return items.map(cleanItem).filter(item => item !== '' && !NOTHING.test(item))
871}
872
873// The Needs-you items of the last assistant message with text in the tail of a transcript (JSONL), and its time (ms,
874// 0 when it has none); no items when a prompt came after it. Null when the tail holds neither. `partial` says the text
875// begins in the middle of the file, so its first line is cut and left out. Lines that are not JSON are skipped.
876export function parseLastReply(text: string, partial: boolean): HqChat | null {
877  const lines = text.split('\n')
878  if (partial) lines.shift()
879
880  for (let i = lines.length - 1; i >= 0; i--) {
881    let rec: TranscriptRecord
882    try {
883      rec = JSON.parse(lines[i]!) as TranscriptRecord
884    } catch {
885      continue
886    }
887    if (!rec || typeof rec !== 'object') continue
888    if (isAnswer(rec)) return { items: [], at: 0 }
889    if (rec.type !== 'assistant' || rec.isSidechain === true) continue
890    const reply = textOf(rec)
891    if (reply.trim() === '') continue
892    const at = Date.parse(String(rec.timestamp))
893
894    return { items: parseNeeds(reply), at: Number.isNaN(at) ? 0 : at }
895  }
896
897  return null
898}
899
900// `stat -c '%n|%s|%Y'` output: size and modification time per path.
901export function parseStat(stdout: string): Record<string, { size: number; mtime: number }> {
902  const out: Record<string, { size: number; mtime: number }> = {}
903  for (const line of stdout.split('\n')) {
904    const [path, size, mtime] = line.split('|')
905    if (path && isTranscript(path) && /^\d+$/.test(size ?? '') && /^\d+$/.test(mtime ?? '')) out[path] = { size: Number(size), mtime: Number(mtime) }
906  }
907
908  return out
909}
910
911// The transcripts whose file changed since they were read: no entry, or another size or modification time.
912export const chatsDue = (stats: Record<string, { size: number; mtime: number }>, cache: Record<string, HqChatEntry>): string[] =>
913  Object.entries(stats)
914    .filter(([path, s]) => cache[path]?.size !== s.size || cache[path]?.mtime !== s.mtime)
915    .map(([path]) => path)
916
917// What a session said in chat that needs the user and is not recorded: `items` of its last reply, written at `at`.
918// Dismissing it stores `key` and `at`.
919// `items` are cut to fit a line; `full` (when set) is the same items whole.
920export type ChatNeed = { session: HqSession; key: string; items: string[]; full?: string[]; at: number }
921
922// What the user dismissed: per session key, the time (ms) of the reply that was dismissed. Kept apart from the seen-state
923// of tasks, which "Seen all" resets and which ages out.
924export type ChatDismissed = Record<string, number>
925
926export const CHAT_AGE_SHOWN_MS = 2 * 3_600_000
927export const CHAT_OLD_MS = 24 * 3_600_000
928
929// The sessions whose last reply is under a day old are a need; older ones are not, though the pane still names them.
930export function splitChats(chats: ChatNeed[], now: number): { fresh: ChatNeed[]; older: ChatNeed[] } {
931  const list = asList<ChatNeed>(chats)
932
933  return { fresh: list.filter(c => now - c.at < CHAT_OLD_MS), older: list.filter(c => now - c.at >= CHAT_OLD_MS) }
934}
935
936export const dismissChat = (dismissed: ChatDismissed, key: string, at: number): ChatDismissed => ({ ...dismissed, [key]: at })
937
938// What the store held: anything that is not a number per key is dropped.
939export function parseDismissed(raw: unknown): ChatDismissed {
940  const entries = Object.entries(raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}).filter(([, at]) => typeof at === 'number')
941
942  return Object.fromEntries(entries) as ChatDismissed
943}
944
945const chatKey = (session: HqSession): string => `chat:${session.name}`
946
947// The dismissals still worth keeping: not those of a session that wrote a newer reply since, nor of one that is gone
948// (judged only while some claude session is live, so a failed tmux-map does not wipe them). A session whose transcript
949// has not been read yet keeps its dismissal.
950export function pruneDismissed(board: HqBoard, chats: Record<string, HqChatEntry>, dismissed: ChatDismissed): ChatDismissed {
951  const sessions = board.elsewhere.filter(s => s.kind === 'claude')
952
953  return Object.fromEntries(
954    Object.entries(dismissed).filter(([key, at]) => {
955      const session = sessions.find(s => chatKey(s) === key)
956      if (!session) return sessions.length === 0
957
958      const chat = session.transcript ? chats[session.transcript] : undefined
959
960      return !chat || chat.at <= at
961    }),
962  )
963}
964
965// The items of each live session's last reply, minus those that name an open decision (the band shows that decision
966// itself), and those of a session whose reply was dismissed. The newest reply first.
967export function chatNeeds(board: HqBoard, chats: Record<string, HqChatEntry>, dismissed: ChatDismissed): ChatNeed[] {
968  const open = new Set([...board.needsYou, ...board.running, ...board.waiting, ...board.done].flatMap(t => (t.decision ? [t.decision] : [])))
969
970  return board.elsewhere
971    .flatMap((session): ChatNeed[] => {
972      const chat = session.kind === 'claude' && session.transcript ? chats[session.transcript] : undefined
973      const key = chatKey(session)
974      if (!chat || chat.at <= (dismissed[key] ?? -Infinity)) return []
975      const full = chat.items.filter(item => !(item.match(/\bD\d+\b/g) ?? []).some(id => open.has(id)))
976      const items = full.map(item => fit(item, CHAT_ITEM_MAX))
977
978      return items.length === 0 ? [] : [{ session, key, items, full, at: chat.at }]
979    })
980    .sort((a, b) => b.at - a.at)
981}
982
983// --- the Focus view: NEEDS YOU, NOW, JUST FINISHED, and the idle sessions on one line ---
984
985// One line per open question, each ending with where to go. `session` is the pane that pressing it jumps to (missing
986// when there is none); `go` is the text at the right edge. A chat line stands for a whole session: its first item, or
987// "N questions"; `suffix` says how old the reply is when it is more than two hours.
988// `key` names the line (`ask:<task id>`, `chat:<session>`, `pr:<repo>#<n>`): pressing it opens its page. `full` is the
989// whole text behind `msg`, when that is more than it shows.
990export type NeedLine = { kind: 'ask' | 'chat' | 'pr'; key: string; name: string; msg: string; suffix: string; go: string; id?: string; full?: string; session?: HqSession }
991// A claude session that is busy or has open tasks, and those tasks as short phrases; `session` is missing on the line of
992// tasks without a live lead.
993// `owners` and `talks` are the asleep owners and open talk threads led by that session (see ownerRows, talkRows), when it has any.
994export type NowEntry = { name: string; status: string; session?: HqSession; tasks: string[]; owners?: OwnerRow[]; talks?: string[] }
995// A finished task for the user to look at, `isNew` until they have seen it. `detail`: it is a result with a page of its own.
996export type FinishedRow = { id: string; mark: '✓' | '✗'; isNew: boolean; name: string; verdict: string; lead: string; detail: boolean }
997
998export type FocusInput = {
999  board: HqBoard
1000  plan: Focus
1001  ctx: FocusContext
1002  holds?: Record<string, string>
1003  requested?: HqReviewPr[]
1004  chats?: ChatNeed[]
1005  files?: Record<string, HqResultFiles>
1006  // when each task last wrote a line to its status file (ms)
1007  last?: Record<string, number>
1008  // the whole text of each open question, by task id (hold.md / ask.md)
1009  questions?: Record<string, string>
1010  // the feature owners and talk threads of the registry
1011  owners?: OwnerEntry[]
1012  talks?: Talk[]
1013}
1014
1015// `total`: what the NEEDS YOU header counts, the review requests cut from the list included. `older`: sessions whose last
1016// reply is over a day old: they are not a need any more. `earlier`: finished rows beyond the five shown.
1017export type FocusView = {
1018  needs: NeedLine[]
1019  morePrs: number
1020  total: number
1021  older: ChatNeed[]
1022  now: NowEntry[]
1023  finished: FinishedRow[]
1024  newCount: number
1025  earlier: number
1026  idle: { name: string; age: string }[]
1027  isClear: boolean
1028}
1029
1030export const MAX_FINISHED_ROWS = 5
1031export const STALE_MS = 6 * 3_600_000
1032const NEED_NAME_MAX = 22
1033const NOW_NAME_MAX = 20
1034const FINISHED_NAME_MAX = 20
1035const LEAD_MAX = 16
1036
1037const prNumber = (note: string): string | undefined => /PR #(\d+)/.exec(note)?.[1] ?? /#(\d+)/.exec(note)?.[1]
1038
1039// "on Sam" when the note says who it waits for, else the note's first clause.
1040function waitNote(note: string): string {
1041  const who = /\b(?:[Oo]n|[Ff]or)\s+([A-Z][\w-]*)/.exec(note)?.[1]
1042
1043  return who ? `on ${who}` : shortNote(note)
1044}
1045
1046const collapse = (text: string): string => text.replace(/\s+/g, ' ').trim()
1047
1048// `text` cut to `width` at the last word that fits, with an ellipsis; inside the word when the first half has no space.
1049export function cutWords(text: string, width: number): string {
1050  if (width <= 0) return ''
1051  if (cols(text) <= width) return text
1052
1053  const chars = Array.from(text)
1054  const n = takeCols(chars, width - 1)
1055  const head = chars.slice(0, n)
1056  const space = chars[n] === ' ' ? n : head.lastIndexOf(' ')
1057  const kept = space >= head.length / 2 ? head.slice(0, space) : head
1058
1059  return kept.join('').replace(/[\s,;:.\-–—(]+$/, '') + '…'
1060}
1061
1062const goText = (session: HqSession | undefined, lead: HqSession | undefined): string =>
1063  session ? `⏎ ${session.target}` : lead ? 'not in tmux' : 'no live lead'
1064
1065const statusOf = (s: HqSession): string => `${s.state === 'busy' ? '● working' : s.state === 'idle' ? '○ idle' : `⏸ ${s.state}`} ${s.age}`
1066
1067// What the user sees, in the order they need it: questions with where to answer them, work in flight, work that just
1068// finished, and the sessions with nothing to do. A task is in one place only: a question is not in NOW, and a finished
1069// task is not in NOW or in a card.
1070export function focusLayout({ board, plan, ctx, holds = {}, requested = [], chats = [], files = {}, last = {}, questions = {}, owners = [], talks = [] }: FocusInput): FocusView {
1071  const [heldQuestions, lastLine, results, wholeQuestions] = [asRecord<string>(holds), asRecord<number>(last), asRecord<HqResultFiles>(files), asRecord<string>(questions)]
1072  const { fresh, older } = splitChats(chats, ctx.now)
1073  const sessions = board.elsewhere.filter(s => s.kind === 'claude').sort((a, b) => Number(b.state === 'busy') - Number(a.state === 'busy') || a.seconds - b.seconds)
1074  // the sessions a question sends the user to: they are not idle
1075  const asked = new Set<string>()
1076  const lastAt = (t: HqTask): number => lastLine[t.id] ?? ctx.entered[t.id] ?? ctx.now - ageSeconds(t.age) * 1000
1077  const stale = (t: HqTask): string => (ctx.now - lastAt(t) > STALE_MS ? ' ⚠' : '')
1078
1079  const needs: NeedLine[] = [
1080    ...plan.asks.map((t): NeedLine => {
1081      const lead = resolveLead(board, t)?.session
1082      const to = jumpFor(board, t)?.session
1083      asked.add((to ?? lead)?.name ?? '')
1084
1085      const label = t.decision ?? 'held'
1086
1087      return {
1088        kind: 'ask',
1089        key: `ask:${t.id}`,
1090        name: (to ?? lead)?.name ?? shortName(t.id),
1091        msg: `${label} ${heldQuestions[t.id] ?? shortNote(t.note)}`.trimEnd(),
1092        full: `${label} ${collapse(wholeQuestions[t.id] ?? heldQuestions[t.id] ?? t.note)}`.trimEnd(),
1093        suffix: '',
1094        go: goText(to, lead),
1095        id: t.id,
1096        ...(to ? { session: to } : {}),
1097      }
1098    }),
1099    ...fresh.map((c): NeedLine => {
1100      asked.add(c.session.name)
1101      const age = ctx.now - c.at
1102
1103      return {
1104        kind: 'chat',
1105        key: chatKey(c.session),
1106        name: c.session.name,
1107        msg: c.items.length > 1 ? `${c.items.length} questions` : (c.items[0] ?? ''),
1108        full: collapse(c.items.join(' ')),
1109        suffix: age > CHAT_AGE_SHOWN_MS ? ` · ${fmtAge(age / 1000)} old` : '',
1110        go: goText(c.session.target === '' ? undefined : c.session, c.session),
1111        ...(c.session.target === '' ? {} : { session: c.session }),
1112      }
1113    }),
1114    ...asList<HqReviewPr>(requested).slice(0, MAX_REVIEW_PRS).map((pr): NeedLine => {
1115      const to = repoJump(board, pr.repo.split('/').pop() ?? pr.repo)
1116
1117      return { kind: 'pr', key: `pr:${pr.repo}#${pr.number}`, name: `PR ${pr.repo} #${pr.number}`, msg: pr.title, suffix: '', go: to ? `⏎ ${to.target}` : 'on GitHub', ...(to ? { session: to } : {}) }
1118    }),
1119  ]
1120  const morePrs = Math.max(0, asList(requested).length - MAX_REVIEW_PRS)
1121
1122  const tasksOf = new Map<HqSession, string[]>(sessions.map(s => [s, []]))
1123  const other: string[] = []
1124  const open = (t: HqTask, phrase: string) => {
1125    const lead = resolveLead(board, t)?.session
1126
1127    ;(lead ? tasksOf.get(lead)! : other).push(phrase)
1128  }
1129  // crew ls lists a task in review without a decision under RUNNING: the lead is verifying it
1130  for (const t of plan.now) open(t, t.state === 'queued' ? `${shortName(t.id)} queued` : `${shortName(t.id)} ${t.state === 'review' ? 'verifying' : t.state} ${t.age}${stale(t)}`)
1131  for (const t of plan.reviews) open(t, `${shortName(t.id)} verifying ${t.age}${stale(t)}`)
1132  for (const t of plan.waiting) open(t, `${[shortName(t.id), prNumber(t.note) && `#${prNumber(t.note)}`].filter(Boolean).join(' ')} waiting ${waitNote(t.note)}`.trimEnd())
1133
1134  // asleep owners and open talk threads sit under their lead's card; those whose lead is not live go with "other crew"
1135  const live = sessions.map(s => s.name)
1136  const extras = (lead: string | null) => {
1137    const [ownerList, talkList] = [ownerRows(asList<OwnerEntry>(owners), ctx.now, lead, live), talkRows(asList<Talk>(talks), lead, live)]
1138
1139    return { ...(ownerList.length > 0 ? { owners: ownerList } : {}), ...(talkList.length > 0 ? { talks: talkList } : {}) }
1140  }
1141  const extrasOf = new Map<HqSession, ReturnType<typeof extras>>(sessions.map(s => [s, extras(s.name)]))
1142  const hasExtras = (s: HqSession): boolean => Object.keys(extrasOf.get(s)!).length > 0
1143
1144  const now: NowEntry[] = sessions
1145    .filter(s => tasksOf.get(s)!.length > 0 || s.state !== 'idle' || hasExtras(s) || s.lead)
1146    // a live lead with nothing else to show names its project in the task column
1147    .map(s => ({ name: s.name, status: statusOf(s), session: s, tasks: tasksOf.get(s)!.length === 0 && s.lead && !hasExtras(s) ? [sessionProject(s.where)] : tasksOf.get(s)!, ...extrasOf.get(s)! }))
1148  const otherExtras = extras(null)
1149  if (other.length > 0 || Object.keys(otherExtras).length > 0) now.push({ name: 'other crew', status: '', tasks: other, ...otherExtras })
1150
1151  const idle = sessions.filter(s => s.state === 'idle' && !s.lead && tasksOf.get(s)!.length === 0 && !hasExtras(s) && !asked.has(s.name)).map(s => ({ name: s.name, age: s.age }))
1152
1153  // unseen: finished after the user last looked, at everything or at this task (a result stays in the list for 24 hours)
1154  const seenAt = (id: string): number => Math.max(ctx.seen.all, ctx.seen.ids[id] ?? 0)
1155  const leadOf = (t: HqTask | undefined): string => (t ? (resolveLead(board, t)?.session.name ?? '') : '')
1156  const rows = [
1157    ...recentResults(board, ctx.entered, ctx.now).map((r): [number, FinishedRow] => [
1158      r.at,
1159      { id: r.id, mark: r.mark, isNew: r.at > seenAt(r.id), name: r.name, verdict: resultVerdict(r, results[r.id]), lead: leadOf(findTask(board, r.id)), detail: true },
1160    ]),
1161    // a task whose PR ended on GitHub has no result page: it asks the lead to log it
1162    ...plan.finished
1163      .filter(f => f.ended)
1164      .map((f): [number, FinishedRow] => [
1165        f.at,
1166        { id: f.task.id, mark: f.mark, isNew: true, name: `${shortName(f.task.id)} #${f.pr}`, verdict: f.text, lead: leadOf(f.task), detail: false },
1167      ]),
1168  ]
1169    .sort((a, b) => b[0] - a[0])
1170    .map(([, row]) => row)
1171  const finished = rows.slice(0, MAX_FINISHED_ROWS)
1172
1173  return {
1174    needs,
1175    morePrs,
1176    total: needs.length + morePrs,
1177    older,
1178    now,
1179    finished,
1180    newCount: rows.filter(r => r.isNew).length,
1181    earlier: rows.length - finished.length,
1182    idle,
1183    isClear: needs.length === 0 && morePrs === 0 && older.length === 0 && now.length === 0 && rows.length === 0,
1184  }
1185}
1186
1187const MIN_MSG = 12
1188const MIN_TASK_ROOM = 24
1189const MIN_VERDICT = 10
1190const COMPACT_INDENT = 4
1191const STATUS_MAX = 18
1192
1193// A column may take at most this share of a narrow pane.
1194const share = (width: number, part = 0.3): number => Math.max(4, Math.floor(width * part))
1195const widest = (texts: string[]): number => Math.max(0, ...texts.map(cols))
1196
1197// The columns of the NEEDS YOU lines: name, what, and where to go, as wide as `width` together with their two gaps.
1198// In a narrow pane the name keeps two fifths of it at most; the target is shortened to "⏎ 1.0" when the message would
1199// get under 12 columns, and when it still would, it moves to `below`, a dim line under the message.
1200export const MORE_MARK = ' ▸'
types/index.d.ts 116 lines
1export type HqTask = {
2  id: string
3  state: string
4  age: string
5  repo: string
6  note: string
7  // the open decision's id (`D3`), from the `D3 · ` that crew ls puts in front of the note
8  decision?: string
9  // from the task's meta file (see applyMeta): its lead session, the repo's full path, its branch and its worktree
10  lead?: string
11  repoPath?: string
12  branch?: string
13  worktree?: string
14  // the meta's last branch= and worktree= lines are both empty: nothing to diff, whatever an earlier line said
15  diffCleared?: true
16}
17
18// A task's meta file: the last `lead=`, `repo=`, `branch=` and `worktree=` line each.
19export type HqMeta = { lead?: string; repoPath?: string; branch?: string; worktree?: string; diffCleared?: true }
20
21// A pull request as `gh` reports it: state is OPEN, MERGED or CLOSED. `at` (ms) is when it merged or closed.
22export type HqPr = { number: number; state: string; at?: number }
23
24// A pull request whose review the user was asked for (gh search prs --review-requested=@me).
25export type HqReviewPr = { repo: string; number: number; title: string; url: string; updatedAt: number }
26
27// What the last gh search for review requests found (`prs` is null until one has worked), and when it ran (ms).
28export type HqReviews = { fetched: number; prs: HqReviewPr[] | null }
29
30// What the user has looked at: everything that finished before `all`, and single tasks by the time they were seen.
31export type HqSeen = { all: number; ids: Record<string, number> }
32
33// What the last gh query for a task found; `pr` is null when it found nothing or failed. `key` says what was
34// asked (`#36` or the branch), `fetched` when (ms).
35export type HqPrEntry = { key: string; fetched: number; pr: HqPr | null }
36
37// The user-facing text kept for a finished task: summary.md, else report.md (read only while there is no summary),
38// each with the mtime (ms) it was read at; null when the file is missing.
39export type HqResultFiles = { summary: string | null; summaryAt: number; report: string | null; reportAt: number }
40
41export type HqSession = {
42  kind: 'claude' | 'proc'
43  name: string
44  state: string
45  age: string
46  seconds: number
47  where: string
48  // tmux pane of a claude session ('' for a proc or a session outside tmux)
49  target: string
50  window: string
51  // the session's transcript file (claude sessions only, when it exists)
52  transcript?: string
53  // https://claude.ai/code/<id> of a claude session with Remote Control on
54  link?: string
55  // a live top-level lead (see markLeads): HQ never folds it into the idle line or the page's "+N idle"
56  lead?: true
57}
58
59// The Needs-you items (markdown stripped) in the last reply of a live session, and when it was written (ms). Empty
60// when the reply has none or the user has answered since.
61export type HqChat = { items: string[]; at: number }
62
63// An HqChat as read from a transcript, with the file's size and mtime (s) it was read at.
64export type HqChatEntry = HqChat & { size: number; mtime: number }
65
66export type HqParked = { pri: string; date: string; repo: string; text: string }
67
68// One line of bin/tmux-map: a live claude session with its full cwd, and its tmux pane if it has one.
69export type HqLive = { name: string; target: string; window: string; cwd: string; state: string; seconds: number; transcript: string; link: string }
70
71export type HqBoard = {
72  needsYou: HqTask[]
73  running: HqTask[]
74  // state waiting: someone else has to act (crew ls: WAITING ON OTHERS)
75  waiting: HqTask[]
76  done: HqTask[]
77  elsewhere: HqSession[]
78  parked: HqParked[]
79  // the `+N more` line crew ls prints when it cuts PARKED
80  parkedMore: number
81}
82
83declare module 'claude-code' {
84  interface PluginState {
85    hq: {
86      board: HqBoard | null
87      error: string | null
88      view: 'focus' | 'projects'
89      holds: Record<string, string>
90      prs: Record<string, HqPrEntry>
91      prToasted: string[]
92      reviews: HqReviews
93      seen: HqSeen
94      // per transcript path: the Needs-you items of the session's last reply
95      chats: Record<string, HqChatEntry>
96      // per session key: the time of the chat reply the user dismissed
97      dismissed: Record<string, number>
98      entered: Record<string, number>
99      // when each task last wrote a line to its status file (ms)
100      last: Record<string, number>
101      results: Record<string, HqResultFiles>
102      // the task whose result the pane shows in full, null for the board
103      detail: string | null
104      // the whole text of each open question, and the last note of each task, by task id
105      questions: Record<string, string>
106      notes: Record<string, string>
107      // the key of the NEEDS YOU line whose page the pane shows, null for the board
108      need: string | null
109      // the first line of that page's question that is shown
110      needScroll: number
111      // the New lead form while the pane shows it (null otherwise), with the folders it offers
112      newLead: { repo: string; name: string; text: string; model: string; choices: string[] } | null
113    }
114  }
115}
116